diff --git a/docs/docs/tutorials/analysis.ipynb b/docs/docs/tutorials/analysis.ipynb index 41e6c7ed..613f4d22 100644 --- a/docs/docs/tutorials/analysis.ipynb +++ b/docs/docs/tutorials/analysis.ipynb @@ -338,7 +338,7 @@ ], "metadata": { "kernelspec": { - "display_name": "Python 3", + "display_name": "default", "language": "python", "name": "python3" }, @@ -352,9 +352,9 @@ "name": "python", "nbconvert_exporter": "python", "pygments_lexer": "ipython3", - "version": "3.14.4" + "version": "3.14.5" } }, "nbformat": 4, "nbformat_minor": 5 -} \ No newline at end of file +} diff --git a/docs/docs/tutorials/analysis1d.ipynb b/docs/docs/tutorials/analysis1d.ipynb index 784f2be5..0cbbae54 100644 --- a/docs/docs/tutorials/analysis1d.ipynb +++ b/docs/docs/tutorials/analysis1d.ipynb @@ -81,7 +81,7 @@ ], "metadata": { "kernelspec": { - "display_name": "Python 3", + "display_name": "default", "language": "python", "name": "python3" }, @@ -100,4 +100,4 @@ }, "nbformat": 4, "nbformat_minor": 5 -} \ No newline at end of file +} diff --git a/docs/docs/tutorials/component_collection.ipynb b/docs/docs/tutorials/component_collection.ipynb index 54f76387..81e6e365 100644 --- a/docs/docs/tutorials/component_collection.ipynb +++ b/docs/docs/tutorials/component_collection.ipynb @@ -67,7 +67,7 @@ ], "metadata": { "kernelspec": { - "display_name": "Python 3", + "display_name": "default", "language": "python", "name": "python3" }, @@ -81,9 +81,9 @@ "name": "python", "nbconvert_exporter": "python", "pygments_lexer": "ipython3", - "version": "3.14.4" + "version": "3.14.5" } }, "nbformat": 4, "nbformat_minor": 5 -} \ No newline at end of file +} diff --git a/docs/docs/tutorials/components.ipynb b/docs/docs/tutorials/components.ipynb index 70070f1e..32805a19 100644 --- a/docs/docs/tutorials/components.ipynb +++ b/docs/docs/tutorials/components.ipynb @@ -172,7 +172,7 @@ ], "metadata": { "kernelspec": { - "display_name": "Python 3", + "display_name": "default", "language": "python", "name": "python3" }, @@ -191,4 +191,4 @@ }, "nbformat": 4, "nbformat_minor": 5 -} \ No newline at end of file +} diff --git a/docs/docs/tutorials/convolution.ipynb b/docs/docs/tutorials/convolution.ipynb index d37c38c1..03a8b86c 100644 --- a/docs/docs/tutorials/convolution.ipynb +++ b/docs/docs/tutorials/convolution.ipynb @@ -210,7 +210,7 @@ ], "metadata": { "kernelspec": { - "display_name": "Python 3", + "display_name": "default", "language": "python", "name": "python3" }, @@ -224,9 +224,9 @@ "name": "python", "nbconvert_exporter": "python", "pygments_lexer": "ipython3", - "version": "3.14.4" + "version": "3.14.5" } }, "nbformat": 4, "nbformat_minor": 5 -} \ No newline at end of file +} diff --git a/docs/docs/tutorials/data/fake_advanced_data.hdf5 b/docs/docs/tutorials/data/fake_advanced_data.hdf5 index d83903fb..aaac7d89 100644 Binary files a/docs/docs/tutorials/data/fake_advanced_data.hdf5 and b/docs/docs/tutorials/data/fake_advanced_data.hdf5 differ diff --git a/docs/docs/tutorials/data/fake_simple_data.hdf5 b/docs/docs/tutorials/data/fake_simple_data.hdf5 index 14afb830..ac976f6b 100644 Binary files a/docs/docs/tutorials/data/fake_simple_data.hdf5 and b/docs/docs/tutorials/data/fake_simple_data.hdf5 differ diff --git a/docs/docs/tutorials/delta_lorentz.ipynb b/docs/docs/tutorials/delta_lorentz.ipynb index 35135812..d6bf1145 100644 --- a/docs/docs/tutorials/delta_lorentz.ipynb +++ b/docs/docs/tutorials/delta_lorentz.ipynb @@ -96,7 +96,7 @@ ], "metadata": { "kernelspec": { - "display_name": "Python 3", + "display_name": "default", "language": "python", "name": "python3" }, @@ -110,9 +110,9 @@ "name": "python", "nbconvert_exporter": "python", "pygments_lexer": "ipython3", - "version": "3.14.4" + "version": "3.14.5" } }, "nbformat": 4, "nbformat_minor": 5 -} \ No newline at end of file +} diff --git a/docs/docs/tutorials/detailed_balance.ipynb b/docs/docs/tutorials/detailed_balance.ipynb index 12d926a6..35928be8 100644 --- a/docs/docs/tutorials/detailed_balance.ipynb +++ b/docs/docs/tutorials/detailed_balance.ipynb @@ -86,7 +86,7 @@ ], "metadata": { "kernelspec": { - "display_name": "easydynamics_newbase", + "display_name": "default", "language": "python", "name": "python3" }, @@ -100,7 +100,7 @@ "name": "python", "nbconvert_exporter": "python", "pygments_lexer": "ipython3", - "version": "3.12.12" + "version": "3.14.5" } }, "nbformat": 4, diff --git a/docs/docs/tutorials/diffusion_model.ipynb b/docs/docs/tutorials/diffusion_model.ipynb index ddf9e9a0..717b1e64 100644 --- a/docs/docs/tutorials/diffusion_model.ipynb +++ b/docs/docs/tutorials/diffusion_model.ipynb @@ -85,7 +85,7 @@ ], "metadata": { "kernelspec": { - "display_name": "Python 3", + "display_name": "default", "language": "python", "name": "python3" }, @@ -99,9 +99,9 @@ "name": "python", "nbconvert_exporter": "python", "pygments_lexer": "ipython3", - "version": "3.14.4" + "version": "3.14.5" } }, "nbformat": 4, "nbformat_minor": 5 -} \ No newline at end of file +} diff --git a/docs/docs/tutorials/experiment.ipynb b/docs/docs/tutorials/experiment.ipynb index 0e6b7c5b..196b7a7d 100644 --- a/docs/docs/tutorials/experiment.ipynb +++ b/docs/docs/tutorials/experiment.ipynb @@ -84,9 +84,9 @@ "name": "python", "nbconvert_exporter": "python", "pygments_lexer": "ipython3", - "version": "3.12.12" + "version": "3.14.5" } }, "nbformat": 4, "nbformat_minor": 5 -} \ No newline at end of file +} diff --git a/docs/docs/tutorials/instrument_model.ipynb b/docs/docs/tutorials/instrument_model.ipynb index 27ccbb93..0f593fab 100644 --- a/docs/docs/tutorials/instrument_model.ipynb +++ b/docs/docs/tutorials/instrument_model.ipynb @@ -89,9 +89,9 @@ "name": "python", "nbconvert_exporter": "python", "pygments_lexer": "ipython3", - "version": "3.12.12" + "version": "3.14.5" } }, "nbformat": 4, "nbformat_minor": 5 -} \ No newline at end of file +} diff --git a/docs/docs/tutorials/sample_model.ipynb b/docs/docs/tutorials/sample_model.ipynb index fa34e8da..f4f72bb5 100644 --- a/docs/docs/tutorials/sample_model.ipynb +++ b/docs/docs/tutorials/sample_model.ipynb @@ -61,7 +61,7 @@ " diffusion_models=diffusion_model,\n", " components=component_collection,\n", " Q=Q,\n", - " unit='meV',\n", + " x_unit='meV',\n", " display_name='MySampleModel',\n", " temperature=10,\n", ")\n", @@ -127,7 +127,7 @@ ], "metadata": { "kernelspec": { - "display_name": "Python 3", + "display_name": "default", "language": "python", "name": "python3" }, @@ -146,4 +146,4 @@ }, "nbformat": 4, "nbformat_minor": 5 -} \ No newline at end of file +} diff --git a/docs/docs/tutorials/tutorial0_basics.ipynb b/docs/docs/tutorials/tutorial0_basics.ipynb index 548c6c71..8a1e1804 100644 --- a/docs/docs/tutorials/tutorial0_basics.ipynb +++ b/docs/docs/tutorials/tutorial0_basics.ipynb @@ -385,9 +385,7 @@ "cell_type": "markdown", "id": "842c1f01", "metadata": {}, - "source": [ - "The final step in this tutorial is to fit the are of the `Gaussian` to a straight line. For this, we use the `ParameterAnalysis` class. We create a `Polynomial` with two coefficients for the fit function. We create a `FitBinding`, telling the class we want to fit the parameter named `Gaussian area` with the fit function that we define." - ] + "source": "The final step in this tutorial is to fit the area of the `Gaussian` as a function of Q using a straight line. For this, we use the `ParameterAnalysis` class.\n\nWe create a `Polynomial` with two coefficients as the fit function. We then create a `FitBinding` to connect the parameter named `Gaussian area` to the fit function, and pass both to a `ParameterAnalysis` object:\n\n
\n Note\n
\nTwo units must be set on the fit function:\n\n- `x_unit='1/angstrom'` — because `ParameterAnalysis` always uses Q as its x-axis.\n- `y_unit='meV'` — because we are fitting `Gaussian area`, which has unit `meV`.\n
\n
" }, { "cell_type": "code", @@ -395,18 +393,7 @@ "id": "75db3d4c", "metadata": {}, "outputs": [], - "source": [ - "fit_func = sm.Polynomial(\n", - " coefficients=[3.7, -0.5], name='Straight line', display_name='Straight line'\n", - ")\n", - "\n", - "binding = edyn.FitBinding(parameter_name='Gaussian area', model=fit_func)\n", - "\n", - "parameter_analysis = edyn.ParameterAnalysis(\n", - " parameters=analysis,\n", - " bindings=[binding],\n", - ")" - ] + "source": "fit_func = sm.Polynomial(\n coefficients=[3.7, -0.5],\n x_unit='1/angstrom',\n y_unit='meV',\n name='Straight line',\n)\n\nbinding = edyn.FitBinding(parameter_name='Gaussian area', model=fit_func)\n\nparameter_analysis = edyn.ParameterAnalysis(\n parameters=analysis,\n bindings=[binding],\n)" }, { "cell_type": "markdown", @@ -450,7 +437,7 @@ "id": "dc33728c", "metadata": {}, "source": [ - "To see the parameters we can use the `get_all_parameters()` method. We can also see only the parameters that can be fitted:" + "To see the parameters we can use the `get_all_parameters()` method. We can also see only the parameters that can be fitted using `get_fittable_parameters`:" ] }, { @@ -476,7 +463,7 @@ ], "metadata": { "kernelspec": { - "display_name": "Python 3", + "display_name": "default", "language": "python", "name": "python3" }, @@ -495,4 +482,4 @@ }, "nbformat": 4, "nbformat_minor": 5 -} +} \ No newline at end of file diff --git a/docs/docs/tutorials/tutorial0_more_advanced.ipynb b/docs/docs/tutorials/tutorial0_more_advanced.ipynb index f203dbdc..ba0e4e7e 100644 --- a/docs/docs/tutorials/tutorial0_more_advanced.ipynb +++ b/docs/docs/tutorials/tutorial0_more_advanced.ipynb @@ -299,9 +299,7 @@ "cell_type": "markdown", "id": "0eadbd91", "metadata": {}, - "source": [ - "With apologies for the lack of creativity, these all appear like straight lines. We can fit them individually or all together using `ParameterAnalysis`" - ] + "source": "With apologies for the lack of creativity, these all appear like straight lines. We can fit them individually or all together using `ParameterAnalysis`.\n\nFor each parameter we want to fit, we create a fit function (here a `Polynomial`) and a `FitBinding` connecting the parameter name to the fit function. We then pass all bindings to a single `ParameterAnalysis` object:\n\n
\n Note\n
\nTwo units must be set on each fit function:\n\n- `x_unit='1/angstrom'` — because `ParameterAnalysis` always uses Q as its x-axis.\n- `y_unit='meV'` — because all three parameters (`Gaussian area`, `DHO area`, `DHO center`) have unit `meV`.\n
\n
" }, { "cell_type": "code", @@ -310,10 +308,14 @@ "metadata": {}, "outputs": [], "source": [ - "gauss_fit_func = sm.Polynomial(coefficients=[3.7, -0.5], unit='1/angstrom', name='Gauss area fit')\n", - "dho_area_fit_func = sm.Polynomial(coefficients=[2.0, 0.12], unit='1/angstrom', name='DHO area fit')\n", + "gauss_fit_func = sm.Polynomial(\n", + " coefficients=[3.7, -0.5], x_unit='1/angstrom', y_unit='meV', name='Gauss area fit'\n", + ")\n", + "dho_area_fit_func = sm.Polynomial(\n", + " coefficients=[2.0, 0.12], x_unit='1/angstrom', y_unit='meV', name='DHO area fit'\n", + ")\n", "dho_center_fit_func = sm.Polynomial(\n", - " coefficients=[1.1, 0.2], unit='1/angstrom', name='DHO center fit'\n", + " coefficients=[1.1, 0.2], x_unit='1/angstrom', y_unit='meV', name='DHO center fit'\n", ")\n", "\n", "binding1 = edyn.FitBinding(parameter_name='Gaussian area', model=gauss_fit_func)\n", @@ -335,7 +337,7 @@ "id": "32bc1efc", "metadata": {}, "source": [ - "The start guesses look reasonable, so we fit:" + "The start guesses look reasonable, so we fit and plot the result:" ] }, { @@ -352,7 +354,7 @@ ], "metadata": { "kernelspec": { - "display_name": "Python 3", + "display_name": "default", "language": "python", "name": "python3" }, @@ -371,4 +373,4 @@ }, "nbformat": 4, "nbformat_minor": 5 -} +} \ No newline at end of file diff --git a/docs/docs/tutorials/tutorial1_brownian.ipynb b/docs/docs/tutorials/tutorial1_brownian.ipynb index 269bb259..4fba2d61 100644 --- a/docs/docs/tutorials/tutorial1_brownian.ipynb +++ b/docs/docs/tutorials/tutorial1_brownian.ipynb @@ -691,7 +691,7 @@ ], "metadata": { "kernelspec": { - "display_name": "Python 3", + "display_name": "default", "language": "python", "name": "python3" }, diff --git a/docs/docs/tutorials/tutorial2_nanoparticles.ipynb b/docs/docs/tutorials/tutorial2_nanoparticles.ipynb index 22de2490..da2188bc 100644 --- a/docs/docs/tutorials/tutorial2_nanoparticles.ipynb +++ b/docs/docs/tutorials/tutorial2_nanoparticles.ipynb @@ -593,7 +593,7 @@ ], "metadata": { "kernelspec": { - "display_name": "Python 3", + "display_name": "default", "language": "python", "name": "python3" }, diff --git a/src/easydynamics/analysis/analysis.py b/src/easydynamics/analysis/analysis.py index 7291ea91..b61f11cf 100644 --- a/src/easydynamics/analysis/analysis.py +++ b/src/easydynamics/analysis/analysis.py @@ -1,6 +1,7 @@ # SPDX-FileCopyrightText: 2026 EasyScience contributors # SPDX-License-Identifier: BSD-3-Clause +from copy import copy from typing import Any import numpy as np @@ -456,10 +457,7 @@ def data_and_model_to_datagroup( self._verify_bool(include_components, 'include_components') self._verify_bool(include_residuals, 'include_residuals') - energy = self._verify_energy(energy) - - if energy is None: - energy = self.energy + energy = self._verify_energy(energy) if energy is not None else self.energy data_and_model = { 'Data': self.experiment.binned_data, @@ -532,6 +530,7 @@ def parameters_to_dataset(self) -> sc.Dataset: units[name] = p.unit elif units[name] != p.unit: try: + p = copy(p) p.convert_unit(units[name]) except Exception as e: raise UnitError( @@ -587,7 +586,7 @@ def plot_parameters( ds = self.parameters_to_dataset() - if not names: + if names is None: names = list(ds.keys()) if isinstance(names, str): @@ -686,9 +685,8 @@ def _on_convolution_settings_changed(self) -> None: def _ensure_analysis_list_current(self) -> None: """Rebuild the analysis list if any dependency has changed since it was last built.""" - if self._analysis_list_is_dirty: - if self.Q is not None: - self._create_analysis_list() + if self._analysis_list_is_dirty and self.Q is not None: + self._create_analysis_list() self._analysis_list_is_dirty = False def _create_analysis_list(self) -> None: @@ -698,12 +696,15 @@ def _create_analysis_list(self) -> None: """ self._analysis_list = [] for Q_index in range(len(self.Q)): + # Each Analysis1d gets its own ConvolutionSettings so that + # convolution_plan_is_valid is tracked independently per Q index. + per_q_settings = copy(self.convolution_settings) analysis = Analysis1d( display_name=f'{self.display_name}_Q{Q_index}', experiment=self.experiment, sample_model=self.sample_model, instrument_model=self.instrument_model, - convolution_settings=self.convolution_settings, + convolution_settings=per_q_settings, detailed_balance_settings=self.detailed_balance_settings, extra_parameters=self._extra_parameters, Q_index=Q_index, @@ -757,15 +758,16 @@ def _fit_all_Q_simultaneously(self) -> FitResults: ws = [] for analysis1d in self.analysis_list: - x, y, weight, _ = self.experiment._extract_x_y_weights_only_finite( # noqa: SLF001 + x, y, weight, mask = self.experiment._extract_x_y_weights_only_finite( # noqa: SLF001 analysis1d.Q_index ) xs.append(x) ys.append(y) ws.append(weight) - # Make sure the convolver is up to date for this Q index - analysis1d.refresh_convolver(energy=x) + # Slice the scipp energy Variable to finite points only. + mask_sc = sc.array(dims=['energy'], values=mask) + analysis1d.refresh_convolver(energy=self.experiment.energy[mask_sc]) mf = MultiFitter( fit_objects=self.analysis_list, diff --git a/src/easydynamics/analysis/analysis1d.py b/src/easydynamics/analysis/analysis1d.py index 341103e1..d9f438f9 100644 --- a/src/easydynamics/analysis/analysis1d.py +++ b/src/easydynamics/analysis/analysis1d.py @@ -184,14 +184,12 @@ def calculate(self, energy: sc.Variable | None = None) -> np.ndarray: The calculated model prediction. """ energy = self._verify_energy(energy) - self._convolver = self._create_convolver(energy=energy) - # Mark dirty so the next fit() call rebuilds the convolver with the standard - # (unmasked) energy grid rather than reusing this plot-path grid. - self._convolver_is_dirty = True + convolver = self._create_convolver(energy=energy) + return self._calculate(energy=energy, convolver=convolver) - return self._calculate(energy=energy) - - def _calculate(self, energy: sc.Variable | None = None) -> np.ndarray: + def _calculate( + self, energy: sc.Variable | None = None, convolver: object | None = None + ) -> np.ndarray: """ Calculate the model prediction for the chosen Q index. @@ -202,18 +200,21 @@ def _calculate(self, energy: sc.Variable | None = None) -> np.ndarray: energy : sc.Variable | None, default=None Optional energy grid to use for calculation. If None, the energy grid from the experiment is used. + convolver : object | None, default=None + Optional convolver to use. If None, uses self._convolver. Returns ------- np.ndarray The calculated model prediction. """ - + if convolver is None: + convolver = self._convolver Q_index = self._require_Q_index() sample = self._evaluate_with_convolution( self.sample_model.get_component_collection(Q_index), energy, - convolver=self._convolver, + convolver=convolver, ) background = self._evaluate_direct( self.instrument_model.background_model.get_component_collection(Q_index), @@ -437,8 +438,10 @@ def data_and_model_to_datagroup( if energy is None: energy = self._masked_energy + mask = self.experiment.get_finite_energy_mask(Q_index=self.Q_index) + mask_var = sc.array(dims=['energy'], values=mask) data_and_model = { - 'Data': self.experiment.binned_data['Q', self.Q_index], + 'Data': self.experiment.binned_data['Q', self.Q_index][mask_var], 'Model': self._create_model_array(energy=energy), } @@ -516,6 +519,10 @@ def _on_Q_index_changed(self) -> None: This method is called whenever the Q index is changed. It updates the masked energy from the experiment for the new Q index and marks the convolver as dirty. """ + if self._Q_index is None: + self._masked_energy = None + self._convolver_is_dirty = True + return masked_energy = self.experiment.get_masked_energy(Q_index=self._Q_index) self._masked_energy = masked_energy self._convolver_is_dirty = True @@ -523,6 +530,9 @@ def _on_Q_index_changed(self) -> None: def _on_experiment_changed(self) -> None: """Mark the convolver as dirty when the experiment changes.""" super()._on_experiment_changed() + # Refresh masked energy if Q_index is already set (i.e. post-init experiment swap). + if getattr(self, '_Q_index', None) is not None and self.experiment is not None: + self._masked_energy = self.experiment.get_masked_energy(Q_index=self._Q_index) self._convolver_is_dirty = True def _on_sample_model_changed(self) -> None: @@ -572,9 +582,13 @@ def _calculate_energy_with_offset( The energy grid with the offset applied. """ + offset_value = energy_offset.value if energy.unit != energy_offset.unit: try: - energy_offset.convert_unit(str(energy.unit)) + offset_value = sc.to_unit( + sc.scalar(energy_offset.value, unit=str(energy_offset.unit)), + str(energy.unit), + ).value except Exception as e: raise sc.UnitError( f'Energy and energy offset must have compatible units. ' @@ -582,7 +596,7 @@ def _calculate_energy_with_offset( ) from e energy_with_offset = energy.copy(deep=True) - energy_with_offset.values -= energy_offset.value + energy_with_offset.values -= offset_value return energy_with_offset ############# @@ -640,18 +654,15 @@ def _evaluate_with_convolution( energy=energy_with_offset, temperature=self.temperature, divide_by_temperature=self.detailed_balance_settings.normalize_detailed_balance, - energy_unit=self.unit, + energy_unit=self.x_unit, ) return result - return Convolution( - energy=energy, + return self._build_convolution( sample_components=components, resolution_components=resolution, + energy=energy, energy_offset=energy_offset, - convolution_settings=self.convolution_settings, - temperature=self.temperature, - detailed_balance_settings=self.detailed_balance_settings, ).convolution() def _evaluate_direct( @@ -719,11 +730,25 @@ def _create_convolver( if resolution_components.is_empty: return None + return self._build_convolution( + sample_components=sample_components, + resolution_components=resolution_components, + energy=energy, + energy_offset=self.instrument_model.get_energy_offset(Q_index), + ) + + def _build_convolution( + self, + sample_components: ComponentCollection | ModelComponent, + resolution_components: ComponentCollection, + energy: sc.Variable, + energy_offset: Parameter, + ) -> Convolution: return Convolution( energy=energy, sample_components=sample_components, resolution_components=resolution_components, - energy_offset=self.instrument_model.get_energy_offset(Q_index), + energy_offset=energy_offset, convolution_settings=self.convolution_settings, temperature=self.temperature, detailed_balance_settings=self.detailed_balance_settings, @@ -769,7 +794,9 @@ def _create_residuals_array(self) -> sc.DataArray: if self.Q_index is None: raise ValueError('Q_index must be set to calculate residuals.') - data = self.experiment.binned_data['Q', self.Q_index] + mask = self.experiment.get_finite_energy_mask(Q_index=self.Q_index) + mask_var = sc.array(dims=['energy'], values=mask) + data = self.experiment.binned_data['Q', self.Q_index][mask_var] model = self._create_model_array() return data.copy(deep=True) - model diff --git a/src/easydynamics/analysis/analysis_base.py b/src/easydynamics/analysis/analysis_base.py index 598daea6..80be2833 100644 --- a/src/easydynamics/analysis/analysis_base.py +++ b/src/easydynamics/analysis/analysis_base.py @@ -13,6 +13,7 @@ from easydynamics.sample_model import SampleModel from easydynamics.settings.convolution_settings import ConvolutionSettings from easydynamics.settings.detailed_balance_settings import DetailedBalanceSettings +from easydynamics.utils.utils import verify_Q_index class AnalysisBase(EasyDynamicsModelBase): @@ -503,13 +504,6 @@ def _verify_Q_index(self, Q_index: int | None) -> int | None: Q_index : int | None The Q index to verify. - Raises - ------ - TypeError - If Q_index is not an integer or None. - IndexError - If the Q index is not valid. - Returns ------- int | None @@ -517,12 +511,7 @@ def _verify_Q_index(self, Q_index: int | None) -> int | None: """ if Q_index is None: return None - - if not isinstance(Q_index, int): - raise TypeError('Q_index must be an integer or None.') - - if Q_index < 0 or (self.Q is not None and Q_index >= len(self.Q)): - raise IndexError('Q_index must be a valid index for the Q values.') + verify_Q_index(Q_index, self.Q) return Q_index def _verify_energy(self, energy: sc.Variable | None) -> sc.Variable | None: diff --git a/src/easydynamics/analysis/fit_binding.py b/src/easydynamics/analysis/fit_binding.py index da74e383..f44f82ca 100644 --- a/src/easydynamics/analysis/fit_binding.py +++ b/src/easydynamics/analysis/fit_binding.py @@ -103,6 +103,9 @@ def __init__( if isinstance(modes, list) and not all(isinstance(mode, str) for mode in modes): raise TypeError('All modes in the list must be strings') + if isinstance(modes, str): + modes = [modes] + self._parameter_name = parameter_name self._model = model self._modes = modes @@ -261,11 +264,7 @@ def get_parameter_names(self) -> list[str]: modes = self._get_modes() if isinstance(self.model, DiffusionModelBase): - # This needs to be generalised. # TODO: Generalise this for different diffusion models and modes. # noqa TD002 TD003 - if 'delta' in modes: - return [f'{self.parameter_name} area' for mode in modes] - return [f'{self.parameter_name} {mode}' for mode in modes] return [self.parameter_name] diff --git a/src/easydynamics/analysis/parameter_analysis.py b/src/easydynamics/analysis/parameter_analysis.py index 142f0b1a..31f333cb 100644 --- a/src/easydynamics/analysis/parameter_analysis.py +++ b/src/easydynamics/analysis/parameter_analysis.py @@ -549,21 +549,29 @@ def _get_xyweight_from_dataset( raise ValueError(f"Parameter name '{parameter_name}' not found in parameters Dataset.") variances = self._parameters[parameter_name].variances + values = self._parameters[parameter_name].values + q_values = self._parameters[parameter_name].coords['Q'].values + if variances is None: - weight = np.ones_like(self._parameters[parameter_name].values) - elif np.any(~np.isfinite(variances)) or np.any(variances <= 0): + return q_values, values, np.ones_like(values) + + # NaN variances arise when a parameter is absent for a given Q (parameters_to_dataset + # fills np.nan for missing parameters). Filter those rows silently; other non-finite or + # non-positive variances are errors. + nan_mask = np.isnan(variances) + if np.any(~nan_mask & (~np.isfinite(variances) | (variances <= 0))): raise ValueError( f"Non-finite variances found for parameter '{parameter_name}', " f'cannot compute weights.' ) - else: - weight = 1 / np.sqrt(variances) + valid_mask = ~nan_mask + if not np.any(valid_mask): + raise ValueError( + f"No finite positive variances found for parameter '{parameter_name}', " + f'cannot compute weights.' + ) - return ( - self._parameters[parameter_name].coords['Q'].values, - self._parameters[parameter_name].values, - weight, - ) + return q_values[valid_mask], values[valid_mask], 1 / np.sqrt(variances[valid_mask]) ############# # Dunder methods diff --git a/src/easydynamics/base_classes/easydynamics_modelbase.py b/src/easydynamics/base_classes/easydynamics_modelbase.py index 6d002f71..1150c562 100644 --- a/src/easydynamics/base_classes/easydynamics_modelbase.py +++ b/src/easydynamics/base_classes/easydynamics_modelbase.py @@ -14,7 +14,8 @@ class EasyDynamicsModelBase(NameMixin, ModelBase): def __init__( self, *args: object, - unit: str | sc.Unit = 'meV', + x_unit: str | sc.Unit = 'meV', + y_unit: str | sc.Unit = 'dimensionless', name: str = 'MyEasyDynamicsModel', display_name: str | None = None, unique_name: str | None = None, @@ -27,8 +28,10 @@ def __init__( ---------- *args : object Positional arguments to pass to the parent class. - unit : str | sc.Unit, default='meV' - Unit of the model. + x_unit : str | sc.Unit, default='meV' + Unit of the x-axis. + y_unit : str | sc.Unit, default='dimensionless' + Unit of the model output (intensity). name : str, default='MyEasyDynamicsModel' Name of the model. display_name : str | None, default=None @@ -58,37 +61,43 @@ def __init__( **kwargs, ) - self._unit = _validate_unit(unit) + self._x_unit = _validate_unit(x_unit) + self._y_unit = _validate_unit(y_unit) @property - def unit(self) -> str | sc.Unit | None: + def x_unit(self) -> str | sc.Unit | None: """ - Get the unit of the model. + Get the unit of the x-axis. Returns ------- str | sc.Unit | None - The unit of the model. + The unit of the x-axis. """ + return self._x_unit - return self._unit + @x_unit.setter + def x_unit(self, _: str) -> None: + raise AttributeError( + f'x_unit is read-only. Use convert_x_unit to change the unit ' + f'or create a new {self.__class__.__name__} with the desired unit.' + ) - @unit.setter - def unit(self, _unit_str: str) -> None: + @property + def y_unit(self) -> str | sc.Unit | None: """ - Unit is read-only and cannot be set directly. - - Parameters - ---------- - _unit_str : str - The new unit to set (ignored). + Get the unit of the model output. - Raises - ------ - AttributeError - Always raised to indicate that the unit is read-only. + Returns + ------- + str | sc.Unit | None + The unit of the model output (intensity). """ + return self._y_unit + + @y_unit.setter + def y_unit(self, _: str) -> None: raise AttributeError( - f'Unit is read-only. Use convert_unit to change the unit between allowed types ' + f'y_unit is read-only. Use convert_y_unit to change the unit ' f'or create a new {self.__class__.__name__} with the desired unit.' ) diff --git a/src/easydynamics/convolution/analytical_convolution.py b/src/easydynamics/convolution/analytical_convolution.py index 535bea98..78d0b6f9 100644 --- a/src/easydynamics/convolution/analytical_convolution.py +++ b/src/easydynamics/convolution/analytical_convolution.py @@ -491,6 +491,6 @@ def __repr__(self) -> str: f'{self.__class__.__name__}(' f'display_name={self.display_name!r}, ' f'unique_name={self.unique_name!r}, ' - f'unit={self._unit}, ' + f'x_unit={self.x_unit}, ' f'energy_len={len(self.energy)})' ) diff --git a/src/easydynamics/convolution/convolution.py b/src/easydynamics/convolution/convolution.py index d4e8aafe..becbd9e0 100644 --- a/src/easydynamics/convolution/convolution.py +++ b/src/easydynamics/convolution/convolution.py @@ -80,7 +80,7 @@ class Convolution(NumericalConvolutionBase): # needs to be rebuilt. # Note: the public 'energy' property setter always writes to '_energy', so '_energy' alone # is sufficient — listing 'energy' separately would cause a double invalidation. - _invalidate_plan_on_change: ClassVar[dict[str, object]] = { + _invalidate_plan_on_change: ClassVar[set[str]] = { '_energy', '_energy_grid', '_sample_components', @@ -101,7 +101,8 @@ def __init__( temperature: Parameter | Numeric | None = None, temperature_unit: str | sc.Unit = 'K', detailed_balance_settings: DetailedBalanceSettings | None = None, - unit: str | sc.Unit = 'meV', + x_unit: str | sc.Unit = 'meV', + y_unit: str | sc.Unit = 'dimensionless', display_name: str | None = 'MyConvolution', unique_name: str | None = None, ) -> None: @@ -113,7 +114,7 @@ def __init__( energy : np.ndarray | sc.Variable 1D array of energy values where the convolution is evaluated. sample_components : ComponentCollection | ModelComponent - The sample components to be convolved. + The sample components to be convolved. resolution_components : ComponentCollection | ModelComponent The resolution components to convolve with. energy_offset : Numeric | Parameter, default=0.0 @@ -126,8 +127,10 @@ def __init__( The unit of the temperature parameter. detailed_balance_settings : DetailedBalanceSettings | None, default=None The settings for detailed balance. If None, default settings will be used. - unit : str | sc.Unit, default='meV' - The unit of the energy. + x_unit : str | sc.Unit, default='meV' + The unit of the energy axis. + y_unit : str | sc.Unit, default='dimensionless' + The unit of the model output (intensity). display_name : str | None, default='MyConvolution' Display name of the model. unique_name : str | None, default=None @@ -144,7 +147,8 @@ def __init__( temperature=temperature, temperature_unit=temperature_unit, detailed_balance_settings=detailed_balance_settings, - unit=unit, + x_unit=x_unit, + y_unit=y_unit, display_name=display_name, unique_name=unique_name, ) @@ -317,7 +321,7 @@ def _set_convolvers(self) -> None: temperature=self.temperature, temperature_unit=self._temperature_unit, detailed_balance_settings=self.detailed_balance_settings, - unit=self.unit, + x_unit=self.x_unit, ) else: self._numerical_convolver = None @@ -350,7 +354,7 @@ def __repr__(self) -> str: f'{self.__class__.__name__}(' f'display_name={self.display_name!r}, ' f'unique_name={self.unique_name!r}, ' - f'unit={self.unit}, ' + f'x_unit={self.x_unit}, ' f'energy_len={len(self.energy)}, ' f'temperature={self.temperature})' ) diff --git a/src/easydynamics/convolution/convolution_base.py b/src/easydynamics/convolution/convolution_base.py index c8516cb0..796d9e8d 100644 --- a/src/easydynamics/convolution/convolution_base.py +++ b/src/easydynamics/convolution/convolution_base.py @@ -9,6 +9,7 @@ from easydynamics.sample_model.component_collection import ComponentCollection from easydynamics.sample_model.components.model_component import ModelComponent from easydynamics.utils.utils import Numeric +from easydynamics.utils.utils import energy_to_scipp class ConvolutionBase(EasyDynamicsModelBase): @@ -23,7 +24,8 @@ def __init__( energy: np.ndarray | sc.Variable, sample_components: ComponentCollection | ModelComponent | None = None, resolution_components: ComponentCollection | ModelComponent | None = None, - unit: str | sc.Unit = 'meV', + x_unit: str | sc.Unit = 'meV', + y_unit: str | sc.Unit = 'dimensionless', energy_offset: Numeric | Parameter = 0.0, display_name: str | None = 'MyConvolution', unique_name: str | None = None, @@ -39,8 +41,10 @@ def __init__( The sample model to be convolved. resolution_components : ComponentCollection | ModelComponent | None, default=None The resolution model to convolve with. - unit : str | sc.Unit, default='meV' - The unit of the energy. + x_unit : str | sc.Unit, default='meV' + The unit of the energy axis. + y_unit : str | sc.Unit, default='dimensionless' + The unit of the model output (intensity). energy_offset : Numeric | Parameter, default=0.0 The energy offset applied to the convolution. display_name : str | None, default='MyConvolution' @@ -58,7 +62,8 @@ def __init__( """ super().__init__( - unit=unit, + x_unit=x_unit, + y_unit=y_unit, display_name=display_name, unique_name=unique_name, ) @@ -70,10 +75,12 @@ def __init__( raise TypeError(f'Energy must be a numpy ndarray or a scipp Variable. Got {energy}') if isinstance(energy, np.ndarray): - energy = sc.array(dims=['energy'], values=energy, unit=unit) + energy = energy_to_scipp(energy, x_unit) if isinstance(energy_offset, Numeric): - energy_offset = Parameter(name='energy_offset', value=float(energy_offset), unit=unit) + energy_offset = Parameter( + name='energy_offset', value=float(energy_offset), unit=x_unit + ) if not isinstance(energy_offset, Parameter): raise TypeError('Energy_offset must be a number or a Parameter.') @@ -147,8 +154,9 @@ def energy_with_offset(self) -> sc.Variable: sc.Variable The energy values with the offset applied. """ + offset_value = sc.to_unit(self._energy_offset.full_value, self._energy.unit).value energy_with_offset = self.energy.copy() - energy_with_offset.values = self.energy.values - self.energy_offset.value + energy_with_offset.values = self._energy.values - offset_value return energy_with_offset @property @@ -187,15 +195,15 @@ def energy(self, energy: np.ndarray | sc.Variable) -> None: raise TypeError('Energy must be a Number, a numpy ndarray or a scipp Variable.') if isinstance(energy, np.ndarray): - self._energy = sc.array(dims=['energy'], values=energy, unit=self._energy.unit) + self._energy = energy_to_scipp(energy, self._energy.unit) if isinstance(energy, sc.Variable): self._energy = energy - self._unit = energy.unit + self._x_unit = energy.unit - def convert_unit(self, unit: str | sc.Unit) -> None: + def convert_x_unit(self, unit: str | sc.Unit) -> None: """ - Convert the energy and energy_offset to the specified unit. + Convert the energy axis, energy_offset, and all components to the specified unit. Parameters ---------- @@ -213,20 +221,45 @@ def convert_unit(self, unit: str | sc.Unit) -> None: raise TypeError('Energy unit must be a string or scipp unit.') old_energy = self.energy.copy() + old_offset_unit = str(self._energy_offset.unit) + try: self.energy = sc.to_unit(self.energy, unit) - except Exception as e: + self._energy_offset.convert_unit(unit) + if self.sample_components is not None: + self.sample_components.convert_x_unit(unit) + if self.resolution_components is not None: + self.resolution_components.convert_x_unit(unit) + except Exception: self.energy = old_energy - raise e + # Roll back energy_offset if it was already converted to the new unit. + if str(self._energy_offset.unit) != old_offset_unit: + self._energy_offset.convert_unit(old_offset_unit) + raise - old_energy_offset = self.energy_offset - try: - self.energy_offset.convert_unit(unit) - except Exception as e: - self.energy_offset = old_energy_offset - raise e + self._x_unit = unit - self._unit = unit + def convert_y_unit(self, unit: str | sc.Unit) -> None: + """ + Convert the y-axis unit of the sample components. + + Only propagates to sample components (resolution is normalised and unit-independent). + + Parameters + ---------- + unit : str | sc.Unit + The new y-axis unit. + + Raises + ------ + TypeError + If unit is not a string or scipp unit. + """ + if not isinstance(unit, (str, sc.Unit)): + raise TypeError('y_unit must be a string or scipp unit.') + if self.sample_components is not None: + self.sample_components.convert_y_unit(unit) + self._y_unit = str(unit) if isinstance(unit, sc.Unit) else unit @property def sample_components(self) -> ComponentCollection | ModelComponent: diff --git a/src/easydynamics/convolution/numerical_convolution.py b/src/easydynamics/convolution/numerical_convolution.py index e0ddde05..b4b50402 100644 --- a/src/easydynamics/convolution/numerical_convolution.py +++ b/src/easydynamics/convolution/numerical_convolution.py @@ -2,6 +2,7 @@ # SPDX-License-Identifier: BSD-3-Clause import numpy as np +import scipp as sc from scipy.signal import fftconvolve from easydynamics.convolution.numerical_convolution_base import NumericalConvolutionBase @@ -46,18 +47,23 @@ def convolution( model_name='resolution model', ) + # Unit-convert the energy offset to match the energy grid unit. + offset_value = sc.to_unit(self.energy_offset.full_value, self.energy.unit).value + # Evaluate sample model. If called via the Convolution class, # delta functions are already filtered out. sample_vals = self.sample_components.evaluate( self._energy_grid.energy_dense - self._energy_grid.energy_even_length_offset - - self.energy_offset.value + - offset_value ) # Detailed balance correction if self.temperature is not None and self.detailed_balance_settings.use_detailed_balance: detailed_balance_factor_correction = detailed_balance_factor( - energy=self._energy_grid.energy_dense - self.energy_offset.value, + energy=self._energy_grid.energy_dense + - self._energy_grid.energy_even_length_offset + - offset_value, temperature=self.temperature, energy_unit=self.energy.unit, divide_by_temperature=self.detailed_balance_settings.normalize_detailed_balance, @@ -90,7 +96,7 @@ def __repr__(self) -> str: f'{self.__class__.__name__}(' f'display_name={self.display_name!r}, ' f'unique_name={self.unique_name!r}, ' - f'unit={self.unit}, ' + f'x_unit={self.x_unit}, ' f'energy_len={len(self.energy)}, ' f'temperature={self.temperature})' ) diff --git a/src/easydynamics/convolution/numerical_convolution_base.py b/src/easydynamics/convolution/numerical_convolution_base.py index 188a69db..c8a2bcde 100644 --- a/src/easydynamics/convolution/numerical_convolution_base.py +++ b/src/easydynamics/convolution/numerical_convolution_base.py @@ -43,7 +43,8 @@ def __init__( temperature: Parameter | Numeric | None = None, temperature_unit: str | sc.Unit = 'K', detailed_balance_settings: DetailedBalanceSettings | None = None, - unit: str | sc.Unit = 'meV', + x_unit: str | sc.Unit = 'meV', + y_unit: str | sc.Unit = 'dimensionless', display_name: str | None = 'MyConvolution', unique_name: str | None = None, ) -> None: @@ -68,8 +69,10 @@ def __init__( The unit of the temperature parameter. detailed_balance_settings : DetailedBalanceSettings | None, default=None The settings for detailed balance. If None, default settings will be used. - unit : str | sc.Unit, default='meV' - The unit of the energy. + x_unit : str | sc.Unit, default='meV' + The unit of the energy axis. + y_unit : str | sc.Unit, default='dimensionless' + The unit of the model output (intensity). display_name : str | None, default='MyConvolution' Display name of the model. unique_name : str | None, default=None @@ -85,7 +88,8 @@ def __init__( energy=energy, sample_components=sample_components, resolution_components=resolution_components, - unit=unit, + x_unit=x_unit, + y_unit=y_unit, energy_offset=energy_offset, display_name=display_name, unique_name=unique_name, @@ -162,6 +166,7 @@ def energy(self, energy: np.ndarray) -> None: The new energy array. """ ConvolutionBase.energy.fset(self, energy) + self._energy_grid = self._create_energy_grid() self.convolution_settings.convolution_plan_is_valid = False @property @@ -437,12 +442,12 @@ def __repr__(self) -> str: """ return ( f'{self.__class__.__name__}(' - f' energy=array of shape {self.energy.values.shape},\n' - f' sample_components={self.sample_components!r},\n' - f' resolution_components={self.resolution_components!r},\n' - f' unit={self.unit}, ' - f' upsample_factor={self.upsample_factor}, ' - f' extension_factor={self.extension_factor}, ' - f' temperature={self.temperature}, ' - f' detailed_balance={self.detailed_balance_settings!r})' + f'energy=array of shape {self.energy.values.shape},\n ' + f'sample_components={self.sample_components!r}, \n' + f'resolution_components={self.resolution_components!r},\n ' + f'x_unit={self.x_unit}, ' + f'upsample_factor={self.upsample_factor}, ' + f'extension_factor={self.extension_factor}, ' + f'temperature={self.temperature}, ' + f'detailed_balance={self.detailed_balance_settings!r})' ) diff --git a/src/easydynamics/experiment/experiment.py b/src/easydynamics/experiment/experiment.py index a2fb04fe..aea3a71b 100644 --- a/src/easydynamics/experiment/experiment.py +++ b/src/easydynamics/experiment/experiment.py @@ -13,6 +13,7 @@ from easydynamics.base_classes.easydynamics_base import EasyDynamicsBase from easydynamics.utils.utils import _in_notebook +from easydynamics.utils.utils import verify_Q_index class Experiment(EasyDynamicsBase): @@ -239,11 +240,6 @@ def get_masked_energy(self, Q_index: int) -> sc.Variable | None: Q_index : int The Q index to get the masked energy values for. - Raises - ------ - IndexError - If Q_index is not a valid index for the Q values. - Returns ------- sc.Variable | None @@ -252,12 +248,7 @@ def get_masked_energy(self, Q_index: int) -> sc.Variable | None: if self.binned_data is None: return None - if ( - not isinstance(Q_index, int) - or Q_index < 0 - or (self.Q is not None and Q_index >= len(self.Q)) - ): - raise IndexError('Q_index must be a valid index for the Q values.') + verify_Q_index(Q_index, self.Q) energy = self.binned_data.coords['energy'] _, _, _, mask = self._extract_x_y_weights_only_finite(Q_index=Q_index) @@ -265,6 +256,28 @@ def get_masked_energy(self, Q_index: int) -> sc.Variable | None: mask_var = sc.array(dims=['energy'], values=mask) return energy[mask_var] + def get_finite_energy_mask(self, Q_index: int) -> np.ndarray | None: + """ + Get a boolean mask selecting energy points with finite intensity at the given Q index. + + Parameters + ---------- + Q_index : int + The Q index to get the mask for. + + Returns + ------- + np.ndarray | None + Boolean array of length n_energy, or None if no data is loaded. + """ + if self.binned_data is None: + return None + + verify_Q_index(Q_index, self.Q) + + _, _, _, mask = self._extract_x_y_weights_only_finite(Q_index=Q_index) + return mask + ########### # Handle data ########### diff --git a/src/easydynamics/sample_model/background_model.py b/src/easydynamics/sample_model/background_model.py index 34d31393..19ea92cb 100644 --- a/src/easydynamics/sample_model/background_model.py +++ b/src/easydynamics/sample_model/background_model.py @@ -47,7 +47,8 @@ def __init__( self, display_name: str | None = 'MyBackgroundModel', unique_name: str | None = None, - unit: str | sc.Unit = 'meV', + x_unit: str | sc.Unit = 'meV', + y_unit: str | sc.Unit = 'dimensionless', components: ModelComponent | ComponentCollection | None = None, Q: Q_type | None = None, ) -> None: @@ -60,8 +61,10 @@ def __init__( Display name of the model. unique_name : str | None, default=None Unique name of the model. If None, a unique name will be generated. - unit : str | sc.Unit, default='meV' - Unit of the model. + x_unit : str | sc.Unit, default='meV' + Unit of the x-axis (energy, Q, etc.). + y_unit : str | sc.Unit, default='dimensionless' + Unit of the model output (intensity). components : ModelComponent | ComponentCollection | None, default=None Template components of the model. If None, no components are added. These components are copied into ComponentCollections for each Q value. @@ -71,7 +74,8 @@ def __init__( super().__init__( display_name=display_name, unique_name=unique_name, - unit=unit, + x_unit=x_unit, + y_unit=y_unit, components=components, Q=Q, ) @@ -80,7 +84,7 @@ def __repr__(self) -> str: return ( f'{self.__class__.__name__}(' f'unique_name={self.unique_name!r}, ' - f'unit={self.unit}, ' + f'x_unit={self.x_unit}, ' f'Q_len={None if self._Q is None else len(self._Q)}, ' f'components={self.components})' ) diff --git a/src/easydynamics/sample_model/component_collection.py b/src/easydynamics/sample_model/component_collection.py index 54a6b001..6cc60f40 100644 --- a/src/easydynamics/sample_model/component_collection.py +++ b/src/easydynamics/sample_model/component_collection.py @@ -9,14 +9,14 @@ import numpy as np import scipp as sc -from easyscience.variable import DescriptorBase -from easyscience.variable import Parameter from easydynamics.base_classes.easydynamics_list import EasyDynamicsList from easydynamics.base_classes.easydynamics_modelbase import EasyDynamicsModelBase from easydynamics.sample_model.components.model_component import ModelComponent if TYPE_CHECKING: + from easyscience.variable import DescriptorBase + from easydynamics.utils.utils import Numeric @@ -54,7 +54,8 @@ class ComponentCollection(EasyDynamicsList, EasyDynamicsModelBase): def __init__( self, components: ModelComponent | list[ModelComponent] | None = None, - unit: str | sc.Unit = 'meV', + x_unit: str | sc.Unit = 'meV', + y_unit: str | sc.Unit = 'dimensionless', name: str = 'ComponentCollection', display_name: str | None = None, unique_name: str | None = None, @@ -66,8 +67,10 @@ def __init__( ---------- components : ModelComponent | list[ModelComponent] | None, default=None Initial model components to add to the ComponentCollection. - unit : str | sc.Unit, default='meV' - Unit of the collection. + x_unit : str | sc.Unit, default='meV' + Unit of the x-axis (energy, Q, etc.). + y_unit : str | sc.Unit, default='dimensionless' + Unit of the model output (intensity). name : str, default='ComponentCollection' Name of the collection. display_name : str | None, default=None @@ -86,12 +89,14 @@ def __init__( components = [components] elif not isinstance(components, list): raise TypeError( - f'components must be a ModelComponent or a list of ModelComponent, got {type(components).__name__} instead.' # noqa: E501 + f'components must be a ModelComponent or a list of ModelComponent, ' + f'got {type(components).__name__} instead.' ) for comp in components: if not isinstance(comp, ModelComponent): raise TypeError( - f'All items in components must be instances of ModelComponent, got {type(comp).__name__} instead.' # noqa: E501 + f'All items in components must be instances of ModelComponent, ' + f'got {type(comp).__name__} instead.' ) EasyDynamicsList.__init__( @@ -102,7 +107,8 @@ def __init__( EasyDynamicsModelBase.__init__( self, - unit=unit, + x_unit=x_unit, + y_unit=y_unit, name=name, display_name=display_name, unique_name=unique_name, @@ -146,39 +152,72 @@ def is_empty(self, _value: bool) -> None: 'whether the collection has components.' ) - def convert_unit(self, unit: str | sc.Unit) -> None: + # ------------------------------------------------------------------ + # Unit conversion + # ------------------------------------------------------------------ + + def convert_x_unit(self, new_x_unit: str | sc.Unit) -> None: """ - Convert the unit of the ComponentCollection and all its components. + Convert the x-axis unit of the ComponentCollection and all its components. Parameters ---------- - unit : str | sc.Unit - The target unit to convert to. + new_x_unit : str | sc.Unit + The target x-axis unit to convert to. Raises ------ TypeError - If unit is not a string or sc.Unit. + If new_x_unit is not a string or sc.Unit. Exception If any component cannot be converted to the specified unit. """ + if not isinstance(new_x_unit, (str, sc.Unit)): + raise TypeError(f'x_unit must be a string or sc.Unit, got {type(new_x_unit).__name__}') - if not isinstance(unit, (str, sc.Unit)): - raise TypeError(f'Unit must be a string or sc.Unit, got {type(unit).__name__}') + old_unit = self._x_unit + try: + for component in self: + component.convert_x_unit(new_x_unit) + self._x_unit = str(new_x_unit) if isinstance(new_x_unit, sc.Unit) else new_x_unit + except Exception as e: + try: + for component in self: + component.convert_x_unit(old_unit) + except Exception: # noqa: S110 + pass + raise e + + def convert_y_unit(self, new_y_unit: str | sc.Unit) -> None: + """ + Convert the y-axis unit of the ComponentCollection and all its components. - old_unit = self._unit + Parameters + ---------- + new_y_unit : str | sc.Unit + The target y-axis unit to convert to. + + Raises + ------ + TypeError + If new_y_unit is not a string or sc.Unit. + Exception + If any component cannot be converted to the specified unit. + """ + if not isinstance(new_y_unit, (str, sc.Unit)): + raise TypeError(f'y_unit must be a string or sc.Unit, got {type(new_y_unit).__name__}') + old_unit = self._y_unit try: for component in self: - component.convert_unit(unit) - self._unit = unit + component.convert_y_unit(new_y_unit) + self._y_unit = str(new_y_unit) if isinstance(new_y_unit, sc.Unit) else new_y_unit except Exception as e: - # Attempt to rollback on failure try: for component in self: - component.convert_unit(old_unit) + component.convert_y_unit(old_unit) except Exception: # noqa: S110 - pass # Best effort rollback + pass raise e # ------------------------------------------------------------------ @@ -211,7 +250,6 @@ def list_component_names(self) -> list[str]: list[str] List of names of the components in the collection. """ - return [component.name for component in self] def normalize_area(self) -> None: @@ -230,28 +268,28 @@ def normalize_area(self) -> None: raise ValueError('No components in the model to normalize.') area_params = [] - total_area = Parameter(name='total_area', value=0.0, unit=self._unit) for component in self: if hasattr(component, 'area'): area_params.append(component.area) - total_area += component.area else: warnings.warn( f"Component '{component.name}' does not have an 'area' attribute " - f'and will be skipped in normalization.', + 'and will be skipped in normalization.', UserWarning, stacklevel=2, ) - if total_area.value == 0: + total_area_value = sum(p.value for p in area_params) + + if total_area_value == 0: raise ValueError('Total area is zero; cannot normalize.') - if not np.isfinite(total_area.value): + if not np.isfinite(total_area_value): raise ValueError('Total area is not finite; cannot normalize.') for param in area_params: - param.value /= total_area.value + param.value /= total_area_value # ------------------------------------------------------------------ # Other methods @@ -259,17 +297,20 @@ def normalize_area(self) -> None: def get_all_variables(self) -> list[DescriptorBase]: """ - Get all parameters from the model component. + Get all parameters from all model components. Returns ------- list[DescriptorBase] - List of parameters in the component. + List of parameters in the collection. """ - return [var for component in self for var in component.get_all_variables()] - def evaluate(self, x: Numeric | list | np.ndarray | sc.Variable | sc.DataArray) -> np.ndarray: + def evaluate( + self, + x: Numeric | list | np.ndarray | sc.Variable | sc.DataArray, + output: str = 'numpy', + ) -> np.ndarray | sc.Variable: """ Evaluate the sum of all components. @@ -277,22 +318,26 @@ def evaluate(self, x: Numeric | list | np.ndarray | sc.Variable | sc.DataArray) ---------- x : Numeric | list | np.ndarray | sc.Variable | sc.DataArray Energy axis. + output : str, default='numpy' + 'numpy' returns np.ndarray; 'scipp' returns sc.Variable with y_unit. Returns ------- - np.ndarray + np.ndarray | sc.Variable Evaluated model values. """ - if not self: return np.zeros_like(x) - return sum(component.evaluate(x) for component in self) + gen = (component.evaluate(x, output=output) for component in self) + first = next(gen) + return sum(gen, first) def evaluate_component( self, x: Numeric | list | np.ndarray | sc.Variable | sc.DataArray, name: str, - ) -> np.ndarray: + output: str = 'numpy', + ) -> np.ndarray | sc.Variable: """ Evaluate a single component by name. @@ -302,6 +347,8 @@ def evaluate_component( Energy axis. name : str Component name. + output : str, default='numpy' + 'numpy' returns np.ndarray; 'scipp' returns sc.Variable with y_unit. Raises ------ @@ -314,22 +361,17 @@ def evaluate_component( Returns ------- - np.ndarray + np.ndarray | sc.Variable Evaluated values for the specified component. """ if not self: raise ValueError('No components in the model to evaluate.') - if not isinstance(name, str): raise TypeError(f'Component name must be a string, got {type(name)} instead.') - matches = [comp for comp in self if comp.name == name] if not matches: raise KeyError(f"No component named '{name}' exists.") - - component = matches[0] - - return component.evaluate(x) + return matches[0].evaluate(x, output=output) def fix_all_parameters(self) -> None: """Fix all free parameters in the model.""" @@ -376,11 +418,10 @@ def __repr__(self) -> str: String representation of the ComponentCollection. """ comp_names = ', '.join(c.name for c in self) or 'No components' - return ( - f'{self.__class__.__name__}(' - f'name={self.name!r}, unit={self.unit},\n' - f' components=[{comp_names}])' + f"ComponentCollection(name='{self.name}', " + f"x_unit='{self.x_unit}', y_unit='{self.y_unit}',\n" + f'Components: {comp_names})' ) def to_dict(self) -> dict: @@ -395,7 +436,8 @@ def to_dict(self) -> dict: return { '@module': self.__class__.__module__, '@class': self.__class__.__name__, - 'unit': str(self.unit), + 'x_unit': str(self.x_unit), + 'y_unit': str(self.y_unit), 'name': self.name, 'display_name': self.display_name, 'components': [c.to_dict() for c in self._data], @@ -418,28 +460,18 @@ def from_dict(cls, obj_dict: dict) -> ComponentCollection: """ def deserialise_component(d: dict) -> ModelComponent: - """ - Deserialise a component from its dictionary representation. - Parameters - ---------- - d : dict - The dictionary representation of the component. - Returns - ------- - ModelComponent - The deserialised component. - """ module = importlib.import_module(d['@module']) - cls = getattr(module, d['@class']) - return cls.from_dict(d) + klass = getattr(module, d['@class']) + return klass.from_dict(d) - components = [deserialise_component(c) for c in obj_dict.get('components', [])] + components = [deserialise_component(c) for c in obj_dict['components']] return cls( components=components, - unit=obj_dict.get('unit', 'meV'), - name=obj_dict.get('name', 'ComponentCollection'), - display_name=obj_dict.get('display_name'), + x_unit=obj_dict['x_unit'], + y_unit=obj_dict['y_unit'], + name=obj_dict['name'], + display_name=obj_dict['display_name'], ) def __copy__(self) -> ComponentCollection: @@ -451,5 +483,4 @@ def __copy__(self) -> ComponentCollection: ComponentCollection A deep copy of the ComponentCollection. """ - return self.from_dict(self.to_dict()) diff --git a/src/easydynamics/sample_model/components/damped_harmonic_oscillator.py b/src/easydynamics/sample_model/components/damped_harmonic_oscillator.py index 9d4d1ed7..ddb8a8d9 100644 --- a/src/easydynamics/sample_model/components/damped_harmonic_oscillator.py +++ b/src/easydynamics/sample_model/components/damped_harmonic_oscillator.py @@ -6,13 +6,13 @@ from typing import TYPE_CHECKING import numpy as np +import scipp as sc from easydynamics.sample_model.components.mixins import CreateParametersMixin from easydynamics.sample_model.components.model_component import ModelComponent from easydynamics.utils.utils import Numeric if TYPE_CHECKING: - import scipp as sc from easyscience.variable import Parameter @@ -20,8 +20,10 @@ class DampedHarmonicOscillator(CreateParametersMixin, ModelComponent): 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. + $$ 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 ``area``, *x*₀ is ``center``, and *gamma* is ``width``. area has unit = x_unit * + y_unit; center and width have unit = x_unit. Examples -------- @@ -55,56 +57,57 @@ def __init__( area: Numeric = 1.0, center: Numeric = 1.0, width: Numeric = 1.0, - unit: str | sc.Unit = 'meV', + x_unit: str | sc.Unit = 'meV', + y_unit: str | sc.Unit = 'dimensionless', name: str = 'DampedHarmonicOscillator', display_name: str | None = None, unique_name: str | None = None, ) -> None: """ - Initialize the Damped Harmonic Oscillator. + Initialize the Damped Harmonic Oscillator component. Parameters ---------- area : Numeric, default=1.0 - Area under the curve. + Integrated area under the DHO profile. Unit is ``x_unit * y_unit``. center : Numeric, default=1.0 - Resonance frequency, approximately the peak position. + Resonance frequency (x_0) in x_unit. Must be strictly positive; a minimum of + ``DHO_MINIMUM_CENTER`` (1e-10) is enforced. width : Numeric, default=1.0 - Damping constant, approximately the half width at half max (HWHM) of the peaks. By - default, 1.0. - unit : str | sc.Unit, default='meV' - Unit of the parameters. + Damping coefficient (gamma) in x_unit. Must be strictly positive. Approximately equal + to the HWHM of each peak. + x_unit : str | sc.Unit, default='meV' + Unit of the x-axis. center and width are stored in this unit. area_unit = x_unit * + y_unit. + y_unit : str | sc.Unit, default='dimensionless' + Unit of the y-axis (output). name : str, default='DampedHarmonicOscillator' - Name of the component for indexing. + Name used for parameter labelling and serialization. display_name : str | None, default=None - Display name of the component. + Display name shown when plotting. Falls back to *name* if None. unique_name : str | None, default=None - Unique name of the component. If None, a unique_name is automatically generated. By - default, None. + Globally unique identifier. Auto-generated if None. """ - super().__init__( name=name, display_name=display_name, unique_name=unique_name, - unit=unit, + x_unit=x_unit, + y_unit=y_unit, ) - # These methods live in ValidationMixin - area = self._create_area_parameter(area=area, name=name, unit=self._unit) - center = self._create_center_parameter( + # Parameters — getters/setters are defined below + self._area = self._create_area_parameter( + area=area, name=name, x_unit=self._x_unit, y_unit=self._y_unit + ) + self._center = self._create_center_parameter( center=center, name=name, fix_if_none=False, - unit=self._unit, + x_unit=self._x_unit, enforce_minimum_center=True, ) - - width = self._create_width_parameter(width=width, name=name, unit=self._unit) - - self._area = area - self._center = center - self._width = width + self._width = self._create_width_parameter(width=width, name=name, x_unit=self._x_unit) @property def area(self) -> Parameter: @@ -114,24 +117,22 @@ def area(self) -> Parameter: Returns ------- Parameter - The area parameter. + The area Parameter with unit ``x_unit * y_unit``. """ return self._area @area.setter def area(self, value: Numeric) -> None: """ - Set the value of the area parameter. - Parameters ---------- value : Numeric - The new value for the area parameter. + New area value (in current area unit = x_unit * y_unit). Raises ------ TypeError - If the value is not a number. + If *value* is not a numeric type. """ if not isinstance(value, Numeric): raise TypeError('area must be a number') @@ -140,35 +141,32 @@ def area(self, value: Numeric) -> None: @property def center(self) -> Parameter: """ - Get the center parameter. + Get the center parameter (resonance frequency). Returns ------- Parameter - The center parameter. + The resonance frequency (x_0) Parameter with unit ``x_unit``. """ return self._center @center.setter def center(self, value: Numeric) -> None: """ - Set the value of the center parameter. - Parameters ---------- value : Numeric - The new value for the center parameter. + New resonance frequency in x_unit. Must be strictly positive. Raises ------ TypeError - If the value is not a number. + If *value* is not a numeric type. ValueError - If the value is not positive. + If *value* is not positive. """ if not isinstance(value, Numeric): raise TypeError('center must be a number') - if float(value) <= 0: raise ValueError('center must be positive') self._center.value = value @@ -176,81 +174,122 @@ def center(self, value: Numeric) -> None: @property def width(self) -> Parameter: """ - Get the width parameter. + Get the width parameter (damping coefficient). Returns ------- Parameter - The width parameter. + The damping coefficient (gamma) Parameter with unit ``x_unit``. """ return self._width @width.setter def width(self, value: Numeric) -> None: """ - Set the value of the width parameter. - Parameters ---------- value : Numeric - The new value for the width parameter. + New damping coefficient in x_unit. Must be strictly positive. Raises ------ TypeError - If the value is not a number. + If *value* is not a numeric type. ValueError - If the value is not positive. + If *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: + def evaluate( + self, + x: Numeric | list | np.ndarray | sc.Variable | sc.DataArray, + output: str = 'numpy', + ) -> np.ndarray | sc.Variable: r""" - Evaluate the Damped Harmonic Oscillator at the given x values. + Evaluate the DHO at x. - If x is a scipp Variable, the unit of the DHO will be converted 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. + Here *I* is the scattered intensity. Parameters in the model's own units are temporarily + converted to x's unit for the computation — the model is never mutated. Parameters ---------- x : Numeric | list | np.ndarray | sc.Variable | sc.DataArray - The x values at which to evaluate the DHO. + Input x values. + output : str, default='numpy' + 'numpy' returns np.ndarray; 'scipp' returns sc.Variable with y_unit. Returns ------- - np.ndarray - The intensity of the DHO at the given x values. + np.ndarray | sc.Variable + Evaluated DHO values at x. """ + x_vals, detected_unit, dim = self._prepare_x_for_evaluate(x) + eval_unit = detected_unit or self._x_unit + eval_area_unit = str(sc.Unit(eval_unit) * sc.Unit(self._y_unit)) - x = self._prepare_x_for_evaluate(x) + center = self._resolve_param_value(self._center, eval_unit) + width = self._resolve_param_value(self._width, eval_unit) + area = self._resolve_param_value(self._area, eval_area_unit) - normalization = 2 * self.center.value**2 * self.width.value / np.pi - # No division by zero here, width>0 enforced in setter - denominator = (x**2 - self.center.value**2) ** 2 + (2 * self.width.value * x) ** 2 + normalization = 2 * center**2 * width / np.pi + denominator = (x_vals**2 - center**2) ** 2 + (2 * width * x_vals) ** 2 + result = ( + area * normalization / denominator + ) # denominator → 0 when x=0; guarded by DHO_MINIMUM_CENTER on center - return self.area.value * normalization / (denominator) + if output == 'scipp': + return sc.array(dims=[dim], values=result, unit=self._y_unit) + return result - def __repr__(self) -> str: + def convert_x_unit(self, new_x_unit: str | sc.Unit) -> None: """ - Return a string representation of the Damped Harmonic Oscillator. + Convert x-axis parameters (center, width) and area to new_x_unit. - Returns - ------- - str - A string representation of the Damped Harmonic Oscillator. + Parameters + ---------- + new_x_unit : str | sc.Unit + Target x-axis unit. Must be dimensionally compatible with the current x_unit. + + Raises + ------ + TypeError + If *new_x_unit* is not a ``str`` or ``sc.Unit``. + Exception + If the unit conversion fails. On failure the component is rolled back to its original + units. """ + self._convert_x_unit_area_based(new_x_unit, [self._center, self._width], self._area) + + def convert_y_unit(self, new_y_unit: str | sc.Unit) -> None: + """ + Convert the y-axis unit by rescaling the area parameter. + + The area is rescaled from ``x_unit * old_y_unit`` to ``x_unit * new_y_unit``. + + Parameters + ---------- + new_y_unit : str | sc.Unit + Target y-axis unit. + + Raises + ------ + TypeError + If *new_y_unit* is not a ``str`` or ``sc.Unit``. + Exception + If the unit conversion fails. On failure the component is rolled back to its original + units. + """ + self._convert_y_unit_area_based(new_y_unit, self._area) + + def __repr__(self) -> str: return ( - f'{self.__class__.__name__}(' - f'name={self.name!r}, display_name={self.display_name!r}, ' - f'unit={self._unit},\n' - f' area={self.area},\n' - f' center={self.center},\n' - f' width={self.width})' + f'{self.__class__.__name__}(name = {self.name}, display_name = {self.display_name}, ' + f'x_unit = {self._x_unit}, y_unit = {self._y_unit},\n ' + f' area = {self.area},\n ' + f' center = {self.center},\n ' + f' width = {self.width})' ) diff --git a/src/easydynamics/sample_model/components/delta_function.py b/src/easydynamics/sample_model/components/delta_function.py index bca8c423..3fb7cba1 100644 --- a/src/easydynamics/sample_model/components/delta_function.py +++ b/src/easydynamics/sample_model/components/delta_function.py @@ -6,16 +6,15 @@ from typing import TYPE_CHECKING import numpy as np +import scipp as sc from easydynamics.sample_model.components.mixins import CreateParametersMixin from easydynamics.sample_model.components.model_component import ModelComponent from easydynamics.utils.utils import Numeric -EPSILON = 1e-8 # small number to avoid floating point issues - +EPSILON = 1e-8 # tolerance for bin-edge comparisons if TYPE_CHECKING: - import scipp as sc from easyscience.variable import Parameter @@ -24,8 +23,10 @@ class DeltaFunction(CreateParametersMixin, ModelComponent): 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. + handled by the Convolution method. area has unit = x_unit * y_unit; center has unit = x_unit. + + If the center is not provided, it will be centered at 0 and fixed, which is typically what you + want in QENS. Examples -------- @@ -57,7 +58,8 @@ def __init__( self, center: Numeric | None = None, area: Numeric = 1.0, - unit: str | sc.Unit = 'meV', + x_unit: str | sc.Unit = 'meV', + y_unit: str | sc.Unit = 'dimensionless', name: str = 'DeltaFunction', display_name: str | None = None, unique_name: str | None = None, @@ -68,35 +70,35 @@ def __init__( Parameters ---------- center : Numeric | None, default=None - Center of the delta function. If None, it will be centered at 0 and fixed. + Position of the delta function in x_unit. If None, defaults to 0 and the center + parameter is fixed. area : Numeric, default=1.0 - Total area under the curve. - unit : str | sc.Unit, default='meV' - Unit of the parameters. + Integrated area (weight) of the delta function. Unit is ``x_unit * y_unit``. + x_unit : str | sc.Unit, default='meV' + Unit of the x-axis. center is stored in this unit. area_unit = x_unit * y_unit. + y_unit : str | sc.Unit, default='dimensionless' + Unit of the y-axis (output). name : str, default='DeltaFunction' - Name of the component for indexing. + Name used for parameter labelling and serialization. display_name : str | None, default=None - Display name of the component. + Display name shown when plotting. Falls back to *name* if None. unique_name : str | None, default=None - Unique name of the component. If None, a unique_name is automatically generated. By - default, None. + Globally unique identifier. Auto-generated if None. """ - # Validate inputs and create Parameters if not given super().__init__( - unit=unit, + x_unit=x_unit, + y_unit=y_unit, name=name, display_name=display_name, unique_name=unique_name, ) - # These methods live in ValidationMixin - area = self._create_area_parameter(area=area, name=name, unit=self._unit) - center = self._create_center_parameter( - center=center, name=name, fix_if_none=True, unit=self._unit + self._area = self._create_area_parameter( + area=area, name=name, x_unit=self._x_unit, y_unit=self._y_unit + ) + self._center = self._create_center_parameter( + center=center, name=name, fix_if_none=True, x_unit=self._x_unit ) - - self._area = area - self._center = center @property def area(self) -> Parameter: @@ -106,27 +108,23 @@ def area(self) -> Parameter: Returns ------- Parameter - The area parameter. + The area Parameter with unit ``x_unit * y_unit``. """ - return self._area @area.setter def area(self, value: Numeric) -> None: """ - Set the value of the area parameter. - Parameters ---------- value : Numeric - The new value for the area parameter. + New area value (in current area unit = x_unit * y_unit). Raises ------ TypeError - If the value is not a number. + If *value* is not a numeric type. """ - if not isinstance(value, Numeric): raise TypeError('area must be a number') self._area.value = value @@ -139,27 +137,24 @@ def center(self) -> Parameter: Returns ------- Parameter - The center parameter. + The center Parameter with unit ``x_unit``. """ - return self._center @center.setter def center(self, value: Numeric | None) -> None: """ - Set the center parameter value. - Parameters ---------- value : Numeric | None - The new value for the center parameter. If None, defaults to 0 and is fixed. + New center value in x_unit. If None, the center is set to 0 and the parameter is + fixed. Raises ------ TypeError - If the value is not a number or None. + If *value* is not None and not a numeric type. """ - if value is None: value = 0.0 self._center.fixed = True @@ -167,67 +162,108 @@ def center(self, value: Numeric | None) -> None: raise TypeError('center must be a number') self._center.value = value - def evaluate(self, x: Numeric | list | np.ndarray | sc.Variable | sc.DataArray) -> np.ndarray: + def evaluate( + self, + x: Numeric | list | np.ndarray | sc.Variable | sc.DataArray, + output: str = 'numpy', + ) -> np.ndarray | sc.Variable: """ - Evaluate the Delta function at the given x values. + Evaluate the Delta function at x. - 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. + Parameters in the model's own units are temporarily converted to x's unit for the + computation — the model is never mutated. Parameters ---------- x : Numeric | list | np.ndarray | sc.Variable | sc.DataArray - The x values at which to evaluate the Delta function. + Input x values. + output : str, default='numpy' + 'numpy' returns np.ndarray; 'scipp' returns sc.Variable with y_unit. Returns ------- - np.ndarray - The evaluated Delta function at the given x values. + np.ndarray | sc.Variable + Zero everywhere, with a single non-zero bin nearest the center when center falls within + the x range. + + Notes + ----- + The DeltaFunction evaluates to zero everywhere when called directly. In convolutions it + acts as an identity element (handled by the Convolution class). """ + x_vals, detected_unit, dim = self._prepare_x_for_evaluate(x) + eval_unit = detected_unit or self._x_unit + eval_area_unit = str(sc.Unit(eval_unit) * sc.Unit(self._y_unit)) - # x assumed sorted, 1D numpy array - x = self._prepare_x_for_evaluate(x) - model = np.zeros_like(x, dtype=float) - center = self.center.value - area = self.area.value + center = self._resolve_param_value(self._center, eval_unit) + area = self._resolve_param_value(self._area, eval_area_unit) - if x.min() - EPSILON <= center <= x.max() + EPSILON: - # nearest index - i = np.argmin(np.abs(x - center)) + model = np.zeros_like(x_vals, dtype=float) - # left half-width - if i == 0: # noqa: SIM108 - left = x[1] - x[0] if x.size > 1 else 0.5 + if x_vals.min() - EPSILON <= center <= x_vals.max() + EPSILON: + i = np.argmin(np.abs(x_vals - center)) + + if i == 0: + left = x_vals[1] - x_vals[0] if x_vals.size > 1 else 0.5 else: - left = x[i] - x[i - 1] + left = x_vals[i] - x_vals[i - 1] - # right half-width - if i == x.size - 1: # noqa: SIM108 - right = x[-1] - x[-2] if x.size > 1 else 0.5 + if i == x_vals.size - 1: + right = x_vals[-1] - x_vals[-2] if x_vals.size > 1 else 0.5 else: - right = x[i + 1] - x[i] + right = x_vals[i + 1] - x_vals[i] - # effective bin width: half left + half right bin_width = 0.5 * (left + right) - model[i] = area / bin_width + if output == 'scipp': + return sc.array(dims=[dim], values=model, unit=self._y_unit) return model - def __repr__(self) -> str: + def convert_x_unit(self, new_x_unit: str | sc.Unit) -> None: """ - Return a string representation of the Delta function. + Convert x-axis parameters (center) and area to new_x_unit. - Returns - ------- - str - A string representation of the Delta function. + Parameters + ---------- + new_x_unit : str | sc.Unit + Target x-axis unit. Must be dimensionally compatible with the current x_unit. + + Raises + ------ + TypeError + If *new_x_unit* is not a ``str`` or ``sc.Unit``. + Exception + If the unit conversion fails. On failure the component is rolled back to its original + units. """ + self._convert_x_unit_area_based(new_x_unit, [self._center], self._area) + def convert_y_unit(self, new_y_unit: str | sc.Unit) -> None: + """ + Convert the y-axis unit by rescaling the area parameter. + + The area is rescaled from ``x_unit * old_y_unit`` to ``x_unit * new_y_unit``. + + Parameters + ---------- + new_y_unit : str | sc.Unit + Target y-axis unit. + + Raises + ------ + TypeError + If *new_y_unit* is not a ``str`` or ``sc.Unit``. + Exception + If the unit conversion fails. On failure the component is rolled back to its original + units. + """ + self._convert_y_unit_area_based(new_y_unit, self._area) + + def __repr__(self) -> str: return ( - f'{self.__class__.__name__}(' - f'name={self.name!r}, display_name={self.display_name!r}, ' - f'unit={self.unit},\n' - f' area={self.area},\n' - f' center={self.center})' + f'{self.__class__.__name__}(name = {self.name}, display_name = {self.display_name}, ' + f'x_unit = {self.x_unit}, y_unit = {self.y_unit},\n' + f' area = {self.area},\n' + f' center = {self.center})' ) diff --git a/src/easydynamics/sample_model/components/exponential.py b/src/easydynamics/sample_model/components/exponential.py index 8b7e3241..dec84027 100644 --- a/src/easydynamics/sample_model/components/exponential.py +++ b/src/easydynamics/sample_model/components/exponential.py @@ -16,11 +16,10 @@ class Exponential(CreateParametersMixin, ModelComponent): r""" Model of an exponential function. - The intensity is given by + $$ I(x) = A e^{B (x-x_0)} $$ - $$ I(x) = A e^{B (x-x_0)}, $$ - - where $A$ is the amplitude, $x_0$ is the center, and $B$ describes the rate of decay or growth. + where $A$ is the amplitude, $x_0$ is the center, and $B$ is the rate. amplitude has unit = + x_unit * y_unit; center has unit = x_unit; rate has unit = 1/x_unit. Examples -------- @@ -53,7 +52,8 @@ def __init__( amplitude: Numeric = 1.0, center: Numeric | None = None, rate: Numeric = 1.0, - unit: str | sc.Unit = 'meV', + x_unit: str | sc.Unit = 'meV', + y_unit: str | sc.Unit = 'dimensionless', name: str = 'Exponential', display_name: str | None = None, unique_name: str | None = None, @@ -64,57 +64,59 @@ def __init__( Parameters ---------- amplitude : Numeric, default=1.0 - Amplitude of the Exponential. + Pre-exponential factor A. Unit is ``x_unit * y_unit``. center : Numeric | None, default=None - Center of the Exponential. If None, the center is fixed at 0. + Reference point x_0 in x_unit. If None, defaults to 0 and the center parameter is + fixed. rate : Numeric, default=1.0 - Decay or growth constant of the Exponential. - unit : str | sc.Unit, default='meV' - Unit of the parameters. + Exponential rate B in units of ``1/x_unit``. + x_unit : str | sc.Unit, default='meV' + Unit of the x-axis. center is stored in this unit; rate is stored in ``1/x_unit``. + amplitude_unit = x_unit * y_unit. + y_unit : str | sc.Unit, default='dimensionless' + Unit of the y-axis (output). name : str, default='Exponential' - Name of the component for indexing. + Name used for parameter labelling and serialization. display_name : str | None, default=None - Display name of the component. + Display name shown when plotting. Falls back to *name* if None. unique_name : str | None, default=None - Unique name of the component. If None, a unique_name is automatically generated. By - default, None. + Globally unique identifier. Auto-generated if None. Raises ------ TypeError - If amplitude, center, or rate are not numbers or Parameters. + If *amplitude* or *rate* is not numeric. ValueError - If amplitude, center or rate are not finite numbers. + If *amplitude* or *rate* is not finite. """ - # Validate inputs and create Parameters if not given super().__init__( - unit=unit, + x_unit=x_unit, + y_unit=y_unit, name=name, display_name=display_name, unique_name=unique_name, ) - if not isinstance(amplitude, (Parameter, Numeric)): - raise TypeError('amplitude must be a number or a Parameter.') - - if isinstance(amplitude, Numeric): - if not np.isfinite(amplitude): - raise ValueError('amplitude must be a finite number or a Parameter') + x_unit_str = str(x_unit) if isinstance(x_unit, sc.Unit) else x_unit + amplitude_unit = str(sc.Unit(x_unit_str) * sc.Unit(self._y_unit)) - amplitude = Parameter(name=name + ' amplitude', value=float(amplitude), unit=unit) + if not isinstance(amplitude, Numeric): + raise TypeError('amplitude must be a number.') + if not np.isfinite(amplitude): + raise ValueError('amplitude must be finite.') + amplitude = Parameter( + name=name + ' amplitude', value=float(amplitude), unit=amplitude_unit + ) center = self._create_center_parameter( - center=center, name=name, fix_if_none=True, unit=self._unit + center=center, name=name, fix_if_none=True, x_unit=self._x_unit ) - if not isinstance(rate, (Parameter, Numeric)): - raise TypeError('rate must be a number or a Parameter.') - - if isinstance(rate, Numeric): - if not np.isfinite(rate): - raise ValueError('rate must be a finite number or a Parameter') - - rate = Parameter(name=name + ' rate', value=float(rate), unit='1/' + str(unit)) + if not isinstance(rate, Numeric): + raise TypeError('rate must be a number.') + if not np.isfinite(rate): + raise ValueError('rate must be finite.') + rate = Parameter(name=name + ' rate', value=float(rate), unit='1/' + x_unit_str) self._amplitude = amplitude self._center = center @@ -128,27 +130,23 @@ def amplitude(self) -> Parameter: Returns ------- Parameter - The amplitude parameter. + The amplitude Parameter with unit ``x_unit * y_unit``. """ - return self._amplitude @amplitude.setter def amplitude(self, value: Numeric) -> None: """ - Set the value of the amplitude parameter. - Parameters ---------- value : Numeric - The new value for the amplitude parameter. + New amplitude value (in current amplitude unit = x_unit * y_unit). Raises ------ TypeError - If the value is not a number. + If *value* is not a numeric type. """ - if not isinstance(value, Numeric): raise TypeError('amplitude must be a number') self._amplitude.value = value @@ -161,31 +159,27 @@ def center(self) -> Parameter: Returns ------- Parameter - The center parameter. + The center (x_0) Parameter with unit ``x_unit``. """ - return self._center @center.setter def center(self, value: Numeric | None) -> None: """ - Set the center parameter value. - Parameters ---------- value : Numeric | None - The new value for the center parameter. + New center value in x_unit. If None, the center is set to 0 and the parameter is + fixed. Raises ------ TypeError - If the value is not a number. + If *value* is not None and not a numeric type. """ - if value is None: value = 0.0 self._center.fixed = True - if not isinstance(value, Numeric): raise TypeError('center must be a number') self._center.value = value @@ -198,110 +192,130 @@ def rate(self) -> Parameter: Returns ------- Parameter - The rate parameter. + The exponential rate (B) Parameter with unit ``1/x_unit``. """ return self._rate @rate.setter def rate(self, value: Numeric) -> None: """ - Set the rate parameter value. - Parameters ---------- value : Numeric - The new value for the rate parameter. + New exponential rate in ``1/x_unit``. Raises ------ TypeError - If the value is not a number. + If *value* is not a numeric type. """ if not isinstance(value, Numeric): raise TypeError('rate must be a number') - self._rate.value = value def evaluate( self, x: Numeric | list | np.ndarray | sc.Variable | sc.DataArray, - ) -> np.ndarray: + output: str = 'numpy', + ) -> np.ndarray | sc.Variable: r""" - Evaluate the Exponential at the given x values. - - If x is a scipp Variable, the unit of the Exponential will be converted to match x. The - intensity is given by $$ I(x) = A \exp\left( r (x - x_0) \right) $$ + Evaluate the Exponential at x. - where $A$ is the amplitude, $x_0$ is the center, and $r$ is the rate. + Parameters in the model's own units are temporarily converted to x's unit for the + computation — the model is never mutated. Parameters ---------- x : Numeric | list | np.ndarray | sc.Variable | sc.DataArray - The x values at which to evaluate the Exponential. + Input x values. + output : str, default='numpy' + 'numpy' returns np.ndarray; 'scipp' returns sc.Variable with y_unit. Returns ------- - np.ndarray - The intensity of the Exponential at the given x values. + np.ndarray | sc.Variable + Evaluated exponential values at x. """ + x_vals, detected_unit, dim = self._prepare_x_for_evaluate(x) + eval_unit = detected_unit or self._x_unit + eval_area_unit = str(sc.Unit(eval_unit) * sc.Unit(self._y_unit)) + eval_rate_unit = '1/' + str(eval_unit) - x = self._prepare_x_for_evaluate(x) - exponent = self.rate.value * (x - self.center.value) + center = self._resolve_param_value(self._center, eval_unit) + rate = self._resolve_param_value(self._rate, eval_rate_unit) + amplitude = self._resolve_param_value(self._amplitude, eval_area_unit) - return self.amplitude.value * np.exp(exponent) + exponent = rate * (x_vals - center) + result = amplitude * np.exp(exponent) - def convert_unit(self, unit: str | sc.Unit) -> None: + if output == 'scipp': + return sc.array(dims=[dim], values=result, unit=self._y_unit) + return result + + def convert_x_unit(self, new_x_unit: str | sc.Unit) -> None: """ - Convert the unit of the Parameters in the component. + Convert center and amplitude to new_x_unit, rate to 1/new_x_unit. Parameters ---------- - unit : str | sc.Unit - The new unit to convert to. + new_x_unit : str | sc.Unit + Target x-axis unit. Must be dimensionally compatible with the current x_unit. The + rate unit is set to ``1/new_x_unit``. Raises ------ TypeError - If unit is not a string or sc.Unit. + If *new_x_unit* is not a ``str`` or ``sc.Unit``. Exception - If conversion fails for any parameter. + If the unit conversion fails. On failure the component is rolled back to its original + units. """ - - if not isinstance(unit, (str, sc.Unit)): - raise TypeError('unit must be a string or sc.Unit') - - old_unit = self._unit - pars = [self.amplitude, self.center] + if not isinstance(new_x_unit, (str, sc.Unit)): + raise TypeError('x_unit must be a string or sc.Unit') + old_x_unit = self._x_unit + new_x_str = str(new_x_unit) if isinstance(new_x_unit, sc.Unit) else new_x_unit + new_area_unit = str(sc.Unit(new_x_str) * sc.Unit(self._y_unit)) try: - for p in pars: - p.convert_unit(unit) - self.rate.convert_unit('1/' + str(unit)) - self._unit = unit + self._center.convert_unit(new_x_unit) + self._amplitude.convert_unit(new_area_unit) + self._rate.convert_unit('1/' + new_x_str) + self._x_unit = new_x_str except Exception as e: - # Attempt to rollback on failure try: - for p in pars: - p.convert_unit(old_unit) - self.rate.convert_unit('1/' + str(old_unit)) + old_area_unit = str(sc.Unit(old_x_unit) * sc.Unit(self._y_unit)) + self._center.convert_unit(old_x_unit) + self._amplitude.convert_unit(old_area_unit) + self._rate.convert_unit('1/' + str(old_x_unit)) except Exception: # noqa: S110 - pass # Best effort rollback + pass raise e - def __repr__(self) -> str: + def convert_y_unit(self, new_y_unit: str | sc.Unit) -> None: """ - Return a string representation of the Exponential. + Convert the y-axis unit by rescaling the amplitude parameter. - Returns - ------- - str - A string representation of the Exponential. + The amplitude is rescaled from ``x_unit * old_y_unit`` to ``x_unit * new_y_unit``. + + Parameters + ---------- + new_y_unit : str | sc.Unit + Target y-axis unit. + + Raises + ------ + TypeError + If *new_y_unit* is not a ``str`` or ``sc.Unit``. + Exception + If the unit conversion fails. On failure the component is rolled back to its original + units. """ + self._convert_y_unit_area_based(new_y_unit, self._amplitude) + def __repr__(self) -> str: return ( - f'{self.__class__.__name__}(' - f'name={self.name!r}, display_name={self.display_name!r}, ' - f'unit={self._unit},\n' - f' amplitude={self.amplitude},\n' - f' center={self.center},\n' - f' rate={self.rate})' + f'{self.__class__.__name__}(name = {self.name}, display_name = {self.display_name}, ' + f'x_unit = {self._x_unit}, y_unit = {self._y_unit},\n ' + f' amplitude = {self.amplitude},\n ' + f' center = {self.center},\n ' + f' rate = {self.rate})' ) diff --git a/src/easydynamics/sample_model/components/expression_component.py b/src/easydynamics/sample_model/components/expression_component.py index feda61c6..7a7196d7 100644 --- a/src/easydynamics/sample_model/components/expression_component.py +++ b/src/easydynamics/sample_model/components/expression_component.py @@ -3,19 +3,20 @@ from __future__ import annotations +import warnings from typing import TYPE_CHECKING from typing import ClassVar +import scipp as sc import sympy as sp from easyscience.variable import Parameter from scipy.special import erf -from easydynamics.sample_model.components.model_component import ModelComponent -from easydynamics.utils.utils import Numeric - if TYPE_CHECKING: import numpy as np - import scipp as sc + +from easydynamics.sample_model.components.model_component import ModelComponent +from easydynamics.utils.utils import Numeric class ExpressionComponent(ModelComponent): @@ -40,7 +41,7 @@ class ExpressionComponent(ModelComponent): expr = sm.ExpressionComponent( 'A * exp(-(x - x0)**2 / (2*sigma**2))', parameters={'A': 10, 'x0': 0, 'sigma': 1}, - unit='meV', + x_unit='meV', display_name='Gaussian Peak', ) x = np.linspace(-3, 3, 100) @@ -56,16 +57,11 @@ class ExpressionComponent(ModelComponent): ``` """ - # ------------------------- - # Allowed symbolic functions - # ------------------------- _ALLOWED_FUNCS: ClassVar[dict[str, object]] = { - # Exponentials & logs 'exp': sp.exp, 'log': sp.log, 'ln': sp.log, 'sqrt': sp.sqrt, - # Trigonometric 'sin': sp.sin, 'cos': sp.cos, 'tan': sp.tan, @@ -76,22 +72,16 @@ class ExpressionComponent(ModelComponent): 'asin': sp.asin, 'acos': sp.acos, 'atan': sp.atan, - # Hyperbolic 'sinh': sp.sinh, 'cosh': sp.cosh, 'tanh': sp.tanh, - # Misc 'abs': sp.Abs, 'sign': sp.sign, 'floor': sp.floor, 'ceil': sp.ceiling, - # Special functions 'erf': sp.erf, } - # ------------------------- - # Allowed constants - # ------------------------- _ALLOWED_CONSTANTS: ClassVar[dict[str, object]] = { 'pi': sp.pi, 'E': sp.E, @@ -103,7 +93,8 @@ def __init__( self, expression: str, parameters: dict[str, Numeric] | None = None, - unit: str | sc.Unit = 'meV', + x_unit: str | sc.Unit = 'meV', + y_unit: str | sc.Unit = 'dimensionless', name: str = 'Expression', display_name: str | None = None, unique_name: str | None = None, @@ -117,12 +108,14 @@ def __init__( The symbolic expression as a string. Must contain 'x' as the independent variable. parameters : dict[str, Numeric] | None, default=None Dictionary of parameter names and their initial values. - unit : str | sc.Unit, default='meV' - Unit of the output. + x_unit : str | sc.Unit, default='meV' + Unit of the x-axis. + y_unit : str | sc.Unit, default='dimensionless' + Unit of the y-axis (output). name : str, default='Expression' - Name of the component for indexing. + Name used for parameter labelling and serialization. display_name : str | None, default=None - Display name for the component. + Display name shown when plotting. Falls back to *name* if None. unique_name : str | None, default=None Unique name for the component. @@ -133,7 +126,13 @@ def __init__( TypeError If any parameter value is not numeric. """ - super().__init__(unit=unit, name=name, display_name=display_name, unique_name=unique_name) + super().__init__( + x_unit=x_unit, + y_unit=y_unit, + name=name, + display_name=display_name, + unique_name=unique_name, + ) if 'np.' in expression: raise ValueError( @@ -152,21 +151,16 @@ def __init__( except Exception as e: raise ValueError(f'Invalid expression: {expression}') from e - # Extract symbols from the expression symbols = self._expr.free_symbols symbol_names = sorted(str(s) for s in symbols) if 'x' not in symbol_names: raise ValueError("Expression must contain 'x' as independent variable") - # Reject unknown functions early so invalid expressions fail at init, - # not later during numerical evaluation. allowed_function_names = set(self._ALLOWED_FUNCS) | { func.__name__ for func in self._ALLOWED_FUNCS.values() } - # Walk all function-call nodes in the parsed expression (e.g. sin(x), foo(x)). - # Keep only function names that are not in our allowlist. unknown_function_names: set[str] = set() function_atoms = self._expr.atoms(sp.Function) for function_atom in function_atoms: @@ -175,13 +169,11 @@ def __init__( unknown_function_names.add(function_name) unknown_functions = sorted(unknown_function_names) - if unknown_functions: raise ValueError( f'Unsupported function(s) in expression: {", ".join(unknown_functions)}' ) - # Create parameters if parameters is not None and not isinstance(parameters, dict): raise TypeError( f'Parameters must be None or a dictionary, got {type(parameters).__name__}' @@ -205,195 +197,96 @@ def __init__( value = parameters.get(name, 1.0) if isinstance(value, Parameter): self._parameters[name] = value - elif isinstance(value, dict) and value.get('@class') == 'Parameter': self._parameters[name] = Parameter.from_dict(value) else: self._parameters[name] = Parameter( name=name, value=value, - unit=self._unit, + unit=self._x_unit, ) - # Create numerical function ordered_symbols = [sp.Symbol(name) for name in self._symbol_names] - self._func = sp.lambdify( ordered_symbols, self._expr, modules=[{'erf': erf}, 'numpy'], ) - # ------------------------- - # Properties - # ------------------------- - @property def expression(self) -> str: - """ - Return the original expression string. - - Returns - ------- - str - The original expression string provided at initialization. - """ return self._expression_str @expression.setter def expression(self, _new_expr: str) -> None: - """ - Prevent changing the expression after initialization. - - Parameters - ---------- - _new_expr : str - New expression string (ignored). - - Raises - ------ - AttributeError - Always raised to prevent changing the expression. - """ raise AttributeError('Expression cannot be changed after initialization') def evaluate( self, x: Numeric | list | np.ndarray | sc.Variable | sc.DataArray, - ) -> np.ndarray: + output: str = 'numpy', + ) -> np.ndarray | sc.Variable: """ Evaluate the expression for given x values. - Parameters - ---------- - x : Numeric | list | np.ndarray | sc.Variable | sc.DataArray - Input values for the independent variable. - - Returns - ------- - np.ndarray - Evaluated results. + Unit conversion of parameters is not supported for ExpressionComponent. If x has a + different unit than x_unit, a warning is issued and x values are used as-is. """ - x = self._prepare_x_for_evaluate(x) + x_vals, detected_unit, dim = self._prepare_x_for_evaluate(x) + + if detected_unit is not None and detected_unit != str(self._x_unit): + warnings.warn( + f'Input x has unit {detected_unit} but {self.__class__.__name__} has ' + f'x_unit {self._x_unit}. ExpressionComponent cannot auto-convert parameters. ' + 'x values are used as-is.', + UserWarning, + stacklevel=2, + ) args = [] for name in self._symbol_names: if name == 'x': - args.append(x) + args.append(x_vals) else: args.append(self._parameters[name].value) - return self._func(*args) + result = self._func(*args) - def get_all_variables(self) -> list[Parameter]: - """ - Return all parameters. + if output == 'scipp': + return sc.array(dims=[dim], values=result, unit=self._y_unit) + return result - Returns - ------- - list[Parameter] - List of all parameters in the expression. - """ + def get_all_variables(self) -> list[Parameter]: return list(self._parameters.values()) - def convert_unit(self, _new_unit: str | sc.Unit) -> None: - """ - Convert the unit of the expression. - - Unit conversion is not implemented for ExpressionComponent. - - Parameters - ---------- - _new_unit : str | sc.Unit - The new unit to convert to (ignored). - - Raises - ------ - NotImplementedError - Always raised to indicate unit conversion is not supported. - """ - + def convert_x_unit(self, _new_unit: str | sc.Unit) -> None: raise NotImplementedError('Unit conversion is not implemented for ExpressionComponent') - # ------------------------- - # dunder methods - # ------------------------- + def convert_y_unit(self, _new_unit: str | sc.Unit) -> None: + raise NotImplementedError('Unit conversion is not implemented for ExpressionComponent') def __getattr__(self, name: str) -> Parameter: - """ - Allow access to parameters as attributes. - - Parameters - ---------- - name : str - Name of the parameter to access. - - Raises - ------ - AttributeError - If the parameter does not exist. - - Returns - ------- - Parameter - The parameter with the given name. - """ if '_parameters' in self.__dict__ and name in self._parameters: return self._parameters[name] raise AttributeError(f"{self.__class__.__name__} has no attribute '{name}'") def __setattr__(self, name: str, value: Numeric) -> None: - """ - Allow setting parameter values as attributes. - - Parameters - ---------- - name : str - Name of the parameter to set. - value : Numeric - New value for the parameter. - - Raises - ------ - TypeError - If the value is not numeric. - """ if '_parameters' in self.__dict__ and name in self._parameters: param = self._parameters[name] - if not isinstance(value, Numeric): raise TypeError(f'{name} must be numeric') - param.value = value else: - # For other attributes, use default behavior super().__setattr__(name, value) def __dir__(self) -> list[str]: - """ - Include parameter names in dir() output for better IDE support. - - Returns - ------- - list[str] - List of attribute names, including parameters. - """ return super().__dir__() + list(self._parameters.keys()) def __repr__(self) -> str: - """ - Return a string representation of the ExpressionComponent. - - Returns - ------- - str - String representation of the ExpressionComponent. - """ param_str = ', '.join(f'{k}={v.value}' for k, v in self._parameters.items()) return ( - f'{self.__class__.__name__}(' - f'name={self.name!r}, display_name={self.display_name!r}, ' - f'unit={self._unit},\n' - f' expr={self._expression_str!r},\n' - f' parameters={{{param_str}}})' + f'ExpressionComponent(name={self.name}, display_name={self.display_name}, ' + f'x_unit={self._x_unit}, y_unit={self._y_unit},\n' + f" expr='{self._expression_str}',\n" + f' parameters={{ {param_str} }} )' ) diff --git a/src/easydynamics/sample_model/components/gaussian.py b/src/easydynamics/sample_model/components/gaussian.py index 6a1805e1..4aab0343 100644 --- a/src/easydynamics/sample_model/components/gaussian.py +++ b/src/easydynamics/sample_model/components/gaussian.py @@ -6,13 +6,13 @@ from typing import TYPE_CHECKING import numpy as np +import scipp as sc from easydynamics.sample_model.components.mixins import CreateParametersMixin from easydynamics.sample_model.components.model_component import ModelComponent from easydynamics.utils.utils import Numeric if TYPE_CHECKING: - import scipp as sc from easyscience.variable import Parameter @@ -20,12 +20,11 @@ class Gaussian(CreateParametersMixin, ModelComponent): 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. + where $A$ is the area, $x_0$ is the center, and $\sigma$ is the width. area has unit = x_unit * + y_unit; center and width have unit = x_unit. If the center is not provided, it will be centered at 0 and fixed, which is typically what you want in QENS. @@ -62,7 +61,8 @@ def __init__( area: Numeric = 1.0, center: Numeric | None = None, width: Numeric = 1.0, - unit: str | sc.Unit = 'meV', + x_unit: str | sc.Unit = 'meV', + y_unit: str | sc.Unit = 'dimensionless', name: str = 'Gaussian', display_name: str | None = None, unique_name: str | None = None, @@ -73,39 +73,38 @@ def __init__( Parameters ---------- area : Numeric, default=1.0 - Area of the Gaussian. + Integrated area under the Gaussian. Unit is ``x_unit * y_unit``. center : Numeric | None, default=None - Center of the Gaussian. If None, defaults to 0 and is fixed. + Peak position in x_unit. If None, defaults to 0 and the center parameter is fixed. width : Numeric, default=1.0 - Standard deviation. - unit : str | sc.Unit, default='meV' - Unit of the parameters. + Standard deviation (sigma) in x_unit. Must be strictly positive. + x_unit : str | sc.Unit, default='meV' + Unit of the x-axis. center and width are stored in this unit. area_unit = x_unit * + y_unit. + y_unit : str | sc.Unit, default='dimensionless' + Unit of the y-axis (output). name : str, default='Gaussian' - Name of the component for indexing. + Name used for parameter labelling and serialization. display_name : str | None, default=None - Name of the component. + Display name shown when plotting. Falls back to *name* if None. unique_name : str | None, default=None - Unique name of the component. if None, a unique_name is automatically generated. By - default, None. + Globally unique identifier. Auto-generated if None. """ - # Validate inputs and create Parameters if not given super().__init__( - unit=unit, + x_unit=x_unit, + y_unit=y_unit, name=name, display_name=display_name, unique_name=unique_name, ) - # These methods live in ValidationMixin - area = self._create_area_parameter(area=area, name=name, unit=self._unit) - center = self._create_center_parameter( - center=center, name=name, fix_if_none=True, unit=self._unit + self._area = self._create_area_parameter( + area=area, name=name, x_unit=self._x_unit, y_unit=self._y_unit ) - width = self._create_width_parameter(width=width, name=name, unit=self._unit) - - self._area = area - self._center = center - self._width = width + self._center = self._create_center_parameter( + center=center, name=name, fix_if_none=True, x_unit=self._x_unit + ) + self._width = self._create_width_parameter(width=width, name=name, x_unit=self._x_unit) @property def area(self) -> Parameter: @@ -115,27 +114,23 @@ def area(self) -> Parameter: Returns ------- Parameter - The area parameter. + The area Parameter with unit ``x_unit * y_unit``. """ - return self._area @area.setter def area(self, value: Numeric) -> None: """ - Set the value of the area parameter. - Parameters ---------- value : Numeric - The new value for the area parameter. + New area value (in current area unit = x_unit * y_unit). Raises ------ TypeError - If the value is not a number. + If *value* is not a numeric type. """ - if not isinstance(value, Numeric): raise TypeError('area must be a number') self._area.value = value @@ -148,27 +143,24 @@ def center(self) -> Parameter: Returns ------- Parameter - The center parameter. + The center Parameter with unit ``x_unit``. """ - return self._center @center.setter def center(self, value: Numeric | None) -> None: """ - Set the center parameter value. - Parameters ---------- value : Numeric | None - The new value for the center parameter. If None, defaults to 0 and is fixed. + New center value in x_unit. If None, the center is set to 0 and the parameter is + fixed. Raises ------ TypeError - If the value is not a number or None. + If *value* is not None and not a numeric type. """ - if value is None: value = 0.0 self._center.fixed = True @@ -179,86 +171,119 @@ def center(self, value: Numeric | None) -> None: @property def width(self) -> Parameter: """ - Get the width parameter (standard deviation). + Get the width parameter (sigma). Returns ------- Parameter - The width parameter. + The width (sigma) Parameter with unit ``x_unit``. """ return self._width @width.setter def width(self, value: Numeric) -> None: """ - Set the width parameter value. - Parameters ---------- value : Numeric - The new value for the width parameter. + New width value in x_unit. Must be strictly positive. Raises ------ TypeError - If the value is not a number or None. + If *value* is not a numeric type. ValueError - If the value is not positive. + If *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: + output: str = 'numpy', + ) -> np.ndarray | sc.Variable: 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 - 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) $$ + Evaluate the Gaussian at x. - where $A$ is the area, $x_0$ is the center, and $\sigma$ is the width. + Parameters in the model's own units are temporarily converted to x's unit for the + computation — the model is never mutated. Parameters ---------- x : Numeric | list | np.ndarray | sc.Variable | sc.DataArray - The x values at which to evaluate the Gaussian. + output : str, default='numpy' + 'numpy' returns np.ndarray; 'scipp' returns sc.Variable with y_unit. Returns ------- - np.ndarray - The intensity of the Gaussian at the given x values. + np.ndarray | sc.Variable + Evaluated Gaussian values at x. """ + x_vals, detected_unit, dim = self._prepare_x_for_evaluate(x) + eval_unit = detected_unit or self._x_unit + eval_area_unit = str(sc.Unit(eval_unit) * sc.Unit(self._y_unit)) - x = self._prepare_x_for_evaluate(x) + center = self._resolve_param_value(self._center, eval_unit) + width = self._resolve_param_value(self._width, eval_unit) + area = self._resolve_param_value(self._area, eval_area_unit) - normalization = 1 / (np.sqrt(2 * np.pi) * self.width.value) - exponent = -0.5 * ((x - self.center.value) / self.width.value) ** 2 + normalization = 1 / (np.sqrt(2 * np.pi) * width) + exponent = -0.5 * ((x_vals - center) / width) ** 2 + result = area * normalization * np.exp(exponent) - return self.area.value * normalization * np.exp(exponent) + if output == 'scipp': + return sc.array(dims=[dim], values=result, unit=self._y_unit) + return result - def __repr__(self) -> str: + def convert_x_unit(self, new_x_unit: str | sc.Unit) -> None: """ - Return a string representation of the Gaussian. + Convert x-axis parameters (center, width) and area to new_x_unit. - Returns - ------- - str - A string representation of the Gaussian. + Parameters + ---------- + new_x_unit : str | sc.Unit + Target x-axis unit. Must be dimensionally compatible with the current x_unit. + + Raises + ------ + TypeError + If *new_x_unit* is not a ``str`` or ``sc.Unit``. + Exception + If the unit conversion fails. On failure the component is rolled back to its original + units. """ + self._convert_x_unit_area_based(new_x_unit, [self._center, self._width], self._area) + def convert_y_unit(self, new_y_unit: str | sc.Unit) -> None: + """ + Convert the y-axis (output) unit by rescaling the area parameter. + + The area is rescaled from ``x_unit * old_y_unit`` to ``x_unit * new_y_unit``. + + Parameters + ---------- + new_y_unit : str | sc.Unit + Target y-axis unit. + + Raises + ------ + TypeError + If *new_y_unit* is not a ``str`` or ``sc.Unit``. + Exception + If the unit conversion fails. On failure the component is rolled back to its original + units. + """ + self._convert_y_unit_area_based(new_y_unit, self._area) + + def __repr__(self) -> str: return ( - f'{self.__class__.__name__}(' - f'name={self.name!r}, display_name={self.display_name!r}, ' - f'unit={self._unit},\n' - f' area={self.area},\n' - f' center={self.center},\n' - f' width={self.width})' + f'{self.__class__.__name__}(name = {self.name}, display_name = {self.display_name}, ' + f'x_unit = {self._x_unit}, y_unit = {self._y_unit},\n' + f' area = {self.area},\n' + f' center = {self.center},\n' + f' width = {self.width})' ) diff --git a/src/easydynamics/sample_model/components/lorentzian.py b/src/easydynamics/sample_model/components/lorentzian.py index f2da78c8..44c7fd12 100644 --- a/src/easydynamics/sample_model/components/lorentzian.py +++ b/src/easydynamics/sample_model/components/lorentzian.py @@ -6,13 +6,13 @@ from typing import TYPE_CHECKING import numpy as np +import scipp as sc from easydynamics.sample_model.components.mixins import CreateParametersMixin from easydynamics.sample_model.components.model_component import ModelComponent from easydynamics.utils.utils import Numeric if TYPE_CHECKING: - import scipp as sc from easyscience.variable import Parameter @@ -20,9 +20,10 @@ class Lorentzian(CreateParametersMixin, ModelComponent): 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). + $$ 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 HWHM. area has unit = x_unit * + y_unit; center and width have unit = x_unit. If the center is not provided, it will be centered at 0 and fixed, which is typically what you want in QENS. @@ -58,7 +59,8 @@ def __init__( area: Numeric = 1.0, center: Numeric | None = None, width: Numeric = 1.0, - unit: str | sc.Unit = 'meV', + x_unit: str | sc.Unit = 'meV', + y_unit: str | sc.Unit = 'dimensionless', name: str = 'Lorentzian', display_name: str | None = None, unique_name: str | None = None, @@ -69,39 +71,38 @@ def __init__( Parameters ---------- area : Numeric, default=1.0 - Area of the Lorentzian. + Integrated area under the Lorentzian. Unit is ``x_unit * y_unit``. center : Numeric | None, default=None - Center of the Lorentzian. If None, defaults to 0 and is fixed. + Peak position in x_unit. If None, defaults to 0 and the center parameter is fixed. width : Numeric, default=1.0 - Half width at half maximum (HWHM). - unit : str | sc.Unit, default='meV' - Unit of the parameters. + Half-width at half-maximum (HWHM, gamma) in x_unit. Must be strictly positive. + x_unit : str | sc.Unit, default='meV' + Unit of the x-axis. center and width are stored in this unit. area_unit = x_unit * + y_unit. + y_unit : str | sc.Unit, default='dimensionless' + Unit of the y-axis (output). name : str, default='Lorentzian' - Name of the component for indexing. + Name used for parameter labelling and serialization. display_name : str | None, default=None - Display name for the component. + Display name shown when plotting. Falls back to *name* if None. unique_name : str | None, default=None - Unique name of the component. If None, a unique_name is automatically generated. By - default, None. + Globally unique identifier. Auto-generated if None. """ - super().__init__( - unit=unit, + x_unit=x_unit, + y_unit=y_unit, name=name, display_name=display_name, unique_name=unique_name, ) - # These methods live in ValidationMixin - area = self._create_area_parameter(area=area, name=name, unit=self._unit) - center = self._create_center_parameter( - center=center, name=name, fix_if_none=True, unit=self._unit + self._area = self._create_area_parameter( + area=area, name=name, x_unit=self._x_unit, y_unit=self._y_unit ) - width = self._create_width_parameter(width=width, name=name, unit=self._unit) - - self._area = area - self._center = center - self._width = width + self._center = self._create_center_parameter( + center=center, name=name, fix_if_none=True, x_unit=self._x_unit + ) + self._width = self._create_width_parameter(width=width, name=name, x_unit=self._x_unit) @property def area(self) -> Parameter: @@ -111,24 +112,22 @@ def area(self) -> Parameter: Returns ------- Parameter - The area parameter. + The area Parameter with unit ``x_unit * y_unit``. """ return self._area @area.setter def area(self, value: Numeric) -> None: """ - Set the value of the area parameter. - Parameters ---------- value : Numeric - The new value for the area parameter. + New area value (in current area unit = x_unit * y_unit). Raises ------ TypeError - If the value is not a number. + If *value* is not a numeric type. """ if not isinstance(value, Numeric): raise TypeError('area must be a number') @@ -142,26 +141,24 @@ def center(self) -> Parameter: Returns ------- Parameter - The center parameter. + The center Parameter with unit ``x_unit``. """ return self._center @center.setter def center(self, value: Numeric | None) -> None: """ - Set the value of the center parameter. - Parameters ---------- value : Numeric | None - The new value for the center parameter. If None, defaults to 0 and is fixed. + New center value in x_unit. If None, the center is set to 0 and the parameter is + fixed. Raises ------ TypeError - If the value is not a number or None. + If *value* is not None and not a numeric type. """ - if value is None: value = 0.0 self._center.fixed = True @@ -177,78 +174,114 @@ def width(self) -> Parameter: Returns ------- Parameter - The width parameter. + The HWHM (gamma) Parameter with unit ``x_unit``. """ return self._width @width.setter def width(self, value: Numeric) -> None: """ - Set the width parameter value (HWHM). - Parameters ---------- value : Numeric - The new value for the width parameter. + New HWHM value in x_unit. Must be strictly positive. Raises ------ TypeError - If the value is not a number. + If *value* is not a numeric type. ValueError - If the value is not positive. + If *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: + def evaluate( + self, + x: Numeric | list | np.ndarray | sc.Variable | sc.DataArray, + output: str = 'numpy', + ) -> np.ndarray | sc.Variable: 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 - intensity is given by + Evaluate the Lorentzian at x. - $$ 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). + Parameters in the model's own units are temporarily converted to x's unit for the + computation — the model is never mutated. Parameters ---------- x : Numeric | list | np.ndarray | sc.Variable | sc.DataArray - The x values at which to evaluate the Lorentzian. + output : str, default='numpy' + 'numpy' returns np.ndarray; 'scipp' returns sc.Variable with y_unit. Returns ------- - np.ndarray - The intensity of the Lorentzian at the given x values. + np.ndarray | sc.Variable + Evaluated Lorentzian values at x. """ + x_vals, detected_unit, dim = self._prepare_x_for_evaluate(x) + eval_unit = detected_unit or self._x_unit + eval_area_unit = str(sc.Unit(eval_unit) * sc.Unit(self._y_unit)) - x = self._prepare_x_for_evaluate(x) + center = self._resolve_param_value(self._center, eval_unit) + width = self._resolve_param_value(self._width, eval_unit) + area = self._resolve_param_value(self._area, eval_area_unit) - normalization = self.width.value / np.pi - denominator = (x - self.center.value) ** 2 + self.width.value**2 + normalization = width / np.pi + denominator = (x_vals - center) ** 2 + width**2 + result = area * normalization / denominator - return self.area.value * normalization / denominator + if output == 'scipp': + return sc.array(dims=[dim], values=result, unit=self._y_unit) + return result - def __repr__(self) -> str: + def convert_x_unit(self, new_x_unit: str | sc.Unit) -> None: """ - Return a string representation of the Lorentzian. + Convert x-axis parameters (center, width) and area to new_x_unit. - Returns - ------- - str - A string representation of the Lorentzian. + Parameters + ---------- + new_x_unit : str | sc.Unit + Target x-axis unit. Must be dimensionally compatible with the current x_unit. + + Raises + ------ + TypeError + If *new_x_unit* is not a ``str`` or ``sc.Unit``. + Exception + If the unit conversion fails. On failure the component is rolled back to its original + units. """ + self._convert_x_unit_area_based(new_x_unit, [self._center, self._width], self._area) + + def convert_y_unit(self, new_y_unit: str | sc.Unit) -> None: + """ + Convert the y-axis (output) unit by rescaling the area parameter. + + The area is rescaled from ``x_unit * old_y_unit`` to ``x_unit * new_y_unit``. + + Parameters + ---------- + new_y_unit : str | sc.Unit + Target y-axis unit. + + Raises + ------ + TypeError + If *new_y_unit* is not a ``str`` or ``sc.Unit``. + Exception + If the unit conversion fails. On failure the component is rolled back to its original + units. + """ + self._convert_y_unit_area_based(new_y_unit, self._area) + + def __repr__(self) -> str: return ( - f'{self.__class__.__name__}(' - f'name={self.name!r}, display_name={self.display_name!r}, ' - f'unit={self._unit},\n' - f' area={self.area},\n' - f' center={self.center},\n' - f' width={self.width})' + f'{self.__class__.__name__}(name = {self.name}, display_name = {self.display_name}, ' + f'x_unit = {self._x_unit}, y_unit = {self._y_unit},\n' + f' area = {self.area},\n' + f' center = {self.center},\n' + f' width = {self.width})' ) diff --git a/src/easydynamics/sample_model/components/mixins.py b/src/easydynamics/sample_model/components/mixins.py index 7552d2e8..82be533f 100644 --- a/src/easydynamics/sample_model/components/mixins.py +++ b/src/easydynamics/sample_model/components/mixins.py @@ -18,52 +18,54 @@ class CreateParametersMixin: """ Provides parameter creation and validation methods for model components. - This mixin provides methods to create and validate common physics parameters (area, center, - width) with appropriate bounds and type checking. + area_unit = x_unit * y_unit, so when y_unit='dimensionless', area_unit = x_unit. """ def _create_area_parameter( self, area: Numeric, name: str, - unit: str | sc.Unit = 'meV', + x_unit: str | sc.Unit = 'meV', + y_unit: str | sc.Unit = 'dimensionless', minimum_area: float = MINIMUM_AREA, ) -> Parameter: """ - Validate and convert a number to a Parameter describing the area of a function. - - 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. + Create a Parameter for the area with unit = x_unit * y_unit. Parameters ---------- area : Numeric - The area value. + Initial area value. name : str - The name of the model component. - unit : str | sc.Unit, default='meV' - The unit of the area Parameter. + Base name used to label the Parameter (``name + ' area'``). + x_unit : str | sc.Unit, default='meV' + X-axis unit. The resulting area unit is ``x_unit * y_unit``. + y_unit : str | sc.Unit, default='dimensionless' + Y-axis unit. The resulting area unit is ``x_unit * y_unit``. minimum_area : float, default=MINIMUM_AREA - The minimum allowed area. + Lower bound applied to the Parameter when the area is non-negative. When *area* is + negative no lower bound is set and a :class:`UserWarning` is issued. + + Returns + ------- + Parameter + Configured area Parameter with ``unit = x_unit * y_unit``. Raises ------ TypeError - If area is not a number. + If *area* is not a numeric type. ValueError - If area is not a finite number. - - Returns - ------- - Parameter - The validated area Parameter. + If *area* is not finite. """ if not isinstance(area, Numeric): raise TypeError('area must be a number.') if not np.isfinite(area): raise ValueError('area must be a finite number.') - area_param = Parameter(name=name + ' area', value=float(area), unit=unit) + + area_unit = str(sc.Unit(x_unit) * sc.Unit(y_unit)) + area_param = Parameter(name=name + ' area', value=float(area), unit=area_unit) if area_param.value < 0: warnings.warn( @@ -81,36 +83,38 @@ def _create_center_parameter( center: Numeric | None, name: str, fix_if_none: bool, - unit: str | sc.Unit = 'meV', + x_unit: str | sc.Unit = 'meV', enforce_minimum_center: bool = False, ) -> Parameter: """ - Validate and convert a number to a Parameter describing the center of a function. + Create a Parameter for the center with unit = x_unit. Parameters ---------- center : Numeric | None - The center value. + Initial center value. If None, the center is set to 0.0 and ``fixed`` is controlled by + *fix_if_none*. name : str - The name of the model component. + Base name used to label the Parameter (``name + ' center'``). fix_if_none : bool - Whether to fix the center Parameter if center is None. - unit : str | sc.Unit, default='meV' - The unit of the center Parameter. + Whether to fix the Parameter when *center* is None. + x_unit : str | sc.Unit, default='meV' + X-axis unit, applied to the center Parameter. enforce_minimum_center : bool, default=False - Whether to enforce a minimum center value to avoid zero center in DHO. + If True, the Parameter's lower bound is raised to ``DHO_MINIMUM_CENTER`` (1e-10) to + prevent a zero center. + + Returns + ------- + Parameter + Configured center Parameter with ``unit = x_unit``. Raises ------ TypeError - If center is not None or a number. + If *center* is not None and not a numeric type. ValueError - If center is a number but not finite. - - Returns - ------- - Parameter - The validated center Parameter. + If *center* is not None and not finite. """ if center is not None and not isinstance(center, Numeric): raise TypeError('center must be None or a number.') @@ -119,16 +123,18 @@ def _create_center_parameter( center_param = Parameter( name=name + ' center', value=0.0, - unit=unit, + unit=x_unit, fixed=fix_if_none, ) else: if not np.isfinite(center): raise ValueError('center must be None or a finite number.') + center_param = Parameter(name=name + ' center', value=float(center), unit=x_unit) - center_param = Parameter(name=name + ' center', value=float(center), unit=unit) if enforce_minimum_center and center_param.min < DHO_MINIMUM_CENTER: center_param.min = DHO_MINIMUM_CENTER + if center_param.value < DHO_MINIMUM_CENTER: + center_param.value = DHO_MINIMUM_CENTER return center_param def _create_width_parameter( @@ -136,36 +142,37 @@ def _create_width_parameter( width: Numeric, name: str, param_name: str = 'width', - unit: str | sc.Unit = 'meV', + x_unit: str | sc.Unit = 'meV', minimum_width: float = MINIMUM_WIDTH, ) -> Parameter: """ - Validate and convert a number to a Parameter describing the width of a function. + Create a Parameter for the width with unit = x_unit. Parameters ---------- width : Numeric - The width value. + Initial width value. Must be strictly positive (>= *minimum_width*). name : str - The name of the model component. + Base name used to label the Parameter (``name + ' ' + param_name``). param_name : str, default='width' - The name of the width parameter. - unit : str | sc.Unit, default='meV' - The unit of the width Parameter. + Logical name of the parameter used in the label and error messages (e.g. + ``'gaussian_width'``, ``'lorentzian_width'``). + x_unit : str | sc.Unit, default='meV' + X-axis unit, applied to the width Parameter. minimum_width : float, default=MINIMUM_WIDTH - The minimum allowed width. + Absolute lower bound for the width to prevent division-by-zero. + + Returns + ------- + Parameter + Configured width Parameter with ``unit = x_unit`` and ``min = minimum_width``. Raises ------ TypeError - If width is not a number. + If *width* is not a numeric type. ValueError - If width is non-positive. - - Returns - ------- - Parameter - The validated width Parameter. + If *width* is not finite or is smaller than *minimum_width*. """ if not isinstance(width, Numeric): raise TypeError(f'{param_name} must be a number.') @@ -180,6 +187,6 @@ def _create_width_parameter( return Parameter( name=name + ' ' + param_name, value=float(width), - unit=unit, + unit=x_unit, min=minimum_width, ) diff --git a/src/easydynamics/sample_model/components/model_component.py b/src/easydynamics/sample_model/components/model_component.py index 878f501f..293efac7 100644 --- a/src/easydynamics/sample_model/components/model_component.py +++ b/src/easydynamics/sample_model/components/model_component.py @@ -3,8 +3,8 @@ from __future__ import annotations -import warnings from abc import abstractmethod +from typing import TYPE_CHECKING import numpy as np import scipp as sc @@ -13,113 +13,151 @@ from easydynamics.base_classes.easydynamics_modelbase import EasyDynamicsModelBase from easydynamics.utils.utils import Numeric +if TYPE_CHECKING: + from easyscience.variable import Parameter + class ModelComponent(EasyDynamicsModelBase): """Abstract base class for all model components.""" def __init__( self, - unit: str | sc.Unit = 'meV', + x_unit: str | sc.Unit = 'meV', + y_unit: str | sc.Unit = 'dimensionless', name: str = 'ModelComponent', display_name: str | None = None, unique_name: str | None = None, ) -> None: """ - Initialize the ModelComponent. - Parameters ---------- - unit : str | sc.Unit, default='meV' - The unit of the model component. + x_unit : str | sc.Unit, default='meV' + Unit for the x-axis (independent variable) of this component. + y_unit : str | sc.Unit, default='dimensionless' + Unit for the y-axis (dependent variable / output) of this component. name : str, default='ModelComponent' - The name of the model component for indexing. + Internal name used for parameter labelling and logging. display_name : str | None, default=None - A human-readable name for the component. + Human-readable name shown in plots and reports. Falls back to *name* if None. unique_name : str | None, default=None - A unique identifier for the component. + Globally unique identifier. Auto-generated if None. """ super().__init__( - unit=unit, + x_unit=x_unit, + y_unit=y_unit, name=name, display_name=display_name, unique_name=unique_name, ) @property - def unit(self) -> str: + def x_unit(self) -> str: """ - Get the unit. - Returns ------- str - The unit of the model component. + The current x-axis unit as a string. """ - return str(self._unit) + return str(self._x_unit) - @unit.setter - def unit(self, _unit_str: str) -> None: + @x_unit.setter + def x_unit(self, _: str) -> None: """ - Unit is read-only. + Unit is read-only; raises AttributeError always. - Use convert_unit to change the unit between allowed types or create a new ModelComponent - with the desired unit. + Use :meth:`convert_x_unit` to change the unit, or create a new instance with the desired + unit. - Parameters - ---------- - _unit_str : str - The new unit to set. + Raises + ------ + AttributeError + Always raised when this setter is called. + """ + raise AttributeError( + f'x_unit is read-only. Use convert_x_unit to change the unit ' + f'or create a new {self.__class__.__name__} with the desired unit.' + ) + + @property + def y_unit(self) -> str: + """ + Returns + ------- + str + The current y-axis unit as a string. + """ + return str(self._y_unit) + + @y_unit.setter + def y_unit(self, _: str) -> None: + """ + Unit is read-only; raises AttributeError always. + + Use :meth:`convert_y_unit` to change the unit, or create a new instance with the desired + unit. Raises ------ AttributeError - Always raised since unit is read-only. + Always raised when this setter is called. """ raise AttributeError( - f'Unit is read-only. Use convert_unit to change the unit between allowed types ' + f'y_unit is read-only. Use convert_y_unit to change the unit ' f'or create a new {self.__class__.__name__} with the desired unit.' ) def fix_all_parameters(self) -> None: - """Fix all parameters in the model component.""" + """ + Fix all parameters in the model component. - pars = self.get_fittable_parameters() - for p in pars: + Sets ``fixed=True`` on every fittable parameter returned by + :meth:`get_fittable_parameters`. + """ + for p in self.get_fittable_parameters(): p.fixed = True def free_all_parameters(self) -> None: - """Free all parameters in the model component.""" + """ + Free all parameters in the model component. + + Sets ``fixed=False`` on every fittable parameter returned by + :meth:`get_fittable_parameters`. + """ for p in self.get_fittable_parameters(): p.fixed = False def _prepare_x_for_evaluate( self, x: Numeric | list | np.ndarray | sc.Variable | sc.DataArray - ) -> np.ndarray: + ) -> tuple[np.ndarray, str | None, str]: """ - Prepare the input x for evaluation by handling units and converting to a numpy array. + Validate x and extract its values, detected unit, and dimension name. + + x is never converted. When x carries a unit, the caller is responsible for resolving + parameter values to that unit via _resolve_param_value. Parameters ---------- x : Numeric | list | np.ndarray | sc.Variable | sc.DataArray - The input data to prepare. + Input x values to validate and extract. + + Returns + ------- + tuple[np.ndarray, str | None, str] + x_values : np.ndarray of raw float values (no unit conversion) detected_unit : str unit + of x if scipp input, else None dim : scipp dimension name if scipp input, else 'x' 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. - - Returns - ------- - np.ndarray - The prepared input data as a numpy array. + If x has a unit incompatible with the model's x_unit. + ValueError + If x contains NaN or infinite values, or if a DataArray has more than one coordinate. """ + detected_unit: str | None = None + dim: str = 'x' + dim_from_dataarray: bool = False - # Handle units if isinstance(x, sc.DataArray): - # Check that there's exactly one coordinate coords = dict(x.coords) ncoords = len(coords) if ncoords != 1: @@ -128,31 +166,25 @@ def _prepare_x_for_evaluate( f'scipp.DataArray must have exactly one coordinate to be used as input `x`. ' f'Found {ncoords} coordinates: {coord_names}.' ) - # get the coordinate, it's a sc.Variable - _, coord_obj = next(iter(coords.items())) + dim, coord_obj = next(iter(coords.items())) x = coord_obj + dim_from_dataarray = True + if isinstance(x, sc.Variable): - # Need to check if the units are consistent, - # and convert if not. + detected_unit = str(x.unit) + if not dim_from_dataarray: + dim = x.dims[0] if x.dims else 'x' x_in = x.value if x.sizes == {} else x.values - if self._unit is not None and x.unit != self._unit: - self_unit_for_warning = self._unit + + # Validate that x's unit is compatible with model's x_unit + if self._x_unit is not None and detected_unit != str(self._x_unit): try: - self.convert_unit(x.unit.name) + sc.to_unit(sc.scalar(1.0, unit=detected_unit), str(self._x_unit)) except Exception as e: raise UnitError( - f'Input x has unit {x.unit}, but {self.__class__.__name__} component \ - has unit {self._unit}. \ - Failed to convert {self.__class__.__name__} to {x.unit}.' + f'Input x has unit {detected_unit}, which is incompatible with ' + f'{self.__class__.__name__} x_unit {self._x_unit}.' ) from e - - warnings.warn( - f'Input x has unit {x.unit}, but {self.__class__.__name__} component \ - has unit {self_unit_for_warning}. \ - Converting {self.__class__.__name__} to {x.unit}.', - UserWarning, - stacklevel=3, - ) else: x_in = x @@ -167,69 +199,198 @@ def _prepare_x_for_evaluate( if any(np.isinf(x_in)): raise ValueError('Input x contains infinite values.') - return np.sort(x_in) + return x_in, detected_unit, dim - def convert_unit(self, unit: str | sc.Unit) -> None: + def _resolve_param_value(self, param: Parameter, target_unit: str | None) -> float: """ - Convert the unit of the Parameters in the component. + Return param's value converted to target_unit without mutating param. + + If target_unit is None or already matches param's unit, returns param.value directly. Uses + a temporary scipp scalar for the conversion. + + Parameters + ---------- + param : Parameter + The parameter whose value should be resolved. + target_unit : str | None + The unit to which the parameter value should be converted. When None (or equal to the + parameter's own unit) the raw value is returned without any conversion. + + Returns + ------- + float + The parameter value expressed in *target_unit*. + """ + if target_unit is None or str(param.unit) == str(target_unit): + return param.value + return sc.to_unit(sc.scalar(param.value, unit=str(param.unit)), target_unit).value + + def _convert_x_unit_area_based( + self, + new_x_unit: str | sc.Unit, + x_params: list, + area_param: Parameter, + ) -> None: + """ + Shared convert_x_unit logic for components with an area parameter (area = x_unit * y_unit). + + Validates the input type, converts all x-axis parameters and the area parameter to the new + unit, and updates ``_x_unit``. Rolls back all conversions if any step fails. Parameters ---------- - unit : str | sc.Unit - The new unit to convert to. + new_x_unit : str | sc.Unit + Target x-axis unit. + x_params : list + Parameters whose unit equals *x_unit* (e.g. center, width). + area_param : Parameter + The parameter whose unit equals ``x_unit * y_unit``. Raises ------ TypeError - If the provided unit is not a str or sc.Unit. + If *new_x_unit* is not a ``str`` or ``sc.Unit``. Exception - If the provided unit is invalid or incompatible with the component's parameters. + If the conversion fails; all parameters are rolled back to their original units. """ - if not isinstance(unit, (str, sc.Unit)): - raise TypeError(f'Unit must be a string or sc.Unit, got {type(unit).__name__}') + if not isinstance(new_x_unit, (str, sc.Unit)): + raise TypeError(f'x_unit must be a string or sc.Unit, got {type(new_x_unit).__name__}') + old_x_unit = self._x_unit + new_x_str = str(new_x_unit) if isinstance(new_x_unit, sc.Unit) else new_x_unit + new_area_unit = str(sc.Unit(new_x_str) * sc.Unit(self._y_unit)) + try: + for p in x_params: + p.convert_unit(new_x_unit) + area_param.convert_unit(new_area_unit) + self._x_unit = new_x_str + except Exception as e: + try: + old_area_unit = str(sc.Unit(old_x_unit) * sc.Unit(self._y_unit)) + for p in x_params: + p.convert_unit(old_x_unit) + area_param.convert_unit(old_area_unit) + except Exception: # noqa: S110 + pass + raise e + + def _convert_y_unit_area_based( + self, + new_y_unit: str | sc.Unit, + area_param: Parameter, + ) -> None: + """ + Shared convert_y_unit logic for components with an area parameter (area = x_unit * y_unit). + + Validates the input type, rescales the area parameter from ``x_unit * old_y_unit`` to + ``x_unit * new_y_unit``, and updates ``_y_unit``. Rolls back on failure. + + Parameters + ---------- + new_y_unit : str | sc.Unit + Target y-axis unit. + area_param : Parameter + The parameter whose unit equals ``x_unit * y_unit``. + + Raises + ------ + TypeError + If *new_y_unit* is not a ``str`` or ``sc.Unit``. + Exception + If the conversion fails; the area parameter is rolled back to its original unit. + """ + if not isinstance(new_y_unit, (str, sc.Unit)): + raise TypeError(f'y_unit must be a string or sc.Unit, got {type(new_y_unit).__name__}') + old_y_unit = self._y_unit + new_area_unit = str(sc.Unit(self._x_unit) * sc.Unit(new_y_unit)) + try: + area_param.convert_unit(new_area_unit) + self._y_unit = str(new_y_unit) if isinstance(new_y_unit, sc.Unit) else new_y_unit + except Exception as e: + try: + old_area_unit = str(sc.Unit(self._x_unit) * sc.Unit(old_y_unit)) + area_param.convert_unit(old_area_unit) + except Exception: # noqa: S110 + pass + raise e + + def convert_x_unit(self, new_x_unit: str | sc.Unit) -> None: + """ + Convert the x-axis unit of the component. + + The base implementation converts all parameters. Subclasses with mixed-unit parameters + (e.g. area ≠ x_unit) should override this method. - old_unit = self._unit + Parameters + ---------- + new_x_unit : str | sc.Unit + Target x-axis unit. Must be dimensionally compatible with the current x_unit. + + Raises + ------ + TypeError + If *new_x_unit* is not a ``str`` or ``sc.Unit``. + Exception + If the conversion between the current unit and *new_x_unit* fails. On failure the + component is rolled back to its original unit. + """ + if not isinstance(new_x_unit, (str, sc.Unit)): + raise TypeError(f'x_unit must be a string or sc.Unit, got {type(new_x_unit).__name__}') + + old_unit = self._x_unit pars = self.get_all_parameters() try: for p in pars: - p.convert_unit(unit) - self._unit = unit + p.convert_unit(new_x_unit) + self._x_unit = str(new_x_unit) if isinstance(new_x_unit, sc.Unit) else new_x_unit except Exception as e: - # Attempt to rollback on failure try: for p in pars: - if hasattr(p, 'convert_unit'): - p.convert_unit(old_unit) + p.convert_unit(old_unit) except Exception: # noqa: S110 - pass # Best effort rollback + pass raise e - @abstractmethod - def evaluate(self, x: Numeric | list | np.ndarray | sc.Variable | sc.DataArray) -> np.ndarray: + def convert_y_unit(self, new_y_unit: str | sc.Unit) -> None: """ - Abstract method to evaluate the model component at input x. - - Must be implemented by subclasses. + Convert the y-axis (output) unit. Subclasses with an area parameter should override this. Parameters ---------- - x : Numeric | list | np.ndarray | sc.Variable | sc.DataArray - The x values at which to evaluate the component. + new_y_unit : str | sc.Unit + Target y-axis unit. - Returns - ------- - np.ndarray - Evaluated function values. + Raises + ------ + NotImplementedError + Always raised in this base implementation. Subclasses that carry an area parameter + (area_unit = x_unit * y_unit) must override this method to rescale the area + appropriately. """ + raise NotImplementedError(f'{self.__class__.__name__} does not support convert_y_unit.') - def __repr__(self) -> str: + @abstractmethod + def evaluate( + self, + x: Numeric | list | np.ndarray | sc.Variable | sc.DataArray, + output: str = 'numpy', + ) -> np.ndarray | sc.Variable: """ - Return a string representation of the ModelComponent. + Evaluate the model component at input x. + + Parameters + ---------- + x : Numeric | list | np.ndarray | sc.Variable | sc.DataArray + output : str, default='numpy' + 'numpy' returns np.ndarray; 'scipp' returns sc.Variable with y_unit. Returns ------- - str - A string representation of the ModelComponent. + np.ndarray | sc.Variable + Evaluated model values at x. """ - return f'{self.__class__.__name__}(unique_name={self.unique_name!r}, unit={self._unit})' + def __repr__(self) -> str: + return ( + f'{self.__class__.__name__}(unique_name={self.unique_name}, ' + f'x_unit={self._x_unit}, y_unit={self._y_unit})' + ) diff --git a/src/easydynamics/sample_model/components/polynomial.py b/src/easydynamics/sample_model/components/polynomial.py index a36700df..95c221dd 100644 --- a/src/easydynamics/sample_model/components/polynomial.py +++ b/src/easydynamics/sample_model/components/polynomial.py @@ -23,8 +23,10 @@ class Polynomial(ModelComponent): 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. + $$ I(x) = c_0 + c_1 x + c_2 x^2 + ... + c_N x^N $$ + + Coefficients are stored as dimensionless Parameters. When x_unit changes, the coefficient + values are rescaled so the evaluated result stays the same. The output unit is y_unit. Examples -------- @@ -53,39 +55,43 @@ class Polynomial(ModelComponent): def __init__( self, coefficients: Sequence[Numeric | Parameter] = (0.0,), - unit: str | sc.Unit = 'meV', + x_unit: str | sc.Unit = 'meV', + y_unit: str | sc.Unit = 'dimensionless', name: str = 'Polynomial', display_name: str | None = None, unique_name: str | None = None, ) -> None: """ - Initialize the Polynomial component. - Parameters ---------- coefficients : Sequence[Numeric | Parameter], default=(0.0,) - Coefficients c0, c1, ..., cN. - unit : str | sc.Unit, default='meV' - Unit of the Polynomial component. + Ordered list of polynomial coefficients ``[c0, c1, ..., cN]`` where the polynomial is + ``c0 + c1*x + c2*x^2 + ... + cN*x^N``. Each element may be a plain numeric value + (wrapped into a dimensionless :class:`Parameter`) or an existing :class:`Parameter` + instance. Must contain at least one element. + x_unit : str | sc.Unit, default='meV' + Unit of the x-axis. When the x_unit is changed via :meth:`convert_x_unit`, coefficient + values are rescaled by power-law factors so the evaluated output remains unchanged. + y_unit : str | sc.Unit, default='dimensionless' + Unit of the y-axis (output). name : str, default='Polynomial' - Name of the component for indexing. + Name used for parameter labelling and serialization. display_name : str | None, default=None - Display name of the Polynomial component. + Display name shown when plotting. Falls back to *name* if None. unique_name : str | None, default=None - Unique name of the component. If None, a unique_name is automatically generated. By - default, None. + Globally unique identifier. Auto-generated if None. Raises ------ TypeError - If coefficients is not a sequence of numbers or Parameters or if any item in - coefficients is not a number or Parameter. + If *coefficients* is not a list, tuple, or ndarray, or if any element is neither + numeric nor a :class:`Parameter`. ValueError - If coefficients is an empty sequence. + If *coefficients* is empty. """ - super().__init__( - unit=unit, + x_unit=x_unit, + y_unit=y_unit, name=name, display_name=display_name, unique_name=unique_name, @@ -93,17 +99,15 @@ def __init__( if not isinstance(coefficients, (list, tuple, np.ndarray)): raise TypeError( - 'coefficients must be a sequence (list/tuple/ndarray) \ - of numbers or Parameter objects.' + 'coefficients must be a sequence (list/tuple/ndarray) ' + 'of numbers or Parameter objects.' ) if len(coefficients) == 0: raise ValueError('At least one coefficient must be provided.') - # Internal storage of Parameter objects self._coefficients: list[Parameter] = [] - # Coefficients are treated as dimensionless Parameters for i, coef in enumerate(coefficients): if isinstance(coef, Parameter): param = coef @@ -113,45 +117,41 @@ def __init__( raise TypeError('Each coefficient must be either a numeric value or a Parameter.') self._coefficients.append(param) - # Helper scipp scalar to track unit conversions - # (value initialized to 1 with provided unit) - self._unit_conversion_helper = sc.scalar(value=1.0, unit=unit) + # Tracks the current x_unit scale for convert_x_unit power-law rescaling + self._x_unit_helper = sc.scalar(value=1.0, unit=x_unit) @property def coefficients(self) -> list[Parameter]: """ - Get the coefficients of the polynomial as a list of Parameters. - Returns ------- list[Parameter] - The coefficients of the polynomial. + A shallow copy of the internal coefficient list ``[c0, c1, ..., cN]``. Modifying the + returned list does not affect the model; use the setter to replace values. """ return list(self._coefficients) @coefficients.setter def coefficients(self, coeffs: Sequence[Numeric | Parameter]) -> None: """ - Set the coefficients of the polynomial. - - Length must match current number of coefficients. - Parameters ---------- coeffs : Sequence[Numeric | Parameter] - New coefficients as a sequence of numbers or Parameters. + New coefficient values. Must be a list, tuple, or ndarray and must have the same + length as the current number of coefficients. Numeric values update the existing + Parameter's ``.value``; a Parameter instance replaces the stored Parameter entirely. Raises ------ TypeError - If coeffs is not a sequence of numbers or Parameters or if any item in coeffs is not a - number or Parameter. + If *coeffs* is not a list, tuple, or ndarray, or if any element is neither numeric nor + a Parameter. ValueError - If the length of coeffs does not match the existing number of coefficients. + If the length of *coeffs* does not match the current number of coefficients. """ if not isinstance(coeffs, (list, tuple, np.ndarray)): raise TypeError( - 'coefficients must be a sequence (list/tuple/ndarray) of numbers or Parameter .' + 'coefficients must be a sequence (list/tuple/ndarray) of numbers or Parameter.' ) if len(coeffs) != len(self._coefficients): raise ValueError( @@ -159,7 +159,6 @@ def coefficients(self, coeffs: Sequence[Numeric | Parameter]) -> None: ) for i, coef in enumerate(coeffs): if isinstance(coef, Parameter): - # replace parameter self._coefficients[i] = coef elif isinstance(coef, Numeric): self._coefficients[i].value = float(coef) @@ -168,57 +167,20 @@ def coefficients(self, coeffs: Sequence[Numeric | Parameter]) -> None: def coefficient_values(self) -> list[float]: """ - Get the coefficients of the polynomial as a list. - Returns ------- list[float] - The coefficient values of the polynomial. + Current numeric values of all coefficients ``[c0.value, c1.value, ..., cN.value]``. """ return [param.value for param in self._coefficients] - def evaluate(self, x: Numeric | list | np.ndarray | sc.Variable | sc.DataArray) -> np.ndarray: - 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. - - Parameters - ---------- - x : Numeric | list | np.ndarray | sc.Variable | sc.DataArray - The x values at which to evaluate the Polynomial. - - Returns - ------- - np.ndarray - The evaluated Polynomial at the given x values. - """ - - x = self._prepare_x_for_evaluate(x) - - result = np.zeros_like(x, dtype=float) - for i, param in enumerate(self._coefficients): - result += param.value * np.power(x, i) - - if any(result < 0): - warnings.warn( - f'The Polynomial with unique_name {self.unique_name} has negative values, ' - 'which may not be physically meaningful.', - UserWarning, - stacklevel=2, - ) - return result - @property def degree(self) -> int: """ - Get the degree of the polynomial. - Returns ------- int - The degree of the polynomial. + Polynomial degree, equal to ``len(coefficients) - 1``. """ return len(self._coefficients) - 1 @@ -230,73 +192,144 @@ def degree(self, _value: int) -> None: Parameters ---------- _value : int - The new degree of the polynomial. + Ignored; this setter always raises :exc:`AttributeError`. Raises ------ AttributeError - Always raised since degree cannot be set directly. + Always raised when this setter is called. """ raise AttributeError( 'The degree of the polynomial is determined by the number of coefficients ' 'and cannot be set directly.' ) - def get_all_variables(self) -> list[DescriptorBase]: + def evaluate( + self, + x: Numeric | list | np.ndarray | sc.Variable | sc.DataArray, + output: str = 'numpy', + ) -> np.ndarray | sc.Variable: + r""" + Evaluate the Polynomial at x. + + When x has a different unit than the stored x_unit, coefficient values are temporarily + rescaled (same power-law logic as convert_x_unit) without mutation. + + Parameters + ---------- + x : Numeric | list | np.ndarray | sc.Variable | sc.DataArray + output : str, default='numpy' + 'numpy' returns np.ndarray; 'scipp' returns sc.Variable with y_unit. + + Returns + ------- + np.ndarray | sc.Variable + Evaluated polynomial values. """ - Get all variables from the model component. + x_vals, detected_unit, dim = self._prepare_x_for_evaluate(x) + + if detected_unit is not None and detected_unit != str(self._x_unit): + # Temporary coefficient rescaling — no mutation + helper = sc.scalar(1.0, unit=str(self._x_unit)) + helper_in_x = sc.to_unit(helper, detected_unit) + scale = helper.value / helper_in_x.value + coeff_vals = [p.value * scale**i for i, p in enumerate(self._coefficients)] + else: + coeff_vals = [p.value for p in self._coefficients] + + result = np.zeros_like(x_vals, dtype=float) + for i, cv in enumerate(coeff_vals): + result += cv * np.power(x_vals, i) + + if any(result < 0): + warnings.warn( + f'The Polynomial with unique_name {self.unique_name} has negative values, ' + 'which may not be physically meaningful.', + UserWarning, + stacklevel=2, + ) + + if output == 'scipp': + return sc.array(dims=[dim], values=result, unit=self._y_unit) + return result + def get_all_variables(self) -> list[DescriptorBase]: + """ Returns ------- list[DescriptorBase] - List of variables in the component. + The coefficient Parameters that constitute the fittable variables of this polynomial + component. """ return list(self._coefficients) - def convert_unit(self, unit: str | sc.Unit) -> None: + def convert_x_unit(self, new_x_unit: str | sc.Unit) -> None: """ - Convert the unit of the polynomial. + Convert the x-axis unit by rescaling coefficients with power-law factors. + + Each coefficient ``c_i`` is rescaled by ``(old_scale / new_scale) ** i`` so the evaluated + polynomial output is unchanged after the conversion. Parameters ---------- - unit : str | sc.Unit - The target unit to convert to. + new_x_unit : str | sc.Unit + Target x-axis unit. Must be dimensionally compatible with the current x_unit. Raises ------ UnitError - If the provided unit is not a string or sc.Unit. + If *new_x_unit* is not a valid unit string or ``sc.Unit``, or if the conversion between + the current unit and *new_x_unit* fails. """ + if not isinstance(new_x_unit, (str, sc.Unit)): + raise UnitError('new_x_unit must be a string or a scipp unit.') - if not isinstance(unit, (str, sc.Unit)): - raise UnitError('unit must be a string or a scipp unit.') - - # Find out how much the unit changes - # by converting a helper variable - conversion_value_before = self._unit_conversion_helper.value - self._unit_conversion_helper = sc.to_unit(self._unit_conversion_helper, unit=unit) - conversion_value_after = self._unit_conversion_helper.value + conversion_value_before = self._x_unit_helper.value + self._x_unit_helper = sc.to_unit(self._x_unit_helper, unit=new_x_unit) + conversion_value_after = self._x_unit_helper.value for i, param in enumerate(self._coefficients): - param.value *= ( - conversion_value_before / conversion_value_after - ) ** i # set the values directly to the appropriate power + param.value *= (conversion_value_before / conversion_value_after) ** i - self._unit = unit + self._x_unit = str(new_x_unit) if isinstance(new_x_unit, sc.Unit) else new_x_unit - def __repr__(self) -> str: + def convert_y_unit(self, new_y_unit: str | sc.Unit) -> None: """ - Return a string representation of the Polynomial. + Rescale all coefficients so the evaluated output remains the same physical value. - Returns - ------- - str - A string representation of the Polynomial. + All coefficients are multiplied by the conversion factor from ``old_y_unit`` to + ``new_y_unit`` so that ``I(x) [new_y_unit]`` represents the same physical quantity as + ``I(x) [old_y_unit]``. + + Parameters + ---------- + new_y_unit : str | sc.Unit + Target y-axis unit. Must be dimensionally compatible with the current y_unit. + + Raises + ------ + UnitError + If *new_y_unit* is not a valid unit string or ``sc.Unit``, or if the conversion between + the current y_unit and *new_y_unit* fails. """ + if not isinstance(new_y_unit, (str, sc.Unit)): + raise UnitError('new_y_unit must be a string or a scipp unit.') + old_y_unit = str(self._y_unit) if self._y_unit is not None else 'dimensionless' + new_y_str = str(new_y_unit) if isinstance(new_y_unit, sc.Unit) else new_y_unit + + # Compute conversion factor: 1 old_y_unit expressed in new_y_unit + y_helper = sc.scalar(1.0, unit=old_y_unit) + y_helper_new = sc.to_unit(y_helper, new_y_str) + scale = y_helper_new.value / y_helper.value + + for param in self._coefficients: + param.value *= scale + self._y_unit = new_y_str + + def __repr__(self) -> str: coeffs_str = ', '.join(f'{param.name}={param.value}' for param in self._coefficients) return ( - f'{self.__class__.__name__}(' - f'name={self.name!r}, display_name={self.display_name!r}, ' - f'unit={self._unit},\n' - f' coefficients=[{coeffs_str}])' + f'{self.__class__.__name__}(name = {self.name}, display_name = {self.display_name}, ' + f'x_unit = {self._x_unit}, y_unit = {self._y_unit},\n' + f' coefficients = [{coeffs_str}])' ) diff --git a/src/easydynamics/sample_model/components/voigt.py b/src/easydynamics/sample_model/components/voigt.py index 5ca29105..fb187118 100644 --- a/src/easydynamics/sample_model/components/voigt.py +++ b/src/easydynamics/sample_model/components/voigt.py @@ -5,6 +5,7 @@ from typing import TYPE_CHECKING +import scipp as sc from scipy.special import voigt_profile from easydynamics.sample_model.components.mixins import CreateParametersMixin @@ -13,19 +14,19 @@ if TYPE_CHECKING: import numpy as np - import scipp as sc from easyscience.variable import Parameter class Voigt(CreateParametersMixin, ModelComponent): r""" - Voigt profile, a convolution of Gaussian and Lorentzian. + Voigt profile — convolution of Gaussian and Lorentzian. + + Uses ``scipy.special.voigt_profile`` to evaluate the profile. area has unit = x_unit * y_unit; + center, gaussian_width, and lorentzian_width have unit = x_unit. 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. - Examples -------- **Creating a Voigt profile with a fixed center (typical QENS use)** @@ -60,7 +61,8 @@ def __init__( center: Numeric | Parameter | None = None, gaussian_width: Numeric | Parameter = 1.0, lorentzian_width: Numeric | Parameter = 1.0, - unit: str | sc.Unit = 'meV', + x_unit: str | sc.Unit = 'meV', + y_unit: str | sc.Unit = 'dimensionless', name: str = 'Voigt', display_name: str | None = None, unique_name: str | None = None, @@ -71,53 +73,45 @@ def __init__( Parameters ---------- area : Numeric | Parameter, default=1.0 - Total area under the curve. + Integrated area under the Voigt profile. Unit is ``x_unit * y_unit``. center : Numeric | Parameter | None, default=None - Center of the Voigt profile. + Peak position in x_unit. If None, defaults to 0 and the center parameter is fixed. gaussian_width : Numeric | Parameter, default=1.0 - Standard deviation of the Gaussian part. + Gaussian component standard deviation (sigma) in x_unit. Must be strictly positive. lorentzian_width : Numeric | Parameter, default=1.0 - Half width at half max (HWHM) of the Lorentzian part. - unit : str | sc.Unit, default='meV' - Unit of the parameters. + Lorentzian component HWHM (gamma) in x_unit. Must be strictly positive. + x_unit : str | sc.Unit, default='meV' + Unit of the x-axis. center, gaussian_width, and lorentzian_width are stored in this + unit. area_unit = x_unit * y_unit. + y_unit : str | sc.Unit, default='dimensionless' + Unit of the y-axis (output). name : str, default='Voigt' - Name of the component for indexing. + Name used for parameter labelling and serialization. display_name : str | None, default=None - Display name of the component. + Display name shown when plotting. Falls back to *name* if None. unique_name : str | None, default=None - Unique name of the component. If None, a unique_name is automatically generated. By - default, None. + Globally unique identifier. Auto-generated if None. """ - super().__init__( - unit=unit, + x_unit=x_unit, + y_unit=y_unit, name=name, display_name=display_name, unique_name=unique_name, ) - # These methods live in ValidationMixin - area = self._create_area_parameter(area=area, name=name, unit=self._unit) - center = self._create_center_parameter( - center=center, name=name, fix_if_none=True, unit=self._unit + self._area = self._create_area_parameter( + area=area, name=name, x_unit=self._x_unit, y_unit=self._y_unit ) - gaussian_width = self._create_width_parameter( - width=gaussian_width, - name=name, - param_name='gaussian_width', - unit=self._unit, + self._center = self._create_center_parameter( + center=center, name=name, fix_if_none=True, x_unit=self._x_unit ) - lorentzian_width = self._create_width_parameter( - width=lorentzian_width, - name=name, - param_name='lorentzian_width', - unit=self._unit, + self._gaussian_width = self._create_width_parameter( + width=gaussian_width, name=name, param_name='gaussian_width', x_unit=self._x_unit + ) + self._lorentzian_width = self._create_width_parameter( + width=lorentzian_width, name=name, param_name='lorentzian_width', x_unit=self._x_unit ) - - self._area = area - self._center = center - self._gaussian_width = gaussian_width - self._lorentzian_width = lorentzian_width @property def area(self) -> Parameter: @@ -127,24 +121,22 @@ def area(self) -> Parameter: Returns ------- Parameter - The area parameter. + The area Parameter with unit ``x_unit * y_unit``. """ return self._area @area.setter def area(self, value: Numeric) -> None: """ - Set the value of the area parameter. - Parameters ---------- value : Numeric - The new value for the area parameter. + New area value (in current area unit = x_unit * y_unit). Raises ------ TypeError - If the value is not a number. + If *value* is not a numeric type. """ if not isinstance(value, Numeric): raise TypeError('area must be a number') @@ -158,24 +150,23 @@ def center(self) -> Parameter: Returns ------- Parameter - The center parameter. + The center Parameter with unit ``x_unit``. """ return self._center @center.setter def center(self, value: Numeric | None) -> None: """ - Set the value of the center parameter. - Parameters ---------- value : Numeric | None - The new value for the center parameter. If None, defaults to 0 and is fixed. + New center value in x_unit. If None, the center is set to 0 and the parameter is + fixed. Raises ------ TypeError - If the value is not a number. + If *value* is not None and not a numeric type. """ if value is None: value = 0.0 @@ -187,31 +178,29 @@ def center(self, value: Numeric | None) -> None: @property def gaussian_width(self) -> Parameter: """ - Get the Gaussian width parameter. + Get the Gaussian width parameter (sigma). Returns ------- Parameter - The Gaussian width parameter. + The Gaussian component width (sigma) Parameter with unit ``x_unit``. """ return self._gaussian_width @gaussian_width.setter def gaussian_width(self, value: Numeric) -> None: """ - Set the width parameter value. - Parameters ---------- value : Numeric - The new value for the width parameter. + New Gaussian width (sigma) in x_unit. Must be strictly positive. Raises ------ TypeError - If the value is not a number. + If *value* is not a numeric type. ValueError - If the value is not positive. + If *value* is not positive. """ if not isinstance(value, Numeric): raise TypeError('gaussian_width must be a number') @@ -227,26 +216,24 @@ def lorentzian_width(self) -> Parameter: Returns ------- Parameter - The Lorentzian width parameter. + The Lorentzian component HWHM (gamma) Parameter with unit ``x_unit``. """ return self._lorentzian_width @lorentzian_width.setter def lorentzian_width(self, value: Numeric) -> None: """ - Set the value of the Lorentzian width parameter. - Parameters ---------- value : Numeric - The new value for the Lorentzian width parameter. + New Lorentzian HWHM (gamma) in x_unit. Must be strictly positive. Raises ------ TypeError - If the value is not a number. + If *value* is not a numeric type. ValueError - If the value is not positive. + If *value* is not positive. """ if not isinstance(value, Numeric): raise TypeError('lorentzian_width must be a number') @@ -254,48 +241,91 @@ def lorentzian_width(self, value: Numeric) -> None: 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: - 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. + def evaluate( + self, + x: Numeric | list | np.ndarray | sc.Variable | sc.DataArray, + output: str = 'numpy', + ) -> np.ndarray | sc.Variable: + """ + Evaluate the Voigt at x. Model is never mutated for unit differences. Parameters ---------- x : Numeric | list | np.ndarray | sc.Variable | sc.DataArray - The x values at which to evaluate the Voigt. + Input x values. + output : str, default='numpy' + 'numpy' returns np.ndarray; 'scipp' returns sc.Variable with y_unit. Returns ------- - np.ndarray - The intensity of the Voigt at the given x values. + np.ndarray | sc.Variable + Evaluated Voigt profile values at x. + """ + x_vals, detected_unit, dim = self._prepare_x_for_evaluate(x) + eval_unit = detected_unit or self._x_unit + eval_area_unit = str(sc.Unit(eval_unit) * sc.Unit(self._y_unit)) + + center = self._resolve_param_value(self._center, eval_unit) + gw = self._resolve_param_value(self._gaussian_width, eval_unit) + lw = self._resolve_param_value(self._lorentzian_width, eval_unit) + area = self._resolve_param_value(self._area, eval_area_unit) + + result = area * voigt_profile(x_vals - center, gw, lw) + + if output == 'scipp': + return sc.array(dims=[dim], values=result, unit=self._y_unit) + return result + + def convert_x_unit(self, new_x_unit: str | sc.Unit) -> None: """ + Convert x-axis parameters (center, widths) and area to new_x_unit. - x = self._prepare_x_for_evaluate(x) + Parameters + ---------- + new_x_unit : str | sc.Unit + Target x-axis unit. Must be dimensionally compatible with the current x_unit. - return self.area.value * voigt_profile( - x - self.center.value, - self.gaussian_width.value, - self.lorentzian_width.value, + Raises + ------ + TypeError + If *new_x_unit* is not a ``str`` or ``sc.Unit``. + Exception + If the unit conversion fails. On failure the component is rolled back to its original + units. + """ + self._convert_x_unit_area_based( + new_x_unit, + [self._center, self._gaussian_width, self._lorentzian_width], + self._area, ) - def __repr__(self) -> str: + def convert_y_unit(self, new_y_unit: str | sc.Unit) -> None: """ - Return a string representation of the Voigt. + Convert the y-axis unit by rescaling the area parameter. - Returns - ------- - str - A string representation of the Voigt. + The area is rescaled from ``x_unit * old_y_unit`` to ``x_unit * new_y_unit``. + + Parameters + ---------- + new_y_unit : str | sc.Unit + Target y-axis unit. + + Raises + ------ + TypeError + If *new_y_unit* is not a ``str`` or ``sc.Unit``. + Exception + If the unit conversion fails. On failure the component is rolled back to its original + units. """ + self._convert_y_unit_area_based(new_y_unit, self._area) + def __repr__(self) -> str: return ( - f'{self.__class__.__name__}(' - f'name={self.name!r}, display_name={self.display_name!r}, unit={self._unit},\n' - f' area={self.area},\n' - f' center={self.center},\n' - f' gaussian_width={self.gaussian_width},\n' - f' lorentzian_width={self.lorentzian_width})' + f'{self.__class__.__name__}(name = {self.name}, display_name = {self.display_name}, ' + f'x_unit = {self._x_unit}, y_unit = {self._y_unit},\n' + f' area = {self.area},\n' + f' center = {self.center},\n' + f' gaussian_width = {self.gaussian_width},\n' + f' lorentzian_width = {self.lorentzian_width})' ) 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 7255f88a..cfb0c687 100644 --- a/src/easydynamics/sample_model/diffusion_model/brownian_translational_diffusion.py +++ b/src/easydynamics/sample_model/diffusion_model/brownian_translational_diffusion.py @@ -52,7 +52,8 @@ def __init__( scale: Numeric = 1.0, diffusion_coefficient: Numeric = 1.0, Q: Q_type | None = None, - unit: str | sc.Unit = 'meV', + x_unit: str | sc.Unit = 'meV', + y_unit: str | sc.Unit = 'dimensionless', name: str = 'BrownianTranslationalDiffusion', display_name: str | None = 'BrownianTranslationalDiffusion', lorentzian_name: str | None = None, @@ -70,8 +71,10 @@ def __init__( Diffusion coefficient D in m^2/s. Q : Q_type | None, default=None Q values for the model. If None, Q is not set. - unit : str | sc.Unit, default='meV' - Unit of the diffusion model. Must be convertible to meV. + x_unit : str | sc.Unit, default='meV' + Unit of the x-axis (energy/frequency). Must be convertible to meV. + y_unit : str | sc.Unit, default='dimensionless' + Unit of the model output (intensity). Determines scale.unit = x_unit * y_unit. name : str, default='BrownianTranslationalDiffusion' Name of the diffusion model. display_name : str | None, default='BrownianTranslationalDiffusion' @@ -96,7 +99,8 @@ def __init__( """ super().__init__( Q=Q, - unit=unit, + x_unit=x_unit, + y_unit=y_unit, scale=scale, name=name, display_name=display_name, @@ -186,7 +190,7 @@ def calculate_width(self, Q: Q_type | None = None) -> np.ndarray: Q = self._ensure_Q(Q) unit_conversion_factor = self._hbar * self.diffusion_coefficient / (self._angstrom**2) - unit_conversion_factor.convert_unit(self.unit) + unit_conversion_factor.convert_unit(self.x_unit) return Q**2 * unit_conversion_factor.value def calculate_EISF(self, Q: Q_type | None = None) -> np.ndarray: @@ -257,13 +261,15 @@ def create_component_collections( component_collection_list[i] = ComponentCollection( name=f'{self.name}_Q{Q_value:.2f}', display_name=f'{self.display_name}_Q{Q_value:.2f}', - unit=self.unit, + x_unit=self.x_unit, + y_unit=self.y_unit, ) lorentzian_component = Lorentzian( name=self.lorentzian_name, display_name=self.lorentzian_display_name, - unit=self.unit, + x_unit=self.x_unit, + y_unit=self.y_unit, ) # Make the width dependent on Q @@ -273,7 +279,7 @@ def create_component_collections( lorentzian_component.width.make_dependent_on( dependency_expression=dependency_expression, dependency_map=dependency_map, - desired_unit=self.unit, + desired_unit=self.x_unit, ) # Make the area dependent on Q @@ -339,43 +345,6 @@ def _write_width_dependency_map_expression(self) -> dict[str, DescriptorNumber]: 'angstrom': self._angstrom, } - def _write_area_dependency_expression(self, QISF: float) -> str: - """ - Write the dependency expression for the area to make dependent Parameters. - - Parameters - ---------- - QISF : float - Quasielastic Incoherent Scattering Function. - - Raises - ------ - TypeError - If QISF is not a float. - - Returns - ------- - str - Dependency expression for the area. - """ - if not isinstance(QISF, (float)): - raise TypeError('QISF must be a float.') - - return f'{QISF} * scale' - - 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, - } - # ------------------------------------------------------------------ # dunder methods # ------------------------------------------------------------------ diff --git a/src/easydynamics/sample_model/diffusion_model/delta_lorentz.py b/src/easydynamics/sample_model/diffusion_model/delta_lorentz.py index 1e709dbc..258675b2 100644 --- a/src/easydynamics/sample_model/diffusion_model/delta_lorentz.py +++ b/src/easydynamics/sample_model/diffusion_model/delta_lorentz.py @@ -63,7 +63,8 @@ def __init__( lorentzian_width: Numeric = 1.0, allow_Q_variation: dict | None = None, Q: Q_type | None = None, - unit: str | sc.Unit = 'meV', + x_unit: str | sc.Unit = 'meV', + y_unit: str | sc.Unit = 'dimensionless', name: str = 'DeltaLorentz', display_name: str | None = None, lorentzian_name: str = 'Lorentzian', @@ -92,8 +93,10 @@ def __init__( allowed. Q : Q_type | None, default=None Q values for the model. If None, Q is not set. - unit : str | sc.Unit, default='meV' - Unit of the diffusion model. Must be convertible to meV. + x_unit : str | sc.Unit, default='meV' + Unit of the x-axis (energy/frequency). Must be convertible to meV. + y_unit : str | sc.Unit, default='dimensionless' + Unit of the model output (intensity). Determines scale.unit = x_unit * y_unit. name : str, default='DeltaLorentz' Name of the diffusion model. display_name : str | None, default=None @@ -120,7 +123,8 @@ def __init__( """ super().__init__( scale=scale, - unit=unit, + x_unit=x_unit, + y_unit=y_unit, Q=Q, lorentzian_name=lorentzian_name, lorentzian_display_name=lorentzian_display_name, @@ -398,8 +402,18 @@ def calculate_width(self, Q: Q_type = None) -> np.ndarray: ------- np.ndarray HWHM values in the unit of the model (e.g., meV). + + Raises + ------ + ValueError + If Q-variation is enabled but Q has not been set on the model yet. """ if self._allow_Q_variation['lorentzian_width'] is True: + if not self._lorentzian_width_list: + raise ValueError( + 'Lorentzian width Q-variation list is empty. ' + 'Set Q before calling calculate_width.' + ) widths = [lorentzian_width.value for lorentzian_width in self._lorentzian_width_list] return np.array(widths) @@ -484,7 +498,8 @@ def create_component_collections( for i, Q_value in enumerate(Q): component_collection_list[i] = ComponentCollection( display_name=f'{self.display_name}_Q{Q_value:.2f}', - unit=self.unit, + x_unit=self.x_unit, + y_unit=self.y_unit, ) # ------------------------------# @@ -493,7 +508,8 @@ def create_component_collections( lorentzian_component = Lorentzian( name=self.lorentzian_name, display_name=self.lorentzian_display_name, - unit=self.unit, + x_unit=self.x_unit, + y_unit=self.y_unit, ) if self._allow_Q_variation['lorentzian_width'] is True: lorentzian_component._width = self._lorentzian_width_list[i] # noqa: SLF001 @@ -507,7 +523,7 @@ def create_component_collections( lorentzian_component.width.make_dependent_on( dependency_expression=self._write_lorz_width_dependency_expression(Q_value), dependency_map=dependency_map, - desired_unit=self.unit, + desired_unit=self.x_unit, ) # The area is always a dependent parameter in this model, as # it depends on the scale, mean_u_squared and A_1 parameters @@ -534,7 +550,8 @@ def create_component_collections( delta_component = DeltaFunction( name=self.delta_name, display_name=self.delta_display_name, - unit=self.unit, + x_unit=self.x_unit, + y_unit=self.y_unit, ) if self._allow_Q_variation['A_0'] is True: @@ -814,7 +831,7 @@ def _create_lorentzian_width_parameter(self, lorentzian_width: Numeric) -> Param value=float(lorentzian_width), fixed=False, min=MINIMUM_WIDTH, - unit=self.unit, + unit=self.x_unit, ) def _create_A0_A1_parameter_lists( @@ -881,7 +898,7 @@ def _create_lorentzian_width_parameter_list( value=float(lorentzian_width.value), fixed=False, min=MINIMUM_WIDTH, - unit=self.unit, + unit=self.x_unit, ) for _ in self.Q ] @@ -1076,10 +1093,10 @@ def __repr__(self) -> str: String representation of the DeltaLorentz model. """ return ( - f'{self.__class__.__name__}(' - f'display_name={self.display_name!r}, unit={self.unit},\n' - f' mean_u_squared={self.mean_u_squared},\n' - f' A_0={self.A_0}, A_1={self.A_1},\n' - f' lorentzian_width={self.lorentzian_width},\n' + f'DeltaLorentz(display_name={self.display_name},' + f'x_unit={self.x_unit}, \n' + f' mean_u_squared={self.mean_u_squared}, \n' + f' A_0={self.A_0}, A_1={self.A_1}, \n' + f' lorentzian_width={self.lorentzian_width}, \n' f' scale={self.scale})' ) diff --git a/src/easydynamics/sample_model/diffusion_model/diffusion_model_base.py b/src/easydynamics/sample_model/diffusion_model/diffusion_model_base.py index 7d752b82..978230f1 100644 --- a/src/easydynamics/sample_model/diffusion_model/diffusion_model_base.py +++ b/src/easydynamics/sample_model/diffusion_model/diffusion_model_base.py @@ -21,7 +21,8 @@ def __init__( self, scale: Numeric = 1.0, Q: Q_type | None = None, - unit: str | sc.Unit = 'meV', + x_unit: str | sc.Unit = 'meV', + y_unit: str | sc.Unit = 'dimensionless', name: str = 'DiffusionModel', display_name: str | None = 'DiffusionModel', lorentzian_name: str | None = None, @@ -34,11 +35,14 @@ def __init__( Parameters ---------- scale : Numeric, default=1.0 - Scale factor for the diffusion model. Must be a non-negative number. + Scale factor for the diffusion model. Must be a non-negative number. Its unit equals + area_unit = x_unit * y_unit because scale * QISF/EISF (dimensionless) = component area. Q : Q_type | None, default=None Q values for the model. If None, Q is not set. - unit : str | sc.Unit, default='meV' - Unit of the diffusion model. Must be convertible to meV. + x_unit : str | sc.Unit, default='meV' + Unit of the x-axis (energy/frequency). Must be convertible to meV. + y_unit : str | sc.Unit, default='dimensionless' + Unit of the model output (intensity). Together with x_unit determines area_unit. name : str, default='DiffusionModel' Name of the diffusion model. display_name : str | None, default='DiffusionModel' @@ -58,7 +62,7 @@ def __init__( 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 x_unit is not a string or scipp Unit, or if it cannot be converted to meV. ValueError If scale is negative. """ @@ -66,11 +70,11 @@ def __init__( self._Q = _validate_and_convert_Q(Q) try: - test = DescriptorNumber(name='test', value=1, unit=unit) + test = DescriptorNumber(name='test', value=1, unit=x_unit) test.convert_unit('meV') except Exception as e: raise UnitError( - f'Invalid unit: {unit}. Unit must be a string or scipp Unit and convertible to meV.' # noqa: E501 + f'Invalid unit: {x_unit}. Unit must be a string or scipp Unit and convertible to meV.' # noqa: E501 ) from e if not isinstance(scale, Numeric): @@ -79,10 +83,17 @@ def __init__( if float(scale) < 0: raise ValueError('scale must be non-negative.') - scale = Parameter(name='scale', value=float(scale), fixed=False, min=0.0, unit=unit) + area_unit = str(sc.Unit(x_unit) * sc.Unit(y_unit)) + scale = Parameter(name='scale', value=float(scale), fixed=False, min=0.0, unit=area_unit) self._scale = scale - super().__init__(unit=unit, name=name, display_name=display_name, unique_name=unique_name) + super().__init__( + x_unit=x_unit, + y_unit=y_unit, + name=name, + display_name=display_name, + unique_name=unique_name, + ) if lorentzian_name is None: lorentzian_name = name @@ -102,7 +113,10 @@ def __init__( if self.Q is None: self._component_collections = [] else: - self._component_collections = [ComponentCollection()] * len(self.Q) + self._component_collections = [ + ComponentCollection(x_unit=self.x_unit, y_unit=self.y_unit) + for _ in range(len(self.Q)) + ] # ------------------------------------------------------------------ # Properties @@ -448,7 +462,9 @@ def create_component_collections(self) -> list[ComponentCollection]: self._component_collections = [] return self._component_collections - self._component_collections = [ComponentCollection()] * len(self.Q) + self._component_collections = [ + ComponentCollection(x_unit=self.x_unit, y_unit=self.y_unit) for _ in range(len(self.Q)) + ] return self._component_collections @@ -493,6 +509,40 @@ def get_component_collections( # private methods # ------------------------------------------------------------------ + def _write_area_dependency_expression(self, QISF: float) -> str: + """ + Write the dependency expression for the Lorentzian area. + + Parameters + ---------- + QISF : float + Q-dependent incoherent scattering function value. + + Raises + ------ + TypeError + If QISF is not a float. + + Returns + ------- + str + Dependency expression for the area. + """ + if not isinstance(QISF, float): + raise TypeError('QISF must be a float.') + return f'{QISF} * scale' + + def _write_area_dependency_map_expression(self) -> dict[str, DescriptorNumber]: + """ + Write the dependency map for the Lorentzian area. + + Returns + ------- + dict[str, DescriptorNumber] + Dependency map for the area. + """ + return {'scale': self.scale} + def _on_Q_change(self) -> None: """Handle changes to the Q values.""" self.create_component_collections() @@ -538,8 +588,7 @@ def __repr__(self) -> str: String representation of the DiffusionModel. """ return ( - f'{self.__class__.__name__}(' - f'name={self.name!r}, display_name={self.display_name!r}, ' - f'unit={self.unit},\n' + f'{self.__class__.__name__}(name={self.name}, display_name={self.display_name}, ' + f'x_unit={self.x_unit}), \n' f' scale={self.scale})' ) diff --git a/src/easydynamics/sample_model/diffusion_model/jump_translational_diffusion.py b/src/easydynamics/sample_model/diffusion_model/jump_translational_diffusion.py index 0cd8a828..a0cfeb09 100644 --- a/src/easydynamics/sample_model/diffusion_model/jump_translational_diffusion.py +++ b/src/easydynamics/sample_model/diffusion_model/jump_translational_diffusion.py @@ -56,7 +56,8 @@ def __init__( diffusion_coefficient: Numeric = 1.0, relaxation_time: Numeric = 1.0, Q: Q_type | None = None, - unit: str | sc.Unit = 'meV', + x_unit: str | sc.Unit = 'meV', + y_unit: str | sc.Unit = 'dimensionless', name: str = 'JumpTranslationalDiffusion', display_name: str | None = 'JumpTranslationalDiffusion', lorentzian_name: str | None = None, @@ -76,8 +77,10 @@ def __init__( Relaxation time t in ps. Q : Q_type | None, default=None Q values for the model. If None, Q is not set. - unit : str | sc.Unit, default='meV' - Unit of the diffusion model. Must be convertible to meV. + x_unit : str | sc.Unit, default='meV' + Unit of the x-axis (energy/frequency). Must be convertible to meV. + y_unit : str | sc.Unit, default='dimensionless' + Unit of the model output (intensity). Determines scale.unit = x_unit * y_unit. name : str, default='JumpTranslationalDiffusion' Name of the diffusion model. display_name : str | None, default='JumpTranslationalDiffusion' @@ -101,7 +104,8 @@ def __init__( """ super().__init__( Q=Q, - unit=unit, + x_unit=x_unit, + y_unit=y_unit, scale=scale, name=name, display_name=display_name, @@ -246,7 +250,7 @@ def calculate_width(self, Q: Q_type | None = None) -> np.ndarray: unit_conversion_factor_numerator = ( self._hbar * self.diffusion_coefficient / (self._angstrom**2) ) - unit_conversion_factor_numerator.convert_unit(self.unit) + unit_conversion_factor_numerator.convert_unit(self.x_unit) numerator = unit_conversion_factor_numerator.value * Q**2 @@ -323,13 +327,15 @@ def create_component_collections( component_collection_list[i] = ComponentCollection( name=f'{self.name}_Q{Q_value:.2f}', display_name=f'{self.display_name}_Q{Q_value:.2f}', - unit=self.unit, + x_unit=self.x_unit, + y_unit=self.y_unit, ) lorentzian_component = Lorentzian( name=self.lorentzian_name, display_name=self.lorentzian_display_name, - unit=self.unit, + x_unit=self.x_unit, + y_unit=self.y_unit, ) # Make the width dependent on Q @@ -339,7 +345,7 @@ def create_component_collections( lorentzian_component.width.make_dependent_on( dependency_expression=dependency_expression, dependency_map=dependency_map, - desired_unit=self.unit, + desired_unit=self.x_unit, ) # Make the area dependent on Q @@ -405,44 +411,6 @@ def _write_width_dependency_map_expression(self) -> dict[str, DescriptorNumber]: 'angstrom': self._angstrom, } - def _write_area_dependency_expression(self, QISF: float) -> str: - """ - Write the dependency expression for the area to make dependent Parameters. - - Parameters - ---------- - QISF : float - Q-dependent intermediate scattering function. - - Raises - ------ - TypeError - If QISF is not a float. - - Returns - ------- - str - Dependency expression for the area. - """ - - if not isinstance(QISF, (float)): - raise TypeError('QISF must be a float.') - - return f'{QISF} * scale' - - 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, - } - ################################ # dunder methods ################################ diff --git a/src/easydynamics/sample_model/instrument_model.py b/src/easydynamics/sample_model/instrument_model.py index 24c3f171..2f5cb0f9 100644 --- a/src/easydynamics/sample_model/instrument_model.py +++ b/src/easydynamics/sample_model/instrument_model.py @@ -60,7 +60,7 @@ def __init__( resolution_model: ResolutionModel | SampleModel | None = None, background_model: BackgroundModel | None = None, energy_offset: Numeric | None = None, - unit: str | sc.Unit = 'meV', + x_unit: str | sc.Unit = 'meV', ) -> None: """ Initialize an InstrumentModel. @@ -83,7 +83,7 @@ def __init__( energy_offset : Numeric | None, default=None Template energy offset of the instrument. Will be copied to each Q value. If None, the energy offset will be 0. - unit : str | sc.Unit, default='meV' + x_unit : str | sc.Unit, default='meV' The unit of the energy axis. Raises @@ -97,7 +97,7 @@ def __init__( unique_name=unique_name, ) - self._unit = _validate_unit(unit) + self._x_unit = _validate_unit(x_unit) if resolution_model is None: self._resolution_model = ResolutionModel() @@ -130,7 +130,7 @@ def __init__( self._energy_offset = Parameter( name='energy_offset', value=float(energy_offset), - unit=self.unit, + unit=self.x_unit, fixed=False, ) self._energy_offsets: list = [] @@ -189,7 +189,6 @@ def background_model(self) -> BackgroundModel: BackgroundModel The background model of the instrument. """ - return self._background_model @background_model.setter @@ -207,7 +206,6 @@ def background_model(self, value: BackgroundModel) -> None: 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__}' @@ -263,61 +261,56 @@ def Q(self, value: Q_type | None) -> None: ) @property - def unit(self) -> str | sc.Unit: + def x_unit(self) -> str | sc.Unit: """ - Get the unit of the InstrumentModel. + Get the x-axis unit of the InstrumentModel. Returns ------- str | sc.Unit - The unit of the InstrumentModel. + The x-axis unit of the InstrumentModel. """ - return self._unit + return self._x_unit - @unit.setter - def unit(self, _unit_str: str) -> None: + @x_unit.setter + def x_unit(self, _: 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. + x_unit is read-only and cannot be set directly. - Parameters - ---------- - _unit_str : str - The new unit for the InstrumentModel (ignored). + Use convert_x_unit to change the unit between allowed types or create a new InstrumentModel + with the desired unit. Raises ------ AttributeError - Always, as the unit is read-only. + Always, as x_unit is read-only. """ raise AttributeError( - f'Unit is read-only. Use convert_unit to change the unit between allowed types ' + f'x_unit is read-only. Use convert_x_unit to change the unit between allowed types ' f'or create a new {self.__class__.__name__} with the desired unit.' ) @property def energy_offset(self) -> Parameter: """ - Get the energy offset template parameter of the instrument model. + Get the template energy offset of the instrument. Returns ------- Parameter - The energy offset template parameter of the instrument model. + The energy offset Parameter. Each Q value gets its own copy via get_energy_offset(). """ return self._energy_offset @energy_offset.setter def energy_offset(self, value: Numeric) -> None: """ - Set the offset parameter of the instrument model. + Set the template energy offset value, propagating to all Q-specific offsets. Parameters ---------- value : Numeric - The new value for the energy offset parameter. Will be copied to all Q values. + The new energy offset value in x_unit. Raises ------ @@ -327,7 +320,6 @@ def energy_offset(self, value: Numeric) -> None: if not isinstance(value, Numeric): raise TypeError(f'energy_offset must be a number, got {type(value).__name__}') self._energy_offset.value = value - self._on_energy_offset_change() # -------------------------------------------------------------- @@ -335,20 +327,6 @@ def energy_offset(self, value: Numeric) -> None: # -------------------------------------------------------------- def clear_Q(self, confirm: bool = False) -> None: - """ - Clear the Q values of the InstrumentModel and any associated ResolutionModel and - BackgroundModel, removing all component collections and their associated Parameters. - - Parameters - ---------- - confirm : bool, default=False - Confirmation to clear Q values. - - Raises - ------ - ValueError - If confirm is not True. - """ if not confirm: raise ValueError( 'Clearing Q values requires confirmation. Set confirm=True to proceed.' @@ -358,57 +336,21 @@ def clear_Q(self, confirm: bool = False) -> None: self.resolution_model.clear_Q(confirm=True) self._on_Q_change() - def convert_unit(self, unit_str: str | sc.Unit) -> None: - """ - Convert the unit of the InstrumentModel. - - Parameters - ---------- - unit_str : str | sc.Unit - The unit to convert to. - - Raises - ------ - ValueError - If unit_str is not a valid unit string or scipp Unit. - """ - unit = _validate_unit(unit_str) + def convert_x_unit(self, x_unit: str | sc.Unit) -> None: + unit = _validate_unit(x_unit) if unit is None: - raise ValueError('unit_str must be a valid unit string or scipp Unit') + raise ValueError('x_unit must be a valid unit string or scipp Unit') - self._background_model.convert_unit(unit) - self._resolution_model.convert_unit(unit) - self._energy_offset.convert_unit(unit) + self._background_model.convert_x_unit(unit) + self._resolution_model.convert_x_unit(unit) self._ensure_energy_offsets_current() + self._energy_offset.convert_unit(unit) for offset in self._energy_offsets: offset.convert_unit(unit) - self._unit = unit + self._x_unit = unit def get_all_variables(self, Q_index: int | None = None) -> list[Parameter]: - """ - Get all variables in the InstrumentModel. - - Parameters - ---------- - Q_index : int | None, default=None - The index of the Q value to get variables for. If None, get variables for all Q values. - - - 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. - - 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. - """ if self._Q is None: return [] @@ -430,45 +372,18 @@ def get_all_variables(self, Q_index: int | None = None) -> list[Parameter]: return variables def fix_resolution_parameters(self) -> None: - """Fix all parameters in the resolution model.""" self.resolution_model.fix_all_parameters() def free_resolution_parameters(self) -> None: - """Free all parameters in the resolution model.""" self.resolution_model.free_all_parameters() def normalize_resolution(self) -> None: - """Normalize the resolution model to have area 1.""" self.resolution_model.normalize_area() def get_energy_offset( self, Q_index: int | None = None, ) -> Parameter | list[Parameter]: - """ - Get the energy offset Parameter at a specific Q index. - - Parameters - ---------- - Q_index : int | None, default=None - The index of the Q value to get the energy offset for. If None, get the energy offset - for all Q values. - - Raises - ------ - ValueError - If no Q values are set in the InstrumentModel. - IndexError - If Q_index is out of bounds. - TypeError - If Q_index is not an int or None. - - Returns - ------- - Parameter | list[Parameter] - The energy offset Parameter at the specified Q index, or a list of Parameters if - Q_index is None. - """ if self._Q is None: raise ValueError('No Q values are set in the InstrumentModel.') @@ -485,63 +400,18 @@ def get_energy_offset( return self._energy_offsets[Q_index] def fix_energy_offset(self, Q_index: int | None = None) -> None: - """ - Fix energy offset parameters. - - If Q_index is specified, only fix the energy offset for that Q value. If Q_index is None, - fix energy offsets for all Q values. - - Parameters - ---------- - Q_index : int | None, default=None - The index of the Q value to fix the energy offset for. If None, fix energy offsets for - all Q values. - """ self._fix_or_free_energy_offset(Q_index, fixed=True) def free_energy_offset(self, Q_index: int | None = None) -> None: - """ - Free energy offset parameters. - - If Q_index is specified, only free the energy offset for that Q value. If Q_index is None, - free energy offsets for all Q values. - - Parameters - ---------- - Q_index : int | None, default=None - The index of the Q value to free the energy offset for. If None, free energy offsets - for all Q values. - """ self._fix_or_free_energy_offset(Q_index, fixed=False) # -------------------------------------------------------------- # Private methods # -------------------------------------------------------------- def _fix_or_free_energy_offset(self, Q_index: int | None = None, fixed: bool = True) -> None: - """ - Fix or free energy offset parameters. - - If Q_index is specified, only fix or free the energy offset for that Q value. If Q_index is - None, fix or free energy offsets for all Q values. - - Parameters - ---------- - Q_index : int | None, default=None - The index of the Q value to fix or free the energy offset for. If None, fix or free - energy offsets for all Q values. - fixed : bool, default=True - Whether to fix (True) or free (False) the energy offset. - - 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. - """ - self._ensure_energy_offsets_current() if Q_index is None: + self._energy_offset.fixed = fixed for offset in self._energy_offsets: offset.fixed = fixed else: @@ -555,13 +425,11 @@ def _fix_or_free_energy_offset(self, Q_index: int | None = None, fixed: bool = T self._energy_offsets[Q_index].fixed = fixed def _ensure_energy_offsets_current(self) -> None: - """Rebuild energy offset Parameters if Q has changed since they were last built.""" if self._energy_offsets_is_dirty: self._generate_energy_offsets() self._energy_offsets_is_dirty = False def _generate_energy_offsets(self) -> None: - """Generate energy offset Parameters for each Q value.""" if self._Q is None: self._energy_offsets = [] return @@ -569,23 +437,19 @@ def _generate_energy_offsets(self) -> None: self._energy_offsets = [copy(self._energy_offset) for _ in self._Q] def _on_Q_change(self) -> None: - """Handle changes to the Q values.""" self._energy_offsets_is_dirty = True self.resolution_model.Q = self.Q self.background_model.Q = self.Q def _on_energy_offset_change(self) -> None: - """Handle changes to the energy offset.""" self._ensure_energy_offsets_current() for offset in self._energy_offsets: offset.value = self._energy_offset.value def _on_resolution_model_change(self) -> None: - """Handle changes to the resolution model.""" self.resolution_model.Q = self.Q def _on_background_model_change(self) -> None: - """Handle changes to the background model.""" self.background_model.Q = self.Q # ------------------------------------------------------------- @@ -593,19 +457,10 @@ def _on_background_model_change(self) -> None: # ------------------------------------------------------------- def __repr__(self) -> str: - """ - 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}, ' - f'unit={self.unit}, ' + f'x_unit={self.x_unit}, ' f'Q_len={None if self._Q is None else len(self._Q)}, ' f'resolution_model={self._resolution_model!r}, ' f'background_model={self._background_model!r})' diff --git a/src/easydynamics/sample_model/model_base.py b/src/easydynamics/sample_model/model_base.py index a2f1d776..8a639803 100644 --- a/src/easydynamics/sample_model/model_base.py +++ b/src/easydynamics/sample_model/model_base.py @@ -26,7 +26,8 @@ def __init__( self, display_name: str = 'MyModelBase', unique_name: str | None = None, - unit: str | sc.Unit | None = 'meV', + x_unit: str | sc.Unit | None = 'meV', + y_unit: str | sc.Unit = 'dimensionless', components: ModelComponent | ComponentCollection | None = None, Q: Q_type | None = None, ) -> None: @@ -39,8 +40,10 @@ def __init__( Display name of the model. unique_name : str | None, default=None Unique name of the model. If None, a unique name will be generated. - unit : str | sc.Unit | None, default='meV' - Unit of the model. + x_unit : str | sc.Unit | None, default='meV' + Unit of the x-axis (energy, Q, etc.). + y_unit : str | sc.Unit, default='dimensionless' + Unit of the model output (intensity). components : ModelComponent | ComponentCollection | None, default=None Template components of the model. If None, no components are added. These components are copied into ComponentCollections for each Q value. @@ -53,7 +56,8 @@ def __init__( If components is not a ModelComponent or ComponentCollection. """ super().__init__( - unit=unit, + x_unit=x_unit, + y_unit=y_unit, display_name=display_name, unique_name=unique_name, ) @@ -74,17 +78,19 @@ def __init__( self.append_component(components) def evaluate( - self, x: Numeric | list | np.ndarray | sc.Variable | sc.DataArray - ) -> list[np.ndarray]: + self, + x: Numeric | list | np.ndarray | sc.Variable | sc.DataArray, + output: str = 'numpy', + ) -> list[np.ndarray] | list[sc.Variable]: """ Evaluate the sample model at all Q for the given x values. Parameters ---------- 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. + Energy axis values to evaluate the model at. + output : str, default='numpy' + 'numpy' returns np.ndarray per Q; 'scipp' returns sc.Variable per Q. Raises ------ @@ -93,15 +99,16 @@ def evaluate( 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. + list[np.ndarray] | list[sc.Variable] + A list of arrays containing the evaluated model values for each Q. The length of the + list will match the number of Q values in the model. """ - self._ensure_component_collections_current() if not self._component_collections: raise ValueError('No components in the model to evaluate.') - return [collection.evaluate(x) for collection in self._component_collections] + return [ + collection.evaluate(x, output=output) for collection in self._component_collections + ] # ------------------------------------------------------------------ # Component management @@ -257,14 +264,14 @@ def clear_Q(self, confirm: bool = False) -> None: # Other methods # ------------------------------------------------------------------ - def convert_unit(self, unit: str | sc.Unit) -> None: + def convert_x_unit(self, unit: str | sc.Unit) -> None: """ - Convert the unit of the ComponentCollection and all its components. + Convert the x-axis unit of all components in the model. Parameters ---------- unit : str | sc.Unit - The new unit to convert to. + The new x-axis unit to convert to. Raises ------ @@ -273,22 +280,54 @@ def convert_unit(self, unit: str | sc.Unit) -> None: Exception If the provided unit is not compatible with the current unit. """ + if not isinstance(unit, (str, sc.Unit)): + raise TypeError(f'Unit must be a string or sc.Unit, got {type(unit).__name__}') - old_unit = self._unit + old_unit = self._x_unit + try: + for component in self.components: + component.convert_x_unit(unit) + self._x_unit = str(unit) if isinstance(unit, sc.Unit) else unit + except Exception as e: + if old_unit is not None: + try: + for component in self.components: + component.convert_x_unit(old_unit) + except Exception: # noqa: S110 + pass + raise e + self._on_components_change() + + def convert_y_unit(self, unit: str | sc.Unit) -> None: + """ + Convert the y-axis unit of all components in the model. + Parameters + ---------- + unit : str | sc.Unit + The new y-axis unit to convert to. + + Raises + ------ + TypeError + If the provided unit is not a string or sc.Unit. + Exception + If the provided unit is not compatible with the current unit. + """ if not isinstance(unit, (str, sc.Unit)): raise TypeError(f'Unit must be a string or sc.Unit, got {type(unit).__name__}') + + old_unit = self._y_unit try: for component in self.components: - component.convert_unit(unit) - self._unit = unit + component.convert_y_unit(unit) + self._y_unit = str(unit) if isinstance(unit, sc.Unit) else unit except Exception as e: - # Attempt to rollback on failure try: for component in self.components: - component.convert_unit(old_unit) + component.convert_y_unit(old_unit) except Exception: # noqa: S110 - pass # Best effort rollback + pass raise e self._on_components_change() @@ -327,7 +366,6 @@ def get_all_variables(self, Q_index: int | None = None) -> list[Parameter]: A list of all Parameters and Descriptors from the ComponentCollections in the ModelBase. """ - self._ensure_component_collections_current() if Q_index is None: all_vars = [ @@ -397,7 +435,6 @@ def _ensure_component_collections_current(self) -> None: def _generate_component_collections(self) -> None: """Generate ComponentCollections for each Q value.""" - if self.Q is None: self._component_collections = [] return @@ -428,9 +465,6 @@ def __repr__(self) -> str: A string representation of the ModelBase. """ return ( - f'{self.__class__.__name__}(' - f'unique_name={self.unique_name!r}, ' - f'unit={self.unit}, ' - f'Q={self.Q}, ' - f'components={self.components})' + f'{self.__class__.__name__}(unique_name={self.unique_name}, ' + f'x_unit={self.x_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 ce5117ff..f5a70d08 100644 --- a/src/easydynamics/sample_model/resolution_model.py +++ b/src/easydynamics/sample_model/resolution_model.py @@ -51,12 +51,13 @@ def __init__( self, display_name: str = 'MyResolutionModel', unique_name: str | None = None, - unit: str | sc.Unit = 'meV', + x_unit: str | sc.Unit = 'meV', + y_unit: str | sc.Unit = 'dimensionless', components: ModelComponent | ComponentCollection | None = None, Q: Q_type | None = None, ) -> None: """ - Initialize a ResolutionModel. + Initialize the ResolutionModel. Parameters ---------- @@ -64,19 +65,20 @@ def __init__( Display name of the model. unique_name : str | None, default=None Unique name of the model. If None, a unique name will be generated. - unit : str | sc.Unit, default='meV' - Unit of the model. + x_unit : str | sc.Unit, default='meV' + Unit of the x-axis. + y_unit : str | sc.Unit, default='dimensionless' + Unit of the y-axis (output). components : ModelComponent | ComponentCollection | None, default=None - Template components of the model. If None, no components are added. These components - are copied into ComponentCollections for each Q value. + Template components. DeltaFunction, Polynomial, and Exponential are not allowed. Q : Q_type | None, default=None Q values for the model. If None, Q is not set. """ - super().__init__( display_name=display_name, unique_name=unique_name, - unit=unit, + x_unit=x_unit, + y_unit=y_unit, components=components, Q=Q, ) @@ -85,18 +87,7 @@ def append_component(self, component: ModelComponent | ComponentCollection) -> N """ Append a component to the ResolutionModel. - Does not allow DeltaFunction or Polynomial components, as these are not physical resolution - components. - - Parameters - ---------- - component : ModelComponent | ComponentCollection - Component(s) to append. - - Raises - ------ - TypeError - If the component is a DeltaFunction or Polynomial. + Does not allow DeltaFunction, Polynomial, or Exponential components. """ components = component if isinstance(component, ComponentCollection) else (component,) @@ -115,29 +106,7 @@ def from_sample_model( normalize_area: bool = True, fix_parameters: bool = True, ) -> 'ResolutionModel': - """ - Create a ResolutionModel from a SampleModel. - - Parameters - ---------- - sample_model : SampleModel - SampleModel to create the ResolutionModel from. - normalize_area : bool, default=True - Whether to normalize the components in the ResolutionModel to have area 1. - fix_parameters : bool, default=True - Whether to fix the parameters in the ResolutionModel. - - Returns - ------- - 'ResolutionModel' - ResolutionModel created from the SampleModel. - - Raises - ------ - TypeError - If sample_model is not a SampleModel, or if normalize_area or fix_parameters are not - bool. - """ + """Create a ResolutionModel from a SampleModel.""" if not isinstance(sample_model, SampleModel): raise TypeError( f'sample_model must be an instance of SampleModel. Got {type(sample_model)}.' @@ -151,7 +120,8 @@ def from_sample_model( resolution_model = cls( display_name=sample_model.display_name, - unit=sample_model.unit, + x_unit=sample_model.x_unit, + y_unit=sample_model.y_unit, components=sample_model.components, Q=sample_model.Q, ) @@ -162,19 +132,28 @@ def from_sample_model( resolution_model._component_collections[index] = copy( sample_model.get_component_collection(Q_index=index) ) + # Prevent any EasyScience callbacks triggered during __init__ or copy + # from scheduling a rebuild that would discard the installed collections. + resolution_model._component_collections_is_dirty = False + if normalize_area: resolution_model.normalize_area() if fix_parameters: resolution_model.fix_all_parameters() + # Re-protect after normalize_area / fix_all_parameters in case their + # parameter callbacks set the dirty flag again. + if sample_model.Q is not None: + resolution_model._component_collections_is_dirty = False + return resolution_model def __repr__(self) -> str: return ( f'{self.__class__.__name__}(' f'unique_name={self.unique_name!r}, ' - f'unit={self.unit}, ' + f'x_unit={self.x_unit}, ' f'Q_len={None if self._Q is None else len(self._Q)}, ' f'components={self.components})' ) diff --git a/src/easydynamics/sample_model/sample_model.py b/src/easydynamics/sample_model/sample_model.py index 3991a10f..9a0e294a 100644 --- a/src/easydynamics/sample_model/sample_model.py +++ b/src/easydynamics/sample_model/sample_model.py @@ -66,7 +66,8 @@ def __init__( self, display_name: str = 'MySampleModel', unique_name: str | None = None, - unit: str | sc.Unit = 'meV', + x_unit: str | sc.Unit = 'meV', + y_unit: str | sc.Unit = 'dimensionless', components: ModelComponent | ComponentCollection | None = None, Q: Q_type | None = None, diffusion_models: DiffusionModelBase | list[DiffusionModelBase] | None = None, @@ -83,33 +84,31 @@ def __init__( Display name of the model. unique_name : str | None, default=None Unique name of the model. If None, a unique name will be generated. - unit : str | sc.Unit, default='meV' - Unit of the model. If None,. + x_unit : str | sc.Unit, default='meV' + Unit of the x-axis. + y_unit : str | sc.Unit, default='dimensionless' + Unit of the y-axis (output). components : ModelComponent | ComponentCollection | None, default=None - Template components of the model. If None, no components are added. These components - are copied into ComponentCollections for each Q value. + Template components copied into each Q's ComponentCollection. Q : Q_type | None, default=None - Q values for the model. If None, Q is not set. + Q values. If None, Q is not set. diffusion_models : DiffusionModelBase | list[DiffusionModelBase] | None, default=None - Diffusion models to include in the SampleModel. If None, no diffusion models are added. + Diffusion models to include. Each must be a DiffusionModelBase. temperature : float | None, default=None - Temperature for detailed balancing. If None, no detailed balancing is applied. By - default, None. + Sample temperature in temperature_unit. If provided, detailed balance is applied. temperature_unit : str | sc.Unit, default='K' - Unit of the temperature. + Unit for the temperature parameter. detailed_balance_settings : DetailedBalanceSettings | None, default=None - Settings for detailed balancing. + Detailed balance settings. If None, default settings are used. Raises ------ TypeError - If diffusion_models is not a DiffusionModelBase, a list of DiffusionModelBase, or None, - or if temperature is not a number or None, or if detailed_balance_settings is not a - DetailedBalanceSettings instance. + If diffusion_models contains non-DiffusionModelBase items, temperature is not numeric, + or detailed_balance_settings is not a DetailedBalanceSettings instance. ValueError If temperature is negative. """ - if diffusion_models is None: self._diffusion_models = [] elif isinstance(diffusion_models, DiffusionModelBase): @@ -131,7 +130,8 @@ def __init__( super().__init__( display_name=display_name, unique_name=unique_name, - unit=unit, + x_unit=x_unit, + y_unit=y_unit, components=components, Q=Q, ) @@ -178,7 +178,6 @@ def append_diffusion_model(self, diffusion_model: DiffusionModelBase) -> None: TypeError If the diffusion_model is not a DiffusionModelBase. """ - if not isinstance(diffusion_model, DiffusionModelBase): raise TypeError( f'diffusion_model must be a DiffusionModelBase, got {type(diffusion_model).__name__}' # noqa: E501 @@ -201,15 +200,19 @@ def remove_diffusion_model(self, name: str) -> None: ValueError If no DiffusionModel with the given name is found. """ - for i, dm in enumerate(self.diffusion_models): - if dm.name == name: - del self.diffusion_models[i] - self._component_collections_is_dirty = True - return - raise ValueError( - f'No DiffusionModel with name {name} found. \n' - f'The available names are: {[dm.name for dm in self.diffusion_models]}' - ) + matches = [i for i, dm in enumerate(self.diffusion_models) if dm.name == name] + if len(matches) == 0: + raise ValueError( + f'No DiffusionModel with name {name!r} found. ' + f'Available names are: {[dm.name for dm in self.diffusion_models]}' + ) + if len(matches) > 1: + raise ValueError( + f'Multiple DiffusionModels share the name {name!r}. ' + f'Rename them to have unique names before removing.' + ) + del self.diffusion_models[matches[0]] + self._component_collections_is_dirty = True def clear_diffusion_models(self) -> None: """Clear all DiffusionModels from the SampleModel.""" @@ -250,7 +253,6 @@ def diffusion_models( TypeError If value is not a DiffusionModelBase, a list of DiffusionModelBase, or None. """ - if value is None: self._diffusion_models = [] self._on_diffusion_models_change() @@ -292,7 +294,7 @@ def temperature(self, value: Numeric | None) -> None: Parameters ---------- value : Numeric | None - The temperature value to set. Can be a number or None to unset the temperature. + The temperature value. If None, temperature is cleared (no detailed balance). Raises ------ @@ -325,53 +327,23 @@ 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. Returns ------- str | sc.Unit - The unit of the temperature Parameter. + 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. - - Parameters - ---------- - _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.' + f'Temperature_unit is read-only. Use convert_temperature_unit to change the unit ' + f'between allowed types 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. - - Parameters - ---------- - unit : str | sc.Unit - The unit to convert the temperature Parameter to. - - Raises - ------ - ValueError - If temperature is not set or conversion fails. - Exception - If the provided unit is invalid or cannot be converted. - """ - if self.temperature is None: raise ValueError('Temperature is not set, cannot convert unit.') @@ -381,7 +353,6 @@ def convert_temperature_unit(self, unit: str | sc.Unit) -> None: self.temperature.convert_unit(unit) self._temperature_unit = unit except Exception: - # Attempt to rollback on failure with suppress(Exception): self.temperature.convert_unit(old_unit) raise @@ -394,25 +365,12 @@ def normalize_detailed_balance(self) -> bool: Returns ------- bool - True if the detailed balance factor is divided by temperature, False otherwise. + True if the detailed balance factor is normalized by temperature. """ return self.detailed_balance_settings.normalize_detailed_balance @normalize_detailed_balance.setter def normalize_detailed_balance(self, value: bool) -> None: - """ - Set whether to divide the detailed balance factor by temperature. - - Parameters - ---------- - value : bool - True to divide the detailed balance factor by temperature, False otherwise. - - Raises - ------ - TypeError - If value is not a bool. - """ if not isinstance(value, bool): raise TypeError('normalize_detailed_balance must be True or False') self.detailed_balance_settings.normalize_detailed_balance = value @@ -420,30 +378,17 @@ def normalize_detailed_balance(self, value: bool) -> None: @property def use_detailed_balance(self) -> bool: """ - Get whether to apply detailed balance to the model. + Get whether detailed balance correction is applied. Returns ------- bool - True if detailed balance is applied, False otherwise. + True if detailed balance is applied during evaluation. """ return self.detailed_balance_settings.use_detailed_balance @use_detailed_balance.setter def use_detailed_balance(self, value: bool) -> None: - """ - Set whether to apply detailed balance to the model. - - Parameters - ---------- - value : bool - True to apply detailed balance, False otherwise. - - Raises - ------ - TypeError - If value is not a bool. - """ if not isinstance(value, bool): raise TypeError('use_detailed_balance must be True or False') self.detailed_balance_settings.use_detailed_balance = value @@ -451,30 +396,17 @@ def use_detailed_balance(self, value: bool) -> None: @property def detailed_balance_settings(self) -> DetailedBalanceSettings: """ - Get the DetailedBalanceSettings of the SampleModel. + Get the detailed balance settings. Returns ------- DetailedBalanceSettings - The DetailedBalanceSettings of the SampleModel. + The detailed balance settings object. """ return self._detailed_balance_settings @detailed_balance_settings.setter def detailed_balance_settings(self, value: DetailedBalanceSettings) -> None: - """ - Set the DetailedBalanceSettings of the SampleModel. - - Parameters - ---------- - value : DetailedBalanceSettings - The DetailedBalanceSettings to set. - - Raises - ------ - TypeError - If value is not a DetailedBalanceSettings. - """ if not isinstance(value, DetailedBalanceSettings): raise TypeError('detailed_balance_settings must be a DetailedBalanceSettings') self._detailed_balance_settings = value @@ -484,56 +416,24 @@ def detailed_balance_settings(self, value: DetailedBalanceSettings) -> None: # ------------------------------------------------------------------ def evaluate( - self, x: Numeric | list | np.ndarray | sc.Variable | sc.DataArray - ) -> list[np.ndarray]: - """ - Evaluate the sample model at all Q for the given x values. - - Parameters - ---------- - 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. - """ - - y = super().evaluate(x) + self, + x: Numeric | list | np.ndarray | sc.Variable | sc.DataArray, + output: str = 'numpy', + ) -> list[np.ndarray] | list[sc.Variable]: + y = super().evaluate(x, output=output) if self.temperature is not None and self.detailed_balance_settings.use_detailed_balance: DBF = detailed_balance_factor( energy=x, temperature=self.temperature, divide_by_temperature=self.detailed_balance_settings.normalize_detailed_balance, - energy_unit=self.unit, + energy_unit=self.x_unit, ) y = [yi * DBF for yi in y] return y def get_all_variables(self, Q_index: int | None = None) -> list[Parameter]: - """ - Get all Parameters and Descriptors from all ComponentCollections in the SampleModel. - - Also includes temperature if set and all variables from diffusion models. Ignores the - Parameters and Descriptors in self._components as these are just templates. - - Parameters - ---------- - Q_index : int | None, default=None - If specified, only get variables from the ComponentCollection at the given Q index. If - None, get variables from all ComponentCollections. - - Returns - ------- - list[Parameter] - List of all Parameters and Descriptors, including temperature if set and all variables - from diffusion models. - """ - all_vars = super().get_all_variables(Q_index=Q_index) if self.temperature is not None: all_vars.append(self.temperature) @@ -549,16 +449,10 @@ def get_all_variables(self, Q_index: int | None = None) -> list[Parameter]: # ------------------------------------------------------------------ def _generate_component_collections(self) -> None: - """ - Generate ComponentCollections from the DiffusionModels for each Q and add the components - from self._components. - """ super()._generate_component_collections() if self.Q is None: return - # Generate components from diffusion models - # and add to component collections for diffusion_model in self.diffusion_models: diffusion_collections = diffusion_model.get_component_collections() for target, source in zip( @@ -570,13 +464,11 @@ def _generate_component_collections(self) -> None: target.append_component(component) def _on_diffusion_models_change(self) -> None: - """Handle changes to the diffusion models.""" for diffusion_model in self.diffusion_models: diffusion_model.Q = self.Q self._component_collections_is_dirty = True def _on_Q_change(self) -> None: - """Handle changes to the Q values.""" for diffusion_model in self.diffusion_models: diffusion_model.clear_Q(confirm=True) diffusion_model.Q = self.Q @@ -587,20 +479,10 @@ def _on_Q_change(self) -> None: # ------------------------------------------------------------------ def __repr__(self) -> str: - """ - Return a string representation of the SampleModel. - - Returns - ------- - str - A string representation of the SampleModel. - """ - return ( - f'{self.__class__.__name__}(' - f'unique_name={self.unique_name!r}, unit={self.unit},\n' - f' Q={self.Q},\n' - f' components={self.components}, diffusion_models={self.diffusion_models},\n' - f' temperature={self.temperature},\n' - f' detailed_balance_settings={self.detailed_balance_settings})' + f'{self.__class__.__name__}(unique_name={self.unique_name}, x_unit={self.x_unit}), ' + f'Q = {self.Q}, \n ' + f'components = {self.components}, diffusion_models = {self.diffusion_models}, ' + f'temperature = {self.temperature}, ' + f'detailed_balance_settings = {self.detailed_balance_settings}' ) diff --git a/src/easydynamics/settings/convolution_settings.py b/src/easydynamics/settings/convolution_settings.py index fadc13a1..4806c035 100644 --- a/src/easydynamics/settings/convolution_settings.py +++ b/src/easydynamics/settings/convolution_settings.py @@ -173,6 +173,11 @@ def extension_factor(self, factor: Numeric) -> None: If factor is negative. """ + if factor is None: + self._extension_factor = factor + self.convolution_plan_is_valid = False + return + if not isinstance(factor, Numeric): raise TypeError('Extension factor must be a number.') if factor < 0.0: @@ -243,6 +248,13 @@ def suppress_warnings(self, suppress: bool) -> None: raise TypeError('suppress_warnings must be True or False.') self._suppress_warnings = suppress + def __copy__(self) -> 'ConvolutionSettings': + return ConvolutionSettings( + upsample_factor=self._upsample_factor, + extension_factor=self._extension_factor, + suppress_warnings=self._suppress_warnings, + ) + def __repr__(self) -> str: """ Return a string representation of the ConvolutionSettings. diff --git a/src/easydynamics/utils/utils.py b/src/easydynamics/utils/utils.py index d9fc27c0..70f4ff3a 100644 --- a/src/easydynamics/utils/utils.py +++ b/src/easydynamics/utils/utils.py @@ -16,6 +16,35 @@ angstrom = DescriptorNumber('angstrom', 1e-10, unit='m') +def verify_Q_index(Q_index: int, Q: np.ndarray | None) -> None: + """ + Verify that Q_index is a valid integer index into Q. + + Parameters + ---------- + Q_index : int + Index to validate. + Q : np.ndarray | None + The Q values array (may be None if no data is loaded). + + Raises + ------ + TypeError + If Q_index is not an integer. + IndexError + If Q_index is out of range. + """ + if not isinstance(Q_index, int): + raise TypeError('Q_index must be an integer.') + if Q is None or not (0 <= Q_index < len(Q)): + raise IndexError('Q_index must be a valid index for the Q values.') + + +def energy_to_scipp(energy: np.ndarray, unit: str | sc.Unit) -> sc.Variable: + """Convert a numpy energy array to a scipp Variable with dimension 'energy'.""" + return sc.array(dims=['energy'], values=energy, unit=unit) + + def _validate_and_convert_Q( Q: np.ndarray | Numeric | list | ArrayLike | sc.Variable | None, ) -> np.ndarray | None: diff --git a/tests/performance_tests/convolution/convolution_width_thresholds.ipynb b/tests/performance_tests/convolution/convolution_width_thresholds.ipynb index fbb5df29..543002d5 100644 --- a/tests/performance_tests/convolution/convolution_width_thresholds.ipynb +++ b/tests/performance_tests/convolution/convolution_width_thresholds.ipynb @@ -48,7 +48,7 @@ ")\n", "numerical_convolver.upsample_factor = None\n", "for gwidth in gaussian_widths:\n", - " sample_components.components[0].width = gwidth\n", + " sample_components[0].width = gwidth\n", " y_analytical = analytical_convolver.convolution()\n", "\n", " y_numerical = numerical_convolver.convolution()\n", @@ -106,8 +106,8 @@ ")\n", "numerical_convolver.upsample_factor = None\n", "for gwidth, gcenter in zip(gaussian_widths, gaussian_centers, strict=True):\n", - " sample_components.components[0].width = gwidth\n", - " sample_components.components[0].center = gcenter\n", + " sample_components[0].width = gwidth\n", + " sample_components[0].center = gcenter\n", " y_analytical = analytical_convolver.convolution()\n", "\n", " y_numerical = numerical_convolver.convolution()\n", @@ -135,7 +135,7 @@ ], "metadata": { "kernelspec": { - "display_name": "Python 3", + "display_name": "default", "language": "python", "name": "python3" }, @@ -149,7 +149,7 @@ "name": "python", "nbconvert_exporter": "python", "pygments_lexer": "ipython3", - "version": "3.14.4" + "version": "3.14.5" } }, "nbformat": 4, diff --git a/tests/performance_tests/utils/detailed_balance_approximations.ipynb b/tests/performance_tests/utils/detailed_balance_approximations.ipynb index f50de3cf..800c1eeb 100644 --- a/tests/performance_tests/utils/detailed_balance_approximations.ipynb +++ b/tests/performance_tests/utils/detailed_balance_approximations.ipynb @@ -62,7 +62,7 @@ ], "metadata": { "kernelspec": { - "display_name": "newdynamics", + "display_name": "default", "language": "python", "name": "python3" }, @@ -76,7 +76,7 @@ "name": "python", "nbconvert_exporter": "python", "pygments_lexer": "ipython3", - "version": "3.11.13" + "version": "3.14.5" } }, "nbformat": 4, diff --git a/tests/unit/easydynamics/analysis/test_analysis.py b/tests/unit/easydynamics/analysis/test_analysis.py index 516b718e..16b68ebc 100644 --- a/tests/unit/easydynamics/analysis/test_analysis.py +++ b/tests/unit/easydynamics/analysis/test_analysis.py @@ -512,7 +512,7 @@ def test_parameters_to_dataset_different_units(self, analysis): ) # Convert the unit of a component to eV. - analysis.sample_model.get_component_collection(Q_index=1)[0].convert_unit('eV') + analysis.sample_model.get_component_collection(Q_index=1)[0].convert_x_unit('eV') # THEN parameters_dataset = analysis.parameters_to_dataset() @@ -732,15 +732,18 @@ def test_on_instrument_model_changed(self, analysis): def test_on_convolution_settings_changed(self, analysis): # WHEN - new_convolution_settings = ConvolutionSettings() + new_convolution_settings = ConvolutionSettings(upsample_factor=7, extension_factor=0.3) # THEN (this calls _on_convolution_settings_changed internally) analysis.convolution_settings = new_convolution_settings - # EXPECT + # EXPECT: the parent holds the new settings object assert analysis.convolution_settings is new_convolution_settings + # Each Analysis1d gets its own copy of the settings (so plan_is_valid is + # tracked independently per Q), but all values are propagated from the parent. for analysis1d in analysis.analysis_list: - assert analysis1d.convolution_settings is new_convolution_settings + assert analysis1d.convolution_settings.upsample_factor == 7 + assert analysis1d.convolution_settings.extension_factor == pytest.approx(0.3) def test_fit_single_Q_valid(self, analysis): # WHEN @@ -824,6 +827,47 @@ def test_fit_all_Q_simultaneously(self, analysis): # And that the result from the fit method is returned assert result == fake_fit_result + def test_fit_all_Q_simultaneously_with_nan_data(self): + # Regression test: energy must be sliced via sc.array(dims=['energy'], values=mask), + # NOT via energy[numpy_bool_array]. The bug: numpy booleans are treated as integer + # indices by scipp (True→1, False→0), so energy[[True,False,True]] returns 3 elements + # with wrong values instead of filtering to the 2 finite points. + # GIVEN: data with a NaN at index 1; finite energies are -1.0 and 1.0 + Q = sc.array(dims=['Q'], values=[1.0], unit='1/Angstrom') + energy = sc.array(dims=['energy'], values=[-1.0, 0.0, 1.0], unit='meV') + values = np.array([[1.0, np.nan, 2.0]]) + variances = np.array([[0.1, 0.1, 0.1]]) + data = sc.array(dims=['Q', 'energy'], values=values, variances=variances) + data_array = sc.DataArray(data=data, coords={'Q': Q, 'energy': energy}) + experiment = Experiment(data=data_array) + sample_model = SampleModel(components=Gaussian(name='G')) + analysis = Analysis(experiment=experiment, sample_model=sample_model) + + captured_energy = [] + original_refresh = analysis.analysis_list[0].refresh_convolver + + def capture_refresh(energy, **kwargs): + captured_energy.append(energy) + return original_refresh(energy=energy, **kwargs) + + analysis.analysis_list[0].refresh_convolver = capture_refresh + analysis.get_fit_functions = MagicMock(return_value=['fit_fn']) + + fake_fitter_instance = MagicMock() + fake_fitter_instance.fit.return_value = object() + + # WHEN + with patch( + 'easydynamics.analysis.analysis.MultiFitter', + return_value=fake_fitter_instance, + ): + analysis._fit_all_Q_simultaneously() + + # EXPECT: only the 2 finite energy points (-1.0, 1.0) were passed, not all 3 + assert len(captured_energy) == 1 + assert len(captured_energy[0]) == 2 + np.testing.assert_array_equal(captured_energy[0].values, [-1.0, 1.0]) + def test_get_fit_functions(self, analysis): # WHEN @@ -923,9 +967,9 @@ def test_create_components_dataset_single_Q(self, analysis_single_Q): assert components_dataset.sizes['Q'] == 1 assert components_dataset.coords['Q'].ndim == 1 - def test_ensure_analysis_list_current_clears_dirty_when_Q_is_none(self): - # An Analysis with no experiment has Q=None; _ensure_analysis_list_current should still - # clear the dirty flag without attempting to build the list. + def test_ensure_analysis_list_current_stays_dirty_when_Q_is_none(self): + # When Q is None, _ensure_analysis_list_current should NOT clear the dirty flag — + # it must stay dirty so the list is rebuilt as soon as Q becomes available. # WHEN analysis = Analysis(display_name='NoQ') @@ -935,8 +979,8 @@ def test_ensure_analysis_list_current_clears_dirty_when_Q_is_none(self): # THEN result = analysis.analysis_list - # EXPECT - dirty flag cleared, list stays empty - assert analysis._analysis_list_is_dirty is False + # EXPECT - dirty flag preserved, list stays empty + assert analysis._analysis_list_is_dirty is True assert result == [] def test_rebin_marks_analysis_list_dirty(self, analysis): diff --git a/tests/unit/easydynamics/analysis/test_analysis1d.py b/tests/unit/easydynamics/analysis/test_analysis1d.py index 5bd57163..25b44ef8 100644 --- a/tests/unit/easydynamics/analysis/test_analysis1d.py +++ b/tests/unit/easydynamics/analysis/test_analysis1d.py @@ -111,10 +111,10 @@ def test_calculate_updates_convolver_and_calls_calculate(self, analysis1d): result = analysis1d.calculate() # EXPECT - analysis1d._create_convolver.assert_called_once() - assert analysis1d._convolver is fake_convolver - analysis1d._calculate.assert_called_once() + # calculate() passes the convolver directly to _calculate without storing it on self + _, call_kwargs = analysis1d._calculate.call_args + assert call_kwargs['convolver'] is fake_convolver np.testing.assert_array_equal(result, expected_result) def test__calculate_adds_sample_and_background(self, analysis1d): @@ -508,15 +508,19 @@ def test_calculate_energy_with_offset_different_units(self, analysis1d): # WHEN energy = analysis1d.experiment.energy energy_offset = analysis1d.instrument_model.get_energy_offset(Q_index=analysis1d.Q_index) - energy_offset.value = 1.0 # override with a simple value for testing - energy_offset.convert_unit('eV') + energy_offset.value = 1.0 # set to 1.0 in original unit (meV) + energy_offset.convert_unit('eV') # now 0.001 eV, still represents 1 meV # THEN result = analysis1d._calculate_energy_with_offset(energy, energy_offset) - # EXPECT - expected = energy.values - energy_offset.value - np.testing.assert_array_equal(result.values, expected) + # EXPECT: offset must be converted to energy's unit before subtraction + offset_in_energy_unit = sc.to_unit( + sc.scalar(energy_offset.value, unit=str(energy_offset.unit)), + str(energy.unit), + ).value + expected = energy.values - offset_in_energy_unit + np.testing.assert_array_almost_equal(result.values, expected) def test_calculate_energy_with_offset_raises_if_incompatible_units(self, analysis1d): # WHEN @@ -1042,3 +1046,51 @@ def test_fit_marks_convolver_dirty_when_resolution_model_components_change(self, # EXPECT analysis1d._create_convolver.assert_called_once() + + # ───── Regression tests ───── + + @pytest.fixture + def analysis1d_with_nan(self): + """analysis1d fixture whose data contains a NaN at the second energy point.""" + Q = sc.array(dims=['Q'], values=[1.0], unit='1/Angstrom') + energy = sc.array(dims=['energy'], values=[10.0, 20.0, 30.0], unit='meV') + data = sc.array( + dims=['Q', 'energy'], + values=[[1.0, float('nan'), 3.0]], + variances=[[0.1, 0.2, 0.3]], + ) + data_array = sc.DataArray(data=data, coords={'Q': Q, 'energy': energy}) + experiment = Experiment(data=data_array) + sample_model = SampleModel(components=Gaussian()) + instrument_model = InstrumentModel() + return Analysis1d( + display_name='TestNaN', + experiment=experiment, + sample_model=sample_model, + instrument_model=instrument_model, + Q_index=0, + ) + + def test_create_residuals_array_with_nan_data_does_not_crash(self, analysis1d_with_nan): + # Before the fix, residuals subtracted a 2-point model from 3-point data + # (including NaN) which caused a dimension mismatch crash. + result = analysis1d_with_nan._create_residuals_array() + assert isinstance(result, sc.DataArray) + # Only the 2 finite energy points survive the mask. + assert result.sizes['energy'] == 2 + + def test_data_and_model_to_datagroup_with_nan_excludes_nan_from_data( + self, analysis1d_with_nan + ): + # Before the fix, 'Data' contained the full 3-point grid (including NaN) + # and computing Residuals crashed on the dimension mismatch. + energy = sc.array(dims=['energy'], values=[20.0, 30.0, 40.0], unit='meV') + datagroup = analysis1d_with_nan.data_and_model_to_datagroup( + energy=energy, include_residuals=True + ) + assert isinstance(datagroup, sc.DataGroup) + # 'Data' must contain only the 2 finite points. + assert datagroup['Data'].sizes['energy'] == 2 + # Residuals must be present and have matching size. + assert 'Residuals' in datagroup + assert datagroup['Residuals'].sizes['energy'] == 2 diff --git a/tests/unit/easydynamics/analysis/test_analysis_base.py b/tests/unit/easydynamics/analysis/test_analysis_base.py index 7507b0b3..71e3d728 100644 --- a/tests/unit/easydynamics/analysis/test_analysis_base.py +++ b/tests/unit/easydynamics/analysis/test_analysis_base.py @@ -532,12 +532,12 @@ def test_on_instrument_model_changed_updates_Q(self, analysis_base): analysis_base._on_instrument_model_changed() np.testing.assert_array_equal(analysis_base.instrument_model.Q, fake_Q) - def test_verify_Q_index_valid(self, analysis_base): + def test_verify_Q_index_valid(self, analysis_base_with_components): # WHEN valid_Q_index = 0 # THEN - result = analysis_base._verify_Q_index(valid_Q_index) + result = analysis_base_with_components._verify_Q_index(valid_Q_index) # EXPECT assert result == valid_Q_index @@ -550,6 +550,14 @@ def test_verify_Q_index_invalid(self, analysis_base): with pytest.raises(IndexError, match='Q_index must be a valid index'): analysis_base._verify_Q_index(invalid_Q_index) + def test_verify_Q_index_invalid_when_Q_is_none(self, analysis_base): + # WHEN + positive_Q_index = 0 + + # THEN / EXPECT + with pytest.raises(IndexError, match='Q_index must be a valid index'): + analysis_base._verify_Q_index(positive_Q_index) + def test_repr(self, analysis_base): # WHEN repr_str = repr(analysis_base) diff --git a/tests/unit/easydynamics/analysis/test_parameter_analysis.py b/tests/unit/easydynamics/analysis/test_parameter_analysis.py index 14d9c58c..1af0c787 100644 --- a/tests/unit/easydynamics/analysis/test_parameter_analysis.py +++ b/tests/unit/easydynamics/analysis/test_parameter_analysis.py @@ -832,14 +832,14 @@ def test_get_xyweight_from_dataset_wrong_parameter_name_raises(self, parameter_a parameter_analysis._get_xyweight_from_dataset('nonexistent_parameter') @pytest.mark.parametrize( - 'non_finite_variance', - [np.inf, -np.inf, np.nan, -1.0, 0.0], - ids=['inf', '-inf', 'nan', 'negative', 'zero'], + 'bad_variance', + [np.inf, -np.inf, -1.0, 0.0], + ids=['inf', '-inf', 'negative', 'zero'], ) def test_get_xyweight_from_dataset_non_finite_weights_raises( - self, parameter_analysis, non_finite_variance + self, parameter_analysis, bad_variance ): - # WHEN + # Non-NaN invalid variances (inf, negative, zero) should still raise. Q = sc.array(dims=['Q'], values=[0.1, 0.2]) parameter_analysis.parameters = sc.Dataset( data={ @@ -847,7 +847,7 @@ def test_get_xyweight_from_dataset_non_finite_weights_raises( data=sc.array( dims=['Q'], values=[1.0, 2.0], - variances=[1.0, non_finite_variance], + variances=[1.0, bad_variance], unit='meV', ), coords={'Q': Q}, @@ -861,6 +861,29 @@ def test_get_xyweight_from_dataset_non_finite_weights_raises( ): parameter_analysis._get_xyweight_from_dataset('parameter1') + def test_get_xyweight_from_dataset_nan_variance_filters_row(self, parameter_analysis): + # NaN variances arise when a parameter is absent for a given Q; those rows are filtered. + Q = sc.array(dims=['Q'], values=[0.1, 0.2]) + parameter_analysis.parameters = sc.Dataset( + data={ + 'parameter1': sc.DataArray( + data=sc.array( + dims=['Q'], + values=[1.0, np.nan], + variances=[0.25, np.nan], + unit='meV', + ), + coords={'Q': Q}, + ), + } + ) + + x, y, w = parameter_analysis._get_xyweight_from_dataset('parameter1') + + np.testing.assert_allclose(x, [0.1]) + np.testing.assert_allclose(y, [1.0]) + np.testing.assert_allclose(w, [1 / np.sqrt(0.25)]) + def test_get_xyweight_from_dataset_valid(self, parameter_analysis): # WHEN THEN x, y, w = parameter_analysis._get_xyweight_from_dataset('parameter1') diff --git a/tests/unit/easydynamics/base_classes/test_easydynamics_modelbase.py b/tests/unit/easydynamics/base_classes/test_easydynamics_modelbase.py index 6767640a..785db043 100644 --- a/tests/unit/easydynamics/base_classes/test_easydynamics_modelbase.py +++ b/tests/unit/easydynamics/base_classes/test_easydynamics_modelbase.py @@ -13,7 +13,7 @@ class TestEasyDynamicsModelBase: def easy_dynamics_modelbase(self): """Fixture for creating an instance of EasyDynamicsModelBase.""" - return EasyDynamicsModelBase(name='TestModel', unit='meV') + return EasyDynamicsModelBase(name='TestModel', x_unit='meV') def test_initialization(self, easy_dynamics_modelbase): """Test that the EasyDynamicsModelBase is initialized correctly.""" @@ -68,9 +68,13 @@ def test_name_setter_invalid_type(self, easy_dynamics_modelbase, invalid_name): def test_unit_property(self, easy_dynamics_modelbase): # WHEN THEN EXPECT - assert easy_dynamics_modelbase.unit == 'meV' + assert easy_dynamics_modelbase.x_unit == 'meV' def test_unit_setter_raises(self, easy_dynamics_modelbase): # WHEN / THEN / EXPECT - with pytest.raises(AttributeError, match='Use convert_unit to change '): - easy_dynamics_modelbase.unit = 'K' + with pytest.raises(AttributeError, match='read-only'): + easy_dynamics_modelbase.x_unit = 'K' + + def test_y_unit_setter_raises(self, easy_dynamics_modelbase): + with pytest.raises(AttributeError, match='read-only'): + easy_dynamics_modelbase.y_unit = '1/meV' diff --git a/tests/unit/easydynamics/convolution/test_convolution.py b/tests/unit/easydynamics/convolution/test_convolution.py index dbb4290d..803ed24f 100644 --- a/tests/unit/easydynamics/convolution/test_convolution.py +++ b/tests/unit/easydynamics/convolution/test_convolution.py @@ -73,7 +73,7 @@ def test_init(self, default_convolution): assert default_convolution.upsample_factor == 5 assert default_convolution.extension_factor == pytest.approx(0.2) assert default_convolution.temperature is None - assert default_convolution.unit == 'meV' + assert default_convolution.x_unit == 'meV' assert default_convolution.detailed_balance_settings.normalize_detailed_balance is True assert isinstance(default_convolution._energy_grid, EnergyGrid) @@ -107,7 +107,7 @@ def test_init_components(self, convolution_with_components): assert convolution_with_components.upsample_factor == 5 assert convolution_with_components.extension_factor == pytest.approx(0.2) assert convolution_with_components.temperature is None - assert convolution_with_components.unit == 'meV' + assert convolution_with_components.x_unit == 'meV' assert ( convolution_with_components.detailed_balance_settings.normalize_detailed_balance is True diff --git a/tests/unit/easydynamics/convolution/test_convolution_base.py b/tests/unit/easydynamics/convolution/test_convolution_base.py index fe8ceef6..d862c404 100644 --- a/tests/unit/easydynamics/convolution/test_convolution_base.py +++ b/tests/unit/easydynamics/convolution/test_convolution_base.py @@ -78,7 +78,7 @@ def test_init_energy_numerical_none_offset(self): 'energy': 'invalid', 'sample_components': ComponentCollection(), 'resolution_components': ComponentCollection(), - 'unit': 'meV', + 'x_unit': 'meV', 'energy_offset': 0, }, 'Energy must be', @@ -88,7 +88,7 @@ def test_init_energy_numerical_none_offset(self): 'energy': np.linspace(-10, 10, 100), 'sample_components': 'invalid', 'resolution_components': ComponentCollection(), - 'unit': 'meV', + 'x_unit': 'meV', 'energy_offset': 0, }, ( @@ -101,7 +101,7 @@ def test_init_energy_numerical_none_offset(self): 'energy': np.linspace(-10, 10, 100), 'sample_components': ComponentCollection(), 'resolution_components': 'invalid', - 'unit': 'meV', + 'x_unit': 'meV', 'energy_offset': 0, }, ( @@ -114,7 +114,7 @@ def test_init_energy_numerical_none_offset(self): 'energy': np.linspace(-10, 10, 100), 'sample_components': ComponentCollection(), 'resolution_components': ComponentCollection(), - 'unit': 123, + 'x_unit': 123, 'energy_offset': 0, }, 'unit must be ', @@ -124,7 +124,7 @@ def test_init_energy_numerical_none_offset(self): 'energy': np.linspace(-10, 10, 100), 'sample_components': ComponentCollection(), 'resolution_components': ComponentCollection(), - 'unit': 'meV', + 'x_unit': 'meV', 'energy_offset': 'invalid', }, 'Energy_offset must be ', @@ -181,17 +181,17 @@ def test_unit_setter_raises(self, convolution_base): # WHEN THEN EXPECT with pytest.raises( AttributeError, - match=r'Use convert_unit to change the unit between allowed types ', + match=r'read-only', ): - convolution_base.unit = 'K' + convolution_base.x_unit = 'K' def test_convert_unit(self, convolution_base): # WHEN THEN - convolution_base.convert_unit('eV') + convolution_base.convert_x_unit('eV') # EXPECT assert convolution_base.energy.unit == 'eV' - assert convolution_base.unit == 'eV' + assert convolution_base.x_unit == 'eV' assert np.allclose(convolution_base.energy.values, np.linspace(-0.01, 0.01, 100)) def test_convert_unit_invalid_type_raises(self, convolution_base): @@ -200,7 +200,7 @@ def test_convert_unit_invalid_type_raises(self, convolution_base): TypeError, match=r'Energy unit must be a string or scipp unit.', ): - convolution_base.convert_unit(123) + convolution_base.convert_x_unit(123) def test_convert_unit_invalid_unit_rollback(self, convolution_base): # WHEN THEN @@ -208,10 +208,10 @@ def test_convert_unit_invalid_unit_rollback(self, convolution_base): UnitError, match=r'Conversion from `meV` to `s` is not valid.', ): - convolution_base.convert_unit('s') + convolution_base.convert_x_unit('s') # EXPECT - assert convolution_base.unit == 'meV' + assert convolution_base.x_unit == 'meV' assert np.allclose(convolution_base.energy.values, np.linspace(-10, 10, 100)) def test_convert_unit_invalid_offset_unit_rollback(self, convolution_base): @@ -223,10 +223,10 @@ def test_convert_unit_invalid_offset_unit_rollback(self, convolution_base): UnitError, match=r'Conversion from `s` to `meV` is not valid.', ): - convolution_base.convert_unit('meV') + convolution_base.convert_x_unit('meV') # EXPECT - assert convolution_base.unit == 'meV' + assert convolution_base.x_unit == 'meV' assert convolution_base.energy_offset.unit == 's' def test_energy_offset_property(self, convolution_base): @@ -255,6 +255,18 @@ def test_energy_with_offset_setter_raises(self, convolution_base): with pytest.raises(AttributeError): convolution_base.energy_with_offset = 5 + def test_energy_with_offset_unit_conversion(self, convolution_base): + # When energy_offset has a different but compatible unit, the property converts it + convolution_base.energy_offset = Parameter( + name='energy_offset', + value=0.001, + unit='eV', # 0.001 eV = 1 meV + ) + result = convolution_base.energy_with_offset + # energy is in meV, offset 0.001 eV = 1 meV → shifted by -1 meV + expected = convolution_base.energy.values - 1.0 + np.testing.assert_allclose(result.values, expected, rtol=1e-5) + def test_sample_components_property(self, convolution_base): # WHEN THEN EXPECT assert isinstance(convolution_base.sample_components, ComponentCollection) diff --git a/tests/unit/easydynamics/convolution/test_numerical_convolution.py b/tests/unit/easydynamics/convolution/test_numerical_convolution.py index 755381c2..0ba9ade9 100644 --- a/tests/unit/easydynamics/convolution/test_numerical_convolution.py +++ b/tests/unit/easydynamics/convolution/test_numerical_convolution.py @@ -1,6 +1,8 @@ # SPDX-FileCopyrightText: 2026 EasyScience contributors # SPDX-License-Identifier: BSD-3-Clause +from unittest.mock import patch + import numpy as np import pytest import scipp as sc @@ -48,7 +50,7 @@ def test_init(self, default_numerical_convolution): assert default_numerical_convolution.upsample_factor == 5 assert default_numerical_convolution.extension_factor == pytest.approx(0.2) assert default_numerical_convolution.temperature is None - assert default_numerical_convolution.unit == 'meV' + assert default_numerical_convolution.x_unit == 'meV' assert ( default_numerical_convolution.detailed_balance_settings.normalize_detailed_balance is True @@ -233,3 +235,58 @@ def fake_interp(*args, **kwargs): # noqa: ARG001 assert result.shape == conv.energy.values.shape else: assert result.shape == dense.shape + + def test_convolution_with_energy_offset_in_different_unit(self, default_numerical_convolution): + # Setting energy_offset as a Parameter in eV when energy grid is in meV exercises the + # unit-conversion branch (line 53 in numerical_convolution.py) + from easyscience.variable import Parameter + + conv = default_numerical_convolution + # energy is in meV; set offset as Parameter in eV → unit != energy unit → sc.to_unit called + conv.energy_offset = Parameter(name='energy_offset', value=0.001, unit='eV') + result = conv.convolution() + # With default upsample_factor=5 the result is interpolated back to the original grid + assert result.shape == conv.energy.values.shape + + def test_repr(self, default_numerical_convolution): + r = repr(default_numerical_convolution) + assert 'NumericalConvolution' in r + assert 'energy_len=' in r + + # ───── Regression tests ───── + + def test_detailed_balance_energy_includes_even_length_offset(self): + # GIVEN: even-length energy grid and non-zero temperature (triggers DBF) + energy = np.linspace(-10, 10, 100) # 100 points = even length + sample_components = ComponentCollection(display_name='Sample') + sample_components.append_component(Gaussian(name='G', area=1.0, center=0.0, width=1.0)) + resolution_components = ComponentCollection(display_name='Resolution') + resolution_components.append_component(Gaussian(name='R', area=1.0, center=0.0, width=0.5)) + nc = NumericalConvolution( + energy=energy, + sample_components=sample_components, + resolution_components=resolution_components, + ) + nc.temperature = 300.0 + + captured_energy = [] + + original_dbf = detailed_balance_factor + + def capturing_dbf(**kwargs): + captured_energy.append(kwargs['energy'].copy()) + return original_dbf(**kwargs) + + with patch( + 'easydynamics.convolution.numerical_convolution.detailed_balance_factor', + capturing_dbf, + ): + nc.convolution() + + assert len(captured_energy) == 1 + # The energy passed to DBF must include energy_even_length_offset. + # Before the fix it was energy_dense - offset_value (missing the offset), + # which for an even-length grid differs from energy_dense by half a bin step. + grid = nc._energy_grid + expected = grid.energy_dense - grid.energy_even_length_offset + np.testing.assert_allclose(captured_energy[0], expected, atol=1e-12) diff --git a/tests/unit/easydynamics/convolution/test_numerical_convolution_base.py b/tests/unit/easydynamics/convolution/test_numerical_convolution_base.py index 73bcee0b..e3a57981 100644 --- a/tests/unit/easydynamics/convolution/test_numerical_convolution_base.py +++ b/tests/unit/easydynamics/convolution/test_numerical_convolution_base.py @@ -48,7 +48,7 @@ def test_init(self, default_numerical_convolution_base): assert default_numerical_convolution_base.upsample_factor == 5 assert default_numerical_convolution_base.extension_factor == pytest.approx(0.2) assert default_numerical_convolution_base.temperature is None - assert default_numerical_convolution_base.unit == 'meV' + assert default_numerical_convolution_base.x_unit == 'meV' assert ( default_numerical_convolution_base.detailed_balance_settings.normalize_detailed_balance is True @@ -79,7 +79,7 @@ def test_init_with_custom_parameters(self): detailed_balance_settings=detailed_balance_settings, temperature=temperature, temperature_unit=temperature_unit, - unit=unit, + x_unit=unit, ) # EXPECT @@ -87,7 +87,7 @@ def test_init_with_custom_parameters(self): assert numerical_convolution_base.extension_factor == pytest.approx(0.5) assert numerical_convolution_base.temperature.value == temperature assert numerical_convolution_base.temperature.unit == temperature_unit - assert numerical_convolution_base.unit == unit + assert numerical_convolution_base.x_unit == unit assert ( numerical_convolution_base.detailed_balance_settings.normalize_detailed_balance is False @@ -638,12 +638,12 @@ def test_repr(self, default_numerical_convolution_base): # Sample and resolution models assert 'ComponentCollection' in repr_str - assert 'components=[No components]' in repr_str + assert 'Components: No components' in repr_str assert 'sample_components=' in repr_str assert 'resolution_components=' in repr_str # Important parameters - assert 'unit=meV' in repr_str + assert 'x_unit=meV' in repr_str assert 'upsample_factor=5' in repr_str assert 'extension_factor=0.2' in repr_str assert 'temperature=None' in repr_str diff --git a/tests/unit/easydynamics/experiment/test_experiment.py b/tests/unit/easydynamics/experiment/test_experiment.py index 7ffc83c8..6201bbc0 100644 --- a/tests/unit/easydynamics/experiment/test_experiment.py +++ b/tests/unit/easydynamics/experiment/test_experiment.py @@ -319,15 +319,21 @@ def test_get_masked_energy_no_data_returns_None(self): @pytest.mark.parametrize( 'Q_index', - [-1, 100, 'not an index'], - ids=['negative_index', 'out_of_bounds_index', 'invalid_type'], + [-1, 100], + ids=['negative_index', 'out_of_bounds_index'], ) def test_get_masked_energy_invalid_Q_index_raises(self, experiment_with_data, Q_index): - "Test getting masked energy raises IndexError when Q index is invalid" + "Test getting masked energy raises IndexError when Q index is out of range" # WHEN THEN EXPECT with pytest.raises(IndexError): experiment_with_data.get_masked_energy(Q_index=Q_index) + def test_get_masked_energy_invalid_type_raises(self, experiment_with_data): + "Test getting masked energy raises TypeError when Q index is not an integer" + # WHEN THEN EXPECT + with pytest.raises(TypeError): + experiment_with_data.get_masked_energy(Q_index='not an index') + ############## # test plotting ############## 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 02fc31df..85f67f89 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 @@ -6,6 +6,7 @@ import numpy as np import pytest from easyscience.variable import Parameter +from scipp import UnitError from scipy.integrate import simpson from easydynamics.sample_model import DampedHarmonicOscillator @@ -20,7 +21,7 @@ def dho(self): area=2.0, center=1.5, width=0.3, - unit='meV', + x_unit='meV', ) def test_init_no_inputs(self): @@ -32,7 +33,7 @@ def test_init_no_inputs(self): assert dho.area.value == pytest.approx(1.0) assert dho.center.value == pytest.approx(1.0) assert dho.width.value == pytest.approx(1.0) - assert dho.unit == 'meV' + assert dho.x_unit == 'meV' def test_initialization(self, dho: DampedHarmonicOscillator): # WHEN THEN EXPECT @@ -40,25 +41,25 @@ def test_initialization(self, dho: DampedHarmonicOscillator): assert dho.area.value == pytest.approx(2.0) assert dho.center.value == pytest.approx(1.5) assert dho.width.value == pytest.approx(0.3) - assert dho.unit == 'meV' + assert dho.x_unit == 'meV' @pytest.mark.parametrize( 'kwargs, expected_message', [ ( - {'area': 'invalid', 'center': 0.5, 'width': 0.6, 'unit': 'meV'}, + {'area': 'invalid', 'center': 0.5, 'width': 0.6, 'x_unit': 'meV'}, 'area must be a number', ), ( - {'area': 2.0, 'center': 'invalid', 'width': 0.6, 'unit': 'meV'}, + {'area': 2.0, 'center': 'invalid', 'width': 0.6, 'x_unit': 'meV'}, 'center must be ', ), ( - {'area': 2.0, 'center': 0.5, 'width': 'invalid', 'unit': 'meV'}, + {'area': 2.0, 'center': 0.5, 'width': 'invalid', 'x_unit': 'meV'}, 'width must be a number', ), ( - {'area': 2.0, 'center': 0.5, 'width': 0.6, 'unit': 123}, + {'area': 2.0, 'center': 0.5, 'width': 0.6, 'x_unit': 123}, 'unit must be None', ), ], @@ -78,7 +79,7 @@ def test_negative_width_raises(self): area=2.0, center=0.5, width=-0.6, - unit='meV', + x_unit='meV', ) def test_negative_area_warns(self): @@ -89,7 +90,7 @@ def test_negative_area_warns(self): area=-2.0, center=0.5, width=0.6, - unit='meV', + x_unit='meV', ) @pytest.mark.parametrize( @@ -169,10 +170,10 @@ def test_area_matches_parameter(self, dho: DampedHarmonicOscillator): def test_convert_unit(self, dho: DampedHarmonicOscillator): # WHEN THEN - dho.convert_unit('microeV') + dho.convert_x_unit('microeV') # EXPECT - assert dho.unit == 'microeV' + assert dho.x_unit == 'microeV' assert dho.area.value == pytest.approx(2 * 1e3) assert dho.center.value == pytest.approx(1.5 * 1e3) assert dho.width.value == pytest.approx(0.3 * 1e3) @@ -194,7 +195,7 @@ def test_copy(self, dho: DampedHarmonicOscillator): assert dho_copy.width.value == dho.width.value assert dho_copy.width.fixed == dho.width.fixed - assert dho_copy.unit == dho.unit + assert dho_copy.x_unit == dho.x_unit def test_repr(self, dho: DampedHarmonicOscillator): # WHEN THEN @@ -202,8 +203,54 @@ def test_repr(self, dho: DampedHarmonicOscillator): # EXPECT assert 'DampedHarmonicOscillator' in repr_str - assert "name='TestDHOName'" in repr_str - assert 'unit=meV' in repr_str - assert 'area=' in repr_str - assert 'center=' in repr_str - assert 'width=' in repr_str + assert 'name = TestDHOName' in repr_str + assert 'x_unit = meV' in repr_str + assert 'area =' in repr_str + assert 'center =' in repr_str + assert 'width =' in repr_str + + def test_y_unit_default(self, dho: DampedHarmonicOscillator): + assert dho.y_unit == 'dimensionless' + + def test_convert_y_unit(self): + # GIVEN: x_unit='meV', y_unit='1/meV' → area_unit='dimensionless' + dho = DampedHarmonicOscillator( + area=1.0, center=1.0, width=0.3, x_unit='meV', y_unit='1/meV' + ) + # WHEN: convert y_unit to '1/eV' (same dimension, different scale) + dho.convert_y_unit('1/eV') + # EXPECT: y_unit updated and area value rescaled (1e3 factor) + assert dho.y_unit == '1/eV' + assert dho.area.value == pytest.approx(1e3) + + def test_convert_y_unit_invalid_type_raises(self, dho: DampedHarmonicOscillator): + with pytest.raises(TypeError): + dho.convert_y_unit(123) + + def test_evaluate_scipp_output(self, dho: DampedHarmonicOscillator): + import scipp as sc + + x = np.linspace(0.5, 5.0, 50) + result = dho.evaluate(x, output='scipp') + assert isinstance(result, sc.Variable) + assert result.unit == sc.Unit('dimensionless') + assert len(result.values) == 50 + + def test_convert_x_unit_invalid_type_raises(self, dho: DampedHarmonicOscillator): + with pytest.raises(TypeError, match=r'x_unit must be a string or sc\.Unit'): + dho.convert_x_unit(123) + + def test_convert_x_unit_rollback_on_failure(self, dho: DampedHarmonicOscillator): + with pytest.raises(UnitError): + dho.convert_x_unit('m') + assert dho.x_unit == 'meV' + assert dho.area.value == pytest.approx(2.0) + assert dho.center.value == pytest.approx(1.5) + assert dho.width.value == pytest.approx(0.3) + + def test_convert_y_unit_rollback_on_failure(self): + dho = DampedHarmonicOscillator(area=1.0, center=1.0, width=0.3, x_unit='meV') + with pytest.raises(UnitError): + dho.convert_y_unit('K') + assert dho.y_unit == 'dimensionless' + assert dho.area.value == pytest.approx(1.0) diff --git a/tests/unit/easydynamics/sample_model/components/test_delta_function.py b/tests/unit/easydynamics/sample_model/components/test_delta_function.py index de3148b7..23c0a1dd 100644 --- a/tests/unit/easydynamics/sample_model/components/test_delta_function.py +++ b/tests/unit/easydynamics/sample_model/components/test_delta_function.py @@ -20,7 +20,7 @@ def delta_function(self): display_name='TestDeltaFunction', area=2.0, center=0.5, - unit='meV', + x_unit='meV', ) def test_init_no_inputs(self): @@ -31,7 +31,7 @@ def test_init_no_inputs(self): assert delta_function.display_name == 'DeltaFunction' assert delta_function.area.value == pytest.approx(1.0) assert delta_function.center.value == pytest.approx(0.0) - assert delta_function.unit == 'meV' + assert delta_function.x_unit == 'meV' assert delta_function.center.fixed is True def test_initialization(self, delta_function: DeltaFunction): @@ -39,21 +39,21 @@ def test_initialization(self, delta_function: DeltaFunction): assert delta_function.display_name == 'TestDeltaFunction' assert delta_function.area.value == pytest.approx(2.0) assert delta_function.center.value == pytest.approx(0.5) - assert delta_function.unit == 'meV' + assert delta_function.x_unit == 'meV' @pytest.mark.parametrize( 'kwargs, expected_message', [ ( - {'area': 'invalid', 'center': 0.5, 'unit': 'meV'}, + {'area': 'invalid', 'center': 0.5, 'x_unit': 'meV'}, 'area must be a number', ), ( - {'area': 2.0, 'center': 'invalid', 'unit': 'meV'}, + {'area': 2.0, 'center': 'invalid', 'x_unit': 'meV'}, 'center must be ', ), ( - {'area': 2.0, 'center': 0.5, 'unit': 123}, + {'area': 2.0, 'center': 0.5, 'x_unit': 123}, 'unit must be ', ), ], @@ -65,7 +65,7 @@ def test_input_type_validation_raises(self, kwargs, expected_message): def test_negative_area_warns(self): # WHEN THEN EXPECT with pytest.warns(UserWarning, match='may not be physically meaningful'): - DeltaFunction(display_name='TestDeltaFunction', area=-2.0, center=0.5, unit='meV') + DeltaFunction(display_name='TestDeltaFunction', area=-2.0, center=0.5, x_unit='meV') @pytest.mark.parametrize( 'prop, valid_value, invalid_value, invalid_message', @@ -147,7 +147,7 @@ def test_evaluate_with_incompatible_unit_raises(self, delta_function: DeltaFunct # THEN EXPECT with pytest.raises( UnitError, - match='Input x has unit nm, but DeltaFunction component ', + match='Input x has unit nm', ): delta_function.evaluate(x) @@ -192,10 +192,10 @@ def test_get_all_parameters(self, delta_function: DeltaFunction): def test_convert_unit(self, delta_function: DeltaFunction): # WHEN THEN - delta_function.convert_unit('microeV') + delta_function.convert_x_unit('microeV') # EXPECT - assert delta_function.unit == 'microeV' + assert delta_function.x_unit == 'microeV' assert delta_function.area.value == pytest.approx(2 * 1e3) assert delta_function.center.value == pytest.approx(0.5 * 1e3) @@ -213,7 +213,7 @@ def test_copy(self, delta_function: DeltaFunction): assert delta_copy.center.value == delta_function.center.value assert delta_copy.center.fixed == delta_function.center.fixed - assert delta_copy.unit == delta_function.unit + assert delta_copy.x_unit == delta_function.x_unit def test_repr(self, delta_function: DeltaFunction): # WHEN THEN @@ -221,7 +221,72 @@ def test_repr(self, delta_function: DeltaFunction): # EXPECT assert 'DeltaFunction' in repr_str - assert "name='DeltaFunctionName'" in repr_str - assert 'unit=meV' in repr_str - assert 'area=' in repr_str - assert 'center=' in repr_str + assert 'name = DeltaFunctionName' in repr_str + assert 'x_unit = meV' in repr_str + assert 'area =' in repr_str + assert 'center =' in repr_str + + def test_y_unit_default(self, delta_function: DeltaFunction): + assert delta_function.y_unit == 'dimensionless' + + def test_convert_y_unit(self): + # GIVEN: x_unit='meV', y_unit='1/meV' → area_unit='dimensionless' + delta = DeltaFunction(area=1.0, x_unit='meV', y_unit='1/meV') + # WHEN: convert y_unit to '1/eV' (same dimension, different scale) + delta.convert_y_unit('1/eV') + # EXPECT: y_unit updated and area value rescaled (1e3 factor) + assert delta.y_unit == '1/eV' + assert delta.area.value == pytest.approx(1e3) + + def test_convert_y_unit_invalid_type_raises(self, delta_function: DeltaFunction): + with pytest.raises(TypeError): + delta_function.convert_y_unit(123) + + def test_evaluate_scipp_output(self, delta_function: DeltaFunction): + import scipp as sc + + x = np.linspace(-5, 5, 50) + result = delta_function.evaluate(x, output='scipp') + assert isinstance(result, sc.Variable) + assert result.unit == sc.Unit('dimensionless') + assert len(result.values) == 50 + + @pytest.mark.parametrize( + 'x, center, expected_idx', + [ + (np.array([0.5, 1.0, 2.0]), 0.5, 0), # center at first element (line 202) + (np.array([0.0, 1.0, 1.5]), 1.5, 2), # center at last element (line 207) + ], + ids=['center_at_first', 'center_at_last'], + ) + def test_evaluate_center_at_boundary(self, x, center, expected_idx): + area = 1.0 + delta = DeltaFunction(area=area, center=center, x_unit='meV') + result = delta.evaluate(x) + # All elements except the boundary one should be zero + assert result[expected_idx] > 0.0 + other_indices = [i for i in range(len(x)) if i != expected_idx] + assert all(result[i] == pytest.approx(0.0) for i in other_indices) + # Boundary bin width: both left and right are set to the single adjacent spacing + bin_width = x[1] - x[0] if expected_idx == 0 else x[-1] - x[-2] + assert np.isclose(result[expected_idx], area / bin_width, rtol=1e-10) + + def test_convert_x_unit_invalid_type_raises(self, delta_function: DeltaFunction): + with pytest.raises(TypeError, match=r'x_unit must be a string or sc\.Unit'): + delta_function.convert_x_unit(123) + + def test_convert_x_unit_rollback_on_failure(self, delta_function: DeltaFunction): + with pytest.raises(UnitError): + delta_function.convert_x_unit('m') + # Parameters should be unchanged after rollback + assert delta_function.x_unit == 'meV' + assert delta_function.area.value == pytest.approx(2.0) + assert delta_function.center.value == pytest.approx(0.5) + + def test_convert_y_unit_rollback_on_failure(self): + delta = DeltaFunction(area=1.0, center=0.0, x_unit='meV', y_unit='dimensionless') + with pytest.raises(UnitError): + delta.convert_y_unit('K') + # State should be unchanged after rollback + assert delta.y_unit == 'dimensionless' + assert delta.area.value == pytest.approx(1.0) diff --git a/tests/unit/easydynamics/sample_model/components/test_exponential.py b/tests/unit/easydynamics/sample_model/components/test_exponential.py index b7fa5503..67671512 100644 --- a/tests/unit/easydynamics/sample_model/components/test_exponential.py +++ b/tests/unit/easydynamics/sample_model/components/test_exponential.py @@ -20,7 +20,7 @@ def exponential(self): amplitude=2.0, center=0.5, rate=1.2, - unit='meV', + x_unit='meV', ) def test_init_no_inputs(self): @@ -32,7 +32,7 @@ def test_init_no_inputs(self): assert exponential.amplitude.value == pytest.approx(1.0) assert exponential.center.value == pytest.approx(0.0) assert exponential.rate.value == pytest.approx(1.0) - assert exponential.unit == 'meV' + assert exponential.x_unit == 'meV' def test_initialization(self, exponential: Exponential): # WHEN THEN EXPECT @@ -40,21 +40,21 @@ def test_initialization(self, exponential: Exponential): assert exponential.amplitude.value == pytest.approx(2.0) assert exponential.center.value == pytest.approx(0.5) assert exponential.rate.value == pytest.approx(1.2) - assert exponential.unit == 'meV' + assert exponential.x_unit == 'meV' @pytest.mark.parametrize( 'kwargs, expected_message', [ ( - {'amplitude': 'invalid', 'center': 0.5, 'rate': 1.0, 'unit': 'meV'}, + {'amplitude': 'invalid', 'center': 0.5, 'rate': 1.0, 'x_unit': 'meV'}, 'amplitude must be a number', ), ( - {'amplitude': 2.0, 'center': 'invalid', 'rate': 1.0, 'unit': 'meV'}, + {'amplitude': 2.0, 'center': 'invalid', 'rate': 1.0, 'x_unit': 'meV'}, 'center must be None or a number', ), ( - {'amplitude': 2.0, 'center': 0.5, 'rate': 'invalid', 'unit': 'meV'}, + {'amplitude': 2.0, 'center': 0.5, 'rate': 'invalid', 'x_unit': 'meV'}, 'rate must be a number', ), ], @@ -67,12 +67,12 @@ def test_input_type_validation_raises(self, kwargs, expected_message): 'kwargs, expected_message', [ ( - {'amplitude': np.nan, 'center': 0.5, 'rate': 1.0, 'unit': 'meV'}, - 'amplitude must be a finite number or a Parameter', + {'amplitude': np.nan, 'center': 0.5, 'rate': 1.0, 'x_unit': 'meV'}, + 'amplitude must be finite', ), ( - {'amplitude': 2.0, 'center': 0.5, 'rate': np.nan, 'unit': 'meV'}, - 'rate must be a finite number or a Parameter', + {'amplitude': 2.0, 'center': 0.5, 'rate': np.nan, 'x_unit': 'meV'}, + 'rate must be finite', ), ], ) @@ -146,10 +146,10 @@ def test_get_all_parameters(self, exponential: Exponential): def test_convert_unit(self, exponential: Exponential): # WHEN - exponential.convert_unit('microeV') + exponential.convert_x_unit('microeV') # THEN EXPECT - assert exponential.unit == 'microeV' + assert exponential.x_unit == 'microeV' assert exponential.amplitude.value == pytest.approx(2.0 * 1e3) assert exponential.center.value == pytest.approx(0.5 * 1e3) @@ -161,7 +161,7 @@ def test_convert_unit(self, exponential: Exponential): def test_convert_unit_incorrect_unit_raises(self, exponential: Exponential): # WHEN THEN EXPECT with pytest.raises(TypeError, match=r'unit must be a string or sc.Unit'): - exponential.convert_unit(123) + exponential.convert_x_unit(123) def test_convert_unit_rollback(self, exponential: Exponential): # WHEN @@ -169,10 +169,10 @@ def test_convert_unit_rollback(self, exponential: Exponential): UnitError, match=r'Failed to convert unit: Conversion from `meV` to `m` is not valid.', ): - exponential.convert_unit('m') + exponential.convert_x_unit('m') # THEN EXPECT - values should be unchanged - assert exponential.unit == 'meV' + assert exponential.x_unit == 'meV' assert exponential.amplitude.value == pytest.approx(2.0) assert exponential.amplitude.unit == 'meV' assert exponential.center.value == pytest.approx(0.5) @@ -197,7 +197,7 @@ def test_copy(self, exponential: Exponential): assert exponential_copy.rate.value == exponential.rate.value assert exponential_copy.rate.fixed == exponential.rate.fixed - assert exponential_copy.unit == exponential.unit + assert exponential_copy.x_unit == exponential.x_unit def test_repr(self, exponential: Exponential): # WHEN @@ -205,8 +205,50 @@ def test_repr(self, exponential: Exponential): # THEN EXPECT assert 'Exponential' in repr_str - assert "name='ExponentialName'" in repr_str - assert 'unit=meV' in repr_str - assert 'amplitude=' in repr_str - assert 'center=' in repr_str - assert 'rate=' in repr_str + assert 'name = ExponentialName' in repr_str + assert 'x_unit = meV' in repr_str + assert 'amplitude =' in repr_str + assert 'center =' in repr_str + assert 'rate =' in repr_str + + def test_y_unit_default(self, exponential: Exponential): + assert exponential.y_unit == 'dimensionless' + + def test_convert_y_unit(self): + # GIVEN: x_unit='meV', y_unit='1/meV' → amplitude_unit='dimensionless' + exp = Exponential(amplitude=1.0, center=0.0, rate=1.0, x_unit='meV', y_unit='1/meV') + # WHEN: convert y_unit to '1/eV' (same dimension, different scale) + exp.convert_y_unit('1/eV') + # EXPECT: y_unit updated and amplitude value rescaled (1e3 factor) + assert exp.y_unit == '1/eV' + assert exp.amplitude.value == pytest.approx(1e3) + + def test_convert_y_unit_invalid_type_raises(self, exponential: Exponential): + with pytest.raises(TypeError): + exponential.convert_y_unit(123) + + def test_evaluate_scipp_output(self, exponential: Exponential): + import scipp as sc + + x = np.linspace(-5, 5, 50) + result = exponential.evaluate(x, output='scipp') + assert isinstance(result, sc.Variable) + assert result.unit == sc.Unit('dimensionless') + assert len(result.values) == 50 + + def test_init_rejects_parameter_amplitude(self): + amplitude_param = Parameter(name='amp', value=3.0, unit='meV') + with pytest.raises(TypeError, match='amplitude must be a number'): + Exponential(amplitude=amplitude_param, x_unit='meV') + + def test_init_rejects_parameter_rate(self): + rate_param = Parameter(name='rate', value=0.5, unit='1/meV') + with pytest.raises(TypeError, match='rate must be a number'): + Exponential(rate=rate_param, x_unit='meV') + + def test_convert_y_unit_rollback_on_failure(self): + exp = Exponential(amplitude=1.0, center=0.0, rate=1.0, x_unit='meV') + with pytest.raises(UnitError): + exp.convert_y_unit('K') + assert exp.y_unit == 'dimensionless' + assert exp.amplitude.value == pytest.approx(1.0) diff --git a/tests/unit/easydynamics/sample_model/components/test_expression_component.py b/tests/unit/easydynamics/sample_model/components/test_expression_component.py index 48fbafe2..15e38cc5 100644 --- a/tests/unit/easydynamics/sample_model/components/test_expression_component.py +++ b/tests/unit/easydynamics/sample_model/components/test_expression_component.py @@ -16,14 +16,14 @@ def expr(self): return ExpressionComponent( 'A * exp(-(x - x0)**2 / (2*sigma**2))', parameters={'A': 2.0, 'x0': 0.5, 'sigma': 0.6}, - unit='meV', + x_unit='meV', display_name='TestExpression', ) def test_init_valid(self, expr: ExpressionComponent): # WHEN THEN EXPECT assert expr.display_name == 'TestExpression' - assert expr.unit == 'meV' + assert expr.x_unit == 'meV' assert expr.A.value == pytest.approx(2.0) assert expr.x0.value == pytest.approx(0.5) @@ -134,7 +134,20 @@ def test_expression_is_read_only(self, expr: ExpressionComponent): def test_convert_unit_not_implemented(self, expr: ExpressionComponent): # WHEN THEN EXPECT with pytest.raises(NotImplementedError, match='not implemented'): - expr.convert_unit('microeV') + expr.convert_x_unit('microeV') + + def test_convert_y_unit_not_implemented(self, expr: ExpressionComponent): + with pytest.raises(NotImplementedError, match='not implemented'): + expr.convert_y_unit('1/meV') + + def test_evaluate_scipp_output(self, expr: ExpressionComponent): + import scipp as sc + + x = np.linspace(-2, 2, 30) + result = expr.evaluate(x, output='scipp') + assert isinstance(result, sc.Variable) + assert result.unit == sc.Unit('dimensionless') + assert len(result.values) == 30 def test_missing_parameter_defaults(self): # WHEN THEN @@ -191,7 +204,7 @@ def test_copy(self, expr: ExpressionComponent): assert expr_copy is not expr assert isinstance(expr_copy, ExpressionComponent) assert expr_copy.expression == expr.expression - assert expr_copy.unit == expr.unit + assert expr_copy.x_unit == expr.x_unit assert expr_copy.display_name == expr.display_name assert expr_copy.A.value == pytest.approx(expr.A.value) diff --git a/tests/unit/easydynamics/sample_model/components/test_gaussian.py b/tests/unit/easydynamics/sample_model/components/test_gaussian.py index a370e74f..98f465ed 100644 --- a/tests/unit/easydynamics/sample_model/components/test_gaussian.py +++ b/tests/unit/easydynamics/sample_model/components/test_gaussian.py @@ -6,6 +6,7 @@ import numpy as np import pytest from easyscience.variable import Parameter +from scipp import UnitError from scipy.integrate import simpson from easydynamics.sample_model import Gaussian @@ -20,7 +21,7 @@ def gaussian(self): area=2.0, center=0.5, width=0.6, - unit='meV', + x_unit='meV', ) def test_init_no_inputs(self): @@ -32,7 +33,7 @@ def test_init_no_inputs(self): assert gaussian.area.value == pytest.approx(1.0) assert gaussian.center.value == pytest.approx(0.0) assert gaussian.width.value == pytest.approx(1.0) - assert gaussian.unit == 'meV' + assert gaussian.x_unit == 'meV' assert gaussian.center.fixed is True def test_initialization(self, gaussian: Gaussian): @@ -41,25 +42,25 @@ def test_initialization(self, gaussian: Gaussian): assert gaussian.area.value == pytest.approx(2.0) assert gaussian.center.value == pytest.approx(0.5) assert gaussian.width.value == pytest.approx(0.6) - assert gaussian.unit == 'meV' + assert gaussian.x_unit == 'meV' @pytest.mark.parametrize( 'kwargs, expected_message', [ ( - {'area': 'invalid', 'center': 0.5, 'width': 0.6, 'unit': 'meV'}, + {'area': 'invalid', 'center': 0.5, 'width': 0.6, 'x_unit': 'meV'}, 'area must be a number', ), ( - {'area': 2.0, 'center': 'invalid', 'width': 0.6, 'unit': 'meV'}, + {'area': 2.0, 'center': 'invalid', 'width': 0.6, 'x_unit': 'meV'}, 'center must be None or a number', ), ( - {'area': 2.0, 'center': 0.5, 'width': 'invalid', 'unit': 'meV'}, + {'area': 2.0, 'center': 0.5, 'width': 'invalid', 'x_unit': 'meV'}, 'width must be a number', ), ( - {'area': 2.0, 'center': 0.5, 'width': 0.6, 'unit': 123}, + {'area': 2.0, 'center': 0.5, 'width': 0.6, 'x_unit': 123}, 'unit must be None', ), ], @@ -84,7 +85,7 @@ def test_negative_width_raises(self): area=2.0, center=0.5, width=-0.6, - unit='meV', + x_unit='meV', ) def test_negative_area_warns(self): @@ -95,7 +96,7 @@ def test_negative_area_warns(self): area=-2.0, center=0.5, width=0.6, - unit='meV', + x_unit='meV', ) @pytest.mark.parametrize( @@ -179,10 +180,10 @@ def test_area_matches_parameter(self, gaussian: Gaussian): def test_convert_unit(self, gaussian: Gaussian): # WHEN THEN - gaussian.convert_unit('microeV') + gaussian.convert_x_unit('microeV') # EXPECT - assert gaussian.unit == 'microeV' + assert gaussian.x_unit == 'microeV' assert gaussian.area.value == pytest.approx(2 * 1e3) assert gaussian.center.value == pytest.approx(0.5 * 1e3) assert gaussian.width.value == pytest.approx(0.6 * 1e3) @@ -216,15 +217,93 @@ def test_copy(self, gaussian: Gaussian): assert gaussian_copy.width.min == gaussian.width.min assert gaussian_copy.width.max == gaussian.width.max - assert gaussian_copy.unit == gaussian.unit + assert gaussian_copy.x_unit == gaussian.x_unit def test_repr(self, gaussian: Gaussian): # WHEN THEN repr_str = repr(gaussian) # EXPECT assert 'Gaussian' in repr_str - assert "name='GaussianName'" in repr_str - assert 'unit=meV' in repr_str - assert 'area=' in repr_str - assert 'center=' in repr_str - assert 'width=' in repr_str + assert 'name = GaussianName' in repr_str + assert 'x_unit = meV' in repr_str + assert 'area =' in repr_str + assert 'center =' in repr_str + assert 'width =' in repr_str + + def test_y_unit_default(self, gaussian: Gaussian): + # EXPECT + assert gaussian.y_unit == 'dimensionless' + + def test_y_unit_custom(self): + # WHEN + gaussian = Gaussian(area=1.0, x_unit='meV', y_unit='1/meV') + # EXPECT + assert gaussian.y_unit == '1/meV' + + def test_y_unit_setter_raises(self, gaussian: Gaussian): + # EXPECT + with pytest.raises(AttributeError): + gaussian.y_unit = '1/meV' + + def test_convert_y_unit(self): + # GIVEN: x_unit='meV', y_unit='1/meV' → area_unit ≈ dimensionless + gaussian = Gaussian(area=1.0, x_unit='meV', y_unit='1/meV') + + # WHEN: convert y_unit to '1/eV' (same dimension, different scale) + gaussian.convert_y_unit('1/eV') + + # EXPECT: unit updated and area value rescaled (1/eV = 1e-3/meV, so value x 1e3) + assert gaussian.y_unit == '1/eV' + assert gaussian.area.value == pytest.approx(1e3) + + def test_convert_y_unit_invalid_type_raises(self, gaussian: Gaussian): + # EXPECT + with pytest.raises(TypeError): + gaussian.convert_y_unit(123) + + def test_evaluate_scipp_output(self, gaussian: Gaussian): + import numpy as np + import scipp as sc + + x = np.linspace(-5, 5, 100) + + # WHEN + result = gaussian.evaluate(x, output='scipp') + + # EXPECT + assert isinstance(result, sc.Variable) + assert result.unit == sc.Unit('dimensionless') + assert len(result.values) == 100 + + def test_evaluate_scipp_output_with_y_unit(self): + import numpy as np + import scipp as sc + + gaussian = Gaussian(area=1.0, x_unit='meV', y_unit='1/meV') + x = np.linspace(-5, 5, 100) + + # WHEN + result = gaussian.evaluate(x, output='scipp') + + # EXPECT + assert isinstance(result, sc.Variable) + assert result.unit == sc.Unit('1/meV') + + def test_convert_x_unit_invalid_type_raises(self, gaussian: Gaussian): + with pytest.raises(TypeError, match=r'x_unit must be a string or sc\.Unit'): + gaussian.convert_x_unit(123) + + def test_convert_x_unit_rollback_on_failure(self, gaussian: Gaussian): + with pytest.raises(UnitError): + gaussian.convert_x_unit('m') + assert gaussian.x_unit == 'meV' + assert gaussian.area.value == pytest.approx(2.0) + assert gaussian.center.value == pytest.approx(0.5) + assert gaussian.width.value == pytest.approx(0.6) + + def test_convert_y_unit_rollback_on_failure(self): + gaussian = Gaussian(area=1.0, center=0.0, width=0.5, x_unit='meV') + with pytest.raises(UnitError): + gaussian.convert_y_unit('K') + assert gaussian.y_unit == 'dimensionless' + assert gaussian.area.value == pytest.approx(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 1aefee6a..09942529 100644 --- a/tests/unit/easydynamics/sample_model/components/test_lorentzian.py +++ b/tests/unit/easydynamics/sample_model/components/test_lorentzian.py @@ -6,6 +6,7 @@ import numpy as np import pytest from easyscience.variable import Parameter +from scipp import UnitError from scipy.integrate import simpson from easydynamics.sample_model import Lorentzian @@ -20,7 +21,7 @@ def lorentzian(self): area=2.0, center=0.5, width=0.6, - unit='meV', + x_unit='meV', ) def test_init_no_inputs(self): @@ -32,7 +33,7 @@ def test_init_no_inputs(self): assert lorentzian.area.value == pytest.approx(1.0) assert lorentzian.center.value == pytest.approx(0.0) assert lorentzian.width.value == pytest.approx(1.0) - assert lorentzian.unit == 'meV' + assert lorentzian.x_unit == 'meV' assert lorentzian.center.fixed is True def test_initialization(self, lorentzian: Lorentzian): @@ -41,25 +42,25 @@ def test_initialization(self, lorentzian: Lorentzian): assert lorentzian.area.value == pytest.approx(2.0) assert lorentzian.center.value == pytest.approx(0.5) assert lorentzian.width.value == pytest.approx(0.6) - assert lorentzian.unit == 'meV' + assert lorentzian.x_unit == 'meV' @pytest.mark.parametrize( 'kwargs, expected_message', [ ( - {'area': 'invalid', 'center': 0.5, 'width': 0.6, 'unit': 'meV'}, + {'area': 'invalid', 'center': 0.5, 'width': 0.6, 'x_unit': 'meV'}, 'area must be a number', ), ( - {'area': 2.0, 'center': 'invalid', 'width': 0.6, 'unit': 'meV'}, + {'area': 2.0, 'center': 'invalid', 'width': 0.6, 'x_unit': 'meV'}, 'center must be None', ), ( - {'area': 2.0, 'center': 0.5, 'width': 'invalid', 'unit': 'meV'}, + {'area': 2.0, 'center': 0.5, 'width': 'invalid', 'x_unit': 'meV'}, 'width must be a number', ), ( - {'area': 2.0, 'center': 0.5, 'width': 0.6, 'unit': 123}, + {'area': 2.0, 'center': 0.5, 'width': 0.6, 'x_unit': 123}, 'unit must be None', ), ], @@ -78,7 +79,7 @@ def test_negative_width_raises(self): area=2.0, center=0.5, width=-0.6, - unit='meV', + x_unit='meV', ) def test_negative_area_warns(self): @@ -89,7 +90,7 @@ def test_negative_area_warns(self): area=-2.0, center=0.5, width=0.6, - unit='meV', + x_unit='meV', ) @pytest.mark.parametrize( @@ -168,10 +169,10 @@ def test_area_matches_parameter(self, lorentzian: Lorentzian): def test_convert_unit(self, lorentzian: Lorentzian): # WHEN THEN - lorentzian.convert_unit('microeV') + lorentzian.convert_x_unit('microeV') # EXPECT - assert lorentzian.unit == 'microeV' + assert lorentzian.x_unit == 'microeV' assert lorentzian.area.value == pytest.approx(2 * 1e3) assert lorentzian.center.value == pytest.approx(0.5 * 1e3) assert lorentzian.width.value == pytest.approx(0.6 * 1e3) @@ -193,7 +194,7 @@ def test_copy(self, lorentzian: Lorentzian): assert lorentzian_copy.width.value == lorentzian.width.value assert lorentzian_copy.width.fixed == lorentzian.width.fixed - assert lorentzian_copy.unit == lorentzian.unit + assert lorentzian_copy.x_unit == lorentzian.x_unit def test_repr(self, lorentzian: Lorentzian): # WHEN THEN @@ -201,8 +202,52 @@ def test_repr(self, lorentzian: Lorentzian): # EXPECT assert 'Lorentzian' in repr_str - assert "name='LorentzianName'" in repr_str - assert 'unit=meV' in repr_str - assert 'area=' in repr_str - assert 'center=' in repr_str - assert 'width=' in repr_str + assert 'name = LorentzianName' in repr_str + assert 'x_unit = meV' in repr_str + assert 'area =' in repr_str + assert 'center =' in repr_str + assert 'width =' in repr_str + + def test_y_unit_default(self, lorentzian: Lorentzian): + assert lorentzian.y_unit == 'dimensionless' + + def test_convert_y_unit(self): + # GIVEN: x_unit='meV', y_unit='1/meV' → area_unit='dimensionless' + lor = Lorentzian(area=1.0, x_unit='meV', y_unit='1/meV') + # WHEN: convert y_unit to '1/eV' (same dimension, different scale) + lor.convert_y_unit('1/eV') + # EXPECT: y_unit updated and area value rescaled (1e3 factor) + assert lor.y_unit == '1/eV' + assert lor.area.value == pytest.approx(1e3) + + def test_convert_y_unit_invalid_type_raises(self, lorentzian: Lorentzian): + with pytest.raises(TypeError): + lorentzian.convert_y_unit(123) + + def test_evaluate_scipp_output(self, lorentzian: Lorentzian): + import scipp as sc + + x = np.linspace(-5, 5, 50) + result = lorentzian.evaluate(x, output='scipp') + assert isinstance(result, sc.Variable) + assert result.unit == sc.Unit('dimensionless') + assert len(result.values) == 50 + + def test_convert_x_unit_invalid_type_raises(self, lorentzian: Lorentzian): + with pytest.raises(TypeError, match=r'x_unit must be a string or sc\.Unit'): + lorentzian.convert_x_unit(123) + + def test_convert_x_unit_rollback_on_failure(self, lorentzian: Lorentzian): + with pytest.raises(UnitError): + lorentzian.convert_x_unit('m') + assert lorentzian.x_unit == 'meV' + assert lorentzian.area.value == pytest.approx(2.0) + assert lorentzian.center.value == pytest.approx(0.5) + assert lorentzian.width.value == pytest.approx(0.6) + + def test_convert_y_unit_rollback_on_failure(self): + lor = Lorentzian(area=1.0, center=0.0, width=0.5, x_unit='meV') + with pytest.raises(UnitError): + lor.convert_y_unit('K') + assert lor.y_unit == 'dimensionless' + assert lor.area.value == pytest.approx(1.0) diff --git a/tests/unit/easydynamics/sample_model/components/test_mixins.py b/tests/unit/easydynamics/sample_model/components/test_mixins.py index e30f4bdd..bc7a312d 100644 --- a/tests/unit/easydynamics/sample_model/components/test_mixins.py +++ b/tests/unit/easydynamics/sample_model/components/test_mixins.py @@ -18,7 +18,7 @@ def dummy_model(self): @pytest.mark.parametrize('area_input', [2, 2.0]) def test_create_area_parameter_from_numeric(self, dummy_model, area_input, unit): # WHEN THEN - area_param = dummy_model._create_area_parameter(area_input, 'TestModel', unit=unit) + area_param = dummy_model._create_area_parameter(area_input, 'TestModel', x_unit=unit) # EXPECT assert isinstance(area_param, Parameter) @@ -59,7 +59,7 @@ def test_negative_area_warns(self, dummy_model): def test_create_center_parameter_from_numeric(self, dummy_model, center_input, unit): # WHEN THEN center_param = dummy_model._create_center_parameter( - center_input, 'TestModel', fix_if_none=False, unit=unit + center_input, 'TestModel', fix_if_none=False, x_unit=unit ) # EXPECT assert isinstance(center_param, Parameter) @@ -104,7 +104,7 @@ def test_create_center_parameter_invalid_numeric_raises(self, dummy_model, non_f def test_create_width_parameter_from_numeric(self, dummy_model, width_input, unit): # WHEN THEN width_param = dummy_model._create_width_parameter( - width_input, 'TestModel', param_name='width', unit=unit + width_input, 'TestModel', param_name='width', x_unit=unit ) # EXPECT assert isinstance(width_param, Parameter) diff --git a/tests/unit/easydynamics/sample_model/components/test_model_component.py b/tests/unit/easydynamics/sample_model/components/test_model_component.py index 514ced73..f42cae52 100644 --- a/tests/unit/easydynamics/sample_model/components/test_model_component.py +++ b/tests/unit/easydynamics/sample_model/components/test_model_component.py @@ -5,6 +5,7 @@ import pytest import scipp as sc from easyscience.variable import Parameter +from scipp import UnitError from easydynamics.sample_model.components.model_component import ModelComponent @@ -15,7 +16,7 @@ def __init__(self): self.area = Parameter(name='area', value=1.0, unit='meV', fixed=False) self.center = Parameter(name='center', value=2.0, unit='meV', fixed=True) self.width = Parameter(name='width', value=3.0, unit='meV', fixed=True) - self._unit = 'meV' + self._x_unit = 'meV' def get_all_parameters(self): return [self.area, self.center, self.width] @@ -31,23 +32,23 @@ def dummy(self): def test_unit_cannot_be_set_directly(self, dummy: ModelComponent): # WHEN THEN EXPECT - with pytest.raises(AttributeError, match='Unit is read-only'): - dummy.unit = 'K' + with pytest.raises(AttributeError, match='read-only'): + dummy.x_unit = 'K' def test_convert_unit(self, dummy: DummyComponent): # WHEN THEN - dummy.convert_unit('microeV') + dummy.convert_x_unit('microeV') # EXPECT - assert dummy.unit == 'microeV' + assert dummy.x_unit == 'microeV' assert dummy.area.value == pytest.approx(1 * 1e3) assert dummy.center.value == pytest.approx(2 * 1e3) assert dummy.width.value == pytest.approx(3 * 1e3) def test_convert_unit_incorrect_unit_raises(self, dummy: DummyComponent): # WHEN THEN EXPECT - with pytest.raises(TypeError, match=r'Unit must be a string or sc.Unit'): - dummy.convert_unit(123) + with pytest.raises(TypeError, match=r'unit must be a string or sc.Unit'): + dummy.convert_x_unit(123) def test_free_and_fix_all_parameters(self, dummy): # WHEN THEN EXPECT @@ -92,7 +93,8 @@ def test_repr(self, dummy): ], ) def test_prepare_x_for_evaluate_various_inputs(self, dummy, x_input, expected_array): - x_prepared = dummy._prepare_x_for_evaluate(x_input) + result = dummy._prepare_x_for_evaluate(x_input) + x_prepared, _detected_unit, _dim = result assert isinstance(x_prepared, np.ndarray) assert x_prepared.shape == expected_array.shape @@ -137,26 +139,101 @@ def test_prepare_x_for_evaluate_with_incompatible_unit_raises(self, dummy): # THEN EXPECT with pytest.raises( Exception, - match='Input x has unit nm, but DummyComponent component ', + match='Input x has unit nm', ): dummy._prepare_x_for_evaluate(x) - def test_prepare_x_for_evaluate_with_different_unit_warns(self, dummy): + def test_prepare_x_for_evaluate_with_different_unit_no_warn(self, dummy): # WHEN x = sc.array(dims=['x'], values=[1.0, 2.0, 3.0], unit='microeV') - # THEN EXPECT - with pytest.warns( - UserWarning, - match='Input x has unit [µμ]eV, but DummyComponent component ', - ): - x_prepared = dummy._prepare_x_for_evaluate(x) + # THEN EXPECT: compatible units are accepted without warning; + # the component's x_unit is NOT mutated and x values are returned as-is. + x_prepared, _detected_unit, _dim = dummy._prepare_x_for_evaluate(x) # EXPECT assert isinstance(x_prepared, np.ndarray) assert x_prepared.shape == (3,) np.testing.assert_array_equal(x_prepared, [1.0, 2.0, 3.0]) - assert dummy.unit == 'µeV' # noqa: RUF001 - assert dummy.area.value == pytest.approx(1.0 * 1e3) - assert dummy.center.value == pytest.approx(2.0 * 1e3) - assert dummy.width.value == pytest.approx(3.0 * 1e3) + assert dummy.x_unit == 'meV' # component unit unchanged + assert dummy.area.value == pytest.approx(1.0) # parameter values unchanged + assert dummy.center.value == pytest.approx(2.0) + assert dummy.width.value == pytest.approx(3.0) + + def test_resolve_param_value_same_unit_returns_raw_value(self, dummy): + # WHEN: target unit matches parameter unit + result = dummy._resolve_param_value(dummy.area, 'meV') + # EXPECT: raw value returned without conversion + assert result == pytest.approx(dummy.area.value) + + def test_resolve_param_value_none_target_returns_raw_value(self, dummy): + # WHEN: target unit is None + result = dummy._resolve_param_value(dummy.area, None) + # EXPECT: raw value returned without conversion + assert result == pytest.approx(dummy.area.value) + + def test_resolve_param_value_converts_without_mutating(self, dummy): + # WHEN: target unit differs from parameter unit + result = dummy._resolve_param_value(dummy.area, 'eV') + # EXPECT: converted value (1.0 meV → 0.001 eV) + assert result == pytest.approx(0.001) + # EXPECT: parameter itself is not mutated + assert dummy.area.value == pytest.approx(1.0) + assert str(dummy.area.unit) == 'meV' + + def test_evaluate_with_compatible_unit_gives_correct_result(self): + # GIVEN: Gaussian in meV and a physically equivalent Gaussian in eV + from easydynamics.sample_model.components.gaussian import Gaussian + + g_mev = Gaussian(area=1.0, center=0.0, width=0.5, x_unit='meV') + g_ev = Gaussian(area=0.001, center=0.0, width=0.0005, x_unit='eV') + + x_ev = sc.array( + dims=['energy'], values=np.array([-0.002, -0.001, 0.0, 0.001, 0.002]), unit='eV' + ) + x_ev_np = np.array([-0.002, -0.001, 0.0, 0.001, 0.002]) + + # WHEN: evaluate meV-Gaussian with x in eV + result_mev = g_mev.evaluate(x_ev) + result_ev = g_ev.evaluate(x_ev_np) + + # EXPECT: physically identical outputs + np.testing.assert_allclose(result_mev, result_ev, rtol=1e-10) + # EXPECT: model state is unchanged + assert g_mev.x_unit == 'meV' + assert g_mev.width.value == pytest.approx(0.5) + assert g_mev.area.value == pytest.approx(1.0) + + # ───── Regression tests ───── + + def test_convert_x_unit_rollback_on_failure(self, dummy: DummyComponent): + # Conversion to 'm' (length) is incompatible with 'meV' (energy) → triggers rollback + with pytest.raises(UnitError): + dummy.convert_x_unit('m') + # Parameters should be restored to original values after rollback + assert dummy.x_unit == 'meV' + assert dummy.area.value == pytest.approx(1.0) + assert dummy.center.value == pytest.approx(2.0) + assert dummy.width.value == pytest.approx(3.0) + + def test_convert_y_unit_not_implemented(self, dummy: DummyComponent): + with pytest.raises(NotImplementedError, match='does not support convert_y_unit'): + dummy.convert_y_unit('1/meV') + + def test_evaluate_preserves_dataarray_coord_key_as_dim(self): + # GIVEN: a Gaussian and a DataArray where the coord key ('energy') differs + # from the coord Variable's internal dim name ('x'). This is a valid scipp + # non-dimension coordinate: the data's dimension is 'x' and the coord is + # labelled 'energy' but lives on the same 'x' axis. + from easydynamics.sample_model.components.gaussian import Gaussian + + g = Gaussian(name='G', area=1.0, center=0.0, width=1.0, x_unit='meV') + coord = sc.Variable(dims=['x'], values=np.linspace(-5.0, 5.0, 10), unit='meV') + data = sc.Variable(dims=['x'], values=np.ones(10)) + da = sc.DataArray(data=data, coords={'energy': coord}) + # WHEN: evaluate with scipp output + # Before the fix, dim was overwritten with coord.dims[0] = 'x', so the + # output Variable had dim 'x' instead of the coord key 'energy'. + result = g.evaluate(da, output='scipp') + # EXPECT: output dim must be the coord key 'energy', not the Variable dim 'x'. + assert result.dims == ('energy',) diff --git a/tests/unit/easydynamics/sample_model/components/test_polynomial.py b/tests/unit/easydynamics/sample_model/components/test_polynomial.py index 2ba3f552..7bb0177e 100644 --- a/tests/unit/easydynamics/sample_model/components/test_polynomial.py +++ b/tests/unit/easydynamics/sample_model/components/test_polynomial.py @@ -6,7 +6,6 @@ import numpy as np import pytest from easyscience.variable import Parameter -from scipp import UnitError from easydynamics.sample_model import Polynomial @@ -27,7 +26,7 @@ def test_init_no_inputs(self): # EXPECT assert polynomial.display_name == 'Polynomial' assert polynomial.coefficients[0].value == pytest.approx(0.0) - assert polynomial.unit == 'meV' + assert polynomial.x_unit == 'meV' def test_initialization(self, polynomial: Polynomial): # WHEN THEN EXPECT @@ -48,7 +47,7 @@ def test_initialization(self, polynomial: Polynomial): 'Each coefficient must be ', ), ( - {'coefficients': [1.0, -2.0, 3.0], 'unit': 123}, + {'coefficients': [1.0, -2.0, 3.0], 'x_unit': 123}, 'unit must be ', ), ( @@ -163,18 +162,18 @@ def test_get_all_parameters(self, polynomial: Polynomial): def test_convert_unit(self, polynomial: Polynomial): # WHEN - polynomial.convert_unit('microeV') + polynomial.convert_x_unit('microeV') # THEN EXPECT - assert polynomial._unit == 'microeV' + assert polynomial._x_unit == 'microeV' assert np.isclose(polynomial.coefficients[0].value, 1.0) assert np.isclose(polynomial.coefficients[1].value, -2.0 * 1e-3) assert np.isclose(polynomial.coefficients[2].value, 3.0 * 1e-6) def test_convert_unit_raises_invalid_unit(self, polynomial: Polynomial): # WHEN THEN EXPECT - with pytest.raises(UnitError, match='unit must be '): - polynomial.convert_unit(123) + with pytest.raises(Exception, match='unit must be '): + polynomial.convert_x_unit(123) def test_copy(self, polynomial: Polynomial): # WHEN THEN @@ -192,10 +191,56 @@ def test_copy(self, polynomial: Polynomial): assert copied_coeff.value == original_coeff.value assert copied_coeff.fixed == original_coeff.fixed + def test_convert_y_unit_scales_all_coefficients(self): + # Polynomial with two non-zero coefficients and a physical y_unit + p = Polynomial(coefficients=[3.0, 1.0], x_unit='meV', y_unit='meV^-1') + x = np.array([2.0]) + val_before = p.evaluate(x)[0] # 3.0 + 1.0*2.0 = 5.0 [meV^-1] + + p.convert_y_unit('eV^-1') + + assert p.y_unit == 'eV^-1' + # Both coefficients must be rescaled by 1000 (1 meV^-1 = 1000 eV^-1) + assert np.isclose(p.coefficients[0].value, 3000.0) + assert np.isclose(p.coefficients[1].value, 1000.0) + # Evaluated result must represent the same physical value + assert np.isclose(p.evaluate(x)[0], val_before * 1000.0) + + def test_evaluate_scipp_output(self): + import scipp as sc + + from easydynamics.sample_model import Polynomial + + p = Polynomial(coefficients=[1.0, 2.0], x_unit='meV') + x = np.linspace(-3, 3, 40) + result = p.evaluate(x, output='scipp') + assert isinstance(result, sc.Variable) + assert result.unit == sc.Unit('dimensionless') + assert len(result.values) == 40 + def test_repr(self, polynomial: Polynomial): # WHEN THEN repr_str = repr(polynomial) # EXPECT - assert "name='PolynomialName'" in repr_str - assert 'coefficients=' in repr_str + assert 'name = PolynomialName' in repr_str + assert 'coefficients =' in repr_str + + def test_evaluate_with_scipp_x_different_compatible_unit(self): + import scipp as sc + + # Polynomial with x_unit='meV', coefficients [1.0, 1.0] → f(x) = 1 + x + p = Polynomial(coefficients=[1.0, 1.0], x_unit='meV') + # Evaluate with x in eV (different but compatible unit) — triggers unit-rescaling branch + x_eV = sc.array(dims=['x'], values=np.array([0.001, 0.002]), unit='eV') + result = p.evaluate(x_eV) + # 0.001 eV = 1 meV, 0.002 eV = 2 meV → f(1)=2, f(2)=3 + np.testing.assert_allclose(result, [2.0, 3.0], rtol=1e-5) + # Component state is NOT mutated + assert p.x_unit == 'meV' + + def test_convert_y_unit_invalid_type_raises(self, polynomial: Polynomial): + from scipp import UnitError + + with pytest.raises(UnitError, match='new_y_unit must be a string or a scipp unit'): + polynomial.convert_y_unit(123) diff --git a/tests/unit/easydynamics/sample_model/components/test_voigt.py b/tests/unit/easydynamics/sample_model/components/test_voigt.py index 8b8d44cf..0206b025 100644 --- a/tests/unit/easydynamics/sample_model/components/test_voigt.py +++ b/tests/unit/easydynamics/sample_model/components/test_voigt.py @@ -6,6 +6,7 @@ import numpy as np import pytest from easyscience.variable import Parameter +from scipp import UnitError from scipy.integrate import simpson from scipy.special import voigt_profile @@ -22,7 +23,7 @@ def voigt(self): center=0.5, gaussian_width=0.6, lorentzian_width=0.7, - unit='meV', + x_unit='meV', ) def test_init_no_inputs(self): @@ -35,7 +36,7 @@ def test_init_no_inputs(self): assert voigt.center.value == pytest.approx(0.0) assert voigt.gaussian_width.value == pytest.approx(1.0) assert voigt.lorentzian_width.value == pytest.approx(1.0) - assert voigt.unit == 'meV' + assert voigt.x_unit == 'meV' assert voigt.center.fixed is True def test_initialization(self, voigt: Voigt): @@ -45,7 +46,7 @@ def test_initialization(self, voigt: Voigt): assert voigt.center.value == pytest.approx(0.5) assert voigt.gaussian_width.value == pytest.approx(0.6) assert voigt.lorentzian_width.value == pytest.approx(0.7) - assert voigt.unit == 'meV' + assert voigt.x_unit == 'meV' @pytest.mark.parametrize( 'kwargs, expected_message', @@ -56,7 +57,7 @@ def test_initialization(self, voigt: Voigt): 'center': 0.5, 'gaussian_width': 0.6, 'lorentzian_width': 0.7, - 'unit': 'meV', + 'x_unit': 'meV', }, 'area must be a number', ), @@ -66,7 +67,7 @@ def test_initialization(self, voigt: Voigt): 'center': 'invalid', 'gaussian_width': 0.6, 'lorentzian_width': 0.7, - 'unit': 'meV', + 'x_unit': 'meV', }, 'center must be None', ), @@ -76,7 +77,7 @@ def test_initialization(self, voigt: Voigt): 'center': 0.5, 'gaussian_width': 'invalid', 'lorentzian_width': 0.7, - 'unit': 'meV', + 'x_unit': 'meV', }, 'gaussian_width must be a number', ), @@ -86,7 +87,7 @@ def test_initialization(self, voigt: Voigt): 'center': 0.5, 'gaussian_width': 0.6, 'lorentzian_width': 'invalid', - 'unit': 'meV', + 'x_unit': 'meV', }, 'lorentzian_width must be a number', ), @@ -96,7 +97,7 @@ def test_initialization(self, voigt: Voigt): 'center': 0.5, 'gaussian_width': 0.6, 'lorentzian_width': 0.7, - 'unit': 123, + 'x_unit': 123, }, 'unit must be None,', ), @@ -117,7 +118,7 @@ def test_negative_gaussian_width_raises(self): center=0.5, gaussian_width=-0.6, lorentzian_width=0.7, - unit='meV', + x_unit='meV', ) def test_negative_lorentzian_width_raises(self): @@ -132,7 +133,7 @@ def test_negative_lorentzian_width_raises(self): center=0.5, gaussian_width=0.6, lorentzian_width=-0.7, - unit='meV', + x_unit='meV', ) def test_negative_area_warns(self): @@ -144,7 +145,7 @@ def test_negative_area_warns(self): center=0.5, gaussian_width=0.6, lorentzian_width=0.7, - unit='meV', + x_unit='meV', ) @pytest.mark.parametrize( @@ -214,7 +215,7 @@ def test_center_is_fixed_if_init_to_None(self): center=None, gaussian_width=0.6, lorentzian_width=0.7, - unit='meV', + x_unit='meV', ) # EXPECT @@ -223,10 +224,10 @@ def test_center_is_fixed_if_init_to_None(self): def test_convert_unit(self, voigt: Voigt): # WHEN THEN - voigt.convert_unit('microeV') + voigt.convert_x_unit('microeV') # EXPECT - assert voigt.unit == 'microeV' + assert voigt.x_unit == 'microeV' assert voigt.area.value == pytest.approx(2 * 1e3) assert voigt.center.value == pytest.approx(0.5 * 1e3) assert voigt.gaussian_width.value == pytest.approx(0.6 * 1e3) @@ -285,7 +286,7 @@ def test_copy(self, voigt: Voigt): assert voigt_copy.lorentzian_width.value == voigt.lorentzian_width.value assert voigt_copy.lorentzian_width.fixed == voigt.lorentzian_width.fixed - assert voigt_copy.unit == voigt.unit + assert voigt_copy.x_unit == voigt.x_unit def test_repr(self, voigt: Voigt): # WHEN THEN @@ -293,9 +294,61 @@ def test_repr(self, voigt: Voigt): # EXPECT assert 'Voigt' in repr_str - assert "name='VoigtName'" in repr_str - assert 'unit=meV' in repr_str - assert 'area=' in repr_str - assert 'center=' in repr_str - assert 'gaussian_width=' in repr_str - assert 'lorentzian_width=' in repr_str + assert 'name = VoigtName' in repr_str + assert 'x_unit = meV' in repr_str + assert 'area =' in repr_str + assert 'center =' in repr_str + assert 'gaussian_width =' in repr_str + assert 'lorentzian_width =' in repr_str + + def test_y_unit_default(self, voigt: Voigt): + assert voigt.y_unit == 'dimensionless' + + def test_convert_y_unit(self): + # GIVEN: x_unit='meV', y_unit='1/meV' → area_unit='dimensionless' + v = Voigt( + area=1.0, + center=0.0, + gaussian_width=0.5, + lorentzian_width=0.3, + x_unit='meV', + y_unit='1/meV', + ) + # WHEN: convert y_unit to '1/eV' (same dimension, different scale) + v.convert_y_unit('1/eV') + # EXPECT: y_unit updated and area value rescaled (1e3 factor) + assert v.y_unit == '1/eV' + assert v.area.value == pytest.approx(1e3) + + def test_convert_y_unit_invalid_type_raises(self, voigt: Voigt): + with pytest.raises(TypeError): + voigt.convert_y_unit(123) + + def test_evaluate_scipp_output(self, voigt: Voigt): + import scipp as sc + + x = np.linspace(-5, 5, 50) + result = voigt.evaluate(x, output='scipp') + assert isinstance(result, sc.Variable) + assert result.unit == sc.Unit('dimensionless') + assert len(result.values) == 50 + + def test_convert_x_unit_invalid_type_raises(self, voigt: Voigt): + with pytest.raises(TypeError, match=r'x_unit must be a string or sc\.Unit'): + voigt.convert_x_unit(123) + + def test_convert_x_unit_rollback_on_failure(self, voigt: Voigt): + with pytest.raises(UnitError): + voigt.convert_x_unit('m') + assert voigt.x_unit == 'meV' + assert voigt.area.value == pytest.approx(2.0) + assert voigt.center.value == pytest.approx(0.5) + assert voigt.gaussian_width.value == pytest.approx(0.6) + assert voigt.lorentzian_width.value == pytest.approx(0.7) + + def test_convert_y_unit_rollback_on_failure(self): + v = Voigt(area=1.0, center=0.0, gaussian_width=0.5, lorentzian_width=0.3, x_unit='meV') + with pytest.raises(UnitError): + v.convert_y_unit('K') + assert v.y_unit == 'dimensionless' + assert v.area.value == pytest.approx(1.0) 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 7941d406..4c5904f3 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 @@ -25,8 +25,10 @@ def brownian_diffusion_model(self): def test_init_default(self, brownian_diffusion_model): # WHEN THEN EXPECT assert brownian_diffusion_model.display_name == 'BrownianTranslationalDiffusion' - assert brownian_diffusion_model.unit == 'meV' + assert brownian_diffusion_model.x_unit == 'meV' + assert brownian_diffusion_model.y_unit == 'dimensionless' assert brownian_diffusion_model.scale.value == pytest.approx(1.0) + assert brownian_diffusion_model.scale.unit == 'meV' assert brownian_diffusion_model.diffusion_coefficient.value == pytest.approx(1.0) @pytest.mark.parametrize( @@ -34,7 +36,7 @@ def test_init_default(self, brownian_diffusion_model): [ ( { - 'unit': 123, + 'x_unit': 123, 'scale': 1.0, 'diffusion_coefficient': 1.0, }, @@ -43,7 +45,7 @@ def test_init_default(self, brownian_diffusion_model): ), ( { - 'unit': 'meV', + 'x_unit': 'meV', 'scale': 'invalid', 'diffusion_coefficient': 1.0, }, @@ -52,7 +54,7 @@ def test_init_default(self, brownian_diffusion_model): ), ( { - 'unit': 'meV', + 'x_unit': 'meV', 'scale': -123.4, 'diffusion_coefficient': 1.0, }, @@ -61,7 +63,7 @@ def test_init_default(self, brownian_diffusion_model): ), ( { - 'unit': 'meV', + 'x_unit': 'meV', 'scale': 1.0, 'diffusion_coefficient': 'invalid', }, @@ -70,7 +72,7 @@ def test_init_default(self, brownian_diffusion_model): ), ( { - 'unit': 'meV', + 'x_unit': 'meV', 'scale': 1.0, 'diffusion_coefficient': -123.4, }, @@ -180,9 +182,11 @@ def test_create_component_collections(self, brownian_diffusion_model, Q): model = component_collections[model_index] assert len(model) == 1 component = model[0] - assert component.width.unit == brownian_diffusion_model.unit + assert component.width.unit == brownian_diffusion_model.x_unit assert np.isclose(component.width.value, expected_widths[model_index]) assert component.width.independent is False + # area.unit = area_unit = x_unit * y_unit + assert component.area.unit == 'meV' def test_write_width_dependency_expression(self, brownian_diffusion_model): # WHEN THEN diff --git a/tests/unit/easydynamics/sample_model/diffusion_model/test_delta_lorentz.py b/tests/unit/easydynamics/sample_model/diffusion_model/test_delta_lorentz.py index b3740db6..4816846e 100644 --- a/tests/unit/easydynamics/sample_model/diffusion_model/test_delta_lorentz.py +++ b/tests/unit/easydynamics/sample_model/diffusion_model/test_delta_lorentz.py @@ -38,7 +38,7 @@ def delta_lorentz_model_with_Q_no_variation(self): def test_init_default(self, delta_lorentz_model): # WHEN THEN EXPECT assert delta_lorentz_model.display_name == 'DeltaLorentz' - assert delta_lorentz_model.unit == 'meV' + assert delta_lorentz_model.x_unit == 'meV' assert delta_lorentz_model.scale.value == pytest.approx(1.0) assert delta_lorentz_model.mean_u_squared.value == pytest.approx(0.0) assert delta_lorentz_model.A_0.value == pytest.approx(1.0) @@ -47,7 +47,7 @@ def test_init_default(self, delta_lorentz_model): def test_init_with_Q(self, delta_lorentz_model_with_Q): # WHEN THEN EXPECT assert delta_lorentz_model_with_Q.display_name == 'DeltaLorentz' - assert delta_lorentz_model_with_Q.unit == 'meV' + assert delta_lorentz_model_with_Q.x_unit == 'meV' assert delta_lorentz_model_with_Q.scale.value == pytest.approx(1.0) assert delta_lorentz_model_with_Q.mean_u_squared.value == pytest.approx(0.0) assert delta_lorentz_model_with_Q.A_0.value == pytest.approx(0.5) @@ -856,3 +856,20 @@ def test_repr(self, delta_lorentz_model): assert 'mean_u_squared' in repr_str assert 'A_0' in repr_str assert 'lorentzian_width' in repr_str + + # ───── Regression tests ───── + + def test_calculate_width_raises_after_clear_Q_when_allow_Q_variation( + self, delta_lorentz_model_with_Q + ): + # GIVEN: model with Q-variation enabled for lorentzian_width + assert delta_lorentz_model_with_Q._allow_Q_variation['lorentzian_width'] is True + assert len(delta_lorentz_model_with_Q._lorentzian_width_list) > 0 + + # WHEN: clear Q (empties _lorentzian_width_list) + delta_lorentz_model_with_Q.clear_Q(confirm=True) + assert len(delta_lorentz_model_with_Q._lorentzian_width_list) == 0 + + # THEN: before the fix, calculate_width() silently returned [] instead of raising. + with pytest.raises(ValueError, match='Lorentzian width Q-variation list is empty'): + delta_lorentz_model_with_Q.calculate_width() diff --git a/tests/unit/easydynamics/sample_model/diffusion_model/test_diffusion_model_base.py b/tests/unit/easydynamics/sample_model/diffusion_model/test_diffusion_model_base.py index 3364e510..b4860889 100644 --- a/tests/unit/easydynamics/sample_model/diffusion_model/test_diffusion_model_base.py +++ b/tests/unit/easydynamics/sample_model/diffusion_model/test_diffusion_model_base.py @@ -19,7 +19,10 @@ def test_init_default(self, diffusion_model): assert diffusion_model.name == 'DiffusionModel' assert diffusion_model.lorentzian_name == 'DiffusionModel' assert diffusion_model.lorentzian_display_name == 'DiffusionModel' - assert diffusion_model.unit == 'meV' + assert diffusion_model.x_unit == 'meV' + assert diffusion_model.y_unit == 'dimensionless' + # scale.unit = area_unit = x_unit * y_unit = meV * dimensionless = meV + assert diffusion_model.scale.unit == 'meV' def test_init_raises(self): # WHEN THEN EXPECT @@ -33,9 +36,9 @@ def test_unit_setter_raises(self, diffusion_model): # WHEN THEN EXPECT with pytest.raises( AttributeError, - match=r'Unit is read-only. Use convert_unit to change the unit between allowed types', + match=r'read-only', ): - diffusion_model.unit = 'eV' + diffusion_model.x_unit = 'eV' @pytest.mark.parametrize( ('attribute', 'value', 'expected'), @@ -153,7 +156,7 @@ def test_repr(self, diffusion_model): # EXPECT assert 'DiffusionModelBase' in repr_str - assert 'unit=meV' in repr_str + assert 'x_unit=meV' in repr_str def test_get_independent_variables(self, diffusion_model): # WHEN THEN EXPECT 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 014a221f..b32db2a2 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 @@ -25,8 +25,10 @@ def jump_diffusion_model(self): def test_init_default(self, jump_diffusion_model): # WHEN THEN EXPECT assert jump_diffusion_model.display_name == 'JumpTranslationalDiffusion' - assert jump_diffusion_model.unit == 'meV' + assert jump_diffusion_model.x_unit == 'meV' + assert jump_diffusion_model.y_unit == 'dimensionless' assert jump_diffusion_model.scale.value == pytest.approx(1.0) + assert jump_diffusion_model.scale.unit == 'meV' assert jump_diffusion_model.diffusion_coefficient.value == pytest.approx(1.0) assert jump_diffusion_model.relaxation_time.value == pytest.approx(1.0) @@ -35,7 +37,7 @@ def test_init_default(self, jump_diffusion_model): [ ( { - 'unit': 123, + 'x_unit': 123, 'scale': 1.0, 'diffusion_coefficient': 1.0, 'relaxation_time': 1.0, @@ -45,7 +47,7 @@ def test_init_default(self, jump_diffusion_model): ), ( { - 'unit': 'meV', + 'x_unit': 'meV', 'scale': 'invalid', 'diffusion_coefficient': 1.0, 'relaxation_time': 1.0, @@ -55,7 +57,7 @@ def test_init_default(self, jump_diffusion_model): ), ( { - 'unit': 'meV', + 'x_unit': 'meV', 'scale': 1.0, 'diffusion_coefficient': 'invalid', 'relaxation_time': 1.0, @@ -65,7 +67,7 @@ def test_init_default(self, jump_diffusion_model): ), ( { - 'unit': 'meV', + 'x_unit': 'meV', 'scale': 1.0, 'diffusion_coefficient': -1.0, 'relaxation_time': 1.0, @@ -75,7 +77,7 @@ def test_init_default(self, jump_diffusion_model): ), ( { - 'unit': 'meV', + 'x_unit': 'meV', 'scale': 1.0, 'diffusion_coefficient': 1.0, 'relaxation_time': 'invalid', @@ -85,7 +87,7 @@ def test_init_default(self, jump_diffusion_model): ), ( { - 'unit': 'meV', + 'x_unit': 'meV', 'scale': 1.0, 'diffusion_coefficient': 1.0, 'relaxation_time': -1.0, @@ -159,7 +161,7 @@ def test_calculate_width(self, jump_diffusion_model): # EXPECT expected_widths = scipp_hbar * diffusion_coefficient_sc * (Q_values**2) / (1 + denominator) - expected_widths = expected_widths.to(unit=jump_diffusion_model.unit) + expected_widths = expected_widths.to(unit=jump_diffusion_model.x_unit) np.testing.assert_allclose(widths, expected_widths.values, rtol=1e-5) @@ -221,9 +223,11 @@ def test_create_component_collections(self, jump_diffusion_model, Q): model = component_collections[model_index] assert len(model) == 1 component = model[0] - assert component.width.unit == jump_diffusion_model.unit + assert component.width.unit == jump_diffusion_model.x_unit assert np.isclose(component.width.value, expected_widths[model_index]) assert component.width.independent is False + # area.unit = area_unit = x_unit * y_unit + assert component.area.unit == 'meV' def test_write_width_dependency_expression(self, jump_diffusion_model): # WHEN THEN diff --git a/tests/unit/easydynamics/sample_model/test_background_model.py b/tests/unit/easydynamics/sample_model/test_background_model.py index a698a1bf..6b01ec6c 100644 --- a/tests/unit/easydynamics/sample_model/test_background_model.py +++ b/tests/unit/easydynamics/sample_model/test_background_model.py @@ -18,14 +18,14 @@ def background_model(self): area=1.0, center=0.0, width=1.0, - unit='meV', + x_unit='meV', ) component2 = Lorentzian( display_name='TestLorentzian1', area=2.0, center=1.0, width=0.5, - unit='meV', + x_unit='meV', ) component_collection = ComponentCollection() component_collection.append_component(component1) @@ -33,7 +33,7 @@ def background_model(self): return BackgroundModel( display_name='InitModel', components=component_collection, - unit='meV', + x_unit='meV', Q=np.array([1.0, 2.0, 3.0]), ) @@ -43,7 +43,7 @@ def test_init(self, background_model): # EXPECT assert model.display_name == 'InitModel' - assert model.unit == 'meV' + assert model.x_unit == 'meV' assert len(model.components) == 2 np.testing.assert_array_equal(model.Q, np.array([1.0, 2.0, 3.0])) diff --git a/tests/unit/easydynamics/sample_model/test_component_collection.py b/tests/unit/easydynamics/sample_model/test_component_collection.py index 7ae15ed6..cc177a3c 100644 --- a/tests/unit/easydynamics/sample_model/test_component_collection.py +++ b/tests/unit/easydynamics/sample_model/test_component_collection.py @@ -5,10 +5,12 @@ import numpy as np import pytest +import scipp as sc from easyscience.variable import Parameter from scipy.integrate import simpson from easydynamics.sample_model import ComponentCollection +from easydynamics.sample_model import ExpressionComponent from easydynamics.sample_model import Gaussian from easydynamics.sample_model import Lorentzian from easydynamics.sample_model import Polynomial @@ -24,7 +26,7 @@ def component_collection(self): area=1.0, center=0.0, width=1.0, - unit='meV', + x_unit='meV', ) component2 = Lorentzian( name='TestLorentzian1Name', @@ -32,7 +34,7 @@ def component_collection(self): area=2.0, center=1.0, width=0.5, - unit='meV', + x_unit='meV', ) model.append_component(component1) model.append_component(component2) @@ -48,7 +50,7 @@ def test_init(self): def test_init_with_component(self): # WHEN THEN - component1 = Gaussian(name='TestGaussian1', area=1.0, center=0.0, width=1.0, unit='meV') + component1 = Gaussian(name='TestGaussian1', area=1.0, center=0.0, width=1.0, x_unit='meV') component_collection = ComponentCollection(display_name='InitModel', components=component1) # EXPECT @@ -58,9 +60,9 @@ def test_init_with_component(self): def test_init_with_components(self): # WHEN THEN - component1 = Gaussian(name='TestGaussian1', area=1.0, center=0.0, width=1.0, unit='meV') + component1 = Gaussian(name='TestGaussian1', area=1.0, center=0.0, width=1.0, x_unit='meV') component2 = Lorentzian( - name='TestLorentzian1', area=2.0, center=1.0, width=0.5, unit='meV' + name='TestLorentzian1', area=2.0, center=1.0, width=0.5, x_unit='meV' ) component_collection = ComponentCollection( display_name='InitModel', components=[component1, component2] @@ -91,13 +93,13 @@ def test_init_with_invalid_list_of_components_raises(self): def test_init_with_invalid_unit_raises(self): # WHEN THEN EXPECT with pytest.raises(TypeError, match='unit must be'): - ComponentCollection(unit=123) + ComponentCollection(x_unit=123) # ───── Component Management ───── def test_append_component(self, component_collection): # WHEN - component = Gaussian(name='TestComponent', area=1.0, center=0.0, width=1.0, unit='meV') + component = Gaussian(name='TestComponent', area=1.0, center=0.0, width=1.0, x_unit='meV') # THEN component_collection.append_component(component) # EXPECT @@ -105,7 +107,7 @@ def test_append_component(self, component_collection): def test_append_component_collection(self, component_collection): # WHEN - component = Gaussian(name='TestComponent', area=1.0, center=0.0, width=1.0, unit='meV') + component = Gaussian(name='TestComponent', area=1.0, center=0.0, width=1.0, x_unit='meV') component_collection2 = ComponentCollection() component_collection2.append_component(component) # THEN @@ -127,7 +129,7 @@ def test_append_invalid_component_raises(self, component_collection): def test_getitem(self, component_collection): # WHEN - component = Gaussian(name='TestComponent', area=1.0, center=0.0, width=1.0, unit='meV') + component = Gaussian(name='TestComponent', area=1.0, center=0.0, width=1.0, x_unit='meV') # THEN component_collection.append_component(component) # EXPECT @@ -140,7 +142,7 @@ def test_is_empty(self): assert component_collection.is_empty is True # WHEN THEN - component = Gaussian(name='TestComponent', area=1.0, center=0.0, width=1.0, unit='meV') + component = Gaussian(name='TestComponent', area=1.0, center=0.0, width=1.0, x_unit='meV') component_collection.append_component(component) # EXPECT assert component_collection.is_empty is False @@ -160,45 +162,45 @@ def test_list_component_names(self, component_collection): def test_convert_unit(self, component_collection): # WHEN THEN - component_collection.convert_unit('eV') + component_collection.convert_x_unit('eV') # EXPECT for component in component_collection: - assert component.unit == 'eV' + assert component.x_unit == 'eV' def test_convert_unit_incorrect_unit_raises(self, component_collection): # WHEN THEN EXPECT - with pytest.raises(TypeError, match=r'Unit must be a string or sc.Unit'): - component_collection.convert_unit(123) + with pytest.raises(TypeError, match=r'unit must be a string or sc.Unit'): + component_collection.convert_x_unit(123) def test_convert_unit_failure_rolls_back(self, component_collection): # WHEN THEN # Introduce a faulty component that will fail conversion class FaultyComponent(Gaussian): - def convert_unit(self, _unit: str) -> None: + def convert_x_unit(self, _unit: str) -> None: raise RuntimeError('Conversion failed.') faulty_component = FaultyComponent( - name='FaultyComponent', area=1.0, center=0.0, width=1.0, unit='meV' + name='FaultyComponent', area=1.0, center=0.0, width=1.0, x_unit='meV' ) component_collection.append_component(faulty_component) - original_units = {component.name: component.unit for component in component_collection} + original_units = {component.name: component.x_unit for component in component_collection} # EXPECT with pytest.raises(RuntimeError, match=r'Conversion failed.'): - component_collection.convert_unit('eV') + component_collection.convert_x_unit('eV') # Check that all components have their original units for component in component_collection: - assert component.unit == original_units[component.name] + assert component.x_unit == original_units[component.name] def test_set_unit(self, component_collection): # WHEN THEN EXPECT with pytest.raises( AttributeError, - match=r'Unit is read-only. Use convert_unit to change the unit', + match=r'read-only', ): - component_collection.unit = 'eV' + component_collection.x_unit = 'eV' def test_evaluate(self, component_collection): # WHEN @@ -290,7 +292,9 @@ def test_normalize_area_not_finite_area_raises(self, component_collection, area_ def test_normalize_area_non_area_component_warns(self, component_collection): # WHEN - component1 = Polynomial(display_name='TestPolynomial', coefficients=[1, 2, 3], unit='meV') + component1 = Polynomial( + display_name='TestPolynomial', coefficients=[1, 2, 3], x_unit='meV' + ) component_collection.append_component(component1) # THEN EXPECT @@ -374,7 +378,9 @@ def test_contains(self, component_collection): assert lorentzian_component in component_collection # WHEN THEN - fake_component = Gaussian(name='FakeGaussian', area=1.0, center=0.0, width=1.0, unit='meV') + fake_component = Gaussian( + name='FakeGaussian', area=1.0, center=0.0, width=1.0, x_unit='meV' + ) # EXPECT assert fake_component not in component_collection assert 123 not in component_collection # Invalid type @@ -392,13 +398,15 @@ def test_to_dict(self, component_collection): # EXPECT assert model_dict['display_name'] == component_collection.display_name - assert model_dict['unit'] == component_collection.unit + assert model_dict['x_unit'] == component_collection.x_unit + assert model_dict['y_unit'] == component_collection.y_unit assert len(model_dict['components']) == len(component_collection) for comp, comp_dict in zip(component_collection, model_dict['components'], strict=True): assert comp_dict['@class'] == type(comp).__name__ assert comp_dict['display_name'] == comp.display_name - assert comp_dict['unit'] == comp.unit + assert comp_dict['x_unit'] == comp.x_unit + assert comp_dict['y_unit'] == comp.y_unit def test_from_dict(self, component_collection): # WHEN @@ -409,12 +417,15 @@ def test_from_dict(self, component_collection): # EXPECT assert new_model.display_name == component_collection.display_name + assert new_model.x_unit == component_collection.x_unit + assert new_model.y_unit == component_collection.y_unit assert len(new_model) == len(component_collection) for orig_comp, new_comp in zip(component_collection, new_model, strict=True): assert type(new_comp) is type(orig_comp) assert new_comp.display_name == orig_comp.display_name - assert new_comp.unit == orig_comp.unit + assert new_comp.x_unit == orig_comp.x_unit + assert new_comp.y_unit == orig_comp.y_unit orig_params = orig_comp.get_all_parameters() new_params = new_comp.get_all_parameters() @@ -426,6 +437,13 @@ def test_from_dict(self, component_collection): assert param_new.value == param_orig.value assert param_new.fixed == param_orig.fixed + @pytest.mark.parametrize('missing_key', ['x_unit', 'y_unit', 'components', 'name']) + def test_from_dict_requires_all_keys(self, component_collection, missing_key): + model_dict = component_collection.to_dict() + del model_dict[missing_key] + with pytest.raises(KeyError): + ComponentCollection.from_dict(model_dict) + def test_copy(self, component_collection): # WHEN component_collection[0].area.min = 0.5 @@ -451,7 +469,7 @@ def test_copy(self, component_collection): # Same type and display name assert type(copied_comp) is type(orig_comp) assert copied_comp.display_name == orig_comp.display_name - assert copied_comp.unit == orig_comp.unit + assert copied_comp.x_unit == orig_comp.x_unit # Parameters are deep-copied and equivalent orig_params = orig_comp.get_all_parameters() @@ -487,3 +505,75 @@ def test_no_warning_with_unique_names(self, recwarn): ComponentCollection(components=[g1, g2]) user_warnings = [w for w in recwarn.list if issubclass(w.category, UserWarning)] assert not user_warnings + + def test_y_unit_default(self, component_collection): + # EXPECT + assert component_collection.y_unit == 'dimensionless' + + def test_convert_y_unit(self): + # GIVEN: components with y_unit='1/meV' so area_unit ≈ dimensionless + g = Gaussian(area=1.0, x_unit='meV', y_unit='1/meV') + lor = Lorentzian(area=1.0, x_unit='meV', y_unit='1/meV') + cc = ComponentCollection(components=[g, lor]) + + # WHEN: convert y_unit to '1/eV' (same dimension, different scale) + cc.convert_y_unit('1/eV') + + # EXPECT + assert cc.y_unit == '1/eV' + for component in cc: + assert component.y_unit == '1/eV' + + def test_convert_y_unit_invalid_type_raises(self, component_collection): + # EXPECT + with pytest.raises(TypeError): + component_collection.convert_y_unit(123) + + def test_convert_x_unit_rollback_on_failure(self): + # GIVEN: collection whose first Gaussian converts fine, but second has an + # ExpressionComponent that raises NotImplementedError for convert_x_unit. + g = Gaussian(area=1.0, x_unit='meV') + expr = ExpressionComponent('A * x', parameters={'A': 1.0}, x_unit='meV') + cc = ComponentCollection(components=[g, expr]) + original_area = g.area.value + + # WHEN: attempt a unit conversion that will fail on the ExpressionComponent + with pytest.raises(NotImplementedError): + cc.convert_x_unit('microeV') + + # EXPECT: Gaussian is rolled back to its original state + assert cc.x_unit == 'meV' + assert g.x_unit == 'meV' + assert g.area.value == pytest.approx(original_area) + + def test_convert_y_unit_rollback_on_failure(self): + # GIVEN: collection where first Gaussian converts successfully but second + # ExpressionComponent always raises NotImplementedError for convert_y_unit. + g = Gaussian(area=1.0, x_unit='meV', y_unit='1/meV') + expr = ExpressionComponent('A * x', parameters={'A': 1.0}, x_unit='meV') + cc = ComponentCollection(components=[g, expr], y_unit='1/meV') + original_area = g.area.value + + # WHEN: attempt y_unit conversion that will fail on the ExpressionComponent + with pytest.raises(NotImplementedError): + cc.convert_y_unit('1/eV') + + # EXPECT: collection y_unit and Gaussian are both rolled back + assert cc.y_unit == '1/meV' + assert g.y_unit == '1/meV' + assert g.area.value == pytest.approx(original_area) + + # ───── Regression tests ───── + + def test_evaluate_scipp_output_multi_component_does_not_raise(self, component_collection): + # GIVEN: collection with two components (Gaussian + Lorentzian) + x = sc.Variable(dims=['energy'], values=np.linspace(-5.0, 5.0, 100), unit='meV') + # WHEN: evaluate with scipp output + # Before the fix, sum() started from int 0 → '0 + sc.Variable' raised TypeError. + result = component_collection.evaluate(x, output='scipp') + # EXPECT: returns a Variable whose values are the sum of both components + assert isinstance(result, sc.Variable) + expected = component_collection[0].evaluate(x, output='scipp') + component_collection[ + 1 + ].evaluate(x, output='scipp') + assert sc.allclose(result, expected) diff --git a/tests/unit/easydynamics/sample_model/test_instrument_model.py b/tests/unit/easydynamics/sample_model/test_instrument_model.py index cb716fc4..791dd3f2 100644 --- a/tests/unit/easydynamics/sample_model/test_instrument_model.py +++ b/tests/unit/easydynamics/sample_model/test_instrument_model.py @@ -113,7 +113,7 @@ def test_init_sample_model_as_resolution_model(self, sample_model): 'energy_offset must be a number', ), ( - {'unit': 123}, + {'x_unit': 123}, TypeError, 'unit must be', ), @@ -209,13 +209,10 @@ def test_clear_Q_raises_without_confirm(self, instrument_model): with pytest.raises(ValueError, match='Clearing Q values requires confirmation'): instrument_model.clear_Q() - def test_unit_setter_raises(self, instrument_model): + def test_x_unit_setter_raises(self, instrument_model): # WHEN / THEN / EXPECT - with pytest.raises( - AttributeError, - match=r'Unit is read-only. Use convert_unit to change the unit between allowed types ', - ): - instrument_model.unit = 'meV' + with pytest.raises(AttributeError): + instrument_model.x_unit = 'meV' def test_energy_offset_setter(self, instrument_model): # WHEN @@ -272,16 +269,16 @@ def test_get_energy_offset_no_Q_raises(self, instrument_model): ): instrument_model.get_energy_offset(0) - def test_convert_unit_calls_all_children(self, instrument_model): + def test_convert_x_unit_calls_all_children(self, instrument_model): # WHEN new_unit = 'eV' # THEN # Ensure energy offsets are built before mocking instrument_model._ensure_energy_offsets_current() - # Mock downstream convert_unit calls - instrument_model._background_model.convert_unit = MagicMock() - instrument_model._resolution_model.convert_unit = MagicMock() + # Mock downstream convert_x_unit calls + instrument_model._background_model.convert_x_unit = MagicMock() + instrument_model._resolution_model.convert_x_unit = MagicMock() instrument_model._energy_offset.convert_unit = MagicMock() for offset in instrument_model._energy_offsets: offset.convert_unit = MagicMock() @@ -290,28 +287,28 @@ def test_convert_unit_calls_all_children(self, instrument_model): 'easydynamics.sample_model.instrument_model._validate_unit', return_value=new_unit, ) as mock_validate: - instrument_model.convert_unit(new_unit) + instrument_model.convert_x_unit(new_unit) # EXPECT mock_validate.assert_called_once_with(new_unit) - instrument_model._background_model.convert_unit.assert_called_once_with(new_unit) - instrument_model._resolution_model.convert_unit.assert_called_once_with(new_unit) + instrument_model._background_model.convert_x_unit.assert_called_once_with(new_unit) + instrument_model._resolution_model.convert_x_unit.assert_called_once_with(new_unit) instrument_model._energy_offset.convert_unit.assert_called_once_with(new_unit) for offset in instrument_model._energy_offsets: offset.convert_unit.assert_called_once_with(new_unit) # final state - assert instrument_model.unit == new_unit + assert instrument_model.x_unit == new_unit - def test_convert_unit_None_raises(self, instrument_model): + def test_convert_x_unit_None_raises(self, instrument_model): # WHEN / THEN / EXPECT with pytest.raises( ValueError, match=' must be a valid unit', ): - instrument_model.convert_unit(None) + instrument_model.convert_x_unit(None) def test_fix_resolution_parameters(self, instrument_model): # WHEN @@ -429,7 +426,7 @@ def test_generate_energy_offsets(self, instrument_model): assert len(instrument_model._energy_offsets) == 4 for offset in instrument_model._energy_offsets: assert offset.name == 'energy_offset' - assert offset.unit == instrument_model.unit + assert offset.unit == instrument_model.x_unit assert offset.value == instrument_model.energy_offset.value def test_Q_setter(self, instrument_model_without_Q): @@ -567,7 +564,7 @@ def test_repr_contains_expected_fields(self, instrument_model): # EXPECT assert repr_str.startswith('InstrumentModel(') assert f'unique_name={instrument_model.unique_name!r}' in repr_str - assert f'unit={instrument_model.unit}' in repr_str + assert f'x_unit={instrument_model.x_unit}' in repr_str assert 'Q_len=3' in repr_str assert f'resolution_model={instrument_model._resolution_model!r}' in repr_str assert f'background_model={instrument_model._background_model!r}' in repr_str diff --git a/tests/unit/easydynamics/sample_model/test_model_base.py b/tests/unit/easydynamics/sample_model/test_model_base.py index 5b58370d..91d9decb 100644 --- a/tests/unit/easydynamics/sample_model/test_model_base.py +++ b/tests/unit/easydynamics/sample_model/test_model_base.py @@ -23,7 +23,7 @@ def model_base(self): area=1.0, center=0.0, width=1.0, - unit='meV', + x_unit='meV', ) component2 = Lorentzian( name='TestLorentzian1Name', @@ -31,7 +31,7 @@ def model_base(self): area=2.0, center=1.0, width=0.5, - unit='meV', + x_unit='meV', ) component_collection = ComponentCollection() component_collection.append_component(component1) @@ -39,7 +39,7 @@ def model_base(self): return ModelBase( display_name='InitModel', components=component_collection, - unit='meV', + x_unit='meV', Q=np.array([1.0, 2.0, 3.0]), ) @@ -49,7 +49,7 @@ def test_init(self, model_base): # EXPECT assert model.display_name == 'InitModel' - assert model.unit == 'meV' + assert model.x_unit == 'meV' assert len(model.components) == 2 np.testing.assert_array_equal(model.Q, np.array([1.0, 2.0, 3.0])) @@ -78,8 +78,8 @@ def test_evaluate_calls_all_component_collections(self, model_base): result = model_base.evaluate(x) # EXPECT - collection1.evaluate.assert_called_once_with(x) - collection2.evaluate.assert_called_once_with(x) + collection1.evaluate.assert_called_once_with(x, output='numpy') + collection2.evaluate.assert_called_once_with(x, output='numpy') np.testing.assert_allclose(result[0], np.array([1.0, 2.0, 3.0])) np.testing.assert_allclose(result[1], np.array([4.0, 5.0, 6.0])) @@ -246,36 +246,61 @@ def test_append_component_invalid_type_raises(self, model_base): with pytest.raises(TypeError, match=' must be '): model_base.append_component('invalid_component') - def test_unit_property(self, model_base): + def test_x_unit_property(self, model_base): # WHEN - unit = model_base.unit + unit = model_base.x_unit # THEN / EXPECT assert unit == 'meV' - def test_unit_setter_raises(self, model_base): + def test_x_unit_setter_raises(self, model_base): # WHEN / THEN / EXPECT - with pytest.raises(AttributeError, match='Use convert_unit to change '): - model_base.unit = 'K' + with pytest.raises(AttributeError): + model_base.x_unit = 'K' - def test_convert_unit(self, model_base): + def test_convert_x_unit(self, model_base): # WHEN - model_base.convert_unit('eV') + model_base.convert_x_unit('eV') # THEN / EXPECT - assert model_base.unit == 'eV' + assert model_base.x_unit == 'eV' for component in model_base.components: - assert component.unit == 'eV' + assert component.x_unit == 'eV' - def test_convert_unit_invalid_raises(self, model_base): + def test_convert_x_unit_invalid_raises(self, model_base): # WHEN / THEN / EXPECT with pytest.raises(UnitError): - model_base.convert_unit('invalid_unit') + model_base.convert_x_unit('invalid_unit') - def test_convert_unit_incorrect_unit_raises(self, model_base): + def test_convert_x_unit_incorrect_unit_raises(self, model_base): # WHEN THEN EXPECT with pytest.raises(TypeError, match=r'Unit must be a string or sc.Unit'): - model_base.convert_unit(123) + model_base.convert_x_unit(123) + + def test_components_setter_none(self, model_base): + # Setting components to None clears all components + model_base.components = None + assert len(model_base.components) == 0 + + def test_convert_x_unit_rollback_when_old_unit_none(self): + # When _x_unit is None, the rollback block is skipped (292->298 branch) + component = Gaussian(name='G', area=1.0, center=0.0, width=0.5, x_unit='meV') + model = ModelBase(display_name='M', x_unit=None, components=component) + model._x_unit = None + with pytest.raises(UnitError): + model.convert_x_unit('m') # incompatible unit triggers failure + + def test_convert_x_unit_rollback_on_failure(self, model_base): + with pytest.raises(UnitError): + model_base.convert_x_unit('m') + assert model_base.x_unit == 'meV' + for component in model_base.components: + assert component.x_unit == 'meV' + + def test_convert_y_unit_rollback_on_failure(self, model_base): + with pytest.raises(UnitError): + model_base.convert_y_unit('K') + assert model_base.y_unit == 'dimensionless' def test_components_setter(self, model_base): # WHEN @@ -386,5 +411,32 @@ def test_repr(self, model_base): # THEN / EXPECT assert 'unique_name' in repr_str assert 'unit' in repr_str - assert 'Q=' in repr_str - assert 'components=' in repr_str + assert 'Q = ' in repr_str + assert 'components = ' in repr_str + + def test_y_unit_default(self, model_base): + # EXPECT + assert model_base.y_unit == 'dimensionless' + + def test_convert_y_unit(self): + # GIVEN: model with components where y_unit='1/meV' so area_unit ≈ dimensionless + g = Gaussian(area=1.0, x_unit='meV', y_unit='1/meV') + lor = Lorentzian(area=1.0, x_unit='meV', y_unit='1/meV') + cc = ComponentCollection(components=[g, lor]) + model = ModelBase(components=cc, x_unit='meV', Q=np.array([1.0])) + + # WHEN: convert y_unit to '1/eV' (same dimension, different scale) + model.convert_y_unit('1/eV') + + # EXPECT: model y_unit and all template components updated + assert model.y_unit == '1/eV' + for component in model.components: + assert component.y_unit == '1/eV' + # Component collections rebuilt on demand reflect the new unit + for component in model.get_component_collection(0): + assert component.y_unit == '1/eV' + + def test_convert_y_unit_invalid_raises(self, model_base): + # EXPECT + with pytest.raises(TypeError): + model_base.convert_y_unit(123) diff --git a/tests/unit/easydynamics/sample_model/test_resolution_model.py b/tests/unit/easydynamics/sample_model/test_resolution_model.py index f894daf2..5b7730b4 100644 --- a/tests/unit/easydynamics/sample_model/test_resolution_model.py +++ b/tests/unit/easydynamics/sample_model/test_resolution_model.py @@ -21,14 +21,14 @@ def resolution_model(self): area=1.0, center=0.0, width=1.0, - unit='meV', + x_unit='meV', ) component2 = Lorentzian( display_name='TestLorentzian1', area=2.0, center=1.0, width=0.5, - unit='meV', + x_unit='meV', ) component_collection = ComponentCollection() component_collection.append_component(component1) @@ -36,7 +36,7 @@ def resolution_model(self): return ResolutionModel( display_name='InitModel', components=component_collection, - unit='meV', + x_unit='meV', Q=np.array([1.0, 2.0, 3.0]), ) @@ -48,7 +48,7 @@ def sample_model(self): area=1.0, center=0.0, width=1.0, - unit='meV', + x_unit='meV', ) component2 = Lorentzian( name='TestLorentzian1Name', @@ -56,7 +56,7 @@ def sample_model(self): area=2.0, center=1.0, width=0.5, - unit='meV', + x_unit='meV', ) component_collection = ComponentCollection() component_collection.append_component(component1) @@ -65,7 +65,7 @@ def sample_model(self): return SampleModel( display_name='InitModel', components=component_collection, - unit='meV', + x_unit='meV', Q=np.array([1.0, 2.0, 3.0]), temperature=10.0, ) @@ -76,7 +76,7 @@ def test_init(self, resolution_model): # EXPECT assert model.display_name == 'InitModel' - assert model.unit == 'meV' + assert model.x_unit == 'meV' assert len(model.components) == 2 np.testing.assert_array_equal(model.Q, np.array([1.0, 2.0, 3.0])) @@ -215,7 +215,7 @@ def test_from_sample_model( # EXPECT assert resolution_model.display_name == 'InitModel' - assert resolution_model.unit == 'meV' + assert resolution_model.x_unit == 'meV' assert len(resolution_model.components) == 2 np.testing.assert_array_equal( resolution_model.Q, diff --git a/tests/unit/easydynamics/sample_model/test_sample_model.py b/tests/unit/easydynamics/sample_model/test_sample_model.py index 8086c92e..ecadee7a 100644 --- a/tests/unit/easydynamics/sample_model/test_sample_model.py +++ b/tests/unit/easydynamics/sample_model/test_sample_model.py @@ -28,7 +28,7 @@ def sample_model(self): area=1.0, center=0.0, width=1.0, - unit='meV', + x_unit='meV', ) component2 = Lorentzian( name='TestLorentzian1Name', @@ -36,7 +36,7 @@ def sample_model(self): area=2.0, center=1.0, width=0.5, - unit='meV', + x_unit='meV', ) component_collection = ComponentCollection() component_collection.append_component(component1) @@ -50,7 +50,7 @@ def sample_model(self): display_name='InitModel', components=component_collection, diffusion_models=diffusion_model, - unit='meV', + x_unit='meV', Q=np.array([1.0, 2.0, 3.0]), temperature=10.0, ) @@ -62,7 +62,7 @@ def test_init(self, sample_model): # EXPECT assert model.display_name == 'InitModel' - assert model.unit == 'meV' + assert model.x_unit == 'meV' assert len(model.components) == 2 assert isinstance(model.diffusion_models, list) assert len(model.diffusion_models) == 1 @@ -402,12 +402,12 @@ def test_evaluate_calls_dbf(self, sample_model): energy=x, temperature=sample_model.temperature, divide_by_temperature=sample_model.normalize_detailed_balance, - energy_unit=sample_model.unit, + energy_unit=sample_model.x_unit, ) # Check that evaluate was called on each component - collection1.evaluate.assert_called_once_with(x) - collection2.evaluate.assert_called_once_with(x) + collection1.evaluate.assert_called_once_with(x, output='numpy') + collection2.evaluate.assert_called_once_with(x, output='numpy') # Check that DBF was applied elementwise np.testing.assert_allclose(result[0], np.array([1.0, 2.0, 3.0]) * 10.0) @@ -452,8 +452,8 @@ def test_evaluate_doesnt_call_dbf_when_disabled( mock_dbf.assert_not_called() # Check that evaluate was called on each component - collection1.evaluate.assert_called_once_with(x) - collection2.evaluate.assert_called_once_with(x) + collection1.evaluate.assert_called_once_with(x, output='numpy') + collection2.evaluate.assert_called_once_with(x, output='numpy') # Check that results were not modified by DBF np.testing.assert_allclose(result[0], np.array([1.0, 2.0, 3.0])) @@ -531,8 +531,8 @@ def test_repr(self, sample_model): # THEN / EXPECT assert 'SampleModel' in repr_str - assert 'unit=' in repr_str - assert 'Q=' in repr_str + assert 'x_unit=' in repr_str + assert 'Q = ' in repr_str assert 'components' in repr_str assert 'diffusion_models' in repr_str assert 'temperature' in repr_str diff --git a/tests/unit/easydynamics/settings/test_convolution_settings.py b/tests/unit/easydynamics/settings/test_convolution_settings.py index 202f5bae..3c3240d7 100644 --- a/tests/unit/easydynamics/settings/test_convolution_settings.py +++ b/tests/unit/easydynamics/settings/test_convolution_settings.py @@ -135,12 +135,13 @@ def test_upsample_factor_setter_invalid( @pytest.mark.parametrize( 'value', - [0.0, 0.2, 1, 5.5], + [0.0, 0.2, 1, 5.5, None], ids=[ 'zero', 'typical_fraction', 'integer', 'float', + 'none_valid', ], ) def test_extension_factor_setter_valid(self, default_convolution_settings, value): @@ -152,7 +153,8 @@ def test_extension_factor_setter_valid(self, default_convolution_settings, value default_convolution_settings.extension_factor = value # EXPECT - assert default_convolution_settings.extension_factor == pytest.approx(float(value)) + expected = pytest.approx(float(value)) if value is not None else None + assert default_convolution_settings.extension_factor == expected assert default_convolution_settings.convolution_plan_is_valid is False @pytest.mark.parametrize( diff --git a/tests/unit/easydynamics/utils/test_detailed_balance.py b/tests/unit/easydynamics/utils/test_detailed_balance.py index bebf5407..76227e9a 100644 --- a/tests/unit/easydynamics/utils/test_detailed_balance.py +++ b/tests/unit/easydynamics/utils/test_detailed_balance.py @@ -305,3 +305,37 @@ def test_incompatible_temperature_unit_raises(self): energy_unit=energy_unit, temperature_unit=temperature_unit, ) + + +class TestConvertToScippVariable: + """Tests for _convert_to_scipp_variable internal helper.""" + + @pytest.fixture(autouse=True) + def _import(self): + from easydynamics.utils.detailed_balance import _convert_to_scipp_variable + + self._fn = _convert_to_scipp_variable + + @pytest.mark.parametrize( + 'name, expected_match', + [ + ('energy', 'energy must be a number'), + ('temperature', 'temperature must be a number'), + ], + ids=['energy_name', 'other_name'], + ) + def test_invalid_type_raises_type_error(self, name, expected_match): + with pytest.raises(TypeError, match=expected_match): + self._fn({'invalid': 'type'}, name=name, unit='meV') + + def test_invalid_unit_scalar_raises_unit_error(self): + from scipp import UnitError as ScippUnitError + + with pytest.raises(ScippUnitError, match='Invalid unit string'): + self._fn(1.0, name='energy', unit='not_a_real_unit_xyz') + + def test_invalid_unit_array_raises_unit_error(self): + from scipp import UnitError as ScippUnitError + + with pytest.raises(ScippUnitError, match='Invalid unit string'): + self._fn([1.0, 2.0], name='energy', unit='not_a_real_unit_xyz')