From 6b995a8d3e58764fe4a251355a75223c521f8835 Mon Sep 17 00:00:00 2001 From: henrikjacobsenfys Date: Wed, 13 May 2026 09:47:49 +0200 Subject: [PATCH 01/18] Add DeltaLorentz model --- docs/docs/tutorials/DeltaLorentz.ipynb | 121 ++++ .../diffusion_model/delta_lorentz.py | 577 ++++++++++++++++++ 2 files changed, 698 insertions(+) create mode 100644 docs/docs/tutorials/DeltaLorentz.ipynb create mode 100644 src/easydynamics/sample_model/diffusion_model/delta_lorentz.py diff --git a/docs/docs/tutorials/DeltaLorentz.ipynb b/docs/docs/tutorials/DeltaLorentz.ipynb new file mode 100644 index 00000000..daa9518d --- /dev/null +++ b/docs/docs/tutorials/DeltaLorentz.ipynb @@ -0,0 +1,121 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "id": "0", + "metadata": {}, + "source": [ + "# Diffusion Model\n", + "We support several standard models of diffusion. Here we show an example of Browniand Translational Diffusion, where the scattering is a Lorentzian with width ($\\Gamma$) given by $\\Gamma = D Q^2$, where $D$ is the diffusion coefficient (in m$^2$/s) and $Q$ is the momentum transfer." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "1", + "metadata": {}, + "outputs": [], + "source": [ + "import matplotlib.pyplot as plt\n", + "import numpy as np\n", + "\n", + "from easydynamics.sample_model import BrownianTranslationalDiffusion\n", + "\n", + "from easydynamics.sample_model.diffusion_model.delta_lorentz import DeltaLorentz\n", + "\n", + "%matplotlib widget" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "6d6e1984", + "metadata": {}, + "outputs": [], + "source": [ + "\n", + "\n", + "Q = np.linspace(0.5, 2, 7)\n", + "energy = np.linspace(-2, 2, 501)\n", + "scale = 1.0\n", + "mean_u_squared = 0.5\n", + "A_0=0.2\n", + "lorentzian_width= 0.2\n", + "\n", + "diffusion_model = DeltaLorentz(\n", + " scale=scale,\n", + " mean_u_squared=mean_u_squared,\n", + " A_0=A_0,\n", + " lorentzian_width=lorentzian_width\n", + ")\n", + "\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "75008797", + "metadata": {}, + "outputs": [], + "source": [ + "\n", + "component_collections = diffusion_model.create_component_collections(Q)\n", + "\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "389aa3cd", + "metadata": {}, + "outputs": [], + "source": [ + "\n", + "cmap = plt.cm.jet\n", + "nQ = len(component_collections)\n", + "plt.figure()\n", + "for Q_index in range(len(component_collections)):\n", + " color = cmap(Q_index / (nQ - 1))\n", + " y = component_collections[Q_index].evaluate(energy)\n", + " plt.plot(energy, y, label=f'Q={Q[Q_index]} Å^-1', color=color)\n", + "\n", + "plt.legend()\n", + "plt.show()\n", + "plt.xlabel('Energy (meV)')\n", + "plt.ylabel('Intensity (arb. units)')\n", + "plt.title('Delta-Lorentz Model')" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "fdcafe76", + "metadata": {}, + "outputs": [], + "source": [ + "component_collections[0].components" + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": "Python 3", + "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.14.4" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} diff --git a/src/easydynamics/sample_model/diffusion_model/delta_lorentz.py b/src/easydynamics/sample_model/diffusion_model/delta_lorentz.py new file mode 100644 index 00000000..b5a7d17b --- /dev/null +++ b/src/easydynamics/sample_model/diffusion_model/delta_lorentz.py @@ -0,0 +1,577 @@ +# SPDX-FileCopyrightText: 2026 EasyScience contributors +# SPDX-License-Identifier: BSD-3-Clause + + +import numpy as np +import scipp as sc +from easyscience.variable import DescriptorNumber +from easyscience.variable import Parameter + +from easydynamics.sample_model.component_collection import ComponentCollection +from easydynamics.sample_model.components import DeltaFunction +from easydynamics.sample_model.components import Lorentzian +from easydynamics.sample_model.diffusion_model.diffusion_model_base import ( + DiffusionModelBase, +) +from easydynamics.utils.utils import Numeric +from easydynamics.utils.utils import Q_type +from easydynamics.utils.utils import _validate_and_convert_Q +from easydynamics.utils.utils import angstrom +from easydynamics.utils.utils import hbar + + +class DeltaLorentz(DiffusionModelBase): + r""" + Model of Delta function and Lorentzian with intensities given by the Debye-Waller factor. + $$ + I = K \exp \left( \frac{-\langle u^2 \rangle Q^2}{3} \right)[A_0 \delta(E) + (A_1) L(E, \Gamma)] + $$, + + where $K$ is the scale factor, $\langle u^2 \rangle$ is the mean square displacement, $Q$ is the scattering vector, $A_0$ and $A_1$ are the amplitudes of the delta function and Lorentzian, respectively, and $L(E, \Gamma)$ is the Lorentzian function with width $\Gamma$. + $A_0+A_1=1$ and $A_0$ is the EISF, while $A_1$ is the QISF. + + Creates ComponentCollections with Lorentzian components for given + Q-values. + + Example + -------- + >>> Q=np.linspace(0.5,2,7) >>>energy=np.linspace(-2, 2, 501) >>>scale=1.0 + >>> diffusion_coefficient = 2.4e-9 # m^2/s + >>> diffusion_model=DeltaLorentz(display_name="DiffusionModel", + >>> scale=scale, diffusion_coefficient= diffusion_coefficient,) + >>> component_collections=diffusion_model.create_component_collections(Q) + + See also the + tutorials. + """ + + def __init__( + self, + display_name: str | None = "DeltaLorentz", + unique_name: str | None = None, + unit: str | sc.Unit = "meV", + scale: Numeric = 1.0, + mean_u_squared: Numeric = 0.0, + A_0: Numeric = 1.0, + lorentzian_width: Numeric = 1.0, + ) -> None: + """ + Initialize a new DeltaLorentz model. + + Parameters + ---------- + display_name : str | None, default='DeltaLorentz' + Display name of the diffusion model. + unique_name : str | None, default=None + Unique name of the diffusion model. If None, a unique name will be generated. By + default, None. + unit : str | sc.Unit, default='meV' + Unit of the diffusion model. Must be convertible to meV. + scale : Numeric, default=1.0 + Scale factor for the diffusion model. Must be a non-negative number. + mean_u_squared : Numeric, default=0.0 + Mean square displacement in angstrom^2. + A_0 : Numeric, default=1.0 + Amplitude of the delta function. + lorentzian_width : Numeric, default=1.0 + Width of the Lorentzian function. + + Raises + ------ + TypeError + If scale or diffusion_coefficient is not a number. + """ + if not isinstance(scale, Numeric): + raise TypeError("scale must be a number.") + + if not isinstance(mean_u_squared, Numeric): + raise TypeError("mean_u_squared must be a number.") + + if not isinstance(A_0, Numeric): + raise TypeError("A_0 must be a number.") + + if not isinstance(lorentzian_width, Numeric): + raise TypeError("lorentzian_width must be a number.") + + super().__init__( + display_name=display_name, + unique_name=unique_name, + unit=unit, + scale=scale, + ) + self._hbar = hbar + self._angstrom = angstrom + self._mean_u_squared = mean_u_squared + + A_0 = Parameter( + name="A_0", + value=float(A_0), + fixed=False, + min=0.0, + max=1.0, + ) + A_1 = Parameter.from_dependency( + name="A_1", + dependency_expression="1 - A_0", + dependency_map={"A_0": A_0}, + ) + + mean_u_squared = Parameter( + name="mean_u_squared", + value=float(mean_u_squared), + fixed=False, + min=0.0, + unit="angstrom**2", + ) + self._mean_u_squared = mean_u_squared + + lorentzian_width = Parameter( + name="lorentzian_width", + value=float(lorentzian_width), + fixed=False, + min=0.0, + unit=unit, + ) + + self._A_0 = A_0 + self._A_1 = A_1 + self._lorentzian_width = lorentzian_width + + # ------------------------------------------------------------------ + # Properties + # ------------------------------------------------------------------ + + @property + def mean_u_squared(self) -> Parameter: + """ + Get the mean square displacement parameter. + + Returns + ------- + Parameter + Mean square displacement in angstrom^2. + """ + return self._mean_u_squared + + @mean_u_squared.setter + def mean_u_squared(self, mean_u_squared: Numeric) -> None: + """ + Set the mean square displacement parameter. + + Parameters + ---------- + mean_u_squared : Numeric + The new value for the mean square displacement in angstrom^2. + + Raises + ------ + TypeError + If mean_u_squared is not a number. + ValueError + If mean_u_squared is negative. + """ + if not isinstance(mean_u_squared, Numeric): + raise TypeError("mean_u_squared must be a number.") + + if float(mean_u_squared) < 0: + raise ValueError("mean_u_squared must be non-negative.") + self._mean_u_squared.value = float(mean_u_squared) + + @property + def A_0(self) -> Parameter: + """ + Get the amplitude of the delta function. + + Returns + ------- + Parameter + Amplitude of the delta function. + """ + return self._A_0 + + @A_0.setter + def A_0(self, A_0: Numeric) -> None: + """ + Set the amplitude of the delta function. + + Parameters + ---------- + A_0 : Numeric + The new value for the amplitude of the delta function. Must be between 0 and 1. + + Raises + ------ + TypeError + If A_0 is not a number. + ValueError + If A_0 is not between 0 and 1. + """ + if not isinstance(A_0, Numeric): + raise TypeError("A_0 must be a number.") + + if not (0 <= float(A_0) <= 1): + raise ValueError("A_0 must be between 0 and 1.") + self._A_0.value = float(A_0) + + @property + def A_1(self) -> Parameter: + """ + Get the amplitude of the Lorentzian function. + + Returns + ------- + Parameter + Amplitude of the Lorentzian function. + """ + return self._A_1 + + @A_1.setter + def A_1(self, _A_1: Numeric) -> None: + """ + A_1 cannot be set directly, as it is a dependent parameter defined as 1 - A_0. To change A_1, set A_0 to the desired value and A_1 will update accordingly. + + + Parameters + ---------- + _A_1 : Numeric + The new value for the amplitude of the Lorentzian function. Is ignored + + Raises + ------ + AttributeError + If an attempt is made to set A_1 directly. + """ + raise AttributeError( + "A_1 is a dependent parameter and cannot be set directly. Set A_0 to change A_1 accordingly." + ) + + @property + def lorentzian_width(self) -> Parameter: + """ + Get the width of the Lorentzian function. + + Returns + ------- + Parameter + Width of the Lorentzian function. + """ + return self._lorentzian_width + + @lorentzian_width.setter + def lorentzian_width(self, lorentzian_width: Numeric) -> None: + """ + Set the width of the Lorentzian function. + + Parameters + ---------- + lorentzian_width : Numeric + The new value for the width of the Lorentzian function. Must be a non-negative number. + + Raises + ------ + TypeError + If lorentzian_width is not a number. + ValueError + If lorentzian_width is negative. + """ + if not isinstance(lorentzian_width, Numeric): + raise TypeError("lorentzian_width must be a number.") + + if float(lorentzian_width) < 0: + raise ValueError("lorentzian_width must be non-negative.") + self._lorentzian_width.value = float(lorentzian_width) + + # ------------------------------------------------------------------ + # Other methods + # ------------------------------------------------------------------ + + def calculate_width(self, Q: Q_type) -> np.ndarray: + """ + Calculate the half-width at half-maximum (HWHM) for the diffusion model. + + Parameters + ---------- + Q : Q_type + Scattering vector in 1/angstrom. + + Returns + ------- + np.ndarray + HWHM values in the unit of the model (e.g., meV). + """ + + Q = _validate_and_convert_Q(Q) + + return self.lorentzian_width.value * np.ones_like(Q) + + def calculate_EISF(self, Q: Q_type) -> np.ndarray: + """ + Calculate the Elastic Incoherent Structure Factor (EISF) for the Brownian translational + diffusion model. + + Parameters + ---------- + Q : Q_type + Scattering vector in 1/angstrom. + + Returns + ------- + np.ndarray + EISF values (dimensionless). + """ + + # Need to handle units better + Q = _validate_and_convert_Q(Q) + return ( + self.scale.value + * np.exp(-self.mean_u_squared.value * Q**2 / 3) + * self.A_0.value + ) + + def calculate_QISF(self, Q: Q_type) -> np.ndarray: + """ + Calculate the Quasi-Elastic Incoherent Structure Factor (QISF). + + Parameters + ---------- + Q : Q_type + Scattering vector in 1/angstrom. + + Returns + ------- + np.ndarray + QISF values (dimensionless). + """ + + Q = _validate_and_convert_Q(Q) + return ( + self.scale.value + * np.exp(-self.mean_u_squared.value * Q**2 / 3) + * self.A_1.value + ) + + def create_component_collections( + self, + Q: Q_type, + component_display_name: str = "DeltaLorentz component", + ) -> list[ComponentCollection]: + r""" + Create ComponentCollection components for the DeltaLorentz model at + given Q values. + + Parameters + ---------- + Q : Q_type + Scattering vector values. + component_display_name : str, default='DeltaLorentz component' + Name of the Lorentzian component. + + Raises + ------ + TypeError + If component_display_name is not a string. + + Returns + ------- + list[ComponentCollection] + List of ComponentCollections with Lorentzian and delta functioncomponents for each Q value. + """ + Q = _validate_and_convert_Q(Q) + + if not isinstance(component_display_name, str): + raise TypeError("component_name must be a string.") + + component_collection_list = [None] * len(Q) + # In more complex models, this is used to scale the area of the + # Lorentzians and the delta function. + QISF = self.calculate_QISF(Q) + + # Create a Lorentzian component for each Q-value, with + # width D*Q^2 and area equal to scale. + # No delta function, as the EISF is 0. + for i, Q_value in enumerate(Q): + component_collection_list[i] = ComponentCollection( + display_name=f"{self.display_name}_Q{Q_value:.2f}", unit=self.unit + ) + + lorentzian_component = Lorentzian( + display_name=component_display_name, + unit=self.unit, + ) + + # Make the width dependent on Q + + lorentzian_component.width.make_dependent_on( + dependency_expression=self._write_width_dependency_expression(Q_value), + dependency_map=self._write_width_dependency_map_expression(), + desired_unit=self.unit, + ) + + # Make the area dependent on Q + lorentzian_component.area.make_dependent_on( + dependency_expression=self._write_lorz_area_dependency_expression( + Q_value + ), + dependency_map=self._write_lorz_area_dependency_map_expression(), + ) + + component_collection_list[i].append_component(lorentzian_component) + + delta_component = DeltaFunction( + display_name="Delta function", unit=self.unit + ) + delta_component.area.make_dependent_on( + dependency_expression=self._write_delta_area_dependency_expression( + Q_value + ), + dependency_map=self._write_delta_area_dependency_map_expression(), + ) + + component_collection_list[i].append_component(delta_component) + + return component_collection_list + + # ------------------------------------------------------------------ + # Private methods + # ------------------------------------------------------------------ + + def _write_width_dependency_expression(self, Q: float) -> str: + """ + Write the dependency expression for the width as a function of Q to make dependent + Parameters. + + Parameters + ---------- + Q : float + Scattering vector in 1/angstrom. + + Raises + ------ + TypeError + If Q is not a float. + + Returns + ------- + str + Dependency expression for the width. + """ + if not isinstance(Q, (float)): + raise TypeError("Q must be a float.") + + # Q is given as a float, so we need to add the units + return "lorentzian_width" + + def _write_width_dependency_map_expression(self) -> dict[str, DescriptorNumber]: + """ + Write the dependency map expression to make dependent Parameters. + + Returns + ------- + dict[str, DescriptorNumber] + Dependency map for the width. + """ + return { + "lorentzian_width": self.lorentzian_width, + } + + def _write_lorz_area_dependency_expression(self, Q) -> str: + """ + Write the dependency expression for the area to make dependent Parameters. + + Parameters + ---------- + QISF : float + Quasielastic Incoherent Scattering Function. + + Raises + ------ + TypeError + If QISF is not a float. + + Returns + ------- + str + Dependency expression for the area. + """ + if not isinstance(Q, (float)): + raise TypeError("Q must be a float.") + + return f"scale * exp(-mean_u_squared.value * {Q}**2 / 3) * A_1" + + def _write_lorz_area_dependency_map_expression(self) -> dict[str, DescriptorNumber]: + """ + Write the dependency map expression to make dependent Parameters. + + Returns + ------- + dict[str, DescriptorNumber] + Dependency map for the area. + """ + return { + "scale": self.scale, + "mean_u_squared": self.mean_u_squared, + "A_1": self.A_1, + } + + def _write_delta_area_dependency_expression(self, Q) -> str: + """ + Write the dependency expression for the area to make dependent Parameters. + + Parameters + ---------- + QISF : float + Quasielastic Incoherent Scattering Function. + + Raises + ------ + TypeError + If QISF is not a float. + + Returns + ------- + str + Dependency expression for the area. + """ + if not isinstance(Q, (float)): + raise TypeError("Q must be a float.") + + return f"scale * exp(-mean_u_squared.value * {Q}**2 / 3) * A_0" + + def _write_delta_area_dependency_map_expression( + self, + ) -> dict[str, DescriptorNumber]: + """ + Write the dependency map expression to make dependent Parameters. + + Returns + ------- + dict[str, DescriptorNumber] + Dependency map for the area. + """ + return { + "scale": self.scale, + "mean_u_squared": self.mean_u_squared, + "A_0": self.A_0, + } + + # ------------------------------------------------------------------ + # dunder methods + # ------------------------------------------------------------------ + + def __repr__(self) -> str: + """ + String representation of the DeltaLorentz model. + + Returns + ------- + str + String representation of the DeltaLorentz model. + """ + return ( + f"DeltaLorentz(display_name={self.display_name}," + f"unit={self.unit}, \n" + f" mean_u_squared={self.mean_u_squared}, \n" + f" A_0={self.A_0}, A_1={self.A_1}, \n" + f" lorentzian_width={self.lorentzian_width}, \n" + f" scale={self.scale})" + ) From 09ec99572c9a9caafa3aac081dd23da52936a79c Mon Sep 17 00:00:00 2001 From: henrikjacobsenfys Date: Wed, 13 May 2026 13:05:16 +0200 Subject: [PATCH 02/18] update to allow multiple A0 and A1 --- src/easydynamics/analysis/fit_binding.py | 62 +++++---- .../diffusion_model/delta_lorentz.py | 122 +++++++++++++++--- 2 files changed, 142 insertions(+), 42 deletions(-) diff --git a/src/easydynamics/analysis/fit_binding.py b/src/easydynamics/analysis/fit_binding.py index ad11ff60..1f38702b 100644 --- a/src/easydynamics/analysis/fit_binding.py +++ b/src/easydynamics/analysis/fit_binding.py @@ -8,7 +8,9 @@ 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 +from easydynamics.sample_model.diffusion_model.diffusion_model_base import ( + DiffusionModelBase, +) if TYPE_CHECKING: from collections.abc import Callable @@ -86,18 +88,20 @@ def __init__( super().__init__(display_name=display_name, unique_name=unique_name) if not isinstance(parameter_name, str): - raise TypeError('parameter_name must be a string') + raise TypeError("parameter_name must be a string") - if not isinstance(model, (ModelComponent, ComponentCollection, DiffusionModelBase)): + if not isinstance( + model, (ModelComponent, ComponentCollection, DiffusionModelBase) + ): raise TypeError( - 'model must be a ModelComponent, ComponentCollection, or DiffusionModelBase' + "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') + 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') + raise TypeError("All modes in the list must be strings") self._parameter_name = parameter_name self._model = model @@ -135,7 +139,7 @@ def parameter_name(self, value: str) -> None: If the value is not a string. """ if not isinstance(value, str): - raise TypeError('parameter_name must be a string') + raise TypeError("parameter_name must be a string") self._parameter_name = value @property @@ -152,7 +156,9 @@ def model(self) -> ModelComponent | ComponentCollection | DiffusionModelBase: return self._model @model.setter - def model(self, value: ModelComponent | ComponentCollection | DiffusionModelBase) -> None: + def model( + self, value: ModelComponent | ComponentCollection | DiffusionModelBase + ) -> None: """ Set the model to fit. @@ -166,9 +172,11 @@ def model(self, value: ModelComponent | ComponentCollection | DiffusionModelBase TypeError If the value is not a ModelComponent, ComponentCollection, or DiffusionModelBase. """ - if not isinstance(value, (ModelComponent, ComponentCollection, DiffusionModelBase)): + if not isinstance( + value, (ModelComponent, ComponentCollection, DiffusionModelBase) + ): raise TypeError( - 'model must be a ModelComponent, ComponentCollection, or DiffusionModelBase.' + "model must be a ModelComponent, ComponentCollection, or DiffusionModelBase." ) self._model = value @@ -201,12 +209,12 @@ def modes(self, value: str | list[str] | None) -> None: 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') + 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') + raise TypeError("All modes in the list must be strings") self._modes = value # ------------------------------------------------------------------ @@ -241,7 +249,7 @@ def get_model_names(self) -> list[str]: modes = self._get_modes() if isinstance(self.model, DiffusionModelBase): - return [f'{self.model.display_name} {mode}' for mode in modes] + return [f"{self.model.display_name} {mode}" for mode in modes] return [self.model.display_name] @@ -257,7 +265,12 @@ def get_parameter_names(self) -> list[str]: modes = self._get_modes() if isinstance(self.model, DiffusionModelBase): - return [f'{self.parameter_name} {mode}' for mode in modes] + + # HACK + if "delta" in modes: + return [f"{self.parameter_name} area" for mode in modes] + + return [f"{self.parameter_name} {mode}" for mode in modes] return [self.parameter_name] @@ -286,13 +299,16 @@ def _build_diffusion_callable(self, mode: str) -> Callable: """ model = self.model - if mode == 'area': + if mode == "area": return lambda x, **_: model.calculate_QISF(x) * model.scale.value - if mode == 'width': + if mode == "width": return lambda x, **_: model.calculate_width(x) - raise ValueError(f'Unknown diffusion mode: {mode}') + if mode == "delta": + return lambda x, **_: model.calculate_EISF(x) * model.scale.value + + raise ValueError(f"Unknown diffusion mode: {mode}") def _get_modes(self) -> list[str]: """ @@ -303,7 +319,7 @@ def _get_modes(self) -> list[str]: list[str] The modes to fit for diffusion models. """ - return ['area', 'width'] if self.modes is None else self.modes + return ["area", "width"] if self.modes is None else self.modes # ------------------------------------------------------------------ # dunder methods @@ -319,9 +335,9 @@ def __repr__(self) -> 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})' + 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/sample_model/diffusion_model/delta_lorentz.py b/src/easydynamics/sample_model/diffusion_model/delta_lorentz.py index b5a7d17b..3874089d 100644 --- a/src/easydynamics/sample_model/diffusion_model/delta_lorentz.py +++ b/src/easydynamics/sample_model/diffusion_model/delta_lorentz.py @@ -54,6 +54,7 @@ def __init__( mean_u_squared: Numeric = 0.0, A_0: Numeric = 1.0, lorentzian_width: Numeric = 1.0, + allow_Q_dependence: bool = False, ) -> None: """ Initialize a new DeltaLorentz model. @@ -75,11 +76,13 @@ def __init__( Amplitude of the delta function. lorentzian_width : Numeric, default=1.0 Width of the Lorentzian function. + allow_Q_dependence : bool, default=False + Whether to allow Q-dependence for the model. Raises ------ TypeError - If scale or diffusion_coefficient is not a number. + If scale, mean_u_squared, A_0, or lorentzian_width is not a number. """ if not isinstance(scale, Numeric): raise TypeError("scale must be a number.") @@ -115,6 +118,10 @@ def __init__( dependency_expression="1 - A_0", dependency_map={"A_0": A_0}, ) + self._allow_Q_dependence = allow_Q_dependence + + self._A_0_list = [] + self._A_1_list = [] mean_u_squared = Parameter( name="mean_u_squared", @@ -322,11 +329,11 @@ def calculate_EISF(self, Q: Q_type) -> np.ndarray: # Need to handle units better Q = _validate_and_convert_Q(Q) - return ( - self.scale.value - * np.exp(-self.mean_u_squared.value * Q**2 / 3) - * self.A_0.value - ) + if self._allow_Q_dependence is True: + A_0_values = [A_0.value for A_0 in self._A_0_list] + else: + A_0_values = [self.A_0.value] * len(Q) + return np.exp(-self.mean_u_squared.value * Q**2 / 3) * np.array(A_0_values) def calculate_QISF(self, Q: Q_type) -> np.ndarray: """ @@ -344,11 +351,11 @@ def calculate_QISF(self, Q: Q_type) -> np.ndarray: """ Q = _validate_and_convert_Q(Q) - return ( - self.scale.value - * np.exp(-self.mean_u_squared.value * Q**2 / 3) - * self.A_1.value - ) + if self._allow_Q_dependence is True: + A_1_values = [A_1.value for A_1 in self._A_1_list] + else: + A_1_values = [self.A_1.value] * len(Q) + return np.exp(-self.mean_u_squared.value * Q**2 / 3) * np.array(A_1_values) def create_component_collections( self, @@ -381,10 +388,14 @@ def create_component_collections( if not isinstance(component_display_name, str): raise TypeError("component_name must be a string.") + if self._allow_Q_dependence is True: + A_0_list, A_1_list = self._create_A0_A1_parameters(self.A_0, Q) + self._A_0_list = A_0_list + self._A_1_list = A_1_list + component_collection_list = [None] * len(Q) # In more complex models, this is used to scale the area of the # Lorentzians and the delta function. - QISF = self.calculate_QISF(Q) # Create a Lorentzian component for each Q-value, with # width D*Q^2 and area equal to scale. @@ -400,7 +411,6 @@ def create_component_collections( ) # Make the width dependent on Q - lorentzian_component.width.make_dependent_on( dependency_expression=self._write_width_dependency_expression(Q_value), dependency_map=self._write_width_dependency_map_expression(), @@ -408,11 +418,15 @@ def create_component_collections( ) # Make the area dependent on Q + if self._allow_Q_dependence is True: + dependency_map = self._write_lorz_area_dependency_map_expression(i) + else: + dependency_map = self._write_lorz_area_dependency_map_expression(None) lorentzian_component.area.make_dependent_on( dependency_expression=self._write_lorz_area_dependency_expression( Q_value ), - dependency_map=self._write_lorz_area_dependency_map_expression(), + dependency_map=dependency_map, ) component_collection_list[i].append_component(lorentzian_component) @@ -420,21 +434,76 @@ def create_component_collections( delta_component = DeltaFunction( display_name="Delta function", unit=self.unit ) + if self._allow_Q_dependence is True: + dependency_map = self._write_delta_area_dependency_map_expression(i) + else: + dependency_map = self._write_delta_area_dependency_map_expression(None) delta_component.area.make_dependent_on( dependency_expression=self._write_delta_area_dependency_expression( Q_value ), - dependency_map=self._write_delta_area_dependency_map_expression(), + dependency_map=dependency_map, ) component_collection_list[i].append_component(delta_component) return component_collection_list + def get_all_variables(self) -> list[DescriptorNumber]: + + if self._allow_Q_dependence is False: + return super().get_all_variables() + + variables = [self.scale, self.mean_u_squared, self.lorentzian_width] + variables.extend(self._A_0_list) + variables.extend(self._A_1_list) + return variables + # ------------------------------------------------------------------ # Private methods # ------------------------------------------------------------------ + def _create_A0_A1_parameters( + self, A_0: Parameter, Q: Q_type + ) -> tuple[list[Parameter], list[Parameter]]: + """ + Create lists of A_0 and A_1 parameters for each Q value. + Parameters + ---------- + A_0 : Parameter + The A_0 parameter to use as the base for creating the A_0 parameters for + each Q value. + Returns + ------- + tuple[list[Parameter], list[Parameter]] + A tuple containing two lists: the first list contains the A_0 parameters for each Q + value, and the second list contains the A_1 parameters for each Q value. + """ + A_0_list = [] + A_1_list = [] + for i, Q_value in enumerate(Q): + A_0_list.append( + Parameter( + name=f"A_0_Q{Q_value:.2f}", + value=float(A_0.value), + fixed=False, + min=0.0, + max=1.0, + ) + ) + A_1_list.append( + Parameter.from_dependency( + name=f"A_1_Q{Q_value:.2f}", + dependency_expression="1 - A_0", + dependency_map={"A_0": A_0_list[i]}, + ) + ) + + self._A_0_list = A_0_list + self._A_1_list = A_1_list + + return A_0_list, A_1_list + def _write_width_dependency_expression(self, Q: float) -> str: """ Write the dependency expression for the width as a function of Q to make dependent @@ -458,7 +527,6 @@ def _write_width_dependency_expression(self, Q: float) -> str: if not isinstance(Q, (float)): raise TypeError("Q must be a float.") - # Q is given as a float, so we need to add the units return "lorentzian_width" def _write_width_dependency_map_expression(self) -> dict[str, DescriptorNumber]: @@ -498,7 +566,9 @@ def _write_lorz_area_dependency_expression(self, Q) -> str: return f"scale * exp(-mean_u_squared.value * {Q}**2 / 3) * A_1" - def _write_lorz_area_dependency_map_expression(self) -> dict[str, DescriptorNumber]: + def _write_lorz_area_dependency_map_expression( + self, Q_index + ) -> dict[str, DescriptorNumber]: """ Write the dependency map expression to make dependent Parameters. @@ -507,10 +577,17 @@ def _write_lorz_area_dependency_map_expression(self) -> dict[str, DescriptorNumb dict[str, DescriptorNumber] Dependency map for the area. """ + if Q_index is None: + return { + "scale": self.scale, + "mean_u_squared": self.mean_u_squared, + "A_1": self.A_1, + } + return { "scale": self.scale, "mean_u_squared": self.mean_u_squared, - "A_1": self.A_1, + "A_1": self._A_1_list[Q_index], } def _write_delta_area_dependency_expression(self, Q) -> str: @@ -539,6 +616,7 @@ def _write_delta_area_dependency_expression(self, Q) -> str: def _write_delta_area_dependency_map_expression( self, + Q_index, ) -> dict[str, DescriptorNumber]: """ Write the dependency map expression to make dependent Parameters. @@ -548,10 +626,16 @@ def _write_delta_area_dependency_map_expression( dict[str, DescriptorNumber] Dependency map for the area. """ + if Q_index is None: + return { + "scale": self.scale, + "mean_u_squared": self.mean_u_squared, + "A_0": self.A_0, + } return { "scale": self.scale, "mean_u_squared": self.mean_u_squared, - "A_0": self.A_0, + "A_0": self._A_0_list[Q_index], } # ------------------------------------------------------------------ From fb3341a9367f9df62ad2767a241986ead055704f Mon Sep 17 00:00:00 2001 From: henrikjacobsenfys Date: Fri, 15 May 2026 06:01:11 +0200 Subject: [PATCH 03/18] formatting --- docs/docs/tutorials/DeltaLorentz.ipynb | 21 +--- src/easydynamics/analysis/fit_binding.py | 61 +++++------ .../diffusion_model/delta_lorentz.py | 102 ++++++++++-------- 3 files changed, 86 insertions(+), 98 deletions(-) diff --git a/docs/docs/tutorials/DeltaLorentz.ipynb b/docs/docs/tutorials/DeltaLorentz.ipynb index daa9518d..25b1282a 100644 --- a/docs/docs/tutorials/DeltaLorentz.ipynb +++ b/docs/docs/tutorials/DeltaLorentz.ipynb @@ -19,8 +19,6 @@ "import matplotlib.pyplot as plt\n", "import numpy as np\n", "\n", - "from easydynamics.sample_model import BrownianTranslationalDiffusion\n", - "\n", "from easydynamics.sample_model.diffusion_model.delta_lorentz import DeltaLorentz\n", "\n", "%matplotlib widget" @@ -33,22 +31,16 @@ "metadata": {}, "outputs": [], "source": [ - "\n", - "\n", "Q = np.linspace(0.5, 2, 7)\n", "energy = np.linspace(-2, 2, 501)\n", "scale = 1.0\n", "mean_u_squared = 0.5\n", - "A_0=0.2\n", - "lorentzian_width= 0.2\n", + "A_0 = 0.2\n", + "lorentzian_width = 0.2\n", "\n", "diffusion_model = DeltaLorentz(\n", - " scale=scale,\n", - " mean_u_squared=mean_u_squared,\n", - " A_0=A_0,\n", - " lorentzian_width=lorentzian_width\n", - ")\n", - "\n" + " scale=scale, mean_u_squared=mean_u_squared, A_0=A_0, lorentzian_width=lorentzian_width\n", + ")" ] }, { @@ -58,9 +50,7 @@ "metadata": {}, "outputs": [], "source": [ - "\n", - "component_collections = diffusion_model.create_component_collections(Q)\n", - "\n" + "component_collections = diffusion_model.create_component_collections(Q)" ] }, { @@ -70,7 +60,6 @@ "metadata": {}, "outputs": [], "source": [ - "\n", "cmap = plt.cm.jet\n", "nQ = len(component_collections)\n", "plt.figure()\n", diff --git a/src/easydynamics/analysis/fit_binding.py b/src/easydynamics/analysis/fit_binding.py index 1f38702b..5ad20fee 100644 --- a/src/easydynamics/analysis/fit_binding.py +++ b/src/easydynamics/analysis/fit_binding.py @@ -8,9 +8,7 @@ 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, -) +from easydynamics.sample_model.diffusion_model.diffusion_model_base import DiffusionModelBase if TYPE_CHECKING: from collections.abc import Callable @@ -88,20 +86,18 @@ def __init__( super().__init__(display_name=display_name, unique_name=unique_name) if not isinstance(parameter_name, str): - raise TypeError("parameter_name must be a string") + raise TypeError('parameter_name must be a string') - if not isinstance( - model, (ModelComponent, ComponentCollection, DiffusionModelBase) - ): + if not isinstance(model, (ModelComponent, ComponentCollection, DiffusionModelBase)): raise TypeError( - "model must be a ModelComponent, ComponentCollection, or DiffusionModelBase" + '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") + 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") + raise TypeError('All modes in the list must be strings') self._parameter_name = parameter_name self._model = model @@ -139,7 +135,7 @@ def parameter_name(self, value: str) -> None: If the value is not a string. """ if not isinstance(value, str): - raise TypeError("parameter_name must be a string") + raise TypeError('parameter_name must be a string') self._parameter_name = value @property @@ -156,9 +152,7 @@ def model(self) -> ModelComponent | ComponentCollection | DiffusionModelBase: return self._model @model.setter - def model( - self, value: ModelComponent | ComponentCollection | DiffusionModelBase - ) -> None: + def model(self, value: ModelComponent | ComponentCollection | DiffusionModelBase) -> None: """ Set the model to fit. @@ -172,11 +166,9 @@ def model( TypeError If the value is not a ModelComponent, ComponentCollection, or DiffusionModelBase. """ - if not isinstance( - value, (ModelComponent, ComponentCollection, DiffusionModelBase) - ): + if not isinstance(value, (ModelComponent, ComponentCollection, DiffusionModelBase)): raise TypeError( - "model must be a ModelComponent, ComponentCollection, or DiffusionModelBase." + 'model must be a ModelComponent, ComponentCollection, or DiffusionModelBase.' ) self._model = value @@ -209,12 +201,12 @@ def modes(self, value: str | list[str] | None) -> None: 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") + 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") + raise TypeError('All modes in the list must be strings') self._modes = value # ------------------------------------------------------------------ @@ -249,7 +241,7 @@ def get_model_names(self) -> list[str]: modes = self._get_modes() if isinstance(self.model, DiffusionModelBase): - return [f"{self.model.display_name} {mode}" for mode in modes] + return [f'{self.model.display_name} {mode}' for mode in modes] return [self.model.display_name] @@ -265,12 +257,11 @@ def get_parameter_names(self) -> list[str]: modes = self._get_modes() if isinstance(self.model, DiffusionModelBase): - # HACK - if "delta" in modes: - return [f"{self.parameter_name} area" for mode in modes] + if 'delta' in modes: + return [f'{self.parameter_name} area' for mode in modes] - return [f"{self.parameter_name} {mode}" for mode in modes] + return [f'{self.parameter_name} {mode}' for mode in modes] return [self.parameter_name] @@ -299,16 +290,16 @@ def _build_diffusion_callable(self, mode: str) -> Callable: """ model = self.model - if mode == "area": + if mode == 'area': return lambda x, **_: model.calculate_QISF(x) * model.scale.value - if mode == "width": + if mode == 'width': return lambda x, **_: model.calculate_width(x) - if mode == "delta": + if mode == 'delta': return lambda x, **_: model.calculate_EISF(x) * model.scale.value - raise ValueError(f"Unknown diffusion mode: {mode}") + raise ValueError(f'Unknown diffusion mode: {mode}') def _get_modes(self) -> list[str]: """ @@ -319,7 +310,7 @@ def _get_modes(self) -> list[str]: list[str] The modes to fit for diffusion models. """ - return ["area", "width"] if self.modes is None else self.modes + return ['area', 'width'] if self.modes is None else self.modes # ------------------------------------------------------------------ # dunder methods @@ -335,9 +326,9 @@ def __repr__(self) -> 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})" + 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/sample_model/diffusion_model/delta_lorentz.py b/src/easydynamics/sample_model/diffusion_model/delta_lorentz.py index 3874089d..e2cf98a0 100644 --- a/src/easydynamics/sample_model/diffusion_model/delta_lorentz.py +++ b/src/easydynamics/sample_model/diffusion_model/delta_lorentz.py @@ -16,57 +16,60 @@ from easydynamics.utils.utils import Numeric from easydynamics.utils.utils import Q_type from easydynamics.utils.utils import _validate_and_convert_Q -from easydynamics.utils.utils import angstrom -from easydynamics.utils.utils import hbar + +MINIMUM_WIDTH = 1e-10 # To avoid division by zero class DeltaLorentz(DiffusionModelBase): r""" - Model of Delta function and Lorentzian with intensities given by the Debye-Waller factor. - $$ - I = K \exp \left( \frac{-\langle u^2 \rangle Q^2}{3} \right)[A_0 \delta(E) + (A_1) L(E, \Gamma)] + Model of Delta function and Lorentzian with intensities given by the Debye-Waller factor. $$ I + = K \exp \left( \frac{-\langle u^2 \rangle Q^2}{3} \right)[A_0 \delta(E) + (A_1) L(E, \Gamma)] $$, - where $K$ is the scale factor, $\langle u^2 \rangle$ is the mean square displacement, $Q$ is the scattering vector, $A_0$ and $A_1$ are the amplitudes of the delta function and Lorentzian, respectively, and $L(E, \Gamma)$ is the Lorentzian function with width $\Gamma$. - $A_0+A_1=1$ and $A_0$ is the EISF, while $A_1$ is the QISF. + where $K$ is the scale factor, $\langle u^2 \rangle$ is the mean square displacement, $Q$ is + the scattering vector, $A_0$ and $A_1$ are the amplitudes of the delta function and Lorentzian, + respectively, and $L(E, \Gamma)$ is the Lorentzian function with width $\Gamma$. $A_0+A_1=1$ + and $A_0$ is the EISF, while $A_1$ is the QISF. $A_0$ and $A_1$ can be Q-dependent or not. - Creates ComponentCollections with Lorentzian components for given - Q-values. - Example + Examples -------- - >>> Q=np.linspace(0.5,2,7) >>>energy=np.linspace(-2, 2, 501) >>>scale=1.0 - >>> diffusion_coefficient = 2.4e-9 # m^2/s - >>> diffusion_model=DeltaLorentz(display_name="DiffusionModel", - >>> scale=scale, diffusion_coefficient= diffusion_coefficient,) - >>> component_collections=diffusion_model.create_component_collections(Q) - - See also the - tutorials. + >>> Q = np.linspace(0.5, 2, 7) + >>> energy = np.linspace(-2, 2, 501) + >>> scale = 1.0 + >>> mean_u_squared = 0.02 + >>> A_0 = 0.7 + >>> lorentzian_width = 1.0 + >>> model = DeltaLorentz( + ... display_name='DiffusionModel', + ... scale=scale, + ... mean_u_squared=mean_u_squared, + ... A_0=A_0, + ... lorentzian_width=lorentzian_width, + ... allow_Q_dependence=True, + ... ) + >>> component_collections = model.create_component_collections(Q) + + See also the tutorials. """ def __init__( self, - display_name: str | None = "DeltaLorentz", - unique_name: str | None = None, - unit: str | sc.Unit = "meV", scale: Numeric = 1.0, mean_u_squared: Numeric = 0.0, A_0: Numeric = 1.0, lorentzian_width: Numeric = 1.0, allow_Q_dependence: bool = False, + unit: str | sc.Unit = "meV", + display_name: str | None = "DeltaLorentz", + unique_name: str | None = None, ) -> None: """ Initialize a new DeltaLorentz model. Parameters ---------- - display_name : str | None, default='DeltaLorentz' - Display name of the diffusion model. - unique_name : str | None, default=None - Unique name of the diffusion model. If None, a unique name will be generated. By - default, None. - unit : str | sc.Unit, default='meV' + unit : str | sc.Unit, default="meV" Unit of the diffusion model. Must be convertible to meV. scale : Numeric, default=1.0 Scale factor for the diffusion model. Must be a non-negative number. @@ -77,7 +80,12 @@ def __init__( lorentzian_width : Numeric, default=1.0 Width of the Lorentzian function. allow_Q_dependence : bool, default=False - Whether to allow Q-dependence for the model. + Whether to allow Q-dependence of A_0 and A_1 + display_name : str | None, default="DeltaLorentz" + Display name of the diffusion model. + unique_name : str | None, default=None + Unique name of the diffusion model. If None, a unique name will be generated. By + default, None. Raises ------ @@ -96,15 +104,15 @@ def __init__( if not isinstance(lorentzian_width, Numeric): raise TypeError("lorentzian_width must be a number.") + if not isinstance(allow_Q_dependence, bool): + raise TypeError("allow_Q_dependence must be True or False.") + super().__init__( display_name=display_name, unique_name=unique_name, unit=unit, scale=scale, ) - self._hbar = hbar - self._angstrom = angstrom - self._mean_u_squared = mean_u_squared A_0 = Parameter( name="A_0", @@ -113,11 +121,15 @@ def __init__( min=0.0, max=1.0, ) + self._A_0 = A_0 + A_1 = Parameter.from_dependency( name="A_1", dependency_expression="1 - A_0", dependency_map={"A_0": A_0}, ) + self._A_1 = A_1 + self._allow_Q_dependence = allow_Q_dependence self._A_0_list = [] @@ -136,12 +148,9 @@ def __init__( name="lorentzian_width", value=float(lorentzian_width), fixed=False, - min=0.0, + min=MINIMUM_WIDTH, unit=unit, ) - - self._A_0 = A_0 - self._A_1 = A_1 self._lorentzian_width = lorentzian_width # ------------------------------------------------------------------ @@ -235,7 +244,8 @@ def A_1(self) -> Parameter: @A_1.setter def A_1(self, _A_1: Numeric) -> None: """ - A_1 cannot be set directly, as it is a dependent parameter defined as 1 - A_0. To change A_1, set A_0 to the desired value and A_1 will update accordingly. + A_1 cannot be set directly, as it is a dependent parameter defined as 1 - A_0. To change + A_1, set A_0 to the desired value and A_1 will update accordingly. Parameters @@ -245,8 +255,7 @@ def A_1(self, _A_1: Numeric) -> None: Raises ------ - AttributeError - If an attempt is made to set A_1 directly. + AttributeError If an attempt is made to set A_1 directly. """ raise AttributeError( "A_1 is a dependent parameter and cannot be set directly. Set A_0 to change A_1 accordingly." @@ -279,13 +288,13 @@ def lorentzian_width(self, lorentzian_width: Numeric) -> None: TypeError If lorentzian_width is not a number. ValueError - If lorentzian_width is negative. + If lorentzian_width is less than the minimum allowed width. """ if not isinstance(lorentzian_width, Numeric): raise TypeError("lorentzian_width must be a number.") - if float(lorentzian_width) < 0: - raise ValueError("lorentzian_width must be non-negative.") + if float(lorentzian_width) < MINIMUM_WIDTH: + raise ValueError(f"lorentzian_width must be at least {MINIMUM_WIDTH}.") self._lorentzian_width.value = float(lorentzian_width) # ------------------------------------------------------------------ @@ -363,14 +372,13 @@ def create_component_collections( component_display_name: str = "DeltaLorentz component", ) -> list[ComponentCollection]: r""" - Create ComponentCollection components for the DeltaLorentz model at - given Q values. + Create ComponentCollection components for the DeltaLorentz model at given Q values. Parameters ---------- Q : Q_type Scattering vector values. - component_display_name : str, default='DeltaLorentz component' + component_display_name : str, default="DeltaLorentz component" Name of the Lorentzian component. Raises @@ -381,7 +389,8 @@ def create_component_collections( Returns ------- list[ComponentCollection] - List of ComponentCollections with Lorentzian and delta functioncomponents for each Q value. + List of ComponentCollections with Lorentzian and delta functioncomponents for each Q + value. """ Q = _validate_and_convert_Q(Q) @@ -471,8 +480,7 @@ def _create_A0_A1_parameters( Parameters ---------- A_0 : Parameter - The A_0 parameter to use as the base for creating the A_0 parameters for - each Q value. + The A_0 parameter to use as the base for creating the A_0 parameters for each Q value. Returns ------- tuple[list[Parameter], list[Parameter]] From f127d4463190550421a051b522b957f35d1e9bdb Mon Sep 17 00:00:00 2001 From: henrikjacobsenfys Date: Wed, 20 May 2026 10:37:16 +0200 Subject: [PATCH 04/18] update delta_lorentz model --- docs/docs/tutorials/DeltaLorentz.ipynb | 49 +++- docs/docs/tutorials/diffusion_model.ipynb | 6 +- .../diffusion_model/delta_lorentz.py | 272 +++++++++++++----- src/easydynamics/sample_model/model_base.py | 48 ++-- src/easydynamics/sample_model/sample_model.py | 75 ++--- 5 files changed, 316 insertions(+), 134 deletions(-) diff --git a/docs/docs/tutorials/DeltaLorentz.ipynb b/docs/docs/tutorials/DeltaLorentz.ipynb index 25b1282a..7d21229a 100644 --- a/docs/docs/tutorials/DeltaLorentz.ipynb +++ b/docs/docs/tutorials/DeltaLorentz.ipynb @@ -39,7 +39,8 @@ "lorentzian_width = 0.2\n", "\n", "diffusion_model = DeltaLorentz(\n", - " scale=scale, mean_u_squared=mean_u_squared, A_0=A_0, lorentzian_width=lorentzian_width\n", + " scale=scale, mean_u_squared=mean_u_squared, A_0=A_0, lorentzian_width=lorentzian_width,\n", + " allow_Q_variation={\"A_0\": True}\n", ")" ] }, @@ -82,7 +83,51 @@ "metadata": {}, "outputs": [], "source": [ - "component_collections[0].components" + "component_collections[0][0]" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "7f6e7736", + "metadata": {}, + "outputs": [], + "source": [ + "component_collections[0].get_all_parameters()" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "ed083ab5", + "metadata": {}, + "outputs": [], + "source": [ + "diffusion_model.get_all_variables(Q_index=0)" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "bc1c94d1", + "metadata": {}, + "outputs": [], + "source": [ + "\n", + "from easydynamics.sample_model.sample_model import SampleModel\n", + "\n", + "\n", + "sample_model = SampleModel(diffusion_models=[diffusion_model], Q=Q)" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "4c0d5e84", + "metadata": {}, + "outputs": [], + "source": [ + "sample_model.get_all_variables(Q_index=0)" ] } ], diff --git a/docs/docs/tutorials/diffusion_model.ipynb b/docs/docs/tutorials/diffusion_model.ipynb index f3d1571b..b64fe0b4 100644 --- a/docs/docs/tutorials/diffusion_model.ipynb +++ b/docs/docs/tutorials/diffusion_model.ipynb @@ -6,7 +6,7 @@ "metadata": {}, "source": [ "# Diffusion Model\n", - "We support several standard models of diffusion. Here we show an example of Browniand Translational Diffusion, where the scattering is a Lorentzian with width ($\\Gamma$) given by $\\Gamma = D Q^2$, where $D$ is the diffusion coefficient (in m$^2$/s) and $Q$ is the momentum transfer." + "We support several standard models of diffusion. Here we show an example of Brownian Translational Diffusion, where the scattering is a Lorentzian with width ($\\Gamma$) given by $\\Gamma = D Q^2$, where $D$ is the diffusion coefficient (in m$^2$/s) and $Q$ is the momentum transfer." ] }, { @@ -87,7 +87,7 @@ ], "metadata": { "kernelspec": { - "display_name": "easydynamics_newbase", + "display_name": "Python 3", "language": "python", "name": "python3" }, @@ -101,7 +101,7 @@ "name": "python", "nbconvert_exporter": "python", "pygments_lexer": "ipython3", - "version": "3.12.12" + "version": "3.14.4" } }, "nbformat": 4, diff --git a/src/easydynamics/sample_model/diffusion_model/delta_lorentz.py b/src/easydynamics/sample_model/diffusion_model/delta_lorentz.py index e2cf98a0..333db739 100644 --- a/src/easydynamics/sample_model/diffusion_model/delta_lorentz.py +++ b/src/easydynamics/sample_model/diffusion_model/delta_lorentz.py @@ -27,9 +27,9 @@ class DeltaLorentz(DiffusionModelBase): $$, where $K$ is the scale factor, $\langle u^2 \rangle$ is the mean square displacement, $Q$ is - the scattering vector, $A_0$ and $A_1$ are the amplitudes of the delta function and Lorentzian, - respectively, and $L(E, \Gamma)$ is the Lorentzian function with width $\Gamma$. $A_0+A_1=1$ - and $A_0$ is the EISF, while $A_1$ is the QISF. $A_0$ and $A_1$ can be Q-dependent or not. + the scattering vector, $A_0$ and $A_1$ are the relative amplitudes of the delta function and Lorentzian, + respectively, with the constraint that $A_0+A_1=1$, and $L(E, \Gamma)$ is the Lorentzian function with width $\Gamma$. + $A_0$, $A_1$ and the width of the Lorentzian can be Q-dependent or not. Examples @@ -46,7 +46,7 @@ class DeltaLorentz(DiffusionModelBase): ... mean_u_squared=mean_u_squared, ... A_0=A_0, ... lorentzian_width=lorentzian_width, - ... allow_Q_dependence=True, + ... allow_Q_variation={"A_0": True, "lorentzian_width": True}, ... ) >>> component_collections = model.create_component_collections(Q) @@ -59,9 +59,10 @@ def __init__( mean_u_squared: Numeric = 0.0, A_0: Numeric = 1.0, lorentzian_width: Numeric = 1.0, - allow_Q_dependence: bool = False, + allow_Q_variation: dict | None = None, unit: str | sc.Unit = "meV", - display_name: str | None = "DeltaLorentz", + name: str = "DeltaLorentz", + display_name: str | None = None, unique_name: str | None = None, ) -> None: """ @@ -79,8 +80,8 @@ def __init__( Amplitude of the delta function. lorentzian_width : Numeric, default=1.0 Width of the Lorentzian function. - allow_Q_dependence : bool, default=False - Whether to allow Q-dependence of A_0 and A_1 + allow_Q_variation : dict | None, default=None + Dict describing whether to allow Q variation of A_0 and the Lorentzian width. The dict should have the keys "A_0" and "lorentzian_width", with boolean values indicating whether to allow Q-dependence for each parameter. If None, no Q-dependence will be allowed. display_name : str | None, default="DeltaLorentz" Display name of the diffusion model. unique_name : str | None, default=None @@ -90,29 +91,57 @@ def __init__( Raises ------ TypeError - If scale, mean_u_squared, A_0, or lorentzian_width is not a number. + If mean_u_squared, A_0, or lorentzian_width is not a number. + If allow_Q_variation is not a dict or None. + + + ValueError + If A_0 is not between 0 and 1, or if lorentzian_width is less than the minimum allowed width. + If mean_u_squared is negative, or if allow_Q_variation contains unknown keys. + """ - if not isinstance(scale, Numeric): - raise TypeError("scale must be a number.") + super().__init__( + scale=scale, + unit=unit, + name=name, + display_name=display_name, + unique_name=unique_name, + ) if not isinstance(mean_u_squared, Numeric): raise TypeError("mean_u_squared must be a number.") + if float(mean_u_squared) < 0: + raise ValueError("mean_u_squared must be non-negative.") + if not isinstance(A_0, Numeric): raise TypeError("A_0 must be a number.") + if float(A_0) < 0 or float(A_0) > 1: + raise ValueError("A_0 must be between 0 and 1.") + if not isinstance(lorentzian_width, Numeric): raise TypeError("lorentzian_width must be a number.") - if not isinstance(allow_Q_dependence, bool): - raise TypeError("allow_Q_dependence must be True or False.") + if float(lorentzian_width) < MINIMUM_WIDTH: + raise ValueError(f"lorentzian_width must be at least {MINIMUM_WIDTH}.") - super().__init__( - display_name=display_name, - unique_name=unique_name, - unit=unit, - scale=scale, - ) + allow_Q_variation_default = { + "A_0": False, + "lorentzian_width": False, + } + allowed_keys = set(allow_Q_variation_default) + + if allow_Q_variation is None: + allow_Q_variation = {} + if not isinstance(allow_Q_variation, dict): + raise TypeError("allow_Q_variation must be a dict or None.") + + unknown_keys = set(allow_Q_variation) - allowed_keys + if unknown_keys: + raise ValueError(f"Unknown keys in allow_Q_variation: {unknown_keys}") + + self._allow_Q_variation = {**allow_Q_variation_default, **allow_Q_variation} A_0 = Parameter( name="A_0", @@ -122,6 +151,7 @@ def __init__( max=1.0, ) self._A_0 = A_0 + self._A_0_list = [] A_1 = Parameter.from_dependency( name="A_1", @@ -129,10 +159,6 @@ def __init__( dependency_map={"A_0": A_0}, ) self._A_1 = A_1 - - self._allow_Q_dependence = allow_Q_dependence - - self._A_0_list = [] self._A_1_list = [] mean_u_squared = Parameter( @@ -152,6 +178,7 @@ def __init__( unit=unit, ) self._lorentzian_width = lorentzian_width + self._lorentzian_width_list = [] # ------------------------------------------------------------------ # Properties @@ -247,7 +274,6 @@ def A_1(self, _A_1: Numeric) -> None: A_1 cannot be set directly, as it is a dependent parameter defined as 1 - A_0. To change A_1, set A_0 to the desired value and A_1 will update accordingly. - Parameters ---------- _A_1 : Numeric @@ -318,11 +344,19 @@ def calculate_width(self, Q: Q_type) -> np.ndarray: Q = _validate_and_convert_Q(Q) - return self.lorentzian_width.value * np.ones_like(Q) + if self._allow_Q_variation["lorentzian_width"] is True: + widths = [ + lorentzian_width.value + for lorentzian_width in self._lorentzian_width_list + ] + else: + widths = self.lorentzian_width.value * np.ones_like(Q) + + return np.array(widths) def calculate_EISF(self, Q: Q_type) -> np.ndarray: """ - Calculate the Elastic Incoherent Structure Factor (EISF) for the Brownian translational + Calculate the Elastic Incoherent Structure Factor (EISF) for the diffusion model. Parameters @@ -338,8 +372,8 @@ def calculate_EISF(self, Q: Q_type) -> np.ndarray: # Need to handle units better Q = _validate_and_convert_Q(Q) - if self._allow_Q_dependence is True: - A_0_values = [A_0.value for A_0 in self._A_0_list] + if self._allow_Q_variation["A_0"] is True: + A_0_values = [A_0_.value for A_0_ in self._A_0_list] else: A_0_values = [self.A_0.value] * len(Q) return np.exp(-self.mean_u_squared.value * Q**2 / 3) * np.array(A_0_values) @@ -360,8 +394,8 @@ def calculate_QISF(self, Q: Q_type) -> np.ndarray: """ Q = _validate_and_convert_Q(Q) - if self._allow_Q_dependence is True: - A_1_values = [A_1.value for A_1 in self._A_1_list] + if self._allow_Q_variation["A_1"] is True: + A_1_values = [A_1_.value for A_1_ in self._A_1_list] else: A_1_values = [self.A_1.value] * len(Q) return np.exp(-self.mean_u_squared.value * Q**2 / 3) * np.array(A_1_values) @@ -369,7 +403,8 @@ def calculate_QISF(self, Q: Q_type) -> np.ndarray: def create_component_collections( self, Q: Q_type, - component_display_name: str = "DeltaLorentz component", + lorentzian_name: str = "Lorentzian", + delta_name: str = "Delta function", ) -> list[ComponentCollection]: r""" Create ComponentCollection components for the DeltaLorentz model at given Q values. @@ -378,13 +413,15 @@ def create_component_collections( ---------- Q : Q_type Scattering vector values. - component_display_name : str, default="DeltaLorentz component" + lorentzian_name : str, default="Lorentzian" Name of the Lorentzian component. + delta_name : str, default="Delta function" + Name of the Delta function component. Raises ------ TypeError - If component_display_name is not a string. + If lorentzian_name or delta_name is not a string. Returns ------- @@ -394,43 +431,64 @@ def create_component_collections( """ Q = _validate_and_convert_Q(Q) - if not isinstance(component_display_name, str): - raise TypeError("component_name must be a string.") + if not isinstance(lorentzian_name, str): + raise TypeError("lorentzian_name must be a string.") + + if not isinstance(delta_name, str): + raise TypeError("delta_name must be a string.") - if self._allow_Q_dependence is True: + if self._allow_Q_variation["A_0"] is True: A_0_list, A_1_list = self._create_A0_A1_parameters(self.A_0, Q) self._A_0_list = A_0_list self._A_1_list = A_1_list - component_collection_list = [None] * len(Q) - # In more complex models, this is used to scale the area of the - # Lorentzians and the delta function. + if self._allow_Q_variation["lorentzian_width"] is True: + lorentzian_width_list = self._create_lorentzian_width_parameters( + self.lorentzian_width, Q + ) + self._lorentzian_width_list = lorentzian_width_list - # Create a Lorentzian component for each Q-value, with - # width D*Q^2 and area equal to scale. - # No delta function, as the EISF is 0. + component_collection_list = [None] * len(Q) for i, Q_value in enumerate(Q): component_collection_list[i] = ComponentCollection( - display_name=f"{self.display_name}_Q{Q_value:.2f}", unit=self.unit + display_name=f"{self.display_name}_Q{Q_value:.2f}", + unit=self.unit, ) + # ------------------------------# + # Create Lorentzian + # ------------------------------# + lorentzian_component = Lorentzian( - display_name=component_display_name, + name=lorentzian_name, unit=self.unit, ) - # Make the width dependent on Q - lorentzian_component.width.make_dependent_on( - dependency_expression=self._write_width_dependency_expression(Q_value), - dependency_map=self._write_width_dependency_map_expression(), - desired_unit=self.unit, - ) + # If the width is allowed to vary with Q it is independent. + # If the width is not allowed to vary with Q it must be made + # dependent on the width parameter of the model. + if self._allow_Q_variation["lorentzian_width"] is False: + dependency_map = self._write_width_dependency_map_expression() + + lorentzian_component.width.make_dependent_on( + dependency_expression=self._write_lorz_width_dependency_expression( + Q_value + ), + dependency_map=dependency_map, + desired_unit=self.unit, + ) - # Make the area dependent on Q - if self._allow_Q_dependence is True: + # The area is always a dependent parameter in this model, as + # it depends on the scale, mean_u_squared and A_1 parameters + # of the model. If A_1 is allowed to vary with Q, the area + # will also depend on the specific A_1 parameter for that Q + # value. If A_1 is not allowed to vary with Q, the area will + # depend on the single A_1 parameter of the model. + if self._allow_Q_variation["A_0"] is True: dependency_map = self._write_lorz_area_dependency_map_expression(i) else: dependency_map = self._write_lorz_area_dependency_map_expression(None) + lorentzian_component.area.make_dependent_on( dependency_expression=self._write_lorz_area_dependency_expression( Q_value @@ -440,13 +498,20 @@ def create_component_collections( component_collection_list[i].append_component(lorentzian_component) + # ------------------------------# + # Create delta function + # ------------------------------# + delta_component = DeltaFunction( - display_name="Delta function", unit=self.unit + name=delta_name, + unit=self.unit, ) - if self._allow_Q_dependence is True: + + if self._allow_Q_variation["A_0"] is True: dependency_map = self._write_delta_area_dependency_map_expression(i) else: dependency_map = self._write_delta_area_dependency_map_expression(None) + delta_component.area.make_dependent_on( dependency_expression=self._write_delta_area_dependency_expression( Q_value @@ -458,14 +523,37 @@ def create_component_collections( return component_collection_list - def get_all_variables(self) -> list[DescriptorNumber]: + def get_all_variables(self, Q_index: int | None = None) -> list[DescriptorNumber]: + """ + Get a list of all variables (Parameters and Descriptors) in the model. - if self._allow_Q_dependence is False: - return super().get_all_variables() - variables = [self.scale, self.mean_u_squared, self.lorentzian_width] - variables.extend(self._A_0_list) - variables.extend(self._A_1_list) + Returns + ------- + list[DescriptorNumber] + List of all variables in the model. + """ + + variables = [self.scale, self.mean_u_squared] + if self._allow_Q_variation["A_0"] is True: + if Q_index is None: + variables.extend(self._A_0_list) + variables.extend(self._A_1_list) + else: + variables.append(self._A_0_list[Q_index]) + variables.append(self._A_1_list[Q_index]) + else: + variables.append(self.A_0) + variables.append(self.A_1) + + if self._allow_Q_variation["lorentzian_width"] is True: + if Q_index is None: + variables.extend(self._lorentzian_width_list) + else: + variables.append(self._lorentzian_width_list[Q_index]) + else: + variables.append(self.lorentzian_width) + return variables # ------------------------------------------------------------------ @@ -507,12 +595,39 @@ def _create_A0_A1_parameters( ) ) - self._A_0_list = A_0_list - self._A_1_list = A_1_list - return A_0_list, A_1_list - def _write_width_dependency_expression(self, Q: float) -> str: + def _create_lorentzian_width_parameters( + self, lorentzian_width: Parameter, Q: Q_type + ) -> list[Parameter]: + """ + Create a list of Lorentzian width parameters for each Q value. + + Parameters + ---------- + lorentzian_width : Parameter + The Lorentzian width parameter to use as the base for creating the Lorentzian width parameters for each Q value. + + Returns + ------- + list[Parameter] + A list containing the Lorentzian width parameters for each Q value. + """ + lorentzian_width_list = [] + for i, Q_value in enumerate(Q): + lorentzian_width_list.append( + Parameter( + name=f"lorentzian_width_Q{Q_value:.2f}", + value=float(lorentzian_width.value), + fixed=False, + min=MINIMUM_WIDTH, + unit=self.unit, + ) + ) + + return lorentzian_width_list + + def _write_lorz_width_dependency_expression(self, Q: float) -> str: """ Write the dependency expression for the width as a function of Q to make dependent Parameters. @@ -537,7 +652,9 @@ def _write_width_dependency_expression(self, Q: float) -> str: return "lorentzian_width" - def _write_width_dependency_map_expression(self) -> dict[str, DescriptorNumber]: + def _write_width_dependency_map_expression( + self, + ) -> dict[str, DescriptorNumber]: """ Write the dependency map expression to make dependent Parameters. @@ -550,19 +667,19 @@ def _write_width_dependency_map_expression(self) -> dict[str, DescriptorNumber]: "lorentzian_width": self.lorentzian_width, } - def _write_lorz_area_dependency_expression(self, Q) -> str: + def _write_lorz_area_dependency_expression(self, Q: float) -> str: """ Write the dependency expression for the area to make dependent Parameters. Parameters ---------- - QISF : float - Quasielastic Incoherent Scattering Function. + Q : float + Scattering vector in 1/angstrom . Raises ------ TypeError - If QISF is not a float. + If Q is not a float. Returns ------- @@ -575,7 +692,7 @@ def _write_lorz_area_dependency_expression(self, Q) -> str: return f"scale * exp(-mean_u_squared.value * {Q}**2 / 3) * A_1" def _write_lorz_area_dependency_map_expression( - self, Q_index + self, Q_index: int | None ) -> dict[str, DescriptorNumber]: """ Write the dependency map expression to make dependent Parameters. @@ -598,19 +715,19 @@ def _write_lorz_area_dependency_map_expression( "A_1": self._A_1_list[Q_index], } - def _write_delta_area_dependency_expression(self, Q) -> str: + def _write_delta_area_dependency_expression(self, Q: float) -> str: """ Write the dependency expression for the area to make dependent Parameters. Parameters ---------- - QISF : float - Quasielastic Incoherent Scattering Function. + Q : float + Scattering vector in 1/angstrom. Raises ------ TypeError - If QISF is not a float. + If Q is not a float. Returns ------- @@ -624,11 +741,16 @@ def _write_delta_area_dependency_expression(self, Q) -> str: def _write_delta_area_dependency_map_expression( self, - Q_index, + Q_index: int | None, ) -> dict[str, DescriptorNumber]: """ Write the dependency map expression to make dependent Parameters. + Parameters + ---------- + Q_index : int | None + Index of the Q value for which to write the dependency map. If None, write the dependency map for the case where A_0 is not Q-dependent. + Returns ------- dict[str, DescriptorNumber] diff --git a/src/easydynamics/sample_model/model_base.py b/src/easydynamics/sample_model/model_base.py index 4895dc6b..6ca886c0 100644 --- a/src/easydynamics/sample_model/model_base.py +++ b/src/easydynamics/sample_model/model_base.py @@ -24,9 +24,9 @@ class ModelBase(EasyDynamicsModelBase): def __init__( self, - display_name: str = 'MyModelBase', + display_name: str = "MyModelBase", unique_name: str | None = None, - unit: str | sc.Unit | None = 'meV', + unit: str | sc.Unit | None = "meV", components: ModelComponent | ComponentCollection | None = None, Q: Q_type | None = None, ) -> None: @@ -63,8 +63,8 @@ def __init__( components, (ModelComponent, ComponentCollection) ): raise TypeError( - f'Components must be a ModelComponent, a ComponentCollection or None, ' - f'got {type(components).__name__}' + f"Components must be a ModelComponent, a ComponentCollection or None, " + f"got {type(components).__name__}" ) self._components = ComponentCollection() @@ -100,8 +100,8 @@ def evaluate( if not self._component_collections: raise ValueError( - 'No components in the model to evaluate. ' - 'Run generate_component_collections() first' + "No components in the model to evaluate. " + "Run generate_component_collections() first" ) return [collection.evaluate(x) for collection in self._component_collections] @@ -170,8 +170,8 @@ def unit(self, _unit_str: str) -> None: Always raised to indicate that the unit is read-only. """ raise AttributeError( - f'Unit is read-only. Use convert_unit to change the unit between allowed types ' - f'or create a new {self.__class__.__name__} with the desired unit.' + f"Unit is read-only. Use convert_unit to change the unit between allowed types " + f"or create a new {self.__class__.__name__} with the desired unit." ) @property @@ -202,7 +202,9 @@ def components(self, value: ModelComponent | ComponentCollection | None) -> None If value is not a ModelComponent, ComponentCollection, or None. """ if not isinstance(value, (ModelComponent, ComponentCollection, type(None))): - raise TypeError('Components must be a ModelComponent or a ComponentCollection') + raise TypeError( + "Components must be a ModelComponent or a ComponentCollection" + ) self.clear_components() if value is not None: @@ -250,8 +252,8 @@ def Q(self, value: Q_type | None) -> None: if len(old_Q) != len(new_Q) or not np.allclose(old_Q, new_Q): raise ValueError( - 'New Q values are not similar to the old ones. ' - 'To change Q values, first run clear_Q().' + "New Q values are not similar to the old ones. " + "To change Q values, first run clear_Q()." ) def clear_Q(self, confirm: bool = False) -> None: @@ -271,7 +273,7 @@ def clear_Q(self, confirm: bool = False) -> None: """ if not confirm: raise ValueError( - 'Clearing Q values requires confirmation. Set confirm=True to proceed.' + "Clearing Q values requires confirmation. Set confirm=True to proceed." ) self._Q = None self._on_Q_change() @@ -300,7 +302,9 @@ def convert_unit(self, unit: str | sc.Unit) -> None: old_unit = self._unit if not isinstance(unit, (str, sc.Unit)): - raise TypeError(f'Unit must be a string or sc.Unit, got {type(unit).__name__}') + raise TypeError( + f"Unit must be a string or sc.Unit, got {type(unit).__name__}" + ) try: for component in self.components: component.convert_unit(unit) @@ -359,11 +363,13 @@ def get_all_variables(self, Q_index: int | None = None) -> list[Parameter]: ] else: if not isinstance(Q_index, int): - raise TypeError(f'Q_index must be an int or None, got {type(Q_index).__name__}') + raise TypeError( + f"Q_index must be an int or None, got {type(Q_index).__name__}" + ) if Q_index < 0 or Q_index >= len(self._component_collections): raise IndexError( - f'Q_index {Q_index} is out of bounds for component collections ' - f'of length {len(self._component_collections)}' + f"Q_index {Q_index} is out of bounds for component collections " + f"of length {len(self._component_collections)}" ) all_vars = self._component_collections[Q_index].get_all_variables() return all_vars @@ -390,11 +396,11 @@ def get_component_collection(self, Q_index: int) -> ComponentCollection: The ComponentCollection at the. """ if not isinstance(Q_index, int): - raise TypeError(f'Q_index must be an int, got {type(Q_index).__name__}') + raise TypeError(f"Q_index must be an int, got {type(Q_index).__name__}") if Q_index < 0 or Q_index >= len(self._component_collections): raise IndexError( - f'Q_index {Q_index} is out of bounds for component collections ' - f'of length {len(self._component_collections)}' + f"Q_index {Q_index} is out of bounds for component collections " + f"of length {len(self._component_collections)}" ) return self._component_collections[Q_index] @@ -440,6 +446,6 @@ def __repr__(self) -> str: A string representation of the ModelBase. """ return ( - f'{self.__class__.__name__}(unique_name={self.unique_name}, ' - f'unit={self.unit}), Q = {self.Q}, components = {self.components}' + f"{self.__class__.__name__}(unique_name={self.unique_name}, " + f"unit={self.unit}), Q = {self.Q}, components = {self.components}" ) diff --git a/src/easydynamics/sample_model/sample_model.py b/src/easydynamics/sample_model/sample_model.py index bcb598f7..4a9155cc 100644 --- a/src/easydynamics/sample_model/sample_model.py +++ b/src/easydynamics/sample_model/sample_model.py @@ -9,7 +9,9 @@ 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 +from easydynamics.sample_model.diffusion_model.diffusion_model_base import ( + DiffusionModelBase, +) from easydynamics.sample_model.model_base import ModelBase from easydynamics.settings.detailed_balance_settings import DetailedBalanceSettings from easydynamics.utils import detailed_balance_factor @@ -28,14 +30,14 @@ class SampleModel(ModelBase): def __init__( self, - display_name: str = 'MySampleModel', + display_name: str = "MySampleModel", unique_name: str | None = None, - unit: str | sc.Unit = 'meV', + unit: str | sc.Unit = "meV", components: ModelComponent | ComponentCollection | None = None, Q: Q_type | None = None, diffusion_models: DiffusionModelBase | list[DiffusionModelBase] | None = None, temperature: float | None = None, - temperature_unit: str | sc.Unit = 'K', + temperature_unit: str | sc.Unit = "K", detailed_balance_settings: DetailedBalanceSettings | None = None, ) -> None: """ @@ -82,8 +84,8 @@ def __init__( isinstance(dm, DiffusionModelBase) for dm in diffusion_models ): raise TypeError( - 'diffusion_models must be a DiffusionModelBase, ' - 'a list of DiffusionModelBase or None' + "diffusion_models must be a DiffusionModelBase, " + "a list of DiffusionModelBase or None" ) self._diffusion_models = diffusion_models @@ -99,15 +101,15 @@ def __init__( self._temperature = None else: if not isinstance(temperature, Numeric): - raise TypeError('temperature must be a number or None') + raise TypeError("temperature must be a number or None") if temperature < 0: - raise ValueError('temperature must be non-negative') + raise ValueError("temperature must be non-negative") self._temperature = Parameter( - name='Temperature', + name="Temperature", value=temperature, unit=temperature_unit, - display_name='Temperature', + display_name="Temperature", fixed=True, ) self._temperature_unit = temperature_unit @@ -117,7 +119,9 @@ def __init__( elif isinstance(detailed_balance_settings, DetailedBalanceSettings): self._detailed_balance_settings = detailed_balance_settings else: - raise TypeError('detailed_balance_settings must be a DetailedBalanceSettings or None') + raise TypeError( + "detailed_balance_settings must be a DetailedBalanceSettings or None" + ) # ------------------------------------------------------------------ # Component management @@ -140,7 +144,7 @@ def append_diffusion_model(self, diffusion_model: DiffusionModelBase) -> None: if not isinstance(diffusion_model, DiffusionModelBase): raise TypeError( - f'diffusion_model must be a DiffusionModelBase, got {type(diffusion_model).__name__}' # noqa: E501 + f"diffusion_model must be a DiffusionModelBase, got {type(diffusion_model).__name__}" # noqa: E501 ) self._diffusion_models.append(diffusion_model) @@ -166,8 +170,8 @@ def remove_diffusion_model(self, name: str) -> None: self._generate_component_collections() return raise ValueError( - f'No DiffusionModel with name {name} found. \n' - f'The available names are: {[dm.name for dm in self._diffusion_models]}' + f"No DiffusionModel with name {name} found. \n" + f"The available names are: {[dm.name for dm in self._diffusion_models]}" ) def clear_diffusion_models(self) -> None: @@ -220,8 +224,8 @@ def diffusion_models( isinstance(dm, DiffusionModelBase) for dm in value ): raise TypeError( - 'diffusion_models must be a DiffusionModelBase, a list of DiffusionModelBase, ' - 'or None' + "diffusion_models must be a DiffusionModelBase, a list of DiffusionModelBase, " + "or None" ) self._diffusion_models = value self._on_diffusion_models_change() @@ -260,17 +264,17 @@ def temperature(self, value: Numeric | None) -> None: return if not isinstance(value, Numeric): - raise TypeError('temperature must be a number or None') + raise TypeError("temperature must be a number or None") if value < 0: - raise ValueError('temperature must be non-negative') + raise ValueError("temperature must be non-negative") if self._temperature is None: self._temperature = Parameter( - name='Temperature', + name="Temperature", value=value, unit=self._temperature_unit, - display_name='Temperature', + display_name="Temperature", fixed=True, ) else: @@ -305,8 +309,8 @@ def temperature_unit(self, _value: str | sc.Unit) -> None: """ raise AttributeError( - f'Temperature_unit is read-only. Use convert_temperature_unit to change the unit between allowed types ' # noqa: E501 - f'or create a new {self.__class__.__name__} with the desired unit.' + f"Temperature_unit is read-only. Use convert_temperature_unit to change the unit between allowed types " # noqa: E501 + f"or create a new {self.__class__.__name__} with the desired unit." ) def convert_temperature_unit(self, unit: str | sc.Unit) -> None: @@ -327,7 +331,7 @@ def convert_temperature_unit(self, unit: str | sc.Unit) -> None: """ if self.temperature is None: - raise ValueError('Temperature is not set, cannot convert unit.') + raise ValueError("Temperature is not set, cannot convert unit.") old_unit = self.temperature.unit @@ -368,7 +372,7 @@ def normalize_detailed_balance(self, value: bool) -> None: If value is not a bool. """ if not isinstance(value, bool): - raise TypeError('normalize_detailed_balance must be True or False') + raise TypeError("normalize_detailed_balance must be True or False") self.detailed_balance_settings.normalize_detailed_balance = value @property @@ -399,7 +403,7 @@ def use_detailed_balance(self, value: bool) -> None: If value is not a bool. """ if not isinstance(value, bool): - raise TypeError('use_detailed_balance must be True or False') + raise TypeError("use_detailed_balance must be True or False") self.detailed_balance_settings.use_detailed_balance = value @property @@ -430,7 +434,9 @@ def detailed_balance_settings(self, value: DetailedBalanceSettings) -> None: If value is not a DetailedBalanceSettings. """ if not isinstance(value, DetailedBalanceSettings): - raise TypeError('detailed_balance_settings must be a DetailedBalanceSettings') + raise TypeError( + "detailed_balance_settings must be a DetailedBalanceSettings" + ) self._detailed_balance_settings = value # ------------------------------------------------------------------ @@ -457,7 +463,10 @@ def evaluate( y = super().evaluate(x) - if self.temperature is not None and self.detailed_balance_settings.use_detailed_balance: + if ( + self.temperature is not None + and self.detailed_balance_settings.use_detailed_balance + ): DBF = detailed_balance_factor( energy=x, temperature=self.temperature, @@ -515,7 +524,7 @@ def _generate_component_collections(self) -> None: for diffusion_model in self._diffusion_models: diffusion_collections = diffusion_model.create_component_collections( Q=self.Q, - component_name=diffusion_model.name, + # component_name=diffusion_model.name, ) for target, source in zip( self._component_collections, @@ -544,9 +553,9 @@ def __repr__(self) -> str: """ return ( - f'{self.__class__.__name__}(unique_name={self.unique_name}, unit={self.unit}), ' - f'Q = {self.Q}, \n ' - f'components = {self.components}, diffusion_models = {self.diffusion_models}, ' - f'temperature = {self.temperature}, ' - f'detailed_balance_settings = {self.detailed_balance_settings}' + f"{self.__class__.__name__}(unique_name={self.unique_name}, unit={self.unit}), " + f"Q = {self.Q}, \n " + f"components = {self.components}, diffusion_models = {self.diffusion_models}, " + f"temperature = {self.temperature}, " + f"detailed_balance_settings = {self.detailed_balance_settings}" ) From 81d38cacb17a561096ef2e62c4b331f427a6cdeb Mon Sep 17 00:00:00 2001 From: henrikjacobsenfys Date: Wed, 20 May 2026 10:37:32 +0200 Subject: [PATCH 05/18] remove unit property from model_base --- src/easydynamics/sample_model/model_base.py | 33 --------------------- 1 file changed, 33 deletions(-) diff --git a/src/easydynamics/sample_model/model_base.py b/src/easydynamics/sample_model/model_base.py index 6ca886c0..059998d1 100644 --- a/src/easydynamics/sample_model/model_base.py +++ b/src/easydynamics/sample_model/model_base.py @@ -141,39 +141,6 @@ def clear_components(self) -> None: # Properties # ------------------------------------------------------------------ - @property - def unit(self) -> str | sc.Unit | None: - """ - Get the unit of the SampleModel. - - Returns - ------- - str | sc.Unit | None - The unit of the SampleModel. - """ - - return self._unit - - @unit.setter - def unit(self, _unit_str: str) -> None: - """ - Unit is read-only and cannot be set directly. - - Parameters - ---------- - _unit_str : str - The new unit to set (ignored). - - Raises - ------ - AttributeError - Always raised to indicate that the unit is read-only. - """ - raise AttributeError( - f"Unit is read-only. Use convert_unit to change the unit between allowed types " - f"or create a new {self.__class__.__name__} with the desired unit." - ) - @property def components(self) -> list[ModelComponent]: """ From dce5689582a088b647a136a6e3d13a551c6aa200 Mon Sep 17 00:00:00 2001 From: henrikjacobsenfys Date: Wed, 20 May 2026 12:30:11 +0200 Subject: [PATCH 06/18] Make som progress on implementing Q in diffusion models --- .../sample_model/component_collection.py | 94 +++++++-------- .../brownian_translational_diffusion.py | 102 ++++++++++------ .../diffusion_model/delta_lorentz.py | 27 ++++- .../diffusion_model/diffusion_model_base.py | 108 ++++++++++++++--- .../jump_translational_diffusion.py | 113 +++++++++++------- src/easydynamics/sample_model/model_base.py | 4 +- 6 files changed, 294 insertions(+), 154 deletions(-) diff --git a/src/easydynamics/sample_model/component_collection.py b/src/easydynamics/sample_model/component_collection.py index 11e6574a..1ba3c975 100644 --- a/src/easydynamics/sample_model/component_collection.py +++ b/src/easydynamics/sample_model/component_collection.py @@ -52,8 +52,8 @@ class ComponentCollection(EasyDynamicsList, EasyDynamicsModelBase): def __init__( self, components: ModelComponent | list[ModelComponent] | None = None, - unit: str | sc.Unit = 'meV', - name: str = 'ComponentCollection', + unit: str | sc.Unit = "meV", + name: str = "ComponentCollection", display_name: str | None = None, unique_name: str | None = None, ) -> None: @@ -76,7 +76,7 @@ def __init__( Raises ------ TypeError - If unit is not a string or sc.Unit, or if components is not a list of ModelComponent. + If components is not a list of ModelComponent. """ if components is None: components = [] @@ -84,12 +84,12 @@ def __init__( components = [components] elif not isinstance(components, list): raise TypeError( - f'components must be a ModelComponent or a list of ModelComponent, got {type(components).__name__} instead.' # noqa: E501 + f"components must be a ModelComponent or a list of ModelComponent, got {type(components).__name__} instead." # noqa: E501 ) for comp in components: if not isinstance(comp, ModelComponent): raise TypeError( - f'All items in components must be instances of ModelComponent, got {type(comp).__name__} instead.' # noqa: E501 + f"All items in components must be instances of ModelComponent, got {type(comp).__name__} instead." # noqa: E501 ) EasyDynamicsList.__init__( @@ -138,8 +138,8 @@ def is_empty(self, _value: bool) -> None: Always raised since is_empty is read-only. """ raise AttributeError( - 'is_empty is a read-only property that indicates ' - 'whether the collection has components.' + "is_empty is a read-only property that indicates " + "whether the collection has components." ) def convert_unit(self, unit: str | sc.Unit) -> None: @@ -160,7 +160,9 @@ def convert_unit(self, unit: str | sc.Unit) -> None: """ if not isinstance(unit, (str, sc.Unit)): - raise TypeError(f'Unit must be a string or sc.Unit, got {type(unit).__name__}') + raise TypeError( + f"Unit must be a string or sc.Unit, got {type(unit).__name__}" + ) old_unit = self._unit @@ -222,28 +224,28 @@ def normalize_area(self) -> None: which would prevent normalization. """ if not self: - raise ValueError('No components in the model to normalize.') + raise ValueError("No components in the model to normalize.") area_params = [] - total_area = Parameter(name='total_area', value=0.0, unit=self._unit) + total_area = Parameter(name="total_area", value=0.0, unit=self._unit) for component in self: - if hasattr(component, 'area'): + if hasattr(component, "area"): area_params.append(component.area) total_area += component.area else: warnings.warn( f"Component '{component.name}' does not have an 'area' attribute " - f'and will be skipped in normalization.', + f"and will be skipped in normalization.", UserWarning, stacklevel=2, ) if total_area.value == 0: - raise ValueError('Total area is zero; cannot normalize.') + raise ValueError("Total area is zero; cannot normalize.") if not np.isfinite(total_area.value): - raise ValueError('Total area is not finite; cannot normalize.') + raise ValueError("Total area is not finite; cannot normalize.") for param in area_params: param.value /= total_area.value @@ -264,7 +266,9 @@ def get_all_variables(self) -> list[DescriptorBase]: return [var for component in self for var in component.get_all_variables()] - def evaluate(self, x: Numeric | list | np.ndarray | sc.Variable | sc.DataArray) -> np.ndarray: + def evaluate( + self, x: Numeric | list | np.ndarray | sc.Variable | sc.DataArray + ) -> np.ndarray: """ Evaluate the sum of all components. @@ -313,10 +317,12 @@ def evaluate_component( Evaluated values for the specified component. """ if not self: - raise ValueError('No components in the model to evaluate.') + raise ValueError("No components in the model to evaluate.") if not isinstance(name, str): - raise TypeError(f'Component name must be a string, got {type(name)} instead.') + raise TypeError( + f"Component name must be a string, got {type(name)} instead." + ) matches = [comp for comp in self if comp.name == name] if not matches: @@ -340,29 +346,6 @@ def free_all_parameters(self) -> None: # Dunder methods # ------------------------------------------------------------------ - def __contains__(self, item: str | ModelComponent) -> bool: - """ - Check if a component with the given name or instance exists in the ComponentCollection. - - Parameters - ---------- - item : str | ModelComponent - The component name or instance to check for. - - Returns - ------- - bool - True if the component exists, False otherwise. - """ - - if isinstance(item, str): - # Check by component name - return any(comp.name == item for comp in self) - if isinstance(item, ModelComponent): - # Check by component instance - return any(comp is item for comp in self) - return False - def __repr__(self) -> str: """ Return a string representation of the ComponentCollection. @@ -372,18 +355,21 @@ def __repr__(self) -> str: str String representation of the ComponentCollection. """ - comp_names = ', '.join(c.name for c in self) or 'No components' + comp_names = ", ".join(c.name for c in self) or "No components" - return f"" + return ( + f"ComponentCollection(name='{self.name}', unit='{self.unit}', \n" + f"Components: {comp_names})" + ) def to_dict(self) -> dict: return { - '@module': self.__class__.__module__, - '@class': self.__class__.__name__, - 'unit': str(self.unit), - 'name': self.name, - 'display_name': self.display_name, - 'components': [c.to_dict() for c in self._data], + "@module": self.__class__.__module__, + "@class": self.__class__.__name__, + "unit": str(self.unit), + "name": self.name, + "display_name": self.display_name, + "components": [c.to_dict() for c in self._data], } @classmethod @@ -401,17 +387,17 @@ def deserialise_component(d: dict) -> ModelComponent: ModelComponent The deserialised component. """ - module = importlib.import_module(d['@module']) - cls = getattr(module, d['@class']) + module = importlib.import_module(d["@module"]) + cls = getattr(module, d["@class"]) return cls.from_dict(d) - components = [deserialise_component(c) for c in obj_dict.get('components', [])] + components = [deserialise_component(c) for c in obj_dict.get("components", [])] return cls( components=components, - unit=obj_dict.get('unit', 'meV'), - name=obj_dict.get('name', 'ComponentCollection'), - display_name=obj_dict.get('display_name'), + unit=obj_dict.get("unit", "meV"), + name=obj_dict.get("name", "ComponentCollection"), + display_name=obj_dict.get("display_name"), ) def __copy__(self) -> ComponentCollection: diff --git a/src/easydynamics/sample_model/diffusion_model/brownian_translational_diffusion.py b/src/easydynamics/sample_model/diffusion_model/brownian_translational_diffusion.py index 0883db57..c6584343 100644 --- a/src/easydynamics/sample_model/diffusion_model/brownian_translational_diffusion.py +++ b/src/easydynamics/sample_model/diffusion_model/brownian_translational_diffusion.py @@ -9,7 +9,9 @@ from easydynamics.sample_model.component_collection import ComponentCollection from easydynamics.sample_model.components import Lorentzian -from easydynamics.sample_model.diffusion_model.diffusion_model_base import DiffusionModelBase +from easydynamics.sample_model.diffusion_model.diffusion_model_base import ( + DiffusionModelBase, +) from easydynamics.utils.utils import Numeric from easydynamics.utils.utils import Q_type from easydynamics.utils.utils import _validate_and_convert_Q @@ -42,9 +44,10 @@ def __init__( self, scale: Numeric = 1.0, diffusion_coefficient: Numeric = 1.0, - unit: str | sc.Unit = 'meV', - name: str = 'BrownianTranslationalDiffusion', - display_name: str | None = 'BrownianTranslationalDiffusion', + Q: Q_type | None = None, + unit: str | sc.Unit = "meV", + name: str = "BrownianTranslationalDiffusion", + display_name: str | None = "BrownianTranslationalDiffusion", unique_name: str | None = None, ) -> None: """ @@ -56,6 +59,8 @@ def __init__( Scale factor for the diffusion model. Must be a non-negative number. diffusion_coefficient : Numeric, default=1.0 Diffusion coefficient D in m^2/s. + Q : Q_type | None, default=None + Q values for the model. If None, Q is not set. unit : str | sc.Unit, default='meV' Unit of the diffusion model. Must be convertible to meV. name : str, default='BrownianTranslationalDiffusion' @@ -72,19 +77,20 @@ def __init__( If scale or diffusion_coefficient is not a number. """ if not isinstance(scale, Numeric): - raise TypeError('scale must be a number.') + raise TypeError("scale must be a number.") if not isinstance(diffusion_coefficient, Numeric): - raise TypeError('diffusion_coefficient must be a number.') + raise TypeError("diffusion_coefficient must be a number.") diffusion_coefficient = Parameter( - name='diffusion_coefficient', + name="diffusion_coefficient", value=float(diffusion_coefficient), fixed=False, - unit='m**2/s', + unit="m**2/s", min=0.0, ) super().__init__( + Q=Q, unit=unit, scale=scale, name=name, @@ -129,17 +135,17 @@ def diffusion_coefficient(self, diffusion_coefficient: Numeric) -> None: If diffusion_coefficient is negative. """ if not isinstance(diffusion_coefficient, Numeric): - raise TypeError('diffusion_coefficient must be a number.') + raise TypeError("diffusion_coefficient must be a number.") if float(diffusion_coefficient) < 0: - raise ValueError('diffusion_coefficient must be non-negative.') + raise ValueError("diffusion_coefficient must be non-negative.") self._diffusion_coefficient.value = float(diffusion_coefficient) # ------------------------------------------------------------------ # Other methods # ------------------------------------------------------------------ - def calculate_width(self, Q: Q_type) -> np.ndarray: + def calculate_width(self, Q: Q_type | None = None) -> np.ndarray: """ Calculate the half-width at half-maximum (HWHM) for the diffusion model. @@ -153,21 +159,28 @@ def calculate_width(self, Q: Q_type) -> np.ndarray: np.ndarray HWHM values in the unit of the model (e.g., meV). """ - + if Q is None: + Q = self.Q + if Q is None: + raise ValueError( + "Q must be provided either as an argument or set as a property of the model." + ) Q = _validate_and_convert_Q(Q) - unit_conversion_factor = self._hbar * self.diffusion_coefficient / (self._angstrom**2) + unit_conversion_factor = ( + self._hbar * self.diffusion_coefficient / (self._angstrom**2) + ) unit_conversion_factor.convert_unit(self.unit) return Q**2 * unit_conversion_factor.value - def calculate_EISF(self, Q: Q_type) -> np.ndarray: + def calculate_EISF(self, Q: Q_type | None = None) -> np.ndarray: """ Calculate the Elastic Incoherent Structure Factor (EISF) for the Brownian translational diffusion model. Parameters ---------- - Q : Q_type + Q : Q_type | None, default=None Scattering vector in 1/angstrom. Returns @@ -175,16 +188,22 @@ def calculate_EISF(self, Q: Q_type) -> np.ndarray: np.ndarray EISF values (dimensionless). """ + if Q is None: + Q = self.Q + if Q is None: + raise ValueError( + "Q must be provided either as an argument or set as a property of the model." + ) Q = _validate_and_convert_Q(Q) return np.zeros_like(Q) - def calculate_QISF(self, Q: Q_type) -> np.ndarray: + def calculate_QISF(self, Q: Q_type | None = None) -> np.ndarray: """ Calculate the Quasi-Elastic Incoherent Structure Factor (QISF). Parameters ---------- - Q : Q_type + Q : Q_type | None, default=None Scattering vector in 1/angstrom. Returns @@ -192,14 +211,18 @@ def calculate_QISF(self, Q: Q_type) -> np.ndarray: np.ndarray QISF values (dimensionless). """ - + if Q is None: + Q = self.Q + if Q is None: + raise ValueError( + "Q must be provided either as an argument or set as a property of the model." + ) Q = _validate_and_convert_Q(Q) return np.ones_like(Q) def create_component_collections( self, - Q: Q_type, - component_name: str = 'Brownian diffusion', + component_name: str = "Brownian diffusion", component_display_name: str | None = None, ) -> list[ComponentCollection]: r""" @@ -208,8 +231,6 @@ def create_component_collections( Parameters ---------- - Q : Q_type - Scattering vector values. component_name : str, default='Brownian diffusion' Name of the Brownian diffusion component. component_display_name : str | None, default=None @@ -227,16 +248,19 @@ def create_component_collections( Lorentzian has a width given by $D*Q^2$ and an area given by the scale parameter multiplied by the QISF (which is 1 for this model). """ - Q = _validate_and_convert_Q(Q) + Q = self.Q + if Q is None: + self._component_collections = [] + return self._component_collections if not isinstance(component_name, str): - raise TypeError('component_name must be a string.') + raise TypeError("component_name must be a string.") if component_display_name is None: component_display_name = component_name if not isinstance(component_display_name, str): - raise TypeError('component_display_name must be a string.') + raise TypeError("component_display_name must be a string.") component_collection_list = [None] * len(Q) # In more complex models, this is used to scale the area of the @@ -248,8 +272,8 @@ def create_component_collections( # No delta function, as the EISF is 0. for i, Q_value in enumerate(Q): component_collection_list[i] = ComponentCollection( - name=f'{self.name}_Q{Q_value:.2f}', - display_name=f'{self.display_name}_Q{Q_value:.2f}', + name=f"{self.name}_Q{Q_value:.2f}", + display_name=f"{self.display_name}_Q{Q_value:.2f}", unit=self.unit, ) @@ -305,10 +329,10 @@ def _write_width_dependency_expression(self, Q: float) -> str: Dependency expression for the width. """ if not isinstance(Q, (float)): - raise TypeError('Q must be a float.') + raise TypeError("Q must be a float.") # Q is given as a float, so we need to add the units - return f'hbar * D* {Q} **2*1/(angstrom**2)' + return f"hbar * D* {Q} **2*1/(angstrom**2)" def _write_width_dependency_map_expression(self) -> dict[str, DescriptorNumber]: """ @@ -320,9 +344,9 @@ def _write_width_dependency_map_expression(self) -> dict[str, DescriptorNumber]: Dependency map for the width. """ return { - 'D': self.diffusion_coefficient, - 'hbar': self._hbar, - 'angstrom': self._angstrom, + "D": self.diffusion_coefficient, + "hbar": self._hbar, + "angstrom": self._angstrom, } def _write_area_dependency_expression(self, QISF: float) -> str: @@ -345,9 +369,9 @@ def _write_area_dependency_expression(self, QISF: float) -> str: Dependency expression for the area. """ if not isinstance(QISF, (float)): - raise TypeError('QISF must be a float.') + raise TypeError("QISF must be a float.") - return f'{QISF} * scale' + return f"{QISF} * scale" def _write_area_dependency_map_expression(self) -> dict[str, DescriptorNumber]: """ @@ -359,7 +383,7 @@ def _write_area_dependency_map_expression(self) -> dict[str, DescriptorNumber]: Dependency map for the area. """ return { - 'scale': self.scale, + "scale": self.scale, } # ------------------------------------------------------------------ @@ -376,8 +400,8 @@ def __repr__(self) -> str: String representation of the BrownianTranslationalDiffusion model. """ return ( - f'BrownianTranslationalDiffusion(name={self.name}, ' - f'display_name={self.display_name}, \n' - f' diffusion_coefficient={self.diffusion_coefficient}, \n' - f' scale={self.scale})' + f"BrownianTranslationalDiffusion(name={self.name}, " + f"display_name={self.display_name}, \n" + f" diffusion_coefficient={self.diffusion_coefficient}, \n" + f" scale={self.scale})" ) diff --git a/src/easydynamics/sample_model/diffusion_model/delta_lorentz.py b/src/easydynamics/sample_model/diffusion_model/delta_lorentz.py index 333db739..b99e2d46 100644 --- a/src/easydynamics/sample_model/diffusion_model/delta_lorentz.py +++ b/src/easydynamics/sample_model/diffusion_model/delta_lorentz.py @@ -60,6 +60,7 @@ def __init__( A_0: Numeric = 1.0, lorentzian_width: Numeric = 1.0, allow_Q_variation: dict | None = None, + Q: Q_type | None = None, unit: str | sc.Unit = "meV", name: str = "DeltaLorentz", display_name: str | None = None, @@ -82,6 +83,8 @@ def __init__( Width of the Lorentzian function. allow_Q_variation : dict | None, default=None Dict describing whether to allow Q variation of A_0 and the Lorentzian width. The dict should have the keys "A_0" and "lorentzian_width", with boolean values indicating whether to allow Q-dependence for each parameter. If None, no Q-dependence will be allowed. + Q : Q_type | None, default=None + Q values for the model. If None, Q is not set. display_name : str | None, default="DeltaLorentz" Display name of the diffusion model. unique_name : str | None, default=None @@ -103,6 +106,7 @@ def __init__( super().__init__( scale=scale, unit=unit, + Q=Q, name=name, display_name=display_name, unique_name=unique_name, @@ -151,7 +155,6 @@ def __init__( max=1.0, ) self._A_0 = A_0 - self._A_0_list = [] A_1 = Parameter.from_dependency( name="A_1", @@ -159,7 +162,6 @@ def __init__( dependency_map={"A_0": A_0}, ) self._A_1 = A_1 - self._A_1_list = [] mean_u_squared = Parameter( name="mean_u_squared", @@ -178,7 +180,26 @@ def __init__( unit=unit, ) self._lorentzian_width = lorentzian_width - self._lorentzian_width_list = [] + + if self.Q is None: + self._A_0_list = [] + self._A_1_list = [] + self._lorentzian_width_list = [] + else: + if self._allow_Q_variation["A_0"] is True: + self._A_0_list, self._A_1_list = self._create_A0_A1_parameters( + A_0, self.Q + ) + else: + self._A_0_list = [] + self._A_1_list = [] + + if self._allow_Q_variation["lorentzian_width"] is True: + self._lorentzian_width_list = self._create_lorentzian_width_parameters( + lorentzian_width, self.Q + ) + else: + self._lorentzian_width_list = [] # ------------------------------------------------------------------ # Properties diff --git a/src/easydynamics/sample_model/diffusion_model/diffusion_model_base.py b/src/easydynamics/sample_model/diffusion_model/diffusion_model_base.py index 95732680..30022ea9 100644 --- a/src/easydynamics/sample_model/diffusion_model/diffusion_model_base.py +++ b/src/easydynamics/sample_model/diffusion_model/diffusion_model_base.py @@ -1,6 +1,7 @@ # SPDX-FileCopyrightText: 2026 EasyScience contributors # SPDX-License-Identifier: BSD-3-Clause +import numpy as np import scipp as sc from easyscience.variable import DescriptorNumber from easyscience.variable import Parameter @@ -8,6 +9,8 @@ from easydynamics.base_classes.easydynamics_modelbase import EasyDynamicsModelBase from easydynamics.utils.utils import Numeric +from easydynamics.utils.utils import Q_type +from easydynamics.utils.utils import _validate_and_convert_Q class DiffusionModelBase(EasyDynamicsModelBase): @@ -16,9 +19,10 @@ class DiffusionModelBase(EasyDynamicsModelBase): def __init__( self, scale: Numeric = 1.0, - unit: str | sc.Unit = 'meV', - name: str = 'DiffusionModel', - display_name: str | None = 'MyDiffusionModel', + Q: Q_type | None = None, + unit: str | sc.Unit = "meV", + name: str = "DiffusionModel", + display_name: str | None = "MyDiffusionModel", unique_name: str | None = None, ) -> None: """ @@ -28,6 +32,8 @@ def __init__( ---------- scale : Numeric, default=1.0 Scale factor for the diffusion model. Must be a non-negative number. + Q : Q_type | None, default=None + Q values for the model. If None, Q is not set. unit : str | sc.Unit, default='meV' Unit of the diffusion model. Must be convertible to meV. name : str, default='DiffusionModel' @@ -46,20 +52,26 @@ def __init__( If unit is not a string or scipp Unit, or if it cannot be converted to meV. """ + self._Q = _validate_and_convert_Q(Q) + try: - test = DescriptorNumber(name='test', value=1, unit=unit) - test.convert_unit('meV') + test = DescriptorNumber(name="test", value=1, unit=unit) + test.convert_unit("meV") except Exception as e: raise UnitError( - f'Invalid unit: {unit}. Unit must be a string or scipp Unit and convertible to meV.' # noqa: E501 + f"Invalid unit: {unit}. Unit must be a string or scipp Unit and convertible to meV." # noqa: E501 ) from e if not isinstance(scale, Numeric): - raise TypeError('scale must be a number.') + raise TypeError("scale must be a number.") - scale = Parameter(name='scale', value=float(scale), fixed=False, min=0.0, unit=unit) + scale = Parameter( + name="scale", value=float(scale), fixed=False, min=0.0, unit=unit + ) - super().__init__(unit=unit, name=name, display_name=display_name, unique_name=unique_name) + super().__init__( + unit=unit, name=name, display_name=display_name, unique_name=unique_name + ) self._scale = scale # ------------------------------------------------------------------ @@ -96,12 +108,80 @@ def scale(self, scale: Numeric) -> None: If scale is negative. """ if not isinstance(scale, Numeric): - raise TypeError('scale must be a number.') + raise TypeError("scale must be a number.") if float(scale) < 0: - raise ValueError('scale must be non-negative.') + raise ValueError("scale must be non-negative.") self._scale.value = float(scale) + @property + def Q(self) -> np.ndarray | None: + """ + Get the Q values of the SampleModel. + + Returns + ------- + np.ndarray | None + The Q values of the SampleModel, or None if not set. + """ + return self._Q + + @Q.setter + def Q(self, value: Q_type | None) -> None: + """ + Set the Q values of the SampleModel. + + If Q is already set, it throws an error if the new Q values are not similar to the old + ones. To change Q values, first run clear_Q(). + + Parameters + ---------- + value : Q_type | None + The new Q values to set. If None, Q values are not changed. + + Raises + ------ + ValueError + If the new Q values are not similar to the old ones when Q is already set. + """ + if value is None: + return + old_Q = self._Q + new_Q = _validate_and_convert_Q(value) + + if old_Q is None: + self._Q = new_Q + self._on_Q_change() + return + + if len(old_Q) != len(new_Q) or not np.allclose(old_Q, new_Q): + raise ValueError( + "New Q values are not similar to the old ones. " + "To change Q values, first run clear_Q()." + ) + + def clear_Q(self, confirm: bool = False) -> None: + """ + Clear the Q values of the SampleModel, removing all component collections and their + associated Parameters. + + Parameters + ---------- + confirm : bool, default=False + Confirmation to clear Q values. + + Raises + ------ + ValueError + If confirm is not True. + """ + if not confirm: + raise ValueError( + "Clearing Q values requires confirmation. Set confirm=True to proceed." + ) + self._Q = None + self._on_Q_change() + # ------------------------------------------------------------------ # dunder methods # ------------------------------------------------------------------ @@ -116,7 +196,7 @@ def __repr__(self) -> str: String representation of the DiffusionModel. """ return ( - f'{self.__class__.__name__}(name={self.name}, display_name={self.display_name}, ' - f'unit={self.unit}), \n' - f' scale={self.scale})' + f"{self.__class__.__name__}(name={self.name}, display_name={self.display_name}, " + f"unit={self.unit}), \n" + f" scale={self.scale})" ) diff --git a/src/easydynamics/sample_model/diffusion_model/jump_translational_diffusion.py b/src/easydynamics/sample_model/diffusion_model/jump_translational_diffusion.py index 0d4b4441..b543261c 100644 --- a/src/easydynamics/sample_model/diffusion_model/jump_translational_diffusion.py +++ b/src/easydynamics/sample_model/diffusion_model/jump_translational_diffusion.py @@ -9,7 +9,9 @@ from easydynamics.sample_model.component_collection import ComponentCollection from easydynamics.sample_model.components import Lorentzian -from easydynamics.sample_model.diffusion_model.diffusion_model_base import DiffusionModelBase +from easydynamics.sample_model.diffusion_model.diffusion_model_base import ( + DiffusionModelBase, +) from easydynamics.utils.utils import Numeric from easydynamics.utils.utils import Q_type from easydynamics.utils.utils import _validate_and_convert_Q @@ -51,9 +53,10 @@ def __init__( scale: Numeric = 1.0, diffusion_coefficient: Numeric = 1.0, relaxation_time: Numeric = 1.0, - unit: str | sc.Unit = 'meV', - name: str = 'JumpTranslationalDiffusion', - display_name: str | None = 'JumpTranslationalDiffusion', + Q: Q_type | None = None, + unit: str | sc.Unit = "meV", + name: str = "JumpTranslationalDiffusion", + display_name: str | None = "JumpTranslationalDiffusion", unique_name: str | None = None, ) -> None: """ @@ -67,6 +70,8 @@ def __init__( Diffusion coefficient D in m^2/s. relaxation_time : Numeric, default=1.0 Relaxation time t in ps. + Q : Q_type | None, default=None + Q values for the model. If None, Q is not set. unit : str | sc.Unit, default='meV' Unit of the diffusion model. Must be convertible to meV. name : str, default='JumpTranslationalDiffusion' @@ -83,31 +88,32 @@ def __init__( If scale, diffusion_coefficient, or relaxation_time are not numbers. """ super().__init__( + Q=Q, + unit=unit, + scale=scale, name=name, display_name=display_name, unique_name=unique_name, - unit=unit, - scale=scale, ) if not isinstance(diffusion_coefficient, Numeric): - raise TypeError('diffusion_coefficient must be a number.') + raise TypeError("diffusion_coefficient must be a number.") if not isinstance(relaxation_time, Numeric): - raise TypeError('relaxation_time must be a number.') + raise TypeError("relaxation_time must be a number.") diffusion_coefficient = Parameter( - name='diffusion_coefficient', + name="diffusion_coefficient", value=float(diffusion_coefficient), fixed=False, - unit='m**2/s', + unit="m**2/s", ) relaxation_time = Parameter( - name='relaxation_time', + name="relaxation_time", value=float(relaxation_time), fixed=False, - unit='ps', + unit="ps", ) self._hbar = hbar @@ -149,9 +155,9 @@ def diffusion_coefficient(self, diffusion_coefficient: Numeric) -> None: If diffusion_coefficient is negative. """ if not isinstance(diffusion_coefficient, Numeric): - raise TypeError('diffusion_coefficient must be a number.') + raise TypeError("diffusion_coefficient must be a number.") if float(diffusion_coefficient) < 0: - raise ValueError('diffusion_coefficient must be non-negative.') + raise ValueError("diffusion_coefficient must be non-negative.") self._diffusion_coefficient.value = float(diffusion_coefficient) @property @@ -184,32 +190,39 @@ def relaxation_time(self, relaxation_time: Numeric) -> None: If relaxation_time is negative. """ if not isinstance(relaxation_time, Numeric): - raise TypeError('relaxation_time must be a number.') + raise TypeError("relaxation_time must be a number.") if float(relaxation_time) < 0: - raise ValueError('relaxation_time must be non-negative.') + raise ValueError("relaxation_time must be non-negative.") self._relaxation_time.value = float(relaxation_time) ################################ # Other methods ################################ - def calculate_width(self, Q: Q_type) -> np.ndarray: + def calculate_width(self, Q: Q_type | None = None) -> np.ndarray: r""" Calculate the half-width at half-maximum (HWHM) for the diffusion model. $\Gamma(Q) = Q^2/(1+D t Q^2)$, where $D$ is the diffusion coefficient and $t$ is the relaxation time. Parameters ---------- - Q : Q_type - Scattering vector in 1/angstrom. Can be a single value or an array of values. + Q : Q_type | None, default=None + Scattering vector in 1/angstrom. Can be a single value or an array of values. If None, Q values stored in the model are used. Returns ------- np.ndarray HWHM values in the unit of the model (e.g., meV). """ + if Q is None: + Q = self.Q + if Q is None: + raise ValueError( + "Q values must be provided either during initialization or as an argument to " + "calculate_width." + ) Q = _validate_and_convert_Q(Q) unit_conversion_factor_numerator = ( @@ -222,7 +235,7 @@ def calculate_width(self, Q: Q_type) -> np.ndarray: unit_conversion_factor_denominator = ( self.diffusion_coefficient / self._angstrom**2 * self.relaxation_time ) - unit_conversion_factor_denominator.convert_unit('dimensionless') + unit_conversion_factor_denominator.convert_unit("dimensionless") denominator = 1 + unit_conversion_factor_denominator.value * Q**2 @@ -242,6 +255,13 @@ def calculate_EISF(self, Q: Q_type) -> np.ndarray: np.ndarray EISF values (dimensionless). """ + if Q is None: + Q = self.Q + if Q is None: + raise ValueError( + "Q values must be provided either during initialization or as an argument to " + "calculate_EISF." + ) Q = _validate_and_convert_Q(Q) return np.zeros_like(Q) @@ -259,22 +279,26 @@ def calculate_QISF(self, Q: Q_type) -> np.ndarray: np.ndarray QISF values (dimensionless). """ + if Q is None: + Q = self.Q + if Q is None: + raise ValueError( + "Q values must be provided either during initialization or as an argument to " + "calculate_QISF." + ) Q = _validate_and_convert_Q(Q) return np.ones_like(Q) def create_component_collections( self, - Q: Q_type, - component_name: str = 'Jump translational diffusion', - component_display_name: str = 'Jump translational diffusion', + component_name: str = "Jump translational diffusion", + component_display_name: str = "Jump translational diffusion", ) -> list[ComponentCollection]: """ Create ComponentCollection components for the diffusion model at given Q values. Parameters ---------- - Q : Q_type - Scattering vector in 1/angstrom. Can be a single value or an array of values. component_name : str, default='Jump translational diffusion' Name of the Jump Diffusion Lorentzian component. component_display_name : str, default='Jump translational diffusion' @@ -290,13 +314,18 @@ def create_component_collections( list[ComponentCollection] List of ComponentCollections with Jump Diffusion Lorentzian components. """ - Q = _validate_and_convert_Q(Q) + Q = self.Q + if Q is None: + raise ValueError( + "Q values must be set in the model to create component collections. Set Q values " + "during initialization or using the Q property." + ) if not isinstance(component_display_name, str): - raise TypeError('component_display_name must be a string.') + raise TypeError("component_display_name must be a string.") if not isinstance(component_name, str): - raise TypeError('component_name must be a string.') + raise TypeError("component_name must be a string.") component_collection_list = [None] * len(Q) # In more complex models, this is used to scale the area of the @@ -308,8 +337,8 @@ def create_component_collections( # is 0. for i, Q_value in enumerate(Q): component_collection_list[i] = ComponentCollection( - name=f'{self.name}_Q{Q_value:.2f}', - display_name=f'{self.display_name}_Q{Q_value:.2f}', + name=f"{self.name}_Q{Q_value:.2f}", + display_name=f"{self.display_name}_Q{Q_value:.2f}", unit=self.unit, ) @@ -365,10 +394,10 @@ def _write_width_dependency_expression(self, Q: float) -> str: Dependency expression for the width. """ if not isinstance(Q, (float)): - raise TypeError('Q must be a float.') + raise TypeError("Q must be a float.") # Q is given as a float, so we need to add the units - return f'hbar * D* {Q} **2/(angstrom**2)/(1 + (D * t* {Q} **2/(angstrom**2)))' + return f"hbar * D* {Q} **2/(angstrom**2)/(1 + (D * t* {Q} **2/(angstrom**2)))" def _write_width_dependency_map_expression(self) -> dict[str, DescriptorNumber]: """ @@ -380,10 +409,10 @@ def _write_width_dependency_map_expression(self) -> dict[str, DescriptorNumber]: Dependency map for the width. """ return { - 'D': self._diffusion_coefficient, - 't': self._relaxation_time, - 'hbar': self._hbar, - 'angstrom': self._angstrom, + "D": self.diffusion_coefficient, + "t": self.relaxation_time, + "hbar": self._hbar, + "angstrom": self._angstrom, } def _write_area_dependency_expression(self, QISF: float) -> str: @@ -407,9 +436,9 @@ def _write_area_dependency_expression(self, QISF: float) -> str: """ if not isinstance(QISF, (float)): - raise TypeError('QISF must be a float.') + raise TypeError("QISF must be a float.") - return f'{QISF} * scale' + return f"{QISF} * scale" def _write_area_dependency_map_expression(self) -> dict[str, DescriptorNumber]: """ @@ -421,7 +450,7 @@ def _write_area_dependency_map_expression(self) -> dict[str, DescriptorNumber]: Dependency map for the area. """ return { - 'scale': self._scale, + "scale": self.scale, } ################################ @@ -438,7 +467,7 @@ def __repr__(self) -> str: String representation of the JumpTranslationalDiffusion model. """ return ( - f'JumpTranslationalDiffusion(name={self.name}, display_name={self.display_name},\n ' - f' diffusion_coefficient={self._diffusion_coefficient}, \n' - f' scale={self._scale})' + f"JumpTranslationalDiffusion(name={self.name}, display_name={self.display_name},\n " + f" diffusion_coefficient={self.diffusion_coefficient}, \n" + f" scale={self.scale})" ) diff --git a/src/easydynamics/sample_model/model_base.py b/src/easydynamics/sample_model/model_base.py index 059998d1..7072d2d2 100644 --- a/src/easydynamics/sample_model/model_base.py +++ b/src/easydynamics/sample_model/model_base.py @@ -383,12 +383,12 @@ def normalize_area(self) -> None: def _generate_component_collections(self) -> None: """Generate ComponentCollections for each Q value.""" - if self._Q is None: + if self.Q is None: self._component_collections = [] return self._component_collections = [] - for _ in self._Q: + for _ in self.Q: self._component_collections.append(copy(self._components)) def _on_Q_change(self) -> None: From 1eb7bbcc1f99fd85448cd315ce6cb15867ebcee2 Mon Sep 17 00:00:00 2001 From: henrikjacobsenfys Date: Wed, 20 May 2026 12:30:56 +0200 Subject: [PATCH 07/18] pixi run fix --- docs/docs/tutorials/DeltaLorentz.ipynb | 9 +- .../sample_model/component_collection.py | 70 +++--- .../brownian_translational_diffusion.py | 76 +++---- .../diffusion_model/delta_lorentz.py | 205 +++++++++--------- .../diffusion_model/diffusion_model_base.py | 44 ++-- .../jump_translational_diffusion.py | 97 ++++----- src/easydynamics/sample_model/model_base.py | 48 ++-- src/easydynamics/sample_model/sample_model.py | 79 +++---- 8 files changed, 295 insertions(+), 333 deletions(-) diff --git a/docs/docs/tutorials/DeltaLorentz.ipynb b/docs/docs/tutorials/DeltaLorentz.ipynb index 7d21229a..1183e75c 100644 --- a/docs/docs/tutorials/DeltaLorentz.ipynb +++ b/docs/docs/tutorials/DeltaLorentz.ipynb @@ -39,8 +39,11 @@ "lorentzian_width = 0.2\n", "\n", "diffusion_model = DeltaLorentz(\n", - " scale=scale, mean_u_squared=mean_u_squared, A_0=A_0, lorentzian_width=lorentzian_width,\n", - " allow_Q_variation={\"A_0\": True}\n", + " scale=scale,\n", + " mean_u_squared=mean_u_squared,\n", + " A_0=A_0,\n", + " lorentzian_width=lorentzian_width,\n", + " allow_Q_variation={'A_0': True},\n", ")" ] }, @@ -113,10 +116,8 @@ "metadata": {}, "outputs": [], "source": [ - "\n", "from easydynamics.sample_model.sample_model import SampleModel\n", "\n", - "\n", "sample_model = SampleModel(diffusion_models=[diffusion_model], Q=Q)" ] }, diff --git a/src/easydynamics/sample_model/component_collection.py b/src/easydynamics/sample_model/component_collection.py index 1ba3c975..081f06bb 100644 --- a/src/easydynamics/sample_model/component_collection.py +++ b/src/easydynamics/sample_model/component_collection.py @@ -52,8 +52,8 @@ class ComponentCollection(EasyDynamicsList, EasyDynamicsModelBase): def __init__( self, components: ModelComponent | list[ModelComponent] | None = None, - unit: str | sc.Unit = "meV", - name: str = "ComponentCollection", + unit: str | sc.Unit = 'meV', + name: str = 'ComponentCollection', display_name: str | None = None, unique_name: str | None = None, ) -> None: @@ -64,9 +64,9 @@ def __init__( ---------- components : ModelComponent | list[ModelComponent] | None, default=None Initial model components to add to the ComponentCollection. - unit : str | sc.Unit, default='meV' + unit : str | sc.Unit, default="meV" Unit of the collection. - name : str, default='ComponentCollection' + name : str, default="ComponentCollection" Name of the collection. display_name : str | None, default=None Display name of the collection. @@ -84,12 +84,12 @@ def __init__( components = [components] elif not isinstance(components, list): raise TypeError( - f"components must be a ModelComponent or a list of ModelComponent, got {type(components).__name__} instead." # noqa: E501 + f'components must be a ModelComponent or a list of ModelComponent, got {type(components).__name__} instead.' # noqa: E501 ) for comp in components: if not isinstance(comp, ModelComponent): raise TypeError( - f"All items in components must be instances of ModelComponent, got {type(comp).__name__} instead." # noqa: E501 + f'All items in components must be instances of ModelComponent, got {type(comp).__name__} instead.' # noqa: E501 ) EasyDynamicsList.__init__( @@ -138,8 +138,8 @@ def is_empty(self, _value: bool) -> None: Always raised since is_empty is read-only. """ raise AttributeError( - "is_empty is a read-only property that indicates " - "whether the collection has components." + 'is_empty is a read-only property that indicates ' + 'whether the collection has components.' ) def convert_unit(self, unit: str | sc.Unit) -> None: @@ -160,9 +160,7 @@ def convert_unit(self, unit: str | sc.Unit) -> None: """ if not isinstance(unit, (str, sc.Unit)): - raise TypeError( - f"Unit must be a string or sc.Unit, got {type(unit).__name__}" - ) + raise TypeError(f'Unit must be a string or sc.Unit, got {type(unit).__name__}') old_unit = self._unit @@ -224,28 +222,28 @@ def normalize_area(self) -> None: which would prevent normalization. """ if not self: - raise ValueError("No components in the model to normalize.") + raise ValueError('No components in the model to normalize.') area_params = [] - total_area = Parameter(name="total_area", value=0.0, unit=self._unit) + total_area = Parameter(name='total_area', value=0.0, unit=self._unit) for component in self: - if hasattr(component, "area"): + if hasattr(component, 'area'): area_params.append(component.area) total_area += component.area else: warnings.warn( f"Component '{component.name}' does not have an 'area' attribute " - f"and will be skipped in normalization.", + f'and will be skipped in normalization.', UserWarning, stacklevel=2, ) if total_area.value == 0: - raise ValueError("Total area is zero; cannot normalize.") + raise ValueError('Total area is zero; cannot normalize.') if not np.isfinite(total_area.value): - raise ValueError("Total area is not finite; cannot normalize.") + raise ValueError('Total area is not finite; cannot normalize.') for param in area_params: param.value /= total_area.value @@ -266,9 +264,7 @@ def get_all_variables(self) -> list[DescriptorBase]: return [var for component in self for var in component.get_all_variables()] - def evaluate( - self, x: Numeric | list | np.ndarray | sc.Variable | sc.DataArray - ) -> np.ndarray: + def evaluate(self, x: Numeric | list | np.ndarray | sc.Variable | sc.DataArray) -> np.ndarray: """ Evaluate the sum of all components. @@ -317,12 +313,10 @@ def evaluate_component( Evaluated values for the specified component. """ if not self: - raise ValueError("No components in the model to evaluate.") + raise ValueError('No components in the model to evaluate.') if not isinstance(name, str): - raise TypeError( - f"Component name must be a string, got {type(name)} instead." - ) + raise TypeError(f'Component name must be a string, got {type(name)} instead.') matches = [comp for comp in self if comp.name == name] if not matches: @@ -355,21 +349,21 @@ def __repr__(self) -> str: str String representation of the ComponentCollection. """ - comp_names = ", ".join(c.name for c in self) or "No components" + comp_names = ', '.join(c.name for c in self) or 'No components' return ( f"ComponentCollection(name='{self.name}', unit='{self.unit}', \n" - f"Components: {comp_names})" + f'Components: {comp_names})' ) def to_dict(self) -> dict: return { - "@module": self.__class__.__module__, - "@class": self.__class__.__name__, - "unit": str(self.unit), - "name": self.name, - "display_name": self.display_name, - "components": [c.to_dict() for c in self._data], + '@module': self.__class__.__module__, + '@class': self.__class__.__name__, + 'unit': str(self.unit), + 'name': self.name, + 'display_name': self.display_name, + 'components': [c.to_dict() for c in self._data], } @classmethod @@ -387,17 +381,17 @@ def deserialise_component(d: dict) -> ModelComponent: ModelComponent The deserialised component. """ - module = importlib.import_module(d["@module"]) - cls = getattr(module, d["@class"]) + module = importlib.import_module(d['@module']) + cls = getattr(module, d['@class']) return cls.from_dict(d) - components = [deserialise_component(c) for c in obj_dict.get("components", [])] + components = [deserialise_component(c) for c in obj_dict.get('components', [])] return cls( components=components, - unit=obj_dict.get("unit", "meV"), - name=obj_dict.get("name", "ComponentCollection"), - display_name=obj_dict.get("display_name"), + unit=obj_dict.get('unit', 'meV'), + name=obj_dict.get('name', 'ComponentCollection'), + display_name=obj_dict.get('display_name'), ) def __copy__(self) -> ComponentCollection: diff --git a/src/easydynamics/sample_model/diffusion_model/brownian_translational_diffusion.py b/src/easydynamics/sample_model/diffusion_model/brownian_translational_diffusion.py index c6584343..0376b159 100644 --- a/src/easydynamics/sample_model/diffusion_model/brownian_translational_diffusion.py +++ b/src/easydynamics/sample_model/diffusion_model/brownian_translational_diffusion.py @@ -9,9 +9,7 @@ from easydynamics.sample_model.component_collection import ComponentCollection from easydynamics.sample_model.components import Lorentzian -from easydynamics.sample_model.diffusion_model.diffusion_model_base import ( - DiffusionModelBase, -) +from easydynamics.sample_model.diffusion_model.diffusion_model_base import DiffusionModelBase from easydynamics.utils.utils import Numeric from easydynamics.utils.utils import Q_type from easydynamics.utils.utils import _validate_and_convert_Q @@ -45,9 +43,9 @@ def __init__( scale: Numeric = 1.0, diffusion_coefficient: Numeric = 1.0, Q: Q_type | None = None, - unit: str | sc.Unit = "meV", - name: str = "BrownianTranslationalDiffusion", - display_name: str | None = "BrownianTranslationalDiffusion", + unit: str | sc.Unit = 'meV', + name: str = 'BrownianTranslationalDiffusion', + display_name: str | None = 'BrownianTranslationalDiffusion', unique_name: str | None = None, ) -> None: """ @@ -61,11 +59,11 @@ def __init__( Diffusion coefficient D in m^2/s. Q : Q_type | None, default=None Q values for the model. If None, Q is not set. - unit : str | sc.Unit, default='meV' + unit : str | sc.Unit, default="meV" Unit of the diffusion model. Must be convertible to meV. - name : str, default='BrownianTranslationalDiffusion' + name : str, default="BrownianTranslationalDiffusion" Name of the diffusion model. - display_name : str | None, default='BrownianTranslationalDiffusion' + display_name : str | None, default="BrownianTranslationalDiffusion" Display name of the diffusion model. unique_name : str | None, default=None Unique name of the diffusion model. If None, a unique name will be generated. By @@ -77,16 +75,16 @@ def __init__( If scale or diffusion_coefficient is not a number. """ if not isinstance(scale, Numeric): - raise TypeError("scale must be a number.") + raise TypeError('scale must be a number.') if not isinstance(diffusion_coefficient, Numeric): - raise TypeError("diffusion_coefficient must be a number.") + raise TypeError('diffusion_coefficient must be a number.') diffusion_coefficient = Parameter( - name="diffusion_coefficient", + name='diffusion_coefficient', value=float(diffusion_coefficient), fixed=False, - unit="m**2/s", + unit='m**2/s', min=0.0, ) super().__init__( @@ -135,10 +133,10 @@ def diffusion_coefficient(self, diffusion_coefficient: Numeric) -> None: If diffusion_coefficient is negative. """ if not isinstance(diffusion_coefficient, Numeric): - raise TypeError("diffusion_coefficient must be a number.") + raise TypeError('diffusion_coefficient must be a number.') if float(diffusion_coefficient) < 0: - raise ValueError("diffusion_coefficient must be non-negative.") + raise ValueError('diffusion_coefficient must be non-negative.') self._diffusion_coefficient.value = float(diffusion_coefficient) # ------------------------------------------------------------------ @@ -151,7 +149,7 @@ def calculate_width(self, Q: Q_type | None = None) -> np.ndarray: Parameters ---------- - Q : Q_type + Q : Q_type | None, default=None Scattering vector in 1/angstrom. Returns @@ -163,13 +161,11 @@ def calculate_width(self, Q: Q_type | None = None) -> np.ndarray: Q = self.Q if Q is None: raise ValueError( - "Q must be provided either as an argument or set as a property of the model." + 'Q must be provided either as an argument or set as a property of the model.' ) Q = _validate_and_convert_Q(Q) - unit_conversion_factor = ( - self._hbar * self.diffusion_coefficient / (self._angstrom**2) - ) + unit_conversion_factor = self._hbar * self.diffusion_coefficient / (self._angstrom**2) unit_conversion_factor.convert_unit(self.unit) return Q**2 * unit_conversion_factor.value @@ -192,7 +188,7 @@ def calculate_EISF(self, Q: Q_type | None = None) -> np.ndarray: Q = self.Q if Q is None: raise ValueError( - "Q must be provided either as an argument or set as a property of the model." + 'Q must be provided either as an argument or set as a property of the model.' ) Q = _validate_and_convert_Q(Q) return np.zeros_like(Q) @@ -215,14 +211,14 @@ def calculate_QISF(self, Q: Q_type | None = None) -> np.ndarray: Q = self.Q if Q is None: raise ValueError( - "Q must be provided either as an argument or set as a property of the model." + 'Q must be provided either as an argument or set as a property of the model.' ) Q = _validate_and_convert_Q(Q) return np.ones_like(Q) def create_component_collections( self, - component_name: str = "Brownian diffusion", + component_name: str = 'Brownian diffusion', component_display_name: str | None = None, ) -> list[ComponentCollection]: r""" @@ -231,7 +227,7 @@ def create_component_collections( Parameters ---------- - component_name : str, default='Brownian diffusion' + component_name : str, default="Brownian diffusion" Name of the Brownian diffusion component. component_display_name : str | None, default=None Display name of the Brownian diffusion component. @@ -254,13 +250,13 @@ def create_component_collections( return self._component_collections if not isinstance(component_name, str): - raise TypeError("component_name must be a string.") + raise TypeError('component_name must be a string.') if component_display_name is None: component_display_name = component_name if not isinstance(component_display_name, str): - raise TypeError("component_display_name must be a string.") + raise TypeError('component_display_name must be a string.') component_collection_list = [None] * len(Q) # In more complex models, this is used to scale the area of the @@ -272,8 +268,8 @@ def create_component_collections( # No delta function, as the EISF is 0. for i, Q_value in enumerate(Q): component_collection_list[i] = ComponentCollection( - name=f"{self.name}_Q{Q_value:.2f}", - display_name=f"{self.display_name}_Q{Q_value:.2f}", + name=f'{self.name}_Q{Q_value:.2f}', + display_name=f'{self.display_name}_Q{Q_value:.2f}', unit=self.unit, ) @@ -329,10 +325,10 @@ def _write_width_dependency_expression(self, Q: float) -> str: Dependency expression for the width. """ if not isinstance(Q, (float)): - raise TypeError("Q must be a float.") + raise TypeError('Q must be a float.') # Q is given as a float, so we need to add the units - return f"hbar * D* {Q} **2*1/(angstrom**2)" + return f'hbar * D* {Q} **2*1/(angstrom**2)' def _write_width_dependency_map_expression(self) -> dict[str, DescriptorNumber]: """ @@ -344,9 +340,9 @@ def _write_width_dependency_map_expression(self) -> dict[str, DescriptorNumber]: Dependency map for the width. """ return { - "D": self.diffusion_coefficient, - "hbar": self._hbar, - "angstrom": self._angstrom, + 'D': self.diffusion_coefficient, + 'hbar': self._hbar, + 'angstrom': self._angstrom, } def _write_area_dependency_expression(self, QISF: float) -> str: @@ -369,9 +365,9 @@ def _write_area_dependency_expression(self, QISF: float) -> str: Dependency expression for the area. """ if not isinstance(QISF, (float)): - raise TypeError("QISF must be a float.") + raise TypeError('QISF must be a float.') - return f"{QISF} * scale" + return f'{QISF} * scale' def _write_area_dependency_map_expression(self) -> dict[str, DescriptorNumber]: """ @@ -383,7 +379,7 @@ def _write_area_dependency_map_expression(self) -> dict[str, DescriptorNumber]: Dependency map for the area. """ return { - "scale": self.scale, + 'scale': self.scale, } # ------------------------------------------------------------------ @@ -400,8 +396,8 @@ def __repr__(self) -> str: String representation of the BrownianTranslationalDiffusion model. """ return ( - f"BrownianTranslationalDiffusion(name={self.name}, " - f"display_name={self.display_name}, \n" - f" diffusion_coefficient={self.diffusion_coefficient}, \n" - f" scale={self.scale})" + f'BrownianTranslationalDiffusion(name={self.name}, ' + f'display_name={self.display_name}, \n' + f' diffusion_coefficient={self.diffusion_coefficient}, \n' + f' scale={self.scale})' ) diff --git a/src/easydynamics/sample_model/diffusion_model/delta_lorentz.py b/src/easydynamics/sample_model/diffusion_model/delta_lorentz.py index b99e2d46..06447c2b 100644 --- a/src/easydynamics/sample_model/diffusion_model/delta_lorentz.py +++ b/src/easydynamics/sample_model/diffusion_model/delta_lorentz.py @@ -10,9 +10,7 @@ from easydynamics.sample_model.component_collection import ComponentCollection from easydynamics.sample_model.components import DeltaFunction from easydynamics.sample_model.components import Lorentzian -from easydynamics.sample_model.diffusion_model.diffusion_model_base import ( - DiffusionModelBase, -) +from easydynamics.sample_model.diffusion_model.diffusion_model_base import DiffusionModelBase from easydynamics.utils.utils import Numeric from easydynamics.utils.utils import Q_type from easydynamics.utils.utils import _validate_and_convert_Q @@ -27,9 +25,10 @@ class DeltaLorentz(DiffusionModelBase): $$, where $K$ is the scale factor, $\langle u^2 \rangle$ is the mean square displacement, $Q$ is - the scattering vector, $A_0$ and $A_1$ are the relative amplitudes of the delta function and Lorentzian, - respectively, with the constraint that $A_0+A_1=1$, and $L(E, \Gamma)$ is the Lorentzian function with width $\Gamma$. - $A_0$, $A_1$ and the width of the Lorentzian can be Q-dependent or not. + the scattering vector, $A_0$ and $A_1$ are the relative amplitudes of the delta function and + Lorentzian, respectively, with the constraint that $A_0+A_1=1$, and $L(E, \Gamma)$ is the + Lorentzian function with width $\Gamma$. $A_0$, $A_1$ and the width of the Lorentzian can be + Q-dependent or not. Examples @@ -46,7 +45,7 @@ class DeltaLorentz(DiffusionModelBase): ... mean_u_squared=mean_u_squared, ... A_0=A_0, ... lorentzian_width=lorentzian_width, - ... allow_Q_variation={"A_0": True, "lorentzian_width": True}, + ... allow_Q_variation={'A_0': True, 'lorentzian_width': True}, ... ) >>> component_collections = model.create_component_collections(Q) @@ -61,8 +60,8 @@ def __init__( lorentzian_width: Numeric = 1.0, allow_Q_variation: dict | None = None, Q: Q_type | None = None, - unit: str | sc.Unit = "meV", - name: str = "DeltaLorentz", + unit: str | sc.Unit = 'meV', + name: str = 'DeltaLorentz', display_name: str | None = None, unique_name: str | None = None, ) -> None: @@ -82,10 +81,13 @@ def __init__( lorentzian_width : Numeric, default=1.0 Width of the Lorentzian function. allow_Q_variation : dict | None, default=None - Dict describing whether to allow Q variation of A_0 and the Lorentzian width. The dict should have the keys "A_0" and "lorentzian_width", with boolean values indicating whether to allow Q-dependence for each parameter. If None, no Q-dependence will be allowed. + Dict describing whether to allow Q variation of A_0 and the Lorentzian width. The dict + should have the keys "A_0" and "lorentzian_width", with boolean values indicating + whether to allow Q-dependence for each parameter. If None, no Q-dependence will be + allowed. Q : Q_type | None, default=None Q values for the model. If None, Q is not set. - display_name : str | None, default="DeltaLorentz" + display_name : str | None, default=None Display name of the diffusion model. unique_name : str | None, default=None Unique name of the diffusion model. If None, a unique name will be generated. By @@ -94,14 +96,13 @@ def __init__( Raises ------ TypeError - If mean_u_squared, A_0, or lorentzian_width is not a number. - If allow_Q_variation is not a dict or None. + If mean_u_squared, A_0, or lorentzian_width is not a number. If allow_Q_variation is + not a dict or None. ValueError - If A_0 is not between 0 and 1, or if lorentzian_width is less than the minimum allowed width. - If mean_u_squared is negative, or if allow_Q_variation contains unknown keys. - + If A_0 is not between 0 and 1, or if lorentzian_width is less than the minimum allowed + width. If mean_u_squared is negative, or if allow_Q_variation contains unknown keys. """ super().__init__( scale=scale, @@ -113,42 +114,42 @@ def __init__( ) if not isinstance(mean_u_squared, Numeric): - raise TypeError("mean_u_squared must be a number.") + raise TypeError('mean_u_squared must be a number.') if float(mean_u_squared) < 0: - raise ValueError("mean_u_squared must be non-negative.") + raise ValueError('mean_u_squared must be non-negative.') if not isinstance(A_0, Numeric): - raise TypeError("A_0 must be a number.") + raise TypeError('A_0 must be a number.') if float(A_0) < 0 or float(A_0) > 1: - raise ValueError("A_0 must be between 0 and 1.") + raise ValueError('A_0 must be between 0 and 1.') if not isinstance(lorentzian_width, Numeric): - raise TypeError("lorentzian_width must be a number.") + raise TypeError('lorentzian_width must be a number.') if float(lorentzian_width) < MINIMUM_WIDTH: - raise ValueError(f"lorentzian_width must be at least {MINIMUM_WIDTH}.") + raise ValueError(f'lorentzian_width must be at least {MINIMUM_WIDTH}.') allow_Q_variation_default = { - "A_0": False, - "lorentzian_width": False, + 'A_0': False, + 'lorentzian_width': False, } allowed_keys = set(allow_Q_variation_default) if allow_Q_variation is None: allow_Q_variation = {} if not isinstance(allow_Q_variation, dict): - raise TypeError("allow_Q_variation must be a dict or None.") + raise TypeError('allow_Q_variation must be a dict or None.') unknown_keys = set(allow_Q_variation) - allowed_keys if unknown_keys: - raise ValueError(f"Unknown keys in allow_Q_variation: {unknown_keys}") + raise ValueError(f'Unknown keys in allow_Q_variation: {unknown_keys}') self._allow_Q_variation = {**allow_Q_variation_default, **allow_Q_variation} A_0 = Parameter( - name="A_0", + name='A_0', value=float(A_0), fixed=False, min=0.0, @@ -157,23 +158,23 @@ def __init__( self._A_0 = A_0 A_1 = Parameter.from_dependency( - name="A_1", - dependency_expression="1 - A_0", - dependency_map={"A_0": A_0}, + name='A_1', + dependency_expression='1 - A_0', + dependency_map={'A_0': A_0}, ) self._A_1 = A_1 mean_u_squared = Parameter( - name="mean_u_squared", + name='mean_u_squared', value=float(mean_u_squared), fixed=False, min=0.0, - unit="angstrom**2", + unit='angstrom**2', ) self._mean_u_squared = mean_u_squared lorentzian_width = Parameter( - name="lorentzian_width", + name='lorentzian_width', value=float(lorentzian_width), fixed=False, min=MINIMUM_WIDTH, @@ -186,15 +187,13 @@ def __init__( self._A_1_list = [] self._lorentzian_width_list = [] else: - if self._allow_Q_variation["A_0"] is True: - self._A_0_list, self._A_1_list = self._create_A0_A1_parameters( - A_0, self.Q - ) + if self._allow_Q_variation['A_0'] is True: + self._A_0_list, self._A_1_list = self._create_A0_A1_parameters(A_0, self.Q) else: self._A_0_list = [] self._A_1_list = [] - if self._allow_Q_variation["lorentzian_width"] is True: + if self._allow_Q_variation['lorentzian_width'] is True: self._lorentzian_width_list = self._create_lorentzian_width_parameters( lorentzian_width, self.Q ) @@ -235,10 +234,10 @@ def mean_u_squared(self, mean_u_squared: Numeric) -> None: If mean_u_squared is negative. """ if not isinstance(mean_u_squared, Numeric): - raise TypeError("mean_u_squared must be a number.") + raise TypeError('mean_u_squared must be a number.') if float(mean_u_squared) < 0: - raise ValueError("mean_u_squared must be non-negative.") + raise ValueError('mean_u_squared must be non-negative.') self._mean_u_squared.value = float(mean_u_squared) @property @@ -271,10 +270,10 @@ def A_0(self, A_0: Numeric) -> None: If A_0 is not between 0 and 1. """ if not isinstance(A_0, Numeric): - raise TypeError("A_0 must be a number.") + raise TypeError('A_0 must be a number.') if not (0 <= float(A_0) <= 1): - raise ValueError("A_0 must be between 0 and 1.") + raise ValueError('A_0 must be between 0 and 1.') self._A_0.value = float(A_0) @property @@ -305,7 +304,7 @@ def A_1(self, _A_1: Numeric) -> None: AttributeError If an attempt is made to set A_1 directly. """ raise AttributeError( - "A_1 is a dependent parameter and cannot be set directly. Set A_0 to change A_1 accordingly." + 'A_1 is a dependent parameter and cannot be set directly. Set A_0 to change A_1 accordingly.' ) @property @@ -338,10 +337,10 @@ def lorentzian_width(self, lorentzian_width: Numeric) -> None: If lorentzian_width is less than the minimum allowed width. """ if not isinstance(lorentzian_width, Numeric): - raise TypeError("lorentzian_width must be a number.") + raise TypeError('lorentzian_width must be a number.') if float(lorentzian_width) < MINIMUM_WIDTH: - raise ValueError(f"lorentzian_width must be at least {MINIMUM_WIDTH}.") + raise ValueError(f'lorentzian_width must be at least {MINIMUM_WIDTH}.') self._lorentzian_width.value = float(lorentzian_width) # ------------------------------------------------------------------ @@ -365,11 +364,8 @@ def calculate_width(self, Q: Q_type) -> np.ndarray: Q = _validate_and_convert_Q(Q) - if self._allow_Q_variation["lorentzian_width"] is True: - widths = [ - lorentzian_width.value - for lorentzian_width in self._lorentzian_width_list - ] + if self._allow_Q_variation['lorentzian_width'] is True: + widths = [lorentzian_width.value for lorentzian_width in self._lorentzian_width_list] else: widths = self.lorentzian_width.value * np.ones_like(Q) @@ -377,8 +373,7 @@ def calculate_width(self, Q: Q_type) -> np.ndarray: def calculate_EISF(self, Q: Q_type) -> np.ndarray: """ - Calculate the Elastic Incoherent Structure Factor (EISF) for the - diffusion model. + Calculate the Elastic Incoherent Structure Factor (EISF) for the diffusion model. Parameters ---------- @@ -393,7 +388,7 @@ def calculate_EISF(self, Q: Q_type) -> np.ndarray: # Need to handle units better Q = _validate_and_convert_Q(Q) - if self._allow_Q_variation["A_0"] is True: + if self._allow_Q_variation['A_0'] is True: A_0_values = [A_0_.value for A_0_ in self._A_0_list] else: A_0_values = [self.A_0.value] * len(Q) @@ -415,7 +410,7 @@ def calculate_QISF(self, Q: Q_type) -> np.ndarray: """ Q = _validate_and_convert_Q(Q) - if self._allow_Q_variation["A_1"] is True: + if self._allow_Q_variation['A_1'] is True: A_1_values = [A_1_.value for A_1_ in self._A_1_list] else: A_1_values = [self.A_1.value] * len(Q) @@ -424,8 +419,8 @@ def calculate_QISF(self, Q: Q_type) -> np.ndarray: def create_component_collections( self, Q: Q_type, - lorentzian_name: str = "Lorentzian", - delta_name: str = "Delta function", + lorentzian_name: str = 'Lorentzian', + delta_name: str = 'Delta function', ) -> list[ComponentCollection]: r""" Create ComponentCollection components for the DeltaLorentz model at given Q values. @@ -453,17 +448,17 @@ def create_component_collections( Q = _validate_and_convert_Q(Q) if not isinstance(lorentzian_name, str): - raise TypeError("lorentzian_name must be a string.") + raise TypeError('lorentzian_name must be a string.') if not isinstance(delta_name, str): - raise TypeError("delta_name must be a string.") + raise TypeError('delta_name must be a string.') - if self._allow_Q_variation["A_0"] is True: + if self._allow_Q_variation['A_0'] is True: A_0_list, A_1_list = self._create_A0_A1_parameters(self.A_0, Q) self._A_0_list = A_0_list self._A_1_list = A_1_list - if self._allow_Q_variation["lorentzian_width"] is True: + if self._allow_Q_variation['lorentzian_width'] is True: lorentzian_width_list = self._create_lorentzian_width_parameters( self.lorentzian_width, Q ) @@ -472,7 +467,7 @@ def create_component_collections( component_collection_list = [None] * len(Q) for i, Q_value in enumerate(Q): component_collection_list[i] = ComponentCollection( - display_name=f"{self.display_name}_Q{Q_value:.2f}", + display_name=f'{self.display_name}_Q{Q_value:.2f}', unit=self.unit, ) @@ -488,13 +483,11 @@ def create_component_collections( # If the width is allowed to vary with Q it is independent. # If the width is not allowed to vary with Q it must be made # dependent on the width parameter of the model. - if self._allow_Q_variation["lorentzian_width"] is False: + if self._allow_Q_variation['lorentzian_width'] is False: dependency_map = self._write_width_dependency_map_expression() lorentzian_component.width.make_dependent_on( - dependency_expression=self._write_lorz_width_dependency_expression( - Q_value - ), + dependency_expression=self._write_lorz_width_dependency_expression(Q_value), dependency_map=dependency_map, desired_unit=self.unit, ) @@ -505,15 +498,13 @@ def create_component_collections( # will also depend on the specific A_1 parameter for that Q # value. If A_1 is not allowed to vary with Q, the area will # depend on the single A_1 parameter of the model. - if self._allow_Q_variation["A_0"] is True: + if self._allow_Q_variation['A_0'] is True: dependency_map = self._write_lorz_area_dependency_map_expression(i) else: dependency_map = self._write_lorz_area_dependency_map_expression(None) lorentzian_component.area.make_dependent_on( - dependency_expression=self._write_lorz_area_dependency_expression( - Q_value - ), + dependency_expression=self._write_lorz_area_dependency_expression(Q_value), dependency_map=dependency_map, ) @@ -528,15 +519,13 @@ def create_component_collections( unit=self.unit, ) - if self._allow_Q_variation["A_0"] is True: + if self._allow_Q_variation['A_0'] is True: dependency_map = self._write_delta_area_dependency_map_expression(i) else: dependency_map = self._write_delta_area_dependency_map_expression(None) delta_component.area.make_dependent_on( - dependency_expression=self._write_delta_area_dependency_expression( - Q_value - ), + dependency_expression=self._write_delta_area_dependency_expression(Q_value), dependency_map=dependency_map, ) @@ -556,7 +545,7 @@ def get_all_variables(self, Q_index: int | None = None) -> list[DescriptorNumber """ variables = [self.scale, self.mean_u_squared] - if self._allow_Q_variation["A_0"] is True: + if self._allow_Q_variation['A_0'] is True: if Q_index is None: variables.extend(self._A_0_list) variables.extend(self._A_1_list) @@ -567,7 +556,7 @@ def get_all_variables(self, Q_index: int | None = None) -> list[DescriptorNumber variables.append(self.A_0) variables.append(self.A_1) - if self._allow_Q_variation["lorentzian_width"] is True: + if self._allow_Q_variation['lorentzian_width'] is True: if Q_index is None: variables.extend(self._lorentzian_width_list) else: @@ -601,7 +590,7 @@ def _create_A0_A1_parameters( for i, Q_value in enumerate(Q): A_0_list.append( Parameter( - name=f"A_0_Q{Q_value:.2f}", + name=f'A_0_Q{Q_value:.2f}', value=float(A_0.value), fixed=False, min=0.0, @@ -610,9 +599,9 @@ def _create_A0_A1_parameters( ) A_1_list.append( Parameter.from_dependency( - name=f"A_1_Q{Q_value:.2f}", - dependency_expression="1 - A_0", - dependency_map={"A_0": A_0_list[i]}, + name=f'A_1_Q{Q_value:.2f}', + dependency_expression='1 - A_0', + dependency_map={'A_0': A_0_list[i]}, ) ) @@ -627,7 +616,8 @@ def _create_lorentzian_width_parameters( Parameters ---------- lorentzian_width : Parameter - The Lorentzian width parameter to use as the base for creating the Lorentzian width parameters for each Q value. + The Lorentzian width parameter to use as the base for creating the Lorentzian width + parameters for each Q value. Returns ------- @@ -638,7 +628,7 @@ def _create_lorentzian_width_parameters( for i, Q_value in enumerate(Q): lorentzian_width_list.append( Parameter( - name=f"lorentzian_width_Q{Q_value:.2f}", + name=f'lorentzian_width_Q{Q_value:.2f}', value=float(lorentzian_width.value), fixed=False, min=MINIMUM_WIDTH, @@ -669,9 +659,9 @@ def _write_lorz_width_dependency_expression(self, Q: float) -> str: Dependency expression for the width. """ if not isinstance(Q, (float)): - raise TypeError("Q must be a float.") + raise TypeError('Q must be a float.') - return "lorentzian_width" + return 'lorentzian_width' def _write_width_dependency_map_expression( self, @@ -685,7 +675,7 @@ def _write_width_dependency_map_expression( Dependency map for the width. """ return { - "lorentzian_width": self.lorentzian_width, + 'lorentzian_width': self.lorentzian_width, } def _write_lorz_area_dependency_expression(self, Q: float) -> str: @@ -708,9 +698,9 @@ def _write_lorz_area_dependency_expression(self, Q: float) -> str: Dependency expression for the area. """ if not isinstance(Q, (float)): - raise TypeError("Q must be a float.") + raise TypeError('Q must be a float.') - return f"scale * exp(-mean_u_squared.value * {Q}**2 / 3) * A_1" + return f'scale * exp(-mean_u_squared.value * {Q}**2 / 3) * A_1' def _write_lorz_area_dependency_map_expression( self, Q_index: int | None @@ -725,15 +715,15 @@ def _write_lorz_area_dependency_map_expression( """ if Q_index is None: return { - "scale": self.scale, - "mean_u_squared": self.mean_u_squared, - "A_1": self.A_1, + 'scale': self.scale, + 'mean_u_squared': self.mean_u_squared, + 'A_1': self.A_1, } return { - "scale": self.scale, - "mean_u_squared": self.mean_u_squared, - "A_1": self._A_1_list[Q_index], + 'scale': self.scale, + 'mean_u_squared': self.mean_u_squared, + 'A_1': self._A_1_list[Q_index], } def _write_delta_area_dependency_expression(self, Q: float) -> str: @@ -756,9 +746,9 @@ def _write_delta_area_dependency_expression(self, Q: float) -> str: Dependency expression for the area. """ if not isinstance(Q, (float)): - raise TypeError("Q must be a float.") + raise TypeError('Q must be a float.') - return f"scale * exp(-mean_u_squared.value * {Q}**2 / 3) * A_0" + return f'scale * exp(-mean_u_squared.value * {Q}**2 / 3) * A_0' def _write_delta_area_dependency_map_expression( self, @@ -770,7 +760,8 @@ def _write_delta_area_dependency_map_expression( Parameters ---------- Q_index : int | None - Index of the Q value for which to write the dependency map. If None, write the dependency map for the case where A_0 is not Q-dependent. + Index of the Q value for which to write the dependency map. If None, write the + dependency map for the case where A_0 is not Q-dependent. Returns ------- @@ -779,14 +770,14 @@ def _write_delta_area_dependency_map_expression( """ if Q_index is None: return { - "scale": self.scale, - "mean_u_squared": self.mean_u_squared, - "A_0": self.A_0, + 'scale': self.scale, + 'mean_u_squared': self.mean_u_squared, + 'A_0': self.A_0, } return { - "scale": self.scale, - "mean_u_squared": self.mean_u_squared, - "A_0": self._A_0_list[Q_index], + 'scale': self.scale, + 'mean_u_squared': self.mean_u_squared, + 'A_0': self._A_0_list[Q_index], } # ------------------------------------------------------------------ @@ -803,10 +794,10 @@ def __repr__(self) -> str: String representation of the DeltaLorentz model. """ return ( - f"DeltaLorentz(display_name={self.display_name}," - f"unit={self.unit}, \n" - f" mean_u_squared={self.mean_u_squared}, \n" - f" A_0={self.A_0}, A_1={self.A_1}, \n" - f" lorentzian_width={self.lorentzian_width}, \n" - f" scale={self.scale})" + f'DeltaLorentz(display_name={self.display_name},' + f'unit={self.unit}, \n' + f' mean_u_squared={self.mean_u_squared}, \n' + f' A_0={self.A_0}, A_1={self.A_1}, \n' + f' lorentzian_width={self.lorentzian_width}, \n' + f' scale={self.scale})' ) diff --git a/src/easydynamics/sample_model/diffusion_model/diffusion_model_base.py b/src/easydynamics/sample_model/diffusion_model/diffusion_model_base.py index 30022ea9..9a0632c3 100644 --- a/src/easydynamics/sample_model/diffusion_model/diffusion_model_base.py +++ b/src/easydynamics/sample_model/diffusion_model/diffusion_model_base.py @@ -20,9 +20,9 @@ def __init__( self, scale: Numeric = 1.0, Q: Q_type | None = None, - unit: str | sc.Unit = "meV", - name: str = "DiffusionModel", - display_name: str | None = "MyDiffusionModel", + unit: str | sc.Unit = 'meV', + name: str = 'DiffusionModel', + display_name: str | None = 'MyDiffusionModel', unique_name: str | None = None, ) -> None: """ @@ -34,11 +34,11 @@ def __init__( Scale factor for the diffusion model. Must be a non-negative number. Q : Q_type | None, default=None Q values for the model. If None, Q is not set. - unit : str | sc.Unit, default='meV' + unit : str | sc.Unit, default="meV" Unit of the diffusion model. Must be convertible to meV. - name : str, default='DiffusionModel' + name : str, default="DiffusionModel" Name of the diffusion model. - display_name : str | None, default='MyDiffusionModel' + display_name : str | None, default="MyDiffusionModel" Display name of the diffusion model. unique_name : str | None, default=None Unique name of the diffusion model. If None, a unique name will be generated. By @@ -55,23 +55,19 @@ def __init__( self._Q = _validate_and_convert_Q(Q) try: - test = DescriptorNumber(name="test", value=1, unit=unit) - test.convert_unit("meV") + test = DescriptorNumber(name='test', value=1, unit=unit) + test.convert_unit('meV') except Exception as e: raise UnitError( - f"Invalid unit: {unit}. Unit must be a string or scipp Unit and convertible to meV." # noqa: E501 + f'Invalid unit: {unit}. Unit must be a string or scipp Unit and convertible to meV.' # noqa: E501 ) from e if not isinstance(scale, Numeric): - raise TypeError("scale must be a number.") + raise TypeError('scale must be a number.') - scale = Parameter( - name="scale", value=float(scale), fixed=False, min=0.0, unit=unit - ) + scale = Parameter(name='scale', value=float(scale), fixed=False, min=0.0, unit=unit) - super().__init__( - unit=unit, name=name, display_name=display_name, unique_name=unique_name - ) + super().__init__(unit=unit, name=name, display_name=display_name, unique_name=unique_name) self._scale = scale # ------------------------------------------------------------------ @@ -108,10 +104,10 @@ def scale(self, scale: Numeric) -> None: If scale is negative. """ if not isinstance(scale, Numeric): - raise TypeError("scale must be a number.") + raise TypeError('scale must be a number.') if float(scale) < 0: - raise ValueError("scale must be non-negative.") + raise ValueError('scale must be non-negative.') self._scale.value = float(scale) @property @@ -156,8 +152,8 @@ def Q(self, value: Q_type | None) -> None: if len(old_Q) != len(new_Q) or not np.allclose(old_Q, new_Q): raise ValueError( - "New Q values are not similar to the old ones. " - "To change Q values, first run clear_Q()." + 'New Q values are not similar to the old ones. ' + 'To change Q values, first run clear_Q().' ) def clear_Q(self, confirm: bool = False) -> None: @@ -177,7 +173,7 @@ def clear_Q(self, confirm: bool = False) -> None: """ if not confirm: raise ValueError( - "Clearing Q values requires confirmation. Set confirm=True to proceed." + 'Clearing Q values requires confirmation. Set confirm=True to proceed.' ) self._Q = None self._on_Q_change() @@ -196,7 +192,7 @@ def __repr__(self) -> str: String representation of the DiffusionModel. """ return ( - f"{self.__class__.__name__}(name={self.name}, display_name={self.display_name}, " - f"unit={self.unit}), \n" - f" scale={self.scale})" + f'{self.__class__.__name__}(name={self.name}, display_name={self.display_name}, ' + f'unit={self.unit}), \n' + f' scale={self.scale})' ) diff --git a/src/easydynamics/sample_model/diffusion_model/jump_translational_diffusion.py b/src/easydynamics/sample_model/diffusion_model/jump_translational_diffusion.py index b543261c..1bd81c1f 100644 --- a/src/easydynamics/sample_model/diffusion_model/jump_translational_diffusion.py +++ b/src/easydynamics/sample_model/diffusion_model/jump_translational_diffusion.py @@ -9,9 +9,7 @@ from easydynamics.sample_model.component_collection import ComponentCollection from easydynamics.sample_model.components import Lorentzian -from easydynamics.sample_model.diffusion_model.diffusion_model_base import ( - DiffusionModelBase, -) +from easydynamics.sample_model.diffusion_model.diffusion_model_base import DiffusionModelBase from easydynamics.utils.utils import Numeric from easydynamics.utils.utils import Q_type from easydynamics.utils.utils import _validate_and_convert_Q @@ -54,9 +52,9 @@ def __init__( diffusion_coefficient: Numeric = 1.0, relaxation_time: Numeric = 1.0, Q: Q_type | None = None, - unit: str | sc.Unit = "meV", - name: str = "JumpTranslationalDiffusion", - display_name: str | None = "JumpTranslationalDiffusion", + unit: str | sc.Unit = 'meV', + name: str = 'JumpTranslationalDiffusion', + display_name: str | None = 'JumpTranslationalDiffusion', unique_name: str | None = None, ) -> None: """ @@ -72,11 +70,11 @@ def __init__( Relaxation time t in ps. Q : Q_type | None, default=None Q values for the model. If None, Q is not set. - unit : str | sc.Unit, default='meV' + unit : str | sc.Unit, default="meV" Unit of the diffusion model. Must be convertible to meV. - name : str, default='JumpTranslationalDiffusion' + name : str, default="JumpTranslationalDiffusion" Name of the diffusion model. - display_name : str | None, default='JumpTranslationalDiffusion' + display_name : str | None, default="JumpTranslationalDiffusion" Display name of the diffusion model. unique_name : str | None, default=None Unique name of the diffusion model. If None, a unique name will be generated. By @@ -97,23 +95,23 @@ def __init__( ) if not isinstance(diffusion_coefficient, Numeric): - raise TypeError("diffusion_coefficient must be a number.") + raise TypeError('diffusion_coefficient must be a number.') if not isinstance(relaxation_time, Numeric): - raise TypeError("relaxation_time must be a number.") + raise TypeError('relaxation_time must be a number.') diffusion_coefficient = Parameter( - name="diffusion_coefficient", + name='diffusion_coefficient', value=float(diffusion_coefficient), fixed=False, - unit="m**2/s", + unit='m**2/s', ) relaxation_time = Parameter( - name="relaxation_time", + name='relaxation_time', value=float(relaxation_time), fixed=False, - unit="ps", + unit='ps', ) self._hbar = hbar @@ -155,9 +153,9 @@ def diffusion_coefficient(self, diffusion_coefficient: Numeric) -> None: If diffusion_coefficient is negative. """ if not isinstance(diffusion_coefficient, Numeric): - raise TypeError("diffusion_coefficient must be a number.") + raise TypeError('diffusion_coefficient must be a number.') if float(diffusion_coefficient) < 0: - raise ValueError("diffusion_coefficient must be non-negative.") + raise ValueError('diffusion_coefficient must be non-negative.') self._diffusion_coefficient.value = float(diffusion_coefficient) @property @@ -190,10 +188,10 @@ def relaxation_time(self, relaxation_time: Numeric) -> None: If relaxation_time is negative. """ if not isinstance(relaxation_time, Numeric): - raise TypeError("relaxation_time must be a number.") + raise TypeError('relaxation_time must be a number.') if float(relaxation_time) < 0: - raise ValueError("relaxation_time must be non-negative.") + raise ValueError('relaxation_time must be non-negative.') self._relaxation_time.value = float(relaxation_time) ################################ @@ -208,7 +206,8 @@ def calculate_width(self, Q: Q_type | None = None) -> np.ndarray: Parameters ---------- Q : Q_type | None, default=None - Scattering vector in 1/angstrom. Can be a single value or an array of values. If None, Q values stored in the model are used. + Scattering vector in 1/angstrom. Can be a single value or an array of values. If None, + Q values stored in the model are used. Returns ------- @@ -220,8 +219,8 @@ def calculate_width(self, Q: Q_type | None = None) -> np.ndarray: if Q is None: raise ValueError( - "Q values must be provided either during initialization or as an argument to " - "calculate_width." + 'Q values must be provided either during initialization or as an argument to ' + 'calculate_width.' ) Q = _validate_and_convert_Q(Q) @@ -235,7 +234,7 @@ def calculate_width(self, Q: Q_type | None = None) -> np.ndarray: unit_conversion_factor_denominator = ( self.diffusion_coefficient / self._angstrom**2 * self.relaxation_time ) - unit_conversion_factor_denominator.convert_unit("dimensionless") + unit_conversion_factor_denominator.convert_unit('dimensionless') denominator = 1 + unit_conversion_factor_denominator.value * Q**2 @@ -259,8 +258,8 @@ def calculate_EISF(self, Q: Q_type) -> np.ndarray: Q = self.Q if Q is None: raise ValueError( - "Q values must be provided either during initialization or as an argument to " - "calculate_EISF." + 'Q values must be provided either during initialization or as an argument to ' + 'calculate_EISF.' ) Q = _validate_and_convert_Q(Q) return np.zeros_like(Q) @@ -283,25 +282,25 @@ def calculate_QISF(self, Q: Q_type) -> np.ndarray: Q = self.Q if Q is None: raise ValueError( - "Q values must be provided either during initialization or as an argument to " - "calculate_QISF." + 'Q values must be provided either during initialization or as an argument to ' + 'calculate_QISF.' ) Q = _validate_and_convert_Q(Q) return np.ones_like(Q) def create_component_collections( self, - component_name: str = "Jump translational diffusion", - component_display_name: str = "Jump translational diffusion", + component_name: str = 'Jump translational diffusion', + component_display_name: str = 'Jump translational diffusion', ) -> list[ComponentCollection]: """ Create ComponentCollection components for the diffusion model at given Q values. Parameters ---------- - component_name : str, default='Jump translational diffusion' + component_name : str, default="Jump translational diffusion" Name of the Jump Diffusion Lorentzian component. - component_display_name : str, default='Jump translational diffusion' + component_display_name : str, default="Jump translational diffusion" Name of the Jump Diffusion Lorentzian component. Raises @@ -317,15 +316,15 @@ def create_component_collections( Q = self.Q if Q is None: raise ValueError( - "Q values must be set in the model to create component collections. Set Q values " - "during initialization or using the Q property." + 'Q values must be set in the model to create component collections. Set Q values ' + 'during initialization or using the Q property.' ) if not isinstance(component_display_name, str): - raise TypeError("component_display_name must be a string.") + raise TypeError('component_display_name must be a string.') if not isinstance(component_name, str): - raise TypeError("component_name must be a string.") + raise TypeError('component_name must be a string.') component_collection_list = [None] * len(Q) # In more complex models, this is used to scale the area of the @@ -337,8 +336,8 @@ def create_component_collections( # is 0. for i, Q_value in enumerate(Q): component_collection_list[i] = ComponentCollection( - name=f"{self.name}_Q{Q_value:.2f}", - display_name=f"{self.display_name}_Q{Q_value:.2f}", + name=f'{self.name}_Q{Q_value:.2f}', + display_name=f'{self.display_name}_Q{Q_value:.2f}', unit=self.unit, ) @@ -394,10 +393,10 @@ def _write_width_dependency_expression(self, Q: float) -> str: Dependency expression for the width. """ if not isinstance(Q, (float)): - raise TypeError("Q must be a float.") + raise TypeError('Q must be a float.') # Q is given as a float, so we need to add the units - return f"hbar * D* {Q} **2/(angstrom**2)/(1 + (D * t* {Q} **2/(angstrom**2)))" + return f'hbar * D* {Q} **2/(angstrom**2)/(1 + (D * t* {Q} **2/(angstrom**2)))' def _write_width_dependency_map_expression(self) -> dict[str, DescriptorNumber]: """ @@ -409,10 +408,10 @@ def _write_width_dependency_map_expression(self) -> dict[str, DescriptorNumber]: Dependency map for the width. """ return { - "D": self.diffusion_coefficient, - "t": self.relaxation_time, - "hbar": self._hbar, - "angstrom": self._angstrom, + 'D': self.diffusion_coefficient, + 't': self.relaxation_time, + 'hbar': self._hbar, + 'angstrom': self._angstrom, } def _write_area_dependency_expression(self, QISF: float) -> str: @@ -436,9 +435,9 @@ def _write_area_dependency_expression(self, QISF: float) -> str: """ if not isinstance(QISF, (float)): - raise TypeError("QISF must be a float.") + raise TypeError('QISF must be a float.') - return f"{QISF} * scale" + return f'{QISF} * scale' def _write_area_dependency_map_expression(self) -> dict[str, DescriptorNumber]: """ @@ -450,7 +449,7 @@ def _write_area_dependency_map_expression(self) -> dict[str, DescriptorNumber]: Dependency map for the area. """ return { - "scale": self.scale, + 'scale': self.scale, } ################################ @@ -467,7 +466,7 @@ def __repr__(self) -> str: String representation of the JumpTranslationalDiffusion model. """ return ( - f"JumpTranslationalDiffusion(name={self.name}, display_name={self.display_name},\n " - f" diffusion_coefficient={self.diffusion_coefficient}, \n" - f" scale={self.scale})" + f'JumpTranslationalDiffusion(name={self.name}, display_name={self.display_name},\n ' + f' diffusion_coefficient={self.diffusion_coefficient}, \n' + f' scale={self.scale})' ) diff --git a/src/easydynamics/sample_model/model_base.py b/src/easydynamics/sample_model/model_base.py index 7072d2d2..99923bb9 100644 --- a/src/easydynamics/sample_model/model_base.py +++ b/src/easydynamics/sample_model/model_base.py @@ -24,9 +24,9 @@ class ModelBase(EasyDynamicsModelBase): def __init__( self, - display_name: str = "MyModelBase", + display_name: str = 'MyModelBase', unique_name: str | None = None, - unit: str | sc.Unit | None = "meV", + unit: str | sc.Unit | None = 'meV', components: ModelComponent | ComponentCollection | None = None, Q: Q_type | None = None, ) -> None: @@ -35,11 +35,11 @@ def __init__( Parameters ---------- - display_name : str, default='MyModelBase' + display_name : str, default="MyModelBase" Display name of the model. unique_name : str | None, default=None Unique name of the model. If None, a unique name will be generated. - unit : str | sc.Unit | None, default='meV' + unit : str | sc.Unit | None, default="meV" Unit of the model. components : ModelComponent | ComponentCollection | None, default=None Template components of the model. If None, no components are added. These components @@ -63,8 +63,8 @@ def __init__( components, (ModelComponent, ComponentCollection) ): raise TypeError( - f"Components must be a ModelComponent, a ComponentCollection or None, " - f"got {type(components).__name__}" + f'Components must be a ModelComponent, a ComponentCollection or None, ' + f'got {type(components).__name__}' ) self._components = ComponentCollection() @@ -100,8 +100,8 @@ def evaluate( if not self._component_collections: raise ValueError( - "No components in the model to evaluate. " - "Run generate_component_collections() first" + 'No components in the model to evaluate. ' + 'Run generate_component_collections() first' ) return [collection.evaluate(x) for collection in self._component_collections] @@ -169,9 +169,7 @@ def components(self, value: ModelComponent | ComponentCollection | None) -> None If value is not a ModelComponent, ComponentCollection, or None. """ if not isinstance(value, (ModelComponent, ComponentCollection, type(None))): - raise TypeError( - "Components must be a ModelComponent or a ComponentCollection" - ) + raise TypeError('Components must be a ModelComponent or a ComponentCollection') self.clear_components() if value is not None: @@ -219,8 +217,8 @@ def Q(self, value: Q_type | None) -> None: if len(old_Q) != len(new_Q) or not np.allclose(old_Q, new_Q): raise ValueError( - "New Q values are not similar to the old ones. " - "To change Q values, first run clear_Q()." + 'New Q values are not similar to the old ones. ' + 'To change Q values, first run clear_Q().' ) def clear_Q(self, confirm: bool = False) -> None: @@ -240,7 +238,7 @@ def clear_Q(self, confirm: bool = False) -> None: """ if not confirm: raise ValueError( - "Clearing Q values requires confirmation. Set confirm=True to proceed." + 'Clearing Q values requires confirmation. Set confirm=True to proceed.' ) self._Q = None self._on_Q_change() @@ -269,9 +267,7 @@ def convert_unit(self, unit: str | sc.Unit) -> None: old_unit = self._unit if not isinstance(unit, (str, sc.Unit)): - raise TypeError( - f"Unit must be a string or sc.Unit, got {type(unit).__name__}" - ) + raise TypeError(f'Unit must be a string or sc.Unit, got {type(unit).__name__}') try: for component in self.components: component.convert_unit(unit) @@ -330,13 +326,11 @@ def get_all_variables(self, Q_index: int | None = None) -> list[Parameter]: ] else: if not isinstance(Q_index, int): - raise TypeError( - f"Q_index must be an int or None, got {type(Q_index).__name__}" - ) + raise TypeError(f'Q_index must be an int or None, got {type(Q_index).__name__}') if Q_index < 0 or Q_index >= len(self._component_collections): raise IndexError( - f"Q_index {Q_index} is out of bounds for component collections " - f"of length {len(self._component_collections)}" + f'Q_index {Q_index} is out of bounds for component collections ' + f'of length {len(self._component_collections)}' ) all_vars = self._component_collections[Q_index].get_all_variables() return all_vars @@ -363,11 +357,11 @@ def get_component_collection(self, Q_index: int) -> ComponentCollection: The ComponentCollection at the. """ if not isinstance(Q_index, int): - raise TypeError(f"Q_index must be an int, got {type(Q_index).__name__}") + raise TypeError(f'Q_index must be an int, got {type(Q_index).__name__}') if Q_index < 0 or Q_index >= len(self._component_collections): raise IndexError( - f"Q_index {Q_index} is out of bounds for component collections " - f"of length {len(self._component_collections)}" + f'Q_index {Q_index} is out of bounds for component collections ' + f'of length {len(self._component_collections)}' ) return self._component_collections[Q_index] @@ -413,6 +407,6 @@ def __repr__(self) -> str: A string representation of the ModelBase. """ return ( - f"{self.__class__.__name__}(unique_name={self.unique_name}, " - f"unit={self.unit}), Q = {self.Q}, components = {self.components}" + f'{self.__class__.__name__}(unique_name={self.unique_name}, ' + f'unit={self.unit}), Q = {self.Q}, components = {self.components}' ) diff --git a/src/easydynamics/sample_model/sample_model.py b/src/easydynamics/sample_model/sample_model.py index 4a9155cc..287554c4 100644 --- a/src/easydynamics/sample_model/sample_model.py +++ b/src/easydynamics/sample_model/sample_model.py @@ -9,9 +9,7 @@ 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, -) +from easydynamics.sample_model.diffusion_model.diffusion_model_base import DiffusionModelBase from easydynamics.sample_model.model_base import ModelBase from easydynamics.settings.detailed_balance_settings import DetailedBalanceSettings from easydynamics.utils import detailed_balance_factor @@ -30,14 +28,14 @@ class SampleModel(ModelBase): def __init__( self, - display_name: str = "MySampleModel", + display_name: str = 'MySampleModel', unique_name: str | None = None, - unit: str | sc.Unit = "meV", + unit: str | sc.Unit = 'meV', components: ModelComponent | ComponentCollection | None = None, Q: Q_type | None = None, diffusion_models: DiffusionModelBase | list[DiffusionModelBase] | None = None, temperature: float | None = None, - temperature_unit: str | sc.Unit = "K", + temperature_unit: str | sc.Unit = 'K', detailed_balance_settings: DetailedBalanceSettings | None = None, ) -> None: """ @@ -45,11 +43,11 @@ def __init__( Parameters ---------- - display_name : str, default='MySampleModel' + display_name : str, default="MySampleModel" Display name of the model. unique_name : str | None, default=None Unique name of the model. If None, a unique name will be generated. - unit : str | sc.Unit, default='meV' + unit : str | sc.Unit, default="meV" Unit of the model. If None,. components : ModelComponent | ComponentCollection | None, default=None Template components of the model. If None, no components are added. These components @@ -61,7 +59,7 @@ def __init__( temperature : float | None, default=None Temperature for detailed balancing. If None, no detailed balancing is applied. By default, None. - temperature_unit : str | sc.Unit, default='K' + temperature_unit : str | sc.Unit, default="K" Unit of the temperature. detailed_balance_settings : DetailedBalanceSettings | None, default=None Settings for detailed balancing. @@ -84,8 +82,8 @@ def __init__( isinstance(dm, DiffusionModelBase) for dm in diffusion_models ): raise TypeError( - "diffusion_models must be a DiffusionModelBase, " - "a list of DiffusionModelBase or None" + 'diffusion_models must be a DiffusionModelBase, ' + 'a list of DiffusionModelBase or None' ) self._diffusion_models = diffusion_models @@ -101,15 +99,15 @@ def __init__( self._temperature = None else: if not isinstance(temperature, Numeric): - raise TypeError("temperature must be a number or None") + raise TypeError('temperature must be a number or None') if temperature < 0: - raise ValueError("temperature must be non-negative") + raise ValueError('temperature must be non-negative') self._temperature = Parameter( - name="Temperature", + name='Temperature', value=temperature, unit=temperature_unit, - display_name="Temperature", + display_name='Temperature', fixed=True, ) self._temperature_unit = temperature_unit @@ -119,9 +117,7 @@ def __init__( elif isinstance(detailed_balance_settings, DetailedBalanceSettings): self._detailed_balance_settings = detailed_balance_settings else: - raise TypeError( - "detailed_balance_settings must be a DetailedBalanceSettings or None" - ) + raise TypeError('detailed_balance_settings must be a DetailedBalanceSettings or None') # ------------------------------------------------------------------ # Component management @@ -144,7 +140,7 @@ def append_diffusion_model(self, diffusion_model: DiffusionModelBase) -> None: if not isinstance(diffusion_model, DiffusionModelBase): raise TypeError( - f"diffusion_model must be a DiffusionModelBase, got {type(diffusion_model).__name__}" # noqa: E501 + f'diffusion_model must be a DiffusionModelBase, got {type(diffusion_model).__name__}' # noqa: E501 ) self._diffusion_models.append(diffusion_model) @@ -170,8 +166,8 @@ def remove_diffusion_model(self, name: str) -> None: self._generate_component_collections() return raise ValueError( - f"No DiffusionModel with name {name} found. \n" - f"The available names are: {[dm.name for dm in self._diffusion_models]}" + f'No DiffusionModel with name {name} found. \n' + f'The available names are: {[dm.name for dm in self._diffusion_models]}' ) def clear_diffusion_models(self) -> None: @@ -224,8 +220,8 @@ def diffusion_models( isinstance(dm, DiffusionModelBase) for dm in value ): raise TypeError( - "diffusion_models must be a DiffusionModelBase, a list of DiffusionModelBase, " - "or None" + 'diffusion_models must be a DiffusionModelBase, a list of DiffusionModelBase, ' + 'or None' ) self._diffusion_models = value self._on_diffusion_models_change() @@ -264,17 +260,17 @@ def temperature(self, value: Numeric | None) -> None: return if not isinstance(value, Numeric): - raise TypeError("temperature must be a number or None") + raise TypeError('temperature must be a number or None') if value < 0: - raise ValueError("temperature must be non-negative") + raise ValueError('temperature must be non-negative') if self._temperature is None: self._temperature = Parameter( - name="Temperature", + name='Temperature', value=value, unit=self._temperature_unit, - display_name="Temperature", + display_name='Temperature', fixed=True, ) else: @@ -309,8 +305,8 @@ def temperature_unit(self, _value: str | sc.Unit) -> None: """ raise AttributeError( - f"Temperature_unit is read-only. Use convert_temperature_unit to change the unit between allowed types " # noqa: E501 - f"or create a new {self.__class__.__name__} with the desired unit." + f'Temperature_unit is read-only. Use convert_temperature_unit to change the unit between allowed types ' # noqa: E501 + f'or create a new {self.__class__.__name__} with the desired unit.' ) def convert_temperature_unit(self, unit: str | sc.Unit) -> None: @@ -331,7 +327,7 @@ def convert_temperature_unit(self, unit: str | sc.Unit) -> None: """ if self.temperature is None: - raise ValueError("Temperature is not set, cannot convert unit.") + raise ValueError('Temperature is not set, cannot convert unit.') old_unit = self.temperature.unit @@ -372,7 +368,7 @@ def normalize_detailed_balance(self, value: bool) -> None: If value is not a bool. """ if not isinstance(value, bool): - raise TypeError("normalize_detailed_balance must be True or False") + raise TypeError('normalize_detailed_balance must be True or False') self.detailed_balance_settings.normalize_detailed_balance = value @property @@ -403,7 +399,7 @@ def use_detailed_balance(self, value: bool) -> None: If value is not a bool. """ if not isinstance(value, bool): - raise TypeError("use_detailed_balance must be True or False") + raise TypeError('use_detailed_balance must be True or False') self.detailed_balance_settings.use_detailed_balance = value @property @@ -434,9 +430,7 @@ def detailed_balance_settings(self, value: DetailedBalanceSettings) -> None: If value is not a DetailedBalanceSettings. """ if not isinstance(value, DetailedBalanceSettings): - raise TypeError( - "detailed_balance_settings must be a DetailedBalanceSettings" - ) + raise TypeError('detailed_balance_settings must be a DetailedBalanceSettings') self._detailed_balance_settings = value # ------------------------------------------------------------------ @@ -463,10 +457,7 @@ def evaluate( y = super().evaluate(x) - if ( - self.temperature is not None - and self.detailed_balance_settings.use_detailed_balance - ): + if self.temperature is not None and self.detailed_balance_settings.use_detailed_balance: DBF = detailed_balance_factor( energy=x, temperature=self.temperature, @@ -553,9 +544,9 @@ def __repr__(self) -> str: """ return ( - f"{self.__class__.__name__}(unique_name={self.unique_name}, unit={self.unit}), " - f"Q = {self.Q}, \n " - f"components = {self.components}, diffusion_models = {self.diffusion_models}, " - f"temperature = {self.temperature}, " - f"detailed_balance_settings = {self.detailed_balance_settings}" + f'{self.__class__.__name__}(unique_name={self.unique_name}, unit={self.unit}), ' + f'Q = {self.Q}, \n ' + f'components = {self.components}, diffusion_models = {self.diffusion_models}, ' + f'temperature = {self.temperature}, ' + f'detailed_balance_settings = {self.detailed_balance_settings}' ) From 49643a7ad5d4e6fd9855ee86c4b379abd1c02366 Mon Sep 17 00:00:00 2001 From: henrikjacobsenfys Date: Wed, 20 May 2026 16:26:07 +0200 Subject: [PATCH 08/18] small docstring updates --- .../diffusion_model/delta_lorentz.py | 237 +++++++++++------- .../diffusion_model/diffusion_model_base.py | 45 ++-- 2 files changed, 175 insertions(+), 107 deletions(-) diff --git a/src/easydynamics/sample_model/diffusion_model/delta_lorentz.py b/src/easydynamics/sample_model/diffusion_model/delta_lorentz.py index 06447c2b..5d038f41 100644 --- a/src/easydynamics/sample_model/diffusion_model/delta_lorentz.py +++ b/src/easydynamics/sample_model/diffusion_model/delta_lorentz.py @@ -10,7 +10,9 @@ from easydynamics.sample_model.component_collection import ComponentCollection from easydynamics.sample_model.components import DeltaFunction from easydynamics.sample_model.components import Lorentzian -from easydynamics.sample_model.diffusion_model.diffusion_model_base import DiffusionModelBase +from easydynamics.sample_model.diffusion_model.diffusion_model_base import ( + DiffusionModelBase, +) from easydynamics.utils.utils import Numeric from easydynamics.utils.utils import Q_type from easydynamics.utils.utils import _validate_and_convert_Q @@ -60,8 +62,8 @@ def __init__( lorentzian_width: Numeric = 1.0, allow_Q_variation: dict | None = None, Q: Q_type | None = None, - unit: str | sc.Unit = 'meV', - name: str = 'DeltaLorentz', + unit: str | sc.Unit = "meV", + name: str = "DeltaLorentz", display_name: str | None = None, unique_name: str | None = None, ) -> None: @@ -114,42 +116,42 @@ def __init__( ) if not isinstance(mean_u_squared, Numeric): - raise TypeError('mean_u_squared must be a number.') + raise TypeError("mean_u_squared must be a number.") if float(mean_u_squared) < 0: - raise ValueError('mean_u_squared must be non-negative.') + raise ValueError("mean_u_squared must be non-negative.") if not isinstance(A_0, Numeric): - raise TypeError('A_0 must be a number.') + raise TypeError("A_0 must be a number.") if float(A_0) < 0 or float(A_0) > 1: - raise ValueError('A_0 must be between 0 and 1.') + raise ValueError("A_0 must be between 0 and 1.") if not isinstance(lorentzian_width, Numeric): - raise TypeError('lorentzian_width must be a number.') + raise TypeError("lorentzian_width must be a number.") if float(lorentzian_width) < MINIMUM_WIDTH: - raise ValueError(f'lorentzian_width must be at least {MINIMUM_WIDTH}.') + raise ValueError(f"lorentzian_width must be at least {MINIMUM_WIDTH}.") allow_Q_variation_default = { - 'A_0': False, - 'lorentzian_width': False, + "A_0": False, + "lorentzian_width": False, } allowed_keys = set(allow_Q_variation_default) if allow_Q_variation is None: allow_Q_variation = {} if not isinstance(allow_Q_variation, dict): - raise TypeError('allow_Q_variation must be a dict or None.') + raise TypeError("allow_Q_variation must be a dict or None.") unknown_keys = set(allow_Q_variation) - allowed_keys if unknown_keys: - raise ValueError(f'Unknown keys in allow_Q_variation: {unknown_keys}') + raise ValueError(f"Unknown keys in allow_Q_variation: {unknown_keys}") self._allow_Q_variation = {**allow_Q_variation_default, **allow_Q_variation} A_0 = Parameter( - name='A_0', + name="A_0", value=float(A_0), fixed=False, min=0.0, @@ -158,23 +160,23 @@ def __init__( self._A_0 = A_0 A_1 = Parameter.from_dependency( - name='A_1', - dependency_expression='1 - A_0', - dependency_map={'A_0': A_0}, + name="A_1", + dependency_expression="1 - A_0", + dependency_map={"A_0": A_0}, ) self._A_1 = A_1 mean_u_squared = Parameter( - name='mean_u_squared', + name="mean_u_squared", value=float(mean_u_squared), fixed=False, min=0.0, - unit='angstrom**2', + unit="angstrom**2", ) self._mean_u_squared = mean_u_squared lorentzian_width = Parameter( - name='lorentzian_width', + name="lorentzian_width", value=float(lorentzian_width), fixed=False, min=MINIMUM_WIDTH, @@ -187,13 +189,15 @@ def __init__( self._A_1_list = [] self._lorentzian_width_list = [] else: - if self._allow_Q_variation['A_0'] is True: - self._A_0_list, self._A_1_list = self._create_A0_A1_parameters(A_0, self.Q) + if self._allow_Q_variation["A_0"] is True: + self._A_0_list, self._A_1_list = self._create_A0_A1_parameters( + A_0, self.Q + ) else: self._A_0_list = [] self._A_1_list = [] - if self._allow_Q_variation['lorentzian_width'] is True: + if self._allow_Q_variation["lorentzian_width"] is True: self._lorentzian_width_list = self._create_lorentzian_width_parameters( lorentzian_width, self.Q ) @@ -234,10 +238,10 @@ def mean_u_squared(self, mean_u_squared: Numeric) -> None: If mean_u_squared is negative. """ if not isinstance(mean_u_squared, Numeric): - raise TypeError('mean_u_squared must be a number.') + raise TypeError("mean_u_squared must be a number.") if float(mean_u_squared) < 0: - raise ValueError('mean_u_squared must be non-negative.') + raise ValueError("mean_u_squared must be non-negative.") self._mean_u_squared.value = float(mean_u_squared) @property @@ -270,10 +274,10 @@ def A_0(self, A_0: Numeric) -> None: If A_0 is not between 0 and 1. """ if not isinstance(A_0, Numeric): - raise TypeError('A_0 must be a number.') + raise TypeError("A_0 must be a number.") if not (0 <= float(A_0) <= 1): - raise ValueError('A_0 must be between 0 and 1.') + raise ValueError("A_0 must be between 0 and 1.") self._A_0.value = float(A_0) @property @@ -304,7 +308,7 @@ def A_1(self, _A_1: Numeric) -> None: AttributeError If an attempt is made to set A_1 directly. """ raise AttributeError( - 'A_1 is a dependent parameter and cannot be set directly. Set A_0 to change A_1 accordingly.' + "A_1 is a dependent parameter and cannot be set directly. Set A_0 to change A_1 accordingly." ) @property @@ -337,10 +341,10 @@ def lorentzian_width(self, lorentzian_width: Numeric) -> None: If lorentzian_width is less than the minimum allowed width. """ if not isinstance(lorentzian_width, Numeric): - raise TypeError('lorentzian_width must be a number.') + raise TypeError("lorentzian_width must be a number.") if float(lorentzian_width) < MINIMUM_WIDTH: - raise ValueError(f'lorentzian_width must be at least {MINIMUM_WIDTH}.') + raise ValueError(f"lorentzian_width must be at least {MINIMUM_WIDTH}.") self._lorentzian_width.value = float(lorentzian_width) # ------------------------------------------------------------------ @@ -361,13 +365,22 @@ def calculate_width(self, Q: Q_type) -> np.ndarray: np.ndarray HWHM values in the unit of the model (e.g., meV). """ - + if self._allow_Q_variation["lorentzian_width"] is True: + widths = [ + lorentzian_width.value + for lorentzian_width in self._lorentzian_width_list + ] + return np.array(widths) + + if Q is None: + Q = self.Q + if Q is None: + raise ValueError( + "Q must be provided either as an argument or set in the model." + ) Q = _validate_and_convert_Q(Q) - if self._allow_Q_variation['lorentzian_width'] is True: - widths = [lorentzian_width.value for lorentzian_width in self._lorentzian_width_list] - else: - widths = self.lorentzian_width.value * np.ones_like(Q) + widths = self.lorentzian_width.value * np.ones_like(Q) return np.array(widths) @@ -385,13 +398,20 @@ def calculate_EISF(self, Q: Q_type) -> np.ndarray: np.ndarray EISF values (dimensionless). """ + if self._allow_Q_variation["A_0"] is True: + A_0_values = [A_0_.value for A_0_ in self._A_0_list] + return np.exp(-self.mean_u_squared.value * Q**2 / 3) * np.array(A_0_values) # Need to handle units better + if Q is None: + Q = self.Q + if Q is None: + raise ValueError( + "Q must be provided either as an argument or set in the model." + ) Q = _validate_and_convert_Q(Q) - if self._allow_Q_variation['A_0'] is True: - A_0_values = [A_0_.value for A_0_ in self._A_0_list] - else: - A_0_values = [self.A_0.value] * len(Q) + + A_0_values = [self.A_0.value] * len(Q) return np.exp(-self.mean_u_squared.value * Q**2 / 3) * np.array(A_0_values) def calculate_QISF(self, Q: Q_type) -> np.ndarray: @@ -408,19 +428,25 @@ def calculate_QISF(self, Q: Q_type) -> np.ndarray: np.ndarray QISF values (dimensionless). """ + if self._allow_Q_variation["A_1"] is True: + A_1_values = [A_1_.value for A_1_ in self._A_1_list] + return np.exp(-self.mean_u_squared.value * Q**2 / 3) * np.array(A_1_values) + + if Q is None: + Q = self.Q + if Q is None: + raise ValueError( + "Q must be provided either as an argument or set in the model." + ) Q = _validate_and_convert_Q(Q) - if self._allow_Q_variation['A_1'] is True: - A_1_values = [A_1_.value for A_1_ in self._A_1_list] - else: - A_1_values = [self.A_1.value] * len(Q) + A_1_values = [self.A_1.value] * len(Q) return np.exp(-self.mean_u_squared.value * Q**2 / 3) * np.array(A_1_values) def create_component_collections( self, - Q: Q_type, - lorentzian_name: str = 'Lorentzian', - delta_name: str = 'Delta function', + lorentzian_name: str = "Lorentzian", + delta_name: str = "Delta function", ) -> list[ComponentCollection]: r""" Create ComponentCollection components for the DeltaLorentz model at given Q values. @@ -445,20 +471,24 @@ def create_component_collections( List of ComponentCollections with Lorentzian and delta functioncomponents for each Q value. """ - Q = _validate_and_convert_Q(Q) + Q = self.Q + if Q is None: + raise ValueError( + "Q must be set in the model to create component collections." + ) if not isinstance(lorentzian_name, str): - raise TypeError('lorentzian_name must be a string.') + raise TypeError("lorentzian_name must be a string.") if not isinstance(delta_name, str): - raise TypeError('delta_name must be a string.') + raise TypeError("delta_name must be a string.") - if self._allow_Q_variation['A_0'] is True: + if self._allow_Q_variation["A_0"] is True: A_0_list, A_1_list = self._create_A0_A1_parameters(self.A_0, Q) self._A_0_list = A_0_list self._A_1_list = A_1_list - if self._allow_Q_variation['lorentzian_width'] is True: + if self._allow_Q_variation["lorentzian_width"] is True: lorentzian_width_list = self._create_lorentzian_width_parameters( self.lorentzian_width, Q ) @@ -467,7 +497,7 @@ def create_component_collections( component_collection_list = [None] * len(Q) for i, Q_value in enumerate(Q): component_collection_list[i] = ComponentCollection( - display_name=f'{self.display_name}_Q{Q_value:.2f}', + display_name=f"{self.display_name}_Q{Q_value:.2f}", unit=self.unit, ) @@ -483,11 +513,13 @@ def create_component_collections( # If the width is allowed to vary with Q it is independent. # If the width is not allowed to vary with Q it must be made # dependent on the width parameter of the model. - if self._allow_Q_variation['lorentzian_width'] is False: + if self._allow_Q_variation["lorentzian_width"] is False: dependency_map = self._write_width_dependency_map_expression() lorentzian_component.width.make_dependent_on( - dependency_expression=self._write_lorz_width_dependency_expression(Q_value), + dependency_expression=self._write_lorz_width_dependency_expression( + Q_value + ), dependency_map=dependency_map, desired_unit=self.unit, ) @@ -498,13 +530,15 @@ def create_component_collections( # will also depend on the specific A_1 parameter for that Q # value. If A_1 is not allowed to vary with Q, the area will # depend on the single A_1 parameter of the model. - if self._allow_Q_variation['A_0'] is True: + if self._allow_Q_variation["A_0"] is True: dependency_map = self._write_lorz_area_dependency_map_expression(i) else: dependency_map = self._write_lorz_area_dependency_map_expression(None) lorentzian_component.area.make_dependent_on( - dependency_expression=self._write_lorz_area_dependency_expression(Q_value), + dependency_expression=self._write_lorz_area_dependency_expression( + Q_value + ), dependency_map=dependency_map, ) @@ -519,13 +553,15 @@ def create_component_collections( unit=self.unit, ) - if self._allow_Q_variation['A_0'] is True: + if self._allow_Q_variation["A_0"] is True: dependency_map = self._write_delta_area_dependency_map_expression(i) else: dependency_map = self._write_delta_area_dependency_map_expression(None) delta_component.area.make_dependent_on( - dependency_expression=self._write_delta_area_dependency_expression(Q_value), + dependency_expression=self._write_delta_area_dependency_expression( + Q_value + ), dependency_map=dependency_map, ) @@ -545,7 +581,7 @@ def get_all_variables(self, Q_index: int | None = None) -> list[DescriptorNumber """ variables = [self.scale, self.mean_u_squared] - if self._allow_Q_variation['A_0'] is True: + if self._allow_Q_variation["A_0"] is True: if Q_index is None: variables.extend(self._A_0_list) variables.extend(self._A_1_list) @@ -556,7 +592,7 @@ def get_all_variables(self, Q_index: int | None = None) -> list[DescriptorNumber variables.append(self.A_0) variables.append(self.A_1) - if self._allow_Q_variation['lorentzian_width'] is True: + if self._allow_Q_variation["lorentzian_width"] is True: if Q_index is None: variables.extend(self._lorentzian_width_list) else: @@ -569,6 +605,27 @@ def get_all_variables(self, Q_index: int | None = None) -> list[DescriptorNumber # ------------------------------------------------------------------ # Private methods # ------------------------------------------------------------------ + def _on_Q_change(self) -> None: + """Handle changes to the Q values. Updates the A_0, A_1 and lorentzian_width parameters if they are allowed to vary with Q.""" + if self.Q is None: + self._A_0_list = [] + self._A_1_list = [] + self._lorentzian_width_list = [] + else: + if self._allow_Q_variation["A_0"] is True: + self._A_0_list, self._A_1_list = self._create_A0_A1_parameters( + self.A_0, self.Q + ) + else: + self._A_0_list = [] + self._A_1_list = [] + + if self._allow_Q_variation["lorentzian_width"] is True: + self._lorentzian_width_list = self._create_lorentzian_width_parameters( + self.lorentzian_width, self.Q + ) + else: + self._lorentzian_width_list = [] def _create_A0_A1_parameters( self, A_0: Parameter, Q: Q_type @@ -590,7 +647,7 @@ def _create_A0_A1_parameters( for i, Q_value in enumerate(Q): A_0_list.append( Parameter( - name=f'A_0_Q{Q_value:.2f}', + name=f"A_0_Q{Q_value:.2f}", value=float(A_0.value), fixed=False, min=0.0, @@ -599,9 +656,9 @@ def _create_A0_A1_parameters( ) A_1_list.append( Parameter.from_dependency( - name=f'A_1_Q{Q_value:.2f}', - dependency_expression='1 - A_0', - dependency_map={'A_0': A_0_list[i]}, + name=f"A_1_Q{Q_value:.2f}", + dependency_expression="1 - A_0", + dependency_map={"A_0": A_0_list[i]}, ) ) @@ -628,7 +685,7 @@ def _create_lorentzian_width_parameters( for i, Q_value in enumerate(Q): lorentzian_width_list.append( Parameter( - name=f'lorentzian_width_Q{Q_value:.2f}', + name=f"lorentzian_width_Q{Q_value:.2f}", value=float(lorentzian_width.value), fixed=False, min=MINIMUM_WIDTH, @@ -659,9 +716,9 @@ def _write_lorz_width_dependency_expression(self, Q: float) -> str: Dependency expression for the width. """ if not isinstance(Q, (float)): - raise TypeError('Q must be a float.') + raise TypeError("Q must be a float.") - return 'lorentzian_width' + return "lorentzian_width" def _write_width_dependency_map_expression( self, @@ -675,7 +732,7 @@ def _write_width_dependency_map_expression( Dependency map for the width. """ return { - 'lorentzian_width': self.lorentzian_width, + "lorentzian_width": self.lorentzian_width, } def _write_lorz_area_dependency_expression(self, Q: float) -> str: @@ -698,9 +755,9 @@ def _write_lorz_area_dependency_expression(self, Q: float) -> str: Dependency expression for the area. """ if not isinstance(Q, (float)): - raise TypeError('Q must be a float.') + raise TypeError("Q must be a float.") - return f'scale * exp(-mean_u_squared.value * {Q}**2 / 3) * A_1' + return f"scale * exp(-mean_u_squared.value * {Q}**2 / 3) * A_1" def _write_lorz_area_dependency_map_expression( self, Q_index: int | None @@ -715,15 +772,15 @@ def _write_lorz_area_dependency_map_expression( """ if Q_index is None: return { - 'scale': self.scale, - 'mean_u_squared': self.mean_u_squared, - 'A_1': self.A_1, + "scale": self.scale, + "mean_u_squared": self.mean_u_squared, + "A_1": self.A_1, } return { - 'scale': self.scale, - 'mean_u_squared': self.mean_u_squared, - 'A_1': self._A_1_list[Q_index], + "scale": self.scale, + "mean_u_squared": self.mean_u_squared, + "A_1": self._A_1_list[Q_index], } def _write_delta_area_dependency_expression(self, Q: float) -> str: @@ -746,9 +803,9 @@ def _write_delta_area_dependency_expression(self, Q: float) -> str: Dependency expression for the area. """ if not isinstance(Q, (float)): - raise TypeError('Q must be a float.') + raise TypeError("Q must be a float.") - return f'scale * exp(-mean_u_squared.value * {Q}**2 / 3) * A_0' + return f"scale * exp(-mean_u_squared.value * {Q}**2 / 3) * A_0" def _write_delta_area_dependency_map_expression( self, @@ -770,14 +827,14 @@ def _write_delta_area_dependency_map_expression( """ if Q_index is None: return { - 'scale': self.scale, - 'mean_u_squared': self.mean_u_squared, - 'A_0': self.A_0, + "scale": self.scale, + "mean_u_squared": self.mean_u_squared, + "A_0": self.A_0, } return { - 'scale': self.scale, - 'mean_u_squared': self.mean_u_squared, - 'A_0': self._A_0_list[Q_index], + "scale": self.scale, + "mean_u_squared": self.mean_u_squared, + "A_0": self._A_0_list[Q_index], } # ------------------------------------------------------------------ @@ -794,10 +851,10 @@ def __repr__(self) -> str: String representation of the DeltaLorentz model. """ return ( - f'DeltaLorentz(display_name={self.display_name},' - f'unit={self.unit}, \n' - f' mean_u_squared={self.mean_u_squared}, \n' - f' A_0={self.A_0}, A_1={self.A_1}, \n' - f' lorentzian_width={self.lorentzian_width}, \n' - f' scale={self.scale})' + f"DeltaLorentz(display_name={self.display_name}," + f"unit={self.unit}, \n" + f" mean_u_squared={self.mean_u_squared}, \n" + f" A_0={self.A_0}, A_1={self.A_1}, \n" + f" lorentzian_width={self.lorentzian_width}, \n" + f" scale={self.scale})" ) diff --git a/src/easydynamics/sample_model/diffusion_model/diffusion_model_base.py b/src/easydynamics/sample_model/diffusion_model/diffusion_model_base.py index 9a0632c3..38beb671 100644 --- a/src/easydynamics/sample_model/diffusion_model/diffusion_model_base.py +++ b/src/easydynamics/sample_model/diffusion_model/diffusion_model_base.py @@ -20,9 +20,9 @@ def __init__( self, scale: Numeric = 1.0, Q: Q_type | None = None, - unit: str | sc.Unit = 'meV', - name: str = 'DiffusionModel', - display_name: str | None = 'MyDiffusionModel', + unit: str | sc.Unit = "meV", + name: str = "DiffusionModel", + display_name: str | None = "MyDiffusionModel", unique_name: str | None = None, ) -> None: """ @@ -55,19 +55,23 @@ def __init__( self._Q = _validate_and_convert_Q(Q) try: - test = DescriptorNumber(name='test', value=1, unit=unit) - test.convert_unit('meV') + test = DescriptorNumber(name="test", value=1, unit=unit) + test.convert_unit("meV") except Exception as e: raise UnitError( - f'Invalid unit: {unit}. Unit must be a string or scipp Unit and convertible to meV.' # noqa: E501 + f"Invalid unit: {unit}. Unit must be a string or scipp Unit and convertible to meV." # noqa: E501 ) from e if not isinstance(scale, Numeric): - raise TypeError('scale must be a number.') + raise TypeError("scale must be a number.") - scale = Parameter(name='scale', value=float(scale), fixed=False, min=0.0, unit=unit) + scale = Parameter( + name="scale", value=float(scale), fixed=False, min=0.0, unit=unit + ) - super().__init__(unit=unit, name=name, display_name=display_name, unique_name=unique_name) + super().__init__( + unit=unit, name=name, display_name=display_name, unique_name=unique_name + ) self._scale = scale # ------------------------------------------------------------------ @@ -104,10 +108,10 @@ def scale(self, scale: Numeric) -> None: If scale is negative. """ if not isinstance(scale, Numeric): - raise TypeError('scale must be a number.') + raise TypeError("scale must be a number.") if float(scale) < 0: - raise ValueError('scale must be non-negative.') + raise ValueError("scale must be non-negative.") self._scale.value = float(scale) @property @@ -152,8 +156,8 @@ def Q(self, value: Q_type | None) -> None: if len(old_Q) != len(new_Q) or not np.allclose(old_Q, new_Q): raise ValueError( - 'New Q values are not similar to the old ones. ' - 'To change Q values, first run clear_Q().' + "New Q values are not similar to the old ones. " + "To change Q values, first run clear_Q()." ) def clear_Q(self, confirm: bool = False) -> None: @@ -173,11 +177,18 @@ def clear_Q(self, confirm: bool = False) -> None: """ if not confirm: raise ValueError( - 'Clearing Q values requires confirmation. Set confirm=True to proceed.' + "Clearing Q values requires confirmation. Set confirm=True to proceed." ) self._Q = None self._on_Q_change() + # ------------------------------------------------------------------ + # private methods + # ------------------------------------------------------------------ + + def _on_Q_change(self) -> None: + """Handle changes to the Q values.""" + # ------------------------------------------------------------------ # dunder methods # ------------------------------------------------------------------ @@ -192,7 +203,7 @@ def __repr__(self) -> str: String representation of the DiffusionModel. """ return ( - f'{self.__class__.__name__}(name={self.name}, display_name={self.display_name}, ' - f'unit={self.unit}), \n' - f' scale={self.scale})' + f"{self.__class__.__name__}(name={self.name}, display_name={self.display_name}, " + f"unit={self.unit}), \n" + f" scale={self.scale})" ) From 0186e9e12d016a59bc9eae2bdba93b3a38650ae0 Mon Sep 17 00:00:00 2001 From: henrikjacobsenfys Date: Fri, 22 May 2026 10:39:21 +0200 Subject: [PATCH 09/18] Fix some tests --- docs/docs/tutorials/diffusion_model.ipynb | 3 +- .../brownian_translational_diffusion.py | 87 ++++----- .../diffusion_model/delta_lorentz.py | 29 +-- .../diffusion_model/diffusion_model_base.py | 26 +++ .../jump_translational_diffusion.py | 104 +++++------ .../test_brownian_translational_diffusion.py | 152 ++++++++-------- .../test_jump_translational_diffusion.py | 166 +++++++++--------- 7 files changed, 278 insertions(+), 289 deletions(-) diff --git a/docs/docs/tutorials/diffusion_model.ipynb b/docs/docs/tutorials/diffusion_model.ipynb index b64fe0b4..85d19246 100644 --- a/docs/docs/tutorials/diffusion_model.ipynb +++ b/docs/docs/tutorials/diffusion_model.ipynb @@ -44,9 +44,10 @@ " display_name='DiffusionModel',\n", " scale=scale,\n", " diffusion_coefficient=diffusion_coefficient,\n", + " Q=Q\n", ")\n", "\n", - "component_collections = diffusion_model.create_component_collections(Q)\n", + "component_collections = diffusion_model.create_component_collections()\n", "\n", "\n", "cmap = plt.cm.jet\n", diff --git a/src/easydynamics/sample_model/diffusion_model/brownian_translational_diffusion.py b/src/easydynamics/sample_model/diffusion_model/brownian_translational_diffusion.py index 0376b159..937c0409 100644 --- a/src/easydynamics/sample_model/diffusion_model/brownian_translational_diffusion.py +++ b/src/easydynamics/sample_model/diffusion_model/brownian_translational_diffusion.py @@ -9,10 +9,11 @@ from easydynamics.sample_model.component_collection import ComponentCollection from easydynamics.sample_model.components import Lorentzian -from easydynamics.sample_model.diffusion_model.diffusion_model_base import DiffusionModelBase +from easydynamics.sample_model.diffusion_model.diffusion_model_base import ( + DiffusionModelBase, +) from easydynamics.utils.utils import Numeric from easydynamics.utils.utils import Q_type -from easydynamics.utils.utils import _validate_and_convert_Q from easydynamics.utils.utils import angstrom from easydynamics.utils.utils import hbar @@ -43,9 +44,9 @@ def __init__( scale: Numeric = 1.0, diffusion_coefficient: Numeric = 1.0, Q: Q_type | None = None, - unit: str | sc.Unit = 'meV', - name: str = 'BrownianTranslationalDiffusion', - display_name: str | None = 'BrownianTranslationalDiffusion', + unit: str | sc.Unit = "meV", + name: str = "BrownianTranslationalDiffusion", + display_name: str | None = "BrownianTranslationalDiffusion", unique_name: str | None = None, ) -> None: """ @@ -75,16 +76,16 @@ def __init__( If scale or diffusion_coefficient is not a number. """ if not isinstance(scale, Numeric): - raise TypeError('scale must be a number.') + raise TypeError("scale must be a number.") if not isinstance(diffusion_coefficient, Numeric): - raise TypeError('diffusion_coefficient must be a number.') + raise TypeError("diffusion_coefficient must be a number.") diffusion_coefficient = Parameter( - name='diffusion_coefficient', + name="diffusion_coefficient", value=float(diffusion_coefficient), fixed=False, - unit='m**2/s', + unit="m**2/s", min=0.0, ) super().__init__( @@ -133,10 +134,10 @@ def diffusion_coefficient(self, diffusion_coefficient: Numeric) -> None: If diffusion_coefficient is negative. """ if not isinstance(diffusion_coefficient, Numeric): - raise TypeError('diffusion_coefficient must be a number.') + raise TypeError("diffusion_coefficient must be a number.") if float(diffusion_coefficient) < 0: - raise ValueError('diffusion_coefficient must be non-negative.') + raise ValueError("diffusion_coefficient must be non-negative.") self._diffusion_coefficient.value = float(diffusion_coefficient) # ------------------------------------------------------------------ @@ -157,15 +158,11 @@ def calculate_width(self, Q: Q_type | None = None) -> np.ndarray: np.ndarray HWHM values in the unit of the model (e.g., meV). """ - if Q is None: - Q = self.Q - if Q is None: - raise ValueError( - 'Q must be provided either as an argument or set as a property of the model.' - ) - Q = _validate_and_convert_Q(Q) + Q = self._ensure_Q(Q) - unit_conversion_factor = self._hbar * self.diffusion_coefficient / (self._angstrom**2) + unit_conversion_factor = ( + self._hbar * self.diffusion_coefficient / (self._angstrom**2) + ) unit_conversion_factor.convert_unit(self.unit) return Q**2 * unit_conversion_factor.value @@ -184,13 +181,8 @@ def calculate_EISF(self, Q: Q_type | None = None) -> np.ndarray: np.ndarray EISF values (dimensionless). """ - if Q is None: - Q = self.Q - if Q is None: - raise ValueError( - 'Q must be provided either as an argument or set as a property of the model.' - ) - Q = _validate_and_convert_Q(Q) + Q = self._ensure_Q(Q) + return np.zeros_like(Q) def calculate_QISF(self, Q: Q_type | None = None) -> np.ndarray: @@ -207,18 +199,13 @@ def calculate_QISF(self, Q: Q_type | None = None) -> np.ndarray: np.ndarray QISF values (dimensionless). """ - if Q is None: - Q = self.Q - if Q is None: - raise ValueError( - 'Q must be provided either as an argument or set as a property of the model.' - ) - Q = _validate_and_convert_Q(Q) + Q = self._ensure_Q(Q) + return np.ones_like(Q) def create_component_collections( self, - component_name: str = 'Brownian diffusion', + component_name: str = "Brownian diffusion", component_display_name: str | None = None, ) -> list[ComponentCollection]: r""" @@ -250,13 +237,13 @@ def create_component_collections( return self._component_collections if not isinstance(component_name, str): - raise TypeError('component_name must be a string.') + raise TypeError("component_name must be a string.") if component_display_name is None: component_display_name = component_name if not isinstance(component_display_name, str): - raise TypeError('component_display_name must be a string.') + raise TypeError("component_display_name must be a string.") component_collection_list = [None] * len(Q) # In more complex models, this is used to scale the area of the @@ -268,8 +255,8 @@ def create_component_collections( # No delta function, as the EISF is 0. for i, Q_value in enumerate(Q): component_collection_list[i] = ComponentCollection( - name=f'{self.name}_Q{Q_value:.2f}', - display_name=f'{self.display_name}_Q{Q_value:.2f}', + name=f"{self.name}_Q{Q_value:.2f}", + display_name=f"{self.display_name}_Q{Q_value:.2f}", unit=self.unit, ) @@ -325,10 +312,10 @@ def _write_width_dependency_expression(self, Q: float) -> str: Dependency expression for the width. """ if not isinstance(Q, (float)): - raise TypeError('Q must be a float.') + raise TypeError("Q must be a float.") # Q is given as a float, so we need to add the units - return f'hbar * D* {Q} **2*1/(angstrom**2)' + return f"hbar * D* {Q} **2*1/(angstrom**2)" def _write_width_dependency_map_expression(self) -> dict[str, DescriptorNumber]: """ @@ -340,9 +327,9 @@ def _write_width_dependency_map_expression(self) -> dict[str, DescriptorNumber]: Dependency map for the width. """ return { - 'D': self.diffusion_coefficient, - 'hbar': self._hbar, - 'angstrom': self._angstrom, + "D": self.diffusion_coefficient, + "hbar": self._hbar, + "angstrom": self._angstrom, } def _write_area_dependency_expression(self, QISF: float) -> str: @@ -365,9 +352,9 @@ def _write_area_dependency_expression(self, QISF: float) -> str: Dependency expression for the area. """ if not isinstance(QISF, (float)): - raise TypeError('QISF must be a float.') + raise TypeError("QISF must be a float.") - return f'{QISF} * scale' + return f"{QISF} * scale" def _write_area_dependency_map_expression(self) -> dict[str, DescriptorNumber]: """ @@ -379,7 +366,7 @@ def _write_area_dependency_map_expression(self) -> dict[str, DescriptorNumber]: Dependency map for the area. """ return { - 'scale': self.scale, + "scale": self.scale, } # ------------------------------------------------------------------ @@ -396,8 +383,8 @@ def __repr__(self) -> str: String representation of the BrownianTranslationalDiffusion model. """ return ( - f'BrownianTranslationalDiffusion(name={self.name}, ' - f'display_name={self.display_name}, \n' - f' diffusion_coefficient={self.diffusion_coefficient}, \n' - f' scale={self.scale})' + f"BrownianTranslationalDiffusion(name={self.name}, " + f"display_name={self.display_name}, \n" + f" diffusion_coefficient={self.diffusion_coefficient}, \n" + f" scale={self.scale})" ) diff --git a/src/easydynamics/sample_model/diffusion_model/delta_lorentz.py b/src/easydynamics/sample_model/diffusion_model/delta_lorentz.py index 5d038f41..7433fd0a 100644 --- a/src/easydynamics/sample_model/diffusion_model/delta_lorentz.py +++ b/src/easydynamics/sample_model/diffusion_model/delta_lorentz.py @@ -15,7 +15,6 @@ ) from easydynamics.utils.utils import Numeric from easydynamics.utils.utils import Q_type -from easydynamics.utils.utils import _validate_and_convert_Q MINIMUM_WIDTH = 1e-10 # To avoid division by zero @@ -30,7 +29,7 @@ class DeltaLorentz(DiffusionModelBase): the scattering vector, $A_0$ and $A_1$ are the relative amplitudes of the delta function and Lorentzian, respectively, with the constraint that $A_0+A_1=1$, and $L(E, \Gamma)$ is the Lorentzian function with width $\Gamma$. $A_0$, $A_1$ and the width of the Lorentzian can be - Q-dependent or not. + the same at all $Q$ or be allowed to vary with $Q$. Examples @@ -372,13 +371,7 @@ def calculate_width(self, Q: Q_type) -> np.ndarray: ] return np.array(widths) - if Q is None: - Q = self.Q - if Q is None: - raise ValueError( - "Q must be provided either as an argument or set in the model." - ) - Q = _validate_and_convert_Q(Q) + Q = self._ensure_Q(Q) widths = self.lorentzian_width.value * np.ones_like(Q) @@ -403,13 +396,7 @@ def calculate_EISF(self, Q: Q_type) -> np.ndarray: return np.exp(-self.mean_u_squared.value * Q**2 / 3) * np.array(A_0_values) # Need to handle units better - if Q is None: - Q = self.Q - if Q is None: - raise ValueError( - "Q must be provided either as an argument or set in the model." - ) - Q = _validate_and_convert_Q(Q) + Q = self._ensure_Q(Q) A_0_values = [self.A_0.value] * len(Q) return np.exp(-self.mean_u_squared.value * Q**2 / 3) * np.array(A_0_values) @@ -432,14 +419,7 @@ def calculate_QISF(self, Q: Q_type) -> np.ndarray: A_1_values = [A_1_.value for A_1_ in self._A_1_list] return np.exp(-self.mean_u_squared.value * Q**2 / 3) * np.array(A_1_values) - if Q is None: - Q = self.Q - if Q is None: - raise ValueError( - "Q must be provided either as an argument or set in the model." - ) - - Q = _validate_and_convert_Q(Q) + Q = self._ensure_Q(Q) A_1_values = [self.A_1.value] * len(Q) return np.exp(-self.mean_u_squared.value * Q**2 / 3) * np.array(A_1_values) @@ -605,6 +585,7 @@ def get_all_variables(self, Q_index: int | None = None) -> list[DescriptorNumber # ------------------------------------------------------------------ # Private methods # ------------------------------------------------------------------ + def _on_Q_change(self) -> None: """Handle changes to the Q values. Updates the A_0, A_1 and lorentzian_width parameters if they are allowed to vary with Q.""" if self.Q is None: diff --git a/src/easydynamics/sample_model/diffusion_model/diffusion_model_base.py b/src/easydynamics/sample_model/diffusion_model/diffusion_model_base.py index 38beb671..2c01dd5f 100644 --- a/src/easydynamics/sample_model/diffusion_model/diffusion_model_base.py +++ b/src/easydynamics/sample_model/diffusion_model/diffusion_model_base.py @@ -189,6 +189,32 @@ def clear_Q(self, confirm: bool = False) -> None: def _on_Q_change(self) -> None: """Handle changes to the Q values.""" + def _ensure_Q(self, Q: Q_type) -> np.ndarray: + """ + Convert Q to a numpy array, ensuring it is not None. + Uses the stored Q if no input is given. + + Parameters: + ----------- + Q : Q_type + The Q to be checked + + Raises: + ------- + ValueError + If the provided Q and self.Q are both None + + + """ + if Q is None: + Q = self.Q + if Q is None: + raise ValueError( + "Q must be provided either as an argument or set in the model." + ) + + return _validate_and_convert_Q(Q) + # ------------------------------------------------------------------ # dunder methods # ------------------------------------------------------------------ diff --git a/src/easydynamics/sample_model/diffusion_model/jump_translational_diffusion.py b/src/easydynamics/sample_model/diffusion_model/jump_translational_diffusion.py index 1bd81c1f..a2d955dd 100644 --- a/src/easydynamics/sample_model/diffusion_model/jump_translational_diffusion.py +++ b/src/easydynamics/sample_model/diffusion_model/jump_translational_diffusion.py @@ -9,10 +9,11 @@ from easydynamics.sample_model.component_collection import ComponentCollection from easydynamics.sample_model.components import Lorentzian -from easydynamics.sample_model.diffusion_model.diffusion_model_base import DiffusionModelBase +from easydynamics.sample_model.diffusion_model.diffusion_model_base import ( + DiffusionModelBase, +) from easydynamics.utils.utils import Numeric from easydynamics.utils.utils import Q_type -from easydynamics.utils.utils import _validate_and_convert_Q from easydynamics.utils.utils import angstrom from easydynamics.utils.utils import hbar @@ -52,9 +53,9 @@ def __init__( diffusion_coefficient: Numeric = 1.0, relaxation_time: Numeric = 1.0, Q: Q_type | None = None, - unit: str | sc.Unit = 'meV', - name: str = 'JumpTranslationalDiffusion', - display_name: str | None = 'JumpTranslationalDiffusion', + unit: str | sc.Unit = "meV", + name: str = "JumpTranslationalDiffusion", + display_name: str | None = "JumpTranslationalDiffusion", unique_name: str | None = None, ) -> None: """ @@ -95,23 +96,23 @@ def __init__( ) if not isinstance(diffusion_coefficient, Numeric): - raise TypeError('diffusion_coefficient must be a number.') + raise TypeError("diffusion_coefficient must be a number.") if not isinstance(relaxation_time, Numeric): - raise TypeError('relaxation_time must be a number.') + raise TypeError("relaxation_time must be a number.") diffusion_coefficient = Parameter( - name='diffusion_coefficient', + name="diffusion_coefficient", value=float(diffusion_coefficient), fixed=False, - unit='m**2/s', + unit="m**2/s", ) relaxation_time = Parameter( - name='relaxation_time', + name="relaxation_time", value=float(relaxation_time), fixed=False, - unit='ps', + unit="ps", ) self._hbar = hbar @@ -153,9 +154,9 @@ def diffusion_coefficient(self, diffusion_coefficient: Numeric) -> None: If diffusion_coefficient is negative. """ if not isinstance(diffusion_coefficient, Numeric): - raise TypeError('diffusion_coefficient must be a number.') + raise TypeError("diffusion_coefficient must be a number.") if float(diffusion_coefficient) < 0: - raise ValueError('diffusion_coefficient must be non-negative.') + raise ValueError("diffusion_coefficient must be non-negative.") self._diffusion_coefficient.value = float(diffusion_coefficient) @property @@ -188,10 +189,10 @@ def relaxation_time(self, relaxation_time: Numeric) -> None: If relaxation_time is negative. """ if not isinstance(relaxation_time, Numeric): - raise TypeError('relaxation_time must be a number.') + raise TypeError("relaxation_time must be a number.") if float(relaxation_time) < 0: - raise ValueError('relaxation_time must be non-negative.') + raise ValueError("relaxation_time must be non-negative.") self._relaxation_time.value = float(relaxation_time) ################################ @@ -214,15 +215,8 @@ def calculate_width(self, Q: Q_type | None = None) -> np.ndarray: np.ndarray HWHM values in the unit of the model (e.g., meV). """ - if Q is None: - Q = self.Q - if Q is None: - raise ValueError( - 'Q values must be provided either during initialization or as an argument to ' - 'calculate_width.' - ) - Q = _validate_and_convert_Q(Q) + Q = self._ensure_Q(Q) unit_conversion_factor_numerator = ( self._hbar * self.diffusion_coefficient / (self._angstrom**2) @@ -234,7 +228,7 @@ def calculate_width(self, Q: Q_type | None = None) -> np.ndarray: unit_conversion_factor_denominator = ( self.diffusion_coefficient / self._angstrom**2 * self.relaxation_time ) - unit_conversion_factor_denominator.convert_unit('dimensionless') + unit_conversion_factor_denominator.convert_unit("dimensionless") denominator = 1 + unit_conversion_factor_denominator.value * Q**2 @@ -254,14 +248,8 @@ def calculate_EISF(self, Q: Q_type) -> np.ndarray: np.ndarray EISF values (dimensionless). """ - if Q is None: - Q = self.Q - if Q is None: - raise ValueError( - 'Q values must be provided either during initialization or as an argument to ' - 'calculate_EISF.' - ) - Q = _validate_and_convert_Q(Q) + Q = self._ensure_Q(Q) + return np.zeros_like(Q) def calculate_QISF(self, Q: Q_type) -> np.ndarray: @@ -278,20 +266,14 @@ def calculate_QISF(self, Q: Q_type) -> np.ndarray: np.ndarray QISF values (dimensionless). """ - if Q is None: - Q = self.Q - if Q is None: - raise ValueError( - 'Q values must be provided either during initialization or as an argument to ' - 'calculate_QISF.' - ) - Q = _validate_and_convert_Q(Q) + Q = self._ensure_Q(Q) + return np.ones_like(Q) def create_component_collections( self, - component_name: str = 'Jump translational diffusion', - component_display_name: str = 'Jump translational diffusion', + component_name: str = "Jump translational diffusion", + component_display_name: str = "Jump translational diffusion", ) -> list[ComponentCollection]: """ Create ComponentCollection components for the diffusion model at given Q values. @@ -315,16 +297,14 @@ def create_component_collections( """ Q = self.Q if Q is None: - raise ValueError( - 'Q values must be set in the model to create component collections. Set Q values ' - 'during initialization or using the Q property.' - ) + self._component_collections = [] + return self._component_collections if not isinstance(component_display_name, str): - raise TypeError('component_display_name must be a string.') + raise TypeError("component_display_name must be a string.") if not isinstance(component_name, str): - raise TypeError('component_name must be a string.') + raise TypeError("component_name must be a string.") component_collection_list = [None] * len(Q) # In more complex models, this is used to scale the area of the @@ -336,8 +316,8 @@ def create_component_collections( # is 0. for i, Q_value in enumerate(Q): component_collection_list[i] = ComponentCollection( - name=f'{self.name}_Q{Q_value:.2f}', - display_name=f'{self.display_name}_Q{Q_value:.2f}', + name=f"{self.name}_Q{Q_value:.2f}", + display_name=f"{self.display_name}_Q{Q_value:.2f}", unit=self.unit, ) @@ -393,10 +373,10 @@ def _write_width_dependency_expression(self, Q: float) -> str: Dependency expression for the width. """ if not isinstance(Q, (float)): - raise TypeError('Q must be a float.') + raise TypeError("Q must be a float.") # Q is given as a float, so we need to add the units - return f'hbar * D* {Q} **2/(angstrom**2)/(1 + (D * t* {Q} **2/(angstrom**2)))' + return f"hbar * D* {Q} **2/(angstrom**2)/(1 + (D * t* {Q} **2/(angstrom**2)))" def _write_width_dependency_map_expression(self) -> dict[str, DescriptorNumber]: """ @@ -408,10 +388,10 @@ def _write_width_dependency_map_expression(self) -> dict[str, DescriptorNumber]: Dependency map for the width. """ return { - 'D': self.diffusion_coefficient, - 't': self.relaxation_time, - 'hbar': self._hbar, - 'angstrom': self._angstrom, + "D": self.diffusion_coefficient, + "t": self.relaxation_time, + "hbar": self._hbar, + "angstrom": self._angstrom, } def _write_area_dependency_expression(self, QISF: float) -> str: @@ -435,9 +415,9 @@ def _write_area_dependency_expression(self, QISF: float) -> str: """ if not isinstance(QISF, (float)): - raise TypeError('QISF must be a float.') + raise TypeError("QISF must be a float.") - return f'{QISF} * scale' + return f"{QISF} * scale" def _write_area_dependency_map_expression(self) -> dict[str, DescriptorNumber]: """ @@ -449,7 +429,7 @@ def _write_area_dependency_map_expression(self) -> dict[str, DescriptorNumber]: Dependency map for the area. """ return { - 'scale': self.scale, + "scale": self.scale, } ################################ @@ -466,7 +446,7 @@ def __repr__(self) -> str: String representation of the JumpTranslationalDiffusion model. """ return ( - f'JumpTranslationalDiffusion(name={self.name}, display_name={self.display_name},\n ' - f' diffusion_coefficient={self.diffusion_coefficient}, \n' - f' scale={self.scale})' + f"JumpTranslationalDiffusion(name={self.name}, display_name={self.display_name},\n " + f" diffusion_coefficient={self.diffusion_coefficient}, \n" + f" scale={self.scale})" ) diff --git a/tests/unit/easydynamics/sample_model/diffusion_model/test_brownian_translational_diffusion.py b/tests/unit/easydynamics/sample_model/diffusion_model/test_brownian_translational_diffusion.py index 4ffd2a5e..8be1192c 100644 --- a/tests/unit/easydynamics/sample_model/diffusion_model/test_brownian_translational_diffusion.py +++ b/tests/unit/easydynamics/sample_model/diffusion_model/test_brownian_translational_diffusion.py @@ -12,9 +12,9 @@ BrownianTranslationalDiffusion, ) -hbar_1 = DescriptorNumber('hbar', 1.0) -hbar = DescriptorNumber.from_scipp('hbar', scipp_hbar) -angstrom = DescriptorNumber('angstrom', 1e-10, unit='m') +hbar_1 = DescriptorNumber("hbar", 1.0) +hbar = DescriptorNumber.from_scipp("hbar", scipp_hbar) +angstrom = DescriptorNumber("angstrom", 1e-10, unit="m") class TestBrownianTranslationalDiffusion: @@ -24,68 +24,82 @@ def brownian_diffusion_model(self): def test_init_default(self, brownian_diffusion_model): # WHEN THEN EXPECT - assert brownian_diffusion_model.display_name == 'BrownianTranslationalDiffusion' - assert brownian_diffusion_model.unit == 'meV' + assert brownian_diffusion_model.display_name == "BrownianTranslationalDiffusion" + assert brownian_diffusion_model.unit == "meV" assert brownian_diffusion_model.scale.value == pytest.approx(1.0) - assert brownian_diffusion_model.diffusion_coefficient.value == pytest.approx(1.0) + assert brownian_diffusion_model.diffusion_coefficient.value == pytest.approx( + 1.0 + ) @pytest.mark.parametrize( - 'kwargs,expected_exception, expected_message', + "kwargs,expected_exception, expected_message", [ ( { - 'unit': 123, - 'scale': 1.0, - 'diffusion_coefficient': 1.0, + "unit": 123, + "scale": 1.0, + "diffusion_coefficient": 1.0, }, UnitError, - 'Invalid unit', + "Invalid unit", ), ( { - 'unit': 123, - 'scale': 'invalid', - 'diffusion_coefficient': 1.0, + "unit": 123, + "scale": "invalid", + "diffusion_coefficient": 1.0, }, TypeError, - 'scale must be a number', + "scale must be a number", ), ( { - 'unit': 123, - 'scale': 1.0, - 'diffusion_coefficient': 'invalid', + "unit": 123, + "scale": 1.0, + "diffusion_coefficient": "invalid", }, TypeError, - 'diffusion_coefficient must be a number', + "diffusion_coefficient must be a number", ), ], ) - def test_input_type_validation_raises(self, kwargs, expected_exception, expected_message): + def test_input_type_validation_raises( + self, kwargs, expected_exception, expected_message + ): with pytest.raises(expected_exception, match=expected_message): - BrownianTranslationalDiffusion(display_name='BrownianTranslationalDiffusion', **kwargs) + BrownianTranslationalDiffusion( + display_name="BrownianTranslationalDiffusion", **kwargs + ) def test_diffusion_coefficient_setter(self, brownian_diffusion_model): # WHEN brownian_diffusion_model.diffusion_coefficient = 3.0 # THEN EXPECT - assert brownian_diffusion_model.diffusion_coefficient.value == pytest.approx(3.0) + assert brownian_diffusion_model.diffusion_coefficient.value == pytest.approx( + 3.0 + ) def test_diffusion_coefficient_setter_raises(self, brownian_diffusion_model): # WHEN THEN EXPECT - with pytest.raises(TypeError, match=r'diffusion_coefficient must be a number.'): - brownian_diffusion_model.diffusion_coefficient = 'invalid' # Invalid type + with pytest.raises(TypeError, match=r"diffusion_coefficient must be a number."): + brownian_diffusion_model.diffusion_coefficient = "invalid" # Invalid type - def test_diffusion_coefficient_setter_negative_raises(self, brownian_diffusion_model): + def test_diffusion_coefficient_setter_negative_raises( + self, brownian_diffusion_model + ): # WHEN THEN EXPECT - with pytest.raises(ValueError, match=r'diffusion_coefficient must be non-negative.'): - brownian_diffusion_model.diffusion_coefficient = -1.0 # Invalid negative value + with pytest.raises( + ValueError, match=r"diffusion_coefficient must be non-negative." + ): + brownian_diffusion_model.diffusion_coefficient = ( + -1.0 + ) # Invalid negative value def test_calculate_width_type_error(self, brownian_diffusion_model): # WHEN THEN EXPECT - with pytest.raises(TypeError, match='Q must be '): - brownian_diffusion_model.calculate_width(Q='invalid') # Invalid type + with pytest.raises(TypeError, match="Q must be "): + brownian_diffusion_model.calculate_width(Q="invalid") # Invalid type def test_calculate_width(self, brownian_diffusion_model): # WHEN @@ -99,8 +113,8 @@ def test_calculate_width(self, brownian_diffusion_model): 1 * sc.Unit(brownian_diffusion_model.diffusion_coefficient.unit) * scipp_hbar - / (1 * sc.Unit('Å') ** 2), - 'meV', + / (1 * sc.Unit("Å") ** 2), + "meV", ) expected_widths = 1.0 * unit_conversion_factor.value * (Q_values**2) np.testing.assert_allclose(widths, expected_widths, rtol=1e-5) @@ -118,8 +132,8 @@ def test_calculate_EISF(self, brownian_diffusion_model): def test_calculate_EISF_type_error(self, brownian_diffusion_model): # WHEN THEN EXPECT - with pytest.raises(TypeError, match='Q must be '): - brownian_diffusion_model.calculate_EISF(Q='invalid') # Invalid type + with pytest.raises(TypeError, match="Q must be "): + brownian_diffusion_model.calculate_EISF(Q="invalid") # Invalid type def test_calculate_QISF(self, brownian_diffusion_model): # WHEN @@ -134,27 +148,27 @@ def test_calculate_QISF(self, brownian_diffusion_model): def test_calculate_QISF_type_error(self, brownian_diffusion_model): # WHEN THEN EXPECT - with pytest.raises(TypeError, match='Q must be '): - brownian_diffusion_model.calculate_QISF(Q='invalid') # Invalid type + with pytest.raises(TypeError, match="Q must be "): + brownian_diffusion_model.calculate_QISF(Q="invalid") # Invalid type @pytest.mark.parametrize( - 'Q', + "Q", [ (0.5), ([1.0, 2.0, 3.0]), (np.array([1.0, 2.0, 3.0])), ], ids=[ - 'python_scalar', - 'python_list', - 'numpy_array', + "python_scalar", + "python_list", + "numpy_array", ], ) def test_create_component_collections(self, brownian_diffusion_model, Q): # WHEN - + brownian_diffusion_model.Q = Q # THEN - component_collections = brownian_diffusion_model.create_component_collections(Q=Q) + component_collections = brownian_diffusion_model.create_component_collections() # EXPECT expected_widths = brownian_diffusion_model.calculate_width(Q) @@ -169,67 +183,63 @@ def test_create_component_collections(self, brownian_diffusion_model, Q): def test_create_component_collections_component_name_must_be_string( self, brownian_diffusion_model ): + brownian_diffusion_model.Q = ( + 0.5 # Set a valid Q value to ensure we get past the Q check + ) # WHEN THEN EXPECT - with pytest.raises(TypeError, match=r'component_name must be a string.'): - brownian_diffusion_model.create_component_collections( - Q=np.array([0.1, 0.2, 0.3]), component_name=123 - ) + with pytest.raises(TypeError, match=r"component_name must be a string."): + brownian_diffusion_model.create_component_collections(component_name=123) def test_create_component_collections_component_display_name_must_be_string( self, brownian_diffusion_model ): + brownian_diffusion_model.Q = ( + 0.5 # Set a valid Q value to ensure we get past the Q check + ) # WHEN THEN EXPECT - with pytest.raises(TypeError, match=r'component_display_name must be a string.'): + with pytest.raises( + TypeError, match=r"component_display_name must be a string." + ): brownian_diffusion_model.create_component_collections( - Q=np.array([0.1, 0.2, 0.3]), component_display_name=123 + component_display_name=123 ) - def test_create_component_collections_Q_type_error(self, brownian_diffusion_model): - # WHEN THEN EXPECT - with pytest.raises(TypeError, match='Q must be a '): - brownian_diffusion_model.create_component_collections(Q='invalid') # Invalid type - - def test_create_component_collections_Q_1dimensional_error(self, brownian_diffusion_model): - # WHEN THEN EXPECT - with pytest.raises(ValueError, match=r'Q must be a 1-dimensional array.'): - brownian_diffusion_model.create_component_collections( - Q=np.array([[0.1, 0.2], [0.3, 0.4]]) - ) # Invalid shape - def test_write_width_dependency_expression(self, brownian_diffusion_model): # WHEN THEN expression = brownian_diffusion_model._write_width_dependency_expression(0.5) # EXPECT - expected_expression = 'hbar * D* 0.5 **2*1/(angstrom**2)' + expected_expression = "hbar * D* 0.5 **2*1/(angstrom**2)" assert expression == expected_expression def test_write_width_dependency_map_expression(self, brownian_diffusion_model): # WHEN THEN - expression_map = brownian_diffusion_model._write_width_dependency_map_expression() + expression_map = ( + brownian_diffusion_model._write_width_dependency_map_expression() + ) # EXPECT expected_map = { - 'D': brownian_diffusion_model.diffusion_coefficient, - 'hbar': brownian_diffusion_model._hbar, - 'angstrom': brownian_diffusion_model._angstrom, + "D": brownian_diffusion_model.diffusion_coefficient, + "hbar": brownian_diffusion_model._hbar, + "angstrom": brownian_diffusion_model._angstrom, } assert expression_map == expected_map def test_write_width_dependency_expression_raises(self, brownian_diffusion_model): - with pytest.raises(TypeError, match='Q must be a float'): - brownian_diffusion_model._write_width_dependency_expression('invalid') + with pytest.raises(TypeError, match="Q must be a float"): + brownian_diffusion_model._write_width_dependency_expression("invalid") def test_write_area_dependency_expression_raises(self, brownian_diffusion_model): - with pytest.raises(TypeError, match='QISF must be a float'): - brownian_diffusion_model._write_area_dependency_expression('invalid') + with pytest.raises(TypeError, match="QISF must be a float"): + brownian_diffusion_model._write_area_dependency_expression("invalid") def test_repr(self, brownian_diffusion_model): # WHEN THEN repr_str = repr(brownian_diffusion_model) # EXPECT - assert 'BrownianTranslationalDiffusion' in repr_str - assert 'diffusion_coefficient' in repr_str - assert 'scale=' in repr_str + assert "BrownianTranslationalDiffusion" in repr_str + assert "diffusion_coefficient" in repr_str + assert "scale=" in repr_str diff --git a/tests/unit/easydynamics/sample_model/diffusion_model/test_jump_translational_diffusion.py b/tests/unit/easydynamics/sample_model/diffusion_model/test_jump_translational_diffusion.py index d1fbf8ff..39c8fa6e 100644 --- a/tests/unit/easydynamics/sample_model/diffusion_model/test_jump_translational_diffusion.py +++ b/tests/unit/easydynamics/sample_model/diffusion_model/test_jump_translational_diffusion.py @@ -12,9 +12,9 @@ JumpTranslationalDiffusion, ) -hbar_1 = DescriptorNumber('hbar', 1.0) -hbar = DescriptorNumber.from_scipp('hbar', scipp_hbar) -angstrom = DescriptorNumber('angstrom', 1e-10, unit='m') +hbar_1 = DescriptorNumber("hbar", 1.0) +hbar = DescriptorNumber.from_scipp("hbar", scipp_hbar) +angstrom = DescriptorNumber("angstrom", 1e-10, unit="m") class TestJumpTranslationalDiffusion: @@ -24,60 +24,64 @@ def jump_diffusion_model(self): def test_init_default(self, jump_diffusion_model): # WHEN THEN EXPECT - assert jump_diffusion_model.display_name == 'JumpTranslationalDiffusion' - assert jump_diffusion_model.unit == 'meV' + assert jump_diffusion_model.display_name == "JumpTranslationalDiffusion" + assert jump_diffusion_model.unit == "meV" assert jump_diffusion_model.scale.value == pytest.approx(1.0) assert jump_diffusion_model.diffusion_coefficient.value == pytest.approx(1.0) assert jump_diffusion_model.relaxation_time.value == pytest.approx(1.0) @pytest.mark.parametrize( - 'kwargs,expected_exception, expected_message', + "kwargs,expected_exception, expected_message", [ ( { - 'unit': 123, - 'scale': 1.0, - 'diffusion_coefficient': 1.0, - 'relaxation_time': 1.0, + "unit": 123, + "scale": 1.0, + "diffusion_coefficient": 1.0, + "relaxation_time": 1.0, }, UnitError, - 'Invalid unit', + "Invalid unit", ), ( { - 'unit': 'meV', - 'scale': 'invalid', - 'diffusion_coefficient': 1.0, - 'relaxation_time': 1.0, + "unit": "meV", + "scale": "invalid", + "diffusion_coefficient": 1.0, + "relaxation_time": 1.0, }, TypeError, - 'scale must be a number', + "scale must be a number", ), ( { - 'unit': 'meV', - 'scale': 1.0, - 'diffusion_coefficient': 'invalid', - 'relaxation_time': 1.0, + "unit": "meV", + "scale": 1.0, + "diffusion_coefficient": "invalid", + "relaxation_time": 1.0, }, TypeError, - 'diffusion_coefficient must be a number', + "diffusion_coefficient must be a number", ), ( { - 'unit': 'meV', - 'scale': 1.0, - 'diffusion_coefficient': 1.0, - 'relaxation_time': 'invalid', + "unit": "meV", + "scale": 1.0, + "diffusion_coefficient": 1.0, + "relaxation_time": "invalid", }, TypeError, - 'relaxation_time must be a number', + "relaxation_time must be a number", ), ], ) - def test_input_type_validation_raises(self, kwargs, expected_exception, expected_message): + def test_input_type_validation_raises( + self, kwargs, expected_exception, expected_message + ): with pytest.raises(expected_exception, match=expected_message): - JumpTranslationalDiffusion(display_name='JumpTranslationalDiffusion', **kwargs) + JumpTranslationalDiffusion( + display_name="JumpTranslationalDiffusion", **kwargs + ) def test_diffusion_coefficient_setter(self, jump_diffusion_model): # WHEN @@ -88,12 +92,14 @@ def test_diffusion_coefficient_setter(self, jump_diffusion_model): def test_diffusion_coefficient_setter_raises(self, jump_diffusion_model): # WHEN THEN EXPECT - with pytest.raises(TypeError, match=r'diffusion_coefficient must be a number.'): - jump_diffusion_model.diffusion_coefficient = 'invalid' # Invalid type + with pytest.raises(TypeError, match=r"diffusion_coefficient must be a number."): + jump_diffusion_model.diffusion_coefficient = "invalid" # Invalid type def test_diffusion_coefficient_setter_negative_raises(self, jump_diffusion_model): # WHEN THEN EXPECT - with pytest.raises(ValueError, match=r'diffusion_coefficient must be non-negative.'): + with pytest.raises( + ValueError, match=r"diffusion_coefficient must be non-negative." + ): jump_diffusion_model.diffusion_coefficient = -1.0 # Invalid negative value def test_relaxation_time_setter(self, jump_diffusion_model): @@ -105,39 +111,42 @@ def test_relaxation_time_setter(self, jump_diffusion_model): def test_relaxation_time_setter_raises(self, jump_diffusion_model): # WHEN THEN EXPECT - with pytest.raises(TypeError, match=r'relaxation_time must be a number.'): - jump_diffusion_model.relaxation_time = 'invalid' # Invalid type + with pytest.raises(TypeError, match=r"relaxation_time must be a number."): + jump_diffusion_model.relaxation_time = "invalid" # Invalid type def test_relaxation_time_setter_negative_raises(self, jump_diffusion_model): # WHEN THEN EXPECT - with pytest.raises(ValueError, match=r'relaxation_time must be non-negative.'): + with pytest.raises(ValueError, match=r"relaxation_time must be non-negative."): jump_diffusion_model.relaxation_time = -1.0 # Invalid negative value def test_calculate_width_type_error(self, jump_diffusion_model): # WHEN THEN EXPECT - with pytest.raises(TypeError, match='Q must be '): - jump_diffusion_model.calculate_width(Q='invalid') # Invalid type + with pytest.raises(TypeError, match="Q must be "): + jump_diffusion_model.calculate_width(Q="invalid") # Invalid type def test_calculate_width(self, jump_diffusion_model): "Test the calculation relying solely on a scipp implementation" - 'instead of our Parameters' + "instead of our Parameters" # WHEN - Q_values = sc.linspace('Q', 0.5, 1.5, num=6, unit='1/angstrom') + Q_values = sc.linspace("Q", 0.5, 1.5, num=6, unit="1/angstrom") relaxation_time_sc = jump_diffusion_model.relaxation_time.value * sc.Unit( jump_diffusion_model.relaxation_time.unit ) - diffusion_coefficient_sc = jump_diffusion_model.diffusion_coefficient.value * sc.Unit( - jump_diffusion_model.diffusion_coefficient.unit + diffusion_coefficient_sc = ( + jump_diffusion_model.diffusion_coefficient.value + * sc.Unit(jump_diffusion_model.diffusion_coefficient.unit) ) # THEN widths = jump_diffusion_model.calculate_width(Q_values) denominator = diffusion_coefficient_sc * relaxation_time_sc * Q_values**2 - denominator = denominator.to(unit='1') + denominator = denominator.to(unit="1") # EXPECT - expected_widths = scipp_hbar * diffusion_coefficient_sc * (Q_values**2) / (1 + denominator) + expected_widths = ( + scipp_hbar * diffusion_coefficient_sc * (Q_values**2) / (1 + denominator) + ) expected_widths = expected_widths.to(unit=jump_diffusion_model.unit) @@ -156,8 +165,8 @@ def test_calculate_EISF(self, jump_diffusion_model): def test_calculate_EISF_type_error(self, jump_diffusion_model): # WHEN THEN EXPECT - with pytest.raises(TypeError, match='Q must be '): - jump_diffusion_model.calculate_EISF(Q='invalid') # Invalid type + with pytest.raises(TypeError, match="Q must be "): + jump_diffusion_model.calculate_EISF(Q="invalid") # Invalid type def test_calculate_QISF(self, jump_diffusion_model): # WHEN @@ -172,27 +181,28 @@ def test_calculate_QISF(self, jump_diffusion_model): def test_calculate_QISF_type_error(self, jump_diffusion_model): # WHEN THEN EXPECT - with pytest.raises(TypeError, match='Q must be '): - jump_diffusion_model.calculate_QISF(Q='invalid') # Invalid type + with pytest.raises(TypeError, match="Q must be "): + jump_diffusion_model.calculate_QISF(Q="invalid") # Invalid type @pytest.mark.parametrize( - 'Q', + "Q", [ (0.5), ([1.0, 2.0, 3.0]), (np.array([1.0, 2.0, 3.0])), ], ids=[ - 'python_scalar', - 'python_list', - 'numpy_array', + "python_scalar", + "python_list", + "numpy_array", ], ) def test_create_component_collections(self, jump_diffusion_model, Q): # WHEN + jump_diffusion_model.Q = Q # THEN - component_collections = jump_diffusion_model.create_component_collections(Q=Q) + component_collections = jump_diffusion_model.create_component_collections() # EXPECT expected_widths = jump_diffusion_model.calculate_width(Q) @@ -207,40 +217,34 @@ def test_create_component_collections(self, jump_diffusion_model, Q): def test_create_component_collections_component_name_must_be_string( self, jump_diffusion_model ): + jump_diffusion_model.Q = ( + 0.5 # Set a valid Q value to ensure we get past the Q check + ) # WHEN THEN EXPECT - with pytest.raises(TypeError, match=r'component_name must be a string.'): - jump_diffusion_model.create_component_collections( - Q=np.array([0.1, 0.2, 0.3]), component_name=123 - ) + with pytest.raises(TypeError, match=r"component_name must be a string."): + jump_diffusion_model.create_component_collections(component_name=123) def test_create_component_collections_component_display_name_must_be_string( self, jump_diffusion_model ): + jump_diffusion_model.Q = ( + 0.5 # Set a valid Q value to ensure we get past the Q check + ) # WHEN THEN EXPECT - with pytest.raises(TypeError, match=r'component_display_name must be a string.'): + with pytest.raises( + TypeError, match=r"component_display_name must be a string." + ): jump_diffusion_model.create_component_collections( - Q=np.array([0.1, 0.2, 0.3]), component_display_name=123 + component_display_name=123 ) - def test_create_component_collections_Q_type_error(self, jump_diffusion_model): - # WHEN THEN EXPECT - with pytest.raises(TypeError, match='Q must be a '): - jump_diffusion_model.create_component_collections(Q='invalid') # Invalid type - - def test_create_component_collections_Q_1dimensional_error(self, jump_diffusion_model): - # WHEN THEN EXPECT - with pytest.raises(ValueError, match=r'Q must be a 1-dimensional array.'): - jump_diffusion_model.create_component_collections( - Q=np.array([[0.1, 0.2], [0.3, 0.4]]) - ) # Invalid shape - def test_write_width_dependency_expression(self, jump_diffusion_model): # WHEN THEN expression = jump_diffusion_model._write_width_dependency_expression(0.5) # EXPECT expected_expression = ( - 'hbar * D* 0.5 **2/(angstrom**2)/(1 + (D * t* 0.5 **2/(angstrom**2)))' + "hbar * D* 0.5 **2/(angstrom**2)/(1 + (D * t* 0.5 **2/(angstrom**2)))" ) assert expression == expected_expression @@ -250,27 +254,27 @@ def test_write_width_dependency_map_expression(self, jump_diffusion_model): # EXPECT expected_map = { - 'D': jump_diffusion_model.diffusion_coefficient, - 't': jump_diffusion_model.relaxation_time, - 'hbar': jump_diffusion_model._hbar, - 'angstrom': jump_diffusion_model._angstrom, + "D": jump_diffusion_model.diffusion_coefficient, + "t": jump_diffusion_model.relaxation_time, + "hbar": jump_diffusion_model._hbar, + "angstrom": jump_diffusion_model._angstrom, } assert expression_map == expected_map def test_write_width_dependency_expression_raises(self, jump_diffusion_model): - with pytest.raises(TypeError, match='Q must be a float'): - jump_diffusion_model._write_width_dependency_expression('invalid') + with pytest.raises(TypeError, match="Q must be a float"): + jump_diffusion_model._write_width_dependency_expression("invalid") def test_write_area_dependency_expression_raises(self, jump_diffusion_model): - with pytest.raises(TypeError, match='QISF must be a float'): - jump_diffusion_model._write_area_dependency_expression('invalid') + with pytest.raises(TypeError, match="QISF must be a float"): + jump_diffusion_model._write_area_dependency_expression("invalid") def test_repr(self, jump_diffusion_model): # WHEN THEN repr_str = repr(jump_diffusion_model) # EXPECT - assert 'JumpTranslationalDiffusion' in repr_str - assert 'diffusion_coefficient' in repr_str - assert 'scale=' in repr_str + assert "JumpTranslationalDiffusion" in repr_str + assert "diffusion_coefficient" in repr_str + assert "scale=" in repr_str From a94138b6402b09a27ead87dfd32866594c79691a Mon Sep 17 00:00:00 2001 From: henrikjacobsenfys Date: Fri, 22 May 2026 13:28:05 +0200 Subject: [PATCH 10/18] all tests pass --- docs/docs/tutorials/DeltaLorentz.ipynb | 64 +---- docs/docs/tutorials/diffusion_model.ipynb | 7 +- .../tutorials/tutorial0_more_advanced.ipynb | 2 +- src/easydynamics/analysis/fit_binding.py | 3 +- .../sample_model/component_collection.py | 4 +- .../brownian_translational_diffusion.py | 86 +++--- .../diffusion_model/delta_lorentz.py | 245 +++++++++--------- .../diffusion_model/diffusion_model_base.py | 107 +++++--- .../jump_translational_diffusion.py | 86 +++--- src/easydynamics/sample_model/model_base.py | 4 +- src/easydynamics/sample_model/sample_model.py | 43 ++- .../test_brownian_translational_diffusion.py | 136 ++++------ .../test_jump_translational_diffusion.py | 151 +++++------ .../sample_model/test_sample_model.py | 1 - 14 files changed, 457 insertions(+), 482 deletions(-) diff --git a/docs/docs/tutorials/DeltaLorentz.ipynb b/docs/docs/tutorials/DeltaLorentz.ipynb index 1183e75c..5729679e 100644 --- a/docs/docs/tutorials/DeltaLorentz.ipynb +++ b/docs/docs/tutorials/DeltaLorentz.ipynb @@ -44,19 +44,10 @@ " A_0=A_0,\n", " lorentzian_width=lorentzian_width,\n", " allow_Q_variation={'A_0': True},\n", + " Q=Q,\n", ")" ] }, - { - "cell_type": "code", - "execution_count": null, - "id": "75008797", - "metadata": {}, - "outputs": [], - "source": [ - "component_collections = diffusion_model.create_component_collections(Q)" - ] - }, { "cell_type": "code", "execution_count": null, @@ -64,6 +55,7 @@ "metadata": {}, "outputs": [], "source": [ + "component_collections = diffusion_model.get_component_collections()\n", "cmap = plt.cm.jet\n", "nQ = len(component_collections)\n", "plt.figure()\n", @@ -78,58 +70,6 @@ "plt.ylabel('Intensity (arb. units)')\n", "plt.title('Delta-Lorentz Model')" ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "fdcafe76", - "metadata": {}, - "outputs": [], - "source": [ - "component_collections[0][0]" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "7f6e7736", - "metadata": {}, - "outputs": [], - "source": [ - "component_collections[0].get_all_parameters()" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "ed083ab5", - "metadata": {}, - "outputs": [], - "source": [ - "diffusion_model.get_all_variables(Q_index=0)" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "bc1c94d1", - "metadata": {}, - "outputs": [], - "source": [ - "from easydynamics.sample_model.sample_model import SampleModel\n", - "\n", - "sample_model = SampleModel(diffusion_models=[diffusion_model], Q=Q)" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "4c0d5e84", - "metadata": {}, - "outputs": [], - "source": [ - "sample_model.get_all_variables(Q_index=0)" - ] } ], "metadata": { diff --git a/docs/docs/tutorials/diffusion_model.ipynb b/docs/docs/tutorials/diffusion_model.ipynb index 85d19246..148f37c3 100644 --- a/docs/docs/tutorials/diffusion_model.ipynb +++ b/docs/docs/tutorials/diffusion_model.ipynb @@ -41,13 +41,10 @@ "diffusion_coefficient = 2.4e-9 # m^2/s\n", "\n", "diffusion_model = BrownianTranslationalDiffusion(\n", - " display_name='DiffusionModel',\n", - " scale=scale,\n", - " diffusion_coefficient=diffusion_coefficient,\n", - " Q=Q\n", + " display_name='DiffusionModel', scale=scale, diffusion_coefficient=diffusion_coefficient, Q=Q\n", ")\n", "\n", - "component_collections = diffusion_model.create_component_collections()\n", + "component_collections = diffusion_model.get_component_collections()\n", "\n", "\n", "cmap = plt.cm.jet\n", diff --git a/docs/docs/tutorials/tutorial0_more_advanced.ipynb b/docs/docs/tutorials/tutorial0_more_advanced.ipynb index 2ebc7120..25088780 100644 --- a/docs/docs/tutorials/tutorial0_more_advanced.ipynb +++ b/docs/docs/tutorials/tutorial0_more_advanced.ipynb @@ -33,7 +33,7 @@ "id": "4c8e97b7", "metadata": {}, "source": [ - "As before, we createa an `Experiment` to hold the data, and rebin it." + "As before, we create an `Experiment` to hold the data, and rebin it." ] }, { diff --git a/src/easydynamics/analysis/fit_binding.py b/src/easydynamics/analysis/fit_binding.py index 5ad20fee..cbd108dc 100644 --- a/src/easydynamics/analysis/fit_binding.py +++ b/src/easydynamics/analysis/fit_binding.py @@ -257,7 +257,8 @@ def get_parameter_names(self) -> list[str]: modes = self._get_modes() if isinstance(self.model, DiffusionModelBase): - # HACK + # This needs to be generalised. + # TODO: Generalise this for different diffusion models and modes. # noqa TD002 TD003 if 'delta' in modes: return [f'{self.parameter_name} area' for mode in modes] diff --git a/src/easydynamics/sample_model/component_collection.py b/src/easydynamics/sample_model/component_collection.py index 081f06bb..f0bbd2d7 100644 --- a/src/easydynamics/sample_model/component_collection.py +++ b/src/easydynamics/sample_model/component_collection.py @@ -64,9 +64,9 @@ def __init__( ---------- components : ModelComponent | list[ModelComponent] | None, default=None Initial model components to add to the ComponentCollection. - unit : str | sc.Unit, default="meV" + unit : str | sc.Unit, default='meV' Unit of the collection. - name : str, default="ComponentCollection" + name : str, default='ComponentCollection' Name of the collection. display_name : str | None, default=None Display name of the collection. diff --git a/src/easydynamics/sample_model/diffusion_model/brownian_translational_diffusion.py b/src/easydynamics/sample_model/diffusion_model/brownian_translational_diffusion.py index 937c0409..2cae5654 100644 --- a/src/easydynamics/sample_model/diffusion_model/brownian_translational_diffusion.py +++ b/src/easydynamics/sample_model/diffusion_model/brownian_translational_diffusion.py @@ -9,9 +9,7 @@ from easydynamics.sample_model.component_collection import ComponentCollection from easydynamics.sample_model.components import Lorentzian -from easydynamics.sample_model.diffusion_model.diffusion_model_base import ( - DiffusionModelBase, -) +from easydynamics.sample_model.diffusion_model.diffusion_model_base import DiffusionModelBase from easydynamics.utils.utils import Numeric from easydynamics.utils.utils import Q_type from easydynamics.utils.utils import angstrom @@ -44,9 +42,9 @@ def __init__( scale: Numeric = 1.0, diffusion_coefficient: Numeric = 1.0, Q: Q_type | None = None, - unit: str | sc.Unit = "meV", - name: str = "BrownianTranslationalDiffusion", - display_name: str | None = "BrownianTranslationalDiffusion", + unit: str | sc.Unit = 'meV', + name: str = 'BrownianTranslationalDiffusion', + display_name: str | None = 'BrownianTranslationalDiffusion', unique_name: str | None = None, ) -> None: """ @@ -60,11 +58,11 @@ def __init__( Diffusion coefficient D in m^2/s. Q : Q_type | None, default=None Q values for the model. If None, Q is not set. - unit : str | sc.Unit, default="meV" + unit : str | sc.Unit, default='meV' Unit of the diffusion model. Must be convertible to meV. - name : str, default="BrownianTranslationalDiffusion" + name : str, default='BrownianTranslationalDiffusion' Name of the diffusion model. - display_name : str | None, default="BrownianTranslationalDiffusion" + display_name : str | None, default='BrownianTranslationalDiffusion' Display name of the diffusion model. unique_name : str | None, default=None Unique name of the diffusion model. If None, a unique name will be generated. By @@ -74,18 +72,27 @@ def __init__( ------ TypeError If scale or diffusion_coefficient is not a number. + + ValueError + If scale or diffusion_coefficient is negative. """ if not isinstance(scale, Numeric): - raise TypeError("scale must be a number.") + raise TypeError('scale must be a number.') + + if float(scale) < 0: + raise ValueError('scale must be non-negative.') if not isinstance(diffusion_coefficient, Numeric): - raise TypeError("diffusion_coefficient must be a number.") + raise TypeError('diffusion_coefficient must be a number.') + + if float(diffusion_coefficient) < 0: + raise ValueError('diffusion_coefficient must be non-negative.') diffusion_coefficient = Parameter( - name="diffusion_coefficient", + name='diffusion_coefficient', value=float(diffusion_coefficient), fixed=False, - unit="m**2/s", + unit='m**2/s', min=0.0, ) super().__init__( @@ -100,6 +107,8 @@ def __init__( self._angstrom = angstrom self._diffusion_coefficient = diffusion_coefficient + self._component_collections = self.create_component_collections() + # ------------------------------------------------------------------ # Properties # ------------------------------------------------------------------ @@ -134,10 +143,10 @@ def diffusion_coefficient(self, diffusion_coefficient: Numeric) -> None: If diffusion_coefficient is negative. """ if not isinstance(diffusion_coefficient, Numeric): - raise TypeError("diffusion_coefficient must be a number.") + raise TypeError('diffusion_coefficient must be a number.') if float(diffusion_coefficient) < 0: - raise ValueError("diffusion_coefficient must be non-negative.") + raise ValueError('diffusion_coefficient must be non-negative.') self._diffusion_coefficient.value = float(diffusion_coefficient) # ------------------------------------------------------------------ @@ -160,9 +169,7 @@ def calculate_width(self, Q: Q_type | None = None) -> np.ndarray: """ Q = self._ensure_Q(Q) - unit_conversion_factor = ( - self._hbar * self.diffusion_coefficient / (self._angstrom**2) - ) + unit_conversion_factor = self._hbar * self.diffusion_coefficient / (self._angstrom**2) unit_conversion_factor.convert_unit(self.unit) return Q**2 * unit_conversion_factor.value @@ -205,7 +212,7 @@ def calculate_QISF(self, Q: Q_type | None = None) -> np.ndarray: def create_component_collections( self, - component_name: str = "Brownian diffusion", + component_name: str = 'Brownian diffusion', component_display_name: str | None = None, ) -> list[ComponentCollection]: r""" @@ -214,7 +221,7 @@ def create_component_collections( Parameters ---------- - component_name : str, default="Brownian diffusion" + component_name : str, default='Brownian diffusion' Name of the Brownian diffusion component. component_display_name : str | None, default=None Display name of the Brownian diffusion component. @@ -237,13 +244,13 @@ def create_component_collections( return self._component_collections if not isinstance(component_name, str): - raise TypeError("component_name must be a string.") + raise TypeError('component_name must be a string.') if component_display_name is None: component_display_name = component_name if not isinstance(component_display_name, str): - raise TypeError("component_display_name must be a string.") + raise TypeError('component_display_name must be a string.') component_collection_list = [None] * len(Q) # In more complex models, this is used to scale the area of the @@ -255,8 +262,8 @@ def create_component_collections( # No delta function, as the EISF is 0. for i, Q_value in enumerate(Q): component_collection_list[i] = ComponentCollection( - name=f"{self.name}_Q{Q_value:.2f}", - display_name=f"{self.display_name}_Q{Q_value:.2f}", + name=f'{self.name}_Q{Q_value:.2f}', + display_name=f'{self.display_name}_Q{Q_value:.2f}', unit=self.unit, ) @@ -291,6 +298,13 @@ def create_component_collections( # Private methods # ------------------------------------------------------------------ + def _on_Q_change(self) -> None: + """ + Update the component collections when Q changes. This is called automatically when the Q + property is set. It regenerates the component collections based on the new Q values. + """ + self._component_collections = self.create_component_collections() + def _write_width_dependency_expression(self, Q: float) -> str: """ Write the dependency expression for the width as a function of Q to make dependent @@ -312,10 +326,10 @@ def _write_width_dependency_expression(self, Q: float) -> str: Dependency expression for the width. """ if not isinstance(Q, (float)): - raise TypeError("Q must be a float.") + raise TypeError('Q must be a float.') # Q is given as a float, so we need to add the units - return f"hbar * D* {Q} **2*1/(angstrom**2)" + return f'hbar * D* {Q} **2*1/(angstrom**2)' def _write_width_dependency_map_expression(self) -> dict[str, DescriptorNumber]: """ @@ -327,9 +341,9 @@ def _write_width_dependency_map_expression(self) -> dict[str, DescriptorNumber]: Dependency map for the width. """ return { - "D": self.diffusion_coefficient, - "hbar": self._hbar, - "angstrom": self._angstrom, + 'D': self.diffusion_coefficient, + 'hbar': self._hbar, + 'angstrom': self._angstrom, } def _write_area_dependency_expression(self, QISF: float) -> str: @@ -352,9 +366,9 @@ def _write_area_dependency_expression(self, QISF: float) -> str: Dependency expression for the area. """ if not isinstance(QISF, (float)): - raise TypeError("QISF must be a float.") + raise TypeError('QISF must be a float.') - return f"{QISF} * scale" + return f'{QISF} * scale' def _write_area_dependency_map_expression(self) -> dict[str, DescriptorNumber]: """ @@ -366,7 +380,7 @@ def _write_area_dependency_map_expression(self) -> dict[str, DescriptorNumber]: Dependency map for the area. """ return { - "scale": self.scale, + 'scale': self.scale, } # ------------------------------------------------------------------ @@ -383,8 +397,8 @@ def __repr__(self) -> str: String representation of the BrownianTranslationalDiffusion model. """ return ( - f"BrownianTranslationalDiffusion(name={self.name}, " - f"display_name={self.display_name}, \n" - f" diffusion_coefficient={self.diffusion_coefficient}, \n" - f" scale={self.scale})" + f'BrownianTranslationalDiffusion(name={self.name}, ' + f'display_name={self.display_name}, \n' + f' diffusion_coefficient={self.diffusion_coefficient}, \n' + f' scale={self.scale})' ) diff --git a/src/easydynamics/sample_model/diffusion_model/delta_lorentz.py b/src/easydynamics/sample_model/diffusion_model/delta_lorentz.py index 7433fd0a..a4360d5b 100644 --- a/src/easydynamics/sample_model/diffusion_model/delta_lorentz.py +++ b/src/easydynamics/sample_model/diffusion_model/delta_lorentz.py @@ -10,9 +10,7 @@ from easydynamics.sample_model.component_collection import ComponentCollection from easydynamics.sample_model.components import DeltaFunction from easydynamics.sample_model.components import Lorentzian -from easydynamics.sample_model.diffusion_model.diffusion_model_base import ( - DiffusionModelBase, -) +from easydynamics.sample_model.diffusion_model.diffusion_model_base import DiffusionModelBase from easydynamics.utils.utils import Numeric from easydynamics.utils.utils import Q_type @@ -47,8 +45,8 @@ class DeltaLorentz(DiffusionModelBase): ... A_0=A_0, ... lorentzian_width=lorentzian_width, ... allow_Q_variation={'A_0': True, 'lorentzian_width': True}, + ... Q=Q, ... ) - >>> component_collections = model.create_component_collections(Q) See also the tutorials. """ @@ -61,8 +59,8 @@ def __init__( lorentzian_width: Numeric = 1.0, allow_Q_variation: dict | None = None, Q: Q_type | None = None, - unit: str | sc.Unit = "meV", - name: str = "DeltaLorentz", + unit: str | sc.Unit = 'meV', + name: str = 'DeltaLorentz', display_name: str | None = None, unique_name: str | None = None, ) -> None: @@ -71,8 +69,6 @@ def __init__( Parameters ---------- - unit : str | sc.Unit, default="meV" - Unit of the diffusion model. Must be convertible to meV. scale : Numeric, default=1.0 Scale factor for the diffusion model. Must be a non-negative number. mean_u_squared : Numeric, default=0.0 @@ -88,6 +84,10 @@ def __init__( allowed. Q : Q_type | None, default=None Q values for the model. If None, Q is not set. + unit : str | sc.Unit, default='meV' + Unit of the diffusion model. Must be convertible to meV. + name : str, default='DeltaLorentz' + Name of the diffusion model. display_name : str | None, default=None Display name of the diffusion model. unique_name : str | None, default=None @@ -100,7 +100,6 @@ def __init__( If mean_u_squared, A_0, or lorentzian_width is not a number. If allow_Q_variation is not a dict or None. - ValueError If A_0 is not between 0 and 1, or if lorentzian_width is less than the minimum allowed width. If mean_u_squared is negative, or if allow_Q_variation contains unknown keys. @@ -115,42 +114,42 @@ def __init__( ) if not isinstance(mean_u_squared, Numeric): - raise TypeError("mean_u_squared must be a number.") + raise TypeError('mean_u_squared must be a number.') if float(mean_u_squared) < 0: - raise ValueError("mean_u_squared must be non-negative.") + raise ValueError('mean_u_squared must be non-negative.') if not isinstance(A_0, Numeric): - raise TypeError("A_0 must be a number.") + raise TypeError('A_0 must be a number.') if float(A_0) < 0 or float(A_0) > 1: - raise ValueError("A_0 must be between 0 and 1.") + raise ValueError('A_0 must be between 0 and 1.') if not isinstance(lorentzian_width, Numeric): - raise TypeError("lorentzian_width must be a number.") + raise TypeError('lorentzian_width must be a number.') if float(lorentzian_width) < MINIMUM_WIDTH: - raise ValueError(f"lorentzian_width must be at least {MINIMUM_WIDTH}.") + raise ValueError(f'lorentzian_width must be at least {MINIMUM_WIDTH}.') allow_Q_variation_default = { - "A_0": False, - "lorentzian_width": False, + 'A_0': False, + 'lorentzian_width': False, } allowed_keys = set(allow_Q_variation_default) if allow_Q_variation is None: allow_Q_variation = {} if not isinstance(allow_Q_variation, dict): - raise TypeError("allow_Q_variation must be a dict or None.") + raise TypeError('allow_Q_variation must be a dict or None.') unknown_keys = set(allow_Q_variation) - allowed_keys if unknown_keys: - raise ValueError(f"Unknown keys in allow_Q_variation: {unknown_keys}") + raise ValueError(f'Unknown keys in allow_Q_variation: {unknown_keys}') self._allow_Q_variation = {**allow_Q_variation_default, **allow_Q_variation} A_0 = Parameter( - name="A_0", + name='A_0', value=float(A_0), fixed=False, min=0.0, @@ -159,23 +158,23 @@ def __init__( self._A_0 = A_0 A_1 = Parameter.from_dependency( - name="A_1", - dependency_expression="1 - A_0", - dependency_map={"A_0": A_0}, + name='A_1', + dependency_expression='1 - A_0', + dependency_map={'A_0': A_0}, ) self._A_1 = A_1 mean_u_squared = Parameter( - name="mean_u_squared", + name='mean_u_squared', value=float(mean_u_squared), fixed=False, min=0.0, - unit="angstrom**2", + unit='angstrom**2', ) self._mean_u_squared = mean_u_squared lorentzian_width = Parameter( - name="lorentzian_width", + name='lorentzian_width', value=float(lorentzian_width), fixed=False, min=MINIMUM_WIDTH, @@ -188,21 +187,21 @@ def __init__( self._A_1_list = [] self._lorentzian_width_list = [] else: - if self._allow_Q_variation["A_0"] is True: - self._A_0_list, self._A_1_list = self._create_A0_A1_parameters( - A_0, self.Q - ) + if self._allow_Q_variation['A_0'] is True: + self._A_0_list, self._A_1_list = self._create_A0_A1_parameters(A_0) else: self._A_0_list = [] self._A_1_list = [] - if self._allow_Q_variation["lorentzian_width"] is True: + if self._allow_Q_variation['lorentzian_width'] is True: self._lorentzian_width_list = self._create_lorentzian_width_parameters( - lorentzian_width, self.Q + lorentzian_width, ) else: self._lorentzian_width_list = [] + self._component_collections = self.create_component_collections() + # ------------------------------------------------------------------ # Properties # ------------------------------------------------------------------ @@ -237,10 +236,10 @@ def mean_u_squared(self, mean_u_squared: Numeric) -> None: If mean_u_squared is negative. """ if not isinstance(mean_u_squared, Numeric): - raise TypeError("mean_u_squared must be a number.") + raise TypeError('mean_u_squared must be a number.') if float(mean_u_squared) < 0: - raise ValueError("mean_u_squared must be non-negative.") + raise ValueError('mean_u_squared must be non-negative.') self._mean_u_squared.value = float(mean_u_squared) @property @@ -273,10 +272,10 @@ def A_0(self, A_0: Numeric) -> None: If A_0 is not between 0 and 1. """ if not isinstance(A_0, Numeric): - raise TypeError("A_0 must be a number.") + raise TypeError('A_0 must be a number.') if not (0 <= float(A_0) <= 1): - raise ValueError("A_0 must be between 0 and 1.") + raise ValueError('A_0 must be between 0 and 1.') self._A_0.value = float(A_0) @property @@ -304,10 +303,12 @@ def A_1(self, _A_1: Numeric) -> None: Raises ------ - AttributeError If an attempt is made to set A_1 directly. + AttributeError + If an attempt is made to set A_1 directly. """ raise AttributeError( - "A_1 is a dependent parameter and cannot be set directly. Set A_0 to change A_1 accordingly." + 'A_1 is a dependent parameter and cannot be set directly. ' + 'Set A_0 to change A_1 accordingly.' ) @property @@ -340,10 +341,10 @@ def lorentzian_width(self, lorentzian_width: Numeric) -> None: If lorentzian_width is less than the minimum allowed width. """ if not isinstance(lorentzian_width, Numeric): - raise TypeError("lorentzian_width must be a number.") + raise TypeError('lorentzian_width must be a number.') if float(lorentzian_width) < MINIMUM_WIDTH: - raise ValueError(f"lorentzian_width must be at least {MINIMUM_WIDTH}.") + raise ValueError(f'lorentzian_width must be at least {MINIMUM_WIDTH}.') self._lorentzian_width.value = float(lorentzian_width) # ------------------------------------------------------------------ @@ -364,11 +365,8 @@ def calculate_width(self, Q: Q_type) -> np.ndarray: np.ndarray HWHM values in the unit of the model (e.g., meV). """ - if self._allow_Q_variation["lorentzian_width"] is True: - widths = [ - lorentzian_width.value - for lorentzian_width in self._lorentzian_width_list - ] + if self._allow_Q_variation['lorentzian_width'] is True: + widths = [lorentzian_width.value for lorentzian_width in self._lorentzian_width_list] return np.array(widths) Q = self._ensure_Q(Q) @@ -391,7 +389,7 @@ def calculate_EISF(self, Q: Q_type) -> np.ndarray: np.ndarray EISF values (dimensionless). """ - if self._allow_Q_variation["A_0"] is True: + if self._allow_Q_variation['A_0'] is True: A_0_values = [A_0_.value for A_0_ in self._A_0_list] return np.exp(-self.mean_u_squared.value * Q**2 / 3) * np.array(A_0_values) @@ -415,7 +413,7 @@ def calculate_QISF(self, Q: Q_type) -> np.ndarray: np.ndarray QISF values (dimensionless). """ - if self._allow_Q_variation["A_1"] is True: + if self._allow_Q_variation['A_1'] is True: A_1_values = [A_1_.value for A_1_ in self._A_1_list] return np.exp(-self.mean_u_squared.value * Q**2 / 3) * np.array(A_1_values) @@ -425,59 +423,55 @@ def calculate_QISF(self, Q: Q_type) -> np.ndarray: def create_component_collections( self, - lorentzian_name: str = "Lorentzian", - delta_name: str = "Delta function", + lorentzian_name: str = 'Lorentzian', + delta_name: str = 'Delta function', ) -> list[ComponentCollection]: r""" Create ComponentCollection components for the DeltaLorentz model at given Q values. Parameters ---------- - Q : Q_type - Scattering vector values. - lorentzian_name : str, default="Lorentzian" + lorentzian_name : str, default='Lorentzian' Name of the Lorentzian component. - delta_name : str, default="Delta function" + delta_name : str, default='Delta function' Name of the Delta function component. Raises ------ TypeError If lorentzian_name or delta_name is not a string. + ValueError + If Q is not set in the model. Returns ------- list[ComponentCollection] - List of ComponentCollections with Lorentzian and delta functioncomponents for each Q + List of ComponentCollections with Lorentzian and delta function components for each Q value. """ Q = self.Q if Q is None: - raise ValueError( - "Q must be set in the model to create component collections." - ) + raise ValueError('Q must be set in the model to create component collections.') if not isinstance(lorentzian_name, str): - raise TypeError("lorentzian_name must be a string.") + raise TypeError('lorentzian_name must be a string.') if not isinstance(delta_name, str): - raise TypeError("delta_name must be a string.") + raise TypeError('delta_name must be a string.') - if self._allow_Q_variation["A_0"] is True: - A_0_list, A_1_list = self._create_A0_A1_parameters(self.A_0, Q) + if self._allow_Q_variation['A_0'] is True: + A_0_list, A_1_list = self._create_A0_A1_parameters(self.A_0) self._A_0_list = A_0_list self._A_1_list = A_1_list - if self._allow_Q_variation["lorentzian_width"] is True: - lorentzian_width_list = self._create_lorentzian_width_parameters( - self.lorentzian_width, Q - ) + if self._allow_Q_variation['lorentzian_width'] is True: + lorentzian_width_list = self._create_lorentzian_width_parameters(self.lorentzian_width) self._lorentzian_width_list = lorentzian_width_list component_collection_list = [None] * len(Q) for i, Q_value in enumerate(Q): component_collection_list[i] = ComponentCollection( - display_name=f"{self.display_name}_Q{Q_value:.2f}", + display_name=f'{self.display_name}_Q{Q_value:.2f}', unit=self.unit, ) @@ -493,13 +487,11 @@ def create_component_collections( # If the width is allowed to vary with Q it is independent. # If the width is not allowed to vary with Q it must be made # dependent on the width parameter of the model. - if self._allow_Q_variation["lorentzian_width"] is False: + if self._allow_Q_variation['lorentzian_width'] is False: dependency_map = self._write_width_dependency_map_expression() lorentzian_component.width.make_dependent_on( - dependency_expression=self._write_lorz_width_dependency_expression( - Q_value - ), + dependency_expression=self._write_lorz_width_dependency_expression(Q_value), dependency_map=dependency_map, desired_unit=self.unit, ) @@ -510,15 +502,13 @@ def create_component_collections( # will also depend on the specific A_1 parameter for that Q # value. If A_1 is not allowed to vary with Q, the area will # depend on the single A_1 parameter of the model. - if self._allow_Q_variation["A_0"] is True: + if self._allow_Q_variation['A_0'] is True: dependency_map = self._write_lorz_area_dependency_map_expression(i) else: dependency_map = self._write_lorz_area_dependency_map_expression(None) lorentzian_component.area.make_dependent_on( - dependency_expression=self._write_lorz_area_dependency_expression( - Q_value - ), + dependency_expression=self._write_lorz_area_dependency_expression(Q_value), dependency_map=dependency_map, ) @@ -533,15 +523,13 @@ def create_component_collections( unit=self.unit, ) - if self._allow_Q_variation["A_0"] is True: + if self._allow_Q_variation['A_0'] is True: dependency_map = self._write_delta_area_dependency_map_expression(i) else: dependency_map = self._write_delta_area_dependency_map_expression(None) delta_component.area.make_dependent_on( - dependency_expression=self._write_delta_area_dependency_expression( - Q_value - ), + dependency_expression=self._write_delta_area_dependency_expression(Q_value), dependency_map=dependency_map, ) @@ -553,6 +541,11 @@ def get_all_variables(self, Q_index: int | None = None) -> list[DescriptorNumber """ Get a list of all variables (Parameters and Descriptors) in the model. + Parameters + ---------- + Q_index : int | None, default=None + The index of the Q value for which to get the variables. If None, variables for all Q + values will be included. Returns ------- @@ -561,7 +554,7 @@ def get_all_variables(self, Q_index: int | None = None) -> list[DescriptorNumber """ variables = [self.scale, self.mean_u_squared] - if self._allow_Q_variation["A_0"] is True: + if self._allow_Q_variation['A_0'] is True: if Q_index is None: variables.extend(self._A_0_list) variables.extend(self._A_1_list) @@ -572,7 +565,7 @@ def get_all_variables(self, Q_index: int | None = None) -> list[DescriptorNumber variables.append(self.A_0) variables.append(self.A_1) - if self._allow_Q_variation["lorentzian_width"] is True: + if self._allow_Q_variation['lorentzian_width'] is True: if Q_index is None: variables.extend(self._lorentzian_width_list) else: @@ -587,36 +580,41 @@ def get_all_variables(self, Q_index: int | None = None) -> list[DescriptorNumber # ------------------------------------------------------------------ def _on_Q_change(self) -> None: - """Handle changes to the Q values. Updates the A_0, A_1 and lorentzian_width parameters if they are allowed to vary with Q.""" + """ + Handle changes to the Q values. Updates the A_0, A_1 and lorentzian_width parameters if + they are allowed to vary with Q. + """ if self.Q is None: self._A_0_list = [] self._A_1_list = [] self._lorentzian_width_list = [] else: - if self._allow_Q_variation["A_0"] is True: - self._A_0_list, self._A_1_list = self._create_A0_A1_parameters( - self.A_0, self.Q - ) + if self._allow_Q_variation['A_0'] is True: + self._A_0_list, self._A_1_list = self._create_A0_A1_parameters(self.A_0) else: self._A_0_list = [] self._A_1_list = [] - if self._allow_Q_variation["lorentzian_width"] is True: + if self._allow_Q_variation['lorentzian_width'] is True: self._lorentzian_width_list = self._create_lorentzian_width_parameters( self.lorentzian_width, self.Q ) else: self._lorentzian_width_list = [] + self._component_collections = self.create_component_collections() def _create_A0_A1_parameters( - self, A_0: Parameter, Q: Q_type + self, + A_0: Parameter, ) -> tuple[list[Parameter], list[Parameter]]: """ Create lists of A_0 and A_1 parameters for each Q value. + Parameters ---------- A_0 : Parameter The A_0 parameter to use as the base for creating the A_0 parameters for each Q value. + Returns ------- tuple[list[Parameter], list[Parameter]] @@ -625,10 +623,10 @@ def _create_A0_A1_parameters( """ A_0_list = [] A_1_list = [] - for i, Q_value in enumerate(Q): + for i, Q_value in enumerate(self.Q): A_0_list.append( Parameter( - name=f"A_0_Q{Q_value:.2f}", + name=f'A_0_Q{Q_value:.2f}', value=float(A_0.value), fixed=False, min=0.0, @@ -637,16 +635,17 @@ def _create_A0_A1_parameters( ) A_1_list.append( Parameter.from_dependency( - name=f"A_1_Q{Q_value:.2f}", - dependency_expression="1 - A_0", - dependency_map={"A_0": A_0_list[i]}, + name=f'A_1_Q{Q_value:.2f}', + dependency_expression='1 - A_0', + dependency_map={'A_0': A_0_list[i]}, ) ) return A_0_list, A_1_list def _create_lorentzian_width_parameters( - self, lorentzian_width: Parameter, Q: Q_type + self, + lorentzian_width: Parameter, ) -> list[Parameter]: """ Create a list of Lorentzian width parameters for each Q value. @@ -663,10 +662,10 @@ def _create_lorentzian_width_parameters( A list containing the Lorentzian width parameters for each Q value. """ lorentzian_width_list = [] - for i, Q_value in enumerate(Q): + for _, Q_value in enumerate(self.Q): lorentzian_width_list.append( Parameter( - name=f"lorentzian_width_Q{Q_value:.2f}", + name=f'lorentzian_width_Q{Q_value:.2f}', value=float(lorentzian_width.value), fixed=False, min=MINIMUM_WIDTH, @@ -697,9 +696,9 @@ def _write_lorz_width_dependency_expression(self, Q: float) -> str: Dependency expression for the width. """ if not isinstance(Q, (float)): - raise TypeError("Q must be a float.") + raise TypeError('Q must be a float.') - return "lorentzian_width" + return 'lorentzian_width' def _write_width_dependency_map_expression( self, @@ -713,7 +712,7 @@ def _write_width_dependency_map_expression( Dependency map for the width. """ return { - "lorentzian_width": self.lorentzian_width, + 'lorentzian_width': self.lorentzian_width, } def _write_lorz_area_dependency_expression(self, Q: float) -> str: @@ -736,9 +735,9 @@ def _write_lorz_area_dependency_expression(self, Q: float) -> str: Dependency expression for the area. """ if not isinstance(Q, (float)): - raise TypeError("Q must be a float.") + raise TypeError('Q must be a float.') - return f"scale * exp(-mean_u_squared.value * {Q}**2 / 3) * A_1" + return f'scale * exp(-mean_u_squared.value * {Q}**2 / 3) * A_1' def _write_lorz_area_dependency_map_expression( self, Q_index: int | None @@ -746,6 +745,12 @@ def _write_lorz_area_dependency_map_expression( """ Write the dependency map expression to make dependent Parameters. + Parameters + ---------- + Q_index : int | None + Index of the Q value for which to write the dependency map. If None, write the + dependency map for the case where A_1 is not Q-dependent. + Returns ------- dict[str, DescriptorNumber] @@ -753,15 +758,15 @@ def _write_lorz_area_dependency_map_expression( """ if Q_index is None: return { - "scale": self.scale, - "mean_u_squared": self.mean_u_squared, - "A_1": self.A_1, + 'scale': self.scale, + 'mean_u_squared': self.mean_u_squared, + 'A_1': self.A_1, } return { - "scale": self.scale, - "mean_u_squared": self.mean_u_squared, - "A_1": self._A_1_list[Q_index], + 'scale': self.scale, + 'mean_u_squared': self.mean_u_squared, + 'A_1': self._A_1_list[Q_index], } def _write_delta_area_dependency_expression(self, Q: float) -> str: @@ -784,9 +789,9 @@ def _write_delta_area_dependency_expression(self, Q: float) -> str: Dependency expression for the area. """ if not isinstance(Q, (float)): - raise TypeError("Q must be a float.") + raise TypeError('Q must be a float.') - return f"scale * exp(-mean_u_squared.value * {Q}**2 / 3) * A_0" + return f'scale * exp(-mean_u_squared.value * {Q}**2 / 3) * A_0' def _write_delta_area_dependency_map_expression( self, @@ -808,14 +813,14 @@ def _write_delta_area_dependency_map_expression( """ if Q_index is None: return { - "scale": self.scale, - "mean_u_squared": self.mean_u_squared, - "A_0": self.A_0, + 'scale': self.scale, + 'mean_u_squared': self.mean_u_squared, + 'A_0': self.A_0, } return { - "scale": self.scale, - "mean_u_squared": self.mean_u_squared, - "A_0": self._A_0_list[Q_index], + 'scale': self.scale, + 'mean_u_squared': self.mean_u_squared, + 'A_0': self._A_0_list[Q_index], } # ------------------------------------------------------------------ @@ -832,10 +837,10 @@ def __repr__(self) -> str: String representation of the DeltaLorentz model. """ return ( - f"DeltaLorentz(display_name={self.display_name}," - f"unit={self.unit}, \n" - f" mean_u_squared={self.mean_u_squared}, \n" - f" A_0={self.A_0}, A_1={self.A_1}, \n" - f" lorentzian_width={self.lorentzian_width}, \n" - f" scale={self.scale})" + f'DeltaLorentz(display_name={self.display_name},' + f'unit={self.unit}, \n' + f' mean_u_squared={self.mean_u_squared}, \n' + f' A_0={self.A_0}, A_1={self.A_1}, \n' + f' lorentzian_width={self.lorentzian_width}, \n' + f' scale={self.scale})' ) diff --git a/src/easydynamics/sample_model/diffusion_model/diffusion_model_base.py b/src/easydynamics/sample_model/diffusion_model/diffusion_model_base.py index 2c01dd5f..a2ce878d 100644 --- a/src/easydynamics/sample_model/diffusion_model/diffusion_model_base.py +++ b/src/easydynamics/sample_model/diffusion_model/diffusion_model_base.py @@ -8,6 +8,7 @@ from scipp import UnitError from easydynamics.base_classes.easydynamics_modelbase import EasyDynamicsModelBase +from easydynamics.sample_model.component_collection import ComponentCollection from easydynamics.utils.utils import Numeric from easydynamics.utils.utils import Q_type from easydynamics.utils.utils import _validate_and_convert_Q @@ -20,9 +21,9 @@ def __init__( self, scale: Numeric = 1.0, Q: Q_type | None = None, - unit: str | sc.Unit = "meV", - name: str = "DiffusionModel", - display_name: str | None = "MyDiffusionModel", + unit: str | sc.Unit = 'meV', + name: str = 'DiffusionModel', + display_name: str | None = 'MyDiffusionModel', unique_name: str | None = None, ) -> None: """ @@ -34,11 +35,11 @@ def __init__( Scale factor for the diffusion model. Must be a non-negative number. Q : Q_type | None, default=None Q values for the model. If None, Q is not set. - unit : str | sc.Unit, default="meV" + unit : str | sc.Unit, default='meV' Unit of the diffusion model. Must be convertible to meV. - name : str, default="DiffusionModel" + name : str, default='DiffusionModel' Name of the diffusion model. - display_name : str | None, default="MyDiffusionModel" + display_name : str | None, default='MyDiffusionModel' Display name of the diffusion model. unique_name : str | None, default=None Unique name of the diffusion model. If None, a unique name will be generated. By @@ -55,23 +56,19 @@ def __init__( self._Q = _validate_and_convert_Q(Q) try: - test = DescriptorNumber(name="test", value=1, unit=unit) - test.convert_unit("meV") + test = DescriptorNumber(name='test', value=1, unit=unit) + test.convert_unit('meV') except Exception as e: raise UnitError( - f"Invalid unit: {unit}. Unit must be a string or scipp Unit and convertible to meV." # noqa: E501 + f'Invalid unit: {unit}. Unit must be a string or scipp Unit and convertible to meV.' # noqa: E501 ) from e if not isinstance(scale, Numeric): - raise TypeError("scale must be a number.") + raise TypeError('scale must be a number.') - scale = Parameter( - name="scale", value=float(scale), fixed=False, min=0.0, unit=unit - ) + scale = Parameter(name='scale', value=float(scale), fixed=False, min=0.0, unit=unit) - super().__init__( - unit=unit, name=name, display_name=display_name, unique_name=unique_name - ) + super().__init__(unit=unit, name=name, display_name=display_name, unique_name=unique_name) self._scale = scale # ------------------------------------------------------------------ @@ -108,10 +105,10 @@ def scale(self, scale: Numeric) -> None: If scale is negative. """ if not isinstance(scale, Numeric): - raise TypeError("scale must be a number.") + raise TypeError('scale must be a number.') if float(scale) < 0: - raise ValueError("scale must be non-negative.") + raise ValueError('scale must be non-negative.') self._scale.value = float(scale) @property @@ -156,8 +153,8 @@ def Q(self, value: Q_type | None) -> None: if len(old_Q) != len(new_Q) or not np.allclose(old_Q, new_Q): raise ValueError( - "New Q values are not similar to the old ones. " - "To change Q values, first run clear_Q()." + 'New Q values are not similar to the old ones. ' + 'To change Q values, first run clear_Q().' ) def clear_Q(self, confirm: bool = False) -> None: @@ -177,7 +174,7 @@ def clear_Q(self, confirm: bool = False) -> None: """ if not confirm: raise ValueError( - "Clearing Q values requires confirmation. Set confirm=True to proceed." + 'Clearing Q values requires confirmation. Set confirm=True to proceed.' ) self._Q = None self._on_Q_change() @@ -186,32 +183,74 @@ def clear_Q(self, confirm: bool = False) -> None: # private methods # ------------------------------------------------------------------ + def get_component_collections( + self, Q_index: int | None = None + ) -> ComponentCollection | list[ComponentCollection]: + """ + Get the ComponentCollection at the given Q index. + + Parameters + ---------- + Q_index : int | None, default=None + The index of the desired ComponentCollection. If None, all ComponentCollections are + returned. + + Raises + ------ + TypeError + If Q_index is not an int. + IndexError + If Q_index is out of bounds for the number of ComponentCollections. + + Returns + ------- + ComponentCollection | list[ComponentCollection] + The ComponentCollection at the specified Q index. If Q_index is None, a list of all + ComponentCollections is returned. + """ + if Q_index is None: + return self._component_collections + + if not isinstance(Q_index, int): + raise TypeError(f'Q_index must be an int, got {type(Q_index).__name__}') + if Q_index < 0 or Q_index >= len(self._component_collections): + raise IndexError( + f'Q_index {Q_index} is out of bounds for component collections ' + f'of length {len(self._component_collections)}' + ) + return self._component_collections[Q_index] + + # ------------------------------------------------------------------ + # private methods + # ------------------------------------------------------------------ + def _on_Q_change(self) -> None: """Handle changes to the Q values.""" def _ensure_Q(self, Q: Q_type) -> np.ndarray: """ - Convert Q to a numpy array, ensuring it is not None. - Uses the stored Q if no input is given. + Convert Q to a numpy array, ensuring it is not None. Uses the stored Q if no input is + given. - Parameters: - ----------- + Parameters + ---------- Q : Q_type The Q to be checked - Raises: + Returns ------- + np.ndarray + The validated and converted Q values. + + Raises + ------ ValueError If the provided Q and self.Q are both None - - """ if Q is None: Q = self.Q if Q is None: - raise ValueError( - "Q must be provided either as an argument or set in the model." - ) + raise ValueError('Q must be provided either as an argument or set in the model.') return _validate_and_convert_Q(Q) @@ -229,7 +268,7 @@ def __repr__(self) -> str: String representation of the DiffusionModel. """ return ( - f"{self.__class__.__name__}(name={self.name}, display_name={self.display_name}, " - f"unit={self.unit}), \n" - f" scale={self.scale})" + f'{self.__class__.__name__}(name={self.name}, display_name={self.display_name}, ' + f'unit={self.unit}), \n' + f' scale={self.scale})' ) diff --git a/src/easydynamics/sample_model/diffusion_model/jump_translational_diffusion.py b/src/easydynamics/sample_model/diffusion_model/jump_translational_diffusion.py index a2d955dd..53377578 100644 --- a/src/easydynamics/sample_model/diffusion_model/jump_translational_diffusion.py +++ b/src/easydynamics/sample_model/diffusion_model/jump_translational_diffusion.py @@ -9,9 +9,7 @@ from easydynamics.sample_model.component_collection import ComponentCollection from easydynamics.sample_model.components import Lorentzian -from easydynamics.sample_model.diffusion_model.diffusion_model_base import ( - DiffusionModelBase, -) +from easydynamics.sample_model.diffusion_model.diffusion_model_base import DiffusionModelBase from easydynamics.utils.utils import Numeric from easydynamics.utils.utils import Q_type from easydynamics.utils.utils import angstrom @@ -53,9 +51,9 @@ def __init__( diffusion_coefficient: Numeric = 1.0, relaxation_time: Numeric = 1.0, Q: Q_type | None = None, - unit: str | sc.Unit = "meV", - name: str = "JumpTranslationalDiffusion", - display_name: str | None = "JumpTranslationalDiffusion", + unit: str | sc.Unit = 'meV', + name: str = 'JumpTranslationalDiffusion', + display_name: str | None = 'JumpTranslationalDiffusion', unique_name: str | None = None, ) -> None: """ @@ -71,11 +69,11 @@ def __init__( Relaxation time t in ps. Q : Q_type | None, default=None Q values for the model. If None, Q is not set. - unit : str | sc.Unit, default="meV" + unit : str | sc.Unit, default='meV' Unit of the diffusion model. Must be convertible to meV. - name : str, default="JumpTranslationalDiffusion" + name : str, default='JumpTranslationalDiffusion' Name of the diffusion model. - display_name : str | None, default="JumpTranslationalDiffusion" + display_name : str | None, default='JumpTranslationalDiffusion' Display name of the diffusion model. unique_name : str | None, default=None Unique name of the diffusion model. If None, a unique name will be generated. By @@ -96,23 +94,23 @@ def __init__( ) if not isinstance(diffusion_coefficient, Numeric): - raise TypeError("diffusion_coefficient must be a number.") + raise TypeError('diffusion_coefficient must be a number.') if not isinstance(relaxation_time, Numeric): - raise TypeError("relaxation_time must be a number.") + raise TypeError('relaxation_time must be a number.') diffusion_coefficient = Parameter( - name="diffusion_coefficient", + name='diffusion_coefficient', value=float(diffusion_coefficient), fixed=False, - unit="m**2/s", + unit='m**2/s', ) relaxation_time = Parameter( - name="relaxation_time", + name='relaxation_time', value=float(relaxation_time), fixed=False, - unit="ps", + unit='ps', ) self._hbar = hbar @@ -120,6 +118,8 @@ def __init__( self._diffusion_coefficient = diffusion_coefficient self._relaxation_time = relaxation_time + self._component_collections = self.create_component_collections() + ################################ # Properties ################################ @@ -154,9 +154,9 @@ def diffusion_coefficient(self, diffusion_coefficient: Numeric) -> None: If diffusion_coefficient is negative. """ if not isinstance(diffusion_coefficient, Numeric): - raise TypeError("diffusion_coefficient must be a number.") + raise TypeError('diffusion_coefficient must be a number.') if float(diffusion_coefficient) < 0: - raise ValueError("diffusion_coefficient must be non-negative.") + raise ValueError('diffusion_coefficient must be non-negative.') self._diffusion_coefficient.value = float(diffusion_coefficient) @property @@ -189,10 +189,10 @@ def relaxation_time(self, relaxation_time: Numeric) -> None: If relaxation_time is negative. """ if not isinstance(relaxation_time, Numeric): - raise TypeError("relaxation_time must be a number.") + raise TypeError('relaxation_time must be a number.') if float(relaxation_time) < 0: - raise ValueError("relaxation_time must be non-negative.") + raise ValueError('relaxation_time must be non-negative.') self._relaxation_time.value = float(relaxation_time) ################################ @@ -228,7 +228,7 @@ def calculate_width(self, Q: Q_type | None = None) -> np.ndarray: unit_conversion_factor_denominator = ( self.diffusion_coefficient / self._angstrom**2 * self.relaxation_time ) - unit_conversion_factor_denominator.convert_unit("dimensionless") + unit_conversion_factor_denominator.convert_unit('dimensionless') denominator = 1 + unit_conversion_factor_denominator.value * Q**2 @@ -272,17 +272,17 @@ def calculate_QISF(self, Q: Q_type) -> np.ndarray: def create_component_collections( self, - component_name: str = "Jump translational diffusion", - component_display_name: str = "Jump translational diffusion", + component_name: str = 'Jump translational diffusion', + component_display_name: str = 'Jump translational diffusion', ) -> list[ComponentCollection]: """ Create ComponentCollection components for the diffusion model at given Q values. Parameters ---------- - component_name : str, default="Jump translational diffusion" + component_name : str, default='Jump translational diffusion' Name of the Jump Diffusion Lorentzian component. - component_display_name : str, default="Jump translational diffusion" + component_display_name : str, default='Jump translational diffusion' Name of the Jump Diffusion Lorentzian component. Raises @@ -301,10 +301,10 @@ def create_component_collections( return self._component_collections if not isinstance(component_display_name, str): - raise TypeError("component_display_name must be a string.") + raise TypeError('component_display_name must be a string.') if not isinstance(component_name, str): - raise TypeError("component_name must be a string.") + raise TypeError('component_name must be a string.') component_collection_list = [None] * len(Q) # In more complex models, this is used to scale the area of the @@ -316,8 +316,8 @@ def create_component_collections( # is 0. for i, Q_value in enumerate(Q): component_collection_list[i] = ComponentCollection( - name=f"{self.name}_Q{Q_value:.2f}", - display_name=f"{self.display_name}_Q{Q_value:.2f}", + name=f'{self.name}_Q{Q_value:.2f}', + display_name=f'{self.display_name}_Q{Q_value:.2f}', unit=self.unit, ) @@ -351,6 +351,12 @@ def create_component_collections( ################################ # Private methods ################################ + def _on_Q_change(self) -> None: + """ + Update the component collections when Q changes. This is called automatically when the Q + property is set. It regenerates the component collections based on the new Q values. + """ + self._component_collections = self.create_component_collections() def _write_width_dependency_expression(self, Q: float) -> str: """ @@ -373,10 +379,10 @@ def _write_width_dependency_expression(self, Q: float) -> str: Dependency expression for the width. """ if not isinstance(Q, (float)): - raise TypeError("Q must be a float.") + raise TypeError('Q must be a float.') # Q is given as a float, so we need to add the units - return f"hbar * D* {Q} **2/(angstrom**2)/(1 + (D * t* {Q} **2/(angstrom**2)))" + return f'hbar * D* {Q} **2/(angstrom**2)/(1 + (D * t* {Q} **2/(angstrom**2)))' def _write_width_dependency_map_expression(self) -> dict[str, DescriptorNumber]: """ @@ -388,10 +394,10 @@ def _write_width_dependency_map_expression(self) -> dict[str, DescriptorNumber]: Dependency map for the width. """ return { - "D": self.diffusion_coefficient, - "t": self.relaxation_time, - "hbar": self._hbar, - "angstrom": self._angstrom, + 'D': self.diffusion_coefficient, + 't': self.relaxation_time, + 'hbar': self._hbar, + 'angstrom': self._angstrom, } def _write_area_dependency_expression(self, QISF: float) -> str: @@ -415,9 +421,9 @@ def _write_area_dependency_expression(self, QISF: float) -> str: """ if not isinstance(QISF, (float)): - raise TypeError("QISF must be a float.") + raise TypeError('QISF must be a float.') - return f"{QISF} * scale" + return f'{QISF} * scale' def _write_area_dependency_map_expression(self) -> dict[str, DescriptorNumber]: """ @@ -429,7 +435,7 @@ def _write_area_dependency_map_expression(self) -> dict[str, DescriptorNumber]: Dependency map for the area. """ return { - "scale": self.scale, + 'scale': self.scale, } ################################ @@ -446,7 +452,7 @@ def __repr__(self) -> str: String representation of the JumpTranslationalDiffusion model. """ return ( - f"JumpTranslationalDiffusion(name={self.name}, display_name={self.display_name},\n " - f" diffusion_coefficient={self.diffusion_coefficient}, \n" - f" scale={self.scale})" + f'JumpTranslationalDiffusion(name={self.name}, display_name={self.display_name},\n ' + f' diffusion_coefficient={self.diffusion_coefficient}, \n' + f' scale={self.scale})' ) diff --git a/src/easydynamics/sample_model/model_base.py b/src/easydynamics/sample_model/model_base.py index 99923bb9..aa76b384 100644 --- a/src/easydynamics/sample_model/model_base.py +++ b/src/easydynamics/sample_model/model_base.py @@ -35,11 +35,11 @@ def __init__( Parameters ---------- - display_name : str, default="MyModelBase" + display_name : str, default='MyModelBase' Display name of the model. unique_name : str | None, default=None Unique name of the model. If None, a unique name will be generated. - unit : str | sc.Unit | None, default="meV" + unit : str | sc.Unit | None, default='meV' Unit of the model. components : ModelComponent | ComponentCollection | None, default=None Template components of the model. If None, no components are added. These components diff --git a/src/easydynamics/sample_model/sample_model.py b/src/easydynamics/sample_model/sample_model.py index 287554c4..35e0128a 100644 --- a/src/easydynamics/sample_model/sample_model.py +++ b/src/easydynamics/sample_model/sample_model.py @@ -15,6 +15,7 @@ from easydynamics.utils import detailed_balance_factor from easydynamics.utils.utils import Numeric from easydynamics.utils.utils import Q_type +from easydynamics.utils.utils import _validate_and_convert_Q class SampleModel(ModelBase): @@ -43,11 +44,11 @@ def __init__( Parameters ---------- - display_name : str, default="MySampleModel" + display_name : str, default='MySampleModel' Display name of the model. unique_name : str | None, default=None Unique name of the model. If None, a unique name will be generated. - unit : str | sc.Unit, default="meV" + unit : str | sc.Unit, default='meV' Unit of the model. If None,. components : ModelComponent | ComponentCollection | None, default=None Template components of the model. If None, no components are added. These components @@ -59,7 +60,7 @@ def __init__( temperature : float | None, default=None Temperature for detailed balancing. If None, no detailed balancing is applied. By default, None. - temperature_unit : str | sc.Unit, default="K" + temperature_unit : str | sc.Unit, default='K' Unit of the temperature. detailed_balance_settings : DetailedBalanceSettings | None, default=None Settings for detailed balancing. @@ -73,6 +74,7 @@ def __init__( ValueError If temperature is negative. """ + if diffusion_models is None: self._diffusion_models = [] elif isinstance(diffusion_models, DiffusionModelBase): @@ -87,6 +89,10 @@ def __init__( ) self._diffusion_models = diffusion_models + Q = _validate_and_convert_Q(Q) + for dm in self.diffusion_models: + dm.Q = Q # Ensure diffusion models have the same Q as the SampleModel + super().__init__( display_name=display_name, unique_name=unique_name, @@ -142,7 +148,7 @@ def append_diffusion_model(self, diffusion_model: DiffusionModelBase) -> None: raise TypeError( f'diffusion_model must be a DiffusionModelBase, got {type(diffusion_model).__name__}' # noqa: E501 ) - + diffusion_model.Q = self.Q self._diffusion_models.append(diffusion_model) self._generate_component_collections() @@ -160,19 +166,19 @@ def remove_diffusion_model(self, name: str) -> None: ValueError If no DiffusionModel with the given name is found. """ - for i, dm in enumerate(self._diffusion_models): + for i, dm in enumerate(self.diffusion_models): if dm.name == name: - del self._diffusion_models[i] + del self.diffusion_models[i] self._generate_component_collections() return raise ValueError( f'No DiffusionModel with name {name} found. \n' - f'The available names are: {[dm.name for dm in self._diffusion_models]}' + f'The available names are: {[dm.name for dm in self.diffusion_models]}' ) def clear_diffusion_models(self) -> None: """Clear all DiffusionModels from the SampleModel.""" - self._diffusion_models.clear() + self.diffusion_models = [] self._generate_component_collections() # ------------------------------------------------------------------ @@ -214,6 +220,7 @@ def diffusion_models( self._diffusion_models = [] return if isinstance(value, DiffusionModelBase): + value.Q = self.Q self._diffusion_models = [value] return if not isinstance(value, list) or not all( @@ -223,6 +230,8 @@ def diffusion_models( 'diffusion_models must be a DiffusionModelBase, a list of DiffusionModelBase, ' 'or None' ) + for dm in value: + dm.Q = self.Q self._diffusion_models = value self._on_diffusion_models_change() @@ -492,7 +501,7 @@ def get_all_variables(self, Q_index: int | None = None) -> list[Parameter]: if self.temperature is not None: all_vars.append(self.temperature) - for diffusion_model in self._diffusion_models: + for diffusion_model in self.diffusion_models: all_vars.extend(diffusion_model.get_all_variables()) return all_vars @@ -512,11 +521,8 @@ def _generate_component_collections(self) -> None: return # Generate components from diffusion models # and add to component collections - for diffusion_model in self._diffusion_models: - diffusion_collections = diffusion_model.create_component_collections( - Q=self.Q, - # component_name=diffusion_model.name, - ) + for diffusion_model in self.diffusion_models: + diffusion_collections = diffusion_model.get_component_collections() for target, source in zip( self._component_collections, diffusion_collections, @@ -529,6 +535,15 @@ def _on_diffusion_models_change(self) -> None: """Handle changes to the diffusion models.""" self._generate_component_collections() + def _on_Q_change(self) -> None: + """Handle changes to the Q values.""" + + for diffusion_model in self.diffusion_models: + # This may be too aggressive + diffusion_model.clear_Q(confirm=True) + diffusion_model.Q = self.Q + self._generate_component_collections() + # ------------------------------------------------------------------ # dunder methods # ------------------------------------------------------------------ diff --git a/tests/unit/easydynamics/sample_model/diffusion_model/test_brownian_translational_diffusion.py b/tests/unit/easydynamics/sample_model/diffusion_model/test_brownian_translational_diffusion.py index 8be1192c..37585c7d 100644 --- a/tests/unit/easydynamics/sample_model/diffusion_model/test_brownian_translational_diffusion.py +++ b/tests/unit/easydynamics/sample_model/diffusion_model/test_brownian_translational_diffusion.py @@ -12,9 +12,9 @@ BrownianTranslationalDiffusion, ) -hbar_1 = DescriptorNumber("hbar", 1.0) -hbar = DescriptorNumber.from_scipp("hbar", scipp_hbar) -angstrom = DescriptorNumber("angstrom", 1e-10, unit="m") +hbar_1 = DescriptorNumber('hbar', 1.0) +hbar = DescriptorNumber.from_scipp('hbar', scipp_hbar) +angstrom = DescriptorNumber('angstrom', 1e-10, unit='m') class TestBrownianTranslationalDiffusion: @@ -24,82 +24,68 @@ def brownian_diffusion_model(self): def test_init_default(self, brownian_diffusion_model): # WHEN THEN EXPECT - assert brownian_diffusion_model.display_name == "BrownianTranslationalDiffusion" - assert brownian_diffusion_model.unit == "meV" + assert brownian_diffusion_model.display_name == 'BrownianTranslationalDiffusion' + assert brownian_diffusion_model.unit == 'meV' assert brownian_diffusion_model.scale.value == pytest.approx(1.0) - assert brownian_diffusion_model.diffusion_coefficient.value == pytest.approx( - 1.0 - ) + assert brownian_diffusion_model.diffusion_coefficient.value == pytest.approx(1.0) @pytest.mark.parametrize( - "kwargs,expected_exception, expected_message", + 'kwargs,expected_exception, expected_message', [ ( { - "unit": 123, - "scale": 1.0, - "diffusion_coefficient": 1.0, + 'unit': 123, + 'scale': 1.0, + 'diffusion_coefficient': 1.0, }, UnitError, - "Invalid unit", + 'Invalid unit', ), ( { - "unit": 123, - "scale": "invalid", - "diffusion_coefficient": 1.0, + 'unit': 123, + 'scale': 'invalid', + 'diffusion_coefficient': 1.0, }, TypeError, - "scale must be a number", + 'scale must be a number', ), ( { - "unit": 123, - "scale": 1.0, - "diffusion_coefficient": "invalid", + 'unit': 123, + 'scale': 1.0, + 'diffusion_coefficient': 'invalid', }, TypeError, - "diffusion_coefficient must be a number", + 'diffusion_coefficient must be a number', ), ], ) - def test_input_type_validation_raises( - self, kwargs, expected_exception, expected_message - ): + def test_input_type_validation_raises(self, kwargs, expected_exception, expected_message): with pytest.raises(expected_exception, match=expected_message): - BrownianTranslationalDiffusion( - display_name="BrownianTranslationalDiffusion", **kwargs - ) + BrownianTranslationalDiffusion(display_name='BrownianTranslationalDiffusion', **kwargs) def test_diffusion_coefficient_setter(self, brownian_diffusion_model): # WHEN brownian_diffusion_model.diffusion_coefficient = 3.0 # THEN EXPECT - assert brownian_diffusion_model.diffusion_coefficient.value == pytest.approx( - 3.0 - ) + assert brownian_diffusion_model.diffusion_coefficient.value == pytest.approx(3.0) def test_diffusion_coefficient_setter_raises(self, brownian_diffusion_model): # WHEN THEN EXPECT - with pytest.raises(TypeError, match=r"diffusion_coefficient must be a number."): - brownian_diffusion_model.diffusion_coefficient = "invalid" # Invalid type + with pytest.raises(TypeError, match=r'diffusion_coefficient must be a number.'): + brownian_diffusion_model.diffusion_coefficient = 'invalid' # Invalid type - def test_diffusion_coefficient_setter_negative_raises( - self, brownian_diffusion_model - ): + def test_diffusion_coefficient_setter_negative_raises(self, brownian_diffusion_model): # WHEN THEN EXPECT - with pytest.raises( - ValueError, match=r"diffusion_coefficient must be non-negative." - ): - brownian_diffusion_model.diffusion_coefficient = ( - -1.0 - ) # Invalid negative value + with pytest.raises(ValueError, match=r'diffusion_coefficient must be non-negative.'): + brownian_diffusion_model.diffusion_coefficient = -1.0 # Invalid negative value def test_calculate_width_type_error(self, brownian_diffusion_model): # WHEN THEN EXPECT - with pytest.raises(TypeError, match="Q must be "): - brownian_diffusion_model.calculate_width(Q="invalid") # Invalid type + with pytest.raises(TypeError, match='Q must be '): + brownian_diffusion_model.calculate_width(Q='invalid') # Invalid type def test_calculate_width(self, brownian_diffusion_model): # WHEN @@ -113,8 +99,8 @@ def test_calculate_width(self, brownian_diffusion_model): 1 * sc.Unit(brownian_diffusion_model.diffusion_coefficient.unit) * scipp_hbar - / (1 * sc.Unit("Å") ** 2), - "meV", + / (1 * sc.Unit('Å') ** 2), + 'meV', ) expected_widths = 1.0 * unit_conversion_factor.value * (Q_values**2) np.testing.assert_allclose(widths, expected_widths, rtol=1e-5) @@ -132,8 +118,8 @@ def test_calculate_EISF(self, brownian_diffusion_model): def test_calculate_EISF_type_error(self, brownian_diffusion_model): # WHEN THEN EXPECT - with pytest.raises(TypeError, match="Q must be "): - brownian_diffusion_model.calculate_EISF(Q="invalid") # Invalid type + with pytest.raises(TypeError, match='Q must be '): + brownian_diffusion_model.calculate_EISF(Q='invalid') # Invalid type def test_calculate_QISF(self, brownian_diffusion_model): # WHEN @@ -148,20 +134,20 @@ def test_calculate_QISF(self, brownian_diffusion_model): def test_calculate_QISF_type_error(self, brownian_diffusion_model): # WHEN THEN EXPECT - with pytest.raises(TypeError, match="Q must be "): - brownian_diffusion_model.calculate_QISF(Q="invalid") # Invalid type + with pytest.raises(TypeError, match='Q must be '): + brownian_diffusion_model.calculate_QISF(Q='invalid') # Invalid type @pytest.mark.parametrize( - "Q", + 'Q', [ (0.5), ([1.0, 2.0, 3.0]), (np.array([1.0, 2.0, 3.0])), ], ids=[ - "python_scalar", - "python_list", - "numpy_array", + 'python_scalar', + 'python_list', + 'numpy_array', ], ) def test_create_component_collections(self, brownian_diffusion_model, Q): @@ -183,63 +169,53 @@ def test_create_component_collections(self, brownian_diffusion_model, Q): def test_create_component_collections_component_name_must_be_string( self, brownian_diffusion_model ): - brownian_diffusion_model.Q = ( - 0.5 # Set a valid Q value to ensure we get past the Q check - ) + brownian_diffusion_model.Q = 0.5 # Set a valid Q value to ensure we get past the Q check # WHEN THEN EXPECT - with pytest.raises(TypeError, match=r"component_name must be a string."): + with pytest.raises(TypeError, match=r'component_name must be a string.'): brownian_diffusion_model.create_component_collections(component_name=123) def test_create_component_collections_component_display_name_must_be_string( self, brownian_diffusion_model ): - brownian_diffusion_model.Q = ( - 0.5 # Set a valid Q value to ensure we get past the Q check - ) + brownian_diffusion_model.Q = 0.5 # Set a valid Q value to ensure we get past the Q check # WHEN THEN EXPECT - with pytest.raises( - TypeError, match=r"component_display_name must be a string." - ): - brownian_diffusion_model.create_component_collections( - component_display_name=123 - ) + with pytest.raises(TypeError, match=r'component_display_name must be a string.'): + brownian_diffusion_model.create_component_collections(component_display_name=123) def test_write_width_dependency_expression(self, brownian_diffusion_model): # WHEN THEN expression = brownian_diffusion_model._write_width_dependency_expression(0.5) # EXPECT - expected_expression = "hbar * D* 0.5 **2*1/(angstrom**2)" + expected_expression = 'hbar * D* 0.5 **2*1/(angstrom**2)' assert expression == expected_expression def test_write_width_dependency_map_expression(self, brownian_diffusion_model): # WHEN THEN - expression_map = ( - brownian_diffusion_model._write_width_dependency_map_expression() - ) + expression_map = brownian_diffusion_model._write_width_dependency_map_expression() # EXPECT expected_map = { - "D": brownian_diffusion_model.diffusion_coefficient, - "hbar": brownian_diffusion_model._hbar, - "angstrom": brownian_diffusion_model._angstrom, + 'D': brownian_diffusion_model.diffusion_coefficient, + 'hbar': brownian_diffusion_model._hbar, + 'angstrom': brownian_diffusion_model._angstrom, } assert expression_map == expected_map def test_write_width_dependency_expression_raises(self, brownian_diffusion_model): - with pytest.raises(TypeError, match="Q must be a float"): - brownian_diffusion_model._write_width_dependency_expression("invalid") + with pytest.raises(TypeError, match='Q must be a float'): + brownian_diffusion_model._write_width_dependency_expression('invalid') def test_write_area_dependency_expression_raises(self, brownian_diffusion_model): - with pytest.raises(TypeError, match="QISF must be a float"): - brownian_diffusion_model._write_area_dependency_expression("invalid") + with pytest.raises(TypeError, match='QISF must be a float'): + brownian_diffusion_model._write_area_dependency_expression('invalid') def test_repr(self, brownian_diffusion_model): # WHEN THEN repr_str = repr(brownian_diffusion_model) # EXPECT - assert "BrownianTranslationalDiffusion" in repr_str - assert "diffusion_coefficient" in repr_str - assert "scale=" in repr_str + assert 'BrownianTranslationalDiffusion' in repr_str + assert 'diffusion_coefficient' in repr_str + assert 'scale=' in repr_str diff --git a/tests/unit/easydynamics/sample_model/diffusion_model/test_jump_translational_diffusion.py b/tests/unit/easydynamics/sample_model/diffusion_model/test_jump_translational_diffusion.py index 39c8fa6e..605a84aa 100644 --- a/tests/unit/easydynamics/sample_model/diffusion_model/test_jump_translational_diffusion.py +++ b/tests/unit/easydynamics/sample_model/diffusion_model/test_jump_translational_diffusion.py @@ -12,9 +12,9 @@ JumpTranslationalDiffusion, ) -hbar_1 = DescriptorNumber("hbar", 1.0) -hbar = DescriptorNumber.from_scipp("hbar", scipp_hbar) -angstrom = DescriptorNumber("angstrom", 1e-10, unit="m") +hbar_1 = DescriptorNumber('hbar', 1.0) +hbar = DescriptorNumber.from_scipp('hbar', scipp_hbar) +angstrom = DescriptorNumber('angstrom', 1e-10, unit='m') class TestJumpTranslationalDiffusion: @@ -24,64 +24,60 @@ def jump_diffusion_model(self): def test_init_default(self, jump_diffusion_model): # WHEN THEN EXPECT - assert jump_diffusion_model.display_name == "JumpTranslationalDiffusion" - assert jump_diffusion_model.unit == "meV" + assert jump_diffusion_model.display_name == 'JumpTranslationalDiffusion' + assert jump_diffusion_model.unit == 'meV' assert jump_diffusion_model.scale.value == pytest.approx(1.0) assert jump_diffusion_model.diffusion_coefficient.value == pytest.approx(1.0) assert jump_diffusion_model.relaxation_time.value == pytest.approx(1.0) @pytest.mark.parametrize( - "kwargs,expected_exception, expected_message", + 'kwargs,expected_exception, expected_message', [ ( { - "unit": 123, - "scale": 1.0, - "diffusion_coefficient": 1.0, - "relaxation_time": 1.0, + 'unit': 123, + 'scale': 1.0, + 'diffusion_coefficient': 1.0, + 'relaxation_time': 1.0, }, UnitError, - "Invalid unit", + 'Invalid unit', ), ( { - "unit": "meV", - "scale": "invalid", - "diffusion_coefficient": 1.0, - "relaxation_time": 1.0, + 'unit': 'meV', + 'scale': 'invalid', + 'diffusion_coefficient': 1.0, + 'relaxation_time': 1.0, }, TypeError, - "scale must be a number", + 'scale must be a number', ), ( { - "unit": "meV", - "scale": 1.0, - "diffusion_coefficient": "invalid", - "relaxation_time": 1.0, + 'unit': 'meV', + 'scale': 1.0, + 'diffusion_coefficient': 'invalid', + 'relaxation_time': 1.0, }, TypeError, - "diffusion_coefficient must be a number", + 'diffusion_coefficient must be a number', ), ( { - "unit": "meV", - "scale": 1.0, - "diffusion_coefficient": 1.0, - "relaxation_time": "invalid", + 'unit': 'meV', + 'scale': 1.0, + 'diffusion_coefficient': 1.0, + 'relaxation_time': 'invalid', }, TypeError, - "relaxation_time must be a number", + 'relaxation_time must be a number', ), ], ) - def test_input_type_validation_raises( - self, kwargs, expected_exception, expected_message - ): + def test_input_type_validation_raises(self, kwargs, expected_exception, expected_message): with pytest.raises(expected_exception, match=expected_message): - JumpTranslationalDiffusion( - display_name="JumpTranslationalDiffusion", **kwargs - ) + JumpTranslationalDiffusion(display_name='JumpTranslationalDiffusion', **kwargs) def test_diffusion_coefficient_setter(self, jump_diffusion_model): # WHEN @@ -92,14 +88,12 @@ def test_diffusion_coefficient_setter(self, jump_diffusion_model): def test_diffusion_coefficient_setter_raises(self, jump_diffusion_model): # WHEN THEN EXPECT - with pytest.raises(TypeError, match=r"diffusion_coefficient must be a number."): - jump_diffusion_model.diffusion_coefficient = "invalid" # Invalid type + with pytest.raises(TypeError, match=r'diffusion_coefficient must be a number.'): + jump_diffusion_model.diffusion_coefficient = 'invalid' # Invalid type def test_diffusion_coefficient_setter_negative_raises(self, jump_diffusion_model): # WHEN THEN EXPECT - with pytest.raises( - ValueError, match=r"diffusion_coefficient must be non-negative." - ): + with pytest.raises(ValueError, match=r'diffusion_coefficient must be non-negative.'): jump_diffusion_model.diffusion_coefficient = -1.0 # Invalid negative value def test_relaxation_time_setter(self, jump_diffusion_model): @@ -111,42 +105,39 @@ def test_relaxation_time_setter(self, jump_diffusion_model): def test_relaxation_time_setter_raises(self, jump_diffusion_model): # WHEN THEN EXPECT - with pytest.raises(TypeError, match=r"relaxation_time must be a number."): - jump_diffusion_model.relaxation_time = "invalid" # Invalid type + with pytest.raises(TypeError, match=r'relaxation_time must be a number.'): + jump_diffusion_model.relaxation_time = 'invalid' # Invalid type def test_relaxation_time_setter_negative_raises(self, jump_diffusion_model): # WHEN THEN EXPECT - with pytest.raises(ValueError, match=r"relaxation_time must be non-negative."): + with pytest.raises(ValueError, match=r'relaxation_time must be non-negative.'): jump_diffusion_model.relaxation_time = -1.0 # Invalid negative value def test_calculate_width_type_error(self, jump_diffusion_model): # WHEN THEN EXPECT - with pytest.raises(TypeError, match="Q must be "): - jump_diffusion_model.calculate_width(Q="invalid") # Invalid type + with pytest.raises(TypeError, match='Q must be '): + jump_diffusion_model.calculate_width(Q='invalid') # Invalid type def test_calculate_width(self, jump_diffusion_model): "Test the calculation relying solely on a scipp implementation" - "instead of our Parameters" + 'instead of our Parameters' # WHEN - Q_values = sc.linspace("Q", 0.5, 1.5, num=6, unit="1/angstrom") + Q_values = sc.linspace('Q', 0.5, 1.5, num=6, unit='1/angstrom') relaxation_time_sc = jump_diffusion_model.relaxation_time.value * sc.Unit( jump_diffusion_model.relaxation_time.unit ) - diffusion_coefficient_sc = ( - jump_diffusion_model.diffusion_coefficient.value - * sc.Unit(jump_diffusion_model.diffusion_coefficient.unit) + diffusion_coefficient_sc = jump_diffusion_model.diffusion_coefficient.value * sc.Unit( + jump_diffusion_model.diffusion_coefficient.unit ) # THEN widths = jump_diffusion_model.calculate_width(Q_values) denominator = diffusion_coefficient_sc * relaxation_time_sc * Q_values**2 - denominator = denominator.to(unit="1") + denominator = denominator.to(unit='1') # EXPECT - expected_widths = ( - scipp_hbar * diffusion_coefficient_sc * (Q_values**2) / (1 + denominator) - ) + expected_widths = scipp_hbar * diffusion_coefficient_sc * (Q_values**2) / (1 + denominator) expected_widths = expected_widths.to(unit=jump_diffusion_model.unit) @@ -165,8 +156,8 @@ def test_calculate_EISF(self, jump_diffusion_model): def test_calculate_EISF_type_error(self, jump_diffusion_model): # WHEN THEN EXPECT - with pytest.raises(TypeError, match="Q must be "): - jump_diffusion_model.calculate_EISF(Q="invalid") # Invalid type + with pytest.raises(TypeError, match='Q must be '): + jump_diffusion_model.calculate_EISF(Q='invalid') # Invalid type def test_calculate_QISF(self, jump_diffusion_model): # WHEN @@ -181,20 +172,20 @@ def test_calculate_QISF(self, jump_diffusion_model): def test_calculate_QISF_type_error(self, jump_diffusion_model): # WHEN THEN EXPECT - with pytest.raises(TypeError, match="Q must be "): - jump_diffusion_model.calculate_QISF(Q="invalid") # Invalid type + with pytest.raises(TypeError, match='Q must be '): + jump_diffusion_model.calculate_QISF(Q='invalid') # Invalid type @pytest.mark.parametrize( - "Q", + 'Q', [ (0.5), ([1.0, 2.0, 3.0]), (np.array([1.0, 2.0, 3.0])), ], ids=[ - "python_scalar", - "python_list", - "numpy_array", + 'python_scalar', + 'python_list', + 'numpy_array', ], ) def test_create_component_collections(self, jump_diffusion_model, Q): @@ -217,26 +208,18 @@ def test_create_component_collections(self, jump_diffusion_model, Q): def test_create_component_collections_component_name_must_be_string( self, jump_diffusion_model ): - jump_diffusion_model.Q = ( - 0.5 # Set a valid Q value to ensure we get past the Q check - ) + jump_diffusion_model.Q = 0.5 # Set a valid Q value to ensure we get past the Q check # WHEN THEN EXPECT - with pytest.raises(TypeError, match=r"component_name must be a string."): + with pytest.raises(TypeError, match=r'component_name must be a string.'): jump_diffusion_model.create_component_collections(component_name=123) def test_create_component_collections_component_display_name_must_be_string( self, jump_diffusion_model ): - jump_diffusion_model.Q = ( - 0.5 # Set a valid Q value to ensure we get past the Q check - ) + jump_diffusion_model.Q = 0.5 # Set a valid Q value to ensure we get past the Q check # WHEN THEN EXPECT - with pytest.raises( - TypeError, match=r"component_display_name must be a string." - ): - jump_diffusion_model.create_component_collections( - component_display_name=123 - ) + with pytest.raises(TypeError, match=r'component_display_name must be a string.'): + jump_diffusion_model.create_component_collections(component_display_name=123) def test_write_width_dependency_expression(self, jump_diffusion_model): # WHEN THEN @@ -244,7 +227,7 @@ def test_write_width_dependency_expression(self, jump_diffusion_model): # EXPECT expected_expression = ( - "hbar * D* 0.5 **2/(angstrom**2)/(1 + (D * t* 0.5 **2/(angstrom**2)))" + 'hbar * D* 0.5 **2/(angstrom**2)/(1 + (D * t* 0.5 **2/(angstrom**2)))' ) assert expression == expected_expression @@ -254,27 +237,27 @@ def test_write_width_dependency_map_expression(self, jump_diffusion_model): # EXPECT expected_map = { - "D": jump_diffusion_model.diffusion_coefficient, - "t": jump_diffusion_model.relaxation_time, - "hbar": jump_diffusion_model._hbar, - "angstrom": jump_diffusion_model._angstrom, + 'D': jump_diffusion_model.diffusion_coefficient, + 't': jump_diffusion_model.relaxation_time, + 'hbar': jump_diffusion_model._hbar, + 'angstrom': jump_diffusion_model._angstrom, } assert expression_map == expected_map def test_write_width_dependency_expression_raises(self, jump_diffusion_model): - with pytest.raises(TypeError, match="Q must be a float"): - jump_diffusion_model._write_width_dependency_expression("invalid") + with pytest.raises(TypeError, match='Q must be a float'): + jump_diffusion_model._write_width_dependency_expression('invalid') def test_write_area_dependency_expression_raises(self, jump_diffusion_model): - with pytest.raises(TypeError, match="QISF must be a float"): - jump_diffusion_model._write_area_dependency_expression("invalid") + with pytest.raises(TypeError, match='QISF must be a float'): + jump_diffusion_model._write_area_dependency_expression('invalid') def test_repr(self, jump_diffusion_model): # WHEN THEN repr_str = repr(jump_diffusion_model) # EXPECT - assert "JumpTranslationalDiffusion" in repr_str - assert "diffusion_coefficient" in repr_str - assert "scale=" in repr_str + assert 'JumpTranslationalDiffusion' in repr_str + assert 'diffusion_coefficient' in repr_str + assert 'scale=' in repr_str diff --git a/tests/unit/easydynamics/sample_model/test_sample_model.py b/tests/unit/easydynamics/sample_model/test_sample_model.py index 337266f5..3055440c 100644 --- a/tests/unit/easydynamics/sample_model/test_sample_model.py +++ b/tests/unit/easydynamics/sample_model/test_sample_model.py @@ -469,7 +469,6 @@ def test_generate_component_collections(self, sample_model): assert collection[0].area.value == pytest.approx(1.0) assert collection[1].name == 'TestLorentzian1Name' assert collection[1].area.value == pytest.approx(2.0) - assert collection[2].name == 'DiffusionModelName' assert isinstance(collection[2], Lorentzian) def test_get_all_variables(self, sample_model): From a5eb1122c8e496c4d0ca46395b2f51b5bd5313f2 Mon Sep 17 00:00:00 2001 From: henrikjacobsenfys Date: Sat, 23 May 2026 22:15:13 +0200 Subject: [PATCH 11/18] Add name and display name for components of diffusion models --- docs/docs/tutorials/DeltaLorentz.ipynb | 2 + src/easydynamics/sample_model/__init__.py | 6 + .../brownian_translational_diffusion.py | 37 ++-- .../diffusion_model/delta_lorentz.py | 184 ++++++++++++------ .../diffusion_model/diffusion_model_base.py | 110 ++++++++++- .../jump_translational_diffusion.py | 34 ++-- src/easydynamics/sample_model/sample_model.py | 2 +- .../test_brownian_translational_diffusion.py | 16 -- .../test_jump_translational_diffusion.py | 16 -- 9 files changed, 272 insertions(+), 135 deletions(-) diff --git a/docs/docs/tutorials/DeltaLorentz.ipynb b/docs/docs/tutorials/DeltaLorentz.ipynb index 5729679e..e648c752 100644 --- a/docs/docs/tutorials/DeltaLorentz.ipynb +++ b/docs/docs/tutorials/DeltaLorentz.ipynb @@ -45,6 +45,8 @@ " lorentzian_width=lorentzian_width,\n", " allow_Q_variation={'A_0': True},\n", " Q=Q,\n", + " lorentzian_name='Lorentzian',\n", + " delta_name='Delta function',\n", ")" ] }, diff --git a/src/easydynamics/sample_model/__init__.py b/src/easydynamics/sample_model/__init__.py index 6f75bafd..07f434a1 100644 --- a/src/easydynamics/sample_model/__init__.py +++ b/src/easydynamics/sample_model/__init__.py @@ -16,6 +16,10 @@ from easydynamics.sample_model.diffusion_model.brownian_translational_diffusion import ( BrownianTranslationalDiffusion, ) +from easydynamics.sample_model.diffusion_model.delta_lorentz import DeltaLorentz +from easydynamics.sample_model.diffusion_model.jump_translational_diffusion import ( + JumpTranslationalDiffusion, +) from easydynamics.sample_model.instrument_model import InstrumentModel from easydynamics.sample_model.resolution_model import ResolutionModel from easydynamics.sample_model.sample_model import SampleModel @@ -26,10 +30,12 @@ 'ComponentCollection', 'DampedHarmonicOscillator', 'DeltaFunction', + 'DeltaLorentz', 'Exponential', 'ExpressionComponent', 'Gaussian', 'InstrumentModel', + 'JumpTranslationalDiffusion', 'Lorentzian', 'Polynomial', 'ResolutionModel', diff --git a/src/easydynamics/sample_model/diffusion_model/brownian_translational_diffusion.py b/src/easydynamics/sample_model/diffusion_model/brownian_translational_diffusion.py index 2cae5654..8862554a 100644 --- a/src/easydynamics/sample_model/diffusion_model/brownian_translational_diffusion.py +++ b/src/easydynamics/sample_model/diffusion_model/brownian_translational_diffusion.py @@ -45,6 +45,8 @@ def __init__( unit: str | sc.Unit = 'meV', name: str = 'BrownianTranslationalDiffusion', display_name: str | None = 'BrownianTranslationalDiffusion', + lorentzian_name: str | None = None, + lorentzian_display_name: str | None = None, unique_name: str | None = None, ) -> None: """ @@ -64,6 +66,12 @@ def __init__( Name of the diffusion model. display_name : str | None, default='BrownianTranslationalDiffusion' Display name of the diffusion model. + lorentzian_name : str | None, default=None + Name of the Lorentzian component. If None, it will be set to the name of the diffusion + model. + lorentzian_display_name : str | None, default=None + Display name of the Lorentzian component. If None, it will be set to the + lorentzian_name. unique_name : str | None, default=None Unique name of the diffusion model. If None, a unique name will be generated. By default, None. @@ -102,6 +110,8 @@ def __init__( name=name, display_name=display_name, unique_name=unique_name, + lorentzian_name=lorentzian_name, + lorentzian_display_name=lorentzian_display_name, ) self._hbar = hbar self._angstrom = angstrom @@ -212,25 +222,11 @@ def calculate_QISF(self, Q: Q_type | None = None) -> np.ndarray: def create_component_collections( self, - component_name: str = 'Brownian diffusion', - component_display_name: str | None = None, ) -> list[ComponentCollection]: r""" Create ComponentCollection components for the Brownian translational diffusion model at given Q values. - Parameters - ---------- - component_name : str, default='Brownian diffusion' - Name of the Brownian diffusion component. - component_display_name : str | None, default=None - Display name of the Brownian diffusion component. - - Raises - ------ - TypeError - If component_display_name is not a string. If component_name is not a string. - Returns ------- list[ComponentCollection] @@ -243,15 +239,6 @@ def create_component_collections( self._component_collections = [] return self._component_collections - if not isinstance(component_name, str): - raise TypeError('component_name must be a string.') - - if component_display_name is None: - component_display_name = component_name - - if not isinstance(component_display_name, str): - raise TypeError('component_display_name must be a string.') - component_collection_list = [None] * len(Q) # In more complex models, this is used to scale the area of the # Lorentzians and the delta function. @@ -268,8 +255,8 @@ def create_component_collections( ) lorentzian_component = Lorentzian( - name=component_name, - display_name=component_display_name, + name=self.lorentzian_name, + display_name=self.lorentzian_display_name, unit=self.unit, ) diff --git a/src/easydynamics/sample_model/diffusion_model/delta_lorentz.py b/src/easydynamics/sample_model/diffusion_model/delta_lorentz.py index a4360d5b..69234724 100644 --- a/src/easydynamics/sample_model/diffusion_model/delta_lorentz.py +++ b/src/easydynamics/sample_model/diffusion_model/delta_lorentz.py @@ -62,6 +62,10 @@ def __init__( unit: str | sc.Unit = 'meV', name: str = 'DeltaLorentz', display_name: str | None = None, + lorentzian_name: str = 'Lorentzian', + lorentzian_display_name: str | None = None, + delta_name: str = 'Delta function', + delta_display_name: str | None = None, unique_name: str | None = None, ) -> None: """ @@ -90,6 +94,17 @@ def __init__( Name of the diffusion model. display_name : str | None, default=None Display name of the diffusion model. + lorentzian_name : str, default='Lorentzian' + Name of the Lorentzian component. If None, it will be set to the name of the diffusion + model. + lorentzian_display_name : str | None, default=None + Display name of the Lorentzian component. If None, it will be set to the display name + of the diffusion model. + delta_name : str, default='Delta function' + Name of the delta function component. + delta_display_name : str | None, default=None + Display name of the delta function component. If None, it will be set to the display + name of the delta function component. unique_name : str | None, default=None Unique name of the diffusion model. If None, a unique name will be generated. By default, None. @@ -108,6 +123,8 @@ def __init__( scale=scale, unit=unit, Q=Q, + lorentzian_name=lorentzian_name, + lorentzian_display_name=lorentzian_display_name, name=name, display_name=display_name, unique_name=unique_name, @@ -131,6 +148,18 @@ def __init__( if float(lorentzian_width) < MINIMUM_WIDTH: raise ValueError(f'lorentzian_width must be at least {MINIMUM_WIDTH}.') + if not isinstance(delta_name, str): + raise TypeError('delta_name must be a string.') + + if delta_display_name is None: + delta_display_name = delta_name + + if not isinstance(delta_display_name, str): + raise TypeError('delta_display_name must be a string.') + + self._delta_name = delta_name + self._delta_display_name = delta_display_name + allow_Q_variation_default = { 'A_0': False, 'lorentzian_width': False, @@ -347,6 +376,71 @@ def lorentzian_width(self, lorentzian_width: Numeric) -> None: raise ValueError(f'lorentzian_width must be at least {MINIMUM_WIDTH}.') self._lorentzian_width.value = float(lorentzian_width) + @property + def delta_name(self) -> str: + """ + Get the name of the delta function component. + + Returns + ------- + str + Name of the delta function component. + """ + return self._delta_name + + @delta_name.setter + def delta_name(self, delta_name: str) -> None: + """ + Set the name of the delta function component. + + Parameters + ---------- + delta_name : str + The new name for the delta function component. + + Raises + ------ + TypeError + If delta_name is not a string. + """ + if not isinstance(delta_name, str): + raise TypeError('delta_name must be a string.') + self._delta_name = delta_name + + if self.delta_display_name is None: + self.delta_display_name = delta_name + + @property + def delta_display_name(self) -> str: + """ + Get the display name of the delta function component. + + Returns + ------- + str + Display name of the delta function component. + """ + return self._delta_display_name + + @delta_display_name.setter + def delta_display_name(self, delta_display_name: str | None) -> None: + """ + Set the display name of the delta function component. + + Parameters + ---------- + delta_display_name : str | None + The new display name for the delta function component. + + Raises + ------ + TypeError + If delta_display_name is not a string or None. + """ + if not isinstance(delta_display_name, (str, type(None))): + raise TypeError('delta_display_name must be a string or None.') + self._delta_display_name = delta_display_name + # ------------------------------------------------------------------ # Other methods # ------------------------------------------------------------------ @@ -423,26 +517,10 @@ def calculate_QISF(self, Q: Q_type) -> np.ndarray: def create_component_collections( self, - lorentzian_name: str = 'Lorentzian', - delta_name: str = 'Delta function', ) -> list[ComponentCollection]: r""" Create ComponentCollection components for the DeltaLorentz model at given Q values. - Parameters - ---------- - lorentzian_name : str, default='Lorentzian' - Name of the Lorentzian component. - delta_name : str, default='Delta function' - Name of the Delta function component. - - Raises - ------ - TypeError - If lorentzian_name or delta_name is not a string. - ValueError - If Q is not set in the model. - Returns ------- list[ComponentCollection] @@ -451,13 +529,7 @@ def create_component_collections( """ Q = self.Q if Q is None: - raise ValueError('Q must be set in the model to create component collections.') - - if not isinstance(lorentzian_name, str): - raise TypeError('lorentzian_name must be a string.') - - if not isinstance(delta_name, str): - raise TypeError('delta_name must be a string.') + return [] if self._allow_Q_variation['A_0'] is True: A_0_list, A_1_list = self._create_A0_A1_parameters(self.A_0) @@ -478,10 +550,15 @@ def create_component_collections( # ------------------------------# # Create Lorentzian # ------------------------------# - + if self._allow_Q_variation['lorentzian_width'] is True: + width = self._lorentzian_width_list[i] + else: + width = 1.0 lorentzian_component = Lorentzian( - name=lorentzian_name, + name=self.lorentzian_name, + display_name=self.lorentzian_display_name, unit=self.unit, + width=width, ) # If the width is allowed to vary with Q it is independent. @@ -495,7 +572,6 @@ def create_component_collections( dependency_map=dependency_map, desired_unit=self.unit, ) - # The area is always a dependent parameter in this model, as # it depends on the scale, mean_u_squared and A_1 parameters # of the model. If A_1 is allowed to vary with Q, the area @@ -519,7 +595,8 @@ def create_component_collections( # ------------------------------# delta_component = DeltaFunction( - name=delta_name, + name=self.delta_name, + display_name=self.delta_display_name, unit=self.unit, ) @@ -597,7 +674,7 @@ def _on_Q_change(self) -> None: if self._allow_Q_variation['lorentzian_width'] is True: self._lorentzian_width_list = self._create_lorentzian_width_parameters( - self.lorentzian_width, self.Q + self.lorentzian_width ) else: self._lorentzian_width_list = [] @@ -623,24 +700,24 @@ def _create_A0_A1_parameters( """ A_0_list = [] A_1_list = [] - for i, Q_value in enumerate(self.Q): - A_0_list.append( - Parameter( - name=f'A_0_Q{Q_value:.2f}', - value=float(A_0.value), - fixed=False, - min=0.0, - max=1.0, - ) + for _ in self.Q: + a0 = Parameter( + name='A_0', + value=float(A_0.value), + fixed=False, + min=0.0, + max=1.0, ) - A_1_list.append( - Parameter.from_dependency( - name=f'A_1_Q{Q_value:.2f}', - dependency_expression='1 - A_0', - dependency_map={'A_0': A_0_list[i]}, - ) + + a1 = Parameter.from_dependency( + name='A_1', + dependency_expression='1 - A_0', + dependency_map={'A_0': a0}, ) + A_0_list.append(a0) + A_1_list.append(a1) + return A_0_list, A_1_list def _create_lorentzian_width_parameters( @@ -661,19 +738,16 @@ def _create_lorentzian_width_parameters( list[Parameter] A list containing the Lorentzian width parameters for each Q value. """ - lorentzian_width_list = [] - for _, Q_value in enumerate(self.Q): - lorentzian_width_list.append( - Parameter( - name=f'lorentzian_width_Q{Q_value:.2f}', - value=float(lorentzian_width.value), - fixed=False, - min=MINIMUM_WIDTH, - unit=self.unit, - ) + return [ + Parameter( + name=f'{self.lorentzian_name} width', + value=float(lorentzian_width.value), + fixed=False, + min=MINIMUM_WIDTH, + unit=self.unit, ) - - return lorentzian_width_list + for _ in self.Q + ] def _write_lorz_width_dependency_expression(self, Q: float) -> str: """ diff --git a/src/easydynamics/sample_model/diffusion_model/diffusion_model_base.py b/src/easydynamics/sample_model/diffusion_model/diffusion_model_base.py index a2ce878d..3ee72cfb 100644 --- a/src/easydynamics/sample_model/diffusion_model/diffusion_model_base.py +++ b/src/easydynamics/sample_model/diffusion_model/diffusion_model_base.py @@ -24,6 +24,8 @@ def __init__( unit: str | sc.Unit = 'meV', name: str = 'DiffusionModel', display_name: str | None = 'MyDiffusionModel', + lorentzian_name: str | None = None, + lorentzian_display_name: str | None = None, unique_name: str | None = None, ) -> None: """ @@ -41,6 +43,12 @@ def __init__( Name of the diffusion model. display_name : str | None, default='MyDiffusionModel' Display name of the diffusion model. + lorentzian_name : str | None, default=None + Name of the Lorentzian component. If None, it will be set to the name of the diffusion + model. + lorentzian_display_name : str | None, default=None + Display name of the Lorentzian component. If None, it will be set to the + lorentzian_name. unique_name : str | None, default=None Unique name of the diffusion model. If None, a unique name will be generated. By default, None. @@ -67,9 +75,24 @@ def __init__( raise TypeError('scale must be a number.') scale = Parameter(name='scale', value=float(scale), fixed=False, min=0.0, unit=unit) + self._scale = scale super().__init__(unit=unit, name=name, display_name=display_name, unique_name=unique_name) - self._scale = scale + + if lorentzian_name is None: + lorentzian_name = name + + if not isinstance(lorentzian_name, str): + raise TypeError('lorentzian_name must be a string.') + + if lorentzian_display_name is None: + lorentzian_display_name = lorentzian_name + + if not isinstance(lorentzian_display_name, str): + raise TypeError('lorentzian_display_name must be a string.') + + self._lorentzian_name = lorentzian_name + self._lorentzian_display_name = lorentzian_display_name # ------------------------------------------------------------------ # Properties @@ -157,6 +180,71 @@ def Q(self, value: Q_type | None) -> None: 'To change Q values, first run clear_Q().' ) + @property + def lorentzian_name(self) -> str: + """ + Get the name of the Lorentzian component. + + Returns + ------- + str + Name of the Lorentzian component. + """ + return self._lorentzian_name + + @lorentzian_name.setter + def lorentzian_name(self, lorentzian_name: str) -> None: + """ + Set the name of the Lorentzian component. + + Parameters + ---------- + lorentzian_name : str + The new name for the Lorentzian component. + + Raises + ------ + TypeError + If lorentzian_name is not a string. + """ + if not isinstance(lorentzian_name, str): + raise TypeError('lorentzian_name must be a string.') + self._lorentzian_name = lorentzian_name + + if self.lorentzian_display_name is None: + self.lorentzian_display_name = lorentzian_name + + @property + def lorentzian_display_name(self) -> str: + """ + Get the display name of the Lorentzian component. + + Returns + ------- + str + Display name of the Lorentzian component. + """ + return self._lorentzian_display_name + + @lorentzian_display_name.setter + def lorentzian_display_name(self, lorentzian_display_name: str | None) -> None: + """ + Set the display name of the Lorentzian component. + + Parameters + ---------- + lorentzian_display_name : str | None + The new display name for the Lorentzian component. + + Raises + ------ + TypeError + If lorentzian_display_name is not a string or None. + """ + if not isinstance(lorentzian_display_name, (str, type(None))): + raise TypeError('lorentzian_display_name must be a string or None.') + self._lorentzian_display_name = lorentzian_display_name + def clear_Q(self, confirm: bool = False) -> None: """ Clear the Q values of the SampleModel, removing all component collections and their @@ -220,6 +308,26 @@ def get_component_collections( ) return self._component_collections[Q_index] + def get_all_variables( + self, + Q_index: int | None = None, # noqa ARG002 + ) -> list[Parameter]: + """ + Get all Parameters from the ComponentCollection at the given Q index. + + Parameters + ---------- + Q_index : int | None, default=None + Unused parameter for compatibility with SampleModel. + + + Returns + ------- + list[Parameter] + A list of all Parameters from the specified ComponentCollection(s). + """ + return super().get_all_variables() + # ------------------------------------------------------------------ # private methods # ------------------------------------------------------------------ diff --git a/src/easydynamics/sample_model/diffusion_model/jump_translational_diffusion.py b/src/easydynamics/sample_model/diffusion_model/jump_translational_diffusion.py index 53377578..74b673b4 100644 --- a/src/easydynamics/sample_model/diffusion_model/jump_translational_diffusion.py +++ b/src/easydynamics/sample_model/diffusion_model/jump_translational_diffusion.py @@ -54,6 +54,8 @@ def __init__( unit: str | sc.Unit = 'meV', name: str = 'JumpTranslationalDiffusion', display_name: str | None = 'JumpTranslationalDiffusion', + lorentzian_name: str | None = None, + lorentzian_display_name: str | None = None, unique_name: str | None = None, ) -> None: """ @@ -75,6 +77,12 @@ def __init__( Name of the diffusion model. display_name : str | None, default='JumpTranslationalDiffusion' Display name of the diffusion model. + lorentzian_name : str | None, default=None + Name of the Lorentzian component. If None, it will be set to the name of the diffusion + model with '_Lorentzian' appended. By default, None. + lorentzian_display_name : str | None, default=None + Display name of the Lorentzian component. If None, it will be set to the display name + of the diffusion model with '_Lorentzian' appended. By default, None unique_name : str | None, default=None Unique name of the diffusion model. If None, a unique name will be generated. By default, None. @@ -90,6 +98,8 @@ def __init__( scale=scale, name=name, display_name=display_name, + lorentzian_name=lorentzian_name, + lorentzian_display_name=lorentzian_display_name, unique_name=unique_name, ) @@ -272,23 +282,11 @@ def calculate_QISF(self, Q: Q_type) -> np.ndarray: def create_component_collections( self, - component_name: str = 'Jump translational diffusion', - component_display_name: str = 'Jump translational diffusion', ) -> list[ComponentCollection]: """ Create ComponentCollection components for the diffusion model at given Q values. - Parameters - ---------- - component_name : str, default='Jump translational diffusion' - Name of the Jump Diffusion Lorentzian component. - component_display_name : str, default='Jump translational diffusion' - Name of the Jump Diffusion Lorentzian component. - - Raises - ------ - TypeError - If component_display_name is not a string. If component_name is not a string. + TypeError If component_display_name is not a string. If component_name is not a string. Returns ------- @@ -300,12 +298,6 @@ def create_component_collections( self._component_collections = [] return self._component_collections - if not isinstance(component_display_name, str): - raise TypeError('component_display_name must be a string.') - - if not isinstance(component_name, str): - raise TypeError('component_name must be a string.') - component_collection_list = [None] * len(Q) # In more complex models, this is used to scale the area of the # Lorentzians and the delta function. @@ -322,8 +314,8 @@ def create_component_collections( ) lorentzian_component = Lorentzian( - name=component_name, - display_name=component_display_name, + name=self.lorentzian_name, + display_name=self.lorentzian_display_name, unit=self.unit, ) diff --git a/src/easydynamics/sample_model/sample_model.py b/src/easydynamics/sample_model/sample_model.py index 35e0128a..83325506 100644 --- a/src/easydynamics/sample_model/sample_model.py +++ b/src/easydynamics/sample_model/sample_model.py @@ -502,7 +502,7 @@ def get_all_variables(self, Q_index: int | None = None) -> list[Parameter]: all_vars.append(self.temperature) for diffusion_model in self.diffusion_models: - all_vars.extend(diffusion_model.get_all_variables()) + all_vars.extend(diffusion_model.get_all_variables(Q_index=Q_index)) return all_vars diff --git a/tests/unit/easydynamics/sample_model/diffusion_model/test_brownian_translational_diffusion.py b/tests/unit/easydynamics/sample_model/diffusion_model/test_brownian_translational_diffusion.py index 37585c7d..738f8f29 100644 --- a/tests/unit/easydynamics/sample_model/diffusion_model/test_brownian_translational_diffusion.py +++ b/tests/unit/easydynamics/sample_model/diffusion_model/test_brownian_translational_diffusion.py @@ -166,22 +166,6 @@ def test_create_component_collections(self, brownian_diffusion_model, Q): assert np.isclose(component.width.value, expected_widths[model_index]) assert component.width.independent is False - def test_create_component_collections_component_name_must_be_string( - self, brownian_diffusion_model - ): - brownian_diffusion_model.Q = 0.5 # Set a valid Q value to ensure we get past the Q check - # WHEN THEN EXPECT - with pytest.raises(TypeError, match=r'component_name must be a string.'): - brownian_diffusion_model.create_component_collections(component_name=123) - - def test_create_component_collections_component_display_name_must_be_string( - self, brownian_diffusion_model - ): - brownian_diffusion_model.Q = 0.5 # Set a valid Q value to ensure we get past the Q check - # WHEN THEN EXPECT - with pytest.raises(TypeError, match=r'component_display_name must be a string.'): - brownian_diffusion_model.create_component_collections(component_display_name=123) - def test_write_width_dependency_expression(self, brownian_diffusion_model): # WHEN THEN expression = brownian_diffusion_model._write_width_dependency_expression(0.5) diff --git a/tests/unit/easydynamics/sample_model/diffusion_model/test_jump_translational_diffusion.py b/tests/unit/easydynamics/sample_model/diffusion_model/test_jump_translational_diffusion.py index 605a84aa..e295e373 100644 --- a/tests/unit/easydynamics/sample_model/diffusion_model/test_jump_translational_diffusion.py +++ b/tests/unit/easydynamics/sample_model/diffusion_model/test_jump_translational_diffusion.py @@ -205,22 +205,6 @@ def test_create_component_collections(self, jump_diffusion_model, Q): assert np.isclose(component.width.value, expected_widths[model_index]) assert component.width.independent is False - def test_create_component_collections_component_name_must_be_string( - self, jump_diffusion_model - ): - jump_diffusion_model.Q = 0.5 # Set a valid Q value to ensure we get past the Q check - # WHEN THEN EXPECT - with pytest.raises(TypeError, match=r'component_name must be a string.'): - jump_diffusion_model.create_component_collections(component_name=123) - - def test_create_component_collections_component_display_name_must_be_string( - self, jump_diffusion_model - ): - jump_diffusion_model.Q = 0.5 # Set a valid Q value to ensure we get past the Q check - # WHEN THEN EXPECT - with pytest.raises(TypeError, match=r'component_display_name must be a string.'): - jump_diffusion_model.create_component_collections(component_display_name=123) - def test_write_width_dependency_expression(self, jump_diffusion_model): # WHEN THEN expression = jump_diffusion_model._write_width_dependency_expression(0.5) From b9eec8eb4f10a9aa3d709dfa0ff29573df66d1c0 Mon Sep 17 00:00:00 2001 From: henrikjacobsenfys Date: Tue, 26 May 2026 09:42:37 +0200 Subject: [PATCH 12/18] Update init, write some tests --- ...DeltaLorentz.ipynb => delta_lorentz.ipynb} | 14 +- docs/docs/tutorials/index.md | 3 + .../diffusion_model/delta_lorentz.py | 326 ++++++++++++------ .../diffusion_model/diffusion_model_base.py | 9 +- .../test_brownian_translational_diffusion.py | 22 +- .../diffusion_model/test_delta_lorentz.py | 21 ++ .../diffusion_model/test_diffusion_model.py | 109 +++++- 7 files changed, 369 insertions(+), 135 deletions(-) rename docs/docs/tutorials/{DeltaLorentz.ipynb => delta_lorentz.ipynb} (75%) create mode 100644 tests/unit/easydynamics/sample_model/diffusion_model/test_delta_lorentz.py diff --git a/docs/docs/tutorials/DeltaLorentz.ipynb b/docs/docs/tutorials/delta_lorentz.ipynb similarity index 75% rename from docs/docs/tutorials/DeltaLorentz.ipynb rename to docs/docs/tutorials/delta_lorentz.ipynb index e648c752..2d0af7b8 100644 --- a/docs/docs/tutorials/DeltaLorentz.ipynb +++ b/docs/docs/tutorials/delta_lorentz.ipynb @@ -5,8 +5,18 @@ "id": "0", "metadata": {}, "source": [ - "# Diffusion Model\n", - "We support several standard models of diffusion. Here we show an example of Browniand Translational Diffusion, where the scattering is a Lorentzian with width ($\\Gamma$) given by $\\Gamma = D Q^2$, where $D$ is the diffusion coefficient (in m$^2$/s) and $Q$ is the momentum transfer." + "# Delta Lorentz\n", + "Model of Delta function and Lorentzian with intensities given by the Debye-Waller factor:\n", + "\n", + "$ I\n", + "= K \\exp \\left( \\frac{-\\langle u^2 \\rangle Q^2}{3} \\right)[A_0 \\delta(E) + (A_1) L(E, \\Gamma)]\n", + "$,\n", + "\n", + "where $K$ is the scale factor, $\\langle u^2 \\rangle$ is the mean square displacement, $Q$ is\n", + "the scattering vector, $A_0$ and $A_1$ are the relative amplitudes of the delta function and\n", + "Lorentzian, respectively, with the constraint that $A_0+A_1=1$, and $L(E, \\Gamma)$ is the\n", + "Lorentzian function with width $\\Gamma$. $A_0$, $A_1$ and the width of the Lorentzian can be\n", + "the same at all $Q$ or be allowed to vary with $Q$.\n" ] }, { diff --git a/docs/docs/tutorials/index.md b/docs/docs/tutorials/index.md index 6c4158a3..0617edb2 100644 --- a/docs/docs/tutorials/index.md +++ b/docs/docs/tutorials/index.md @@ -50,6 +50,9 @@ tutorials. balancing. - [Diffusion model](diffusion_model.ipynb) – Learn how to create and use a model of diffusion. +- [DeltaLorentz](delta_lorentz.ipynb) – Learn how to create and use a + model with a Delta function and a Lorentzian that have a shared + Debye-Waller-like Q-dependence. - [Sample model](sample_model.ipynb) – Learn how to create a model of the scattering from your sample including model components and diffusion models. diff --git a/src/easydynamics/sample_model/diffusion_model/delta_lorentz.py b/src/easydynamics/sample_model/diffusion_model/delta_lorentz.py index 69234724..49d4be6e 100644 --- a/src/easydynamics/sample_model/diffusion_model/delta_lorentz.py +++ b/src/easydynamics/sample_model/diffusion_model/delta_lorentz.py @@ -83,24 +83,24 @@ def __init__( Width of the Lorentzian function. allow_Q_variation : dict | None, default=None Dict describing whether to allow Q variation of A_0 and the Lorentzian width. The dict - should have the keys "A_0" and "lorentzian_width", with boolean values indicating + can have the keys "A_0" and/or "lorentzian_width", with boolean values indicating whether to allow Q-dependence for each parameter. If None, no Q-dependence will be allowed. Q : Q_type | None, default=None Q values for the model. If None, Q is not set. - unit : str | sc.Unit, default='meV' + unit : str | sc.Unit, default="meV" Unit of the diffusion model. Must be convertible to meV. - name : str, default='DeltaLorentz' + name : str, default="DeltaLorentz" Name of the diffusion model. display_name : str | None, default=None Display name of the diffusion model. - lorentzian_name : str, default='Lorentzian' + lorentzian_name : str, default="Lorentzian" Name of the Lorentzian component. If None, it will be set to the name of the diffusion model. lorentzian_display_name : str | None, default=None Display name of the Lorentzian component. If None, it will be set to the display name of the diffusion model. - delta_name : str, default='Delta function' + delta_name : str, default="Delta function" Name of the delta function component. delta_display_name : str | None, default=None Display name of the delta function component. If None, it will be set to the display @@ -112,12 +112,7 @@ def __init__( Raises ------ TypeError - If mean_u_squared, A_0, or lorentzian_width is not a number. If allow_Q_variation is - not a dict or None. - - ValueError - If A_0 is not between 0 and 1, or if lorentzian_width is less than the minimum allowed - width. If mean_u_squared is negative, or if allow_Q_variation contains unknown keys. + If delta_name is not a string or if delta_display_name is not a string or None. """ super().__init__( scale=scale, @@ -130,24 +125,18 @@ def __init__( unique_name=unique_name, ) - if not isinstance(mean_u_squared, Numeric): - raise TypeError('mean_u_squared must be a number.') - - if float(mean_u_squared) < 0: - raise ValueError('mean_u_squared must be non-negative.') + # -------------------------------------------------------------- + # Parameters + # -------------------------------------------------------------- + self._mean_u_squared = self._create_mean_u_squared_parameter(mean_u_squared) - if not isinstance(A_0, Numeric): - raise TypeError('A_0 must be a number.') - - if float(A_0) < 0 or float(A_0) > 1: - raise ValueError('A_0 must be between 0 and 1.') - - if not isinstance(lorentzian_width, Numeric): - raise TypeError('lorentzian_width must be a number.') + self._A_0, self._A_1 = self._create_A0_A1_parameters(A_0) - if float(lorentzian_width) < MINIMUM_WIDTH: - raise ValueError(f'lorentzian_width must be at least {MINIMUM_WIDTH}.') + self._lorentzian_width = self._create_lorentzian_width_parameter(lorentzian_width) + # -------------------------------------------------------------- + # names + # -------------------------------------------------------------- if not isinstance(delta_name, str): raise TypeError('delta_name must be a string.') @@ -155,79 +144,27 @@ def __init__( delta_display_name = delta_name if not isinstance(delta_display_name, str): - raise TypeError('delta_display_name must be a string.') + raise TypeError('delta_display_name must be a string or None.') self._delta_name = delta_name self._delta_display_name = delta_display_name - allow_Q_variation_default = { - 'A_0': False, - 'lorentzian_width': False, - } - allowed_keys = set(allow_Q_variation_default) - - if allow_Q_variation is None: - allow_Q_variation = {} - if not isinstance(allow_Q_variation, dict): - raise TypeError('allow_Q_variation must be a dict or None.') - - unknown_keys = set(allow_Q_variation) - allowed_keys - if unknown_keys: - raise ValueError(f'Unknown keys in allow_Q_variation: {unknown_keys}') - - self._allow_Q_variation = {**allow_Q_variation_default, **allow_Q_variation} - - A_0 = Parameter( - name='A_0', - value=float(A_0), - fixed=False, - min=0.0, - max=1.0, - ) - self._A_0 = A_0 - - A_1 = Parameter.from_dependency( - name='A_1', - dependency_expression='1 - A_0', - dependency_map={'A_0': A_0}, - ) - self._A_1 = A_1 - - mean_u_squared = Parameter( - name='mean_u_squared', - value=float(mean_u_squared), - fixed=False, - min=0.0, - unit='angstrom**2', - ) - self._mean_u_squared = mean_u_squared - - lorentzian_width = Parameter( - name='lorentzian_width', - value=float(lorentzian_width), - fixed=False, - min=MINIMUM_WIDTH, - unit=unit, - ) - self._lorentzian_width = lorentzian_width + # -------------------------------------------------------------- + # Q variation + # -------------------------------------------------------------- + self._allow_Q_variation = self._create_Q_variation_dict(allow_Q_variation) - if self.Q is None: - self._A_0_list = [] - self._A_1_list = [] - self._lorentzian_width_list = [] - else: + self._A_0_list = [] + self._A_1_list = [] + self._lorentzian_width_list = [] + if self.Q is not None: if self._allow_Q_variation['A_0'] is True: - self._A_0_list, self._A_1_list = self._create_A0_A1_parameters(A_0) - else: - self._A_0_list = [] - self._A_1_list = [] + self._A_0_list, self._A_1_list = self._create_A0_A1_parameter_lists(A_0) if self._allow_Q_variation['lorentzian_width'] is True: - self._lorentzian_width_list = self._create_lorentzian_width_parameters( + self._lorentzian_width_list = self._create_lorentzian_width_parameter_list( lorentzian_width, ) - else: - self._lorentzian_width_list = [] self._component_collections = self.create_component_collections() @@ -532,12 +469,14 @@ def create_component_collections( return [] if self._allow_Q_variation['A_0'] is True: - A_0_list, A_1_list = self._create_A0_A1_parameters(self.A_0) + A_0_list, A_1_list = self._create_A0_A1_parameter_lists(self.A_0) self._A_0_list = A_0_list self._A_1_list = A_1_list if self._allow_Q_variation['lorentzian_width'] is True: - lorentzian_width_list = self._create_lorentzian_width_parameters(self.lorentzian_width) + lorentzian_width_list = self._create_lorentzian_width_parameter_list( + self.lorentzian_width + ) self._lorentzian_width_list = lorentzian_width_list component_collection_list = [None] * len(Q) @@ -653,34 +592,165 @@ def get_all_variables(self, Q_index: int | None = None) -> list[DescriptorNumber return variables # ------------------------------------------------------------------ - # Private methods + # Private methods for init # ------------------------------------------------------------------ - def _on_Q_change(self) -> None: + def _create_Q_variation_dict(self, allow_Q_variation: dict | None) -> dict: """ - Handle changes to the Q values. Updates the A_0, A_1 and lorentzian_width parameters if - they are allowed to vary with Q. + Create a dict for the allow_Q_variation attribute, ensuring that it has the correct keys + and default values. + + Parameters + ---------- + allow_Q_variation : dict | None + Dict describing whether to allow Q variation of A_0 and the Lorentzian width. + + Raises + ------ + TypeError + If allow_Q_variation is not a dict or None. + ValueError + If allow_Q_variation contains unknown keys. + + Returns + ------- + dict + A dict with keys 'A_0' and 'lorentzian_width', indicating whether to allow Q variation + for each parameter. """ - if self.Q is None: - self._A_0_list = [] - self._A_1_list = [] - self._lorentzian_width_list = [] - else: - if self._allow_Q_variation['A_0'] is True: - self._A_0_list, self._A_1_list = self._create_A0_A1_parameters(self.A_0) - else: - self._A_0_list = [] - self._A_1_list = [] - if self._allow_Q_variation['lorentzian_width'] is True: - self._lorentzian_width_list = self._create_lorentzian_width_parameters( - self.lorentzian_width - ) - else: - self._lorentzian_width_list = [] - self._component_collections = self.create_component_collections() + allow_Q_variation_default = { + 'A_0': False, + 'lorentzian_width': False, + } + allowed_keys = set(allow_Q_variation_default) + + if allow_Q_variation is None: + allow_Q_variation = {} + if not isinstance(allow_Q_variation, dict): + raise TypeError('allow_Q_variation must be a dict or None.') - def _create_A0_A1_parameters( + unknown_keys = set(allow_Q_variation) - allowed_keys + if unknown_keys: + raise ValueError(f'Unknown keys in allow_Q_variation: {unknown_keys}') + + return {**allow_Q_variation_default, **allow_Q_variation} + + def _create_mean_u_squared_parameter(self, mean_u_squared: Numeric) -> Parameter: + """ + Create the mean square displacement parameter. + + Parameters + ---------- + mean_u_squared : Numeric + The value for the mean square displacement in angstrom^2. + + Raises + ------ + TypeError + If mean_u_squared is not a number. + ValueError + If mean_u_squared is negative. + + Returns + ------- + Parameter + The created mean square displacement parameter. + """ + + if not isinstance(mean_u_squared, Numeric): + raise TypeError('mean_u_squared must be a number.') + + if float(mean_u_squared) < 0: + raise ValueError('mean_u_squared must be non-negative.') + + return Parameter( + name='mean_u_squared', + value=float(mean_u_squared), + fixed=False, + min=0.0, + unit='angstrom**2', + ) + + def _create_A0_A1_parameters(self, A_0: Numeric) -> tuple[Parameter, Parameter]: + """ + Create the A_0 and A_1 parameters. + + Parameters + ---------- + A_0 : Numeric + The value for the A_0 parameter. + + Raises + ------ + TypeError + If A_0 is not a number. + ValueError + If A_0 is not between 0 and 1. + + Returns + ------- + tuple[Parameter, Parameter] + A tuple containing the A_0 and A_1 parameters. + """ + if not isinstance(A_0, Numeric): + raise TypeError('A_0 must be a number.') + + if float(A_0) < 0 or float(A_0) > 1: + raise ValueError('A_0 must be between 0 and 1.') + + A_0 = Parameter( + name='A_0', + value=float(A_0), + fixed=False, + min=0.0, + max=1.0, + ) + + A_1 = Parameter.from_dependency( + name='A_1', + dependency_expression='1 - A_0', + dependency_map={'A_0': A_0}, + ) + return A_0, A_1 + + def _create_lorentzian_width_parameter(self, lorentzian_width: Numeric) -> Parameter: + """ + Create the Lorentzian width parameter. + + Parameters + ---------- + lorentzian_width : Numeric + The value for the Lorentzian width parameter. + + Raises + ------ + TypeError + If lorentzian_width is not a number. + ValueError + If lorentzian_width is less than the minimum width. + + Returns + ------- + Parameter + The created Lorentzian width parameter. + """ + + if not isinstance(lorentzian_width, Numeric): + raise TypeError('lorentzian_width must be a number.') + + if float(lorentzian_width) < MINIMUM_WIDTH: + raise ValueError(f'lorentzian_width must be at least {MINIMUM_WIDTH}.') + + return Parameter( + name='lorentzian_width', + value=float(lorentzian_width), + fixed=False, + min=MINIMUM_WIDTH, + unit=self.unit, + ) + + def _create_A0_A1_parameter_lists( self, A_0: Parameter, ) -> tuple[list[Parameter], list[Parameter]]: @@ -720,7 +790,7 @@ def _create_A0_A1_parameters( return A_0_list, A_1_list - def _create_lorentzian_width_parameters( + def _create_lorentzian_width_parameter_list( self, lorentzian_width: Parameter, ) -> list[Parameter]: @@ -749,6 +819,34 @@ def _create_lorentzian_width_parameters( for _ in self.Q ] + # ------------------------------------------------------------------ + # Private methods + # ------------------------------------------------------------------ + + def _on_Q_change(self) -> None: + """ + Handle changes to the Q values. Updates the A_0, A_1 and lorentzian_width parameters if + they are allowed to vary with Q. + """ + if self.Q is None: + self._A_0_list = [] + self._A_1_list = [] + self._lorentzian_width_list = [] + else: + if self._allow_Q_variation['A_0'] is True: + self._A_0_list, self._A_1_list = self._create_A0_A1_parameter_lists(self.A_0) + else: + self._A_0_list = [] + self._A_1_list = [] + + if self._allow_Q_variation['lorentzian_width'] is True: + self._lorentzian_width_list = self._create_lorentzian_width_parameter_list( + self.lorentzian_width + ) + else: + self._lorentzian_width_list = [] + self._component_collections = self.create_component_collections() + def _write_lorz_width_dependency_expression(self, Q: float) -> str: """ Write the dependency expression for the width as a function of Q to make dependent diff --git a/src/easydynamics/sample_model/diffusion_model/diffusion_model_base.py b/src/easydynamics/sample_model/diffusion_model/diffusion_model_base.py index 3ee72cfb..4b2670a2 100644 --- a/src/easydynamics/sample_model/diffusion_model/diffusion_model_base.py +++ b/src/easydynamics/sample_model/diffusion_model/diffusion_model_base.py @@ -211,18 +211,15 @@ def lorentzian_name(self, lorentzian_name: str) -> None: raise TypeError('lorentzian_name must be a string.') self._lorentzian_name = lorentzian_name - if self.lorentzian_display_name is None: - self.lorentzian_display_name = lorentzian_name - @property - def lorentzian_display_name(self) -> str: + def lorentzian_display_name(self) -> str | None: """ Get the display name of the Lorentzian component. Returns ------- - str - Display name of the Lorentzian component. + str | None + Display name of the Lorentzian component, or None if not set. """ return self._lorentzian_display_name diff --git a/tests/unit/easydynamics/sample_model/diffusion_model/test_brownian_translational_diffusion.py b/tests/unit/easydynamics/sample_model/diffusion_model/test_brownian_translational_diffusion.py index 738f8f29..7941d406 100644 --- a/tests/unit/easydynamics/sample_model/diffusion_model/test_brownian_translational_diffusion.py +++ b/tests/unit/easydynamics/sample_model/diffusion_model/test_brownian_translational_diffusion.py @@ -43,7 +43,7 @@ def test_init_default(self, brownian_diffusion_model): ), ( { - 'unit': 123, + 'unit': 'meV', 'scale': 'invalid', 'diffusion_coefficient': 1.0, }, @@ -52,13 +52,31 @@ def test_init_default(self, brownian_diffusion_model): ), ( { - 'unit': 123, + 'unit': 'meV', + 'scale': -123.4, + 'diffusion_coefficient': 1.0, + }, + ValueError, + 'scale must be non-negative', + ), + ( + { + 'unit': 'meV', 'scale': 1.0, 'diffusion_coefficient': 'invalid', }, TypeError, 'diffusion_coefficient must be a number', ), + ( + { + 'unit': 'meV', + 'scale': 1.0, + 'diffusion_coefficient': -123.4, + }, + ValueError, + 'diffusion_coefficient must be non-negative', + ), ], ) def test_input_type_validation_raises(self, kwargs, expected_exception, expected_message): diff --git a/tests/unit/easydynamics/sample_model/diffusion_model/test_delta_lorentz.py b/tests/unit/easydynamics/sample_model/diffusion_model/test_delta_lorentz.py new file mode 100644 index 00000000..eb34802f --- /dev/null +++ b/tests/unit/easydynamics/sample_model/diffusion_model/test_delta_lorentz.py @@ -0,0 +1,21 @@ +# SPDX-FileCopyrightText: 2026 EasyScience contributors +# SPDX-License-Identifier: BSD-3-Clause + +import pytest + +from easydynamics.sample_model.diffusion_model.delta_lorentz import DeltaLorentz + + +class TestDeltaLorentz: + @pytest.fixture + def delta_lorentz_model(self): + return DeltaLorentz() + + def test_init_default(self, delta_lorentz_model): + # WHEN THEN EXPECT + assert delta_lorentz_model.display_name == 'DeltaLorentz' + assert delta_lorentz_model.unit == 'meV' + assert delta_lorentz_model.scale.value == pytest.approx(1.0) + assert delta_lorentz_model.mean_u_squared.value == pytest.approx(0.0) + assert delta_lorentz_model.A_0.value == pytest.approx(1.0) + assert delta_lorentz_model.lorentzian_width.value == pytest.approx(1.0) diff --git a/tests/unit/easydynamics/sample_model/diffusion_model/test_diffusion_model.py b/tests/unit/easydynamics/sample_model/diffusion_model/test_diffusion_model.py index c46dc561..8a34b590 100644 --- a/tests/unit/easydynamics/sample_model/diffusion_model/test_diffusion_model.py +++ b/tests/unit/easydynamics/sample_model/diffusion_model/test_diffusion_model.py @@ -2,6 +2,7 @@ # SPDX-License-Identifier: BSD-3-Clause import pytest +from easyscience.variable.parameter import Parameter from easydynamics.sample_model.diffusion_model.diffusion_model_base import DiffusionModelBase @@ -24,22 +25,108 @@ def test_unit_setter_raises(self, diffusion_model): ): diffusion_model.unit = 'eV' - def test_scale_setter(self, diffusion_model): + # def test_scale_setter(self, diffusion_model): + # # WHEN + # diffusion_model.scale = 2.0 + + # # THEN EXPECT + # assert diffusion_model.scale.value == pytest.approx(2.0) + + # def test_scale_setter_negative_raises(self, diffusion_model): + # # WHEN THEN EXPECT + # with pytest.raises(ValueError, match=r'scale must be non-negative.'): + # diffusion_model.scale = -1.0 # Invalid negative value + + # def test_scale_setter_raises(self, diffusion_model): + # # WHEN THEN EXPECT + # with pytest.raises(TypeError, match=r'scale must be a number.'): + # diffusion_model.scale = 'invalid' # Invalid type + + @pytest.mark.parametrize( + ('attribute', 'value', 'expected'), + [ + ('scale', 2.0, 2.0), + ('scale', 0.0, 0.0), + ('scale', 5, 5.0), + ('lorentzian_name', 'lorentzian', 'lorentzian'), + ('lorentzian_name', '', ''), + ('lorentzian_display_name', 'display', 'display'), + ('lorentzian_display_name', None, None), + ], + ) + def test_setters_valid( + self, + diffusion_model, + attribute, + value, + expected, + ): # WHEN - diffusion_model.scale = 2.0 - # THEN EXPECT - assert diffusion_model.scale.value == pytest.approx(2.0) + # THEN + setattr(diffusion_model, attribute, value) - def test_scale_setter_negative_raises(self, diffusion_model): - # WHEN THEN EXPECT - with pytest.raises(ValueError, match=r'scale must be non-negative.'): - diffusion_model.scale = -1.0 # Invalid negative value + # EXPECT + result = getattr(diffusion_model, attribute) + + # Handle Parameters + if isinstance(result, Parameter): + result = result.value + + assert result == expected - def test_scale_setter_raises(self, diffusion_model): + @pytest.mark.parametrize( + ('attribute', 'value', 'exception', 'message'), + [ + ( + 'scale', + -1.0, + ValueError, + r'scale must be non-negative.', + ), + ( + 'scale', + 'invalid', + TypeError, + r'scale must be a number.', + ), + ( + 'lorentzian_name', + 1, + TypeError, + r'lorentzian_name must be a string.', + ), + ( + 'lorentzian_name', + None, + TypeError, + r'lorentzian_name must be a string.', + ), + ( + 'lorentzian_display_name', + 1, + TypeError, + r'lorentzian_display_name must be a string or None.', + ), + ( + 'lorentzian_display_name', + [], + TypeError, + r'lorentzian_display_name must be a string or None.', + ), + ], + ) + def test_setters_invalid( + self, + diffusion_model, + attribute, + value, + exception, + message, + ): # WHEN THEN EXPECT - with pytest.raises(TypeError, match=r'scale must be a number.'): - diffusion_model.scale = 'invalid' # Invalid type + with pytest.raises(exception, match=message): + setattr(diffusion_model, attribute, value) def test_repr(self, diffusion_model): # WHEN THEN From f237d926906ec8778fa16a79fe725b6cd08fa4cd Mon Sep 17 00:00:00 2001 From: henrikjacobsenfys Date: Tue, 26 May 2026 09:46:13 +0200 Subject: [PATCH 13/18] Fix minor bug --- .../sample_model/diffusion_model/delta_lorentz.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/easydynamics/sample_model/diffusion_model/delta_lorentz.py b/src/easydynamics/sample_model/diffusion_model/delta_lorentz.py index 49d4be6e..3f1ae8b2 100644 --- a/src/easydynamics/sample_model/diffusion_model/delta_lorentz.py +++ b/src/easydynamics/sample_model/diffusion_model/delta_lorentz.py @@ -159,11 +159,11 @@ def __init__( self._lorentzian_width_list = [] if self.Q is not None: if self._allow_Q_variation['A_0'] is True: - self._A_0_list, self._A_1_list = self._create_A0_A1_parameter_lists(A_0) + self._A_0_list, self._A_1_list = self._create_A0_A1_parameter_lists(self.A_0) if self._allow_Q_variation['lorentzian_width'] is True: self._lorentzian_width_list = self._create_lorentzian_width_parameter_list( - lorentzian_width, + self.lorentzian_width, ) self._component_collections = self.create_component_collections() From bd05e842e99d210d1693fd9f955754485cce3413 Mon Sep 17 00:00:00 2001 From: henrikjacobsenfys Date: Tue, 26 May 2026 12:26:16 +0200 Subject: [PATCH 14/18] tests --- .../diffusion_model/delta_lorentz.py | 27 +- .../diffusion_model/test_delta_lorentz.py | 251 ++++++++++++++++++ 2 files changed, 263 insertions(+), 15 deletions(-) diff --git a/src/easydynamics/sample_model/diffusion_model/delta_lorentz.py b/src/easydynamics/sample_model/diffusion_model/delta_lorentz.py index 3f1ae8b2..2964295b 100644 --- a/src/easydynamics/sample_model/diffusion_model/delta_lorentz.py +++ b/src/easydynamics/sample_model/diffusion_model/delta_lorentz.py @@ -344,9 +344,6 @@ def delta_name(self, delta_name: str) -> None: raise TypeError('delta_name must be a string.') self._delta_name = delta_name - if self.delta_display_name is None: - self.delta_display_name = delta_name - @property def delta_display_name(self) -> str: """ @@ -382,13 +379,15 @@ def delta_display_name(self, delta_display_name: str | None) -> None: # Other methods # ------------------------------------------------------------------ - def calculate_width(self, Q: Q_type) -> np.ndarray: + def calculate_width(self, Q: Q_type = None) -> np.ndarray: """ - Calculate the half-width at half-maximum (HWHM) for the diffusion model. + Calculate the half-width at half-maximum (HWHM) for the diffusion model. If the width is + allowed to vary with Q then the Q stored in the model is used and the input is ignored. If + the width is not allowed to vary then the same width is returned for all Q values. Parameters ---------- - Q : Q_type + Q : Q_type, default=None Scattering vector in 1/angstrom. Returns @@ -406,13 +405,13 @@ def calculate_width(self, Q: Q_type) -> np.ndarray: return np.array(widths) - def calculate_EISF(self, Q: Q_type) -> np.ndarray: + def calculate_EISF(self, Q: Q_type = None) -> np.ndarray: """ Calculate the Elastic Incoherent Structure Factor (EISF) for the diffusion model. Parameters ---------- - Q : Q_type + Q : Q_type, default=None Scattering vector in 1/angstrom. Returns @@ -420,23 +419,21 @@ def calculate_EISF(self, Q: Q_type) -> np.ndarray: np.ndarray EISF values (dimensionless). """ + Q = self._ensure_Q(Q) if self._allow_Q_variation['A_0'] is True: A_0_values = [A_0_.value for A_0_ in self._A_0_list] return np.exp(-self.mean_u_squared.value * Q**2 / 3) * np.array(A_0_values) - # Need to handle units better - Q = self._ensure_Q(Q) - A_0_values = [self.A_0.value] * len(Q) return np.exp(-self.mean_u_squared.value * Q**2 / 3) * np.array(A_0_values) - def calculate_QISF(self, Q: Q_type) -> np.ndarray: + def calculate_QISF(self, Q: Q_type = None) -> np.ndarray: """ Calculate the Quasi-Elastic Incoherent Structure Factor (QISF). Parameters ---------- - Q : Q_type + Q : Q_type, default=None Scattering vector in 1/angstrom. Returns @@ -444,11 +441,11 @@ def calculate_QISF(self, Q: Q_type) -> np.ndarray: np.ndarray QISF values (dimensionless). """ - if self._allow_Q_variation['A_1'] is True: + Q = self._ensure_Q(Q) + if self._allow_Q_variation['A_0'] is True: A_1_values = [A_1_.value for A_1_ in self._A_1_list] return np.exp(-self.mean_u_squared.value * Q**2 / 3) * np.array(A_1_values) - Q = self._ensure_Q(Q) A_1_values = [self.A_1.value] * len(Q) return np.exp(-self.mean_u_squared.value * Q**2 / 3) * np.array(A_1_values) diff --git a/tests/unit/easydynamics/sample_model/diffusion_model/test_delta_lorentz.py b/tests/unit/easydynamics/sample_model/diffusion_model/test_delta_lorentz.py index eb34802f..dd757c84 100644 --- a/tests/unit/easydynamics/sample_model/diffusion_model/test_delta_lorentz.py +++ b/tests/unit/easydynamics/sample_model/diffusion_model/test_delta_lorentz.py @@ -1,6 +1,7 @@ # SPDX-FileCopyrightText: 2026 EasyScience contributors # SPDX-License-Identifier: BSD-3-Clause +import numpy as np import pytest from easydynamics.sample_model.diffusion_model.delta_lorentz import DeltaLorentz @@ -11,6 +12,16 @@ class TestDeltaLorentz: def delta_lorentz_model(self): return DeltaLorentz() + @pytest.fixture + def delta_lorentz_model_with_Q(self): + Q = np.linspace(0.5, 2, 7) + return DeltaLorentz( + Q=Q, + A_0=0.5, + lorentzian_width=0.0015, + allow_Q_variation={'A_0': True, 'lorentzian_width': True}, + ) + def test_init_default(self, delta_lorentz_model): # WHEN THEN EXPECT assert delta_lorentz_model.display_name == 'DeltaLorentz' @@ -19,3 +30,243 @@ def test_init_default(self, delta_lorentz_model): assert delta_lorentz_model.mean_u_squared.value == pytest.approx(0.0) assert delta_lorentz_model.A_0.value == pytest.approx(1.0) assert delta_lorentz_model.lorentzian_width.value == pytest.approx(1.0) + + def test_init_with_Q(self, delta_lorentz_model_with_Q): + # WHEN THEN EXPECT + assert delta_lorentz_model_with_Q.display_name == 'DeltaLorentz' + assert delta_lorentz_model_with_Q.unit == 'meV' + assert delta_lorentz_model_with_Q.scale.value == pytest.approx(1.0) + assert delta_lorentz_model_with_Q.mean_u_squared.value == pytest.approx(0.0) + assert delta_lorentz_model_with_Q.A_0.value == pytest.approx(0.5) + assert delta_lorentz_model_with_Q.lorentzian_width.value == pytest.approx(0.0015) + assert delta_lorentz_model_with_Q._allow_Q_variation == { + 'A_0': True, + 'lorentzian_width': True, + } + assert len(delta_lorentz_model_with_Q._A_0_list) == len(delta_lorentz_model_with_Q.Q) + assert len(delta_lorentz_model_with_Q._lorentzian_width_list) == len( + delta_lorentz_model_with_Q.Q + ) + assert all(pytest.approx(a.value) == 0.5 for a in delta_lorentz_model_with_Q._A_0_list) + assert all( + pytest.approx(lw.value) == 0.0015 + for lw in delta_lorentz_model_with_Q._lorentzian_width_list + ) + + @pytest.mark.parametrize( + 'kwargs,expected_exception, expected_message', + [ + ( + { + 'mean_u_squared': -1.0, + 'A_0': 0.5, + 'lorentzian_width': 1.0, + 'allow_Q_variation': {'A_0': True, 'lorentzian_width': True}, + 'delta_name': 'Delta', + 'delta_display_name': 'DeltaDisplay', + }, + ValueError, + 'mean_u_squared must be non-negative', + ), + ( + { + 'mean_u_squared': 'not a number', + 'A_0': 0.5, + 'lorentzian_width': 1.0, + 'allow_Q_variation': {'A_0': True, 'lorentzian_width': True}, + 'delta_name': 'Delta', + 'delta_display_name': 'DeltaDisplay', + }, + TypeError, + 'mean_u_squared must be a number', + ), + ( + { + 'mean_u_squared': 0.1, + 'A_0': -1.0, + 'lorentzian_width': 1.0, + 'allow_Q_variation': {'A_0': True, 'lorentzian_width': True}, + 'delta_name': 'Delta', + 'delta_display_name': 'DeltaDisplay', + }, + ValueError, + 'A_0 must be between 0 and 1', + ), + ( + { + 'mean_u_squared': 0.1, + 'A_0': 'not a number', + 'lorentzian_width': 1.0, + 'allow_Q_variation': {'A_0': True, 'lorentzian_width': True}, + 'delta_name': 'Delta', + 'delta_display_name': 'DeltaDisplay', + }, + TypeError, + 'A_0 must be a number', + ), + ( + { + 'mean_u_squared': 0.1, + 'A_0': 0.5, + 'lorentzian_width': -1.0, + 'allow_Q_variation': {'A_0': True, 'lorentzian_width': True}, + 'delta_name': 'Delta', + 'delta_display_name': 'DeltaDisplay', + }, + ValueError, + 'lorentzian_width must be ', + ), + ( + { + 'mean_u_squared': 0.1, + 'A_0': 0.5, + 'lorentzian_width': 'not a number', + 'allow_Q_variation': {'A_0': True, 'lorentzian_width': True}, + 'delta_name': 'Delta', + 'delta_display_name': 'DeltaDisplay', + }, + TypeError, + 'lorentzian_width must be a number', + ), + ( + { + 'mean_u_squared': 0.1, + 'A_0': 0.5, + 'lorentzian_width': 1.0, + 'allow_Q_variation': 'Not a dict', + 'delta_name': 'Delta', + 'delta_display_name': 'DeltaDisplay', + }, + TypeError, + 'allow_Q_variation must be a dict', + ), + ( + { + 'mean_u_squared': 0.1, + 'A_0': 0.5, + 'lorentzian_width': 1.0, + 'allow_Q_variation': {'A_0': True, 'lorentzian_width': True}, + 'delta_name': 123, + 'delta_display_name': 'DeltaDisplay', + }, + TypeError, + 'delta_name must be a string', + ), + ( + { + 'mean_u_squared': 0.1, + 'A_0': 0.5, + 'lorentzian_width': 1.0, + 'allow_Q_variation': {'A_0': True, 'lorentzian_width': True}, + 'delta_name': None, + 'delta_display_name': 'DeltaDisplay', + }, + TypeError, + 'delta_name must be a string', + ), + ( + { + 'mean_u_squared': 0.1, + 'A_0': 0.5, + 'lorentzian_width': 1.0, + 'allow_Q_variation': {'A_0': True, 'lorentzian_width': True}, + 'delta_name': 'Delta', + 'delta_display_name': 123, + }, + TypeError, + 'delta_display_name must be a string', + ), + ], + ids=[ + 'mean_u_squared negative', + 'mean_u_squared not a number', + 'A_0 negative', + 'A_0 not a number', + 'lorentzian_width negative', + 'lorentzian_width not a number', + 'allow_Q_variation not a dict', + 'delta_name not a string', + 'delta_name not a string (None)', + 'delta_display_name not a string', + ], + ) + def test_input_type_validation_raises(self, kwargs, expected_exception, expected_message): + with pytest.raises(expected_exception, match=expected_message): + DeltaLorentz(**kwargs) + + # ------------------------------------------------------------------ + # Properties + # ------------------------------------------------------------------ + + # ------------------------------------------------------------------ + # Other methods + # ------------------------------------------------------------------ + + def test_calculate_width_without_Q(self, delta_lorentz_model): + # WHEN THEN + width = delta_lorentz_model.calculate_width(Q=0.5) + + # EXPECT + assert len(width) == 1 + assert width[0] == pytest.approx(1.0) + + def test_calculate_width_with_Q(self, delta_lorentz_model_with_Q): + # WHEN THEN + width = delta_lorentz_model_with_Q.calculate_width() + + # EXPECT + assert len(width) == len(delta_lorentz_model_with_Q.Q) + assert all(width_i == pytest.approx(0.0015) for width_i in width) + + def test_calculate_EISF(self, delta_lorentz_model): + # WHEN + + # THEN + eisf = delta_lorentz_model.calculate_EISF(Q=0.5) + + # EXPECT + assert len(eisf) == 1 + expected = delta_lorentz_model.A_0.value * np.exp( + -delta_lorentz_model.mean_u_squared.value * 0.5**2 + ) + assert eisf[0] == pytest.approx(expected) + + def test_calculate_EISF_with_Q(self, delta_lorentz_model_with_Q): + # WHEN + + # THEN + eisf = delta_lorentz_model_with_Q.calculate_EISF() + + # EXPECT + assert len(eisf) == len(delta_lorentz_model_with_Q.Q) + for i in range(len(eisf)): + expected = delta_lorentz_model_with_Q._A_0_list[i].value * np.exp( + -delta_lorentz_model_with_Q.mean_u_squared.value + * delta_lorentz_model_with_Q.Q[i] ** 2 + ) + assert eisf[i] == pytest.approx(expected) + + def test_calculate_QISF(self, delta_lorentz_model): + # WHEN THEN + qisf = delta_lorentz_model.calculate_QISF(Q=0.5) + + # EXPECT + assert len(qisf) == 1 + expected = delta_lorentz_model.A_1.value * np.exp( + -delta_lorentz_model.mean_u_squared.value * 0.5**2 + ) + assert qisf[0] == pytest.approx(expected) + + def test_calculate_QISF_with_Q(self, delta_lorentz_model_with_Q): + # WHEN THEN + qisf = delta_lorentz_model_with_Q.calculate_QISF() + + # EXPECT + assert len(qisf) == len(delta_lorentz_model_with_Q.Q) + for i in range(len(qisf)): + expected = delta_lorentz_model_with_Q._A_1_list[i].value * np.exp( + -delta_lorentz_model_with_Q.mean_u_squared.value + * delta_lorentz_model_with_Q.Q[i] ** 2 + ) + + assert qisf[i] == pytest.approx(expected) From ad6b5cf4521cf44b82befa2ec178dbcc4f2c9b85 Mon Sep 17 00:00:00 2001 From: henrikjacobsenfys Date: Tue, 26 May 2026 15:13:06 +0200 Subject: [PATCH 15/18] More tests --- .../diffusion_model/delta_lorentz.py | 8 +-- .../diffusion_model/diffusion_model_base.py | 21 ++++++- .../diffusion_model/test_diffusion_model.py | 57 ++++++++++++------- 3 files changed, 61 insertions(+), 25 deletions(-) diff --git a/src/easydynamics/sample_model/diffusion_model/delta_lorentz.py b/src/easydynamics/sample_model/diffusion_model/delta_lorentz.py index 2964295b..d2847d31 100644 --- a/src/easydynamics/sample_model/diffusion_model/delta_lorentz.py +++ b/src/easydynamics/sample_model/diffusion_model/delta_lorentz.py @@ -88,19 +88,19 @@ def __init__( allowed. Q : Q_type | None, default=None Q values for the model. If None, Q is not set. - unit : str | sc.Unit, default="meV" + unit : str | sc.Unit, default='meV' Unit of the diffusion model. Must be convertible to meV. - name : str, default="DeltaLorentz" + name : str, default='DeltaLorentz' Name of the diffusion model. display_name : str | None, default=None Display name of the diffusion model. - lorentzian_name : str, default="Lorentzian" + lorentzian_name : str, default='Lorentzian' Name of the Lorentzian component. If None, it will be set to the name of the diffusion model. lorentzian_display_name : str | None, default=None Display name of the Lorentzian component. If None, it will be set to the display name of the diffusion model. - delta_name : str, default="Delta function" + delta_name : str, default='Delta function' Name of the delta function component. delta_display_name : str | None, default=None Display name of the delta function component. If None, it will be set to the display diff --git a/src/easydynamics/sample_model/diffusion_model/diffusion_model_base.py b/src/easydynamics/sample_model/diffusion_model/diffusion_model_base.py index 4b2670a2..1ae0ddce 100644 --- a/src/easydynamics/sample_model/diffusion_model/diffusion_model_base.py +++ b/src/easydynamics/sample_model/diffusion_model/diffusion_model_base.py @@ -89,11 +89,13 @@ def __init__( lorentzian_display_name = lorentzian_name if not isinstance(lorentzian_display_name, str): - raise TypeError('lorentzian_display_name must be a string.') + raise TypeError('lorentzian_display_name must be a string or None.') self._lorentzian_name = lorentzian_name self._lorentzian_display_name = lorentzian_display_name + self._component_collections = self.create_component_collections() + # ------------------------------------------------------------------ # Properties # ------------------------------------------------------------------ @@ -268,6 +270,23 @@ def clear_Q(self, confirm: bool = False) -> None: # private methods # ------------------------------------------------------------------ + def create_component_collections(self) -> list[ComponentCollection]: + """ + Create the ComponentCollections for the diffusion model based on the current Q values. + + Returns + ------- + list[ComponentCollection] + A list of ComponentCollections corresponding to the current Q values. + """ + if self.Q is None: + self._component_collections = [] + return self._component_collections + + self._component_collections = [ComponentCollection()] * len(self.Q) + + return self._component_collections + def get_component_collections( self, Q_index: int | None = None ) -> ComponentCollection | list[ComponentCollection]: diff --git a/tests/unit/easydynamics/sample_model/diffusion_model/test_diffusion_model.py b/tests/unit/easydynamics/sample_model/diffusion_model/test_diffusion_model.py index 8a34b590..00eccf04 100644 --- a/tests/unit/easydynamics/sample_model/diffusion_model/test_diffusion_model.py +++ b/tests/unit/easydynamics/sample_model/diffusion_model/test_diffusion_model.py @@ -10,13 +10,24 @@ class TestDiffusionModel: @pytest.fixture def diffusion_model(self): - return DiffusionModelBase(display_name='TestDiffusionModel', unit='meV') + return DiffusionModelBase() def test_init_default(self, diffusion_model): # WHEN THEN EXPECT - assert diffusion_model.display_name == 'TestDiffusionModel' + assert diffusion_model.display_name == 'MyDiffusionModel' + assert diffusion_model.name == 'MyDiffusionModel' + assert diffusion_model.lorentzian_name == 'MyDiffusionModel' + assert diffusion_model.lorentzian_display_name == 'MyDiffusionModel' assert diffusion_model.unit == 'meV' + def test_init_raises(self): + # WHEN THEN EXPECT + with pytest.raises(TypeError, match=r'lorentzian_name must be a string'): + DiffusionModelBase(lorentzian_name=123) + + with pytest.raises(TypeError, match=r'lorentzian_display_name must be a string or None'): + DiffusionModelBase(lorentzian_display_name=123) + def test_unit_setter_raises(self, diffusion_model): # WHEN THEN EXPECT with pytest.raises( @@ -25,23 +36,6 @@ def test_unit_setter_raises(self, diffusion_model): ): diffusion_model.unit = 'eV' - # def test_scale_setter(self, diffusion_model): - # # WHEN - # diffusion_model.scale = 2.0 - - # # THEN EXPECT - # assert diffusion_model.scale.value == pytest.approx(2.0) - - # def test_scale_setter_negative_raises(self, diffusion_model): - # # WHEN THEN EXPECT - # with pytest.raises(ValueError, match=r'scale must be non-negative.'): - # diffusion_model.scale = -1.0 # Invalid negative value - - # def test_scale_setter_raises(self, diffusion_model): - # # WHEN THEN EXPECT - # with pytest.raises(TypeError, match=r'scale must be a number.'): - # diffusion_model.scale = 'invalid' # Invalid type - @pytest.mark.parametrize( ('attribute', 'value', 'expected'), [ @@ -128,11 +122,34 @@ def test_setters_invalid( with pytest.raises(exception, match=message): setattr(diffusion_model, attribute, value) + def test_Q_property(self, diffusion_model): + # WHEN THEN EXPECT + assert diffusion_model.Q is None + + # THEN + diffusion_model.Q = [1.0, 2.0, 3.0] + + # EXPECT + assert diffusion_model.Q == [1.0, 2.0, 3.0] + + # THEN EXPECT + with pytest.raises(ValueError, match=r'New Q values are not similar to the old ones'): + diffusion_model.Q = [10.0, 20.0, 30.0] + + # THEN EXPECT + with pytest.raises(ValueError, match=r'Clearing Q values requires confirmation'): + diffusion_model.clear_Q() + + # THEN + diffusion_model.clear_Q(confirm=True) + + # EXPECT + assert diffusion_model.Q is None + def test_repr(self, diffusion_model): # WHEN THEN repr_str = repr(diffusion_model) # EXPECT assert 'DiffusionModelBase' in repr_str - assert 'display_name=TestDiffusionModel' in repr_str assert 'unit=meV' in repr_str From 8a58777a3a85f0e1fab029fbc2f6257fb8ca17db Mon Sep 17 00:00:00 2001 From: henrikjacobsenfys Date: Tue, 26 May 2026 17:26:25 +0200 Subject: [PATCH 16/18] Minor fix --- .../brownian_translational_diffusion.py | 28 ++++++++++--------- .../diffusion_model/diffusion_model_base.py | 9 ++++-- .../diffusion_model/test_diffusion_model.py | 11 ++++---- 3 files changed, 27 insertions(+), 21 deletions(-) diff --git a/src/easydynamics/sample_model/diffusion_model/brownian_translational_diffusion.py b/src/easydynamics/sample_model/diffusion_model/brownian_translational_diffusion.py index 8862554a..f2cf4371 100644 --- a/src/easydynamics/sample_model/diffusion_model/brownian_translational_diffusion.py +++ b/src/easydynamics/sample_model/diffusion_model/brownian_translational_diffusion.py @@ -60,11 +60,11 @@ def __init__( Diffusion coefficient D in m^2/s. Q : Q_type | None, default=None Q values for the model. If None, Q is not set. - unit : str | sc.Unit, default='meV' + unit : str | sc.Unit, default="meV" Unit of the diffusion model. Must be convertible to meV. - name : str, default='BrownianTranslationalDiffusion' + name : str, default="BrownianTranslationalDiffusion" Name of the diffusion model. - display_name : str | None, default='BrownianTranslationalDiffusion' + display_name : str | None, default="BrownianTranslationalDiffusion" Display name of the diffusion model. lorentzian_name : str | None, default=None Name of the Lorentzian component. If None, it will be set to the name of the diffusion @@ -84,6 +84,17 @@ def __init__( ValueError If scale or diffusion_coefficient is negative. """ + super().__init__( + Q=Q, + unit=unit, + scale=scale, + name=name, + display_name=display_name, + unique_name=unique_name, + lorentzian_name=lorentzian_name, + lorentzian_display_name=lorentzian_display_name, + ) + if not isinstance(scale, Numeric): raise TypeError('scale must be a number.') @@ -103,16 +114,7 @@ def __init__( unit='m**2/s', min=0.0, ) - super().__init__( - Q=Q, - unit=unit, - scale=scale, - name=name, - display_name=display_name, - unique_name=unique_name, - lorentzian_name=lorentzian_name, - lorentzian_display_name=lorentzian_display_name, - ) + self._hbar = hbar self._angstrom = angstrom self._diffusion_coefficient = diffusion_coefficient diff --git a/src/easydynamics/sample_model/diffusion_model/diffusion_model_base.py b/src/easydynamics/sample_model/diffusion_model/diffusion_model_base.py index 1ae0ddce..a6af01cf 100644 --- a/src/easydynamics/sample_model/diffusion_model/diffusion_model_base.py +++ b/src/easydynamics/sample_model/diffusion_model/diffusion_model_base.py @@ -23,7 +23,7 @@ def __init__( Q: Q_type | None = None, unit: str | sc.Unit = 'meV', name: str = 'DiffusionModel', - display_name: str | None = 'MyDiffusionModel', + display_name: str | None = 'DiffusionModel', lorentzian_name: str | None = None, lorentzian_display_name: str | None = None, unique_name: str | None = None, @@ -41,7 +41,7 @@ def __init__( Unit of the diffusion model. Must be convertible to meV. name : str, default='DiffusionModel' Name of the diffusion model. - display_name : str | None, default='MyDiffusionModel' + display_name : str | None, default='DiffusionModel' Display name of the diffusion model. lorentzian_name : str | None, default=None Name of the Lorentzian component. If None, it will be set to the name of the diffusion @@ -94,7 +94,10 @@ def __init__( self._lorentzian_name = lorentzian_name self._lorentzian_display_name = lorentzian_display_name - self._component_collections = self.create_component_collections() + if self.Q is None: + self._component_collections = [] + else: + self._component_collections = [ComponentCollection()] * len(self.Q) # ------------------------------------------------------------------ # Properties diff --git a/tests/unit/easydynamics/sample_model/diffusion_model/test_diffusion_model.py b/tests/unit/easydynamics/sample_model/diffusion_model/test_diffusion_model.py index 00eccf04..2caf0b90 100644 --- a/tests/unit/easydynamics/sample_model/diffusion_model/test_diffusion_model.py +++ b/tests/unit/easydynamics/sample_model/diffusion_model/test_diffusion_model.py @@ -1,6 +1,7 @@ # SPDX-FileCopyrightText: 2026 EasyScience contributors # SPDX-License-Identifier: BSD-3-Clause +import numpy as np import pytest from easyscience.variable.parameter import Parameter @@ -14,10 +15,10 @@ def diffusion_model(self): def test_init_default(self, diffusion_model): # WHEN THEN EXPECT - assert diffusion_model.display_name == 'MyDiffusionModel' - assert diffusion_model.name == 'MyDiffusionModel' - assert diffusion_model.lorentzian_name == 'MyDiffusionModel' - assert diffusion_model.lorentzian_display_name == 'MyDiffusionModel' + assert diffusion_model.display_name == 'DiffusionModel' + assert diffusion_model.name == 'DiffusionModel' + assert diffusion_model.lorentzian_name == 'DiffusionModel' + assert diffusion_model.lorentzian_display_name == 'DiffusionModel' assert diffusion_model.unit == 'meV' def test_init_raises(self): @@ -130,7 +131,7 @@ def test_Q_property(self, diffusion_model): diffusion_model.Q = [1.0, 2.0, 3.0] # EXPECT - assert diffusion_model.Q == [1.0, 2.0, 3.0] + np.testing.assert_allclose(diffusion_model.Q, [1.0, 2.0, 3.0]) # THEN EXPECT with pytest.raises(ValueError, match=r'New Q values are not similar to the old ones'): From 9c1e753997ac0120da27e0d3f7857769931aa9c1 Mon Sep 17 00:00:00 2001 From: henrikjacobsenfys Date: Tue, 26 May 2026 17:35:59 +0200 Subject: [PATCH 17/18] another minor fix --- .../brownian_translational_diffusion.py | 12 +++--------- .../diffusion_model/diffusion_model_base.py | 11 ++++++++--- .../diffusion_model/jump_translational_diffusion.py | 10 ++++++++++ 3 files changed, 21 insertions(+), 12 deletions(-) diff --git a/src/easydynamics/sample_model/diffusion_model/brownian_translational_diffusion.py b/src/easydynamics/sample_model/diffusion_model/brownian_translational_diffusion.py index f2cf4371..11f10b1b 100644 --- a/src/easydynamics/sample_model/diffusion_model/brownian_translational_diffusion.py +++ b/src/easydynamics/sample_model/diffusion_model/brownian_translational_diffusion.py @@ -60,11 +60,11 @@ def __init__( Diffusion coefficient D in m^2/s. Q : Q_type | None, default=None Q values for the model. If None, Q is not set. - unit : str | sc.Unit, default="meV" + unit : str | sc.Unit, default='meV' Unit of the diffusion model. Must be convertible to meV. - name : str, default="BrownianTranslationalDiffusion" + name : str, default='BrownianTranslationalDiffusion' Name of the diffusion model. - display_name : str | None, default="BrownianTranslationalDiffusion" + display_name : str | None, default='BrownianTranslationalDiffusion' Display name of the diffusion model. lorentzian_name : str | None, default=None Name of the Lorentzian component. If None, it will be set to the name of the diffusion @@ -95,12 +95,6 @@ def __init__( lorentzian_display_name=lorentzian_display_name, ) - if not isinstance(scale, Numeric): - raise TypeError('scale must be a number.') - - if float(scale) < 0: - raise ValueError('scale must be non-negative.') - if not isinstance(diffusion_coefficient, Numeric): raise TypeError('diffusion_coefficient must be a number.') diff --git a/src/easydynamics/sample_model/diffusion_model/diffusion_model_base.py b/src/easydynamics/sample_model/diffusion_model/diffusion_model_base.py index a6af01cf..79a34eae 100644 --- a/src/easydynamics/sample_model/diffusion_model/diffusion_model_base.py +++ b/src/easydynamics/sample_model/diffusion_model/diffusion_model_base.py @@ -37,11 +37,11 @@ def __init__( Scale factor for the diffusion model. Must be a non-negative number. Q : Q_type | None, default=None Q values for the model. If None, Q is not set. - unit : str | sc.Unit, default='meV' + unit : str | sc.Unit, default="meV" Unit of the diffusion model. Must be convertible to meV. - name : str, default='DiffusionModel' + name : str, default="DiffusionModel" Name of the diffusion model. - display_name : str | None, default='DiffusionModel' + display_name : str | None, default="DiffusionModel" Display name of the diffusion model. lorentzian_name : str | None, default=None Name of the Lorentzian component. If None, it will be set to the name of the diffusion @@ -59,6 +59,8 @@ def __init__( If scale is not a number. UnitError If unit is not a string or scipp Unit, or if it cannot be converted to meV. + ValueError + If scale is negative. """ self._Q = _validate_and_convert_Q(Q) @@ -74,6 +76,9 @@ def __init__( if not isinstance(scale, Numeric): raise TypeError('scale must be a number.') + if float(scale) < 0: + raise ValueError('scale must be non-negative.') + scale = Parameter(name='scale', value=float(scale), fixed=False, min=0.0, unit=unit) self._scale = scale diff --git a/src/easydynamics/sample_model/diffusion_model/jump_translational_diffusion.py b/src/easydynamics/sample_model/diffusion_model/jump_translational_diffusion.py index 74b673b4..160f1476 100644 --- a/src/easydynamics/sample_model/diffusion_model/jump_translational_diffusion.py +++ b/src/easydynamics/sample_model/diffusion_model/jump_translational_diffusion.py @@ -91,6 +91,8 @@ def __init__( ------ TypeError If scale, diffusion_coefficient, or relaxation_time are not numbers. + ValueError + If scale, diffusion_coefficient, or relaxation_time are negative. """ super().__init__( Q=Q, @@ -106,14 +108,21 @@ def __init__( if not isinstance(diffusion_coefficient, Numeric): raise TypeError('diffusion_coefficient must be a number.') + if float(diffusion_coefficient) < 0: + raise ValueError('diffusion_coefficient must be non-negative.') + if not isinstance(relaxation_time, Numeric): raise TypeError('relaxation_time must be a number.') + if float(relaxation_time) < 0: + raise ValueError('relaxation_time must be non-negative.') + diffusion_coefficient = Parameter( name='diffusion_coefficient', value=float(diffusion_coefficient), fixed=False, unit='m**2/s', + min=0.0, ) relaxation_time = Parameter( @@ -121,6 +130,7 @@ def __init__( value=float(relaxation_time), fixed=False, unit='ps', + min=0.0, ) self._hbar = hbar From 0f724ffe2dcccbb60c31ac9b764cd1c84583afdd Mon Sep 17 00:00:00 2001 From: henrikjacobsenfys Date: Tue, 26 May 2026 17:51:57 +0200 Subject: [PATCH 18/18] More tests --- .../diffusion_model/diffusion_model_base.py | 6 +- .../diffusion_model/test_delta_lorentz.py | 150 ++++++++++++++++++ .../test_jump_translational_diffusion.py | 20 +++ 3 files changed, 173 insertions(+), 3 deletions(-) diff --git a/src/easydynamics/sample_model/diffusion_model/diffusion_model_base.py b/src/easydynamics/sample_model/diffusion_model/diffusion_model_base.py index 79a34eae..12565986 100644 --- a/src/easydynamics/sample_model/diffusion_model/diffusion_model_base.py +++ b/src/easydynamics/sample_model/diffusion_model/diffusion_model_base.py @@ -37,11 +37,11 @@ def __init__( Scale factor for the diffusion model. Must be a non-negative number. Q : Q_type | None, default=None Q values for the model. If None, Q is not set. - unit : str | sc.Unit, default="meV" + unit : str | sc.Unit, default='meV' Unit of the diffusion model. Must be convertible to meV. - name : str, default="DiffusionModel" + name : str, default='DiffusionModel' Name of the diffusion model. - display_name : str | None, default="DiffusionModel" + display_name : str | None, default='DiffusionModel' Display name of the diffusion model. lorentzian_name : str | None, default=None Name of the Lorentzian component. If None, it will be set to the name of the diffusion diff --git a/tests/unit/easydynamics/sample_model/diffusion_model/test_delta_lorentz.py b/tests/unit/easydynamics/sample_model/diffusion_model/test_delta_lorentz.py index dd757c84..927b3442 100644 --- a/tests/unit/easydynamics/sample_model/diffusion_model/test_delta_lorentz.py +++ b/tests/unit/easydynamics/sample_model/diffusion_model/test_delta_lorentz.py @@ -3,6 +3,7 @@ import numpy as np import pytest +from easyscience.variable import Parameter from easydynamics.sample_model.diffusion_model.delta_lorentz import DeltaLorentz @@ -197,6 +198,155 @@ def test_input_type_validation_raises(self, kwargs, expected_exception, expected # ------------------------------------------------------------------ # Properties # ------------------------------------------------------------------ + @pytest.mark.parametrize( + ('attribute', 'value', 'expected'), + [ + ('mean_u_squared', 2.0, 2.0), + ('mean_u_squared', 0.0, 0.0), + ('mean_u_squared', 5, 5.0), + ('A_0', 0.0, 0.0), + ('A_0', 1.0, 1.0), + ('A_0', 0.5, 0.5), + ('lorentzian_width', 1.5, 1.5), + ('delta_name', 'delta', 'delta'), + ('delta_display_name', 'display', 'display'), + ('delta_display_name', None, None), + ], + ids=[ + 'mean_u_squared set to 2.0', + 'mean_u_squared set to 0.0', + 'mean_u_squared set to 5 (int)', + 'A_0 set to 0.0', + 'A_0 set to 1.0', + 'A_0 set to 0.5', + 'lorentzian_width set to 1.5', + "delta_name set to 'delta'", + "delta_display_name set to 'display'", + 'delta_display_name set to None', + ], + ) + def test_setters_valid( + self, + delta_lorentz_model, + attribute, + value, + expected, + ): + # WHEN + setattr(delta_lorentz_model, attribute, value) + + # THEN + result = getattr(delta_lorentz_model, attribute) + + # Handle Parameters + if isinstance(result, Parameter): + result = result.value + + # EXPECT + assert result == expected + + @pytest.mark.parametrize( + ('attribute', 'value', 'exception', 'message'), + [ + ( + 'mean_u_squared', + -1.0, + ValueError, + r'mean_u_squared must be non-negative.', + ), + ( + 'mean_u_squared', + 'invalid', + TypeError, + r'mean_u_squared must be a number.', + ), + ( + 'A_0', + -0.1, + ValueError, + r'A_0 must be between 0 and 1.', + ), + ( + 'A_0', + 1.1, + ValueError, + r'A_0 must be between 0 and 1.', + ), + ( + 'A_0', + 'invalid', + TypeError, + r'A_0 must be a number.', + ), + ( + 'A_1', + 0.5, + AttributeError, + r'A_1 is a dependent parameter and cannot be set directly.', + ), + ( + 'lorentzian_width', + -0.1, + ValueError, + r'lorentzian_width must be.', + ), + ( + 'lorentzian_width', + 'invalid', + TypeError, + r'lorentzian_width must be a number.', + ), + ( + 'delta_name', + 1, + TypeError, + r'delta_name must be a string.', + ), + ( + 'delta_name', + None, + TypeError, + r'delta_name must be a string.', + ), + ( + 'delta_display_name', + 1, + TypeError, + r'delta_display_name must be a string or None.', + ), + ( + 'delta_display_name', + [], + TypeError, + r'delta_display_name must be a string or None.', + ), + ], + ids=[ + 'mean_u_squared negative', + 'mean_u_squared not a number', + 'A_0 less than 0', + 'A_0 greater than 1', + 'A_0 not a number', + 'A_1 set directly', + 'lorentzian_width negative', + 'lorentzian_width not a number', + 'delta_name not a string', + 'delta_name not a string (None)', + 'delta_display_name not a string', + 'delta_display_name not a string (list)', + ], + ) + def test_setters_invalid( + self, + delta_lorentz_model, + attribute, + value, + exception, + message, + ): + # WHEN THEN EXPECT + with pytest.raises(exception, match=message): + setattr(delta_lorentz_model, attribute, value) # ------------------------------------------------------------------ # Other methods diff --git a/tests/unit/easydynamics/sample_model/diffusion_model/test_jump_translational_diffusion.py b/tests/unit/easydynamics/sample_model/diffusion_model/test_jump_translational_diffusion.py index e295e373..014a221f 100644 --- a/tests/unit/easydynamics/sample_model/diffusion_model/test_jump_translational_diffusion.py +++ b/tests/unit/easydynamics/sample_model/diffusion_model/test_jump_translational_diffusion.py @@ -63,6 +63,16 @@ def test_init_default(self, jump_diffusion_model): TypeError, 'diffusion_coefficient must be a number', ), + ( + { + 'unit': 'meV', + 'scale': 1.0, + 'diffusion_coefficient': -1.0, + 'relaxation_time': 1.0, + }, + ValueError, + 'diffusion_coefficient must be non-negative', + ), ( { 'unit': 'meV', @@ -73,6 +83,16 @@ def test_init_default(self, jump_diffusion_model): TypeError, 'relaxation_time must be a number', ), + ( + { + 'unit': 'meV', + 'scale': 1.0, + 'diffusion_coefficient': 1.0, + 'relaxation_time': -1.0, + }, + ValueError, + 'relaxation_time must be non-negative', + ), ], ) def test_input_type_validation_raises(self, kwargs, expected_exception, expected_message):