diff --git a/docs/mkdocs.yml b/docs/mkdocs.yml index 88b1bda5..b0b2fd61 100644 --- a/docs/mkdocs.yml +++ b/docs/mkdocs.yml @@ -86,6 +86,8 @@ extra_css: - assets/stylesheets/extra.css extra_javascript: - assets/javascripts/extra.js + - javascripts/mathjax.js + - https://unpkg.com/mathjax@3/es5/tex-mml-chtml.js # A list of extensions beyond the ones that MkDocs uses by default (meta, toc, tables, and fenced_code) markdown_extensions: @@ -94,6 +96,8 @@ markdown_extensions: - attr_list - def_list - footnotes + - pymdownx.arithmatex: + generic: true - pymdownx.blocks.caption - pymdownx.details - pymdownx.emoji: @@ -132,7 +136,7 @@ plugins: allow_errors: false include_source: true include_requirejs: true # Required for Plotly - custom_mathjax_url: 'https://cdnjs.cloudflare.com/ajax/libs/mathjax/2.7.7/latest.js?config=TeX-AMS_CHTML-full,Safe' + # custom_mathjax_url: 'https://cdnjs.cloudflare.com/ajax/libs/mathjax/2.7.7/latest.js?config=TeX-AMS_CHTML-full,Safe' ignore_h1_titles: true # Use titles defined in the nav section below remove_tag_config: remove_input_tags: @@ -143,6 +147,7 @@ plugins: paths: ['src'] # Change 'src' to your actual sources directory options: docstring_style: google + render_markdown: true group_by_category: false heading_level: 1 show_root_heading: true diff --git a/src/easydynamics/sample_model/components/damped_harmonic_oscillator.py b/src/easydynamics/sample_model/components/damped_harmonic_oscillator.py index 2bedeb76..f85eb93b 100644 --- a/src/easydynamics/sample_model/components/damped_harmonic_oscillator.py +++ b/src/easydynamics/sample_model/components/damped_harmonic_oscillator.py @@ -14,19 +14,38 @@ class DampedHarmonicOscillator(CreateParametersMixin, ModelComponent): - """ - Damped Harmonic Oscillator (DHO). - 2*area*center^2*width/pi / ( (x^2 - center^2)^2 + (2*width*x)^2 ) + r"""Model of a Damped Harmonic Oscillator (DHO). + + The intensity is given by + $$ + I(x) = \frac{2 A x_0^2 \gamma}{\pi \left( (x^2 - x_0^2)^2 + + (2 \gamma x)^2 \right)}, + $$ + where $A$ is the area, $x_0$ is the center, and $\gamma$ is the + width. + Args: - display_name (str): Display name of the component. - center (Int or float): Resonance frequency, approximately the - peak position. - width (Int or float): Damping constant, approximately the - half width at half max (HWHM) of the peaks. - area (Int or float): Area under the curve. - unit (str or sc.Unit): Unit of the parameters. - Defaults to "meV". + area (Int | float): Area under the curve. + center (Int | float): Resonance frequency, approximately the + peak position. + width (Int | float): Damping constant, approximately the + half width at half max (HWHM) of the peaks. + unit (str | sc.Unit): Unit of the parameters. + Defaults to "meV". + display_name (str | None): Display name of the component. + unique_name (str | None): Unique name of the component. + If None, a unique_name is automatically generated. + + Attributes: + area (Parameter): Area under the curve. + center (Parameter): Resonance frequency, approximately the + peak position. + width (Parameter): Damping constant, approximately the + half width at half max (HWHM) of the peaks. + unit (str | sc.Unit): Unit of the parameters. + display_name (str | None): Display name of the component. + unique_name (str | None): Unique name of the component. """ def __init__( @@ -62,50 +81,95 @@ def __init__( @property def area(self) -> Parameter: - """Get the area parameter.""" + """Get the area parameter. + + Returns: + Parameter: The area parameter. + """ return self._area @area.setter def area(self, value: Numeric) -> None: - """Set the area parameter value.""" + """Set the value of the area parameter.""" if not isinstance(value, Numeric): raise TypeError('area must be a number') self._area.value = value @property def center(self) -> Parameter: - """Get the center parameter.""" + """Get the center parameter. + + Returns: + Parameter: The center parameter. + """ return self._center @center.setter def center(self, value: Numeric) -> None: - """Set the center parameter value.""" + """Set the value of the center parameter. + + Args: + value (Numeric): The new value for the center parameter. + + Raises: + TypeError: If the value is not a number. + ValueError: If the value is not positive. + """ if not isinstance(value, Numeric): raise TypeError('center must be a number') - if value <= 0: + if float(value) <= 0: raise ValueError('center must be positive') self._center.value = value @property def width(self) -> Parameter: - """Get the width parameter.""" + """Get the width parameter. + + Returns: + Parameter: The width parameter. + """ return self._width @width.setter def width(self, value: Numeric) -> None: - """Set the width parameter value.""" + """Set the value of the width parameter. + + Args: + value (Numeric): The new value for the width parameter. + + Raises: + TypeError: If the value is not a number. + ValueError: If the value is not positive. + """ if not isinstance(value, Numeric): raise TypeError('width must be a number') + + if float(value) <= 0: + raise ValueError('width must be positive') + self._width.value = value def evaluate(self, x: Numeric | list | np.ndarray | sc.Variable | sc.DataArray) -> np.ndarray: - """Evaluate the Damped Harmonic Oscillator at the given x + r"""Evaluate the Damped Harmonic Oscillator at the given x values. If x is a scipp Variable, the unit of the DHO will be converted - to match x. The DHO evaluates to - 2*area*center^2*width/pi / ((x^2 - center^2)^2 + (2*width*x)^2) + to match x. The intensity is given by + $$ + I(x) = \frac{2 A x_0^2 \gamma}{\pi \left( (x^2 - x_0^2)^2 + + (2 \gamma x)^2 \right)}, + $$ + where $A$ is the area, $x_0$ is the center, and $\gamma$ is the + width. + + Args: + x (Numeric | list | np.ndarray | sc.Variable | + sc.DataArray): The x values at which to evaluate the + DHO. + + Returns: + np.ndarray: The intensity of the DHO at the given x values. """ x = self._prepare_x_for_evaluate(x) @@ -116,7 +180,14 @@ def evaluate(self, x: Numeric | list | np.ndarray | sc.Variable | sc.DataArray) return self.area.value * normalization / (denominator) - def __repr__(self): + def __repr__(self) -> str: + """Return a string representation of the Damped Harmonic + Oscillator. + + Returns: + str: A string representation of the Damped Harmonic + Oscillator. + """ return ( f'DampedHarmonicOscillator(display_name = {self.display_name}, unit = {self._unit},\n \ area = {self.area},\n center = {self.center},\n width = {self.width})' diff --git a/src/easydynamics/sample_model/components/delta_function.py b/src/easydynamics/sample_model/components/delta_function.py index 7e302886..6cc1faca 100644 --- a/src/easydynamics/sample_model/components/delta_function.py +++ b/src/easydynamics/sample_model/components/delta_function.py @@ -16,20 +16,29 @@ class DeltaFunction(CreateParametersMixin, ModelComponent): - """Delta function. Evaluates to zero everywhere, except in - convolutions, where it acts as an identity. This is handled in the - ResolutionHandler. If the center is not provided, it will be - centered at 0 and fixed, which is typically what you want in QENS. + """Delta function. + + Evaluates to zero everywhere, except in convolutions, where it acts + as an identity. This is handled by the Convolution method. If the + center is not provided, it will be centered at 0 and fixed, which is + typically what you want in QENS. Args: - center (Int or float or None): Center of the delta function. - If None, defaults to 0 and is fixed. - area (Int or float): Total area under the curve. - unit (str or sc.Unit): Unit of the parameters. - Defaults to "meV". - display_name (str): Name of the component. - unique_name (str or None): Unique name of the component. - If None, a unique_name is automatically generated. + center (Int | float | None): Center of the delta function. If + None, defaults to 0 and is fixed. + area (Int | float): Total area under the curve. + unit (str | sc.Unit): Unit of the parameters. + Defaults to "meV". + display_name (str | None): Name of the component. + unique_name (str | None): Unique name of the component. + If None, a unique_name is automatically generated. + + Attributes: + center (Parameter): Center of the delta function. + area (Parameter): Total area under the curve. + unit (str | sc.Unit): Unit of the parameters. + display_name (str | None): Name of the component. + unique_name (str | None): Unique name of the component. """ def __init__( @@ -58,24 +67,51 @@ def __init__( @property def area(self) -> Parameter: - """Get the area parameter.""" + """Get the area parameter. + + Returns: + Parameter: The area parameter. + """ + return self._area @area.setter def area(self, value: Numeric) -> None: - """Set the area parameter value.""" + """Set the value of the area parameter. + + Args: + value (Numeric): The new value for the area parameter. + + Raises: + TypeError: If the value is not a number. + """ + if not isinstance(value, Numeric): raise TypeError('area must be a number') self._area.value = value @property def center(self) -> Parameter: - """Get the center parameter.""" + """Get the center parameter. + + Returns: + Parameter: The center parameter. + """ + return self._center @center.setter def center(self, value: Numeric | None) -> None: - """Set the center parameter value.""" + """Set the center parameter value. + + Args: + value (Numeric | None): The new value for the center + parameter. If None, defaults to 0 and is fixed. + + Raises: + TypeError: If the value is not a number or None. + """ + if value is None: value = 0.0 self._center.fixed = True @@ -89,6 +125,15 @@ def evaluate(self, x: Numeric | list | np.ndarray | sc.Variable | sc.DataArray) The Delta function evaluates to zero everywhere, except at the center. Its numerical integral is equal to the area. It acts as an identity in convolutions. + + Args: + x (Numeric | list | np.ndarray | sc.Variable | + sc.DataArray): The x values at which to evaluate the + Delta function. + + Returns: + np.ndarray: The evaluated Delta function at the given x + values. """ # x assumed sorted, 1D numpy array @@ -120,6 +165,12 @@ def evaluate(self, x: Numeric | list | np.ndarray | sc.Variable | sc.DataArray) return model - def __repr__(self): + def __repr__(self) -> str: + """Return a string representation of the Delta function. + + Returns: + str: A string representation of the Delta function. + """ + return f'DeltaFunction(unique_name = {self.unique_name}, unit = {self._unit},\n \ area = {self.area},\n center = {self.center}' diff --git a/src/easydynamics/sample_model/components/gaussian.py b/src/easydynamics/sample_model/components/gaussian.py index 196b4f23..d10d6978 100644 --- a/src/easydynamics/sample_model/components/gaussian.py +++ b/src/easydynamics/sample_model/components/gaussian.py @@ -14,22 +14,42 @@ class Gaussian(CreateParametersMixin, ModelComponent): - """ - Gaussian function: - area/(width*sqrt(2pi)) * exp(-0.5*((x - center)/width)^2) - If the center is not provided, it will be centered at 0 and fixed, - which is typically what you want in QENS. - - Args: - area (Int, float or Parameter): Area of the Gaussian. - center (Int, float, None or Parameter): Center of the Gaussian. - If None, defaults to 0 and is fixed - width (Int, float or Parameter): Standard deviation. - unit (str or sc.Unit): Unit of the parameters. - Defaults to "meV". - display_name (str): Name of the component. - unique_name (str or None): Unique name of the component. - If None, a unique_name is automatically generated. + r"""Model of a Gaussian function. + + The intensity is given by + + $$ + I(x) = \frac{A}{\sigma \sqrt{2\pi}} + \exp\left( + -\frac{1}{2} + \left(\frac{x - x_0}{\sigma}\right)^2 + \right) + $$ + + where $A$ is the area, $x_0$ is the center, and $\sigma$ is the + width. + + If the center is not provided, it will be centered at 0 and + fixed, which is typically what you want in QENS. + + Args: + area (Int | float | Parameter): Area of the Gaussian. + center (Int | float | None | Parameter): Center of the + Gaussian. If None, defaults to 0 and is fixed. + width (Int | float | Parameter): Standard deviation. + unit (str | sc.Unit): Unit of the parameters. Defaults to + "meV". + display_name (str | None): Name of the component. + unique_name (str | None): Unique name of the component. if + None, a unique_name is automatically generated. + + Attributes: + area (Parameter): Area of the Gaussian. + center (Parameter): Center of the Gaussian. + width (Parameter): Standard deviation of the Gaussian. + unit (str | sc.Unit): Unit of the parameters. + display_name (str | None): Name of the component. + unique_name (str | None): Unique name of the component. """ def __init__( @@ -61,24 +81,51 @@ def __init__( @property def area(self) -> Parameter: - """Get the area parameter.""" + """Get the area parameter. + + Returns: + Parameter: The area parameter. + """ + return self._area @area.setter def area(self, value: Numeric) -> None: - """Set the area parameter value.""" + """Set the value of the area parameter. + + Args: + value (Numeric): The new value for the area parameter. + + Raises: + TypeError: If the value is not a number. + """ + if not isinstance(value, Numeric): raise TypeError('area must be a number') self._area.value = value @property def center(self) -> Parameter: - """Get the center parameter.""" + """Get the center parameter. + + Returns: + Parameter: The center parameter. + """ + return self._center @center.setter def center(self, value: Numeric) -> None: - """Set the center parameter value.""" + """Set the center parameter value. + + Args: + value (Numeric | None): The new value for the center + parameter. If None, defaults to 0 and is fixed. + + Raises: + TypeError: If the value is not a number or None. + """ + if value is None: value = 0.0 self._center.fixed = True @@ -88,23 +135,62 @@ def center(self, value: Numeric) -> None: @property def width(self) -> Parameter: - """Get the width parameter.""" + """Get the width parameter (standard deviation). + + Returns: + Parameter: The width parameter. + """ return self._width @width.setter def width(self, value: Numeric) -> None: - """Set the width parameter value.""" + """Set the width parameter value. + + Args: + value (Numeric | None): The new value for the width + parameter. + + Raises: + TypeError: If the value is not a number or None. + ValueError: If the value is not positive. + """ if not isinstance(value, Numeric): raise TypeError('width must be a number') + + if float(value) <= 0: + raise ValueError('width must be positive') + self._width.value = value - def evaluate(self, x: Numeric | list | np.ndarray | sc.Variable | sc.DataArray) -> np.ndarray: - """Evaluate the Gaussian at the given x values. + def evaluate( + self, + x: Numeric | list | np.ndarray | sc.Variable | sc.DataArray, + ) -> np.ndarray: + r"""Evaluate the Gaussian at the given x values. If x is a scipp Variable, the unit of the Gaussian will be converted to match x. - The Gaussian evaluates to - area/(width*sqrt(2pi)) * exp(-0.5*((x - center)/width)^2) + The intensity is given by + $$ + I(x) = \frac{A}{\sigma \sqrt{2\pi}} + \exp\left( + -\frac{1}{2} + \left(\frac{x - x_0}{\sigma}\right)^2 + \right) + $$ + + where $A$ is the area, $x_0$ is the center, and $\sigma$ is the + width. + + + Args: + x (Numeric or list or np.ndarray or sc.Variable or + sc.DataArray): + The x values at which to evaluate the Gaussian. + + Returns: + np.ndarray: The intensity of the Gaussian at the given x + values. """ x = self._prepare_x_for_evaluate(x) @@ -114,6 +200,12 @@ def evaluate(self, x: Numeric | list | np.ndarray | sc.Variable | sc.DataArray) return self.area.value * normalization * np.exp(exponent) - def __repr__(self): + def __repr__(self) -> str: + """Return a string representation of the Gaussian. + + Returns: + str: A string representation of the Gaussian. + """ + return f'Gaussian(unique_name = {self.unique_name}, unit = {self._unit},\n \ area = {self.area},\n center = {self.center},\n width = {self.width})' diff --git a/src/easydynamics/sample_model/components/lorentzian.py b/src/easydynamics/sample_model/components/lorentzian.py index 8b5c6ab7..28685f98 100644 --- a/src/easydynamics/sample_model/components/lorentzian.py +++ b/src/easydynamics/sample_model/components/lorentzian.py @@ -14,22 +14,37 @@ class Lorentzian(CreateParametersMixin, ModelComponent): - """ - Lorentzian function: - area*width / (pi * ( (x - center)^2 + width^2 ) ) - If the center is not provided, it will be centered at 0 and fixed, - which is typically what you want in QENS. + r"""Model of a Lorentzian function. + + The intensity is given by + $$ + I(x) = \frac{A}{\pi} \frac{\Gamma}{(x - x_0)^2 + \Gamma^2}, + $$ + where $A$ is the area, $x_0$ is the center, and $\Gamma$ is the + half width at half maximum (HWHM). + + If the center is not provided, it will be centered at 0 + and fixed, which is typically what you want in QENS. Args: - area (Int, float or Parameter): Area of the Lorentzian. - center (Int, float, None or Parameter): Peak center. - If None, defaults to 0 and is fixed. - width (Int, float or Parameter): - Half Width at Half Maximum (HWHM) - unit (str or sc.Unit): Unit of the parameters. Defaults to "meV" - display_name (str): Display name of the component. - unique_name (str or None): Unique name of the component. - If None, a unique_name is automatically generated. + area (Int | float | Parameter): Area of the Lorentzian. + center (Int | float | None | Parameter): Center of the + Lorentzian. If None, defaults to 0 and is fixed + width (Int | float | Parameter): Half width at half maximum + (HWHM). + unit (str | sc.Unit): Unit of the parameters. Defaults to "meV". + display_name (str | None): Name of the component. + unique_name (str | None): Unique name of the component. If None, + a unique_name is automatically generated. + + Attributes: + area (Parameter): Area of the Lorentzian. + center (Parameter): Center of the Lorentzian. + width (Parameter): Half width at half maximum (HWHM) of the + Lorentzian. + unit (str | sc.Unit): Unit of the parameters. + display_name (str | None): Name of the component. + unique_name (str | None): Unique name of the component. """ def __init__( @@ -60,24 +75,48 @@ def __init__( @property def area(self) -> Parameter: - """Get the area parameter.""" + """Get the area parameter. + + Returns: + Parameter: The area parameter. + """ return self._area @area.setter def area(self, value: Numeric) -> None: - """Set the area parameter value.""" + """Set the value of the area parameter. + + Args: + value (Numeric): The new value for the area parameter. + + Raises: + TypeError: If the value is not a number. + """ if not isinstance(value, Numeric): raise TypeError('area must be a number') self._area.value = value @property def center(self) -> Parameter: - """Get the center parameter.""" + """Get the center parameter. + + Returns: + Parameter: The center parameter. + """ return self._center @center.setter def center(self, value: Numeric | None) -> None: - """Set the center parameter value.""" + """Set the value of the center parameter. + + Args: + value (Numeric | None): The new value for the center + parameter. If None, defaults to 0 and is fixed. + + Raises: + TypeError: If the value is not a number or None. + """ + if value is None: value = 0.0 self._center.fixed = True @@ -87,23 +126,54 @@ def center(self, value: Numeric | None) -> None: @property def width(self) -> Parameter: - """Get the width parameter.""" + """Get the width parameter (HWHM). + + Returns: + Parameter: The width parameter. + """ return self._width @width.setter def width(self, value: Numeric) -> None: - """Set the width parameter value.""" + """Set the width parameter value (HWHM). + + Args: + value (Numeric | None): The new value for the width + parameter. + + Raises: + TypeError: If the value is not a number or None. + ValueError: If the value is not positive. + """ if not isinstance(value, Numeric): raise TypeError('width must be a number') + + if float(value) <= 0: + raise ValueError('width must be positive') self._width.value = value def evaluate(self, x: Numeric | list | np.ndarray | sc.Variable | sc.DataArray) -> np.ndarray: - """Evaluate the Lorentzian at the given x values. + r"""Evaluate the Lorentzian at the given x values. If x is a scipp Variable, the unit of the Lorentzian will be - converted to match x. - The Lorentzian evaluates to - area*width / (pi * ( (x - center)^2 + width^2 ) ) + converted to match x. The intensity is given by + + $$ + I(x) = \frac{A}{\pi} \frac{\Gamma}{(x - + x_0)^2 + \Gamma^2}, + $$ + + where $A$ is the area, $x_0$ is the center, and $\Gamma$ is + the half width at half maximum (HWHM). + + Args: + x (Numeric or list or np.ndarray or sc.Variable or + sc.DataArray): + The x values at which to evaluate the Lorentzian. + + Returns: + np.ndarray: The intensity of the Lorentzian at the given x + values. """ x = self._prepare_x_for_evaluate(x) @@ -114,5 +184,10 @@ def evaluate(self, x: Numeric | list | np.ndarray | sc.Variable | sc.DataArray) return self.area.value * normalization / denominator def __repr__(self): + """Return a string representation of the Lorentzian. + + Returns: + str: A string representation of the Lorentzian. + """ return f'Lorentzian(unique_name = {self.unique_name}, unit = {self._unit},\n \ area = {self.area},\n center = {self.center},\n width = {self.width})' diff --git a/src/easydynamics/sample_model/components/mixins.py b/src/easydynamics/sample_model/components/mixins.py index 9c98e7e8..b8bb8b47 100644 --- a/src/easydynamics/sample_model/components/mixins.py +++ b/src/easydynamics/sample_model/components/mixins.py @@ -36,14 +36,17 @@ def _create_area_parameter( If the area is negative, a warning is raised. If the area is non-negative, its minimum is set to 0 to avoid it accidentally becoming negative during fitting. - args: - area (Numeric or Parameter): The area value or Parameter. + + Args: + area (Numeric | Parameter): The area value or Parameter. name (str): The name of the model component. - unit (str or sc.Unit): The unit of the area Parameter. + unit (str | sc.Unit): The unit of the area Parameter. minimum_area (float): The minimum allowed area. - returns: + + Returns: Parameter: The validated area Parameter. - raises: + + Raises: TypeError: If area is not a number or a Parameter. Warning: If area is negative. """ @@ -76,16 +79,18 @@ def _create_center_parameter( """Validate and convert a number to a Parameter describing the center of a function. - args: - center (Numeric, Parameter, or None): The center value or - Parameter. + Args: + center (Numeric | Parameter | None): The center value or + Parameter. name (str): The name of the model component. fix_if_none (bool): Whether to fix the center Parameter - if center is None. - unit (str or sc.Unit): The unit of the center Parameter. - returns: + if center is None. + unit (str | sc.Unit): The unit of the center Parameter. + + Returns: Parameter: The validated center Parameter. - raises: + + Raises: TypeError: If center is not None, a number, or a Parameter. """ if center is not None and not isinstance(center, (Numeric, Parameter)): @@ -118,15 +123,17 @@ def _create_width_parameter( """Validate and convert a number to a Parameter describing the width of a function. - args: + Args: width (Numeric or Parameter): The width value or Parameter. name (str): The name of the model component. param_name (str): The name of the width parameter. unit (str or sc.Unit): The unit of the width Parameter. minimum_width (float): The minimum allowed width. - returns: + + Returns: Parameter: The validated width Parameter. - raises: + + Raises: TypeError: If width is not a number or a Parameter. ValueError: If width is non-positive. """ diff --git a/src/easydynamics/sample_model/components/model_component.py b/src/easydynamics/sample_model/components/model_component.py index 1ffdd0ce..470e2cb2 100644 --- a/src/easydynamics/sample_model/components/model_component.py +++ b/src/easydynamics/sample_model/components/model_component.py @@ -16,7 +16,23 @@ class ModelComponent(ModelBase): - """Abstract base class for all model components.""" + """Abstract base class for all model components. + + Args: + unit (str | sc.Unit): The unit of the model component. + Default is 'meV'. + display_name (str | None): A human-readable name for the + component. Default is None. + unique_name (str | None): A unique identifier for the + component. Default is None. + + Attributes: + unit (str): The unit of the model component. + display_name (str | None): A human-readable name for the + component. + unique_name (str | None): A unique identifier for the + component. + """ def __init__( self, @@ -32,12 +48,23 @@ def __init__( def unit(self) -> str: """Get the unit. - :return: Unit as a string. + Returns: + str: The unit of the model component. """ return str(self._unit) @unit.setter def unit(self, unit_str: str) -> None: + """Unit is read-only. Use convert_unit to change the unit + between allowed types or create a new ModelComponent with the + desired unit. + + Args: + unit_str (str): The new unit to set. + + 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 ' @@ -62,6 +89,19 @@ def _prepare_x_for_evaluate( ) -> np.ndarray: """Prepare the input x for evaluation by handling units and converting to a numpy array. + + Args: + x (Numeric | List[Numeric] | np.ndarray | sc.Variable | + sc.DataArray): The input data to prepare. + + Returns: + np.ndarray: The prepared input data as a numpy array. + + Raises: + ValueError: If x contains NaN or infinite values, or if a + sc.DataArray has more than one coordinate. + UnitError: If x has incompatible units that cannot be + converted to the component's unit. """ # Handle units @@ -119,8 +159,10 @@ def _prepare_x_for_evaluate( @staticmethod def validate_unit(unit) -> None: - """Raise TypeError if unit is not allowed (string or - sc.Unit). + """Validate that the unit is either a string or a scipp Unit. + + Raises: + TypeError: If unit is not a string or scipp Unit. """ if unit is not None and not isinstance(unit, (str, sc.Unit)): raise TypeError( @@ -151,11 +193,15 @@ def convert_unit(self, unit: str | sc.Unit): raise e @abstractmethod - def evaluate(self, x: Numeric | sc.Variable) -> np.ndarray: - """Evaluate the model component at input x. + def evaluate( + self, x: Numeric | List[Numeric] | np.ndarray | sc.Variable | sc.DataArray + ) -> np.ndarray: + """Abstract method to evaluate the model component at input x. + Must be implemented by subclasses. Args: - x (Numeric | sc.Variable): Input values. + x (Numeric | list[Numeric] | np.ndarray | sc.Variable | + sc.DataArray): Input values. Returns: np.ndarray: Evaluated function values. @@ -163,4 +209,10 @@ def evaluate(self, x: Numeric | sc.Variable) -> np.ndarray: pass def __repr__(self): + """Return a string representation of the ModelComponent. + + Returns: + str: A string representation of the ModelComponent. + """ + return f'{self.__class__.__name__}(unique_name={self.unique_name}, unit={self._unit})' diff --git a/src/easydynamics/sample_model/components/polynomial.py b/src/easydynamics/sample_model/components/polynomial.py index 43777807..fa475b2c 100644 --- a/src/easydynamics/sample_model/components/polynomial.py +++ b/src/easydynamics/sample_model/components/polynomial.py @@ -18,15 +18,28 @@ class Polynomial(ModelComponent): - """Polynomial function component. c0 + c1*x + c2*x^2 + ... + cN*x^N. + r"""Polynomial function component. + + The intensity is given by + $$ + I(x) = c_0 + c_1 x + c_2 x^2 + ... + c_N x^N, + $$ + where $C_i$ are the coefficients. Args: coefficients (list or tuple): Coefficients c0, c1, ..., cN - representing f(x) = c0 + c1*x + c2*x^2 + ... + cN*x^N unit (str or sc.Unit): Unit of the Polynomial component. display_name (str): Display name of the Polynomial component. unique_name (str or None): Unique name of the component. If None, a unique_name is automatically generated. + + Attributes: + coefficients (list of Parameter): Coefficients of the polynomial + as Parameters. + unit (str): Unit of the Polynomial component. + display_name (str): Display name of the Polynomial component. + unique_name (str or None): Unique name of the component. + If None, a unique_name is automatically generated. """ def __init__( @@ -68,14 +81,29 @@ def __init__( def coefficients(self) -> list[Parameter]: """Get the coefficients of the polynomial as a list of Parameters. + + Returns: + list[Parameter]: The coefficients of the polynomial. """ return list(self._coefficients) @coefficients.setter def coefficients(self, coeffs: Sequence[Numeric | Parameter]) -> None: - """Replace the coefficients. + """Set the coefficients of the polynomial. Length must match current number of coefficients. + + Args: + coeffs (Sequence[Numeric | Parameter]): New coefficients as + a sequence of numbers or Parameters. + + Raises: + TypeError: If coeffs is not a sequence of numbers or + Parameters. + ValueError: If the length of coeffs does not match the + existing number of coefficients. + TypeError: If any item in coeffs is not a number or + Parameter. """ if not isinstance(coeffs, (list, tuple, np.ndarray)): raise TypeError( @@ -95,14 +123,31 @@ def coefficients(self, coeffs: Sequence[Numeric | Parameter]) -> None: raise TypeError('Each coefficient must be either a numeric value or a Parameter.') def coefficient_values(self) -> list[float]: - """Get the coefficients of the polynomial as a list.""" + """Get the coefficients of the polynomial as a list. + + Returns: + list[float]: The coefficient values of the polynomial. + """ coefficient_list = [param.value for param in self._coefficients] return coefficient_list def evaluate(self, x: Numeric | list | np.ndarray | sc.Variable | sc.DataArray) -> np.ndarray: - """Evaluate the Polynomial at the given x values. + r"""Evaluate the Polynomial at the given x values. + + The intensity is given by + $$ + I(x) = c_0 + c_1 x + c_2 x^2 + ... + c_N x^N, + $$ + where $C_i$ are the coefficients. + + Args: + x (Numeric | list | np.ndarray | sc.Variable | + sc.DataArray): + The x values at which to evaluate the Polynomial. - The Polynomial evaluates to c0 + c1*x + c2*x^2 + ... + cN*x^N + Returns: + np.ndarray: The evaluated Polynomial at the given x + values. """ x = self._prepare_x_for_evaluate(x) @@ -121,11 +166,25 @@ def evaluate(self, x: Numeric | list | np.ndarray | sc.Variable | sc.DataArray) @property def degree(self) -> int: - """Return the degree of the polynomial.""" + """Get the degree of the polynomial. + + Returns: + int: The degree of the polynomial. + """ return len(self._coefficients) - 1 @degree.setter def degree(self, value: int) -> None: + """The degree is determined by the number of coefficients and + cannot be set directly. + + Args: + value (int): The new degree of the polynomial. + + Raises: + AttributeError: Always raised since degree cannot be set + directly. + """ raise AttributeError( 'The degree of the polynomial is determined by the number of coefficients \ and cannot be set directly.' @@ -144,6 +203,9 @@ def convert_unit(self, unit: str | sc.Unit): Args: unit (str or sc.Unit): The target unit to convert to. + + Raises: + UnitError: If the provided unit is not a string or sc.Unit. """ if not isinstance(unit, (str, sc.Unit)): @@ -162,6 +224,12 @@ def convert_unit(self, unit: str | sc.Unit): self._unit = unit def __repr__(self) -> str: + """Return a string representation of the Polynomial. + + Returns: + str: A string representation of the Polynomial. + """ + coeffs_str = ', '.join(f'{param.name}={param.value}' for param in self._coefficients) return f'Polynomial(unique_name = {self.unique_name}, \ unit = {self._unit},\n coefficients = [{coeffs_str}])' diff --git a/src/easydynamics/sample_model/components/voigt.py b/src/easydynamics/sample_model/components/voigt.py index fcd05fb2..dc0c3315 100644 --- a/src/easydynamics/sample_model/components/voigt.py +++ b/src/easydynamics/sample_model/components/voigt.py @@ -15,21 +15,34 @@ class Voigt(CreateParametersMixin, ModelComponent): - """Voigt profile, a convolution of Gaussian and Lorentzian. If the + r"""Voigt profile, a convolution of Gaussian and Lorentzian. If the center is not provided, it will be centered at 0 and fixed, which is typically what you want in QENS. + Use scipy.special.voigt_profile to evaluate the Voigt profile. + Args: - area (Int or float): Total area under the curve. - center (Int or float or None): Center of the Voigt profile. - gaussian_width (Int or float): Standard deviation of the - Gaussian part. - lorentzian_width (Int or float): Half width at half max (HWHM) - of the Lorentzian part. - unit (str or sc.Unit): Unit of the parameters. Defaults to "meV" - display_name (str): Display name of the component. - unique_name (str or None): Unique name of the component. - If None, a unique_name is automatically generated. + area (Int | float): Total area under the curve. + center (Int | float | None): Center of the Voigt profile. + gaussian_width (Int | float): Standard deviation of the + Gaussian part. + lorentzian_width (Int | float): Half width at half max (HWHM) + of the Lorentzian part. + unit (str | sc.Unit): Unit of the parameters. Defaults to "meV" + display_name (str | None): Display name of the component. + unique_name (str | None): Unique name of the component. + If None, a unique_name is automatically generated. + + Attributes: + area (Parameter): Total area under the curve. + center (Parameter): Center of the Voigt profile. + gaussian_width (Parameter): Standard deviation of the Gaussian + part. + lorentzian_width (Parameter): Half width at half max (HWHM) of + the Lorentzian part. + unit (str | sc.Unit): Unit of the parameters. + display_name (str | None): Display name of the component. + unique_name (str | None): Unique name of the component. """ def __init__( @@ -73,24 +86,47 @@ def __init__( @property def area(self) -> Parameter: - """Get the area parameter.""" + """Get the area parameter. + + Returns: + Parameter: The area parameter. + """ return self._area @area.setter def area(self, value: Numeric) -> None: - """Set the area parameter value.""" + """Set the value of the area parameter. + + Args: + value (Numeric): The new value for the area parameter. + + Raises: + TypeError: If the value is not a number. + """ if not isinstance(value, Numeric): raise TypeError('area must be a number') self._area.value = value @property def center(self) -> Parameter: - """Get the center parameter.""" + """Get the center parameter. + + Returns: + Parameter: The center parameter. + """ return self._center @center.setter def center(self, value: Numeric | None) -> None: - """Set the center parameter value.""" + """Set the value of the center parameter. + + Args: + value (Numeric | None): The new value for the center + parameter. If None, defaults to 0 and is fixed. + + Raises: + TypeError: If the value is not a number. + """ if value is None: value = 0.0 self._center.fixed = True @@ -100,36 +136,76 @@ def center(self, value: Numeric | None) -> None: @property def gaussian_width(self) -> Parameter: - """Get the width parameter.""" + """Get the Gaussian width parameter. + + Returns: + Parameter: The Gaussian width parameter. + """ return self._gaussian_width @gaussian_width.setter def gaussian_width(self, value: Numeric) -> None: - """Set the width parameter value.""" + """Set the width parameter value. + + Args: + value (Numeric | None): The new value for the width + parameter. + + Raises: + TypeError: If the value is not a number or None. + ValueError: If the value is not positive. + """ if not isinstance(value, Numeric): raise TypeError('gaussian_width must be a number') + if float(value) <= 0: + raise ValueError('gaussian_width must be positive') self._gaussian_width.value = value @property def lorentzian_width(self) -> Parameter: - """Get the width parameter.""" + """Get the Lorentzian width parameter (HWHM). + + Returns: + Parameter: The Lorentzian width parameter. + """ return self._lorentzian_width @lorentzian_width.setter def lorentzian_width(self, value: Numeric) -> None: - """Set the width parameter value.""" + """Set the value of the Lorentzian width parameter. + + Args: + value (Numeric): The new value for the Lorentzian width + parameter. + + Raises: + TypeError: If the value is not a number. + ValueError: If the value is not positive. + """ if not isinstance(value, Numeric): raise TypeError('lorentzian_width must be a number') + if float(value) <= 0: + raise ValueError('lorentzian_width must be positive') self._lorentzian_width.value = value def evaluate(self, x: Numeric | list | np.ndarray | sc.Variable | sc.DataArray) -> np.ndarray: - """Evaluate the Voigt at the given x values. + r"""Evaluate the Voigt at the given x values. If x is a scipp Variable, the unit of the Voigt will be converted to match x. The Voigt evaluates to the convolution of a Gaussian with sigma gaussian_width and a Lorentzian with half width at half max lorentzian_width, centered at center, with area equal to area. + + + Args: + x (Numeric or list or np.ndarray or sc.Variable or + sc.DataArray): + The x values at which to evaluate the Voigt. + + Returns: + np.ndarray: The intensity of the Voigt at the given x + values. """ x = self._prepare_x_for_evaluate(x) @@ -141,6 +217,12 @@ def evaluate(self, x: Numeric | list | np.ndarray | sc.Variable | sc.DataArray) ) def __repr__(self): + """Return a string representation of the Voigt. + + Returns: + str: A string representation of the Voigt. + """ + return f'Voigt(unique_name = {self.unique_name}, unit = {self._unit},\n \ area = {self.area},\n \ center = {self.center},\n \ diff --git a/src/easydynamics/sample_model/sample_model.py b/src/easydynamics/sample_model/sample_model.py index 346bd7a4..ba21e869 100644 --- a/src/easydynamics/sample_model/sample_model.py +++ b/src/easydynamics/sample_model/sample_model.py @@ -115,7 +115,7 @@ def append_diffusion_model(self, diffusion_model: DiffusionModelBase) -> None: Args: diffusion_model (DiffusionModelBase): The DiffusionModel - to append. + to append. """ if not isinstance(diffusion_model, DiffusionModelBase): diff --git a/src/easydynamics/utils/detailed_balance.py b/src/easydynamics/utils/detailed_balance.py index f82d3c89..8afcd47c 100644 --- a/src/easydynamics/utils/detailed_balance.py +++ b/src/easydynamics/utils/detailed_balance.py @@ -30,32 +30,31 @@ def _detailed_balance_factor( divide_by_temperature: bool = True, ) -> np.ndarray: """ - Compute the detailed balance factor (DBF): - DBF(E, T) = E*(n(E)+1)=E / (1 - exp(-E / (kB*T))), - where n(E) is the Bose-Einstein distribution. - If divide_by_temperature is True, - the result is normalized by kB*T to have value 1 at E=0. + Compute the detailed balance factor (DBF): $DBF(E, T) = E*(n(E)+1)=E + / (1 - exp(-E / (kB*T)))$, where $n(E)$ is the Bose-Einstein + distribution, $E$ is the energy transfer, and $T$ is the + temperature. $k_B$ is the Boltzmann constant. If + divide_by_temperature is True, the result is normalized by kB*T to + have value 1 at E=0. Args: - energy : number, list, np.ndarray, or scipp Variable. - If number, assumed to be in meV unless energy_unit is set. - Energy transfer - T : number, scipp Variable, or Parameter. - If number, assumed to be in K unless temperature_unit is set. - Temperature - energy_unit : str, optional - Unit for energy if energy is given as a number or list. - Default is 'meV' - temperature_unit : str, optional - Unit for temperature if temperature is given as a number. - Default is 'K' - divide_by_temperature : True or False, optional - If True, divide the result by kB*T to make it dimensionless - and have value 1 at E=0. Default is True. + energy (number | list | np.ndarray | scipp.Variable): The energy + transfer. If number, assumed to be in meV unless energy_unit + is set. + temperature (number | scipp.Variable | Parameter): The + temperature. If number, assumed to be in K unless + temperature_unit is set. + energy_unit (str | sc.Unit |None): Unit for energy if energy is + given as a number or list. Default is 'meV' + temperature_unit (str | sc.Unit |None): Unit for temperature if + temperature is given as a number. Default is 'K' + divide_by_temperature (bool | None): If True, divide the result + by $k_B*T$ to make it dimensionless and have value 1 at E=0. + Default is True. Returns: - DBF : np.ndarray TODO: change to sc.Variable? - Detailed balance factor + DBF (np.ndarray): Detailed balance factor evaluated at the + given energy and temperature. Examples -------- @@ -178,6 +177,28 @@ def _convert_to_scipp_variable( ) -> sc.Variable: """Convert various input types to a scipp Variable with proper units. + + Args: + value (int | float | list | np.ndarray | Parameter | + sc.Variable): The value to convert. Can be a number, list, + numpy array, Parameter, or scipp Variable. If a number or + list, the unit must be specified in the unit argument. + name (str): The name of the variable, used for error messages. + unit (str | None): The unit to use if value is a number or list. + Must be specified if value is a number or list. Ignored if + value is a Parameter or sc.Variable, which have their own + units. + + Raises: + TypeError: If value is not one of the accepted types, or if unit + is not a string when needed. + ValueError: If value is a number or list and unit is not + provided. + UnitError: If the provided unit is invalid. + + Returns: + sc.Variable: The input value converted to a scipp Variable with + appropriate units. """ if isinstance(value, sc.Variable): return value diff --git a/tests/conftest.py b/tests/conftest.py index d11735d3..0bca2e2a 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -5,14 +5,6 @@ # TODO: remove once weakref bug is fixed -# import easyscience.global_object -# import pytest - - -# @pytest.fixture(autouse=True) -# def reset_global_object(): -# easyscience.global_object.map._clear() - from unittest.mock import patch import pytest diff --git a/tests/unit/easydynamics/sample_model/components/test_damped_harmonic_oscillator.py b/tests/unit/easydynamics/sample_model/components/test_damped_harmonic_oscillator.py index 0b10a43e..e77f521e 100644 --- a/tests/unit/easydynamics/sample_model/components/test_damped_harmonic_oscillator.py +++ b/tests/unit/easydynamics/sample_model/components/test_damped_harmonic_oscillator.py @@ -138,6 +138,11 @@ def test_center_setter_negative_raises(self, dho: DampedHarmonicOscillator): with pytest.raises(ValueError, match='center must be positive'): dho.center = -1.0 + def test_width_must_be_positive(self, dho: DampedHarmonicOscillator): + # WHEN THEN EXPECT + with pytest.raises(ValueError, match='width must be positive'): + dho.width = -0.5 + def test_evaluate(self, dho: DampedHarmonicOscillator): # WHEN x = np.array([0.0, 1.5, 3.0]) diff --git a/tests/unit/easydynamics/sample_model/components/test_gaussian.py b/tests/unit/easydynamics/sample_model/components/test_gaussian.py index c96eacc3..6699c6d0 100644 --- a/tests/unit/easydynamics/sample_model/components/test_gaussian.py +++ b/tests/unit/easydynamics/sample_model/components/test_gaussian.py @@ -124,6 +124,11 @@ def test_property_setters( with pytest.raises(TypeError, match=invalid_message): setattr(gaussian, prop, invalid_value) + def test_width_must_be_positive(self, gaussian: Gaussian): + # WHEN THEN EXPECT + with pytest.raises(ValueError, match='width must be positive'): + gaussian.width = -0.5 + def test_evaluate(self, gaussian: Gaussian): # WHEN x = np.array([0.0, 0.5, 1.0]) diff --git a/tests/unit/easydynamics/sample_model/components/test_lorentzian.py b/tests/unit/easydynamics/sample_model/components/test_lorentzian.py index 7757a8ab..34b26cca 100644 --- a/tests/unit/easydynamics/sample_model/components/test_lorentzian.py +++ b/tests/unit/easydynamics/sample_model/components/test_lorentzian.py @@ -128,6 +128,11 @@ def test_property_setters( with pytest.raises(TypeError, match=invalid_message): setattr(lorentzian, prop, invalid_value) + def test_width_must_be_positive(self, lorentzian: Lorentzian): + # WHEN THEN EXPECT + with pytest.raises(ValueError, match='width must be positive'): + lorentzian.width = -0.5 + def test_evaluate(self, lorentzian: Lorentzian): # WHEN x = np.array([0.0, 0.5, 1.0]) diff --git a/tests/unit/easydynamics/sample_model/components/test_voigt.py b/tests/unit/easydynamics/sample_model/components/test_voigt.py index a2515a47..9094aedc 100644 --- a/tests/unit/easydynamics/sample_model/components/test_voigt.py +++ b/tests/unit/easydynamics/sample_model/components/test_voigt.py @@ -196,6 +196,19 @@ def test_property_setters( with pytest.raises(TypeError, match=invalid_message): setattr(voigt, prop, invalid_value) + def test_gaussian_width_must_be_positive(self, voigt: Voigt): + # WHEN THEN + with pytest.raises(ValueError, match='gaussian_width must be positive'): + voigt.gaussian_width = -0.6 + + def test_lorentzian_width_must_be_positive(self, voigt: Voigt): + # WHEN THEN + with pytest.raises( + ValueError, + match='lorentzian_width must be positive', + ): + voigt.lorentzian_width = -0.7 + def test_center_is_fixed_if_set_to_None(self, voigt: Voigt): # WHEN assert voigt.center.fixed is False