diff --git a/src/easydynamics/analysis/analysis.py b/src/easydynamics/analysis/analysis.py index ba120037..cee393b1 100644 --- a/src/easydynamics/analysis/analysis.py +++ b/src/easydynamics/analysis/analysis.py @@ -374,7 +374,7 @@ def plot_parameters( Returns: InteractiveFigure: A Plopp InteractiveFigure containing the - plot of the parameters. + plot of the parameters. """ ds = self.parameters_to_dataset() diff --git a/src/easydynamics/convolution/convolution.py b/src/easydynamics/convolution/convolution.py index b4fa19e3..46c4f0c6 100644 --- a/src/easydynamics/convolution/convolution.py +++ b/src/easydynamics/convolution/convolution.py @@ -136,9 +136,15 @@ def convolution( return total def _convolve_delta_functions(self) -> np.ndarray: - "Convolve delta function components of the sample model with" - 'the resolution components.' - 'No detailed balance correction is applied to delta functions.' + """Convolve delta function components of the sample model with + the resolution components. No detailed balance correction is + applied to delta functions. + + Returns: + np.ndarray + The convolved values of the delta function components + evaluated at energy. + """ return sum( delta.area.value * self._resolution_components.evaluate( diff --git a/src/easydynamics/convolution/convolution_base.py b/src/easydynamics/convolution/convolution_base.py index d0235eaf..9f1799f5 100644 --- a/src/easydynamics/convolution/convolution_base.py +++ b/src/easydynamics/convolution/convolution_base.py @@ -15,14 +15,14 @@ class ConvolutionBase: base class has no convolution functionality. Args: - energy : np.ndarray or scipp.Variable - 1D array of energy values where the convolution is evaluated. - sample_components : ComponentCollection or ModelComponent - The sample model to be convolved. - resolution_components : ComponentCollection or ModelComponent - The resolution model to convolve with. - energy_unit : str or sc.Unit, optional - The unit of the energy. Default is 'meV'. + energy : np.ndarray or scipp.Variable + 1D array of energy values where the convolution is evaluated + sample_components : ComponentCollection or ModelComponent + The sample model to be convolved. + resolution_components : ComponentCollection or ModelComponent + The resolution model to convolve with. + energy_unit : str or sc.Unit, optional + The unit of the energy. Default is 'meV'. """ def __init__( @@ -126,16 +126,15 @@ def energy(self) -> sc.Variable: return self._energy @energy.setter - def energy(self, energy: np.ndarray) -> None: + def energy(self, energy: np.ndarray | sc.Variable) -> None: """Set the energy. Args: - energy : np.ndarray or scipp.Variable - 1D array of energy values where the convolution is - evaluated. + energy (np.ndarray | scipp.Variable): 1D array of energy + values where the convolution is evaluated. Raises: TypeError: If energy is not a numpy ndarray or a - scipp Variable. + scipp Variable. """ if isinstance(energy, Numeric): diff --git a/src/easydynamics/experiment/experiment.py b/src/easydynamics/experiment/experiment.py index ff48706f..4245eba3 100644 --- a/src/easydynamics/experiment/experiment.py +++ b/src/easydynamics/experiment/experiment.py @@ -15,6 +15,21 @@ class Experiment(NewBase): This is a minimal implementation that will be extended in the future. + + Args: + display_name (str): Display name of the experiment. + unique_name (str | None): Unique name of the experiment. If + None, a unique name will be generated. + data (sc.DataArray | str | None): Dataset associated with the + experiment. Can be a sc.DataArray or a filename string to + load from. If None, no data is loaded. + + Attributes: + data (sc.DataArray | None): Dataset associated with the + experiment. + binned_data (sc.DataArray | None): Binned dataset associated + with the experiment. This is derived from `data` and is updated + whenever `data` is set. """ def __init__( @@ -50,12 +65,26 @@ def __init__( @property def data(self) -> sc.DataArray | None: - """Get the dataset associated with this experiment.""" + """Get the dataset associated with this experiment. + + Returns: + sc.DataArray | None: The dataset associated with this + experiment, or None if no data is loaded. + """ return self._data @data.setter def data(self, value: sc.DataArray) -> None: - """Set the dataset associated with this experiment.""" + """Set the dataset associated with this experiment. + + Args: + value (sc.DataArray): The new dataset to associate with this + experiment. + + Raises: + TypeError: If the value is not a sc.DataArray. + ValueError: If the dataset is missing required coordinates. + """ if not isinstance(value, sc.DataArray): raise TypeError(f'Data must be a sc.DataArray, not {type(value).__name__}') self._validate_coordinates(value) @@ -66,36 +95,76 @@ def data(self, value: sc.DataArray) -> None: @property def binned_data(self) -> sc.DataArray | None: - """Get the binned dataset associated with this experiment.""" + """Get the binned dataset associated with this experiment. + + Returns: + sc.DataArray | None: The binned dataset associated with this + experiment, or None if no data is loaded. + """ return self._binned_data @binned_data.setter def binned_data(self, value: sc.DataArray) -> None: - """Set the binned dataset associated with this experiment.""" + """Set the binned dataset associated with this experiment. Read- + only property. Use rebin() to rebin the data instead. + + Args: + value (sc.DataArray): The new binned dataset to associate + with this experiment (ignored) + + Raises: + AttributeError: Always, since binned_data is read-only. + """ raise AttributeError('binned_data is a read-only property. Use rebin() to rebin the data') @property def Q(self) -> sc.Variable | None: - """Get the Q values from the dataset.""" + """Get the Q values from the dataset. + + Returns: + sc.Variable | None: The Q values from the dataset, or None + if no data is loaded. + """ if self._data is None: return None return self._binned_data.coords['Q'] @Q.setter def Q(self, value: sc.Variable) -> None: - """Set the Q values for the dataset.""" + """Set the Q values for the dataset. Q is a read-only property + derived from the data, so this setter raises an error. + + Args: + value (sc.Variable): The new Q values to set (ignored) + + Raises: + AttributeError: Always, since Q is read-only. + """ raise AttributeError('Q is a read-only property derived from the data.') @property def energy(self) -> sc.Variable | None: - """Get the energy values from the dataset.""" + """Get the energy values from the dataset. + + Returns: + sc.Variable | None: The energy values from the dataset, or + None if no data is loaded. + """ if self._data is None: return None return self._binned_data.coords['energy'] @energy.setter def energy(self, value: sc.Variable) -> None: - """Set the energy values for the dataset.""" + """Set the energy values for the dataset. Energy is a read-only + property derived from the data, so this setter raises an error. + + Args: + value (sc.Variable): The new energy values to set (ignored) + + Raises: + AttributeError: Always, since energy is read-only. + """ raise AttributeError('energy is a read-only property derived from the data.') ########### @@ -109,6 +178,12 @@ def load_hdf5(self, filename: str, display_name: str | None = None): filename (str ): Path to the HDF5 file. display_name (str | None): Optional display name for the experiment. + + Raises: + TypeError: If filename is not a string or if display_name is + not a string or None. + ValueError: If the loaded data is missing required + coordinates. """ if not isinstance(filename, str): raise TypeError(f'Filename must be a string, not {type(filename).__name__}') @@ -133,6 +208,12 @@ def save_hdf5(self, filename: str | None = None): Args: filename (str | None): Path to the output HDF5 file. + If None, the file will be named after the unique_name of + the experiment with a .h5 extension. + + Raises: + TypeError: If filename is not a string or None. + ValueError: If there is no data to save. """ if filename is None: @@ -160,12 +241,13 @@ def rebin(self, dimensions: dict[str, int | sc.Variable]) -> None: Args: dimensions (dict[str, int | sc.Variable]): A dictionary - mapping dimension names to number of bins (int) or bin edges - (sc.Variable). + mapping dimension names to number of bins (int) or bin + edges (sc.Variable). + Raises: TypeError: If dimensions is not a dictionary or if - keys/values are of incorrect types. KeyError: If a specified - dimension is not in the dataset. + keys/values are of incorrect types. + KeyError: If a specified dimension is not in the dataset. """ if not isinstance(dimensions, dict): @@ -208,7 +290,16 @@ def rebin(self, dimensions: dict[str, int | sc.Variable]) -> None: ########### def plot_data(self, slicer=False, **kwargs) -> None: - """Plot the dataset using plopp.""" + """Plot the dataset using plopp: https://scipp.github.io/plopp/ + + Args: + slicer (bool): If True, use plopp's slicer instead of plot. + **kwargs: Additional keyword arguments to pass to plopp. + + Raises: + ValueError: If there is no data to plot. + RuntimeError: If not in a Jupyter notebook environment. + """ if self._binned_data is None: raise ValueError('No data to plot. Please load data first.') @@ -243,6 +334,9 @@ def plot_data(self, slicer=False, **kwargs) -> None: def _validate_coordinates(data: sc.DataArray) -> None: """Validate that required coordinates are present in the data. + Args: + data (sc.DataArray): The data to validate. + Raises: ValueError: If required coordinates are missing. """ @@ -258,7 +352,7 @@ def _convert_to_bin_centers(self, data: sc.DataArray) -> sc.DataArray: """Convert the coordinates of the data to bin centers. Args: - data (sc.DataArray): The data to check. + data (sc.DataArray): The data to convert. Returns: sc.DataArray: The data with coordinates at bin centers. @@ -275,10 +369,20 @@ def _convert_to_bin_centers(self, data: sc.DataArray) -> sc.DataArray: ########### def __repr__(self) -> str: + """Return a string representation of the Experiment object. + + Returns: + str: A string representation of the Experiment object. + """ + return f'Experiment `{self.unique_name}` with data: {self._data}' def __copy__(self) -> 'Experiment': - """Return a copy of the object.""" + """Return a copy of the object. + + Returns: + Experiment: A copy of the Experiment object. + """ temp = self.to_dict(skip=['unique_name']) new_obj = self.__class__.from_dict(temp) new_obj.data = self.data.copy() if self.data is not None else None diff --git a/src/easydynamics/sample_model/background_model.py b/src/easydynamics/sample_model/background_model.py index 50f76cc2..71a16881 100644 --- a/src/easydynamics/sample_model/background_model.py +++ b/src/easydynamics/sample_model/background_model.py @@ -14,22 +14,25 @@ class BackgroundModel(ModelBase): """BackgroundModel represents a model of the background in an experiment at various Q. - Parameters - ---------- - display_name : str - Display name of the model. - unique_name : str | None - Unique name of the model. If None, a unique name will be - generated. - unit : str | sc.Unit | None - Unit of the model. If None, unitless. - components : ModelComponent | ComponentCollection | None - Template components of the model. If None, no components are - added. - These components are copied into ComponentCollections for each - Q value. - Q : Q_type | None - Q values for the model. If None, Q is not set. + Args: + display_name (str): Display name of the model. + unique_name (str | None): Unique name of the model. If None, a + unique name will be generated. + unit (str | sc.Unit | None): Unit of the model. Defaults to + "meV". + components (ModelComponent | ComponentCollection | None): + Template components of the model. If None, no components + are added. These components are copied into + ComponentCollections for each Q value. + Q (Q_type | None): Q values for the model. If None, Q is not + set. + + Attributes: + unit (str | sc.Unit): Unit of the model. + components (list[ModelComponent]): List of ModelComponents in + the model. + Q (np.ndarray | Numeric | list | ArrayLike | sc.Variable + | None): Q values of the model. """ def __init__( diff --git a/src/easydynamics/sample_model/component_collection.py b/src/easydynamics/sample_model/component_collection.py index f04d7ae8..45bac989 100644 --- a/src/easydynamics/sample_model/component_collection.py +++ b/src/easydynamics/sample_model/component_collection.py @@ -18,15 +18,24 @@ class ComponentCollection(ModelBase): - """A model of the scattering from a sample, combining multiple model - components. - - Attributes - ---------- - display_name : str - Display name of the ComponentCollection. - unit : str or sc.Unit - Unit of the ComponentCollection. + """Collection of model components representing a sample, background + or resolution model. + + Args: + unit (str | sc.Unit): Unit of the sample model. Defaults to + "meV". + display_name (str): Display name of the sample model. + unique_name (str | None): Unique name of the sample model. + If None, a unique_name is automatically generated. + components (List[ModelComponent] | None): Initial model + components to add to the ComponentCollection. + + Attributes: + components (List[ModelComponent]): List of model components in + the collection. + unit (str | sc.Unit): Unit of the sample model. + display_name (str): Display name of the sample model. + unique_name (str): Unique name of the sample model. """ def __init__( @@ -38,16 +47,19 @@ def __init__( ): """Initialize a new ComponentCollection. - Parameters - ---------- - unit : str or sc.Unit, optional - Unit of the sample model. Defaults to "meV". - display_name : str - Display name of the sample model. - unique_name : str or None, optional - Unique name of the sample model. Defaults to None. - components : List[ModelComponent], optional - Initial model components to add to the ComponentCollection. + Args: + unit (str | sc.Unit | None): Unit of the sample model. + Defaults to "meV". + display_name (str | None): Display name of the sample model. + unique_name (str | None): Unique name of the sample model. + Defaults to None. + components (List[ModelComponent] | None): Initial model + components to add to the ComponentCollection. + + Raises: + TypeError: If unit is not a string or sc.Unit, + or if components is not a list of ModelComponent. + ValueError: If components contains duplicate unique names. """ super().__init__(display_name=display_name, unique_name=unique_name) @@ -66,19 +78,143 @@ def __init__( for comp in components: self.append_component(comp) + # ------------------------------------------------------------------ + # Properties + # ------------------------------------------------------------------ + + @property + def components(self) -> list[ModelComponent]: + """Get the list of components in the collection. + + Returns: + List[ModelComponent]: The components in the collection. + """ + + return list(self._components) + + @components.setter + def components(self, components: List[ModelComponent]) -> None: + """Set the list of components in the collection. + + Args: + components (List[ModelComponent]): The new list of + components. + + Raises: + TypeError: If components is not a list of ModelComponent. + """ + + if not isinstance(components, list): + raise TypeError('components must be a list of ModelComponent instances.') + for comp in components: + if not isinstance(comp, ModelComponent): + raise TypeError( + 'All items in components must be instances of ModelComponent. ' + f'Got {type(comp).__name__} instead.' + ) + + self._components = components + + @property + def is_empty(self) -> bool: + """Check if the ComponentCollection has no components. + + Returns: + bool: True if the collection has no components, + False otherwise. + """ + return not self._components + + @is_empty.setter + def is_empty(self, value: bool) -> None: + """is_empty is a read-only property that indicates whether the + collection has components. + + Args: + value (bool): The value to set (ignored). + + Raises: + AttributeError: Always raised since is_empty is read-only. + """ + raise AttributeError( + 'is_empty is a read-only property that indicates ' + 'whether the collection has components.' + ) + + @property + def unit(self) -> str | sc.Unit | None: + """Get the unit of the ComponentCollection. + + Returns: + str | sc.Unit | None: The unit of the ComponentCollection, + which is the same as the unit of its components. + """ + return self._unit + + @unit.setter + def unit(self, unit_str: str) -> None: + """Unit is read-only and cannot be set directly. + + Args: + unit_str (str): The unit to set (ignored). + + Raises: + AttributeError: Always raised since 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.' + ) + ) # noqa: E501 + + def convert_unit(self, unit: str | sc.Unit) -> None: + """Convert the unit of the ComponentCollection and all its + components. + + Args: + unit (str | sc.Unit): The target unit to convert to. + + Raises: + TypeError: If unit is not a string or sc.Unit. + UnitError: If any component cannot be converted to the + specified unit. + """ + + old_unit = self._unit + + try: + for component in self.components: + component.convert_unit(unit) + self._unit = unit + except Exception as e: + # Attempt to rollback on failure + try: + for component in self.components: + component.convert_unit(old_unit) + except Exception: # noqa: S110 + pass # Best effort rollback + raise e + + # ------------------------------------------------------------------ + # Component management + # ------------------------------------------------------------------ + def append_component(self, component: ModelComponent | 'ComponentCollection') -> None: """Append a model component or the components from another ComponentCollection to this ComponentCollection. - Parameters - ---------- - component : ModelComponent or ComponentCollection - The component to append. - Raises - ------ - TypeError - If the component is not a ModelComponent or - ComponentCollection. + Args: + component (ModelComponent | ComponentCollection): The component + to append. If a ComponentCollection is provided, all of its + components will be appended. + + Raises: + TypeError: If component is not a ModelComponent or + ComponentCollection. + ValueError: If a component with the same unique name already + exists in the collection. """ if not isinstance(component, (ModelComponent, ComponentCollection)): raise TypeError( @@ -100,6 +236,16 @@ def append_component(self, component: ModelComponent | 'ComponentCollection') -> self._components.append(comp) def remove_component(self, unique_name: str) -> None: + """Remove a component from the collection by its unique name. + + Args: + unique_name (str): Unique name of the component to remove. + Raises: + TypeError: If unique_name is not a string. + KeyError: If no component with the given unique name exists + in the collection. + """ + if not isinstance(unique_name, str): raise TypeError('Component name must be a string.') @@ -145,10 +291,9 @@ def is_empty(self, value: bool) -> None: def list_component_names(self) -> List[str]: """List the names of all components in the model. - Returns - ------- - List[str] - Component names. + Returns: + List[str]: List of unique names of the components in the + collection. """ return [component.unique_name for component in self._components] @@ -158,8 +303,14 @@ def clear_components(self) -> None: self._components.clear() def normalize_area(self) -> None: - # Useful for convolutions. - """Normalize the areas of all components so they sum to 1.""" + """Normalize the areas of all components so they sum to 1. This + is useful for convolutions. + + Raises: + ValueError: If there are no components in the model. + ValueError: If the total area is zero or not finite, which + would prevent normalization. + """ if not self.components: raise ValueError('No components in the model to normalize.') @@ -186,66 +337,28 @@ def normalize_area(self) -> None: for param in area_params: param.value /= total_area.value + # ------------------------------------------------------------------ + # Other methods + # ------------------------------------------------------------------ + def get_all_variables(self) -> list[DescriptorBase]: """Get all parameters from the model component. Returns: - List[Parameter]: List of parameters in the component. + List[Parameter]: List of parameters in the component. """ return [var for component in self.components for var in component.get_all_variables()] - @property - def unit(self) -> str | sc.Unit: - """Get the unit of the ComponentCollection. - - Returns - ------- - str or sc.Unit or None - """ - return self._unit - - @unit.setter - def unit(self, unit_str: str) -> None: - 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.' - ) - ) # noqa: E501 - - def convert_unit(self, unit: str | sc.Unit) -> None: - """Convert the unit of the ComponentCollection and all its - components. - """ - - old_unit = self._unit - - try: - for component in self.components: - component.convert_unit(unit) - self._unit = unit - except Exception as e: - # Attempt to rollback on failure - try: - for component in self.components: - component.convert_unit(old_unit) - except Exception: # noqa: S110 - pass # Best effort rollback - raise e - def evaluate(self, x: Numeric | list | np.ndarray | sc.Variable | sc.DataArray) -> np.ndarray: """Evaluate the sum of all components. - Parameters - ---------- - x : Number, list, np.ndarray, sc.Variable, or sc.DataArray - Energy axis. + Args: + x (Number, list, np.ndarray, sc.Variable, or sc.DataArray): + Energy axis. Returns - ------- - np.ndarray - Evaluated model values. + np.ndarray: Evaluated model values. """ if not self.components: @@ -259,17 +372,18 @@ def evaluate_component( ) -> np.ndarray: """Evaluate a single component by name. - Parameters - ---------- - x : Number, list, np.ndarray, sc.Variable, or sc.DataArray - Energy axis. - unique_name : str - Component unique name. + Args: + x (Number, list, np.ndarray, sc.Variable, or sc.DataArray): + Energy axis. + unique_name (str): Component unique name. - Returns - ------- - np.ndarray - Evaluated values for the specified component. + Returns: + np.ndarray: Evaluated values for the specified component. + Raises: + ValueError: If there are no components in the model. + TypeError: If unique_name is not a string. + KeyError: If no component with the given unique name exists + in the collection. """ if not self.components: raise ValueError('No components in the model to evaluate.') @@ -299,18 +413,20 @@ def free_all_parameters(self) -> None: for param in self.get_fittable_parameters(): param.fixed = False + # ------------------------------------------------------------------ + # Dunder methods + # ------------------------------------------------------------------ + def __contains__(self, item: str | ModelComponent) -> bool: """Check if a component with the given name or instance exists in the ComponentCollection. Args: - ---------- - item : str or ModelComponent - The component name or instance to check for. - Returns - ------- - bool - True if the component exists, False otherwise. + item (str or ModelComponent): The component name or instance + to check for. + + Returns: + bool: True if the component exists, False otherwise. """ if isinstance(item, str): @@ -325,9 +441,8 @@ def __contains__(self, item: str | ModelComponent) -> bool: def __repr__(self) -> str: """Return a string representation of the ComponentCollection. - Returns - ------- - str + Returns: + str: String representation of the ComponentCollection. """ comp_names = ', '.join(c.unique_name for c in self.components) or 'No components' 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 2853132a..1ecb8c2a 100644 --- a/src/easydynamics/sample_model/diffusion_model/brownian_translational_diffusion.py +++ b/src/easydynamics/sample_model/diffusion_model/brownian_translational_diffusion.py @@ -19,19 +19,40 @@ class BrownianTranslationalDiffusion(DiffusionModelBase): - """Model of Brownian translational diffusion, consisting of a - Lorentzian function for each Q-value, where the width is given by - :math:`DQ^2`. Q is assumed to have units of 1/angstrom. Creates - ComponentCollections with Lorentzian components for given Q-values. + r"""Model of Brownian translational diffusion, consisting of a + Lorentzian function for each Q-value, where the width is given by $D + Q^2$, where $D$ is the diffusion coefficient. The area of the + Lorentzians is given by the scale parameter multiplied by the QISF, + which is 1 for this model. The EISF is 0 for this model, so there is + no delta function component. Q is assumed to have units of + 1/angstrom. Creates ComponentCollections with Lorentzian components + for given Q-values. + + Args: + display_name (str): Display name of the diffusion model. + unique_name (str | None): Unique name of the diffusion model. If + None, a unique name will be generated. + unit (str | sc.Unit): Unit of the diffusion model. Must be + convertible to meV. Defaults to "meV". + scale (Numeric): Scale factor for the diffusion model. Must be + a non-negative number. Defaults to 1.0. + diffusion_coefficient (Numeric): Diffusion coefficient D in + m^2/s. Defaults to 1.0. + + Attributes: + unit (str | sc.Unit): Unit of the diffusion model. + scale (Parameter): Scale parameter of the diffusion model. + diffusion_coefficient (Parameter): Diffusion coefficient D in + m^2/s. Example usage: - 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=BrownianTranslationalDiffusion(display_name="DiffusionModel", - scale=scale, diffusion_coefficient= diffusion_coefficient) - component_collections=diffusion_model.create_component_collections(Q) + >>>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=BrownianTranslationalDiffusion(display_name="DiffusionModel", + >>>scale=scale, diffusion_coefficient= diffusion_coefficient) + >>>component_collections=diffusion_model.create_component_collections(Q) See also the examples. """ @@ -45,21 +66,22 @@ def __init__( ): """Initialize a new BrownianTranslationalDiffusion model. - Parameters - ---------- - display_name : str - Display name of the diffusion model. - unique_name : str or None - Unique name of the diffusion model. If None, a unique name - is automatically generated. - unit : str or sc.Unit, optional - Energy unit for the underlying Lorentzian components. - Defaults to "meV". - scale : float or Parameter, optional - Scale factor for the diffusion model. - diffusion_coefficient : Number, optional - Diffusion coefficient D in m^2/s. - Defaults to 1.0. + Args: + display_name (str): Display name of the diffusion model. + unique_name (str | None): Unique name of the diffusion + model. If None, a unique name will be generated. + unit (str | sc.Unit): Unit of the diffusion model. Must be + convertible to meV. Defaults to "meV". + scale (Numeric): Scale factor for the diffusion model. Must + be a non-negative number. Defaults to 1.0. + diffusion_coefficient (Numeric): Diffusion coefficient D in + m^2/s. Defaults to 1.0. + + Raises: + TypeError: If scale or diffusion_coefficient is not a + number. + ValueError: If scale is negative. + UnitError: If unit is not a string or scipp Unit. """ if not isinstance(scale, Numeric): raise TypeError('scale must be a number.') @@ -72,6 +94,7 @@ def __init__( value=float(diffusion_coefficient), fixed=False, unit='m**2/s', + min=0.0, ) super().__init__( display_name=display_name, @@ -91,19 +114,29 @@ def __init__( def diffusion_coefficient(self) -> Parameter: """Get the diffusion coefficient parameter D. - Returns - ------- - Parameter - Diffusion coefficient D. + Returns: + Parameter: Diffusion coefficient D in m^2/s. """ return self._diffusion_coefficient @diffusion_coefficient.setter def diffusion_coefficient(self, diffusion_coefficient: Numeric) -> None: - """Set the diffusion coefficient parameter D.""" + """Set the diffusion coefficient parameter D. + + Args: + diffusion_coefficient (Numeric): The new value for the + diffusion coefficient D in m^2/s. + + Raises: + TypeError: If diffusion_coefficient is not a number. + ValueError: If diffusion_coefficient is negative. + """ if not isinstance(diffusion_coefficient, Numeric): raise TypeError('diffusion_coefficient must be a number.') - self._diffusion_coefficient.value = diffusion_coefficient + + if float(diffusion_coefficient) < 0: + raise ValueError('diffusion_coefficient must be non-negative.') + self._diffusion_coefficient.value = float(diffusion_coefficient) # ------------------------------------------------------------------ # Other methods @@ -113,15 +146,13 @@ def calculate_width(self, Q: Q_type) -> np.ndarray: """Calculate the half-width at half-maximum (HWHM) for the diffusion model. - Parameters - ---------- - Q : np.ndarray | Numeric | list | ArrayLike - Scattering vector in 1/angstrom + Args: + Q (np.ndarray | Numeric | list | ArrayLike): Scattering + vector in 1/angstrom - Returns - ------- - np.ndarray - HWHM values in the unit of the model (e.g., meV). + Returns: + np.ndarray: HWHM values in the unit of the model + (e.g., meV). """ Q = _validate_and_convert_Q(Q) @@ -136,15 +167,12 @@ def calculate_EISF(self, Q: Q_type) -> np.ndarray: """Calculate the Elastic Incoherent Structure Factor (EISF) for the Brownian translational diffusion model. - Parameters - ---------- - Q : np.ndarray | Numeric | list | ArrayLike - Scattering vector in 1/angstrom + Args: + Q (np.ndarray | Numeric | list | ArrayLike): Scattering + vector in 1/angstrom - Returns - ------- - np.ndarray - EISF values (dimensionless). + Returns: + np.ndarray: EISF values (dimensionless). """ Q = _validate_and_convert_Q(Q) EISF = np.zeros_like(Q) @@ -154,15 +182,12 @@ def calculate_QISF(self, Q: Q_type) -> np.ndarray: """Calculate the Quasi-Elastic Incoherent Structure Factor (QISF). - Parameters - ---------- - Q : np.ndarray | Numeric | list | ArrayLike - Scattering vector in 1/angstrom + Args: + Q (np.ndarray | Numeric | list | ArrayLike): Scattering + vector in 1/angstrom - Returns - ------- - np.ndarray - QISF values (dimensionless). + Returns: + np.ndarray: QISF values (dimensionless). """ Q = _validate_and_convert_Q(Q) @@ -174,19 +199,23 @@ def create_component_collections( Q: Q_type, component_display_name: str = 'Brownian translational diffusion', ) -> List[ComponentCollection]: - """Create ComponentCollection components for the Brownian + r"""Create ComponentCollection components for the Brownian translational diffusion model at given Q values. Args: - ---------- - Q : Number, list, or np.ndarray - Scattering vector values. - component_display_name : str - Name of the Lorentzian component. - Returns - ------- - List[ComponentCollection] - List of ComponentCollections with Lorentzian components. + Q (Number, list, or np.ndarray): Scattering vector values. + component_display_name (str): Name of the Lorentzian + component. + + Returns: + List[ComponentCollection]: List of ComponentCollections with + Lorentzian components for each Q value. Each 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). + + Raises: + TypeError: If component_display_name is not a string. """ Q = _validate_and_convert_Q(Q) @@ -240,14 +269,14 @@ 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 - Returns - ------- - str - Dependency expression for the width. + Args: + Q (float): Scattering vector in 1/angstrom + + Returns: + str: Dependency expression for the width. + + Raises: + TypeError: If Q is not a float. """ if not isinstance(Q, (float)): raise TypeError('Q must be a float.') @@ -258,6 +287,9 @@ def _write_width_dependency_expression(self, Q: float) -> str: 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 { 'D': self.diffusion_coefficient, @@ -269,10 +301,14 @@ def _write_area_dependency_expression(self, QISF: float) -> str: """Write the dependency expression for the area to make dependent Parameters. - Returns - ------- - str - Dependency expression for the area. + Args: + QISF (float): Quasielastic Incoherent Scattering Function. + + Returns: + str: Dependency expression for the area. + + Raises: + TypeError: If QISF is not a float. """ if not isinstance(QISF, (float)): raise TypeError('QISF must be a float.') @@ -282,6 +318,9 @@ def _write_area_dependency_expression(self, QISF: float) -> str: def _write_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, @@ -294,6 +333,10 @@ def _write_area_dependency_map_expression(self) -> Dict[str, DescriptorNumber]: def __repr__(self): """String representation of the BrownianTranslationalDiffusion model. + + Returns: + str: String representation of the + BrownianTranslationalDiffusion model. """ return ( f'BrownianTranslationalDiffusion(display_name={self.display_name},' 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 a6711334..096272ee 100644 --- a/src/easydynamics/sample_model/diffusion_model/diffusion_model_base.py +++ b/src/easydynamics/sample_model/diffusion_model/diffusion_model_base.py @@ -11,7 +11,21 @@ class DiffusionModelBase(ModelBase): - """Base class for constructing diffusion models.""" + """Base class for constructing diffusion models. + + Args: + display_name (str): Display name of the diffusion model. + unique_name (str | None): Unique name of the diffusion model. + If None, a unique name will be generated. + scale (Numeric): Scale factor for the diffusion model. Must be a + non-negative number. Defaults to 1.0. + unit (str | sc.Unit): Unit of the diffusion model. Must be + convertible to meV. Defaults to "meV". + + Attributes: + unit (str | sc.Unit): Unit of the diffusion model. + scale (Parameter): Scale parameter of the diffusion model. + """ def __init__( self, @@ -22,12 +36,19 @@ def __init__( ): """Initialize a new DiffusionModel. - Parameters - ---------- - display_name : str - Display name of the diffusion model. - unit : str or sc.Unit, optional - Unit of the diffusion model. Defaults to "meV". + Args: + display_name (str): Display name of the diffusion model. + unique_name (str | None): Unique name of the diffusion + model. If None, a unique name will be generated. + scale (Numeric): Scale factor for the diffusion model. Must + be a non-negative number. Defaults to 1.0. + unit (str | sc.Unit): Unit of the diffusion model. Must be + convertible to meV. Defaults to "meV". + + Raises: + TypeError: If scale is not a number. + UnitError: If unit is not a string or scipp Unit, or if it + cannot be converted to meV. """ if not isinstance(scale, Numeric): raise TypeError('scale must be a number.') @@ -52,16 +73,25 @@ def __init__( @property def unit(self) -> str: - """Get the unit of the DiffusionModel. + """Get the unit of the energy axis of the DiffusionModel. - Returns - ------- - str or sc.Unit or None + Returns: + (str | sc.Unit | None): Unit of the DiffusionModel. """ return str(self._unit) @unit.setter def unit(self, unit_str: str) -> None: + """The unit of the energy axis is read-only. To change the unit, + use convert_unit or create a new DiffusionModel with the desired + unit. + + Args: + unit_str (str): The new unit to set (ignored) + + Raises: + AttributeError: Always, since the unit is read-only. + """ raise AttributeError( ( f'Unit is read-only. Use convert_unit to change the unit between allowed types ' @@ -73,18 +103,28 @@ def unit(self, unit_str: str) -> None: def scale(self) -> Parameter: """Get the scale parameter of the diffusion model. - Returns - ------- - Parameter - Scale parameter. + Returns: + Parameter: scale parameter of the diffusion model """ return self._scale @scale.setter def scale(self, scale: Numeric) -> None: - """Set the scale parameter of the diffusion model.""" + """Set the scale parameter of the diffusion model. + + Args: + scale (Numeric): The new value for the scale parameter. Must + be a non-negative number. + + Raises: + TypeError: If scale is not a number. + ValueError: If scale is negative. + """ if not isinstance(scale, Numeric): raise TypeError('scale must be a number.') + + if float(scale) < 0: + raise ValueError('scale must be non-negative.') self._scale.value = scale # ------------------------------------------------------------------ @@ -92,5 +132,9 @@ def scale(self, scale: Numeric) -> None: # ------------------------------------------------------------------ def __repr__(self): - """String representation of the Diffusion model.""" + """String representation of the Diffusion model. + + Returns: + str: String representation of the DiffusionModel. + """ return f'{self.__class__.__name__}(display_name={self.display_name}, unit={self.unit})' 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 d6ab64b6..b7e28573 100644 --- a/src/easydynamics/sample_model/diffusion_model/jump_translational_diffusion.py +++ b/src/easydynamics/sample_model/diffusion_model/jump_translational_diffusion.py @@ -16,16 +16,48 @@ class JumpTranslationalDiffusion(DiffusionModelBase): - """Model of Jump translational diffusion, consisting of a Lorentzian - function for each Q-value, where the width is given by :math:`D - Q^2/(1+D t Q^2)`. Q is assumed to have units of 1/angstrom. Creates + r"""Model of Jump translational diffusion. The model consists of a + Lorentzian function for each Q-value, where the width is given by + + $$ + \Gamma(Q) = \frac{Q^2}{1+D t Q^2}. + $$ + + where $D$ is the diffusion coefficient and $t$ is the relaxation + time. Q is assumed to have units of 1/angstrom. Creates ComponentCollections with Lorentzian components for given Q-values. - Example usage: 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=JumpTranslationalDiffusion(display_name="DiffusionModel", - scale=scale, diffusion_coefficient= diffusion_coefficient) - component_collections=diffusion_model.create_component_collections(Q) + Args: + display_name (str): Display name of the diffusion model. + unique_name (str | None): Unique name of the diffusion model. If + None, a unique name will be generated. + unit (str | sc.Unit): Unit of the diffusion model. Must be + convertible to meV. Defaults to "meV". + scale (Numeric): Scale factor for the diffusion model. Must be + a non-negative number. Defaults to 1.0. + diffusion_coefficient (Numeric): Diffusion coefficient D in + m^2/s. Defaults to 1.0. + relaxation_time (Numeric): Relaxation time t in ps. Defaults to + 1.0. + + Attributes: + unit (str | sc.Unit): Unit of the diffusion model. + scale (Parameter): Scale parameter of the diffusion model. + diffusion_coefficient (Parameter): Diffusion coefficient D in + m^2/s. + relaxation_time (Parameter): Relaxation time t in ps. + + Example usage: + >>> Q = np.linspace(0.5, 2, 7) + >>> energy = np.linspace(-2, 2, 501) + >>> scale = 1.0 + >>> diffusion_coefficient = 2.4e-9 # m^2/s + >>> relaxation_time = 1.0 # ps + >>> diffusion_model=JumpTranslationalDiffusion( + >>> scale = scale, diffusion_coefficient = (diffusion_coefficient,) + >>> relaxation_time=relaxation_time) + >>> component_collections= + >>> diffusion_model.create_component_collections(Q) See also the examples. """ @@ -40,22 +72,24 @@ def __init__( ): """Initialize a new JumpTranslationalDiffusion model. - Parameters - ---------- - display_name : str - Display name of the diffusion model. - unique_name : str or None - Unique name of the diffusion model. If None, a unique name - is automatically generated. - unit : str or sc.Unit, optional - Energy unit for the underlying Lorentzian components. - Defaults to "meV". - scale : float, optional - Scale factor for the diffusion model. - diffusion_coefficient : float, optional - Diffusion coefficient D in m^2/s. Defaults to 1.0. - relaxation_time : float, optional - Relaxation time t in ps. Defaults to 1.0. + Args: + display_name (str): Display name of the diffusion model. + unique_name (str | None): Unique name of the diffusion model. If + None, a unique name will be generated. + unit (str | sc.Unit): Unit of the diffusion model. Must be + convertible to meV. Defaults to "meV". + scale (Numeric): Scale factor for the diffusion model. Must be + a non-negative number. Defaults to 1.0. + diffusion_coefficient (Numeric): Diffusion coefficient D in + m^2/s. Defaults to 1.0. + relaxation_time (Numeric): Relaxation time t in ps. Defaults to + 1.0. + + Raises: + TypeError: If scale, diffusion_coefficient, or relaxation_time + are not numbers. + ValueError: If scale is negative. + UnitError: If unit is not a string or scipp Unit """ super().__init__( display_name=display_name, @@ -97,56 +131,72 @@ def __init__( def diffusion_coefficient(self) -> Parameter: """Get the diffusion coefficient parameter D. - Returns - ------- - Parameter - Diffusion coefficient D. + Returns: + Parameter: Diffusion coefficient D. """ return self._diffusion_coefficient @diffusion_coefficient.setter def diffusion_coefficient(self, diffusion_coefficient: Numeric) -> None: - """Set the diffusion coefficient parameter D.""" + """Set the diffusion coefficient parameter D. + + Args: + diffusion_coefficient (Numeric): Diffusion coefficient D in + m^2/s. + + Raises: + TypeError: If diffusion_coefficient is not a number. + ValueError: If diffusion_coefficient is negative. + """ if not isinstance(diffusion_coefficient, Numeric): raise TypeError('diffusion_coefficient must be a number.') - self._diffusion_coefficient.value = diffusion_coefficient + if float(diffusion_coefficient) < 0: + raise ValueError('diffusion_coefficient must be non-negative.') + self._diffusion_coefficient.value = float(diffusion_coefficient) @property def relaxation_time(self) -> Parameter: """Get the relaxation time parameter t. - Returns - ------- - Parameter - Relaxation time t. + Returns: + Parameter: Relaxation time t in ps. """ return self._relaxation_time @relaxation_time.setter def relaxation_time(self, relaxation_time: Numeric) -> None: - """Set the relaxation time parameter t.""" + """Set the relaxation time parameter t. + + Args: + relaxation_time (Numeric): Relaxation time t in ps. + + Raises: + TypeError: If relaxation_time is not a number. + ValueError: If relaxation_time is negative. + """ if not isinstance(relaxation_time, Numeric): raise TypeError('relaxation_time must be a number.') - self._relaxation_time.value = relaxation_time + + if float(relaxation_time) < 0: + 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: - """Calculate the half-width at half-maximum (HWHM) for the - diffusion model. Equation: :math:`\\Gamma(Q) = \\hbar D Q^2/(1+D - t Q^2)` - - Parameters - ---------- - Q : np.ndarray | Numeric | list | ArrayLike - Scattering vector in 1/angstrom - - Returns - ------- - np.ndarray - HWHM values in the unit of the model (e.g., meV). + 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. + + Args: + Q (Q_type): Scattering vector in 1/angstrom. Can be a single + value or an array of values. + + Returns: + np.ndarray: HWHM values in the unit of the model (e.g., + meV). """ Q = _validate_and_convert_Q(Q) @@ -171,15 +221,12 @@ 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). - Parameters - ---------- - Q : np.ndarray | Numeric | list | ArrayLike - Scattering vector in 1/angstrom + Args: + Q (Q_type): Scattering vector in 1/angstrom. Can be a single + value or an array of values. - Returns - ------- - np.ndarray - EISF values (dimensionless). + Returns: + np.ndarray: EISF values (dimensionless). """ Q = _validate_and_convert_Q(Q) EISF = np.zeros_like(Q) @@ -189,17 +236,12 @@ def calculate_QISF(self, Q: Q_type) -> np.ndarray: """Calculate the Quasi-Elastic Incoherent Structure Factor (QISF). - Parameters - ---------- - Q : np.ndarray | Numeric | list | ArrayLike - Scattering vector in 1/angstrom - - Returns - ------- - np.ndarray - QISF values (dimensionless). + Args: + Q (Q_type): Scattering vector in 1/angstrom. Can be a single + value or an array of values. + Returns: + np.ndarray: QISF values (dimensionless). """ - Q = _validate_and_convert_Q(Q) QISF = np.ones_like(Q) return QISF @@ -213,16 +255,17 @@ def create_component_collections( at given Q values. Args: - ---------- - Q : Number, list, or np.ndarray - Scattering vector values. - component_display_name : str - Name of the Jump Diffusion Lorentzian component. - Returns - ------- - List[ComponentCollection] - List of ComponentCollections with Jump Diffusion - Lorentzian components. + Q (Q_type): Scattering vector in 1/angstrom. Can be a single + value or an array of values. + component_display_name (str): Name of the Jump Diffusion + Lorentzian component. + + Returns: + List[ComponentCollection]: List of ComponentCollections with + Jump Diffusion Lorentzian components. + + Raises: + TypeError: If component_display_name is not a string. """ Q = _validate_and_convert_Q(Q) @@ -276,14 +319,11 @@ 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 - Returns - ------- - str - Dependency expression for the width. + Args: + Q (float): Scattering vector in 1/angstrom + + Returns: + str: Dependency expression for the width. """ if not isinstance(Q, (float)): raise TypeError('Q must be a float.') @@ -294,6 +334,9 @@ def _write_width_dependency_expression(self, Q: float) -> str: 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 { 'D': self._diffusion_coefficient, @@ -306,11 +349,13 @@ def _write_area_dependency_expression(self, QISF: float) -> str: """Write the dependency expression for the area to make dependent Parameters. - Returns - ------- - str - Dependency expression for the area. + Args: + QISF (float): Q-dependent intermediate scattering function. + + Returns: + str: Dependency expression for the area. """ + if not isinstance(QISF, (float)): raise TypeError('QISF must be a float.') @@ -319,6 +364,9 @@ def _write_area_dependency_expression(self, QISF: float) -> str: def _write_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, @@ -331,6 +379,10 @@ def _write_area_dependency_map_expression(self) -> Dict[str, DescriptorNumber]: def __repr__(self): """String representation of the JumpTranslationalDiffusion model. + + Returns: + str: String representation of the JumpTranslationalDiffusion + model. """ return ( f'JumpTranslationalDiffusion(display_name={self.display_name}, ' diff --git a/src/easydynamics/sample_model/instrument_model.py b/src/easydynamics/sample_model/instrument_model.py index 33b6aacb..732955fc 100644 --- a/src/easydynamics/sample_model/instrument_model.py +++ b/src/easydynamics/sample_model/instrument_model.py @@ -22,28 +22,36 @@ class InstrumentModel(NewBase): function for convolutions, of the background and an offset in the energy axis. - Parameters - ---------- - display_name : str, optional - The display name of the InstrumentModel. Default is - "MyInstrumentModel". - unique_name : str or None, optional - The unique name of the InstrumentModel. Default is None. - Q : np.ndarray, list, scipp Variable or None, optional - The Q values where the instrument is modelled. - resolution_model : ResolutionModel or None, optional - The resolution model of the instrument. If None, an empty - resolution model is created and no resolution convolution is - carried out. Default is None. - background_model : BackgroundModel or None, optional - The background model of the instrument. If None, an empty - background model is created, and the background evaluates to 0. - Default is None. - energy_offset : float, int or None, optional - Template energy offset of the instrument. Will be copied to each - Q value. If None, the energy offset will be 0. Default is None. - unit : str or sc.Unit, optional - The unit of the energy axis. Default is 'meV'. + Args: + display_name (str | None): The display name of the + InstrumentModel. Default is "MyInstrumentModel". + unique_name (str | None): The unique name of the + InstrumentModel. Default is None. + Q (np.ndarray | list | sc.Variable | None): The Q values where + the instrument is modelled. + resolution_model (ResolutionModel | None): The resolution model + of the instrument. If None, an empty resolution model is + created and no resolution convolution is carried out. + Default is None. + background_model (BackgroundModel | None): The background model + of the instrument. If None, an empty background model is + created, and the background evaluates to 0. Default is None. + energy_offset (float | int | None): Template energy offset of + the instrument. Will be copied to each Q value. If None, the + energy offset will be 0. Default is None. + unit (str | sc.Unit): The unit of the energy axis. Default is + 'meV'. + + Attributes: + resolution_model (ResolutionModel): The resolution model of the + instrument. + background_model (BackgroundModel): The background model of the + instrument. + Q (np.ndarray | None): The Q values where the instrument is + modelled. + energy_offset (Parameter): The template energy offset Parameter + of the instrument. Will be copied to each Q value. + unit (str | sc.Unit): The unit of the energy axis. """ def __init__( @@ -104,12 +112,24 @@ def __init__( @property def resolution_model(self) -> ResolutionModel: - """Get the resolution model of the instrument.""" + """Get the resolution model of the instrument. + + Returns: + ResolutionModel: The resolution model of the instrument. + """ return self._resolution_model @resolution_model.setter def resolution_model(self, value: ResolutionModel): - """Set the resolution model of the instrument.""" + """Set the resolution model of the instrument. + + Args: + value (ResolutionModel): The new resolution model of the + instrument. + + Raises: + TypeError: If value is not a ResolutionModel. + """ if not isinstance(value, ResolutionModel): raise TypeError( f'resolution_model must be a ResolutionModel, got {type(value).__name__}' @@ -119,12 +139,26 @@ def resolution_model(self, value: ResolutionModel): @property def background_model(self) -> BackgroundModel: - """The background model of the instrument.""" + """Get the background model of the instrument. + + Returns: + BackgroundModel: The background model of the instrument. + """ + return self._background_model @background_model.setter def background_model(self, value: BackgroundModel): - """Set the background model of the instrument.""" + """Set the background model of the instrument. + + Args: + value (BackgroundModel): The new background model of the + instrument. + + Raises: + TypeError: If value is not a BackgroundModel. + """ + if not isinstance(value, BackgroundModel): raise TypeError( f'background_model must be a BackgroundModel, got {type(value).__name__}' @@ -134,12 +168,25 @@ def background_model(self, value: BackgroundModel): @property def Q(self) -> np.ndarray | None: - """Get the Q values of the InstrumentModel.""" + """Get the Q values of the InstrumentModel. + + Returns: + np.ndarray or None: The Q values of the InstrumentModel, or + None if not set + """ return self._Q @Q.setter def Q(self, value: Q_type | None) -> None: - """Set the Q values of the InstrumentModel.""" + """Set the Q values of the InstrumentModel. + + Args: + value (Q_type | None): The new Q values for the + InstrumentModel. + + Raises: + TypeError: If value is not a valid Q_type or None. + """ self._Q = _validate_and_convert_Q(value) self._on_Q_change() @@ -147,14 +194,25 @@ def Q(self, value: Q_type | None) -> None: def unit(self) -> sc.Unit: """Get the unit of the InstrumentModel. - Returns - ------- - str or sc.Unit or None + Returns: + (str | sc.Unit): The unit of the InstrumentModel. """ return self._unit @unit.setter def unit(self, unit_str: str) -> None: + """Set the unit of the InstrumentModel. The unit is read-only + and cannot be set directly. Use convert_unit to change the unit + between allowed types or create a new InstrumentModel with the + desired unit. + + Args: + unit_str (str): The new unit for the InstrumentModel + (ignored) + + Raises: + AttributeError: Always, as the unit is read-only. + """ raise AttributeError( ( f'Unit is read-only. Use convert_unit to change the unit between allowed types ' @@ -164,24 +222,25 @@ def unit(self, unit_str: str) -> None: @property def energy_offset(self) -> Parameter: - """The energy offset template parameter of the instrument + """Get the energy offset template parameter of the instrument model. + + Returns: + Parameter: The energy offset template parameter of the + instrument model. """ return self._energy_offset @energy_offset.setter def energy_offset(self, value: Numeric): - """Set the offset parameter of the instrument model.". - - Parameters - ---------- - value : float or int - The new value for the energy offset parameter. Will be - copied to all Q values. - Raises - ------ - TypeError - If value is not a number. + """Set the offset parameter of the instrument model. + + Args: + value (float | int): The new value for the energy offset + parameter. Will be copied to all Q values. + + Raises: + TypeError: If value is not a number. """ if not isinstance(value, Numeric): raise TypeError(f'energy_offset must be a number, got {type(value).__name__}') @@ -196,15 +255,13 @@ def energy_offset(self, value: Numeric): def convert_unit(self, unit_str: str | sc.Unit) -> None: """Convert the unit of the InstrumentModel. - Parameters - ---------- - unit_str : str or sc.Unit - The unit to convert to. + Args: + unit_str (str | sc.Unit): The unit to convert to. - Raises - ------ - TypeError - If unit_str is not a string or scipp Unit. + Raises: + TypeError: If unit_str is not a string or scipp Unit. + ValueError: If unit_str is not a valid unit string or + scipp Unit. """ unit = _validate_unit(unit_str) if unit is None: @@ -221,15 +278,21 @@ def convert_unit(self, unit_str: str | sc.Unit) -> None: def get_all_variables(self, Q_index: int | None = None) -> list[Parameter]: """Get all variables in the InstrumentModel. - Parameters - ---------- - Q_index : int | None - The index of the Q value to get variables for. If None, get - variables for all Q values. - Returns - ------- - list of Parameter - All variables in the InstrumentModel. + Args: + Q_index (int | None): The index of the Q value to get + variables for. If None, get variables for all Q values. + + Returns: + list[Parameter]: A list of all variables in the + InstrumentModel. If Q_index is specified, only variables + from the ComponentCollection at the given Q index are + included. Otherwise, all variables in the + InstrumentModel are included. + + Raises: + TypeError: If Q_index is not an int or None. + IndexError: If Q_index is out of bounds for the Q values in + the InstrumentModel. """ if self._Q is None: return [] @@ -261,20 +324,17 @@ def free_resolution_parameters(self) -> None: def get_energy_offset_at_Q(self, Q_index: int) -> Parameter: """Get the energy offset Parameter at a specific Q index. - Parameters - ---------- - Q_index : int - The index of the Q value to get the energy offset for. + Args: + Q_index (int): The index of the Q value to get the energy + offset for. - Returns - ------- - Parameter - The energy offset Parameter at the specified Q index. + Returns: + Parameter: The energy offset Parameter at the specified Q + index. - Raises - ------ - IndexError - If Q_index is out of bounds. + Raises: + ValueError: If no Q values are set in the InstrumentModel. + IndexError: If Q_index is out of bounds. """ if self._Q is None: raise ValueError('No Q values are set in the InstrumentModel.') @@ -320,6 +380,12 @@ def _on_background_model_change(self) -> None: # ------------------------------------------------------------- def __repr__(self): + """Return a string representation of the InstrumentModel. + + Returns: + str: A string representation of the InstrumentModel. + """ + return ( f'{self.__class__.__name__}(' f'unique_name={self.unique_name!r}, ' diff --git a/src/easydynamics/sample_model/model_base.py b/src/easydynamics/sample_model/model_base.py index 570234a2..18098cd0 100644 --- a/src/easydynamics/sample_model/model_base.py +++ b/src/easydynamics/sample_model/model_base.py @@ -22,22 +22,25 @@ class ModelBase(EasyScienceModelBase): Contains common functionality for models with components and Q dependence. - Parameters - ---------- - display_name : str - Display name of the model. - unique_name : str | None - Unique name of the model. If None, a unique name will be - generated. - unit : str | sc.Unit | None - Unit of the model. If None, unitless. - components : ModelComponent | ComponentCollection | None - Template components of the model. If None, no components - are added. - These components are copied into ComponentCollections for each - Q value. - Q : Q_type | None - Q values for the model. If None, Q is not set. + Args: + display_name (str): Display name of the model. + unique_name (str | None): Unique name of the model. If None, a + unique name will be generated. + unit (str | sc.Unit | None): Unit of the model. Defaults to + "meV". + components (ModelComponent | ComponentCollection | None): + Template components of the model. If None, no components + are added. These components are copied into + ComponentCollections for each Q value. + Q (Q_type | None): Q values for the model. If None, Q is not + set. + + Attributes: + unit (str | sc.Unit): Unit of the model. + components (list[ModelComponent]): List of ModelComponents in + the model. + Q (np.ndarray | Numeric | list | ArrayLike | sc.Variable + | None): Q values of the model. """ def __init__( @@ -74,15 +77,21 @@ def evaluate( ) -> list[np.ndarray]: """Evaluate the sample model at all Q for the given x values. - Parameters - ---------- - x : Number, list, np.ndarray, sc.Variable, or sc.DataArray - Energy axis. - - Returns - ------- - list[np.ndarray] - Evaluated model values. + Args: + x (Numeric | list | np.ndarray | sc.Variable | sc.DataArray): + Energy axis values to evaluate the model at. If a scipp + Variable or DataArray is provided, the unit of the model + will be converted to match the unit of x for evaluation, and + the result will be returned in the same unit as x. + + Returns: + list[np.ndarray]: A list of numpy arrays containing the + evaluated model values for each Q. The length of the + list will match the number of Q values in the model. + + Raises: + ValueError: If there are no components in the model to + evaluate. """ if not self._component_collections: @@ -132,14 +141,23 @@ def clear_components(self) -> None: def unit(self) -> str | sc.Unit: """Get the unit of the ComponentCollection. - Returns - ------- - str or sc.Unit or None + Returns: + str | sc.Unit |None: The unit of the ComponentCollection. """ + return self._unit @unit.setter def unit(self, unit_str: str) -> None: + """Unit is read-only and cannot be set directly. + + Args: + 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 ' @@ -150,6 +168,14 @@ def unit(self, unit_str: str) -> None: def convert_unit(self, unit: str | sc.Unit) -> None: """Convert the unit of the ComponentCollection and all its components. + + Args: + unit (str | sc.Unit): The new unit to convert to. + + Raises: + TypeError: If the provided unit is not a string or sc.Unit. + UnitError: If the provided unit is not compatible with the + current unit. """ old_unit = self._unit @@ -170,12 +196,25 @@ def convert_unit(self, unit: str | sc.Unit) -> None: @property def components(self) -> list[ModelComponent]: - """Get the components of the SampleModel.""" + """Get the components of the SampleModel. + + Returns: + list[ModelComponent]: The components of the SampleModel. + """ return self._components.components @components.setter def components(self, value: ModelComponent | ComponentCollection | None) -> None: - """Set the components of the SampleModel.""" + """Set the components of the SampleModel. + + Args: + value (ModelComponent | ComponentCollection | None): The new + components to set. If None, all components will be cleared. + + Raises: + TypeError: 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') @@ -185,12 +224,22 @@ def components(self, value: ModelComponent | ComponentCollection | None) -> None @property def Q(self) -> np.ndarray | None: - """Get the Q values of the SampleModel.""" + """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.""" + """Set the Q values of the SampleModel. + + Args: + value (Q_type | None): The new Q values to set. If None, Q + will be unset. + """ old_Q = self._Q new_Q = _validate_and_convert_Q(value) @@ -223,16 +272,16 @@ def get_all_variables(self, Q_index: int | None = None) -> list[Parameter]: Parameters and Descriptors in self._components as these are just templates. - Parameters - ---------- - Q_index : int | None - If int, get variables for the ComponentCollection at - this index. If None, get variables for all - ComponentCollections. - Returns - ------- - list[Parameter] + Args: + Q_index (int | None): If None, get variables for all + ComponentCollections. If int, get variables for the + ComponentCollection at this index. Defaults to None. + + Returns: + list[Parameter]: A list of all Parameters and Descriptors + from the ComponentCollections in the ModelBase. """ + if Q_index is None: all_vars = [ var @@ -253,15 +302,17 @@ def get_all_variables(self, Q_index: int | None = None) -> list[Parameter]: def get_component_collection(self, Q_index: int) -> ComponentCollection: """Get the ComponentCollection at the given Q index. - Parameters - ---------- - Q_index : int - The index of the desired ComponentCollection. + Args: + Q_index (int): The index of the desired ComponentCollection. - Returns - ------- - ComponentCollection - The ComponentCollection at the specified Q index. + Returns: + ComponentCollection: The ComponentCollection at the + specified Q index. + + Raises: + TypeError: If Q_index is not an int. + IndexError: If Q_index is out of bounds for the number of + ComponentCollections. """ if not isinstance(Q_index, int): raise TypeError(f'Q_index must be an int, got {type(Q_index).__name__}') @@ -300,6 +351,11 @@ def _on_components_change(self) -> None: # ------------------------------------------------------------------ def __repr__(self): + """Return a string representation of the ModelBase. + + Returns: + 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}' diff --git a/src/easydynamics/sample_model/resolution_model.py b/src/easydynamics/sample_model/resolution_model.py index 16a1bccb..ae042b0c 100644 --- a/src/easydynamics/sample_model/resolution_model.py +++ b/src/easydynamics/sample_model/resolution_model.py @@ -16,21 +16,25 @@ class ResolutionModel(ModelBase): """ResolutionModel represents a model of the instrment resolution in an experiment at various Q. - Parameters - ---------- - display_name : str - Display name of the model. - unique_name : str | None - Unique name of the model. If None, a unique name will be - generated. - unit : str | sc.Unit | None - Unit of the model. If None, unitless. - components : ModelComponent | ComponentCollection | None - Template components of the model. If None, no components - are added. These components are copied into ComponentCollections - for each Q value. - Q : Number, list, np.ndarray or sc.Variable | None - Q values for the model. If None, Q is not set. + Args: + display_name (str): Display name of the model. + unique_name (str | None): Unique name of the model. If None, a + unique name will be generated. + unit (str | sc.Unit | None): Unit of the model. Defaults to + "meV". + components (ModelComponent | ComponentCollection | None): + Template components of the model. If None, no components + are added. These components are copied into + ComponentCollections for each Q value. + Q (Q_type | None): Q values for the model. If None, Q is not + set. + + Attributes: + unit (str | sc.Unit): Unit of the model. + components (list[ModelComponent]): List of ModelComponents in + the model. + Q (np.ndarray | Numeric | list | ArrayLike | sc.Variable + | None): Q values of the model. """ def __init__( @@ -54,9 +58,11 @@ def append_component(self, component: ModelComponent | ComponentCollection): Does not allow DeltaFunction or Polynomial components, as these are not physical resolution components. + Args: component (ModelComponent | ComponentCollection): Component(s) to append. + Raises: TypeError: If the component is a DeltaFunction or Polynomial """ diff --git a/src/easydynamics/sample_model/sample_model.py b/src/easydynamics/sample_model/sample_model.py index ba21e869..ce94bbe5 100644 --- a/src/easydynamics/sample_model/sample_model.py +++ b/src/easydynamics/sample_model/sample_model.py @@ -22,33 +22,28 @@ class SampleModel(ModelBase): components from the base model and diffusion models. Applies detailed balancing based on temperature if provided. - Parameters - ---------- - display_name : str - Display name of the model. - unique_name : str | None - Unique name of the model. If None, a unique name will be - generated. - unit : str | sc.Unit | None - Unit of the model. If None, unitless. - components : ModelComponent | ComponentCollection | None - Template components of the model. If None, no components are - added. These components are copied into ComponentCollections - for each Q value. - Q : Number, list, np.ndarray or sc.array or None. - Q values for the model. If None, Q is not set. - diffusion_models : DiffusionModelBase | list[DiffusionModelBase] - | None - Diffusion models to include in the SampleModel. If None, - no diffusion models are added - temperature : float | None - Temperature for detailed balancing. - If None, no detailed balancing is applied. - temperature_unit : str | sc.Unit - Unit of the temperature. Defaults to "K". - divide_by_temperature : bool - Whether to divide the detailed balance factor by temperature. - Defaults to True. + + Args: + display_name (str): Display name of the model. + unique_name (str | None): Unique name of the model. If None, a + unique name will be generated. + unit (str | sc.Unit | None): Unit of the model. If None, + defaults to "meV". + components (ModelComponent | ComponentCollection | None): + Template components of the model. If None, no components are + added. These components are copied into ComponentCollections + for each Q value. + Q (Number, list, np.ndarray, sc.array | None): + Q values for the model. If None, Q is not set. + diffusion_models (DiffusionModelBase | list[DiffusionModelBase] + | None): Diffusion models to include in the SampleModel. + If None, no diffusion models are added. + temperature (float | None): Temperature for detailed balancing. + If None, no detailed balancing is applied. + temperature_unit (str | sc.Unit): Unit of the temperature. + Defaults to "K". + divide_by_temperature (bool): Whether to divide the detailed + balance factor by temperature. Defaults to True. """ def __init__( @@ -115,7 +110,11 @@ def append_diffusion_model(self, diffusion_model: DiffusionModelBase) -> None: Args: diffusion_model (DiffusionModelBase): The DiffusionModel - to append. + to append. + + Raises: + TypeError: If the diffusion_model is not a + DiffusionModelBase """ if not isinstance(diffusion_model, DiffusionModelBase): @@ -131,6 +130,10 @@ def remove_diffusion_model(self, name: 'str') -> None: Args: name (str): The unique name of the DiffusionModel to remove. + + Raises: + ValueError: If no DiffusionModel with the given unique name + is found. """ for i, dm in enumerate(self._diffusion_models): if dm.unique_name == name: @@ -153,14 +156,28 @@ def clear_diffusion_models(self) -> None: @property def diffusion_models(self) -> list[DiffusionModelBase]: - """Get the diffusion models of the SampleModel.""" + """Get the diffusion models of the SampleModel. + + Returns: + list[DiffusionModelBase]: The diffusion models of the + SampleModel. + """ return self._diffusion_models @diffusion_models.setter def diffusion_models( self, value: DiffusionModelBase | list[DiffusionModelBase] | None ) -> None: - """Set the diffusion models of the SampleModel.""" + """Set the diffusion models of the SampleModel. + + Args: + value (DiffusionModelBase | list[DiffusionModelBase] | + None): + The diffusion model(s) to set. Can be a single + DiffusionModelBase, a list of DiffusionModelBase, or + None to clear all diffusion models. + """ + if value is None: self._diffusion_models = [] return @@ -179,12 +196,22 @@ def diffusion_models( @property def temperature(self) -> Parameter | None: - """Get the temperature of the SampleModel.""" + """Get the temperature of the SampleModel. + + Returns: + Parameter | None: The temperature Parameter of the + SampleModel, or None if not set. + """ return self._temperature @temperature.setter def temperature(self, value: Numeric | None) -> None: - """Set the temperature of the SampleModel.""" + """Set the temperature of the SampleModel. + + Args: + value (Numeric | None): The temperature value to set. Can be + a number or None to unset the temperature. + """ if value is None: self._temperature = None return @@ -208,18 +235,40 @@ def temperature(self, value: Numeric | None) -> None: @property def temperature_unit(self) -> str | sc.Unit: - """Get the temperature unit of the SampleModel.""" + """Get the temperature unit of the SampleModel. + + Returns: + str | sc.Unit: The unit of the temperature Parameter. + """ return self._temperature_unit @temperature_unit.setter def temperature_unit(self, value: str | sc.Unit) -> None: + """The temperature unit of the SampleModel is read-only. + + Args: + value (str | sc.Unit): The unit to set for the temperature + Parameter. + + Raises: + AttributeError: Always, as temperature_unit is read-only. + """ + 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.' ) # noqa: E501 def convert_temperature_unit(self, unit: str | sc.Unit) -> None: - """Convert the unit of the temperature Parameter.""" + """Convert the unit of the temperature Parameter. + + Args: + unit (str | sc.Unit): The unit to convert the temperature + Parameter to. + + Raises: + ValueError: If temperature is not set or conversion fails. + """ if self._temperature is None: raise ValueError('Temperature is not set, cannot convert unit.') @@ -241,6 +290,10 @@ def convert_temperature_unit(self, unit: str | sc.Unit) -> None: def divide_by_temperature(self) -> bool: """Get whether to divide the detailed balance factor by temperature. + + Returns: + bool: True if the detailed balance factor is divided by + temperature, False otherwise. """ return self._divide_by_temperature @@ -248,6 +301,10 @@ def divide_by_temperature(self) -> bool: def divide_by_temperature(self, value: bool) -> None: """Set whether to divide the detailed balance factor by temperature. + + Args: + value (bool): True to divide the detailed balance factor by + temperature, False otherwise. """ if not isinstance(value, bool): raise TypeError('divide_by_temperature must be True or False') @@ -262,15 +319,14 @@ def evaluate( ) -> list[np.ndarray]: """Evaluate the sample model at all Q for the given x values. - Parameters - ---------- - x : Number, list, np.ndarray, sc.Variable, or sc.DataArray - Energy axis. + Args: + x (Numeric | list | np.ndarray | sc.Variable | + sc.DataArray): + The x values to evaluate the model at. Can be a number, + list, numpy array, scipp Variable, or scipp DataArray. - Returns - ------- - list[np.ndarray] - List of evaluated model values for each Q. + Returns: + list[np.ndarray]: List of evaluated model values for each Q. """ y = super().evaluate(x) @@ -293,6 +349,11 @@ def get_all_variables(self, Q_index: int | None = None) -> list[Parameter]: Also includes temperature if set and all variables from diffusion models. Ignores the Parameters and Descriptors in self._components as these are just templates. + + Args: + Q_index (int | None): If specified, only get variables from + the ComponentCollection at the given Q index. If None, + get variables from all ComponentCollections. """ all_vars = super().get_all_variables(Q_index=Q_index) @@ -335,6 +396,12 @@ def _on_diffusion_models_change(self) -> None: # ------------------------------------------------------------------ def __repr__(self): + """Return a string representation of the SampleModel. + + Returns: + str: A string representation of the SampleModel. + """ + return ( f'{self.__class__.__name__}(unique_name={self.unique_name}, unit={self._unit}), ' f'Q = {self._Q}, ' diff --git a/tests/conftest.py b/tests/conftest.py index 0bca2e2a..98e27afe 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,69 +1,2 @@ # SPDX-FileCopyrightText: 2025-2026 EasyDynamics contributors # SPDX-License-Identifier: BSD-3-Clause - -# Local fixture to reset global object map for problematic test -# TODO: remove once weakref bug is fixed - - -from unittest.mock import patch - -import pytest - - -@pytest.fixture(autouse=True) -def patch_easyscience_map(): - """Patch the problematic Map methods.""" - from easyscience.global_object.map import Map - - # Store the original methods - original_add_vertex = Map.add_vertex - # original_vertices = Map.vertices - - def safe_add_vertex(self, obj: object, obj_type: str = None): - try: - return original_add_vertex(self, obj, obj_type) - except KeyError: - # Object was garbage collected during setup - name = obj.unique_name - # Clean up any partial state - if hasattr(self, '_Map__type_dict') and name in self._Map__type_dict: - del self._Map__type_dict[name] - if name in self._store: - del self._store[name] - - def safe_vertices(self): - """Safe version of vertices() that handles dictionary changes - during iteration.""" - max_retries = 3 - for attempt in range(max_retries): - try: - return list(self._store.keys()) - except RuntimeError as e: - if 'dictionary changed size during iteration' in str(e): - if attempt < max_retries - 1: - # Force cleanup and try again - import gc - - gc.collect() - continue - else: - # Last attempt - return what we can get - try: - # Try to get keys in a different way - keys = [] - for k in list(self._store.data.keys()): - if k in self._store: - keys.append(k) - return keys - except: # noqa: E722 - return [] - else: - raise - return [] - - # Apply the patches - with ( - patch.object(Map, 'add_vertex', safe_add_vertex), - patch.object(Map, 'vertices', safe_vertices), - ): - yield 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 0d0963c0..cbe57926 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 @@ -77,6 +77,11 @@ def test_diffusion_coefficient_setter_raises(self, brownian_diffusion_model): with pytest.raises(TypeError, match='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): + # WHEN THEN EXPECT + with pytest.raises(ValueError, match='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 '): 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 f053e4cb..e7e726d4 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 @@ -31,6 +31,11 @@ def test_scale_setter(self, diffusion_model): # THEN EXPECT assert diffusion_model.scale.value == 2.0 + def test_scale_setter_negative_raises(self, diffusion_model): + # WHEN THEN EXPECT + with pytest.raises(ValueError, match='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='scale must be a number.'): 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 90a842d6..744e176b 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 @@ -88,6 +88,11 @@ def test_diffusion_coefficient_setter_raises(self, jump_diffusion_model): with pytest.raises(TypeError, match='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='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): # WHEN jump_diffusion_model.relaxation_time = 2.5 @@ -100,6 +105,11 @@ def test_relaxation_time_setter_raises(self, jump_diffusion_model): with pytest.raises(TypeError, match='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='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 '):