diff --git a/docs/docs/tutorials/analysis.ipynb b/docs/docs/tutorials/analysis.ipynb index 41e6c7ed1..7ba7858b5 100644 --- a/docs/docs/tutorials/analysis.ipynb +++ b/docs/docs/tutorials/analysis.ipynb @@ -255,17 +255,6 @@ "diffusion_analysis.plot_parameters(names=['Lorentzian width', 'Lorentzian area'])" ] }, - { - "cell_type": "code", - "execution_count": null, - "id": "eb226c8f", - "metadata": {}, - "outputs": [], - "source": [ - "# It will be possible to fit this to a DiffusionModel, but that will\n", - "# come later." - ] - }, { "cell_type": "code", "execution_count": null, @@ -332,13 +321,13 @@ "outputs": [], "source": [ "# Let us look at the fitted diffusion coefficient\n", - "diffusion_model.get_all_parameters()" + "diffusion_model.get_fittable_parameters()" ] } ], "metadata": { "kernelspec": { - "display_name": "Python 3", + "display_name": "default", "language": "python", "name": "python3" }, @@ -352,9 +341,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 784f2be50..5ee06676d 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" }, @@ -95,9 +95,9 @@ "name": "python", "nbconvert_exporter": "python", "pygments_lexer": "ipython3", - "version": "3.14.5" + "version": "3.14.6" } }, "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 54f763871..656fcf59f 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.6" } }, "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 70070f1e0..c940c717b 100644 --- a/docs/docs/tutorials/components.ipynb +++ b/docs/docs/tutorials/components.ipynb @@ -39,7 +39,9 @@ "gaussian = sm.Gaussian(display_name='Gaussian', width=0.5, area=1)\n", "dho = sm.DampedHarmonicOscillator(display_name='DHO', center=1.0, width=0.3, area=2.0)\n", "lorentzian = sm.Lorentzian(display_name='Lorentzian', center=-1.0, width=0.2, area=1.0)\n", - "polynomial = sm.Polynomial(display_name='Polynomial', coefficients=[0.1, 0, 0.5]) # y=0.1+0.5*x^2\n", + "polynomial = sm.Polynomial(\n", + " display_name='Polynomial', coefficients=[-0.2, 0, 0.5]\n", + ") # y=-0.2+0.5*x^2\n", "exponential = sm.Exponential(display_name='Exponential', amplitude=1.0, rate=-0.5)\n", "\n", "x = np.linspace(-2, 2, 100)\n", @@ -59,6 +61,18 @@ "plt.show()" ] }, + { + "cell_type": "code", + "execution_count": null, + "id": "6db00a3e", + "metadata": {}, + "outputs": [], + "source": [ + "# Suppress the warning from the Polynomial:\n", + "polynomial.suppress_warnings = True\n", + "y = polynomial.evaluate(x)" + ] + }, { "cell_type": "code", "execution_count": null, @@ -119,6 +133,14 @@ "plt.show()" ] }, + { + "cell_type": "markdown", + "id": "11884136", + "metadata": {}, + "source": [ + "The `ExpressionComponent` can create almost any function you would like. Units are supported and must be given for each parameter either at construction as below, or afterwards as properties. The individual parameters can be accessed by their name in the usual way. You must use `x` fore the x-axis. It will warn if the units are not consistent." + ] + }, { "cell_type": "code", "execution_count": null, @@ -142,7 +164,9 @@ "plt.figure()\n", "plt.plot(x, y, label='Expression Component')\n", "plt.legend()\n", - "plt.show()" + "plt.show()\n", + "plt.xlabel('Energy (meV)')\n", + "plt.ylabel('Intensity (arb. units)')" ] }, { @@ -172,7 +196,7 @@ ], "metadata": { "kernelspec": { - "display_name": "Python 3", + "display_name": "default", "language": "python", "name": "python3" }, @@ -186,9 +210,9 @@ "name": "python", "nbconvert_exporter": "python", "pygments_lexer": "ipython3", - "version": "3.14.5" + "version": "3.14.6" } }, "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 d37c38c14..2e9625559 100644 --- a/docs/docs/tutorials/convolution.ipynb +++ b/docs/docs/tutorials/convolution.ipynb @@ -51,8 +51,8 @@ "sample_components.append_component(delta)\n", "\n", "resolution_components = sm.ComponentCollection()\n", - "resolution_gaussian = sm.Gaussian(display_name='Resolution Gaussian', width=0.015, area=0.8)\n", - "resolution_lorentzian = sm.Lorentzian(display_name='Resolution Lorentzian', width=0.025, area=0.2)\n", + "resolution_gaussian = sm.Gaussian(display_name='Resolution Gaussian', width=0.05, area=0.8)\n", + "resolution_lorentzian = sm.Lorentzian(display_name='Resolution Lorentzian', width=0.05, area=0.2)\n", "resolution_components.append_component(resolution_gaussian)\n", "resolution_components.append_component(resolution_lorentzian)\n", "\n", @@ -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.6" } }, "nbformat": 4, "nbformat_minor": 5 -} \ No newline at end of file +} diff --git a/docs/docs/tutorials/data/create_fake_data.ipynb b/docs/docs/tutorials/data/create_fake_data.ipynb index e4ed0ee8c..55ac3822c 100644 --- a/docs/docs/tutorials/data/create_fake_data.ipynb +++ b/docs/docs/tutorials/data/create_fake_data.ipynb @@ -59,7 +59,7 @@ "intensity_dataarray.values += noise\n", "intensity_dataarray.variances = noise**2\n", "\n", - "sc.io.save_hdf5(intensity_dataarray, 'fake_simple_data.hdf5')\n", + "# sc.io.save_hdf5(intensity_dataarray, 'fake_simple_data.hdf5')\n", "pp.slicer(intensity_dataarray)" ] }, @@ -105,14 +105,14 @@ "intensity_dataarray.values += noise\n", "intensity_dataarray.variances = noise**2\n", "\n", - "sc.io.save_hdf5(intensity_dataarray, 'fake_advanced_data.hdf5')\n", + "# sc.io.save_hdf5(intensity_dataarray, 'fake_advanced_data.hdf5')\n", "pp.slicer(intensity_dataarray)" ] } ], "metadata": { "kernelspec": { - "display_name": "Python 3", + "display_name": "default", "language": "python", "name": "python3" }, @@ -126,7 +126,7 @@ "name": "python", "nbconvert_exporter": "python", "pygments_lexer": "ipython3", - "version": "3.14.4" + "version": "3.14.6" } }, "nbformat": 4, diff --git a/docs/docs/tutorials/data/fake_advanced_data.hdf5 b/docs/docs/tutorials/data/fake_advanced_data.hdf5 index d83903fb0..2a21cb285 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 14afb8306..bef9761a8 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 351358124..c1046f984 100644 --- a/docs/docs/tutorials/delta_lorentz.ipynb +++ b/docs/docs/tutorials/delta_lorentz.ipynb @@ -45,7 +45,7 @@ "energy = np.linspace(-2, 2, 501)\n", "scale = 1.0\n", "mean_u_squared = 0.5\n", - "A_0 = 0.2\n", + "A_0 = 0.01\n", "lorentzian_width = 0.2\n", "\n", "diffusion_model = sm.DeltaLorentz(\n", @@ -60,6 +60,25 @@ ")" ] }, + { + "cell_type": "markdown", + "id": "0aee03b1", + "metadata": {}, + "source": [ + "Both `A_0` and `lorentzian_width` are here allowed to vary with Q. We here change a few of them just to show how this impacts the model. The `# noqa` comment is because we are accessing private members of the model (ones beginning with `_`), which is generally discouraged. Because of these changes, in the figure below, the delta function at Q=1.25 Å^-1 is much larger than the other ones, and the Lorentzian at Q=1.75 Å^-1 is much narrower and taller than the other ones." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "2b9d4113", + "metadata": {}, + "outputs": [], + "source": [ + "diffusion_model._A_0_list[3].value = 0.02 # noqa\n", + "diffusion_model._lorentzian_width_list[5].value = 0.1 # noqa" + ] + }, { "cell_type": "code", "execution_count": null, @@ -96,7 +115,7 @@ ], "metadata": { "kernelspec": { - "display_name": "Python 3", + "display_name": "default", "language": "python", "name": "python3" }, @@ -110,9 +129,9 @@ "name": "python", "nbconvert_exporter": "python", "pygments_lexer": "ipython3", - "version": "3.14.4" + "version": "3.14.6" } }, "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 12d926a6d..bd6fccce3 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.6" } }, "nbformat": 4, diff --git a/docs/docs/tutorials/diffusion_model.ipynb b/docs/docs/tutorials/diffusion_model.ipynb index ddf9e9a0e..ffc26cc09 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.6" } }, "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 0e6b7c5b4..8e265aff8 100644 --- a/docs/docs/tutorials/experiment.ipynb +++ b/docs/docs/tutorials/experiment.ipynb @@ -70,9 +70,9 @@ ], "metadata": { "kernelspec": { - "display_name": "Python (Pixi)", + "display_name": "default", "language": "python", - "name": "pixi-kernel-python3" + "name": "python3" }, "language_info": { "codemirror_mode": { @@ -84,9 +84,9 @@ "name": "python", "nbconvert_exporter": "python", "pygments_lexer": "ipython3", - "version": "3.12.12" + "version": "3.14.6" } }, "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 27ccbb93a..99e05545a 100644 --- a/docs/docs/tutorials/instrument_model.ipynb +++ b/docs/docs/tutorials/instrument_model.ipynb @@ -75,7 +75,7 @@ ], "metadata": { "kernelspec": { - "display_name": "easydynamics_newbase", + "display_name": "default", "language": "python", "name": "python3" }, @@ -89,9 +89,9 @@ "name": "python", "nbconvert_exporter": "python", "pygments_lexer": "ipython3", - "version": "3.12.12" + "version": "3.14.6" } }, "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 fa34e8da1..0edad8e3e 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" }, @@ -141,9 +141,9 @@ "name": "python", "nbconvert_exporter": "python", "pygments_lexer": "ipython3", - "version": "3.14.5" + "version": "3.14.6" } }, "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 548c6c714..10ec842f1 100644 --- a/docs/docs/tutorials/tutorial0_basics.ipynb +++ b/docs/docs/tutorials/tutorial0_basics.ipynb @@ -366,7 +366,8 @@ "id": "d6e8ece6", "metadata": {}, "source": [ - "If you wish to customise the plot beyond what is immediately possible with EasyDynamics, you can get the data and model as a scipp datagroup. You may evaluate the model at different energies than the data like this." + "If you wish to customise the plot beyond what is immediately possible with EasyDynamics, you can get the data and model as a scipp D\n", + "atagroup. You may evaluate the model at different energies than the data like this." ] }, { @@ -386,7 +387,19 @@ "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." + "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", + "\n", + "We 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", + "
\n", + "Two 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", + "
" ] }, { @@ -397,7 +410,10 @@ "outputs": [], "source": [ "fit_func = sm.Polynomial(\n", - " coefficients=[3.7, -0.5], name='Straight line', display_name='Straight line'\n", + " coefficients=[3.7, -0.5],\n", + " x_unit='1/angstrom',\n", + " y_unit='meV',\n", + " name='Straight line',\n", ")\n", "\n", "binding = edyn.FitBinding(parameter_name='Gaussian area', model=fit_func)\n", @@ -450,7 +466,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 +492,7 @@ ], "metadata": { "kernelspec": { - "display_name": "Python 3", + "display_name": "default", "language": "python", "name": "python3" }, @@ -490,7 +506,7 @@ "name": "python", "nbconvert_exporter": "python", "pygments_lexer": "ipython3", - "version": "3.14.5" + "version": "3.14.6" } }, "nbformat": 4, diff --git a/docs/docs/tutorials/tutorial0_more_advanced.ipynb b/docs/docs/tutorials/tutorial0_more_advanced.ipynb index f203dbdcb..6869b2596 100644 --- a/docs/docs/tutorials/tutorial0_more_advanced.ipynb +++ b/docs/docs/tutorials/tutorial0_more_advanced.ipynb @@ -144,6 +144,13 @@ "
\n", " SampleModel, BackgroundModel and ResolutionModel (introduced later) all take components in several different ways: you can add a single component like in the previous tutorial, append components using the `append_component` method, or give a ComponentCollection like we do here. \n", "
\n", + "\n", + "\n", + "
\n", + " 💡 Tip\n", + "
\n", + " Polynomials will warn if they produce negative values, since negative backgrounds are unphysical. To suppress the warning, set suppress_warnings to True, either when constructing the Polynomial (polynomial=sm.Polynomial(coefficients=[0.0], suppress_warnings=True)), or afterwards (polynomial.suppress_warnings = True).\n", + "
\n", "
\n" ] }, @@ -300,7 +307,19 @@ "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`" + "With apologies for the lack of creativity, these all appear like straight lines. We can fit them individually or all together using `ParameterAnalysis`.\n", + "\n", + "For 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", + "
\n", + "Two 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", + "
" ] }, { @@ -310,10 +329,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 +358,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 +375,7 @@ ], "metadata": { "kernelspec": { - "display_name": "Python 3", + "display_name": "default", "language": "python", "name": "python3" }, @@ -366,7 +389,7 @@ "name": "python", "nbconvert_exporter": "python", "pygments_lexer": "ipython3", - "version": "3.14.5" + "version": "3.14.6" } }, "nbformat": 4, diff --git a/docs/docs/tutorials/tutorial2_nanoparticles.ipynb b/docs/docs/tutorials/tutorial2_nanoparticles.ipynb index 22de24900..cca884bb2 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" }, @@ -607,7 +607,7 @@ "name": "python", "nbconvert_exporter": "python", "pygments_lexer": "ipython3", - "version": "3.14.5" + "version": "3.14.6" } }, "nbformat": 4, diff --git a/src/easydynamics/analysis/analysis.py b/src/easydynamics/analysis/analysis.py index 712169669..ed1a2d44f 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 @@ -253,7 +254,6 @@ def calculate( if Q_index is None: return [analysis.calculate(energy=energy) for analysis in self.analysis_list] - verify_Q_index(Q_index=Q_index, Q=self.Q) return self.analysis_list[Q_index].calculate(energy=energy) @@ -456,10 +456,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 +529,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,6 +585,7 @@ def plot_parameters( ds = self.parameters_to_dataset() + # None or an empty list both mean 'plot all parameters'. if not names: names = list(ds.keys()) @@ -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,6 +696,8 @@ def _create_analysis_list(self) -> None: """ self._analysis_list = [] for Q_index in range(len(self.Q)): + # The ConvolutionSettings object is shared so user changes reach every Q index; + # plan validity is tracked per convolver, not on the settings object. analysis = Analysis1d( display_name=f'{self.display_name}_Q{Q_index}', experiment=self.experiment, @@ -756,16 +756,22 @@ def _fit_all_Q_simultaneously(self) -> FitResults: ys = [] ws = [] + # TODO: consider using scipp built-in masking instead of numpy boolean masks, # noqa: FIX002 TD002 TD003 + 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( 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) + # Reuse the mask from the extraction above so the masked energy does not require a + # second full extraction. + mask_var = sc.array(dims=['energy'], values=mask) + analysis1d.refresh_convolver( + energy=self.experiment.get_masked_energy(Q_index=analysis1d.Q_index, mask=mask_var) + ) mf = MultiFitter( fit_objects=self.analysis_list, diff --git a/src/easydynamics/analysis/analysis1d.py b/src/easydynamics/analysis/analysis1d.py index 01fa826f5..3a66ca6de 100644 --- a/src/easydynamics/analysis/analysis1d.py +++ b/src/easydynamics/analysis/analysis1d.py @@ -109,6 +109,14 @@ def __init__( Extra parameters to be included in the analysis for advanced users. If None, no extra parameters are added. """ + # Initialize state read by observer callbacks (e.g. _on_experiment_changed) before + # super().__init__ wires the sub-models and fires them. + self._Q_index = None + self._masked_energy = None + self._fit_result = None + self._convolver = None + self._convolver_is_dirty = True + super().__init__( display_name=display_name, unique_name=unique_name, @@ -124,14 +132,7 @@ def __init__( self._Q_index = Q_index if self._Q_index is not None and self.experiment is not None: - masked_energy = self.experiment.get_masked_energy(Q_index=self._Q_index) - self._masked_energy = masked_energy - else: - self._masked_energy = None - - self._fit_result = None - self._convolver = None - self._convolver_is_dirty = True + self._masked_energy = self.experiment.get_masked_energy(Q_index=self._Q_index) ############# # Properties @@ -172,7 +173,7 @@ def calculate(self, energy: sc.Variable | None = None) -> np.ndarray: """ Calculate the model prediction for the chosen Q index. - Makes sure the convolver is up to date before calculating. + Creates a new convolver before calculating without touching the stored convolver. Parameters ---------- @@ -186,14 +187,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 - - return self._calculate(energy=energy) + convolver = self._create_convolver(energy=energy) + return self._calculate(energy=energy, convolver=convolver) - def _calculate(self, energy: sc.Variable | None = None) -> np.ndarray: + def _calculate( + self, energy: sc.Variable | None = None, convolver: Convolution | None = None + ) -> np.ndarray: """ Calculate the model prediction for the chosen Q index. @@ -204,18 +203,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 : Convolution | 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), @@ -256,7 +258,7 @@ def fit(self) -> FitResults: fit_function=self.as_fit_function(), ) - x, y, weights, _ = self.experiment._extract_x_y_weights_only_finite( # noqa: SLF001 + x, y, weights, _ = self.experiment.extract_x_y_weights_only_finite( Q_index=self._require_Q_index() ) fit_result = fitter.fit(x=x, y=y, weights=weights) @@ -440,7 +442,7 @@ def data_and_model_to_datagroup( energy = self._masked_energy data_and_model = { - 'Data': self.experiment.binned_data['Q', self.Q_index], + 'Data': self.experiment.get_masked_binned_data(Q_index=self.Q_index), 'Model': self._create_model_array(energy=energy), } @@ -518,6 +520,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 @@ -525,6 +531,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 self._Q_index 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: @@ -563,28 +572,15 @@ def _calculate_energy_with_offset( energy_offset : Parameter The energy offset to apply. - Raises - ------ - sc.UnitError - If the energy and energy offset have incompatible units. - Returns ------- sc.Variable The energy grid with the offset applied. """ - if energy.unit != energy_offset.unit: - try: - energy_offset.convert_unit(str(energy.unit)) - except Exception as e: - raise sc.UnitError( - f'Energy and energy offset must have compatible units. ' - f'Got {energy.unit} and {energy_offset.unit}.' - ) from e - - energy_with_offset = energy.copy(deep=True) - energy_with_offset.values -= energy_offset.value + offset_value = sc.to_unit(energy_offset.full_value, energy.unit).value + energy_with_offset = energy.copy() + energy_with_offset.values = energy.values - offset_value return energy_with_offset ############# @@ -642,18 +638,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( @@ -721,11 +714,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, @@ -771,7 +778,7 @@ 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] + data = self.experiment.get_masked_binned_data(Q_index=self.Q_index) model = self._create_model_array() return data.copy(deep=True) - model diff --git a/src/easydynamics/base_classes/easydynamics_modelbase.py b/src/easydynamics/base_classes/easydynamics_modelbase.py index 6d002f71e..3e48fe82c 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 y-axis. 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 y-axis. """ + 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 535bea989..efcdf6f52 100644 --- a/src/easydynamics/convolution/analytical_convolution.py +++ b/src/easydynamics/convolution/analytical_convolution.py @@ -491,6 +491,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'y_unit={self.y_unit}, ' f'energy_len={len(self.energy)})' ) diff --git a/src/easydynamics/convolution/convolution.py b/src/easydynamics/convolution/convolution.py index d4e8aafee..ccf37f474 100644 --- a/src/easydynamics/convolution/convolution.py +++ b/src/easydynamics/convolution/convolution.py @@ -80,9 +80,8 @@ 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', '_resolution_components', '_temperature', @@ -101,7 +100,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 +113,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 +126,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 +146,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, ) @@ -168,7 +171,7 @@ def convolution( np.ndarray The convolved values evaluated at energy. """ - if not self.convolution_settings.convolution_plan_is_valid: + if not self._convolution_plan_is_current(): self._build_convolution_plan() total = np.zeros_like(self.energy.values, dtype=float) @@ -287,7 +290,7 @@ def _build_convolution_plan(self) -> None: # Update convolvers self._set_convolvers() - self.convolution_settings.convolution_plan_is_valid = True + self._mark_convolution_plan_current() def _set_convolvers(self) -> None: """ @@ -303,6 +306,8 @@ def _set_convolvers(self) -> None: energy_offset=self.energy_offset, sample_components=self._analytical_sample_components, resolution_components=self._resolution_components, + x_unit=self.x_unit, + y_unit=self.y_unit, ) else: self._analytical_convolver = None @@ -317,11 +322,29 @@ 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, + y_unit=self.y_unit, ) else: self._numerical_convolver = None + def convert_y_unit(self, unit: str) -> None: + """ + Convert the y-axis unit and propagate it to the analytical and numerical sub-convolvers. + + Parameters + ---------- + unit : str + The new y-axis unit. + """ + super().convert_y_unit(unit) + # The sub-convolvers share this convolver's component objects, which were already + # converted by super(); only their y-unit labels need updating. + if getattr(self, '_analytical_convolver', None) is not None: + self._analytical_convolver._relabel_y_unit(self.y_unit) # noqa: SLF001 + if getattr(self, '_numerical_convolver', None) is not None: + self._numerical_convolver._relabel_y_unit(self.y_unit) # noqa: SLF001 + # Update some setters so the internal sample models are updated def __setattr__(self, name: str, value: any) -> None: """ @@ -341,16 +364,18 @@ def __setattr__(self, name: str, value: any) -> None: super().__setattr__(name, value) # Only rebuild the convolution plan if reactions are enabled, to - # avoid issues during __init__ + # avoid issues during __init__. These are convolver-local changes, so other convolvers + # sharing the same ConvolutionSettings are unaffected. if getattr(self, '_reactions_enabled', False) and name in self._invalidate_plan_on_change: - self.convolution_settings.convolution_plan_is_valid = False + self._plan_seen_version = None def __repr__(self) -> str: return ( 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'y_unit={self.y_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 c8516cb0b..83c0c2351 100644 --- a/src/easydynamics/convolution/convolution_base.py +++ b/src/easydynamics/convolution/convolution_base.py @@ -1,6 +1,8 @@ # SPDX-FileCopyrightText: 2026 EasyScience contributors # SPDX-License-Identifier: BSD-3-Clause +from functools import partial + import numpy as np import scipp as sc from easyscience.variable import Parameter @@ -9,6 +11,9 @@ 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 convert_parameter_unit +from easydynamics.utils.utils import convert_units_with_rollback +from easydynamics.utils.utils import energy_to_scipp class ConvolutionBase(EasyDynamicsModelBase): @@ -23,7 +28,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 +45,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 +66,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 +79,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 +158,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 +199,18 @@ 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. + + If any conversion fails, the already-converted state is rolled back best-effort before the + failing conversion's exception is re-raised. Parameters ---------- @@ -206,27 +221,69 @@ def convert_unit(self, unit: str | sc.Unit) -> None: ------ TypeError If unit is not a string or scipp unit. - Exception - If energy cannot be converted to the specified unit. """ if not isinstance(unit, (str, sc.Unit)): raise TypeError('Energy unit must be a string or scipp unit.') - old_energy = self.energy.copy() - try: - self.energy = sc.to_unit(self.energy, unit) - except Exception as e: - self.energy = old_energy - raise e - - 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._unit = unit + old_x_unit = str(self.x_unit) + old_offset_unit = str(self.energy_offset.unit) + + def _convert_energy(target_unit: str | sc.Unit) -> None: + self.energy = sc.to_unit(self.energy, target_unit) + + conversions = [ + (_convert_energy, unit, old_x_unit), + (partial(convert_parameter_unit, self._energy_offset), unit, old_offset_unit), + ] + if self.sample_components is not None: + conversions.append((self.sample_components.convert_x_unit, unit, old_x_unit)) + if self.resolution_components is not None: + conversions.append((self.resolution_components.convert_x_unit, unit, old_x_unit)) + convert_units_with_rollback(conversions) + + self._x_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). If + any component raises during unit conversion, the conversion is rolled back best-effort + before the exception is re-raised. + + 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.') + old_y_unit = self.y_unit + if self.sample_components is not None: + convert_units_with_rollback([ + (self.sample_components.convert_y_unit, unit, old_y_unit) + ]) + self._relabel_y_unit(unit) + + def _relabel_y_unit(self, unit: str | sc.Unit) -> None: + """ + Update the y-unit label without converting any components. + + This is the contract for a parent convolver whose sub-convolvers share its component + objects: the parent converts the components once, then relabels the sub-convolvers so their + y_unit stays consistent without double-converting the shared components. + + Parameters + ---------- + unit : str | sc.Unit + The new y-axis unit. The caller is responsible for having converted the components. + """ + 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 e0ddde05d..0443e986a 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 @@ -31,9 +32,9 @@ def convolution( """ # Make sure the convolver is updated with the latest convolution # settings before convolution. - if not self.convolution_settings.convolution_plan_is_valid: + if not self._convolution_plan_is_current(): self._energy_grid = self._create_energy_grid() - self.convolution_settings.convolution_plan_is_valid = True + self._mark_convolution_plan_current() # Give warnings if peaks are very wide or very narrow if not self.convolution_settings.suppress_warnings: @@ -46,18 +47,24 @@ def convolution( model_name='resolution model', ) - # Evaluate sample model. If called via the Convolution class, - # delta functions are already filtered out. - sample_vals = self.sample_components.evaluate( + # Unit-convert the energy offset to match the energy grid unit. + # sc.to_unit returns a new scalar — self.energy_offset is never mutated. + offset_value = sc.to_unit(self.energy_offset.full_value, self.energy.unit).value + + shifted_energy = ( self._energy_grid.energy_dense - self._energy_grid.energy_even_length_offset - - self.energy_offset.value + - offset_value ) + # Evaluate sample model. If called via the Convolution class, + # delta functions are already filtered out. + sample_vals = self.sample_components.evaluate(shifted_energy) + # 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=shifted_energy, temperature=self.temperature, energy_unit=self.energy.unit, divide_by_temperature=self.detailed_balance_settings.normalize_detailed_balance, @@ -90,7 +97,8 @@ 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'y_unit={self.y_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 188a69dbc..dc257fd36 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, @@ -116,7 +120,30 @@ def __init__( # When upsample_factor>1, we evaluate on this grid and # interpolate back to the original values at the end self._energy_grid = self._create_energy_grid() - self._convolution_settings.convolution_plan_is_valid = True + self._mark_convolution_plan_current() + + def _convolution_plan_is_current(self) -> bool: + """ + Check whether this convolver's plan is up to date. + + Plan validity is tracked per convolver so several convolvers can share one + ConvolutionSettings object: each convolver stores the settings' plan version it last + rebuilt against (None after a convolver-local invalidation such as a new energy grid), and + the settings bump their version whenever an accuracy knob changes. + + Returns + ------- + bool + True if the plan does not need to be rebuilt. + """ + seen_version = getattr(self, '_plan_seen_version', None) + if seen_version is None: + return False + return self.convolution_settings._plan_valid_for(seen_version) # noqa: SLF001 + + def _mark_convolution_plan_current(self) -> None: + """Record that this convolver's plan matches its current state and settings.""" + self._plan_seen_version = self.convolution_settings._plan_version # noqa: SLF001 @property def convolution_settings(self) -> ConvolutionSettings: @@ -149,12 +176,17 @@ def convolution_settings(self, settings: ConvolutionSettings) -> None: if not isinstance(settings, ConvolutionSettings): raise TypeError('settings must be a ConvolutionSettings instance.') self._convolution_settings = settings - self._convolution_settings.convolution_plan_is_valid = False + # Convolver-local invalidation: other convolvers sharing the new settings object are + # unaffected. + self._plan_seen_version = None @ConvolutionBase.energy.setter def energy(self, energy: np.ndarray) -> None: """ - Set the energy array and recreate the dense grid. + Set the energy array and invalidate this convolver's plan. + + The dense grid is rebuilt lazily on the next convolution. Other convolvers sharing the same + ConvolutionSettings are unaffected — a new energy array is a convolver-local change. Parameters ---------- @@ -162,7 +194,7 @@ def energy(self, energy: np.ndarray) -> None: The new energy array. """ ConvolutionBase.energy.fset(self, energy) - self.convolution_settings.convolution_plan_is_valid = False + self._plan_seen_version = None @property def upsample_factor(self) -> Numeric | None: @@ -332,6 +364,10 @@ def _create_energy_grid( energy_span_dense = self.energy.values.max() - self.energy.values.min() else: + if self.extension_factor is None: + raise ValueError( + 'extension_factor must be a number (not None) when upsample_factor is set.' + ) # Create an extended and upsampled energy grid energy_min, energy_max = self.energy.values.min(), self.energy.values.max() energy_span_original = energy_max - energy_min @@ -437,12 +473,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}, y_unit={self.y_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 a27dc2b66..27df30315 100644 --- a/src/easydynamics/experiment/experiment.py +++ b/src/easydynamics/experiment/experiment.py @@ -230,7 +230,9 @@ def energy(self, _value: sc.Variable) -> None: """ raise AttributeError('energy is a read-only property derived from the data.') - def get_masked_energy(self, Q_index: int) -> sc.Variable | None: + def get_masked_energy( + self, Q_index: int, mask: sc.Variable | None = None + ) -> sc.Variable | None: """ Get the energy values from the dataset, removing points where the y values or variances are NaN or Inf for the given Q index. @@ -239,22 +241,66 @@ def get_masked_energy(self, Q_index: int) -> sc.Variable | None: ---------- Q_index : int The Q index to get the masked energy values for. + mask : sc.Variable | None, default=None + Optional precomputed finite-energy mask (as returned by + :meth:`get_finite_energy_mask`), so callers that already extracted the data do not pay + for a second extraction. If None, the mask is computed. Returns ------- sc.Variable | None The masked energy values from the dataset, or None if no data is loaded. """ + if mask is None: + mask = self.get_finite_energy_mask(Q_index=Q_index) + if mask is None: + return None + + return self.binned_data.coords['energy'][mask] + + def get_finite_energy_mask(self, Q_index: int) -> sc.Variable | None: + """ + Get a boolean scipp Variable selecting energy points with finite intensity at the given Q. + + Parameters + ---------- + Q_index : int + The Q index to get the mask for. + + Returns + ------- + sc.Variable | None + Boolean scipp Variable of length n_energy with dim ``'energy'``, or None if no data is + loaded. + """ if self.binned_data is None: return None 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) + _, _, _, mask = self.extract_x_y_weights_only_finite(Q_index=Q_index) + return sc.array(dims=['energy'], values=mask) + + def get_masked_binned_data(self, Q_index: int) -> sc.DataArray | None: + """ + Get the binned data for a single Q slice with non-finite points masked out. + + Parameters + ---------- + Q_index : int + The Q index to extract. + + Returns + ------- + sc.DataArray | None + The binned data for the given Q index with NaN/Inf points removed, or None if no data + is loaded. + """ + mask_var = self.get_finite_energy_mask(Q_index=Q_index) + if mask_var is None: + return None - mask_var = sc.array(dims=['energy'], values=mask) - return energy[mask_var] + return self.binned_data['Q', Q_index][mask_var] ########### # Handle data @@ -537,7 +583,7 @@ def _extract_x_y_var(self, Q_index: int) -> tuple[np.ndarray, np.ndarray, np.nda var = data.variances return x, y, var - def _extract_x_y_weights_only_finite( + def extract_x_y_weights_only_finite( self, Q_index: int ) -> tuple[np.ndarray, np.ndarray, np.ndarray, np.ndarray]: """ diff --git a/src/easydynamics/sample_model/background_model.py b/src/easydynamics/sample_model/background_model.py index 34d313932..031d9493c 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}, y_unit={self.y_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 54a6b0010..b63135fb8 100644 --- a/src/easydynamics/sample_model/component_collection.py +++ b/src/easydynamics/sample_model/component_collection.py @@ -9,14 +9,17 @@ 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 +from easydynamics.utils.fit_target import FitTarget +from easydynamics.utils.utils import convert_units_with_rollback +from easydynamics.utils.utils import convert_value_unit if TYPE_CHECKING: + from easyscience.variable import DescriptorBase + from easydynamics.utils.utils import Numeric @@ -54,7 +57,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 +70,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 +92,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 +110,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,40 +155,65 @@ 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 + ---------- + new_x_unit : str | sc.Unit + The target x-axis unit to convert to. + """ + self._convert_axis_unit(new_x_unit, axis='x') + + def convert_y_unit(self, new_y_unit: str | sc.Unit) -> None: + """ + Convert the y-axis unit of the ComponentCollection and all its components. + + Parameters + ---------- + new_y_unit : str | sc.Unit + The target y-axis unit to convert to. + """ + self._convert_axis_unit(new_y_unit, axis='y') + + def _convert_axis_unit(self, unit: str | sc.Unit, axis: str) -> None: + """ + Convert one axis unit on all components in the collection. + + Converts every component via its ``convert__unit`` method and updates the + collection's own unit attribute. On failure, attempts a best-effort rollback of all + components to the old unit before re-raising the failing conversion's exception. Parameters ---------- unit : str | sc.Unit - The target unit to convert to. + The new unit to convert to. + axis : str + Which axis to convert: ``'x'`` or ``'y'``. Raises ------ TypeError - If unit is not a string or sc.Unit. - Exception - If any component cannot be converted to the specified unit. + If the provided unit is not a string or sc.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 - - try: - for component in self: - component.convert_unit(unit) - self._unit = unit - except Exception as e: - # Attempt to rollback on failure - try: - for component in self: - component.convert_unit(old_unit) - except Exception: # noqa: S110 - pass # Best effort rollback - raise e + raise TypeError(f'{axis}_unit must be a string or sc.Unit, got {type(unit).__name__}') + + method = f'convert_{axis}_unit' + old_unit = self.x_unit if axis == 'x' else self.y_unit + convert_units_with_rollback([ + (getattr(component, method), unit, old_unit) for component in self + ]) + unit_str = str(unit) if isinstance(unit, sc.Unit) else unit + if axis == 'x': + self._x_unit = unit_str + else: + self._y_unit = unit_str # ------------------------------------------------------------------ # Component management @@ -211,9 +245,33 @@ 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 get_fit_targets(self) -> list[FitTarget]: + """ + Get the fittable predictions of this collection as FitTargets. + + Collections have a single prediction — their summed ``evaluate`` — named ``'value'`` with + no default dataset key; ``FitBinding`` supplies the dataset key to fit against. The target + is a snapshot: its units reflect the collection's x_unit/y_unit at call time (None means + raw values are fitted without unit conversion). + + Returns + ------- + list[FitTarget] + A single FitTarget wrapping this collection's evaluate. + """ + return [ + FitTarget( + name='value', + dataset_key=None, + function=lambda x, model=self, **_: model.evaluate(x), + label=self.display_name, + x_unit=self.x_unit, + y_unit=self.y_unit, + ) + ] + def normalize_area(self) -> None: """ Normalize the areas of all components so they sum to 1. @@ -230,28 +288,37 @@ 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: + if not area_params: + raise ValueError('No components with an area attribute; cannot normalize.') + + # Sum the areas in a common unit so components with different (but compatible) + # units normalize correctly. Dividing each value by the total expressed in the + # reference unit makes the areas sum to 1 in that unit. + reference_unit = str(area_params[0].unit) + total_area_value = sum( + convert_value_unit(p.value, p.unit, reference_unit) 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 +326,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 +347,35 @@ 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) + if isinstance(x, (sc.Variable, sc.DataArray)): + values = np.zeros_like(x.values, dtype=float) + dim = x.dims[0] if x.dims else 'x' + else: + values = np.zeros_like(x, dtype=float) + dim = 'x' + if output == 'scipp': + return sc.array(dims=[dim], values=values, unit=self.y_unit) + return values + # This is needed to handle both scipp and numpy output - a normal call to sum does not work + 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 +385,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 +399,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 +456,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"{self.__class__.__name__}(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 +474,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], @@ -433,13 +513,14 @@ def deserialise_component(d: dict) -> ModelComponent: cls = getattr(module, d['@class']) return cls.from_dict(d) - components = [deserialise_component(c) for c in obj_dict.get('components', [])] + components = [deserialise_component(c) for c in obj_dict['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 +532,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 9d4d1ed75..90a63562f 100644 --- a/src/easydynamics/sample_model/components/damped_harmonic_oscillator.py +++ b/src/easydynamics/sample_model/components/damped_harmonic_oscillator.py @@ -20,8 +20,11 @@ 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 the area (``area``), $x_0$ is the center (``center``), and $\gamma$ is the half + width at half max (``width``). area has unit = x_unit * y_unit; center and width have unit = + x_unit. Examples -------- @@ -55,56 +58,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; approximately the peak position. 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 of the component. 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( + # These methods live in CreateParametersMixin + 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 +118,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 +142,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,66 +175,94 @@ 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_values(self, x_vals: np.ndarray, eval_unit: str | None) -> np.ndarray: r""" - Evaluate the Damped Harmonic Oscillator at the given x values. + Evaluate the DHO at x_vals. + + $$ I(x) = \frac{2 A x_0^2 \gamma}{\pi \left( (x^2 - x_0^2)^2 + (2\gamma x)^2 \right)} $$ - 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. + where *A* is ``area``, *x*₀ is ``center`` (resonance frequency), and *gamma* is ``width`` + (damping coefficient). Here *I* is the scattered intensity. Parameters in the model's own + units are temporarily converted to eval_unit for the computation. Parameters ---------- - x : Numeric | list | np.ndarray | sc.Variable | sc.DataArray - The x values at which to evaluate the DHO. + x_vals : np.ndarray + Raw x values expressed in eval_unit. + eval_unit : str | None + The unit of x_vals. Returns ------- np.ndarray - The intensity of the DHO at the given x values. + Evaluated DHO values at x_vals. """ + 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, self._eval_area_unit(eval_unit)) - x = self._prepare_x_for_evaluate(x) + normalization = 2 * center**2 * width / np.pi + denominator = (x_vals**2 - center**2) ** 2 + (2 * width * x_vals) ** 2 + # denominator cannot reach zero: center > 0 enforced by DHO_MINIMUM_CENTER + return area * normalization / denominator - 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 + def convert_x_unit(self, new_x_unit: str | sc.Unit) -> None: + """ + Convert x-axis parameters (center, width) and area to new_x_unit. + + Parameters + ---------- + new_x_unit : str | sc.Unit + Target x-axis unit. Must be dimensionally compatible with the current x_unit. + """ + self._convert_x_unit_area_based( + new_x_unit=new_x_unit, + x_params=[self._center, self._width], + area_param=self._area, + ) - return self.area.value * normalization / (denominator) + 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. + """ + self._convert_y_unit_area_based(new_y_unit=new_y_unit, area_param=self._area) def __repr__(self) -> str: """ @@ -247,10 +274,9 @@ def __repr__(self) -> str: A string representation of the Damped Harmonic Oscillator. """ 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 bca8c4239..60c343a3d 100644 --- a/src/easydynamics/sample_model/components/delta_function.py +++ b/src/easydynamics/sample_model/components/delta_function.py @@ -11,8 +11,7 @@ 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 @@ -23,9 +22,12 @@ 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. + When called directly, returns zero everywhere except at the bin nearest to ``center``, where it + returns ``area / bin_width``. In convolutions it acts as an identity element (handled by the + ``Convolution`` class). 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 +59,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 +71,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 of the component. display_name : str | None, default=None - Display name of the component. + Display name of the component, 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 +109,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 +138,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,53 +163,93 @@ 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_values(self, x_vals: np.ndarray, eval_unit: str | None) -> np.ndarray: """ - Evaluate the Delta function at the given x values. + Evaluate the Delta function at x_vals. - 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 eval_unit for the + computation. Parameters ---------- - x : Numeric | list | np.ndarray | sc.Variable | sc.DataArray - The x values at which to evaluate the Delta function. + x_vals : np.ndarray + Raw x values expressed in eval_unit. May be in any order; bin widths are computed on + the sorted values. + eval_unit : str | None + The unit of x_vals. Returns ------- np.ndarray - The evaluated Delta function at the given x values. + Zero everywhere, with a single non-zero bin nearest the center when center falls within + the x range. + + Notes + ----- + When ``center`` falls within the x range, the bin nearest to ``center`` receives ``area / + bin_width`` rather than zero. In convolutions, the DeltaFunction acts as an identity + element (handled by the Convolution class). """ + center = self._resolve_param_value(self._center, eval_unit) + area = self._resolve_param_value(self._area, self._eval_area_unit(eval_unit)) + + model = np.zeros_like(x_vals, dtype=float) - # 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 + if x_vals.min() - EPSILON <= center <= x_vals.max() + EPSILON: + # Bin widths only make sense on a sorted grid; compute there and map the spike back + # to the original position of the nearest x value. + order = np.argsort(x_vals) + x_sorted = x_vals[order] - if x.min() - EPSILON <= center <= x.max() + EPSILON: - # nearest index - i = np.argmin(np.abs(x - center)) + # nearest index in the sorted grid + i = np.argmin(np.abs(x_sorted - center)) # left half-width - if i == 0: # noqa: SIM108 - left = x[1] - x[0] if x.size > 1 else 0.5 + if i == 0: + left = x_sorted[1] - x_sorted[0] if x_sorted.size > 1 else 0.5 else: - left = x[i] - x[i - 1] + left = x_sorted[i] - x_sorted[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_sorted.size - 1: + right = x_sorted[-1] - x_sorted[-2] if x_sorted.size > 1 else 0.5 else: - right = x[i + 1] - x[i] + right = x_sorted[i + 1] - x_sorted[i] # effective bin width: half left + half right bin_width = 0.5 * (left + right) - - model[i] = area / bin_width + model[order[i]] = area / bin_width return model + def convert_x_unit(self, new_x_unit: str | sc.Unit) -> None: + """ + Convert x-axis parameters (center) and area to new_x_unit. + + Parameters + ---------- + new_x_unit : str | sc.Unit + Target x-axis unit. Must be dimensionally compatible with the current x_unit. + """ + self._convert_x_unit_area_based( + new_x_unit=new_x_unit, + x_params=[self._center], + area_param=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. + """ + self._convert_y_unit_area_based(new_y_unit=new_y_unit, area_param=self._area) + def __repr__(self) -> str: """ Return a string representation of the Delta function. @@ -223,11 +259,9 @@ def __repr__(self) -> str: str A string representation of the Delta function. """ - 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 8b7e3241a..08dd3b437 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 = + 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,61 +64,57 @@ def __init__( Parameters ---------- amplitude : Numeric, default=1.0 - Amplitude of the Exponential. + Pre-exponential factor A. Unit is ``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``. + y_unit : str | sc.Unit, default='dimensionless' + Unit of the y-axis (output). amplitude is stored in this unit. name : str, default='Exponential' - Name of the component for indexing. + Name of the component. 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 = Parameter(name=name + ' amplitude', value=float(amplitude), unit=unit) - - center = self._create_center_parameter( - center=center, name=name, fix_if_none=True, unit=self._unit + if not isinstance(amplitude, Numeric): + raise TypeError('amplitude must be a number.') + if not np.isfinite(amplitude): + raise ValueError('amplitude must be finite.') + self._amplitude = Parameter( + name=name + ' amplitude', value=float(amplitude), unit=self.y_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)) + self._center = self._create_center_parameter( + center=center, name=name, fix_if_none=True, x_unit=self.x_unit + ) - self._amplitude = amplitude - self._center = center - self._rate = rate + if not isinstance(rate, Numeric): + raise TypeError('rate must be a number.') + if not np.isfinite(rate): + raise ValueError('rate must be finite.') + self._rate = Parameter(name=name + ' rate', value=float(rate), unit='1/' + x_unit_str) @property def amplitude(self) -> Parameter: @@ -128,27 +124,23 @@ def amplitude(self) -> Parameter: Returns ------- Parameter - The amplitude parameter. + The amplitude Parameter with 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 = 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 +153,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,94 +186,86 @@ 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: + def _evaluate_values(self, x_vals: np.ndarray, eval_unit: str | None) -> np.ndarray: r""" - Evaluate the Exponential at the given x values. + Evaluate the Exponential at x_vals. - 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) $$ - - 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 eval_unit for the + computation. Parameters ---------- - x : Numeric | list | np.ndarray | sc.Variable | sc.DataArray - The x values at which to evaluate the Exponential. + x_vals : np.ndarray + Raw x values expressed in eval_unit. + eval_unit : str | None + The unit of x_vals. Returns ------- np.ndarray - The intensity of the Exponential at the given x values. + Evaluated exponential values at x_vals. """ + eval_rate_unit = None if eval_unit is None else '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) + # The amplitude carries y_unit only, so it is unaffected by the x evaluation unit. + amplitude = self._amplitude.value - return self.amplitude.value * np.exp(exponent) + exponent = rate * (x_vals - center) + return amplitude * np.exp(exponent) - 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 Parameters in the component. + Convert center to new_x_unit and rate to 1/new_x_unit. + + The amplitude carries ``y_unit`` only and is unaffected. 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``. + """ + self._convert_x_unit_area_based( + new_x_unit=new_x_unit, + x_params=[self._center], + inverse_params=[self._rate], + ) - Raises - ------ - TypeError - If unit is not a string or sc.Unit. - Exception - If conversion fails for any parameter. + def convert_y_unit(self, new_y_unit: str | sc.Unit) -> None: """ + Convert the y-axis unit by rescaling the amplitude parameter. - 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] - try: - for p in pars: - p.convert_unit(unit) - self.rate.convert_unit('1/' + str(unit)) - self._unit = unit - 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)) - except Exception: # noqa: S110 - pass # Best effort rollback - raise e + The amplitude is rescaled from ``old_y_unit`` to ``new_y_unit``. + + Parameters + ---------- + new_y_unit : str | sc.Unit + Target y-axis unit. + """ + self._convert_y_unit_area_based(new_y_unit=new_y_unit, y_params=[self._amplitude]) def __repr__(self) -> str: """ @@ -296,12 +276,10 @@ def __repr__(self) -> str: str A string representation of the Exponential. """ - 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 feda61c6f..eef67ff66 100644 --- a/src/easydynamics/sample_model/components/expression_component.py +++ b/src/easydynamics/sample_model/components/expression_component.py @@ -3,6 +3,7 @@ from __future__ import annotations +import warnings from typing import TYPE_CHECKING from typing import ClassVar @@ -10,13 +11,13 @@ 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,13 @@ class ExpressionComponent(ModelComponent): ``` """ - # ------------------------- - # Allowed symbolic functions - # ------------------------- _ALLOWED_FUNCS: ClassVar[dict[str, object]] = { - # Exponentials & logs + # exponential / logarithmic 'exp': sp.exp, 'log': sp.log, 'ln': sp.log, 'sqrt': sp.sqrt, - # Trigonometric + # trigonometric 'sin': sp.sin, 'cos': sp.cos, 'tan': sp.tan, @@ -73,25 +71,23 @@ class ExpressionComponent(ModelComponent): 'cot': sp.cot, 'sec': sp.sec, 'csc': sp.csc, + # inverse trigonometric 'asin': sp.asin, 'acos': sp.acos, 'atan': sp.atan, - # Hyperbolic + # hyperbolic 'sinh': sp.sinh, 'cosh': sp.cosh, 'tanh': sp.tanh, - # Misc + # rounding / sign 'abs': sp.Abs, 'sign': sp.sign, 'floor': sp.floor, 'ceil': sp.ceiling, - # Special functions + # special functions 'erf': sp.erf, } - # ------------------------- - # Allowed constants - # ------------------------- _ALLOWED_CONSTANTS: ClassVar[dict[str, object]] = { 'pi': sp.pi, 'E': sp.E, @@ -103,7 +99,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 +114,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 of the component. 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 +132,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( @@ -158,15 +163,14 @@ def __init__( 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,12 +179,10 @@ 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( @@ -205,19 +207,17 @@ 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, @@ -255,31 +255,42 @@ def expression(self, _new_expr: str) -> None: 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: + def _evaluate_values(self, x_vals: np.ndarray, eval_unit: str | None) -> np.ndarray: """ - Evaluate the expression for given x values. + Evaluate the expression for the given x values. + + Unit conversion of parameters is not supported for ExpressionComponent. If x_vals is + expressed in a different unit than x_unit, a warning is issued and the values are used + as-is. Parameters ---------- - x : Numeric | list | np.ndarray | sc.Variable | sc.DataArray - Input values for the independent variable. + x_vals : np.ndarray + Raw x values expressed in eval_unit. + eval_unit : str | None + The unit of x_vals. Returns ------- np.ndarray Evaluated results. """ - x = self._prepare_x_for_evaluate(x) + if eval_unit is not None and self.x_unit is not None and eval_unit != self.x_unit: + warnings.warn( + f'Input x has unit {eval_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=3, + ) args = [] for name in self._symbol_names: if name == 'x': - args.append(x) + args.append(x_vals) else: args.append(self._parameters[name].value) @@ -296,9 +307,9 @@ def get_all_variables(self) -> list[Parameter]: """ return list(self._parameters.values()) - def convert_unit(self, _new_unit: str | sc.Unit) -> None: + def convert_x_unit(self, _new_unit: str | sc.Unit) -> None: """ - Convert the unit of the expression. + Convert the x-axis unit of the expression. Unit conversion is not implemented for ExpressionComponent. @@ -312,7 +323,26 @@ def convert_unit(self, _new_unit: str | sc.Unit) -> None: NotImplementedError Always raised to indicate unit conversion is not supported. """ + # TODO: Generalize x_unit conversion for ExpressionComponent. Not tackled in this PR. # noqa: FIX002 TD002 TD003 + raise NotImplementedError('Unit conversion is not implemented for ExpressionComponent') + + def convert_y_unit(self, _new_unit: str | sc.Unit) -> None: + """ + Convert the y-axis 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. + """ + # TODO: Generalize y_unit conversion for ExpressionComponent. Not tackled in this PR. # noqa: FIX002 TD002 TD003 raise NotImplementedError('Unit conversion is not implemented for ExpressionComponent') # ------------------------- @@ -360,13 +390,10 @@ def __setattr__(self, name: str, value: Numeric) -> None: """ 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]: @@ -391,9 +418,8 @@ def __repr__(self) -> str: """ 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'{self.__class__.__name__}(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 6a1805e13..364e89aba 100644 --- a/src/easydynamics/sample_model/components/gaussian.py +++ b/src/easydynamics/sample_model/components/gaussian.py @@ -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. - display_name : str | None, default=None Name of the component. + display_name : str | None, default=None + 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,48 +171,43 @@ 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: + def _evaluate_values(self, x_vals: np.ndarray, eval_unit: str | None) -> np.ndarray: r""" - Evaluate the Gaussian at the given x values. + Evaluate the Gaussian at x_vals. + + Parameters in the model's own units are temporarily converted to eval_unit for the + computation. - 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) $$ @@ -228,21 +215,51 @@ def evaluate( Parameters ---------- - x : Numeric | list | np.ndarray | sc.Variable | sc.DataArray - The x values at which to evaluate the Gaussian. + x_vals : np.ndarray + Raw x values expressed in eval_unit. + eval_unit : str | None + The unit of x_vals. Returns ------- np.ndarray - The intensity of the Gaussian at the given x values. + Evaluated Gaussian values at x_vals. + """ + 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, self._eval_area_unit(eval_unit)) + + normalization = 1 / (np.sqrt(2 * np.pi) * width) + exponent = -0.5 * ((x_vals - center) / width) ** 2 + return area * normalization * np.exp(exponent) + + def convert_x_unit(self, new_x_unit: str | sc.Unit) -> None: + """ + Convert x-axis parameters (center, width) and area to new_x_unit. + + Parameters + ---------- + new_x_unit : str | sc.Unit + Target x-axis unit. Must be dimensionally compatible with the current x_unit. """ + self._convert_x_unit_area_based( + new_x_unit=new_x_unit, + x_params=[self._center, self._width], + area_param=self._area, + ) - x = self._prepare_x_for_evaluate(x) + def convert_y_unit(self, new_y_unit: str | sc.Unit) -> None: + """ + Convert the y-axis (output) unit by rescaling the area parameter. - normalization = 1 / (np.sqrt(2 * np.pi) * self.width.value) - exponent = -0.5 * ((x - self.center.value) / self.width.value) ** 2 + The area is rescaled from ``x_unit * old_y_unit`` to ``x_unit * new_y_unit``. - return self.area.value * normalization * np.exp(exponent) + Parameters + ---------- + new_y_unit : str | sc.Unit + Target y-axis unit. + """ + self._convert_y_unit_area_based(new_y_unit=new_y_unit, area_param=self._area) def __repr__(self) -> str: """ @@ -253,12 +270,10 @@ def __repr__(self) -> str: str A string representation of the Gaussian. """ - 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 f2da78c84..fe36340fc 100644 --- a/src/easydynamics/sample_model/components/lorentzian.py +++ b/src/easydynamics/sample_model/components/lorentzian.py @@ -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 hald width at half max (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 of the component. 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,63 +174,85 @@ 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_values(self, x_vals: np.ndarray, eval_unit: str | None) -> np.ndarray: r""" - Evaluate the Lorentzian at the given x values. + Evaluate the Lorentzian at x_vals. - If x is a scipp Variable, the unit of the Lorentzian will be converted to match x. The - intensity is given by - - $$ I(x) = \frac{A}{\pi} \frac{\Gamma}{(x - x_0)^2 + \Gamma^2}, $$ - - where $A$ is the area, $x_0$ is the center, and $\Gamma$ is the half width at half maximum - (HWHM). + Parameters in the model's own units are temporarily converted to eval_unit for the + computation. Parameters ---------- - x : Numeric | list | np.ndarray | sc.Variable | sc.DataArray - The x values at which to evaluate the Lorentzian. + x_vals : np.ndarray + Raw x values expressed in eval_unit. + eval_unit : str | None + The unit of x_vals. Returns ------- np.ndarray - The intensity of the Lorentzian at the given x values. + Evaluated Lorentzian values at x_vals. """ + 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, self._eval_area_unit(eval_unit)) - x = self._prepare_x_for_evaluate(x) + normalization = width / np.pi + denominator = (x_vals - center) ** 2 + width**2 + return area * normalization / denominator - normalization = self.width.value / np.pi - denominator = (x - self.center.value) ** 2 + self.width.value**2 + def convert_x_unit(self, new_x_unit: str | sc.Unit) -> None: + """ + Convert x-axis parameters (center, width) and area to new_x_unit. - return self.area.value * normalization / denominator + Parameters + ---------- + new_x_unit : str | sc.Unit + Target x-axis unit. Must be dimensionally compatible with the current x_unit. + """ + self._convert_x_unit_area_based( + new_x_unit=new_x_unit, + x_params=[self._center, self._width], + area_param=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. + """ + self._convert_y_unit_area_based(new_y_unit=new_y_unit, area_param=self._area) def __repr__(self) -> str: """ @@ -245,10 +264,9 @@ def __repr__(self) -> str: A string representation of the Lorentzian. """ 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 7552d2e80..2fc0b8071 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,21 @@ 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=unit) - if enforce_minimum_center and center_param.min < DHO_MINIMUM_CENTER: - center_param.min = DHO_MINIMUM_CENTER + center_param = Parameter(name=name + ' center', value=float(center), unit=x_unit) + + if enforce_minimum_center: + # Clamp the value before raising the bound: Parameter.min rejects a new minimum + # that is larger than the current value. + if center_param.value < DHO_MINIMUM_CENTER: + center_param.value = DHO_MINIMUM_CENTER + if center_param.min < DHO_MINIMUM_CENTER: + center_param.min = DHO_MINIMUM_CENTER return center_param def _create_width_parameter( @@ -136,36 +145,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 +190,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 878f501fa..43a10cbad 100644 --- a/src/easydynamics/sample_model/components/model_component.py +++ b/src/easydynamics/sample_model/components/model_component.py @@ -3,15 +3,23 @@ from __future__ import annotations -import warnings from abc import abstractmethod +from functools import partial +from typing import TYPE_CHECKING import numpy as np import scipp as sc from scipp import UnitError from easydynamics.base_classes.easydynamics_modelbase import EasyDynamicsModelBase +from easydynamics.utils.fit_target import FitTarget from easydynamics.utils.utils import Numeric +from easydynamics.utils.utils import convert_parameter_unit +from easydynamics.utils.utils import convert_units_with_rollback +from easydynamics.utils.utils import convert_value_unit + +if TYPE_CHECKING: + from easyscience.variable import Parameter class ModelComponent(EasyDynamicsModelBase): @@ -19,107 +27,111 @@ class ModelComponent(EasyDynamicsModelBase): 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 get_fit_targets(self) -> list[FitTarget]: """ - Get the unit. + Get the fittable predictions of this component as FitTargets. + + Component models have a single prediction — their ``evaluate`` — named ``'value'`` with no + default dataset key; ``FitBinding`` supplies the dataset key to fit against. The target is + a snapshot: its units reflect the component's x_unit/y_unit at call time (None means raw + values are fitted without unit conversion). Returns ------- - str - The unit of the model component. + list[FitTarget] + A single FitTarget wrapping this component's evaluate. """ - return str(self._unit) + return [ + FitTarget( + name='value', + dataset_key=None, + function=lambda x, model=self, **_: model.evaluate(x), + label=self.display_name, + x_unit=self.x_unit, + y_unit=self.y_unit, + ) + ] - @unit.setter - def unit(self, _unit_str: str) -> None: + def fix_all_parameters(self) -> None: """ - Unit is read-only. - - Use convert_unit to change the unit between allowed types or create a new ModelComponent - with the desired unit. - - Parameters - ---------- - _unit_str : str - The new unit to set. + Fix all parameters in the model component. - Raises - ------ - AttributeError - Always raised since unit is read-only. + Sets ``fixed=True`` on every fittable parameter returned by + :meth:`get_fittable_parameters`. """ - raise AttributeError( - f'Unit is read-only. Use convert_unit to change the unit between allowed types ' - f'or create a new {self.__class__.__name__} with the desired unit.' - ) - - def fix_all_parameters(self) -> None: - """Fix all parameters in the model component.""" - - pars = self.get_fittable_parameters() - for p in pars: + 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 +140,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 != self.x_unit: try: - self.convert_unit(x.unit.name) + sc.to_unit(sc.scalar(1.0, unit=detected_unit), 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,60 +173,259 @@ 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 _resolve_param_value(self, param: Parameter, target_unit: str | None) -> float: + """ + 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: + return param.value + return convert_value_unit(param.value, param.unit, target_unit) - def convert_unit(self, unit: str | sc.Unit) -> None: + def _convert_x_unit_area_based( + self, + new_x_unit: str | sc.Unit, + x_params: list, + area_param: Parameter | None = None, + inverse_params: list | None = None, + ) -> None: """ - Convert the unit of the Parameters in the component. + Shared convert_x_unit logic for components whose parameters carry mixed x-based units. + + Validates the input type, converts all x-axis parameters, the optional area parameter (area + = x_unit * y_unit), and any reciprocal (``1/x_unit``) parameters to the new unit, and + updates ``_x_unit``. If any step fails, all parameters are rolled back to their original + units and the failing conversion's exception is re-raised. 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 | None, default=None + The parameter whose unit equals ``x_unit * y_unit``, if the component has one. + inverse_params : list | None, default=None + Parameters whose unit equals ``1/x_unit`` (e.g. an exponential rate). Raises ------ TypeError - If the provided unit is not a str or sc.Unit. - Exception - If the provided unit is invalid or incompatible with the component's parameters. - """ - 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 - pars = self.get_all_parameters() - try: - for p in pars: - p.convert_unit(unit) - self._unit = 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) - except Exception: # noqa: S110 - pass # Best effort rollback - raise e + If *new_x_unit* is not a ``str`` or ``sc.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_x_unit = self.x_unit + new_x_str = str(new_x_unit) if isinstance(new_x_unit, sc.Unit) else new_x_unit + conversions = [ + (partial(convert_parameter_unit, p), new_x_str, old_x_unit) for p in x_params + ] + if area_param is not None: + new_area_unit = str(sc.Unit(new_x_str) * sc.Unit(self.y_unit)) + old_area_unit = str(sc.Unit(old_x_unit) * sc.Unit(self.y_unit)) + conversions.append(( + partial(convert_parameter_unit, area_param), + new_area_unit, + old_area_unit, + )) + conversions.extend( + (partial(convert_parameter_unit, p), '1/' + new_x_str, '1/' + str(old_x_unit)) + for p in inverse_params or [] + ) + convert_units_with_rollback(conversions) + self._x_unit = new_x_str - @abstractmethod - def evaluate(self, x: Numeric | list | np.ndarray | sc.Variable | sc.DataArray) -> np.ndarray: + def _convert_y_unit_area_based( + self, + new_y_unit: str | sc.Unit, + area_param: Parameter | None = None, + y_params: list | None = None, + ) -> None: """ - Abstract method to evaluate the model component at input x. + Shared convert_y_unit logic for components whose parameters carry y-based units. - Must be implemented by subclasses. + Validates the input type, rescales the optional area parameter from ``x_unit * old_y_unit`` + to ``x_unit * new_y_unit`` and any pure y-unit parameters to the new unit, and updates + ``_y_unit``. If any step fails, the parameters are rolled back to their original units and + the failing conversion's exception is re-raised. + + Parameters + ---------- + new_y_unit : str | sc.Unit + Target y-axis unit. + area_param : Parameter | None, default=None + The parameter whose unit equals ``x_unit * y_unit``, if the component has one. + y_params : list | None, default=None + Parameters whose unit equals *y_unit* (e.g. an amplitude). + + Raises + ------ + TypeError + If *new_y_unit* is not a ``str`` or ``sc.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_y_str = str(new_y_unit) if isinstance(new_y_unit, sc.Unit) else new_y_unit + conversions = [] + if area_param is not None: + new_area_unit = str(sc.Unit(self.x_unit) * sc.Unit(new_y_str)) + old_area_unit = str(sc.Unit(self.x_unit) * sc.Unit(old_y_unit)) + conversions.append(( + partial(convert_parameter_unit, area_param), + new_area_unit, + old_area_unit, + )) + conversions.extend( + (partial(convert_parameter_unit, p), new_y_str, old_y_unit) for p in y_params or [] + ) + convert_units_with_rollback(conversions) + self._y_unit = new_y_str + + 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. If the conversion between the current + unit and *new_x_unit* fails, the component is rolled back to its original unit and the + failing conversion's exception is re-raised. + + 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``. + """ + 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 + convert_units_with_rollback([ + (partial(convert_parameter_unit, p), new_x_unit, old_unit) + for p in self.get_all_parameters() + ]) + self._x_unit = str(new_x_unit) if isinstance(new_x_unit, sc.Unit) else new_x_unit + + def convert_y_unit(self, new_y_unit: str | sc.Unit) -> None: + """ + Convert the y-axis (output) unit. Subclasses with an area parameter should override this. + + Parameters + ---------- + new_y_unit : str | sc.Unit + Target y-axis unit. + + 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 evaluate( + self, + x: Numeric | list | np.ndarray | sc.Variable | sc.DataArray, + output: str = 'numpy', + ) -> np.ndarray | sc.Variable: + """ + Evaluate the model component at input x. + + When x carries a unit (scipp input), parameter values are temporarily converted to that + unit for the computation without mutating the parameters. Parameters ---------- x : Numeric | list | np.ndarray | sc.Variable | sc.DataArray - The x values at which to evaluate the component. + Input x values. + output : str, default='numpy' + 'numpy' returns np.ndarray; 'scipp' returns sc.Variable with y_unit. + + Raises + ------ + ValueError + If output is not 'numpy' or 'scipp'. + + Returns + ------- + np.ndarray | sc.Variable + Evaluated model values at x. + """ + if output not in ('numpy', 'scipp'): + raise ValueError(f"output must be 'numpy' or 'scipp', got {output!r}") + x_vals, detected_unit, dim = self._prepare_x_for_evaluate(x) + # eval_unit is None when x is already in the component's own x_unit (unitless input is + # assumed to be), letting _evaluate_values skip all unit resolution in the hot path. + eval_unit = detected_unit if detected_unit != self.x_unit else None + values = self._evaluate_values(x_vals, eval_unit) + if output == 'scipp': + return sc.array(dims=[dim], values=values, unit=self.y_unit) + return values + + @abstractmethod + def _evaluate_values(self, x_vals: np.ndarray, eval_unit: str | None) -> np.ndarray: + """ + Compute the component's values for raw x values expressed in eval_unit. + + Implementations must resolve their parameters to *eval_unit* (via + :meth:`_resolve_param_value`) and return plain numpy values; the public :meth:`evaluate` + handles input validation and output wrapping. + + Parameters + ---------- + x_vals : np.ndarray + Raw x values, expressed in *eval_unit*. + eval_unit : str | None + The unit of *x_vals* when it differs from the component's own x_unit, or None when + x_vals is already expressed in the component's x_unit (no resolution needed). Returns ------- np.ndarray - Evaluated function values. + Evaluated model values at x_vals. + """ + + def _eval_area_unit(self, eval_unit: str | None) -> str | None: """ + Get the area unit (``eval_unit * y_unit``) matching an evaluation unit. + + Parameters + ---------- + eval_unit : str | None + The unit x values are expressed in during evaluation. + + Returns + ------- + str | None + The corresponding area unit, or None when either unit is unknown (in which case + parameter values are used unconverted). + """ + if eval_unit is None or self.y_unit is None: + return None + return str(sc.Unit(eval_unit) * sc.Unit(self.y_unit)) def __repr__(self) -> str: """ @@ -231,5 +436,7 @@ def __repr__(self) -> str: str A string representation of the ModelComponent. """ - - return f'{self.__class__.__name__}(unique_name={self.unique_name!r}, unit={self._unit})' + 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 a36700dff..683af8d9f 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,46 @@ 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, + suppress_warnings: bool = False, ) -> 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 of the component. 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. + suppress_warnings : bool, default=False + Whether to suppress warnings 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 +102,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,19 +120,20 @@ 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) + + self.suppress_warnings = suppress_warnings @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) @@ -133,25 +141,24 @@ def coefficients(self) -> list[Parameter]: 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 +166,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) @@ -169,56 +175,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 +200,167 @@ 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]: + @property + def suppress_warnings(self) -> bool: + """ + Get whether or not to suppress warnings. + """ + return self._suppress_warnings + + @suppress_warnings.setter + def suppress_warnings(self, value: bool) -> None: """ - Get all variables from the model component. + Choose whether or not to suppress warnings. + + Parameters + ---------- + value : bool + Whether or not to suppress warnings + + Raises + ------ + TypeError + If suppress_warnings is not True or False + """ + if not isinstance(value, bool): + raise TypeError('Suppress_warnings must be True or False') + self._suppress_warnings = value + + def _evaluate_values(self, x_vals: np.ndarray, eval_unit: str | None) -> np.ndarray: + r""" + Evaluate the Polynomial at x_vals. + + When x_vals is expressed in 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_vals : np.ndarray + Raw x values expressed in eval_unit. + eval_unit : str | None + The unit of x_vals. + Returns + ------- + np.ndarray + Evaluated polynomial values. + """ + if ( + eval_unit is not None + and self.x_unit is not None + and sc.Unit(eval_unit) != sc.Unit(self.x_unit) + ): + # Temporary coefficient rescaling — no mutation + helper = sc.scalar(1.0, unit=self.x_unit) + helper_in_x = sc.to_unit(helper, eval_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 not self._suppress_warnings and 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=3, + ) + + 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 = self.y_unit or '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 5ca29105c..ee8c29046 100644 --- a/src/easydynamics/sample_model/components/voigt.py +++ b/src/easydynamics/sample_model/components/voigt.py @@ -19,13 +19,14 @@ 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,54 +73,52 @@ 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 of the component. 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( + self._center = self._create_center_parameter( + center=center, name=name, fix_if_none=True, x_unit=self.x_unit + ) + self._gaussian_width = self._create_width_parameter( width=gaussian_width, name=name, param_name='gaussian_width', - unit=self._unit, + x_unit=self.x_unit, ) - lorentzian_width = self._create_width_parameter( + self._lorentzian_width = self._create_width_parameter( width=lorentzian_width, name=name, param_name='lorentzian_width', - unit=self._unit, + 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 +127,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 +156,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 +184,30 @@ 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. - + Set the gaussian 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,7 +223,7 @@ 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 @@ -235,18 +231,17 @@ def lorentzian_width(self) -> Parameter: 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,33 +249,60 @@ 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. + def _evaluate_values(self, x_vals: np.ndarray, eval_unit: str | None) -> np.ndarray: + """ + Evaluate the Voigt at x_vals. - 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. + Parameters in the model's own units are temporarily converted to eval_unit for the + computation. Parameters ---------- - x : Numeric | list | np.ndarray | sc.Variable | sc.DataArray - The x values at which to evaluate the Voigt. + x_vals : np.ndarray + Raw x values expressed in eval_unit. + eval_unit : str | None + The unit of x_vals. Returns ------- np.ndarray - The intensity of the Voigt at the given x values. + Evaluated Voigt profile values at x_vals. """ + 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, self._eval_area_unit(eval_unit)) + + return area * voigt_profile(x_vals - center, gw, lw) - x = self._prepare_x_for_evaluate(x) + def convert_x_unit(self, new_x_unit: str | sc.Unit) -> None: + """ + Convert x-axis parameters (center, widths) and area to new_x_unit. - return self.area.value * voigt_profile( - x - self.center.value, - self.gaussian_width.value, - self.lorentzian_width.value, + Parameters + ---------- + new_x_unit : str | sc.Unit + Target x-axis unit. Must be dimensionally compatible with the current x_unit. + """ + self._convert_x_unit_area_based( + new_x_unit=new_x_unit, + x_params=[self._center, self._gaussian_width, self._lorentzian_width], + area_param=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. + """ + self._convert_y_unit_area_based(new_y_unit=new_y_unit, area_param=self._area) + def __repr__(self) -> str: """ Return a string representation of the Voigt. @@ -290,12 +312,11 @@ def __repr__(self) -> str: str A string representation of the Voigt. """ - 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 04a5bbdda..b15d00120 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: @@ -240,12 +244,11 @@ def create_component_collections( Lorentzian has a width given by $D*Q^2$ and an area given by the scale parameter multiplied by the QISF (which is 1 for this model). """ - Q = self.Q - if Q is None: + if self.Q is None: self._component_collections = [] return self._component_collections - Q = Q.values + Q = self.Q.values component_collection_list = [None] * len(Q) # In more complex models, this is used to scale the area of the # Lorentzians and the delta function. @@ -258,31 +261,37 @@ 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 dependency_expression = self._write_width_dependency_expression(Q[i]) dependency_map = self._write_width_dependency_map_expression() - lorentzian_component.width.make_dependent_on( - dependency_expression=dependency_expression, - dependency_map=dependency_map, - desired_unit=self.unit, - ) - - # Make the area dependent on Q - area_dependency_map = self._write_area_dependency_map_expression() - lorentzian_component.area.make_dependent_on( - dependency_expression=self._write_area_dependency_expression(QISF[i]), - dependency_map=area_dependency_map, - ) + # easyscience propagates inf bounds through arithmetic, producing inf/inf=nan + # as a transient intermediate. Python's min/max ignore nan so the final bounds + # are correct; suppress the spurious numpy RuntimeWarning. + with np.errstate(invalid='ignore'): + lorentzian_component.width.make_dependent_on( + dependency_expression=dependency_expression, + dependency_map=dependency_map, + desired_unit=self.x_unit, + ) + + # Make the area dependent on Q + area_dependency_map = self._write_area_dependency_map_expression() + lorentzian_component.area.make_dependent_on( + dependency_expression=self._write_area_dependency_expression(QISF[i]), + dependency_map=area_dependency_map, + ) component_collection_list[i].append_component(lorentzian_component) @@ -340,43 +349,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 # ------------------------------------------------------------------ @@ -393,6 +365,7 @@ def __repr__(self) -> str: return ( f'{self.__class__.__name__}(' f'name={self.name!r}, display_name={self.display_name!r},\n' + f'x_unit={self.x_unit}, y_unit={self.y_unit}, \n' f' diffusion_coefficient={self.diffusion_coefficient},\n' f' scale={self.scale})' ) diff --git a/src/easydynamics/sample_model/diffusion_model/delta_lorentz.py b/src/easydynamics/sample_model/diffusion_model/delta_lorentz.py index 0f181481a..de049c4bf 100644 --- a/src/easydynamics/sample_model/diffusion_model/delta_lorentz.py +++ b/src/easydynamics/sample_model/diffusion_model/delta_lorentz.py @@ -11,8 +11,12 @@ from easydynamics.sample_model.components import DeltaFunction from easydynamics.sample_model.components import Lorentzian from easydynamics.sample_model.diffusion_model.diffusion_model_base import DiffusionModelBase +from easydynamics.utils.fit_target import FitTarget +from easydynamics.utils.utils import CANONICAL_Q_UNIT from easydynamics.utils.utils import Numeric from easydynamics.utils.utils import Q_type +from easydynamics.utils.utils import angstrom +from easydynamics.utils.utils import convert_parameter_unit from easydynamics.utils.utils import verify_Q_index MINIMUM_WIDTH = 1e-10 # To avoid division by zero @@ -64,7 +68,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', @@ -93,8 +98,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 @@ -121,7 +128,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, @@ -135,6 +143,18 @@ def __init__( # -------------------------------------------------------------- self._mean_u_squared = self._create_mean_u_squared_parameter(mean_u_squared) + # Dimensionless /angstrom^2 for use inside exp() in the area dependency + # expressions and the EISF/QISF calculations. Deriving it through the dependency + # graph keeps those quantities correct if mean_u_squared is converted to another + # unit (e.g. nm**2), which a raw `.value` would silently get wrong. + with np.errstate(invalid='ignore'): + self._mean_u_squared_over_angstrom_squared = Parameter.from_dependency( + name='mean_u_squared_over_angstrom_squared', + dependency_expression='mean_u_squared / angstrom**2', + dependency_map={'mean_u_squared': self._mean_u_squared, 'angstrom': angstrom}, + ) + self._mean_u_squared_over_angstrom_squared.set_desired_unit('dimensionless') + self._A_0, self._A_1 = self._create_A0_A1_parameters(A_0) self._lorentzian_width = self._create_lorentzian_width_parameter(lorentzian_width) @@ -387,28 +407,38 @@ def delta_display_name(self, delta_display_name: str | None) -> None: def calculate_width(self, Q: Q_type = None) -> np.ndarray: """ Calculate the half-width at half-maximum (HWHM) for the diffusion model. If the width is - allowed to vary with Q then the Q stored in the model is used and the input is ignored. If - the width is not allowed to vary then the same width is returned for all Q values. + allowed to vary with Q then the requested Q values are matched against the Q stored in the + model and the corresponding per-Q widths are returned. If the width is not allowed to vary + then the same width is returned for all Q values. Parameters ---------- Q : Q_type, default=None - Scattering vector in 1/angstrom. + Scattering vector in 1/angstrom. If None, the Q stored in the model is used. Returns ------- np.ndarray HWHM values in the unit of the model (e.g., meV). - """ - if self._allow_Q_variation['lorentzian_width'] is True: - widths = [lorentzian_width.value for lorentzian_width in self._lorentzian_width_list] - return np.array(widths) + Raises + ------ + ValueError + If Q-variation is enabled but Q has not been set on the model yet, or if the requested + Q values do not match the stored ones. + """ Q = self._ensure_Q(Q) - widths = self.lorentzian_width.value * np.ones_like(Q) + 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.' + ) + indices = self._match_Q_indices(Q) + return np.array([self._lorentzian_width_list[i].value for i in indices]) - return np.array(widths) + return self.lorentzian_width.value * np.ones_like(Q) def calculate_EISF(self, Q: Q_type = None) -> np.ndarray: """ @@ -425,12 +455,14 @@ def calculate_EISF(self, Q: Q_type = None) -> np.ndarray: EISF values (dimensionless). """ Q = self._ensure_Q(Q) + mean_u_squared = self._mean_u_squared_over_angstrom_squared.value if self._allow_Q_variation['A_0'] is True: - A_0_values = [A_0_.value for A_0_ in self._A_0_list] - return np.exp(-self.mean_u_squared.value * Q**2 / 3) * np.array(A_0_values) + indices = self._match_Q_indices(Q) + A_0_values = [self._A_0_list[i].value for i in indices] + return np.exp(-mean_u_squared * Q**2 / 3) * np.array(A_0_values) A_0_values = [self.A_0.value] * len(Q) - return np.exp(-self.mean_u_squared.value * Q**2 / 3) * np.array(A_0_values) + return np.exp(-mean_u_squared * Q**2 / 3) * np.array(A_0_values) def calculate_QISF(self, Q: Q_type = None) -> np.ndarray: """ @@ -447,12 +479,14 @@ def calculate_QISF(self, Q: Q_type = None) -> np.ndarray: QISF values (dimensionless). """ Q = self._ensure_Q(Q) + mean_u_squared = self._mean_u_squared_over_angstrom_squared.value if self._allow_Q_variation['A_0'] is True: - A_1_values = [A_1_.value for A_1_ in self._A_1_list] - return np.exp(-self.mean_u_squared.value * Q**2 / 3) * np.array(A_1_values) + indices = self._match_Q_indices(Q) + A_1_values = [self._A_1_list[i].value for i in indices] + return np.exp(-mean_u_squared * Q**2 / 3) * np.array(A_1_values) A_1_values = [self.A_1.value] * len(Q) - return np.exp(-self.mean_u_squared.value * Q**2 / 3) * np.array(A_1_values) + return np.exp(-mean_u_squared * Q**2 / 3) * np.array(A_1_values) def create_component_collections( self, @@ -466,10 +500,10 @@ def create_component_collections( List of ComponentCollections with Lorentzian and delta function components for each Q value. """ - Q = self.Q - if Q is None: + if self.Q is None: return [] - Q = Q.values + + Q = self.Q.values if self._allow_Q_variation['A_0'] is True: A_0_list, A_1_list = self._create_A0_A1_parameter_lists(self.A_0) @@ -486,19 +520,21 @@ 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, ) # ------------------------------# # Create Lorentzian # ------------------------------# - lorentzian_component = Lorentzian( + lorz_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 + lorz_component._width = self._lorentzian_width_list[i] # noqa: SLF001 # If the width is allowed to vary with Q it is independent. # If the width is not allowed to vary with Q it must be made @@ -506,10 +542,10 @@ def create_component_collections( if self._allow_Q_variation['lorentzian_width'] is False: dependency_map = self._write_width_dependency_map_expression() - lorentzian_component.width.make_dependent_on( + lorz_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 @@ -522,12 +558,12 @@ def create_component_collections( else: dependency_map = self._write_lorz_area_dependency_map_expression(None) - lorentzian_component.area.make_dependent_on( + lorz_component.area.make_dependent_on( dependency_expression=self._write_lorz_area_dependency_expression(Q_value), dependency_map=dependency_map, ) - component_collection_list[i].append_component(lorentzian_component) + component_collection_list[i].append_component(lorz_component) # ------------------------------# # Create delta function @@ -536,7 +572,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: @@ -553,6 +590,32 @@ def create_component_collections( return component_collection_list + def get_fit_targets(self) -> list[FitTarget]: + """ + Get the fittable predictions of the DeltaLorentz model as FitTargets. + + Extends the base ``'area'`` and ``'width'`` predictions with ``'delta_area'`` (``scale * + EISF(Q)``, the delta function's weight), whose default dataset key is derived from the + delta component's name. + + Returns + ------- + list[FitTarget] + The fittable predictions of this model. + """ + targets = super().get_fit_targets() + targets.append( + FitTarget( + name='delta_area', + dataset_key=f'{self.delta_name} area', + function=lambda Q, model=self, **_: model.calculate_EISF(Q) * model.scale.value, + label=f'{self.display_name} delta_area', + x_unit=CANONICAL_Q_UNIT, + y_unit=str(self.scale.unit), + ) + ) + return targets + def get_global_variables(self) -> list[Parameter]: """ Get all global variables from the diffusion model. @@ -790,7 +853,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( @@ -857,7 +920,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 range(len(self.Q)) ] @@ -890,6 +953,20 @@ def _on_Q_change(self) -> None: self._lorentzian_width_list = [] self._component_collections = self.create_component_collections() + def _convert_extra_x_unit_parameters(self, unit_str: str) -> None: + """ + Convert the Lorentzian width template to the new x-axis unit. + + The per-Q width list (when Q-variation is enabled) holds the very Parameter objects used by + the components, so those are converted in place with the collections. + + Parameters + ---------- + unit_str : str + The new x-axis unit as a string. + """ + convert_parameter_unit(self._lorentzian_width, unit_str) + def _write_lorz_width_dependency_expression(self, Q: float) -> str: """ Write the dependency expression for the width as a function of Q to make dependent @@ -952,7 +1029,9 @@ def _write_lorz_area_dependency_expression(self, Q: float) -> str: if not isinstance(Q, (float)): raise TypeError('Q must be a float.') - return f'scale * exp(-mean_u_squared.value * {Q}**2 / 3) * A_1' + # mean_u_squared_ratio is /angstrom^2, kept dimensionless through the dependency + # graph so the expression stays correct if mean_u_squared is converted to another unit. + return f'scale * exp(-mean_u_squared_ratio.value * {Q}**2 / 3) * A_1' def _write_lorz_area_dependency_map_expression( self, Q_index: int | None @@ -974,13 +1053,13 @@ def _write_lorz_area_dependency_map_expression( if Q_index is None: return { 'scale': self.scale, - 'mean_u_squared': self.mean_u_squared, + 'mean_u_squared_ratio': self._mean_u_squared_over_angstrom_squared, 'A_1': self.A_1, } return { 'scale': self.scale, - 'mean_u_squared': self.mean_u_squared, + 'mean_u_squared_ratio': self._mean_u_squared_over_angstrom_squared, 'A_1': self._A_1_list[Q_index], } @@ -1006,7 +1085,9 @@ def _write_delta_area_dependency_expression(self, Q: float) -> str: if not isinstance(Q, (float)): raise TypeError('Q must be a float.') - return f'scale * exp(-mean_u_squared.value * {Q}**2 / 3) * A_0' + # mean_u_squared_ratio is /angstrom^2, kept dimensionless through the dependency + # graph so the expression stays correct if mean_u_squared is converted to another unit. + return f'scale * exp(-mean_u_squared_ratio.value * {Q}**2 / 3) * A_0' def _write_delta_area_dependency_map_expression( self, @@ -1029,12 +1110,12 @@ def _write_delta_area_dependency_map_expression( if Q_index is None: return { 'scale': self.scale, - 'mean_u_squared': self.mean_u_squared, + 'mean_u_squared_ratio': self._mean_u_squared_over_angstrom_squared, 'A_0': self.A_0, } return { 'scale': self.scale, - 'mean_u_squared': self.mean_u_squared, + 'mean_u_squared_ratio': self._mean_u_squared_over_angstrom_squared, 'A_0': self._A_0_list[Q_index], } @@ -1052,10 +1133,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}, y_unit={self.y_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 ab97f8b94..abf071e7b 100644 --- a/src/easydynamics/sample_model/diffusion_model/diffusion_model_base.py +++ b/src/easydynamics/sample_model/diffusion_model/diffusion_model_base.py @@ -1,6 +1,8 @@ # SPDX-FileCopyrightText: 2026 EasyScience contributors # SPDX-License-Identifier: BSD-3-Clause +from functools import partial + import numpy as np import scipp as sc from easyscience.variable import DescriptorNumber @@ -9,9 +11,13 @@ from easydynamics.base_classes.easydynamics_modelbase import EasyDynamicsModelBase from easydynamics.sample_model.component_collection import ComponentCollection +from easydynamics.utils.fit_target import FitTarget +from easydynamics.utils.utils import CANONICAL_Q_UNIT from easydynamics.utils.utils import Numeric from easydynamics.utils.utils import Q_type from easydynamics.utils.utils import _validate_and_convert_Q +from easydynamics.utils.utils import convert_parameter_unit +from easydynamics.utils.utils import convert_units_with_rollback from easydynamics.utils.utils import verify_Q_index @@ -22,7 +28,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, @@ -32,14 +39,20 @@ def __init__( """ Initialize a new DiffusionModel. + Unit validation raises ``UnitError`` if x_unit is not a string or scipp Unit, or if it + cannot be converted to meV. + 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,21 +71,13 @@ 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. ValueError If scale is negative. """ self._Q = _validate_and_convert_Q(Q) - try: - test = DescriptorNumber(name='test', value=1, unit=unit) - test.convert_unit('meV') - except Exception as e: - raise UnitError( - f'Invalid unit: {unit}. Unit must be a string or scipp Unit and convertible to meV.' # noqa: E501 - ) from e + self._assert_convertible_to_mev(x_unit) if not isinstance(scale, Numeric): raise TypeError('scale must be a number.') @@ -80,10 +85,18 @@ 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) - self._scale = scale + area_unit = str(sc.Unit(str(x_unit)) * sc.Unit(str(y_unit))) + self._scale = Parameter( + name='scale', value=float(scale), fixed=False, min=0.0, unit=area_unit + ) - 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 @@ -103,7 +116,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 @@ -275,6 +291,167 @@ def clear_Q(self, confirm: bool = False) -> None: self._Q = None self._on_Q_change() + # ------------------------------------------------------------------ + # Unit conversion + # ------------------------------------------------------------------ + + @staticmethod + def _assert_convertible_to_mev(unit: str | sc.Unit) -> None: + """ + Assert that the given unit is an energy unit (convertible to meV). + + Frequency axes such as 1/ps are not supported for now. + + Parameters + ---------- + unit : str | sc.Unit + The unit to validate. + + Raises + ------ + UnitError + If unit is not a string or scipp Unit, or if it cannot be converted to meV. + """ + try: + test = DescriptorNumber(name='test', value=1, unit=str(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 + ) from e + + def convert_x_unit(self, unit: str | sc.Unit) -> None: + """ + Convert the x-axis unit of the diffusion model. + + Converts the scale parameter (unit ``x_unit * y_unit``), any subclass-specific x-unit + parameters, and the existing component collections in place — parameter values and object + identity are preserved, and nothing is scheduled for regeneration. Only energy units are + supported (the unit must be convertible to meV). + + Unit validation raises ``UnitError`` when the unit is not convertible to meV. If any + conversion fails, the already-converted state is rolled back best-effort before the failing + conversion's exception is re-raised. + + Parameters + ---------- + unit : str | sc.Unit + The new x-axis unit. + + Raises + ------ + TypeError + If unit is not a string or sc.Unit. + """ + if not isinstance(unit, (str, sc.Unit)): + raise TypeError(f'x_unit must be a string or sc.Unit, got {type(unit).__name__}') + unit_str = str(unit) + self._assert_convertible_to_mev(unit_str) + + old_x_unit = str(self.x_unit) + new_scale_unit = str(sc.Unit(unit_str) * sc.Unit(str(self.y_unit))) + old_scale_unit = str(sc.Unit(old_x_unit) * sc.Unit(str(self.y_unit))) + conversions = [ + (partial(convert_parameter_unit, self._scale), new_scale_unit, old_scale_unit), + (self._convert_extra_x_unit_parameters, unit_str, old_x_unit), + ] + conversions.extend( + (collection.convert_x_unit, unit_str, old_x_unit) + for collection in self._component_collections + ) + convert_units_with_rollback(conversions) + + self._x_unit = unit_str + + def convert_y_unit(self, unit: str | sc.Unit) -> None: + """ + Convert the y-axis unit of the diffusion model. + + Converts the scale parameter from ``x_unit * old_y_unit`` to ``x_unit * new_y_unit`` and + the existing component collections in place — parameter values and object identity are + preserved, and nothing is scheduled for regeneration. The new y-unit must be dimensionally + compatible with the current one; the scale conversion raises ``UnitError`` otherwise. If + any conversion fails, the already-converted state is rolled back best-effort before the + failing conversion's exception is re-raised. + + Parameters + ---------- + unit : str | sc.Unit + The new y-axis unit. + + Raises + ------ + TypeError + If unit is not a string or sc.Unit. + """ + if not isinstance(unit, (str, sc.Unit)): + raise TypeError(f'y_unit must be a string or sc.Unit, got {type(unit).__name__}') + unit_str = str(unit) + + old_y_unit = str(self.y_unit) + new_scale_unit = str(sc.Unit(str(self.x_unit)) * sc.Unit(unit_str)) + old_scale_unit = str(sc.Unit(str(self.x_unit)) * sc.Unit(old_y_unit)) + conversions = [ + (partial(convert_parameter_unit, self._scale), new_scale_unit, old_scale_unit), + ] + conversions.extend( + (collection.convert_y_unit, unit_str, old_y_unit) + for collection in self._component_collections + ) + convert_units_with_rollback(conversions) + + self._y_unit = unit_str + + def _convert_extra_x_unit_parameters(self, unit_str: str) -> None: + """ + Convert subclass-specific parameters that carry the x-axis unit. + + The base implementation does nothing; subclasses with x-unit parameters (e.g. a Lorentzian + width template) override this method. + + Parameters + ---------- + unit_str : str + The new x-axis unit as a string. + """ + + # ------------------------------------------------------------------ + # Fit targets + # ------------------------------------------------------------------ + + def get_fit_targets(self) -> list[FitTarget]: + """ + Get the fittable predictions of the diffusion model as FitTargets. + + The base implementation declares ``'area'`` (``scale * QISF(Q)``) and ``'width'`` (the HWHM + ``Gamma(Q)``), with default dataset keys derived from the Lorentzian component's name. + Subclasses with additional predictions (e.g. a delta-function weight) extend this list. The + targets are snapshots: units and default keys reflect the model state at call time. + + Returns + ------- + list[FitTarget] + The fittable predictions of this model. + """ + return [ + FitTarget( + name='area', + dataset_key=f'{self.lorentzian_name} area', + function=lambda Q, model=self, **_: model.calculate_QISF(Q) * model.scale.value, + label=f'{self.display_name} area', + x_unit=CANONICAL_Q_UNIT, + y_unit=str(self.scale.unit), + ), + FitTarget( + name='width', + dataset_key=f'{self.lorentzian_name} width', + function=lambda Q, model=self, **_: model.calculate_width(Q), + label=f'{self.display_name} width', + x_unit=CANONICAL_Q_UNIT, + y_unit=str(self.x_unit), + ), + ] + # ------------------------------------------------------------------ # Methods # ------------------------------------------------------------------ @@ -422,7 +599,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 @@ -454,14 +633,52 @@ 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() def _ensure_Q(self, Q: Q_type) -> np.ndarray: """ - Convert Q to a numpy array, ensuring it is not None. Uses the stored Q if no input is - given. + Convert Q to a numpy array of values in the canonical Q unit (1/angstrom), ensuring it is + not None. Uses the stored Q if no input is given. + + The stored Q and 1-dimensional numpy input are returned directly (the stored Q was + validated when set; raw numpy values are assumed to be in the canonical unit, as in + :func:`_validate_and_convert_Q`). Other inputs go through full validation and conversion. Parameters ---------- @@ -479,12 +696,51 @@ def _ensure_Q(self, Q: Q_type) -> np.ndarray: If the provided Q and self.Q are both None """ if Q is None: - Q = self.Q - if Q is None: - raise ValueError('Q must be provided either as an argument or set in the model.') + if self._Q is None: + raise ValueError('Q must be provided either as an argument or set in the model.') + return self._Q.values + + if isinstance(Q, np.ndarray) and Q.ndim == 1: + return Q return _validate_and_convert_Q(Q).values + def _match_Q_indices(self, Q_values: np.ndarray) -> np.ndarray: + """ + Map Q values (in 1/angstrom) onto indices of the stored Q. + + Predictions backed by per-Q parameters use this to evaluate a subset of the stored Q values + (e.g. when a fit drops rows with missing data), returning one entry per requested Q instead + of one per stored Q. + + Parameters + ---------- + Q_values : np.ndarray + The requested Q values in 1/angstrom. + + Returns + ------- + np.ndarray + For each requested Q value, the index of the matching stored Q value. + + Raises + ------ + ValueError + If Q is not set on the model, or if any requested Q value does not match a stored Q + value. + """ + if self.Q is None: + raise ValueError('Q must be set in the model to match Q values against it.') + + stored = self.Q.values + indices = np.abs(np.subtract.outer(Q_values, stored)).argmin(axis=1) + if not np.allclose(stored[indices], Q_values): + raise ValueError( + 'Requested Q values do not match the Q values stored in the model. ' + 'Per-Q parameters are only defined at the stored Q values.' + ) + return indices + # ------------------------------------------------------------------ # dunder methods # ------------------------------------------------------------------ @@ -499,8 +755,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}, y_unit={self.y_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 9dd288ca4..67a693205 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 @@ -306,12 +310,11 @@ def create_component_collections( list[ComponentCollection] List of ComponentCollections with Jump Diffusion Lorentzian components. """ - Q = self.Q - if Q is None: + if self.Q is None: self._component_collections = [] return self._component_collections - Q = Q.values + Q = self.Q.values component_collection_list = [None] * len(Q) # In more complex models, this is used to scale the area of the # Lorentzians and the delta function. @@ -324,31 +327,37 @@ 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 dependency_expression = self._write_width_dependency_expression(Q[i]) dependency_map = self._write_width_dependency_map_expression() - lorentzian_component.width.make_dependent_on( - dependency_expression=dependency_expression, - dependency_map=dependency_map, - desired_unit=self.unit, - ) - - # Make the area dependent on Q - area_dependency_map = self._write_area_dependency_map_expression() - lorentzian_component.area.make_dependent_on( - dependency_expression=self._write_area_dependency_expression(QISF[i]), - dependency_map=area_dependency_map, - ) + # easyscience propagates inf bounds through arithmetic, producing inf/inf=nan + # as a transient intermediate. Python's min/max ignore nan so the final bounds + # are correct; suppress the spurious numpy RuntimeWarning. + with np.errstate(invalid='ignore'): + lorentzian_component.width.make_dependent_on( + dependency_expression=dependency_expression, + dependency_map=dependency_map, + desired_unit=self.x_unit, + ) + + # Make the area dependent on Q + area_dependency_map = self._write_area_dependency_map_expression() + lorentzian_component.area.make_dependent_on( + dependency_expression=self._write_area_dependency_expression(QISF[i]), + dependency_map=area_dependency_map, + ) component_collection_list[i].append_component(lorentzian_component) @@ -406,44 +415,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 ################################ @@ -460,6 +431,7 @@ def __repr__(self) -> str: return ( f'{self.__class__.__name__}(' f'name={self.name!r}, display_name={self.display_name!r},\n' + f'x_unit={self.x_unit}, y_unit={self.y_unit}, \n' f' diffusion_coefficient={self.diffusion_coefficient},\n' f' scale={self.scale})' ) diff --git a/src/easydynamics/sample_model/instrument_model.py b/src/easydynamics/sample_model/instrument_model.py index 7c2333513..e6d45447a 100644 --- a/src/easydynamics/sample_model/instrument_model.py +++ b/src/easydynamics/sample_model/instrument_model.py @@ -2,6 +2,7 @@ # SPDX-License-Identifier: BSD-3-Clause from copy import copy +from functools import partial import scipp as sc from easyscience.base_classes.new_base import NewBase @@ -14,6 +15,8 @@ from easydynamics.utils.utils import Q_type from easydynamics.utils.utils import _validate_and_convert_Q from easydynamics.utils.utils import _validate_unit +from easydynamics.utils.utils import convert_parameter_unit +from easydynamics.utils.utils import convert_units_with_rollback from easydynamics.utils.utils import verify_Q_index @@ -60,7 +63,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 +86,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 +100,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 +133,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 +192,6 @@ def background_model(self) -> BackgroundModel: BackgroundModel The background model of the instrument. """ - return self._background_model @background_model.setter @@ -207,7 +209,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 +264,56 @@ def Q(self, value: Q_type | None) -> None: ) @property - def unit(self) -> str | sc.Unit: + def x_unit(self) -> str | sc.Unit | None: """ - Get the unit of the InstrumentModel. + Get the x-axis unit of the InstrumentModel. Returns ------- - str | sc.Unit - The unit of the InstrumentModel. + str | sc.Unit | None + 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 +323,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() # -------------------------------------------------------------- @@ -358,32 +353,38 @@ 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: + def convert_x_unit(self, x_unit: str | sc.Unit) -> None: """ Convert the unit of the InstrumentModel. Parameters ---------- - unit_str : str | sc.Unit + x_unit : str | sc.Unit The unit to convert to. Raises ------ ValueError - If unit_str is not a valid unit string or scipp Unit. + If x_unit is not a valid unit string or scipp Unit. """ - unit = _validate_unit(unit_str) + 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._ensure_energy_offsets_current() - for offset in self._energy_offsets: - offset.convert_unit(unit) + old_unit = self.x_unit + conversions = [ + (self._background_model.convert_x_unit, unit, old_unit), + (self._resolution_model.convert_x_unit, unit, old_unit), + (partial(convert_parameter_unit, self._energy_offset), unit, old_unit), + ] + conversions.extend( + (partial(convert_parameter_unit, offset), unit, old_unit) + for offset in self._energy_offsets + ) + convert_units_with_rollback(conversions) - self._unit = unit + self._x_unit = unit def get_all_variables(self, Q_index: int | None = None) -> list[Parameter]: """ @@ -514,6 +515,7 @@ def _fix_or_free_energy_offset(self, Q_index: int | None = None, fixed: bool = T self._ensure_energy_offsets_current() verify_Q_index(Q_index=Q_index, Q=self._Q, allow_none=True) if Q_index is None: + self._energy_offset.fixed = fixed for offset in self._energy_offsets: offset.fixed = fixed else: @@ -566,11 +568,10 @@ def __repr__(self) -> str: 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 12437b7a1..9a20e53fc 100644 --- a/src/easydynamics/sample_model/model_base.py +++ b/src/easydynamics/sample_model/model_base.py @@ -13,6 +13,7 @@ from easydynamics.utils.utils import Numeric from easydynamics.utils.utils import Q_type from easydynamics.utils.utils import _validate_and_convert_Q +from easydynamics.utils.utils import convert_units_with_rollback from easydynamics.utils.utils import verify_Q_index @@ -27,7 +28,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: @@ -40,8 +42,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. @@ -54,7 +58,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, ) @@ -75,17 +80,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 ------ @@ -94,15 +101,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 @@ -258,40 +266,62 @@ 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 x-axis unit of all components in the model. + + Parameters + ---------- + unit : str | sc.Unit + The new x-axis unit to convert to. + """ + self._convert_axis_unit(unit, axis='x') + + def convert_y_unit(self, unit: str | sc.Unit) -> None: """ - Convert the unit of the ComponentCollection and all its components. + Convert the y-axis unit of all components in the model. + + Parameters + ---------- + unit : str | sc.Unit + The new y-axis unit to convert to. + """ + self._convert_axis_unit(unit, axis='y') + + def _convert_axis_unit(self, unit: str | sc.Unit, axis: str) -> None: + """ + Convert one axis unit on all template components and per-Q collections. + + Converts every child via its ``convert__unit`` method and updates the model's own + unit attribute. On failure, attempts a best-effort rollback of all children to the old unit + before re-raising the failing conversion's exception. Parameters ---------- unit : str | sc.Unit The new unit to convert to. + axis : str + Which axis to convert: ``'x'`` or ``'y'``. 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. """ - - old_unit = self._unit - if not isinstance(unit, (str, sc.Unit)): raise TypeError(f'Unit must be a string or sc.Unit, got {type(unit).__name__}') - try: - for component in self.components: - component.convert_unit(unit) - self._unit = unit - except Exception as e: - # Attempt to rollback on failure - try: - for component in self.components: - component.convert_unit(old_unit) - except Exception: # noqa: S110 - pass # Best effort rollback - raise e - self._on_components_change() + + method = f'convert_{axis}_unit' + old_unit = self.x_unit if axis == 'x' else self.y_unit + children = [*self.components, *self._component_collections] + convert_units_with_rollback([ + (getattr(child, method), unit, old_unit) for child in children + ]) + unit_str = str(unit) if isinstance(unit, sc.Unit) else unit + if axis == 'x': + self._x_unit = unit_str + else: + self._y_unit = unit_str def fix_all_parameters(self) -> None: """Fix all Parameters in all ComponentCollections.""" @@ -371,7 +401,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 @@ -404,7 +433,8 @@ 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'y_unit={self.y_unit}, ' f'Q={None if self.Q is None else self.Q.values}, ' f'components={self.components})' ) diff --git a/src/easydynamics/sample_model/resolution_model.py b/src/easydynamics/sample_model/resolution_model.py index ce5117ff7..a9fc9e1ee 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,8 +87,8 @@ 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. + Does not allow DeltaFunction, Polynomial, or Exponential components, as these are not + physical resolution components. Parameters ---------- @@ -96,7 +98,7 @@ def append_component(self, component: ModelComponent | ComponentCollection) -> N Raises ------ TypeError - If the component is a DeltaFunction or Polynomial. + If the component is a DeltaFunction, Polynomial, or Exponential. """ components = component if isinstance(component, ComponentCollection) else (component,) @@ -151,22 +153,27 @@ 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, ) if sample_model.Q is not None: - resolution_model._ensure_component_collections_current() - for index in range(len(sample_model.Q)): - resolution_model._component_collections[index] = copy( - sample_model.get_component_collection(Q_index=index) - ) - if normalize_area: - resolution_model.normalize_area() - - if fix_parameters: - resolution_model.fix_all_parameters() + # Prepare the per-Q collections detached from the model so no EasyScience + # callback can schedule a rebuild halfway through, then install them and + # clear the dirty flag in one final step. + collections = [ + copy(sample_model.get_component_collection(Q_index=index)) + for index in range(len(sample_model.Q)) + ] + for collection in collections: + if normalize_area: + collection.normalize_area() + if fix_parameters: + collection.fix_all_parameters() + resolution_model._component_collections = collections + resolution_model._component_collections_is_dirty = False return resolution_model @@ -174,7 +181,8 @@ 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'y_unit={self.y_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 3991a10f9..c253590ec 100644 --- a/src/easydynamics/sample_model/sample_model.py +++ b/src/easydynamics/sample_model/sample_model.py @@ -16,6 +16,7 @@ from easydynamics.utils.utils import Numeric from easydynamics.utils.utils import Q_type from easydynamics.utils.utils import _validate_and_convert_Q +from easydynamics.utils.utils import convert_units_with_rollback class SampleModel(ModelBase): @@ -66,7 +67,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 +85,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 +131,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 +179,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 +201,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 +254,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 +295,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,12 +328,12 @@ 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 @@ -351,8 +354,8 @@ def temperature_unit(self, _value: str | sc.Unit) -> None: """ raise AttributeError( - f'Temperature_unit is read-only. Use convert_temperature_unit to change the unit between allowed types ' # noqa: E501 - f'or create a new {self.__class__.__name__} with the desired unit.' + f'Temperature_unit is read-only. Use convert_temperature_unit to change the unit ' + 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: @@ -382,10 +385,50 @@ def convert_temperature_unit(self, unit: str | sc.Unit) -> None: self._temperature_unit = unit except Exception: # Attempt to rollback on failure + with suppress(Exception): self.temperature.convert_unit(old_unit) raise + def _convert_axis_unit(self, unit: str | sc.Unit, axis: str) -> None: + """ + Convert one axis unit on the SampleModel, including any attached diffusion models. + + Extends the ModelBase conversion (template components and per-Q collections) by also + converting each diffusion model, whose regenerated collections would otherwise come back in + the old unit on the next rebuild. + + Parameters + ---------- + unit : str | sc.Unit + The new unit to convert to. + axis : str + Which axis to convert: ``'x'`` or ``'y'``. + + Raises + ------ + Exception + If any conversion fails; the already-converted diffusion models and the ModelBase state + are rolled back before re-raising. + """ + old_unit = self.x_unit if axis == 'x' else self.y_unit + super()._convert_axis_unit(unit, axis) + + method = f'convert_{axis}_unit' + try: + convert_units_with_rollback([ + (getattr(diffusion_model, method), unit, old_unit) + for diffusion_model in self.diffusion_models + ]) + except Exception: + if old_unit is not None: + with suppress(Exception): + super()._convert_axis_unit(old_unit, axis) + raise + # Everything is converted in place (the merged collections share the diffusion models' + # component objects), so nothing is marked dirty: conversion must not discard the + # per-Q state by triggering a rebuild. + @property def normalize_detailed_balance(self) -> bool: """ @@ -420,12 +463,12 @@ 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, False otherwise """ return self.detailed_balance_settings.use_detailed_balance @@ -451,12 +494,12 @@ 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 @@ -484,31 +527,33 @@ def detailed_balance_settings(self, value: DetailedBalanceSettings) -> None: # ------------------------------------------------------------------ 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 - The x values to evaluate the model at. Can be a number, list, numpy array, scipp - Variable, or scipp DataArray. + The x values to evaluate the model at. + output : str, default='numpy' + 'numpy' returns list of np.ndarray; 'scipp' returns list of sc.Variable. Returns ------- - list[np.ndarray] + list[np.ndarray] | list[sc.Variable] List of evaluated model values for each Q. """ - - y = super().evaluate(x) + 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] @@ -530,10 +575,8 @@ def get_all_variables(self, Q_index: int | None = None) -> list[Parameter]: Returns ------- list[Parameter] - List of all Parameters and Descriptors, including temperature if set and all variables - from diffusion models. + All Parameters and Descriptors in the SampleModel. """ - all_vars = super().get_all_variables(Q_index=Q_index) if self.temperature is not None: all_vars.append(self.temperature) @@ -557,8 +600,7 @@ def _generate_component_collections(self) -> None: if self.Q is None: return - # Generate components from diffusion models - # and add to component collections + # 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( @@ -595,12 +637,11 @@ def __repr__(self) -> str: 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}, ' + f'x_unit={self.x_unit}, y_unit={self.y_unit}, ' + f'Q = {None if self.Q is None else self.Q.values}, \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 fadc13a1e..1ac059c73 100644 --- a/src/easydynamics/settings/convolution_settings.py +++ b/src/easydynamics/settings/convolution_settings.py @@ -89,7 +89,10 @@ def __init__( raise TypeError('suppress_warnings must be True or False.') self._suppress_warnings = suppress_warnings - self._convolution_plan_is_valid = False + # Plan-invalidation bookkeeping for convolvers sharing this settings object. + # _plan_version is bumped whenever an accuracy knob changes; each convolver records + # the version it last rebuilt against and rebuilds when the versions differ. + self._plan_version = 0 @property def upsample_factor(self) -> Numeric | None: @@ -123,7 +126,7 @@ def upsample_factor(self, factor: Numeric | None) -> None: """ if factor is None: self._upsample_factor = factor - self.convolution_plan_is_valid = False + self._invalidate_plan() return if not isinstance(factor, Numeric): @@ -134,7 +137,7 @@ def upsample_factor(self, factor: Numeric | None) -> None: self._upsample_factor = factor - self.convolution_plan_is_valid = False + self._invalidate_plan() @property def extension_factor(self) -> float: @@ -179,38 +182,32 @@ def extension_factor(self, factor: Numeric) -> None: raise ValueError('Extension factor must be non-negative.') self._extension_factor = float(factor) - self.convolution_plan_is_valid = False + self._invalidate_plan() - @property - def convolution_plan_is_valid(self) -> bool: + def _invalidate_plan(self) -> None: """ - Get whether the convolution plan is valid. + Invalidate the convolution plan for every convolver sharing these settings. - Returns - ------- - bool - Whether the convolution plan is valid. + Bumps the plan version, so every convolver that recorded an earlier version rebuilds its + plan before the next convolution. """ - return self._convolution_plan_is_valid + self._plan_version += 1 - @convolution_plan_is_valid.setter - def convolution_plan_is_valid(self, is_valid: bool) -> None: + def _plan_valid_for(self, seen_version: int) -> bool: """ - Set whether the convolution plan is valid. + Check whether a convolver that last rebuilt at seen_version can skip rebuilding. Parameters ---------- - is_valid : bool - Whether the convolution plan is valid. + seen_version : int + The plan version the convolver recorded when it last rebuilt its plan. - Raises - ------ - TypeError - If is_valid is not a bool. + Returns + ------- + bool + True if no invalidation happened since the convolver's rebuild. """ - if not isinstance(is_valid, bool): - raise TypeError('convolution_plan_is_valid must be True or False.') - self._convolution_plan_is_valid = is_valid + return seen_version == self._plan_version @property def suppress_warnings(self) -> bool: @@ -243,6 +240,22 @@ 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 a shallow copy of the ConvolutionSettings. + + Returns + ------- + 'ConvolutionSettings' + A new ConvolutionSettings instance with the same parameter values. + """ + return ConvolutionSettings( + upsample_factor=self.upsample_factor, + extension_factor=self.extension_factor, + suppress_warnings=self.suppress_warnings, + display_name=self.display_name, + ) + def __repr__(self) -> str: """ Return a string representation of the ConvolutionSettings. diff --git a/tests/performance_tests/convolution/convolution_width_thresholds.ipynb b/tests/performance_tests/convolution/convolution_width_thresholds.ipynb index fbb5df296..543002d54 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 f50de3cf5..7b5a75fa0 100644 --- a/tests/performance_tests/utils/detailed_balance_approximations.ipynb +++ b/tests/performance_tests/utils/detailed_balance_approximations.ipynb @@ -42,8 +42,6 @@ "metadata": {}, "outputs": [], "source": [ - "import numpy as np\n", - "\n", "x = np.linspace(1e-10, 1e-5, 1000)\n", "\n", "y = x / (1 - np.exp(-x))\n", @@ -62,7 +60,7 @@ ], "metadata": { "kernelspec": { - "display_name": "newdynamics", + "display_name": "default", "language": "python", "name": "python3" }, @@ -76,7 +74,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 62e79de72..2a67357ce 100644 --- a/tests/unit/easydynamics/analysis/test_analysis.py +++ b/tests/unit/easydynamics/analysis/test_analysis.py @@ -98,6 +98,24 @@ def test_analysis_list_contains_all_Q_indices(self, analysis): for i in range(3): assert analysis.analysis_list[i].Q_index == i + def test_analysis_list_shares_convolution_settings(self, analysis): + # WHEN THEN EXPECT: every Analysis1d holds the same ConvolutionSettings object, so + # user changes to analysis.convolution_settings reach all Q indices (regression: + # per-Q copies silently detached the settings) + for analysis1d in analysis.analysis_list: + assert analysis1d.convolution_settings is analysis.convolution_settings + + def test_convolution_settings_changes_reach_all_Q(self, analysis): + # WHEN: the analysis list has been built + assert len(analysis.analysis_list) == 3 + + # THEN: mutate the settings on the Analysis after the fact + analysis.convolution_settings.upsample_factor = 20 + + # EXPECT: the per-Q analyses see the new value + for analysis1d in analysis.analysis_list: + assert analysis1d.convolution_settings.upsample_factor == 20 + def test_analysis_list_setter_raises(self, analysis): # WHEN / THEN / EXPECT with pytest.raises( @@ -512,7 +530,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() @@ -533,12 +551,18 @@ def test_parameters_to_dataset_different_units(self, analysis): assert 'Q' in parameters_dataset[parameter_name].dims def test_parameters_to_dataset_raises_on_duplicate_names(self, analysis): - # Add a second Gaussian with the same parameter names as the first - analysis.sample_model.append_component( - Gaussian(name='GaussianName', display_name='Gaussian2', area=0.5) - ) + # Add a second Gaussian with the same parameter names as the first. Appending and the + # later per-Q copy both warn about the duplicate names; capture them so the test suite + # stays warning-clean. + with pytest.warns(UserWarning, match='Duplicate component names'): + analysis.sample_model.append_component( + Gaussian(name='GaussianName', display_name='Gaussian2', area=0.5) + ) - with pytest.raises(ValueError, match='Duplicate parameter names'): + with ( + pytest.warns(UserWarning, match='Duplicate component names'), + pytest.raises(ValueError, match='Duplicate parameter names'), + ): analysis.parameters_to_dataset() @pytest.mark.parametrize( @@ -732,15 +756,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 +851,51 @@ 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. + + # WHEN: 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) + + # Set up a sample model and analysis + 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 + + # Patch the refresh_convolver method to capture the energy passed to it + 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() + + # THEN + 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 +995,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 +1007,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): @@ -1039,3 +1111,9 @@ def test_direct_experiment_rebin_does_not_update_analysis_list(self, analysis): # EXPECT - analysis_list is NOT marked dirty (callers must use Analysis.rebin()) assert analysis._analysis_list_is_dirty is False + + def test_repr(self, analysis): + repr_str = repr(analysis) + assert 'Analysis' in repr_str + assert 'display_name=' in repr_str + assert 'n_analyses=' in repr_str diff --git a/tests/unit/easydynamics/analysis/test_analysis1d.py b/tests/unit/easydynamics/analysis/test_analysis1d.py index 58e090a58..86e35fe8b 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): @@ -150,7 +150,7 @@ def test_fit_calls_fitter_with_correct_arguments(self, analysis1d): fake_weights = np.array([0.1, 0.2, 0.3]) fake_mask = np.array([True, False, True]) - analysis1d.experiment._extract_x_y_weights_only_finite = MagicMock( + analysis1d.experiment.extract_x_y_weights_only_finite = MagicMock( return_value=(fake_x, fake_y, fake_weights, fake_mask) ) @@ -180,7 +180,7 @@ def test_fit_calls_fitter_with_correct_arguments(self, analysis1d): fit_function='fit_func', ) - analysis1d.experiment._extract_x_y_weights_only_finite.assert_called_once() + analysis1d.experiment.extract_x_y_weights_only_finite.assert_called_once() fake_fitter_instance.fit.assert_called_once_with( x=fake_x, @@ -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 @@ -524,9 +528,7 @@ def test_calculate_energy_with_offset_raises_if_incompatible_units(self, analysi energy_offset = Parameter(name='energy_offset', value=1.0, unit='m') # incompatible unit # THEN / EXPECT - with pytest.raises( - sc.UnitError, match='Energy and energy offset must have compatible units' - ): + with pytest.raises(sc.UnitError): analysis1d._calculate_energy_with_offset(energy, energy_offset) ############# @@ -920,7 +922,7 @@ def test_fit_marks_convolver_dirty_when_sample_model_components_change(self, ana 'easydynamics.analysis.analysis1d.EasyScienceFitter', return_value=MagicMock(fit=MagicMock(return_value=MagicMock())), ): - analysis1d.experiment._extract_x_y_weights_only_finite = MagicMock( + analysis1d.experiment.extract_x_y_weights_only_finite = MagicMock( return_value=( np.array([1.0, 2.0, 3.0]), np.array([1.0, 2.0, 3.0]), @@ -946,7 +948,7 @@ def test_fit_does_not_rebuild_convolver_when_nothing_changed(self, analysis1d): 'easydynamics.analysis.analysis1d.EasyScienceFitter', return_value=MagicMock(fit=MagicMock(return_value=MagicMock())), ): - analysis1d.experiment._extract_x_y_weights_only_finite = MagicMock( + analysis1d.experiment.extract_x_y_weights_only_finite = MagicMock( return_value=( np.array([1.0, 2.0, 3.0]), np.array([1.0, 2.0, 3.0]), @@ -1030,7 +1032,7 @@ def test_fit_marks_convolver_dirty_when_resolution_model_components_change(self, 'easydynamics.analysis.analysis1d.EasyScienceFitter', return_value=MagicMock(fit=MagicMock(return_value=MagicMock())), ): - analysis1d.experiment._extract_x_y_weights_only_finite = MagicMock( + analysis1d.experiment.extract_x_y_weights_only_finite = MagicMock( return_value=( np.array([1.0, 2.0, 3.0]), np.array([1.0, 2.0, 3.0]), @@ -1042,3 +1044,106 @@ 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. + # WHEN + + # THEN + result = analysis1d_with_nan._create_residuals_array() + + # EXPECT + 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. + # WHEN + energy = sc.array(dims=['energy'], values=[20.0, 30.0, 40.0], unit='meV') + + # THEN + datagroup = analysis1d_with_nan.data_and_model_to_datagroup( + energy=energy, include_residuals=True + ) + + # EXPECT + 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 + + def test_repr(self, analysis1d): + repr_str = repr(analysis1d) + assert 'Analysis1d' in repr_str + assert 'display_name=' in repr_str + assert 'Q_index=' in repr_str + + +def _coverage_analysis1d(): + Q = sc.array(dims=['Q'], values=[1, 2, 3], 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, 2.0, 3.0], [4.0, 5.0, 6.0], [7.0, 8.0, 9.0]], + variances=[[0.1, 0.2, 0.3], [0.4, 0.5, 0.6], [0.7, 0.8, 0.9]], + ) + data_array = sc.DataArray(data=data, coords={'Q': Q, 'energy': energy}) + return Analysis1d( + display_name='CoverageAnalysis', + experiment=Experiment(data=data_array), + sample_model=SampleModel(components=Gaussian()), + instrument_model=InstrumentModel(), + Q_index=0, + ) + + +def test_on_Q_index_changed_with_none_clears_masked_energy(): + # GIVEN an analysis whose Q_index has been cleared + analysis1d = _coverage_analysis1d() + analysis1d._Q_index = None + # WHEN the Q-index-changed handler runs + analysis1d._on_Q_index_changed() + # EXPECT masked energy cleared and convolver marked dirty + assert analysis1d._masked_energy is None + assert analysis1d._convolver_is_dirty is True + + +def test_on_experiment_changed_refreshes_masked_energy_when_Q_index_set(): + # GIVEN an analysis with a Q_index already set + analysis1d = _coverage_analysis1d() + # WHEN the experiment-changed handler runs + analysis1d._on_experiment_changed() + # EXPECT masked energy refreshed and convolver marked dirty + assert analysis1d._masked_energy is not None + assert analysis1d._convolver_is_dirty is True diff --git a/tests/unit/easydynamics/analysis/test_analysis_base.py b/tests/unit/easydynamics/analysis/test_analysis_base.py index 313ad09ee..afddabc83 100644 --- a/tests/unit/easydynamics/analysis/test_analysis_base.py +++ b/tests/unit/easydynamics/analysis/test_analysis_base.py @@ -92,19 +92,28 @@ def test_init_detailed_balance_settings(self): assert analysis.detailed_balance_settings is detailed_balance_settings def test_init_extra_parameter(self): + # WHEN extra_parameter = Parameter(name='param1', value=1.0) + + # THEN analysis = AnalysisBase(extra_parameters=extra_parameter) + # EXPECT assert analysis._extra_parameters == [extra_parameter] def test_init_extra_parameters(self): + # WHEN extra_parameters = [ Parameter(name='param1', value=1.0), Parameter(name='param2', value=2.0), ] + + # THEN analysis = AnalysisBase(extra_parameters=extra_parameters) + # EXPECT assert analysis._extra_parameters == extra_parameters def test_init_calls_on_experiment_changed(self): + # WHEN THEN EXPECT with patch.object(AnalysisBase, '_on_experiment_changed') as mock_on_experiment_changed: AnalysisBase() mock_on_experiment_changed.assert_called_once() @@ -159,74 +168,99 @@ def test_init_calls_on_experiment_changed(self): ], ) def test_init_invalid_inputs(self, kwargs, expected_exception, expected_message): + # WHEN THEN EXPECT with pytest.raises(expected_exception, match=expected_message): AnalysisBase(**kwargs) def test_experiment_setter_calls_on_experiment_changed(self, analysis_base): + # WHEN THEN EXPECT with patch.object(analysis_base, '_on_experiment_changed') as mock_on_experiment_changed: new_experiment = Experiment() analysis_base.experiment = new_experiment mock_on_experiment_changed.assert_called_once() def test_experiment_setter_invalid_type(self, analysis_base): + # WHEN THEN EXPECT with pytest.raises(TypeError, match='experiment must be an instance of Experiment'): analysis_base.experiment = 'not an experiment' def test_experiment_setter_valid(self, analysis_base): + # WHEN new_experiment = Experiment() analysis_base.experiment = new_experiment + # THEN EXPECT assert analysis_base.experiment == new_experiment def test_sample_model_setter_invalid_type(self, analysis_base): + # WHEN THEN EXPECT with pytest.raises(TypeError, match='sample_model must be an instance of SampleModel'): analysis_base.sample_model = 'not a sample model' def test_sample_model_setter_valid(self, analysis_base): + # WHEN new_sample_model = SampleModel() + + # THEN analysis_base.sample_model = new_sample_model + # EXPECT assert analysis_base.sample_model == new_sample_model def test_sample_model_setter_calls_on_sample_model_changed(self, analysis_base): + # WHEN + new_sample_model = SampleModel() with patch.object( analysis_base, '_on_sample_model_changed' ) as mock_on_sample_model_changed: - new_sample_model = SampleModel() + # THEN analysis_base.sample_model = new_sample_model + + # EXPECT mock_on_sample_model_changed.assert_called_once() def test_instrument_model_setter_invalid_type(self, analysis_base): + # WHEN THEN EXPECT with pytest.raises( TypeError, match='instrument_model must be an instance of InstrumentModel' ): analysis_base.instrument_model = 'not an instrument model' def test_instrument_model_setter_valid(self, analysis_base): + # WHEN new_instrument_model = InstrumentModel() + + # THEN analysis_base.instrument_model = new_instrument_model + + # EXPECT assert analysis_base.instrument_model == new_instrument_model def test_instrument_model_setter_calls_on_instrument_model_changed(self, analysis_base): + # WHEN + new_instrument_model = InstrumentModel() with patch.object( analysis_base, '_on_instrument_model_changed' ) as mock_on_instrument_model_changed: - new_instrument_model = InstrumentModel() + # THEN analysis_base.instrument_model = new_instrument_model + + # EXPECT mock_on_instrument_model_changed.assert_called_once() def test_Q_property(self, analysis_base): - # Create a mock Q value + # WHEN fake_Q = [1, 2, 3] - - # Patch the 'experiment' attribute's Q property with patch.object( type(analysis_base.experiment), 'Q', new_callable=PropertyMock ) as mock_Q: mock_Q.return_value = fake_Q - result = analysis_base.Q # Access the property + # THEN + result = analysis_base.Q + # EXPECT assert result == fake_Q mock_Q.assert_called_once() def test_Q_setter_raises(self, analysis_base): + # WHEN THEN EXPECT with pytest.raises( AttributeError, match=r'Q is a read-only property derived from the Experiment.', @@ -234,19 +268,20 @@ def test_Q_setter_raises(self, analysis_base): analysis_base.Q = [1, 2, 3] def test_energy_property(self, analysis_base): - # Create a mock energy value + # WHEN fake_energy = [10, 20, 30] - - # Patch the 'experiment' attribute's energy property with patch.object( type(analysis_base.experiment), 'energy', new_callable=PropertyMock ) as mock_energy: mock_energy.return_value = fake_energy - result = analysis_base.energy # Access the property + # THEN + result = analysis_base.energy + # EXPECT assert result == fake_energy mock_energy.assert_called_once() def test_energy_setter_raises(self, analysis_base): + # WHEN THEN EXPECT with pytest.raises( AttributeError, match=r'energy is a read-only property derived from the Experiment.', @@ -254,30 +289,32 @@ def test_energy_setter_raises(self, analysis_base): analysis_base.energy = [10, 20, 30] def test_temperature_property_no_temperature(self, analysis_base): - # Patch the 'experiment' attribute's temperature property to - # return None + # WHEN with patch.object( type(analysis_base.sample_model), 'temperature', new_callable=PropertyMock ) as mock_temperature: mock_temperature.return_value = None - result = analysis_base.temperature # Access the property + # THEN + result = analysis_base.temperature + # EXPECT assert result is None mock_temperature.assert_called_once() def test_temperature_property(self, analysis_base): - # Create a mock temperature value + # WHEN fake_temperature = 300 - - # Patch the 'sample_model' attribute's temperature property with patch.object( type(analysis_base.sample_model), 'temperature', new_callable=PropertyMock ) as mock_temperature: mock_temperature.return_value = fake_temperature - result = analysis_base.temperature # Access the property + # THEN + result = analysis_base.temperature + # EXPECT assert result == fake_temperature mock_temperature.assert_called_once() def test_temperature_setter_raises(self, analysis_base): + # WHEN THEN EXPECT with pytest.raises( AttributeError, match='temperature is a read-only property', @@ -381,6 +418,7 @@ def test_extra_parameters_property(self, analysis_base, extra_parameters): ], ) def test_extra_parameters_setter_invalid_type(self, analysis_base, invalid_extra_parameters): + # WHEN THEN EXPECT with pytest.raises( TypeError, match='extra_parameters must be', @@ -392,6 +430,7 @@ def test_extra_parameters_setter_invalid_type(self, analysis_base, invalid_extra ############# def test_normalize_resolution_calls_instrument_model(self, analysis_base): + # WHEN THEN EXPECT with patch.object( analysis_base.instrument_model, 'normalize_resolution' ) as mock_normalize_resolution: @@ -458,12 +497,13 @@ def test_get_parameters_near_bounds_with_tolerances(self, analysis_base_with_com def test_get_parameters_near_bounds_errors( self, analysis_base_with_components, rtol, atol, expected_param, expected_error ): + # WHEN THEN with pytest.raises(expected_error) as exc: analysis_base_with_components.get_parameters_near_bounds( rtol=rtol, atol=atol, ) - + # EXPECT assert expected_param in str(exc.value) def test_not_finite_parameters(self, analysis_base_with_components): @@ -499,7 +539,6 @@ def test_on_experiment_changed_updates_Q(self, analysis_base): analysis_base._on_experiment_changed() # EXPECT - # assert that the Q attribute was set np.testing.assert_array_equal(analysis_base.Q, fake_Q) np.testing.assert_array_equal(analysis_base.sample_model.Q.values, fake_Q) np.testing.assert_array_equal(analysis_base.instrument_model.Q.values, fake_Q) @@ -521,15 +560,15 @@ def test_on_sample_model_changed_updates_Q(self, analysis_base): np.testing.assert_array_equal(analysis_base.sample_model.Q.values, fake_Q) def test_on_instrument_model_changed_updates_Q(self, analysis_base): + # WHEN fake_Q = [1, 2, 3] - - # Patch the Q property of analysis_base with patch.object( type(analysis_base.experiment), 'Q', new_callable=PropertyMock ) as mock_Q: mock_Q.return_value = fake_Q - + # THEN analysis_base._on_instrument_model_changed() + # EXPECT np.testing.assert_array_equal(analysis_base.instrument_model.Q.values, fake_Q) def test_repr(self, analysis_base): diff --git a/tests/unit/easydynamics/base_classes/test_easydynamics_modelbase.py b/tests/unit/easydynamics/base_classes/test_easydynamics_modelbase.py index 6767640a4..c205c9959 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.""" @@ -22,6 +22,8 @@ def test_initialization(self, easy_dynamics_modelbase): assert easy_dynamics_modelbase.name == 'TestModel' assert easy_dynamics_modelbase.display_name == 'TestModel' assert easy_dynamics_modelbase.unique_name is not None + assert easy_dynamics_modelbase.x_unit == 'meV' + assert easy_dynamics_modelbase.y_unit == 'dimensionless' def test_init_raises_type_error_for_invalid_name(self): """Test that initializing with an invalid name raises a TypeError.""" @@ -68,9 +70,15 @@ 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' + assert easy_dynamics_modelbase.y_unit == 'dimensionless' - def test_unit_setter_raises(self, easy_dynamics_modelbase): + def test_x_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): + # WHEN THEN EXPECT + with pytest.raises(AttributeError, match='read-only'): + easy_dynamics_modelbase.y_unit = '1/meV' diff --git a/tests/unit/easydynamics/convolution/test_analytical_convolution.py b/tests/unit/easydynamics/convolution/test_analytical_convolution.py index c9d3f46e2..55c6cc297 100644 --- a/tests/unit/easydynamics/convolution/test_analytical_convolution.py +++ b/tests/unit/easydynamics/convolution/test_analytical_convolution.py @@ -289,7 +289,6 @@ def test_convolute_analytic_pair_delta(self, default_analytical_convolution, fun """ # WHEN THEN delta_function = DeltaFunction(area=2.0, center=0.5) - function1 = Gaussian(area=3.0, center=-1.0, width=1.0) convoluted = default_analytical_convolution._convolute_analytic_pair( delta_function, function1 diff --git a/tests/unit/easydynamics/convolution/test_convolution.py b/tests/unit/easydynamics/convolution/test_convolution.py index dbb4290d4..818767983 100644 --- a/tests/unit/easydynamics/convolution/test_convolution.py +++ b/tests/unit/easydynamics/convolution/test_convolution.py @@ -73,7 +73,8 @@ 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.y_unit == 'dimensionless' assert default_convolution.detailed_balance_settings.normalize_detailed_balance is True assert isinstance(default_convolution._energy_grid, EnergyGrid) @@ -93,7 +94,7 @@ def test_init(self, default_convolution): default_convolution._delta_sample_components[0] is default_convolution.sample_components[2] ) - assert default_convolution.convolution_settings.convolution_plan_is_valid is True + assert default_convolution._convolution_plan_is_current() is True assert default_convolution._reactions_enabled is True def test_init_components(self, convolution_with_components): @@ -107,7 +108,8 @@ 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.y_unit == 'dimensionless' assert ( convolution_with_components.detailed_balance_settings.normalize_detailed_balance is True @@ -132,7 +134,7 @@ def test_init_components(self, convolution_with_components): convolution_with_components._delta_sample_components, ComponentCollection ) assert convolution_with_components._delta_sample_components.is_empty - assert convolution_with_components.convolution_settings.convolution_plan_is_valid is True + assert convolution_with_components._convolution_plan_is_current() is True assert convolution_with_components._reactions_enabled is True def test_convolution_plan_is_built_when_invalid(self, default_convolution): @@ -141,7 +143,7 @@ def test_convolution_plan_is_built_when_invalid(self, default_convolution): """ # WHEN conv = default_convolution - conv.convolution_settings.convolution_plan_is_valid = False + conv._plan_seen_version = None # THEN EXPECT with patch.object(conv, '_build_convolution_plan') as build_plan: @@ -276,7 +278,9 @@ def test_convolution_calls_correct_methods( patch_numerical as mock_numerical_method, patch_delta as mock_delta_method, ): - conv.convolution_settings._convolution_plan_is_valid = True + # The plan was built above; keep it current so convolution() does not rebuild + # the convolvers and discard the mocks. + conv._mark_convolution_plan_current() conv.convolution() if analytical_component: @@ -403,7 +407,7 @@ def test_check_if_pair_is_analytic_raises_with_delta_in_resolution(self, default ) @pytest.mark.parametrize('delta_component', [True, False], ids=['with_delta', 'without_delta']) @pytest.mark.parametrize( - 'temperature', [None, 100], ids=['with_temperature', 'without_temperature'] + 'temperature', [None, 100], ids=['without_temperature', 'with_temperature'] ) def test_build_convolution_plan( self, @@ -481,7 +485,7 @@ def test_build_convolution_plan( expected_numerical_count += 1 assert len(conv._numerical_sample_components) == expected_numerical_count - assert conv.convolution_settings.convolution_plan_is_valid is True + assert conv._convolution_plan_is_current() is True @pytest.mark.parametrize( 'analytical_component', @@ -547,7 +551,7 @@ def test_setattr_does_not_invalidate_plan_for_non_tracked_attribute( ): # WHEN conv = default_convolution - conv.convolution_settings.convolution_plan_is_valid = True + assert conv._convolution_plan_is_current() is True # Capture current identity of internal state to ensure no rebuild old_plan_id = id(conv._analytical_sample_components) @@ -558,7 +562,7 @@ def test_setattr_does_not_invalidate_plan_for_non_tracked_attribute( conv.display_name = 'new_name' # EXPECT - assert conv.convolution_settings.convolution_plan_is_valid is True + assert conv._convolution_plan_is_current() is True assert id(conv._analytical_sample_components) == old_plan_id assert id(conv._numerical_sample_components) == old_numerical_id assert id(conv._delta_sample_components) == old_delta_id @@ -570,10 +574,61 @@ def test_setattr_invalidates_plan_for_tracked_attribute( ): # WHEN conv = default_convolution - conv.convolution_settings.convolution_plan_is_valid = True + assert conv._convolution_plan_is_current() is True # THEN conv.upsample_factor = 10 # EXPECT - assert conv.convolution_settings.convolution_plan_is_valid is False + assert conv._convolution_plan_is_current() is False + + def test_convert_y_unit_propagates_to_sub_convolvers(self): + # WHEN: Convolution with Gaussian sample and resolution components, + # both with y_unit='1/meV' + energy = np.linspace(-10, 10, 5001) + sample_components = ComponentCollection() + sample_components.append_component( + Gaussian(name='G', area=1.0, center=0.0, width=0.5, x_unit='meV', y_unit='1/meV') + ) + resolution_components = ComponentCollection() + resolution_components.append_component( + Gaussian(name='R', area=1.0, center=0.0, width=0.3, x_unit='meV', y_unit='1/meV') + ) + conv = Convolution( + energy=energy, + sample_components=sample_components, + resolution_components=resolution_components, + ) + + # THEN: convert y_unit to '1/eV' + conv.convert_y_unit('1/eV') + + # EXPECT: y_unit updated and propagated to sub-convolvers via Convolution.convert_y_unit + assert conv.y_unit == '1/eV' + assert conv._analytical_convolver._y_unit == '1/eV' + + def test_convert_y_unit_propagates_to_numerical_convolver(self): + # WHEN: a DHO sample component forces a numerical convolver + energy = np.linspace(-10, 10, 5001) + sample_components = ComponentCollection() + sample_components.append_component( + DampedHarmonicOscillator( + name='DHO', area=1.0, center=1.0, width=0.5, x_unit='meV', y_unit='1/meV' + ) + ) + resolution_components = ComponentCollection() + resolution_components.append_component( + Gaussian(name='R', area=1.0, center=0.0, width=0.3, x_unit='meV', y_unit='1/meV') + ) + conv = Convolution( + energy=energy, + sample_components=sample_components, + resolution_components=resolution_components, + ) + + # THEN + conv.convert_y_unit('1/eV') + + # EXPECT + assert conv.y_unit == '1/eV' + assert conv._numerical_convolver._y_unit == '1/eV' diff --git a/tests/unit/easydynamics/convolution/test_convolution_base.py b/tests/unit/easydynamics/convolution/test_convolution_base.py index fe8ceef62..7bc26fff7 100644 --- a/tests/unit/easydynamics/convolution/test_convolution_base.py +++ b/tests/unit/easydynamics/convolution/test_convolution_base.py @@ -32,6 +32,8 @@ def test_init(self, convolution_base): assert np.allclose(convolution_base.energy.values, np.linspace(-10, 10, 100)) assert isinstance(convolution_base._sample_components, ComponentCollection) assert isinstance(convolution_base._resolution_components, ComponentCollection) + assert convolution_base.x_unit == 'meV' + assert convolution_base.y_unit == 'dimensionless' def test_init_with_model_component(self): # WHEN @@ -78,7 +80,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 +90,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 +103,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 +116,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 +126,17 @@ def test_init_energy_numerical_none_offset(self): 'energy': np.linspace(-10, 10, 100), 'sample_components': ComponentCollection(), 'resolution_components': ComponentCollection(), - 'unit': 'meV', + 'y_unit': 123, + 'energy_offset': 0, + }, + 'unit must be ', + ), + ( + { + 'energy': np.linspace(-10, 10, 100), + 'sample_components': ComponentCollection(), + 'resolution_components': ComponentCollection(), + 'x_unit': 'meV', 'energy_offset': 'invalid', }, 'Energy_offset must be ', @@ -181,17 +193,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 +212,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 +220,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 +235,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 +267,21 @@ 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 is in meV and energy_offset given in eV (0.001 eV = 1 meV) + convolution_base.energy_offset = Parameter( + name='energy_offset', + value=0.001, + unit='eV', # 0.001 eV = 1 meV + ) + + # THEN + result = convolution_base.energy_with_offset + + # EXPECT: offset is converted to meV before subtracting → 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) @@ -303,3 +330,76 @@ def test_resolution_components_setter_invalid_type_raises(self, convolution_base ), ): convolution_base.resolution_components = 'invalid' + + def test_y_unit_setter_raises(self, convolution_base): + # WHEN THEN EXPECT + with pytest.raises(AttributeError, match='read-only'): + convolution_base.y_unit = '1/meV' + + def test_convert_y_unit(self, convolution_base): + # WHEN: sample component with y_unit='1/meV' + convolution_base.sample_components.append_component( + Gaussian(area=1.0, x_unit='meV', y_unit='1/meV') + ) + + # THEN: convert y_unit to '1/eV' (same dimension, different scale) + convolution_base.convert_y_unit('1/eV') + + # EXPECT: y_unit updated and propagated to sample components + assert convolution_base.y_unit == '1/eV' + for component in convolution_base.sample_components: + assert component.y_unit == '1/eV' + + def test_convert_y_unit_invalid_type_raises(self, convolution_base): + # WHEN THEN EXPECT + with pytest.raises(TypeError): + convolution_base.convert_y_unit(123) + + def test_convert_y_unit_rollback_on_failure(self, convolution_base): + # WHEN: sample component with default y_unit='dimensionless' + convolution_base.sample_components.append_component(Gaussian(area=1.0, x_unit='meV')) + + # THEN EXPECT: 'K' is dimensionally incompatible → triggers rollback + with pytest.raises(UnitError): + convolution_base.convert_y_unit('K') + + assert convolution_base.y_unit == 'dimensionless' + + def test_convert_x_unit_without_sample_components(self): + # WHEN + cb = ConvolutionBase(energy=np.linspace(-10, 10, 100), sample_components=None) + + # THEN + cb.convert_x_unit('eV') + + # EXPECT + assert cb.energy.unit == 'eV' + assert np.allclose(cb.energy.values, np.linspace(-0.01, 0.01, 100)) + assert cb.x_unit == 'eV' + + def test_convert_x_unit_without_resolution_components(self): + # WHEN + sample = ComponentCollection(display_name='Sample') + cb = ConvolutionBase( + energy=np.linspace(-10, 10, 100), + sample_components=sample, + resolution_components=None, + ) + + # THEN + cb.convert_x_unit('eV') + + # EXPECT + assert cb.energy.unit == 'eV' + assert np.allclose(cb.energy.values, np.linspace(-0.01, 0.01, 100)) + assert cb.x_unit == 'eV' + + def test_convert_y_unit_without_sample_components(self): + # WHEN + cb = ConvolutionBase(energy=np.linspace(-10, 10, 100), sample_components=None) + + # THEN + cb.convert_y_unit('1/meV') + + # EXPECT + assert cb.y_unit == '1/meV' diff --git a/tests/unit/easydynamics/convolution/test_energy_grid.py b/tests/unit/easydynamics/convolution/test_energy_grid.py index 01cf19d40..e0390b3ee 100644 --- a/tests/unit/easydynamics/convolution/test_energy_grid.py +++ b/tests/unit/easydynamics/convolution/test_energy_grid.py @@ -8,12 +8,14 @@ class TestEnergyGrid: def test_energy_grid_attributes(self): + # WHEN energy_dense = np.array([-1.0, 0.0, 1.0]) energy_dense_centered = np.array([-1.0, 0.0, 1.0]) energy_dense_step = 1.0 energy_span_dense = 2.0 energy_even_length_offset = 0.0 + # THEN energy_grid = EnergyGrid( energy_dense=energy_dense, energy_span_dense=energy_span_dense, @@ -22,6 +24,7 @@ def test_energy_grid_attributes(self): energy_dense_step=energy_dense_step, ) + # EXPECT assert np.array_equal(energy_grid.energy_dense, energy_dense) assert np.array_equal(energy_grid.energy_dense_centered, energy_dense_centered) assert energy_grid.energy_dense_step == energy_dense_step diff --git a/tests/unit/easydynamics/convolution/test_numerical_convolution.py b/tests/unit/easydynamics/convolution/test_numerical_convolution.py index 755381c24..5db8bc15d 100644 --- a/tests/unit/easydynamics/convolution/test_numerical_convolution.py +++ b/tests/unit/easydynamics/convolution/test_numerical_convolution.py @@ -4,33 +4,72 @@ import numpy as np import pytest import scipp as sc +from easyscience.variable import Parameter from scipy.signal import fftconvolve from easydynamics.convolution.energy_grid import EnergyGrid from easydynamics.convolution.numerical_convolution import NumericalConvolution from easydynamics.sample_model import Gaussian from easydynamics.sample_model.component_collection import ComponentCollection +from easydynamics.settings.convolution_settings import ConvolutionSettings from easydynamics.utils.detailed_balance import detailed_balance_factor +def _make_numerical_convolution(convolution_settings=None): + energy = np.linspace(-10, 10, 5001) + sample_components = ComponentCollection(display_name='ComponentCollection') + sample_components.append_component(Gaussian(name='Gaussian1', area=2.0, center=0.1, width=0.4)) + resolution_components = ComponentCollection(display_name='ResolutionModel') + resolution_components.append_component( + Gaussian(name='GaussianRes', area=3.0, center=0.2, width=0.5) + ) + + return NumericalConvolution( + energy=energy, + sample_components=sample_components, + resolution_components=resolution_components, + convolution_settings=convolution_settings, + ) + + class TestNumericalConvolution: @pytest.fixture def default_numerical_convolution(self): - energy = np.linspace(-10, 10, 5001) - sample_components = ComponentCollection(display_name='ComponentCollection') - sample_components.append_component( - Gaussian(name='Gaussian1', area=2.0, center=0.1, width=0.4) - ) - resolution_components = ComponentCollection(display_name='ResolutionModel') - resolution_components.append_component( - Gaussian(name='GaussianRes', area=3.0, center=0.2, width=0.5) - ) + return _make_numerical_convolution() + + def test_settings_change_invalidates_all_sharing_convolvers(self): + # WHEN: two convolvers sharing one ConvolutionSettings, and an accuracy knob changes + settings = ConvolutionSettings() + convolver_a = _make_numerical_convolution(settings) + convolver_b = _make_numerical_convolution(settings) + settings.upsample_factor = 10 + + # THEN: A consumes the change by rebuilding its plan + convolver_a.convolution() + + # EXPECT: A is current, but B still sees the stale plan (regression: A's rebuild + # setting the shared flag used to mask the settings change from B) + assert convolver_a._convolution_plan_is_current() is True + assert convolver_b._convolution_plan_is_current() is False + + # and B becomes current after its own rebuild + convolver_b.convolution() + assert convolver_b._convolution_plan_is_current() is True + + def test_invalidate_plan_invalidates_all_sharing_convolvers(self): + # WHEN: two current convolvers sharing one ConvolutionSettings + settings = ConvolutionSettings() + convolver_a = _make_numerical_convolution(settings) + convolver_b = _make_numerical_convolution(settings) + + # THEN: an invalidation affects every convolver, and one convolver rebuilding does + # not mask the invalidation from the other + settings._invalidate_plan() + convolver_a.convolution() - return NumericalConvolution( - energy=energy, - sample_components=sample_components, - resolution_components=resolution_components, - ) + # EXPECT + assert convolver_a._convolution_plan_is_current() is True + assert convolver_b._convolution_plan_is_current() is False def test_init(self, default_numerical_convolution): """ @@ -48,7 +87,8 @@ 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.y_unit == 'dimensionless' assert ( default_numerical_convolution.detailed_balance_settings.normalize_detailed_balance is True @@ -150,7 +190,10 @@ def test_convolution_branches( conv.upsample_factor = upsample - conv.convolution_settings.convolution_plan_is_valid = plan_valid + if plan_valid: + conv._mark_convolution_plan_current() + else: + conv._plan_seen_version = None # --- Track calls --- create_grid_called = False @@ -233,3 +276,53 @@ 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): + # WHEN: energy grid is in meV, offset given as a Parameter in eV (0.001 eV = 1.0 meV) + conv = default_numerical_convolution + conv.energy_offset = Parameter(name='energy_offset', value=0.001, unit='eV') + result_ev = conv.convolution() + + # THEN: run again with the numerically equivalent offset as an explicit meV Parameter + conv.energy_offset = Parameter(name='energy_offset', value=1.0, unit='meV') + result_mev = conv.convolution() + + # EXPECT: unit conversion is transparent — results are numerically identical + np.testing.assert_allclose(result_ev, result_mev, rtol=1e-6) + + 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, default_numerical_convolution, monkeypatch + ): + # WHEN: even-length energy grid with temperature + nc = default_numerical_convolution + nc.energy = np.linspace(-10, 10, 100) # even-length → non-zero energy_even_length_offset + nc.temperature = 300.0 + + captured = {} + + # spy_dbf wraps detailed_balance_factor: captures the energy argument passed to it, + # then delegates to the real function so the convolution still produces a valid result. + def spy_dbf(**kwargs): + captured['energy'] = kwargs['energy'] + return detailed_balance_factor(**kwargs) + + monkeypatch.setattr( + 'easydynamics.convolution.numerical_convolution.detailed_balance_factor', spy_dbf + ) + + # THEN: run convolution + nc.convolution() + + # EXPECT: DBF receives energy_dense - energy_even_length_offset + # Before the fix, energy_even_length_offset was omitted, causing a half-bin error. + grid = nc._energy_grid + np.testing.assert_allclose( + captured['energy'], grid.energy_dense - grid.energy_even_length_offset, 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 73bcee0bd..9f90ff98c 100644 --- a/tests/unit/easydynamics/convolution/test_numerical_convolution_base.py +++ b/tests/unit/easydynamics/convolution/test_numerical_convolution_base.py @@ -48,7 +48,8 @@ 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.y_unit == 'dimensionless' assert ( default_numerical_convolution_base.detailed_balance_settings.normalize_detailed_balance is True @@ -79,7 +80,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 +88,8 @@ 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.y_unit == 'dimensionless' assert ( numerical_convolution_base.detailed_balance_settings.normalize_detailed_balance is False @@ -161,11 +163,8 @@ def test_energy_setter(self, default_numerical_convolution_base): assert isinstance(default_numerical_convolution_base.energy, sc.Variable) assert np.allclose(default_numerical_convolution_base.energy.values, new_energy) - # EXPECT: plan invalidated - assert ( - default_numerical_convolution_base.convolution_settings.convolution_plan_is_valid - is False - ) + # EXPECT: this convolver's plan is invalidated + assert default_numerical_convolution_base._convolution_plan_is_current() is False # THEN # Force regeneration of energy grid @@ -200,10 +199,7 @@ def test_upsample_factor_setter( default_numerical_convolution_base.upsample_factor = new_upsample_factor # EXPECT: plan invalidated - assert ( - default_numerical_convolution_base.convolution_settings.convolution_plan_is_valid - is False - ) + assert default_numerical_convolution_base._convolution_plan_is_current() is False # Force regeneration of energy grid default_numerical_convolution_base._energy_grid = ( @@ -254,10 +250,7 @@ def test_extension_factor_setter(self, default_numerical_convolution_base): default_numerical_convolution_base.extension_factor = new_extension_factor # EXPECT: plan invalidated - assert ( - default_numerical_convolution_base.convolution_settings.convolution_plan_is_valid - is False - ) + assert default_numerical_convolution_base._convolution_plan_is_current() is False # THEN # Force regeneration of energy grid @@ -409,14 +402,16 @@ def test_convolution_settings_setter_valid( new_settings = ConvolutionSettings() # WHEN - new_settings.convolution_plan_is_valid = True + version_before = new_settings._plan_version # THEN default_numerical_convolution_base.convolution_settings = new_settings - # EXPECT + # EXPECT: the convolver's own plan is invalidated, but the new settings object is + # untouched — other convolvers sharing it are unaffected. assert default_numerical_convolution_base.convolution_settings is new_settings - assert new_settings.convolution_plan_is_valid is False + assert default_numerical_convolution_base._convolution_plan_is_current() is False + assert new_settings._plan_version == version_before @pytest.mark.parametrize( 'value, expected_exception, match', @@ -489,9 +484,11 @@ def test_create_energy_grid_upsample_none_non_uniform_raises( and non-uniform energy raises ValueError (the energy grid must always be uniform). """ - # WHEN + # WHEN: non-uniform energy with upsample_factor=None default_numerical_convolution_base.energy = np.array([0, 1, 3, 6, 10]) default_numerical_convolution_base.upsample_factor = None + + # THEN EXPECT with pytest.raises( ValueError, match='Input array `energy` must be uniformly spaced if upsample_factor is not given', @@ -638,13 +635,25 @@ 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 assert 'normalize_detailed_balance=True' in repr_str + + +def test_create_energy_grid_raises_when_extension_factor_none_with_upsampling(): + # GIVEN upsampling enabled but no extension_factor, the dense energy grid cannot be built + # WHEN THEN EXPECT (the grid is built eagerly during construction) + with pytest.raises(ValueError, match=r'extension_factor must be a number'): + NumericalConvolutionBase( + energy=np.linspace(-10, 10, 101), + sample_components=ComponentCollection(display_name='ComponentCollection'), + resolution_components=ComponentCollection(display_name='ResolutionModel'), + convolution_settings=ConvolutionSettings(upsample_factor=5, extension_factor=None), + ) diff --git a/tests/unit/easydynamics/experiment/test_experiment.py b/tests/unit/easydynamics/experiment/test_experiment.py index 6201bbc05..21e63d2da 100644 --- a/tests/unit/easydynamics/experiment/test_experiment.py +++ b/tests/unit/easydynamics/experiment/test_experiment.py @@ -142,6 +142,18 @@ def test_load_hdf5_invalid_file_raises(self, experiment): with pytest.raises(OSError): experiment.load_hdf5('non_existent_file.h5') + def test_load_hdf5_invalid_data_type_raises(self, experiment): + "Test that loading a file that returns a non-DataArray raises TypeError" + # WHEN THEN EXPECT + with ( + patch( + 'easydynamics.experiment.experiment.sc_load_hdf5', + return_value=sc.scalar(1.0), + ), + pytest.raises(TypeError, match=r'sc\.DataArray'), + ): + experiment.load_hdf5('fake_file.h5') + def test_save_hdf5(self, tmp_path, experiment): "Test saving data to an HDF5 file. Load the saved file" 'using scipp and compare to the original data.' @@ -307,6 +319,23 @@ def test_get_masked_energy(self, experiment_with_data): assert len(masked_energy) == 1 assert masked_energy.values == pytest.approx(30.0) + def test_get_masked_energy_with_precomputed_mask(self, experiment_with_data): + "Test that passing a precomputed mask gives the same result as computing it" + # WHEN + Q_index = 0 + invalid_data = experiment_with_data._data.copy() + invalid_data.data.values[Q_index][0] = np.inf + + experiment_with_data.data = invalid_data + mask = experiment_with_data.get_finite_energy_mask(Q_index=Q_index) + + # THEN + masked_energy = experiment_with_data.get_masked_energy(Q_index=Q_index, mask=mask) + + # EXPECT: identical to the mask-free call + expected = experiment_with_data.get_masked_energy(Q_index=Q_index) + assert sc.identical(masked_energy, expected) + def test_get_masked_energy_no_data_returns_None(self): "Test getting masked energy returns zero when no data is present" @@ -334,6 +363,40 @@ def test_get_masked_energy_invalid_type_raises(self, experiment_with_data): with pytest.raises(TypeError): experiment_with_data.get_masked_energy(Q_index='not an index') + def test_get_finite_energy_mask_returns_none_without_data(self): + "Test get_finite_energy_mask returns None when no data is loaded" + # WHEN THEN EXPECT + assert Experiment().get_finite_energy_mask(Q_index=0) is None + + def test_get_masked_binned_data_returns_none_without_data(self): + "Test get_masked_binned_data returns None when no data is loaded" + # WHEN THEN EXPECT + assert Experiment().get_masked_binned_data(Q_index=0) is None + + def test_get_finite_energy_mask_with_data(self, experiment_with_data): + "Test get_finite_energy_mask returns a boolean scipp variable when data is loaded" + # WHEN + + # THEN + mask = experiment_with_data.get_finite_energy_mask(Q_index=0) + + # EXPECT + assert mask is not None + assert mask.dims == ('energy',) + assert len(mask) == 3 + + def test_get_masked_binned_data_with_data(self, experiment_with_data): + "Test get_masked_binned_data returns a DataArray with all-finite data loaded" + # WHEN + + # THEN + result = experiment_with_data.get_masked_binned_data(Q_index=0) + + # EXPECT + assert result is not None + assert isinstance(result, sc.DataArray) + assert result.dims == ('energy',) + ############## # test plotting ############## @@ -502,8 +565,8 @@ def test_extract_x_y_var(self, experiment_with_data): experiment_with_data.data.variances[Q_index], ) - def test_extract_x_y_weights_only_finite_zero_variances(self, experiment_with_data): - "Test that _extract_x_y_weights_only_finite raises ValueError when variances contain zeros" + def testextract_x_y_weights_only_finite_zero_variances(self, experiment_with_data): + "Test that extract_x_y_weights_only_finite raises ValueError when variances contain zeros" # WHEN Q_index = 0 invalid_data = experiment_with_data._data.copy() @@ -513,10 +576,10 @@ def test_extract_x_y_weights_only_finite_zero_variances(self, experiment_with_da # THEN EXPECT with pytest.raises(ValueError, match='Cannot compute weights: some variances are zero'): - Experiment(data=invalid_data)._extract_x_y_weights_only_finite(Q_index=Q_index) + Experiment(data=invalid_data).extract_x_y_weights_only_finite(Q_index=Q_index) - def test_extract_x_y_weights_only_finite(self, experiment_with_data): - "Test that _extract_x_y_weights_only_finite only returns finite values" + def testextract_x_y_weights_only_finite(self, experiment_with_data): + "Test that extract_x_y_weights_only_finite only returns finite values" # WHEN Q_index = 0 invalid_data = experiment_with_data._data.copy() @@ -524,7 +587,7 @@ def test_extract_x_y_weights_only_finite(self, experiment_with_data): invalid_data.data.variances[Q_index][1] = np.nan # THEN - x, y, weights, mask = Experiment(data=invalid_data)._extract_x_y_weights_only_finite( + x, y, weights, mask = Experiment(data=invalid_data).extract_x_y_weights_only_finite( Q_index=Q_index ) @@ -539,7 +602,7 @@ def test_extract_x_y_weights_only_finite(self, experiment_with_data): # Mask should indicate which values were removed assert np.array_equal(mask, [False, False, True]) - def test_extract_x_y_weights_only_finite_zero_variance(self, experiment_with_data): + def testextract_x_y_weights_only_finite_zero_variance(self, experiment_with_data): "Test getting x y and weights when variances are None" # WHEN Q_index = 0 @@ -549,9 +612,7 @@ def test_extract_x_y_weights_only_finite_zero_variance(self, experiment_with_dat experiment_with_data.data = data # THEN - x, y, weights, mask = experiment_with_data._extract_x_y_weights_only_finite( - Q_index=Q_index - ) + x, y, weights, mask = experiment_with_data.extract_x_y_weights_only_finite(Q_index=Q_index) # EXPECT assert np.array_equal(x, experiment_with_data.energy.values) 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 02fc31dfa..12f73235f 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 @@ -5,7 +5,9 @@ import numpy as np import pytest +import scipp as sc from easyscience.variable import Parameter +from scipp import UnitError from scipy.integrate import simpson from easydynamics.sample_model import DampedHarmonicOscillator @@ -20,7 +22,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 +34,8 @@ 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' + assert dho.y_unit == 'dimensionless' def test_initialization(self, dho: DampedHarmonicOscillator): # WHEN THEN EXPECT @@ -40,26 +43,36 @@ 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}, - 'unit must be None', + {'area': 2.0, 'center': 0.5, 'width': 0.6, 'x_unit': 123}, + 'unit must be None, a string', + ), + ( + { + 'area': 2.0, + 'center': 0.5, + 'width': 0.6, + 'x_unit': 'meV', + 'y_unit': 123, + }, + 'unit must be None, a string', ), ], ) @@ -78,7 +91,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 +102,7 @@ def test_negative_area_warns(self): area=-2.0, center=0.5, width=0.6, - unit='meV', + x_unit='meV', ) @pytest.mark.parametrize( @@ -108,11 +121,15 @@ def test_property_setters( invalid_value, invalid_message, ): - # set valid + # WHEN + + # THEN : set a valid value setattr(dho, prop, valid_value) + + # EXPECT assert getattr(dho, prop).value == valid_value - # invalid + # WHEN: set an invalid value — THEN EXPECT with pytest.raises(TypeError, match=invalid_message): setattr(dho, prop, invalid_value) @@ -167,12 +184,12 @@ def test_area_matches_parameter(self, dho: DampedHarmonicOscillator): # EXPECT assert numerical_area == pytest.approx(dho.area.value, rel=2e-3) - def test_convert_unit(self, dho: DampedHarmonicOscillator): + def test_convert_x_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 +211,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 +219,91 @@ 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_custom(self): + # WHEN THEN + dho = DampedHarmonicOscillator( + area=1.0, center=1.0, width=0.3, x_unit='meV', y_unit='1/meV' + ) + # EXPECT + assert dho.y_unit == '1/meV' + + def test_y_unit_setter_raises(self, dho: DampedHarmonicOscillator): + # WHEN THEN EXPECT + with pytest.raises(AttributeError): + dho.y_unit = '1/meV' + + def test_convert_y_unit(self): + # WHEN: 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' + ) + + # THEN: 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): + # WHEN THEN EXPECT + with pytest.raises(TypeError): + dho.convert_y_unit(123) + + def test_evaluate_scipp_output(self, dho: DampedHarmonicOscillator): + # WHEN + x = np.linspace(0.5, 5.0, 50) + + # THEN + result = dho.evaluate(x, output='scipp') + + # EXPECT + assert isinstance(result, sc.Variable) + assert result.unit == sc.Unit('dimensionless') + assert len(result.values) == 50 + np.testing.assert_allclose(result.values, dho.evaluate(x, output='numpy')) + + def test_evaluate_scipp_output_with_y_unit(self): + # WHEN + dho = DampedHarmonicOscillator( + area=1.0, center=1.0, width=0.3, x_unit='meV', y_unit='1/meV' + ) + x = np.linspace(0.5, 5.0, 50) + + # THEN + result = dho.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, dho: DampedHarmonicOscillator): + # WHEN THEN EXPECT + 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): + # WHEN THEN + with pytest.raises(UnitError): + dho.convert_x_unit('m') + # EXPECT: state rolled back + 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): + # WHEN + dho = DampedHarmonicOscillator(area=1.0, center=1.0, width=0.3, x_unit='meV') + # THEN + with pytest.raises(UnitError): + dho.convert_y_unit('K') + # EXPECT: state rolled back + 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 de3148b7d..85fbf1f6f 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,8 @@ 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.y_unit == 'dimensionless' assert delta_function.center.fixed is True def test_initialization(self, delta_function: DeltaFunction): @@ -39,21 +40,25 @@ 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 ', + ), + ( + {'area': 2.0, 'center': 0.5, 'x_unit': 'meV', 'y_unit': 123}, 'unit must be ', ), ], @@ -65,7 +70,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', @@ -82,11 +87,13 @@ def test_property_setters( invalid_value, invalid_message, ): - # set valid + # WHEN: set a valid value setattr(delta_function, prop, valid_value) + + # THEN EXPECT assert getattr(delta_function, prop).value == valid_value - # invalid + # WHEN: set an invalid value — THEN EXPECT with pytest.raises(TypeError, match=invalid_message): setattr(delta_function, prop, invalid_value) @@ -106,6 +113,29 @@ def test_evaluate(self, delta_function: DeltaFunction): np.testing.assert_allclose(result, expected_result, rtol=1e-5) + def test_evaluate_descending_grid(self): + # WHEN: a descending energy grid (regression: neighbor differences used to produce a + # negative bin width and a negative spike) + delta = DeltaFunction(area=1.0) + x = np.array([2.0, 0.0, -2.0]) + + # THEN + result = delta.evaluate(x) + + # EXPECT: positive spike at the position of the value nearest the center + np.testing.assert_allclose(result, [0.0, 0.5, 0.0]) + + def test_evaluate_unsorted_grid(self): + # WHEN: an unsorted grid; bin widths must come from the sorted values + delta = DeltaFunction(area=1.0) + x = np.array([0.0, 2.0, 1.0]) + + # THEN + result = delta.evaluate(x) + + # EXPECT: spike at x=0 with bin width from the sorted grid [0, 1, 2] -> 1.0 + np.testing.assert_allclose(result, [1.0, 0.0, 0.0]) + def test_evaluate_out_of_bounds(self, delta_function: DeltaFunction): # WHEN x = np.linspace(1, 2, 100) # center is at 0.5, so out of bounds @@ -147,7 +177,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) @@ -190,12 +220,12 @@ def test_get_all_parameters(self, delta_function: DeltaFunction): actual_names = {param.name for param in params} assert actual_names == expected_names - def test_convert_unit(self, delta_function: DeltaFunction): + def test_convert_x_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 +243,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 +251,103 @@ 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_custom(self): + # WHEN THEN + delta = DeltaFunction(area=1.0, x_unit='meV', y_unit='1/meV') + + # EXPECT + assert delta.y_unit == '1/meV' + + def test_y_unit_setter_raises(self, delta_function: DeltaFunction): + # WHEN THEN EXPECT + with pytest.raises(AttributeError): + delta_function.y_unit = '1/meV' + + def test_convert_y_unit(self): + # WHEN: x_unit='meV', y_unit='1/meV' → area_unit='dimensionless' + delta = DeltaFunction(area=1.0, x_unit='meV', y_unit='1/meV') + # THEN: 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): + # WHEN THEN EXPECT + with pytest.raises(TypeError): + delta_function.convert_y_unit(123) + + def test_evaluate_scipp_output(self, delta_function: DeltaFunction): + # WHEN + x = np.linspace(-5, 5, 50) + # THEN + result = delta_function.evaluate(x, output='scipp') + # EXPECT + assert isinstance(result, sc.Variable) + assert result.unit == sc.Unit('dimensionless') + assert len(result.values) == 50 + np.testing.assert_allclose(result.values, delta_function.evaluate(x, output='numpy')) + + def test_evaluate_scipp_output_with_y_unit(self): + # WHEN + delta = DeltaFunction(area=1.0, x_unit='meV', y_unit='1/meV') + x = np.linspace(-5, 5, 50) + # THEN + result = delta.evaluate(x, output='scipp') + # EXPECT + assert isinstance(result, sc.Variable) + assert result.unit == sc.Unit('1/meV') + + @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): + # WHEN + area = 1.0 + delta = DeltaFunction(area=area, center=center, x_unit='meV') + + # THEN + result = delta.evaluate(x) + + # EXPECT + # 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): + # WHEN THEN EXPECT + 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): + # WHEN THEN + with pytest.raises(UnitError): + delta_function.convert_x_unit('m') + # EXPECT: state rolled back + 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): + # WHEN + delta = DeltaFunction(area=1.0, center=0.0, x_unit='meV', y_unit='dimensionless') + # THEN + with pytest.raises(UnitError): + delta.convert_y_unit('K') + # EXPECT: state rolled back + 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 b7fa55031..1b9de0988 100644 --- a/tests/unit/easydynamics/sample_model/components/test_exponential.py +++ b/tests/unit/easydynamics/sample_model/components/test_exponential.py @@ -5,6 +5,7 @@ import numpy as np import pytest +import scipp as sc from easyscience.variable import Parameter from scipp import UnitError @@ -20,7 +21,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 +33,8 @@ 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' + assert exponential.y_unit == 'dimensionless' def test_initialization(self, exponential: Exponential): # WHEN THEN EXPECT @@ -40,23 +42,33 @@ 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', ), + ( + { + 'amplitude': 2.0, + 'center': 0.5, + 'rate': 1.0, + 'x_unit': 'meV', + 'y_unit': 123, + }, + 'unit must be None, a string', + ), ], ) def test_input_type_validation_raises(self, kwargs, expected_message): @@ -67,12 +79,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', ), ], ) @@ -96,11 +108,12 @@ def test_property_setters( invalid_value, invalid_message, ): - # set valid + # WHEN: set a valid value setattr(exponential, prop, valid_value) + # THEN EXPECT assert getattr(exponential, prop).value == valid_value - # invalid + # WHEN: set an invalid value — THEN EXPECT with pytest.raises(TypeError, match=invalid_message): setattr(exponential, prop, invalid_value) @@ -144,37 +157,40 @@ def test_get_all_parameters(self, exponential: Exponential): assert actual_names == expected_names - def test_convert_unit(self, exponential: Exponential): + def test_convert_x_unit(self, exponential: Exponential): # WHEN - exponential.convert_unit('microeV') - # THEN EXPECT - assert exponential.unit == 'microeV' + # THEN + exponential.convert_x_unit('microeV') + + # EXPECT + assert exponential.x_unit == 'microeV' - assert exponential.amplitude.value == pytest.approx(2.0 * 1e3) + # amplitude carries y_unit only and is unaffected by x-unit conversion + assert exponential.amplitude.value == pytest.approx(2.0) assert exponential.center.value == pytest.approx(0.5 * 1e3) # rate should scale inversely assert exponential.rate.value == pytest.approx(1.2 / 1e3) assert str(exponential.rate.unit) == '1/ueV' - def test_convert_unit_incorrect_unit_raises(self, exponential: Exponential): + def test_convert_x_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 + def test_convert_x_unit_rollback(self, exponential: Exponential): + # WHEN THEN with pytest.raises( 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' + # EXPECT - values should be unchanged + assert exponential.x_unit == 'meV' assert exponential.amplitude.value == pytest.approx(2.0) - assert exponential.amplitude.unit == 'meV' + assert exponential.amplitude.unit == 'dimensionless' assert exponential.center.value == pytest.approx(0.5) assert exponential.center.unit == 'meV' assert exponential.rate.value == pytest.approx(1.2) @@ -182,9 +198,11 @@ def test_convert_unit_rollback(self, exponential: Exponential): def test_copy(self, exponential: Exponential): # WHEN + + # THEN exponential_copy = copy(exponential) - # THEN EXPECT + # EXPECT assert exponential_copy is not exponential assert exponential_copy.display_name == exponential.display_name @@ -197,7 +215,8 @@ 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 + assert exponential_copy.y_unit == exponential.y_unit def test_repr(self, exponential: Exponential): # WHEN @@ -205,8 +224,89 @@ 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_custom(self): + # WHEN THEN + exp = Exponential(amplitude=1.0, center=0.0, rate=1.0, x_unit='meV', y_unit='1/meV') + # EXPECT + assert exp.y_unit == '1/meV' + + def test_y_unit_setter_raises(self, exponential: Exponential): + # WHEN THEN EXPECT + with pytest.raises(AttributeError): + exponential.y_unit = '1/meV' + + def test_convert_y_unit(self): + # WHEN: 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') + # THEN: 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): + # WHEN THEN EXPECT + with pytest.raises(TypeError): + exponential.convert_y_unit(123) + + def test_evaluate_scipp_output(self, exponential: Exponential): + # WHEN + x = np.linspace(-5, 5, 50) + # THEN + result = exponential.evaluate(x, output='scipp') + # EXPECT + assert isinstance(result, sc.Variable) + assert result.unit == sc.Unit('dimensionless') + assert len(result.values) == 50 + np.testing.assert_allclose(result.values, exponential.evaluate(x, output='numpy')) + + def test_evaluate_scipp_output_with_y_unit(self): + # WHEN + exp = Exponential(amplitude=1.0, center=0.0, rate=1.0, x_unit='meV', y_unit='1/meV') + x = np.linspace(-5, 5, 50) + # THEN + result = exp.evaluate(x, output='scipp') + # EXPECT + assert isinstance(result, sc.Variable) + assert result.unit == sc.Unit('1/meV') + + def test_init_rejects_parameter_amplitude(self): + # WHEN THEN EXPECT + 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): + # WHEN THEN EXPECT + 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): + # WHEN + exp = Exponential(amplitude=1.0, center=0.0, rate=1.0, x_unit='meV') + # THEN + with pytest.raises(UnitError): + exp.convert_y_unit('K') + # EXPECT: state rolled back + assert exp.y_unit == 'dimensionless' + assert exp.amplitude.value == pytest.approx(1.0) + + def test_evaluate_unchanged_by_convert_x_unit(self): + # WHEN: regression — the amplitude used to carry x_unit * y_unit, so converting the + # x unit rescaled the whole curve by the conversion factor + exp = Exponential(amplitude=2.0, center=0.0, rate=1.0, x_unit='meV') + before = exp.evaluate(np.array([0.0])) + + # THEN: convert the x-axis unit and evaluate at the same physical point + exp.convert_x_unit('ueV') + after = exp.evaluate(np.array([0.0])) + + # EXPECT: the curve is unchanged + np.testing.assert_allclose(after, before) 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 48fbafe26..6cf811474 100644 --- a/tests/unit/easydynamics/sample_model/components/test_expression_component.py +++ b/tests/unit/easydynamics/sample_model/components/test_expression_component.py @@ -5,6 +5,7 @@ import numpy as np import pytest +import scipp as sc from easyscience.variable import Parameter from easydynamics.sample_model import ExpressionComponent @@ -16,14 +17,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) @@ -131,10 +132,26 @@ def test_expression_is_read_only(self, expr: ExpressionComponent): with pytest.raises(AttributeError, match='cannot be changed'): expr.expression = 'x' - def test_convert_unit_not_implemented(self, expr: ExpressionComponent): + def test_convert_x_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): + # WHEN THEN EXPECT + with pytest.raises(NotImplementedError, match='not implemented'): + expr.convert_y_unit('1/meV') + + def test_evaluate_scipp_output(self, expr: ExpressionComponent): + # WHEN + x = np.linspace(-2, 2, 30) + # THEN + result = expr.evaluate(x, output='scipp') + # EXPECT + assert isinstance(result, sc.Variable) + assert result.unit == sc.Unit('dimensionless') + assert len(result.values) == 30 + np.testing.assert_allclose(result.values, expr.evaluate(x, output='numpy')) def test_missing_parameter_defaults(self): # WHEN THEN @@ -166,20 +183,19 @@ def test_repr(self, expr: ExpressionComponent): def test_evaluate_scalar_input(self, expr: ExpressionComponent): # WHEN x = 0.5 - result = expr.evaluate(x) - # THEN + result = expr.evaluate(x) + # EXPECT expected = 2.0 * np.exp(-((x - 0.5) ** 2) / (2 * 0.6**2)) assert np.isclose(result, expected) def test_reserved_name_not_parameter(self): # WHEN expr = ExpressionComponent('x + A', parameters={'A': 2.0}) - # THEN params = expr.get_all_variables() names = {p.name for p in params} - + # EXPECT assert 'A' in names assert 'x' not in names # x is reserved @@ -191,7 +207,8 @@ 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.y_unit == expr.y_unit assert expr_copy.display_name == expr.display_name assert expr_copy.A.value == pytest.approx(expr.A.value) @@ -209,3 +226,12 @@ def test_erf(self): # EXPECT expected = np.array([-0.84270079, 0.0, 0.84270079]) # erf(-1), erf(0), erf(1) np.testing.assert_allclose(result, expected, rtol=1e-5) + + +def test_evaluate_warns_when_input_unit_differs_from_x_unit(): + # GIVEN an ExpressionComponent with x_unit meV + expr = ExpressionComponent('A * x', parameters={'A': 2.0}, x_unit='meV') + x = sc.array(dims=['x'], values=[1.0, 2.0], unit='ueV') + # WHEN evaluating with x in a different unit THEN EXPECT a warning + with pytest.warns(UserWarning, match=r'cannot auto-convert parameters'): + expr.evaluate(x) diff --git a/tests/unit/easydynamics/sample_model/components/test_gaussian.py b/tests/unit/easydynamics/sample_model/components/test_gaussian.py index a370e74fb..3ef9b01b7 100644 --- a/tests/unit/easydynamics/sample_model/components/test_gaussian.py +++ b/tests/unit/easydynamics/sample_model/components/test_gaussian.py @@ -5,7 +5,9 @@ import numpy as np import pytest +import scipp as sc from easyscience.variable import Parameter +from scipp import UnitError from scipy.integrate import simpson from easydynamics.sample_model import Gaussian @@ -20,7 +22,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 +34,8 @@ 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.y_unit == 'dimensionless' assert gaussian.center.fixed is True def test_initialization(self, gaussian: Gaussian): @@ -41,33 +44,38 @@ 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}, - 'unit must be None', + {'area': 2.0, 'center': 0.5, 'width': 0.6, 'x_unit': 123}, + 'unit must be None, a string', + ), + ( + {'area': 2.0, 'center': 0.5, 'width': 0.6, 'x_unit': 'meV', 'y_unit': 123}, + 'unit must be None, a string', ), ], ids=[ 'invalid area', 'invalid center', 'invalid width', - 'invalid unit', + 'invalid x_unit', + 'invalid y_unit', ], ) def test_input_type_validation_raises(self, kwargs, expected_message): @@ -84,7 +92,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 +103,7 @@ def test_negative_area_warns(self): area=-2.0, center=0.5, width=0.6, - unit='meV', + x_unit='meV', ) @pytest.mark.parametrize( @@ -109,11 +117,12 @@ def test_negative_area_warns(self): def test_property_setters( self, gaussian: Gaussian, prop, valid_value, invalid_value, invalid_message ): - # set valid + # WHEN: set a valid value setattr(gaussian, prop, valid_value) + # THEN EXPECT assert getattr(gaussian, prop).value == valid_value - # invalid + # WHEN: set an invalid value — THEN EXPECT with pytest.raises(TypeError, match=invalid_message): setattr(gaussian, prop, invalid_value) @@ -177,12 +186,12 @@ def test_area_matches_parameter(self, gaussian: Gaussian): numerical_area = simpson(y, x) assert np.isclose(numerical_area, gaussian.area.value, rtol=1e-3) - def test_convert_unit(self, gaussian: Gaussian): + def test_convert_x_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 +225,91 @@ 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_custom(self): + # WHEN THEN + 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): + # WHEN THEN EXPECT + with pytest.raises(AttributeError): + gaussian.y_unit = '1/meV' + + def test_convert_y_unit(self): + # WHEN: x_unit='meV', y_unit='1/meV' → area_unit ≈ dimensionless + gaussian = Gaussian(area=1.0, x_unit='meV', y_unit='1/meV') + + # THEN: 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): + # WHEN THEN EXPECT + with pytest.raises(TypeError): + gaussian.convert_y_unit(123) + + def test_evaluate_scipp_output(self, gaussian: Gaussian): + # WHEN + x = np.linspace(-5, 5, 100) + + # THEN + result = gaussian.evaluate(x, output='scipp') + + # EXPECT + assert isinstance(result, sc.Variable) + assert result.unit == sc.Unit('dimensionless') + assert len(result.values) == 100 + np.testing.assert_allclose(result.values, gaussian.evaluate(x, output='numpy')) + + def test_evaluate_scipp_output_with_y_unit(self): + 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): + # WHEN THEN EXPECT + 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): + # WHEN THEN + with pytest.raises(UnitError): + gaussian.convert_x_unit('m') + # EXPECT: state rolled back + 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): + # WHEN + gaussian = Gaussian(area=1.0, center=0.0, width=0.5, x_unit='meV') + # THEN + with pytest.raises(UnitError): + gaussian.convert_y_unit('K') + # EXPECT: state rolled back + 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 1aefee6a9..97e02aad9 100644 --- a/tests/unit/easydynamics/sample_model/components/test_lorentzian.py +++ b/tests/unit/easydynamics/sample_model/components/test_lorentzian.py @@ -5,7 +5,9 @@ import numpy as np import pytest +import scipp as sc from easyscience.variable import Parameter +from scipp import UnitError from scipy.integrate import simpson from easydynamics.sample_model import Lorentzian @@ -20,7 +22,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 +34,8 @@ 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.y_unit == 'dimensionless' assert lorentzian.center.fixed is True def test_initialization(self, lorentzian: Lorentzian): @@ -41,26 +44,30 @@ 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'}, - 'center must be None', + {'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}, - 'unit must be None', + {'area': 2.0, 'center': 0.5, 'width': 0.6, 'x_unit': 123}, + 'unit must be None, a string', + ), + ( + {'area': 2.0, 'center': 0.5, 'width': 0.6, 'x_unit': 'meV', 'y_unit': 123}, + 'unit must be None, a string', ), ], ) @@ -78,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): @@ -89,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( @@ -103,11 +110,12 @@ def test_negative_area_warns(self): def test_property_setters( self, lorentzian: Lorentzian, prop, valid_value, invalid_value, invalid_message ): - # set valid + # WHEN: set a valid value setattr(lorentzian, prop, valid_value) + # THEN EXPECT assert getattr(lorentzian, prop).value == valid_value - # invalid + # WHEN: set an invalid value — THEN EXPECT with pytest.raises(TypeError, match=invalid_message): setattr(lorentzian, prop, invalid_value) @@ -166,12 +174,12 @@ def test_area_matches_parameter(self, lorentzian: Lorentzian): # EXPECT assert numerical_area == pytest.approx(lorentzian.area.value, rel=2e-3) - def test_convert_unit(self, lorentzian: Lorentzian): + def test_convert_x_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 +201,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 +209,79 @@ 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_custom(self): + # WHEN THEN + lor = Lorentzian(area=1.0, x_unit='meV', y_unit='1/meV') + # EXPECT + assert lor.y_unit == '1/meV' + + def test_y_unit_setter_raises(self, lorentzian: Lorentzian): + # WHEN THEN EXPECT + with pytest.raises(AttributeError): + lorentzian.y_unit = '1/meV' + + def test_convert_y_unit(self): + # WHEN: x_unit='meV', y_unit='1/meV' → area_unit='dimensionless' + lor = Lorentzian(area=1.0, x_unit='meV', y_unit='1/meV') + # THEN: 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): + # WHEN THEN EXPECT + with pytest.raises(TypeError): + lorentzian.convert_y_unit(123) + + def test_evaluate_scipp_output(self, lorentzian: Lorentzian): + # WHEN + x = np.linspace(-5, 5, 50) + # THEN + result = lorentzian.evaluate(x, output='scipp') + # EXPECT + assert isinstance(result, sc.Variable) + assert result.unit == sc.Unit('dimensionless') + assert len(result.values) == 50 + np.testing.assert_allclose(result.values, lorentzian.evaluate(x, output='numpy')) + + def test_evaluate_scipp_output_with_y_unit(self): + # WHEN + lor = Lorentzian(area=1.0, x_unit='meV', y_unit='1/meV') + x = np.linspace(-5, 5, 50) + # THEN + result = lor.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, lorentzian: Lorentzian): + # WHEN THEN EXPECT + 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): + # WHEN THEN + with pytest.raises(UnitError): + lorentzian.convert_x_unit('m') + # EXPECT: state rolled back + 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): + # WHEN + lor = Lorentzian(area=1.0, center=0.0, width=0.5, x_unit='meV') + # THEN + with pytest.raises(UnitError): + lor.convert_y_unit('K') + # EXPECT: state rolled back + 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 e30f4bdd1..d7a8487ca 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) @@ -86,6 +86,18 @@ def test_create_center_parameter_invalid_type_raises(self, dummy_model): with pytest.raises(TypeError, match='center must be None or a number'): dummy_model._create_center_parameter('invalid', 'TestModel', fix_if_none=False) + @pytest.mark.parametrize('center_input', [None, 0.0, 1e-12]) + def test_create_center_parameter_enforce_minimum_clamps(self, dummy_model, center_input): + # WHEN: a center below the DHO minimum (regression: setting min before clamping the + # value used to raise ValueError from Parameter.min) + center_param = dummy_model._create_center_parameter( + center_input, 'TestModel', fix_if_none=False, enforce_minimum_center=True + ) + + # EXPECT: value clamped up to the minimum, and the bound enforced + assert center_param.value == pytest.approx(1e-10) + assert center_param.min == pytest.approx(1e-10) + @pytest.mark.parametrize( 'non_finite_center', [ @@ -104,7 +116,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 514ced73b..40e05e03a 100644 --- a/tests/unit/easydynamics/sample_model/components/test_model_component.py +++ b/tests/unit/easydynamics/sample_model/components/test_model_component.py @@ -5,7 +5,9 @@ import pytest import scipp as sc from easyscience.variable import Parameter +from scipp import UnitError +from easydynamics.sample_model.components.gaussian import Gaussian from easydynamics.sample_model.components.model_component import ModelComponent @@ -15,13 +17,13 @@ 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] - def evaluate(self, x): - return np.zeros_like(x) + def _evaluate_values(self, x_vals, _eval_unit): + return np.zeros_like(x_vals) class TestModelComponent: @@ -31,23 +33,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 @@ -58,6 +60,23 @@ def test_free_and_fix_all_parameters(self, dummy): dummy.fix_all_parameters() assert all(p.fixed for p in dummy.get_all_parameters()) + def test_get_fit_targets(self, dummy: DummyComponent): + # WHEN + targets = dummy.get_fit_targets() + + # EXPECT: a single 'value' prediction wrapping evaluate, stamped with the component's + # units and no default dataset key + assert len(targets) == 1 + target = targets[0] + assert target.name == 'value' + assert target.dataset_key is None + assert target.label == 'Dummy' + assert target.x_unit == dummy.x_unit + assert target.y_unit == dummy.y_unit + np.testing.assert_allclose( + target.function(np.array([1.0, 2.0])), dummy.evaluate(np.array([1.0, 2.0])) + ) + def test_repr(self, dummy): # WHEN THEN EXPECT repr_str = repr(dummy) @@ -92,8 +111,11 @@ 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) + # WHEN THEN + result = dummy._prepare_x_for_evaluate(x_input) + x_prepared, _detected_unit, _dim = result + # EXPECT assert isinstance(x_prepared, np.ndarray) assert x_prepared.shape == expected_array.shape np.testing.assert_array_equal(x_prepared, expected_array) @@ -137,26 +159,126 @@ 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: 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 + + # THEN + 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 + + # THEN + 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 + + # THEN + result = dummy._resolve_param_value(dummy.area, 'eV') + # EXPECT: converted value (1.0 meV → 0.001 eV) + assert result == pytest.approx(0.001) + # 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): + # WHEN: Gaussian in meV and a physically equivalent Gaussian in eV + 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]) + + # THEN: 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_invalid_output_raises(self, dummy: DummyComponent): + # WHEN THEN EXPECT + with pytest.raises(ValueError, match="output must be 'numpy' or 'scipp'"): + dummy.evaluate(np.linspace(-1, 1, 5), output='Scipp') + + def test_eval_area_unit(self, dummy: DummyComponent): + # WHEN THEN EXPECT: y_unit is dimensionless, so the area unit equals the eval unit + assert sc.Unit(dummy._eval_area_unit('meV')) == sc.Unit('meV') + + def test_eval_area_unit_none_eval_unit(self, dummy: DummyComponent): + # WHEN THEN EXPECT: without an eval unit there is no area unit + assert dummy._eval_area_unit(None) is None + + def test_eval_area_unit_none_y_unit(self, dummy: DummyComponent): + # WHEN + dummy._y_unit = None + + # THEN EXPECT + assert dummy._eval_area_unit('meV') is None + + def test_evaluate_preserves_dataarray_coord_key_as_dim(self): + # WHEN: 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. + 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}) + # THEN: 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 2ba3f5525..24b382e78 100644 --- a/tests/unit/easydynamics/sample_model/components/test_polynomial.py +++ b/tests/unit/easydynamics/sample_model/components/test_polynomial.py @@ -5,6 +5,7 @@ import numpy as np import pytest +import scipp as sc from easyscience.variable import Parameter from scipp import UnitError @@ -27,7 +28,8 @@ 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' + assert polynomial.y_unit == 'dimensionless' def test_initialization(self, polynomial: Polynomial): # WHEN THEN EXPECT @@ -48,7 +50,11 @@ 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 ', + ), + ( + {'coefficients': [1.0, -2.0, 3.0], 'x_unit': 'meV', 'y_unit': 123}, 'unit must be ', ), ( @@ -59,6 +65,7 @@ def test_initialization(self, polynomial: Polynomial): ], ) def test_input_type_validation_raises(self, kwargs, expected_message): + # WHEN THEN EXPECT with pytest.raises(TypeError, match=expected_message): Polynomial(display_name='TestPolynomial', **kwargs) @@ -118,13 +125,12 @@ def test_set_coefficients(self, polynomial: Polynomial, values): assert np.isclose(polynomial.coefficients[i].value, expected) def test_set_coefficients_wrong_length_raises(self, polynomial: Polynomial): - """Ensure that setting coefficients with mismatched length - raises an error.""" + # WHEN THEN EXPECT with pytest.raises(ValueError, match='Number of coefficients'): polynomial.coefficients = [1.0, 2.0] # shorter list def test_set_coefficients_invalid_type_raises(self, polynomial: Polynomial): - """Ensure that invalid coefficient types raise a TypeError.""" + # WHEN THEN EXPECT with pytest.raises(TypeError): polynomial.coefficients = [1.0, 'invalid', 3.0] @@ -137,8 +143,11 @@ def test_set_coefficients_invalid_type_raises(self, polynomial: Polynomial): ], ) def test_set_coefficients_raises(self, invalid_coeffs, expected_message): + # WHEN + # THEN + polynomial = Polynomial(display_name='TestPolynomial', coefficients=[1.0, -2.0, 3.0]) with pytest.raises(TypeError, match=expected_message): - polynomial = Polynomial(display_name='TestPolynomial', coefficients=[1.0, -2.0, 3.0]) + # EXPECT polynomial.coefficients = invalid_coeffs def test_coefficient_values(self, polynomial: Polynomial): @@ -161,20 +170,20 @@ def test_get_all_parameters(self, polynomial: Polynomial): actual_names = {param.name for param in params} assert actual_names == expected_names - def test_convert_unit(self, polynomial: Polynomial): + def test_convert_x_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): + def test_convert_x_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 +201,109 @@ def test_copy(self, polynomial: Polynomial): assert copied_coeff.value == original_coeff.value assert copied_coeff.fixed == original_coeff.fixed + def test_y_unit_custom(self): + # WHEN THEN + p = Polynomial(coefficients=[1.0, 2.0], x_unit='meV', y_unit='1/meV') + # EXPECT + assert p.y_unit == '1/meV' + + def test_y_unit_setter_raises(self, polynomial: Polynomial): + # WHEN THEN EXPECT + with pytest.raises(AttributeError): + polynomial.y_unit = '1/meV' + + def test_convert_y_unit_scales_all_coefficients(self): + # WHEN: 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] + + # THEN + p.convert_y_unit('eV^-1') + + # EXPECT: both coefficients rescaled by 1000 (1 meV^-1 = 1000 eV^-1) + assert p.y_unit == 'eV^-1' + assert np.isclose(p.coefficients[0].value, 3000.0) + assert np.isclose(p.coefficients[1].value, 1000.0) + assert np.isclose(p.evaluate(x)[0], val_before * 1000.0) + + def test_evaluate_scipp_output(self): + # WHEN + p = Polynomial(coefficients=[1.0, 2.0], x_unit='meV', suppress_warnings=True) + x = np.linspace(-3, 3, 40) + # THEN + result = p.evaluate(x, output='scipp') + # EXPECT + assert isinstance(result, sc.Variable) + assert result.unit == sc.Unit('dimensionless') + assert len(result.values) == 40 + np.testing.assert_allclose(result.values, p.evaluate(x, output='numpy')) + + def test_evaluate_scipp_output_with_y_unit(self): + # WHEN + p = Polynomial( + coefficients=[1.0, 2.0], + x_unit='meV', + y_unit='1/meV', + suppress_warnings=True, + ) + x = np.linspace(-3, 3, 40) + # THEN + result = p.evaluate(x, output='scipp') + # EXPECT + assert isinstance(result, sc.Variable) + assert result.unit == sc.Unit('1/meV') + 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): + # WHEN: polynomial with x_unit='meV', coefficients [1.0, 1.0] → f(x) = 1 + x + p = Polynomial(coefficients=[1.0, 1.0], x_unit='meV') + # THEN: evaluate with x in eV (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) + # EXPECT: 0.001 eV = 1 meV → f(1)=2, 0.002 eV = 2 meV → f(2)=3; state not mutated + np.testing.assert_allclose(result, [2.0, 3.0], rtol=1e-5) + assert p.x_unit == 'meV' + + def test_evaluate_with_equivalent_unit_spelling_does_not_rescale(self): + # WHEN: 'ueV' and scipp's canonical micro-sign spelling are the same unit + # (regression: a string comparison used to treat them as different and rescale) + p = Polynomial(coefficients=[1.0, 1.0], x_unit='ueV', suppress_warnings=True) + x = sc.array(dims=['x'], values=np.array([1.0, 2.0]), unit='\u00b5eV') + + # THEN + result = p.evaluate(x) + + # EXPECT: f(x) = 1 + x with the raw coefficient values, no rescaling applied + np.testing.assert_allclose(result, [2.0, 3.0]) + + def test_convert_y_unit_invalid_type_raises(self, polynomial: Polynomial): + # WHEN THEN EXPECT + with pytest.raises(UnitError, match='new_y_unit must be a string or a scipp unit'): + polynomial.convert_y_unit(123) + + def test_convert_y_unit_rollback_on_failure(self): + # WHEN + p = Polynomial(coefficients=[1.0, 2.0], x_unit='meV') + # THEN + with pytest.raises(UnitError): + p.convert_y_unit('K') + # EXPECT: state rolled back + assert p.y_unit == 'dimensionless' + assert np.isclose(p.coefficients[0].value, 1.0) + assert np.isclose(p.coefficients[1].value, 2.0) + + +def test_suppress_warnings_setter_raises_for_non_bool(): + # GIVEN a Polynomial + p = Polynomial(coefficients=[1.0, 2.0], x_unit='meV') + # WHEN THEN EXPECT + with pytest.raises(TypeError, match=r'Suppress_warnings must be True or False'): + p.suppress_warnings = 'yes' diff --git a/tests/unit/easydynamics/sample_model/components/test_voigt.py b/tests/unit/easydynamics/sample_model/components/test_voigt.py index 8b8d44cf3..42eb5099c 100644 --- a/tests/unit/easydynamics/sample_model/components/test_voigt.py +++ b/tests/unit/easydynamics/sample_model/components/test_voigt.py @@ -5,7 +5,9 @@ import numpy as np import pytest +import scipp as sc from easyscience.variable import Parameter +from scipp import UnitError from scipy.integrate import simpson from scipy.special import voigt_profile @@ -22,7 +24,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 +37,8 @@ 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.y_unit == 'dimensionless' assert voigt.center.fixed is True def test_initialization(self, voigt: Voigt): @@ -45,7 +48,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 +59,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 +69,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 +79,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 +89,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,13 +99,25 @@ 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,', + 'unit must be None, a string', + ), + ( + { + 'area': 2.0, + 'center': 0.5, + 'gaussian_width': 0.6, + 'lorentzian_width': 0.7, + 'x_unit': 'meV', + 'y_unit': 123, + }, + 'unit must be None, a string', ), ], ) def test_input_type_validation_raises(self, kwargs, expected_message): + # WHEN THEN EXPECT with pytest.raises(TypeError, match=expected_message): Voigt(display_name='TestVoigt', **kwargs) @@ -117,7 +132,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 +147,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 +159,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( @@ -164,11 +179,12 @@ def test_negative_area_warns(self): def test_property_setters( self, voigt: Voigt, prop, valid_value, invalid_value, invalid_message ): - # set valid + # WHEN: set a valid value setattr(voigt, prop, valid_value) + # THEN EXPECT assert getattr(voigt, prop).value == valid_value - # invalid + # WHEN: set an invalid value — THEN EXPECT with pytest.raises(TypeError, match=invalid_message): setattr(voigt, prop, invalid_value) @@ -214,19 +230,19 @@ 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 assert test_voigt.center.value == pytest.approx(0.0) assert test_voigt.center.fixed is True - def test_convert_unit(self, voigt: Voigt): + def test_convert_x_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 +301,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 +309,102 @@ 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_custom(self): + # WHEN THEN + voigt = Voigt( + area=1.0, + center=0.0, + gaussian_width=0.5, + lorentzian_width=0.3, + x_unit='meV', + y_unit='1/meV', + ) + # EXPECT + assert voigt.y_unit == '1/meV' + + def test_y_unit_setter_raises(self, voigt: Voigt): + # WHEN THEN EXPECT + with pytest.raises(AttributeError): + voigt.y_unit = '1/meV' + + def test_convert_y_unit(self): + # WHEN: x_unit='meV', y_unit='1/meV' → area_unit='dimensionless' + voigt = Voigt( + area=1.0, + center=0.0, + gaussian_width=0.5, + lorentzian_width=0.3, + x_unit='meV', + y_unit='1/meV', + ) + # THEN: convert y_unit to '1/eV' (same dimension, different scale) + voigt.convert_y_unit('1/eV') + # EXPECT: y_unit updated and area value rescaled (1e3 factor) + assert voigt.y_unit == '1/eV' + assert voigt.area.value == pytest.approx(1e3) + + def test_convert_y_unit_invalid_type_raises(self, voigt: Voigt): + # WHEN THEN EXPECT + with pytest.raises(TypeError): + voigt.convert_y_unit(123) + + def test_evaluate_scipp_output(self, voigt: Voigt): + # WHEN + x = np.linspace(-5, 5, 50) + # THEN + result = voigt.evaluate(x, output='scipp') + # EXPECT + assert isinstance(result, sc.Variable) + assert result.unit == sc.Unit('dimensionless') + assert len(result.values) == 50 + np.testing.assert_allclose(result.values, voigt.evaluate(x, output='numpy')) + + def test_evaluate_scipp_output_with_y_unit(self): + # WHEN + voigt = Voigt( + area=1.0, + center=0.0, + gaussian_width=0.5, + lorentzian_width=0.3, + x_unit='meV', + y_unit='1/meV', + ) + x = np.linspace(-5, 5, 50) + # THEN + result = voigt.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, voigt: Voigt): + # WHEN THEN EXPECT + 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): + # WHEN THEN + with pytest.raises(UnitError): + voigt.convert_x_unit('m') + # EXPECT: state rolled back + 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): + # WHEN + voigt = Voigt(area=1.0, center=0.0, gaussian_width=0.5, lorentzian_width=0.3, x_unit='meV') + # THEN + with pytest.raises(UnitError): + voigt.convert_y_unit('K') + # EXPECT: state rolled back + assert voigt.y_unit == 'dimensionless' + assert voigt.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 7941d4068..4b2137540 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,16 +25,60 @@ 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) + def test_convert_x_unit_rescales_widths(self): + # WHEN + model = BrownianTranslationalDiffusion(diffusion_coefficient=2.4e-9, Q=np.array([1.0])) + width_mev = model.calculate_width()[0] + + # THEN + model.convert_x_unit('ueV') + + # EXPECT: calculated width and the component width follow the new unit + assert model.calculate_width()[0] == pytest.approx(width_mev * 1000) + collection = model.get_component_collections(0) + assert sc.Unit(str(collection[0].width.unit)) == sc.Unit('ueV') + assert collection[0].width.value == pytest.approx(width_mev * 1000) + assert sc.Unit(str(model.scale.unit)) == sc.Unit('ueV') + + def test_convert_x_unit_converts_collections_in_place(self): + # WHEN + model = BrownianTranslationalDiffusion(diffusion_coefficient=2.4e-9, Q=np.array([1.0])) + collection_before = model.get_component_collections(0) + + # THEN + model.convert_x_unit('ueV') + + # EXPECT: conversion does not regenerate the collections (regression: rebuilding + # replaced the component objects, breaking external references) + assert model.get_component_collections(0) is collection_before + + def test_convert_x_unit_persists_after_dependency_update(self): + # WHEN + model = BrownianTranslationalDiffusion(diffusion_coefficient=2.4e-9, Q=np.array([1.0])) + width = model.get_component_collections(0)[0].width + model.convert_x_unit('ueV') + width_uev = width.value + + # THEN: trigger a dependency-graph recompute + model.diffusion_coefficient = 4.8e-9 + + # EXPECT: the dependent width stays in the new unit (regression: a plain convert_unit + # was reverted to the old desired unit by the next graph update) + assert sc.Unit(str(width.unit)) == sc.Unit('ueV') + assert width.value == pytest.approx(2 * width_uev) + @pytest.mark.parametrize( 'kwargs,expected_exception, expected_message', [ ( { - 'unit': 123, + 'x_unit': 123, 'scale': 1.0, 'diffusion_coefficient': 1.0, }, @@ -43,7 +87,16 @@ def test_init_default(self, brownian_diffusion_model): ), ( { - 'unit': 'meV', + 'y_unit': 123, + 'scale': 1.0, + 'diffusion_coefficient': 1.0, + }, + TypeError, + None, + ), + ( + { + 'x_unit': 'meV', 'scale': 'invalid', 'diffusion_coefficient': 1.0, }, @@ -52,7 +105,7 @@ def test_init_default(self, brownian_diffusion_model): ), ( { - 'unit': 'meV', + 'x_unit': 'meV', 'scale': -123.4, 'diffusion_coefficient': 1.0, }, @@ -61,7 +114,7 @@ def test_init_default(self, brownian_diffusion_model): ), ( { - 'unit': 'meV', + 'x_unit': 'meV', 'scale': 1.0, 'diffusion_coefficient': 'invalid', }, @@ -70,7 +123,7 @@ def test_init_default(self, brownian_diffusion_model): ), ( { - 'unit': 'meV', + 'x_unit': 'meV', 'scale': 1.0, 'diffusion_coefficient': -123.4, }, @@ -78,6 +131,14 @@ def test_init_default(self, brownian_diffusion_model): 'diffusion_coefficient must be non-negative', ), ], + ids=[ + 'invalid_x_unit', + 'invalid_y_unit', + 'invalid_scale_type', + 'invalid_scale_negative', + 'invalid_diffusion_coefficient_type', + 'invalid_diffusion_coefficient_negative', + ], ) def test_input_type_validation_raises(self, kwargs, expected_exception, expected_message): with pytest.raises(expected_exception, match=expected_message): @@ -123,6 +184,17 @@ def test_calculate_width(self, brownian_diffusion_model): expected_widths = 1.0 * unit_conversion_factor.value * (Q_values**2) np.testing.assert_allclose(widths, expected_widths, rtol=1e-5) + def test_calculate_width_scipp_Q_converts_unit(self, brownian_diffusion_model): + # WHEN: the same Q expressed in 1/angstrom (numpy, assumed) and in 1/nm (scipp) + Q_values = np.array([0.1, 0.2, 0.3]) + Q_scipp = sc.Variable(dims=['Q'], values=Q_values * 10, unit='1/nm') + + # THEN EXPECT: scipp input is converted to 1/angstrom before the width calculation + np.testing.assert_allclose( + brownian_diffusion_model.calculate_width(Q_scipp), + brownian_diffusion_model.calculate_width(Q_values), + ) + def test_calculate_EISF(self, brownian_diffusion_model): # WHEN Q_values = np.array([0.1, 0.2, 0.3]) # Example Q values in Å^-1 @@ -180,9 +252,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 @@ -213,6 +287,11 @@ def test_write_area_dependency_expression_raises(self, brownian_diffusion_model) with pytest.raises(TypeError, match='QISF must be a float'): brownian_diffusion_model._write_area_dependency_expression('invalid') + def test_y_unit_setter_raises(self, brownian_diffusion_model): + # WHEN THEN EXPECT + with pytest.raises(AttributeError, match=r'read-only'): + brownian_diffusion_model.y_unit = '1/meV' + def test_repr(self, brownian_diffusion_model): # WHEN THEN repr_str = repr(brownian_diffusion_model) @@ -221,3 +300,5 @@ def test_repr(self, brownian_diffusion_model): assert 'BrownianTranslationalDiffusion' in repr_str assert 'diffusion_coefficient' in repr_str assert 'scale=' in repr_str + # Regression: a stray ')' used to mangle this into 'x_unit=meV), y_unit=...' + assert 'x_unit=meV, y_unit=dimensionless' in repr_str 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 5441713cc..17074eb95 100644 --- a/tests/unit/easydynamics/sample_model/diffusion_model/test_delta_lorentz.py +++ b/tests/unit/easydynamics/sample_model/diffusion_model/test_delta_lorentz.py @@ -3,6 +3,7 @@ import numpy as np import pytest +import scipp as sc from easyscience.variable import Parameter from easydynamics.sample_model.components.delta_function import DeltaFunction @@ -15,6 +16,57 @@ class TestDeltaLorentz: def delta_lorentz_model(self): return DeltaLorentz() + def test_mean_u_squared_unit_conversion_leaves_physics_unchanged(self): + # WHEN: a model with a non-zero Debye-Waller factor + model = DeltaLorentz(A_0=0.5, mean_u_squared=1.0, lorentzian_width=0.1, Q=np.array([1.0])) + eisf_before = model.calculate_EISF() + qisf_before = model.calculate_QISF() + delta_area_before = model.get_component_collections(0)[1].area.value + + # THEN: convert mean_u_squared to nm**2 (same physical value, different number) + model.mean_u_squared.convert_unit('nm**2') + + # EXPECT: EISF, QISF, and the dependent delta area are unchanged + np.testing.assert_allclose(model.calculate_EISF(), eisf_before) + np.testing.assert_allclose(model.calculate_QISF(), qisf_before) + assert model.get_component_collections(0)[1].area.value == pytest.approx(delta_area_before) + + def test_convert_x_unit_converts_lorentzian_width(self): + # WHEN + model = DeltaLorentz(lorentzian_width=0.1, Q=np.array([1.0])) + + # THEN + model.convert_x_unit('ueV') + + # EXPECT: the width template is rescaled and the regenerated component follows + assert sc.Unit(str(model.lorentzian_width.unit)) == sc.Unit('ueV') + assert model.lorentzian_width.value == pytest.approx(100.0) + collection = model.get_component_collections(0) + assert sc.Unit(str(collection[0].width.unit)) == sc.Unit('ueV') + assert collection[0].width.value == pytest.approx(100.0) + + def test_convert_x_unit_with_q_varying_width_preserves_state(self): + # WHEN: a model with per-Q widths, one of which has been changed from the template + model = DeltaLorentz( + lorentzian_width=0.1, + Q=np.array([1.0, 2.0]), + allow_Q_variation={'lorentzian_width': True}, + ) + model._lorentzian_width_list[0].value = 0.2 + collection_before = model.get_component_collections(0) + + # THEN + model.convert_x_unit('ueV') + + # EXPECT: conversion happens in place — per-Q values are converted, not reset to the + # template, and the collections are not regenerated (regression: conversion used to + # rebuild the collections, discarding per-Q state) + assert model.get_component_collections(0) is collection_before + assert model._lorentzian_width_list[0].value == pytest.approx(200.0) + assert model._lorentzian_width_list[1].value == pytest.approx(100.0) + for width in model._lorentzian_width_list: + assert sc.Unit(str(width.unit)) == sc.Unit('ueV') + @pytest.fixture def delta_lorentz_model_with_Q(self): Q = np.linspace(0.5, 2, 7) @@ -38,16 +90,22 @@ 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.y_unit == 'dimensionless' assert delta_lorentz_model.scale.value == pytest.approx(1.0) assert delta_lorentz_model.mean_u_squared.value == pytest.approx(0.0) assert delta_lorentz_model.A_0.value == pytest.approx(1.0) assert delta_lorentz_model.lorentzian_width.value == pytest.approx(1.0) + def test_y_unit_setter_raises(self, delta_lorentz_model): + # WHEN THEN EXPECT + with pytest.raises(AttributeError, match=r'read-only'): + delta_lorentz_model.y_unit = '1/meV' + 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) @@ -459,7 +517,8 @@ def test_create_component_collections_with_Q_variation(self, delta_lorentz_model assert collection[0].width.independent is True assert collection[0].area.independent is False assert ( - 'scale * exp(-mean_u_squared.value *' in collection[0].area.dependency_expression + 'scale * exp(-mean_u_squared_ratio.value *' + in collection[0].area.dependency_expression ) assert 'A_1' in collection[0].area.dependency_expression @@ -467,7 +526,8 @@ def test_create_component_collections_with_Q_variation(self, delta_lorentz_model assert isinstance(collection[1], DeltaFunction) assert collection[1].area.independent is False assert ( - 'scale * exp(-mean_u_squared.value *' in collection[1].area.dependency_expression + 'scale * exp(-mean_u_squared_ratio.value *' + in collection[1].area.dependency_expression ) assert 'A_0' in collection[1].area.dependency_expression @@ -491,7 +551,8 @@ def test_create_component_collections_with_no_Q_variation( assert collection[0].width.dependency_expression == 'lorentzian_width' assert collection[0].area.independent is False assert ( - 'scale * exp(-mean_u_squared.value *' in collection[0].area.dependency_expression + 'scale * exp(-mean_u_squared_ratio.value *' + in collection[0].area.dependency_expression ) assert 'A_1' in collection[0].area.dependency_expression @@ -499,7 +560,8 @@ def test_create_component_collections_with_no_Q_variation( assert isinstance(collection[1], DeltaFunction) assert collection[1].area.independent is False assert ( - 'scale * exp(-mean_u_squared.value *' in collection[1].area.dependency_expression + 'scale * exp(-mean_u_squared_ratio.value *' + in collection[1].area.dependency_expression ) assert 'A_0' in collection[1].area.dependency_expression @@ -858,3 +920,73 @@ 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: a stray ')' used to mangle this into 'x_unit=meV), y_unit=...' + assert 'x_unit=meV, y_unit=dimensionless' in repr_str + + # ───── Regression tests ───── + + def test_calculate_width_with_Q_subset(self, delta_lorentz_model_with_Q): + # WHEN: Q-varying widths with distinguishable per-Q values + model = delta_lorentz_model_with_Q + for i, width in enumerate(model._lorentzian_width_list): + width.value = 1.0 + i + + # THEN: request a subset of the stored Q values (as ParameterAnalysis does when it + # drops rows with missing data) + subset = model.Q.values[[1, 3, 5]] + widths = model.calculate_width(subset) + + # EXPECT: one width per requested Q, matching the stored per-Q parameters + np.testing.assert_allclose(widths, [2.0, 4.0, 6.0]) + + def test_calculate_EISF_with_Q_subset(self, delta_lorentz_model_with_Q): + # WHEN: Q-varying A_0 with distinguishable per-Q values + model = delta_lorentz_model_with_Q + for i, A_0 in enumerate(model._A_0_list): + A_0.value = (i + 1) / 10 + + # THEN + subset = model.Q.values[[0, 6]] + eisf = model.calculate_EISF(subset) + + # EXPECT: mean_u_squared is 0, so EISF equals the per-Q A_0 values + np.testing.assert_allclose(eisf, [0.1, 0.7]) + + def test_calculate_width_with_unknown_Q_raises(self, delta_lorentz_model_with_Q): + # WHEN THEN EXPECT: a Q value not stored in the model has no per-Q width + with pytest.raises(ValueError, match='do not match the Q values stored'): + delta_lorentz_model_with_Q.calculate_width(np.array([10.0])) + + def test_calculate_width_raises_after_clear_Q_when_allow_Q_variation( + self, delta_lorentz_model_with_Q + ): + # WHEN: 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 + + # THEN: 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='Q must be provided'): + delta_lorentz_model_with_Q.calculate_width() + + +def test_get_fit_targets_includes_delta_area(): + # GIVEN a DeltaLorentz model + model = DeltaLorentz(delta_name='Delta function', lorentzian_name='Lorentzian') + # WHEN + targets = model.get_fit_targets() + # EXPECT base area/width plus the delta_area prediction + assert [t.name for t in targets] == ['area', 'width', 'delta_area'] + delta_area = next(t for t in targets if t.name == 'delta_area') + assert delta_area.dataset_key == 'Delta function area' + + +def test_calculate_width_raises_when_Q_variation_enabled_but_Q_unset(): + # GIVEN Q-variation enabled for the width but Q never set on the model (empty per-Q list) + model = DeltaLorentz(lorentzian_width=0.1, allow_Q_variation={'lorentzian_width': True}) + # WHEN a Q is requested THEN EXPECT the empty per-Q width list to be reported + with pytest.raises(ValueError, match=r'Lorentzian width Q-variation list is empty'): + model.calculate_width(np.array([1.0])) 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 c92c5fb09..d323b0ba6 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 @@ -5,6 +5,7 @@ import pytest import scipp as sc from easyscience.variable.parameter import Parameter +from scipp import UnitError from easydynamics.sample_model.diffusion_model.diffusion_model_base import DiffusionModelBase @@ -20,7 +21,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 @@ -30,13 +34,66 @@ def test_init_raises(self): with pytest.raises(TypeError, match=r'lorentzian_display_name must be a string or None'): DiffusionModelBase(lorentzian_display_name=123) - def test_unit_setter_raises(self, diffusion_model): + def test_x_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' + + def test_y_unit_setter_raises(self, diffusion_model): + # WHEN THEN EXPECT + with pytest.raises(AttributeError, match=r'read-only'): + diffusion_model.y_unit = '1/meV' + + def test_init_accepts_scipp_units(self): + # WHEN THEN + model = DiffusionModelBase(x_unit=sc.Unit('meV'), y_unit=sc.Unit('1/meV')) + + # EXPECT: scale.unit = x_unit * y_unit = dimensionless + assert str(model.x_unit) == 'meV' + assert sc.Unit(str(model.scale.unit)) == sc.Unit('dimensionless') + + def test_convert_x_unit(self, diffusion_model): + # WHEN THEN + diffusion_model.convert_x_unit('ueV') + + # EXPECT: x_unit and the scale unit (x_unit * y_unit) follow + assert sc.Unit(diffusion_model.x_unit) == sc.Unit('ueV') + assert sc.Unit(str(diffusion_model.scale.unit)) == sc.Unit('ueV') + + def test_convert_x_unit_invalid_type_raises(self, diffusion_model): + # WHEN THEN EXPECT + with pytest.raises(TypeError, match=r'x_unit must be a string or sc.Unit'): + diffusion_model.convert_x_unit(123) + + @pytest.mark.parametrize('unit', ['m', '1/ps'], ids=['length', 'frequency']) + def test_convert_x_unit_non_energy_raises(self, diffusion_model, unit): + # WHEN THEN EXPECT: only energy axes are supported for now + with pytest.raises(UnitError, match=r'convertible to meV'): + diffusion_model.convert_x_unit(unit) + + def test_convert_y_unit(self): + # WHEN + model = DiffusionModelBase(y_unit='1/meV') + + # THEN + model.convert_y_unit('1/eV') + + # EXPECT: y_unit and the scale unit follow (meV * 1/eV) + assert sc.Unit(model.y_unit) == sc.Unit('1/eV') + assert sc.Unit(str(model.scale.unit)) == sc.Unit('meV/eV') + + def test_convert_y_unit_invalid_type_raises(self, diffusion_model): + # WHEN THEN EXPECT + with pytest.raises(TypeError, match=r'y_unit must be a string or sc.Unit'): + diffusion_model.convert_y_unit(123) + + def test_convert_y_unit_incompatible_raises(self, diffusion_model): + # WHEN THEN EXPECT: dimensionless -> K changes the dimension of the scale unit + with pytest.raises(UnitError): + diffusion_model.convert_y_unit('K') @pytest.mark.parametrize( ('attribute', 'value', 'expected'), @@ -166,7 +223,8 @@ def test_repr(self, diffusion_model): # EXPECT assert 'DiffusionModelBase' in repr_str - assert 'unit=meV' in repr_str + # Regression: a stray ')' used to mangle this into 'x_unit=meV), y_unit=...' + assert 'x_unit=meV, y_unit=dimensionless' in repr_str def test_get_independent_variables(self, diffusion_model): # WHEN THEN EXPECT @@ -296,3 +354,22 @@ def test_ensure_Q_uses_argument(self, diffusion_model): # EXPECT np.testing.assert_allclose(Q, [1.0, 2.0]) + + +def test_get_fit_targets_declares_area_and_width(): + # GIVEN a diffusion model + model = DiffusionModelBase(lorentzian_name='Lorentzian') + # WHEN + targets = model.get_fit_targets() + # EXPECT area and width predictions with keys derived from the Lorentzian name + assert [t.name for t in targets] == ['area', 'width'] + assert targets[0].dataset_key == 'Lorentzian area' + assert targets[1].dataset_key == 'Lorentzian width' + + +def test_match_Q_indices_raises_when_Q_not_set(): + # GIVEN a diffusion model with no Q set + model = DiffusionModelBase() + # WHEN THEN EXPECT + with pytest.raises(ValueError, match=r'Q must be set in the model'): + model._match_Q_indices(np.array([1.0])) 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 014a221f7..7fcf4dacd 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,17 @@ def test_init_default(self, jump_diffusion_model): ), ( { - 'unit': 'meV', + 'y_unit': 123, + 'scale': 1.0, + 'diffusion_coefficient': 1.0, + 'relaxation_time': 1.0, + }, + TypeError, + None, + ), + ( + { + 'x_unit': 'meV', 'scale': 'invalid', 'diffusion_coefficient': 1.0, 'relaxation_time': 1.0, @@ -55,7 +67,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 +77,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 +87,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 +97,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, @@ -94,6 +106,15 @@ def test_init_default(self, jump_diffusion_model): 'relaxation_time must be non-negative', ), ], + ids=[ + 'invalid_x_unit', + 'invalid_y_unit', + 'invalid_scale_type', + 'invalid_diffusion_coefficient_type', + 'invalid_diffusion_coefficient_negative', + 'invalid_relaxation_time_type', + 'invalid_relaxation_time_negative', + ], ) def test_input_type_validation_raises(self, kwargs, expected_exception, expected_message): with pytest.raises(expected_exception, match=expected_message): @@ -159,7 +180,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 +242,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 @@ -257,6 +280,11 @@ def test_write_area_dependency_expression_raises(self, jump_diffusion_model): with pytest.raises(TypeError, match='QISF must be a float'): jump_diffusion_model._write_area_dependency_expression('invalid') + def test_y_unit_setter_raises(self, jump_diffusion_model): + # WHEN THEN EXPECT + with pytest.raises(AttributeError, match=r'read-only'): + jump_diffusion_model.y_unit = '1/meV' + def test_repr(self, jump_diffusion_model): # WHEN THEN repr_str = repr(jump_diffusion_model) @@ -265,3 +293,5 @@ def test_repr(self, jump_diffusion_model): assert 'JumpTranslationalDiffusion' in repr_str assert 'diffusion_coefficient' in repr_str assert 'scale=' in repr_str + # Regression: a stray ')' used to mangle this into 'x_unit=meV), y_unit=...' + assert 'x_unit=meV, y_unit=dimensionless' in repr_str diff --git a/tests/unit/easydynamics/sample_model/test_background_model.py b/tests/unit/easydynamics/sample_model/test_background_model.py index 8b08bbd84..ff1233d84 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,19 +33,19 @@ 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]), ) def test_init(self, background_model): # WHEN THEN - model = background_model # EXPECT - assert model.display_name == 'InitModel' - assert model.unit == 'meV' - assert len(model.components) == 2 - np.testing.assert_array_equal(model.Q.values, np.array([1.0, 2.0, 3.0])) + assert background_model.display_name == 'InitModel' + assert background_model.x_unit == 'meV' + assert background_model.y_unit == 'dimensionless' + assert len(background_model.components) == 2 + np.testing.assert_array_equal(background_model.Q.values, np.array([1.0, 2.0, 3.0])) @pytest.mark.parametrize( 'invalid_component, expected_error_msg', @@ -80,3 +80,19 @@ def test_init_raises_with_invalid_components(self, invalid_component, expected_e collection = ComponentCollection() collection.append_component(invalid_component) BackgroundModel(components=collection) + + def test_y_unit_setter_raises(self, background_model): + # WHEN / THEN / EXPECT + with pytest.raises(AttributeError): + background_model.y_unit = '1/meV' + + def test_convert_y_unit(self): + # WHEN + g = Gaussian(area=1.0, x_unit='meV', y_unit='1/meV') + model = BackgroundModel(components=g, x_unit='meV') + # THEN: convert y_unit to '1/eV' (same dimension, different scale) + model.convert_y_unit('1/eV') + # EXPECT + assert model.y_unit == '1/eV' + assert model.components[0].y_unit == '1/eV' + assert g.area.value == pytest.approx(1e3) diff --git a/tests/unit/easydynamics/sample_model/test_component_collection.py b/tests/unit/easydynamics/sample_model/test_component_collection.py index 7ae15ed65..233117031 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) @@ -45,10 +47,28 @@ def test_init(self): # EXPECT assert component_collection.display_name == 'InitModel' assert not component_collection + assert component_collection.x_unit == 'meV' + assert component_collection.y_unit == 'dimensionless' + + def test_get_fit_targets(self, component_collection): + # WHEN + targets = component_collection.get_fit_targets() + + # EXPECT: a single 'value' prediction wrapping the summed evaluate, stamped with the + # collection's units and no default dataset key + assert len(targets) == 1 + target = targets[0] + assert target.name == 'value' + assert target.dataset_key is None + assert target.label == 'TestComponentCollection' + assert target.x_unit == component_collection.x_unit + assert target.y_unit == component_collection.y_unit + x = np.linspace(-1, 1, 5) + np.testing.assert_allclose(target.function(x), component_collection.evaluate(x)) 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 +78,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 +111,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 +125,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 +147,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 +160,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 @@ -158,68 +178,100 @@ def test_list_component_names(self, component_collection): assert components[0] == 'TestGaussian1Name' assert components[1] == 'TestLorentzian1Name' - def test_convert_unit(self, component_collection): + def test_convert_x_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): + def test_convert_x_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): + def test_convert_x_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): + def test_set_x_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 x = np.linspace(-5, 5, 100) + + # THEN result = component_collection.evaluate(x) # EXPECT expected_result = component_collection[0].evaluate(x) + component_collection[1].evaluate(x) np.testing.assert_allclose(result, expected_result, rtol=1e-5) def test_evaluate_no_components_returns_zero(self): - # WHEN THEN + # WHEN component_collection = ComponentCollection(display_name='EmptyModel') x = np.linspace(-5, 5, 100) - # EXPECT + # THEN result = component_collection.evaluate(x) + + # EXPECT assert np.all(result == pytest.approx(0.0)) assert result.shape == x.shape + def test_evaluate_no_components_scipp_output(self): + # WHEN + component_collection = ComponentCollection(display_name='EmptyModel', y_unit='1/meV') + x = np.linspace(-5, 5, 100) + + # THEN + result = component_collection.evaluate(x, output='scipp') + + # EXPECT: an sc.Variable of zeros carrying the collection's y_unit + assert isinstance(result, sc.Variable) + assert result.unit == sc.Unit('1/meV') + assert np.all(result.values == pytest.approx(0.0)) + + def test_evaluate_no_components_scipp_input(self): + # WHEN + component_collection = ComponentCollection(display_name='EmptyModel') + x = sc.linspace('energy', -5.0, 5.0, 100, unit='meV') + + # THEN + result = component_collection.evaluate(x, output='scipp') + + # EXPECT: zeros on the input grid, keeping the input's dimension name + assert isinstance(result, sc.Variable) + assert result.dims == ('energy',) + assert np.all(result.values == pytest.approx(0.0)) + def test_evaluate_component(self, component_collection): - # WHEN THEN + # WHEN x = np.linspace(-5, 5, 100) + + # THEN result1 = component_collection.evaluate_component(x, 'TestGaussian1Name') result2 = component_collection.evaluate_component(x, 'TestLorentzian1Name') @@ -290,13 +342,57 @@ 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 with pytest.warns(UserWarning, match="does not have an 'area' "): component_collection.normalize_area() + def test_convert_x_unit_rollback_skipped_when_old_unit_none(self): + # WHEN: a collection without an x_unit of its own + collection = ComponentCollection( + components=Gaussian(name='G', area=1.0, width=0.5, x_unit='meV'), x_unit=None + ) + + # THEN: an incompatible unit fails; the outer rollback is skipped (no old unit to + # restore) but the component's own atomic rollback keeps it consistent + with pytest.raises(sc.UnitError): + collection.convert_x_unit('m') + + # EXPECT + assert collection[0].x_unit == 'meV' + + def test_normalize_area_only_non_area_components_raises(self): + # WHEN: no component in the collection has an area attribute + collection = ComponentCollection( + components=Polynomial(display_name='OnlyPolynomial', coefficients=[1, 2]) + ) + + # THEN EXPECT + with ( + pytest.warns(UserWarning, match="does not have an 'area' "), + pytest.raises(ValueError, match='No components with an area attribute'), + ): + collection.normalize_area() + + def test_normalize_area_mixed_units(self): + # WHEN: two Gaussians with compatible but different area units (meV and ueV) + gaussian_mev = Gaussian(name='G1', area=1.0, width=1.0, x_unit='meV') + gaussian_uev = Gaussian(name='G2', area=1000.0, width=500.0, x_unit='ueV') + collection = ComponentCollection(components=[gaussian_mev, gaussian_uev]) + + # THEN: both areas are physically 1 meV, so each should end up at half its value + collection.normalize_area() + + # EXPECT: areas sum to 1 in the first component's unit (meV) + total_mev = gaussian_mev.area.value + gaussian_uev.area.value / 1000.0 + assert total_mev == pytest.approx(1.0) + assert gaussian_mev.area.value == pytest.approx(0.5) + assert gaussian_uev.area.value == pytest.approx(500.0) + def test_get_all_parameters(self, component_collection): # WHEN THEN parameters = component_collection.get_all_parameters() @@ -364,6 +460,7 @@ def test_fix_and_free_all_parameters(self, component_collection): assert param.fixed is False def test_contains(self, component_collection): + # WHEN THEN EXPECT: membership by name and by object assert 'TestGaussian1Name' in component_collection assert 'TestLorentzian1Name' in component_collection assert 'NonExistentComponent' not in component_collection @@ -373,9 +470,10 @@ def test_contains(self, component_collection): assert gaussian_component in 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') - # EXPECT + # WHEN: a component not in the collection — THEN EXPECT + fake_component = Gaussian( + name='FakeGaussian', area=1.0, center=0.0, width=1.0, x_unit='meV' + ) assert fake_component not in component_collection assert 123 not in component_collection # Invalid type @@ -392,13 +490,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 +509,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 +529,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 +561,8 @@ 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 + assert copied_comp.y_unit == orig_comp.y_unit # Parameters are deep-copied and equivalent orig_params = orig_comp.get_all_parameters() @@ -487,3 +598,95 @@ 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_custom(self): + # WHEN THEN + cc = ComponentCollection(y_unit='1/meV') + # EXPECT + assert cc.y_unit == '1/meV' + + def test_y_unit_setter_raises(self, component_collection): + # WHEN THEN EXPECT + with pytest.raises(AttributeError, match=r'read-only'): + component_collection.y_unit = '1/meV' + + def test_convert_y_unit(self): + # WHEN: 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]) + + # THEN: 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' + assert g.area.value == pytest.approx(1e3) + assert lor.area.value == pytest.approx(1e3) + + def test_convert_y_unit_invalid_type_raises(self, component_collection): + # WHEN THEN EXPECT + with pytest.raises(TypeError): + component_collection.convert_y_unit(123) + + def test_convert_x_unit_rollback_on_failure(self): + # WHEN: 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 + + # THEN: 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): + # WHEN: 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 + + # THEN: 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) + + def test_evaluate_scipp_output_with_y_unit(self): + # WHEN + g = Gaussian(area=1.0, x_unit='meV', y_unit='1/meV') + cc = ComponentCollection(components=[g], y_unit='1/meV') + x = np.linspace(-5, 5, 50) + # THEN + result = cc.evaluate(x, output='scipp') + # EXPECT + assert isinstance(result, sc.Variable) + assert result.unit == sc.Unit('1/meV') + + # ───── Regression tests ───── + + def test_evaluate_scipp_output_multi_component_does_not_raise(self, component_collection): + # WHEN: collection with two components (Gaussian + Lorentzian) + x = sc.Variable(dims=['energy'], values=np.linspace(-5.0, 5.0, 100), unit='meV') + # THEN: 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 5c04c55e5..a802b1961 100644 --- a/tests/unit/easydynamics/sample_model/test_instrument_model.py +++ b/tests/unit/easydynamics/sample_model/test_instrument_model.py @@ -68,12 +68,13 @@ def test_init(self, instrument_model): # EXPECT assert model.display_name == 'TestInstrumentModel' + assert isinstance(model.Q, sc.Variable) + assert model.Q.unit == sc.Unit('1/angstrom') np.testing.assert_array_equal(model.Q.values, np.array([1.0, 2.0, 3.0])) assert isinstance(model.background_model, BackgroundModel) assert isinstance(model.resolution_model, ResolutionModel) np.testing.assert_array_equal(model.background_model.Q.values, np.array([1.0, 2.0, 3.0])) np.testing.assert_array_equal(model.resolution_model.Q.values, np.array([1.0, 2.0, 3.0])) - np.testing.assert_array_equal(model.Q.values, np.array([1.0, 2.0, 3.0])) def test_init_defaults(self): # WHEN THEN @@ -113,7 +114,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', ), @@ -122,7 +123,7 @@ def test_init_sample_model_as_resolution_model(self, sample_model): 'invalid resolution_model', 'invalid background_model', 'invalid energy_offset', - 'invalid unit', + 'invalid x unit', ], ) def test_instrument_model_init_invalid_inputs( @@ -209,13 +210,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 +270,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 +288,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 @@ -420,7 +418,9 @@ def test_generate_energy_offsets_Q_none(self, instrument_model): def test_generate_energy_offsets(self, instrument_model): # WHEN - instrument_model._Q = np.array([1.0, 2.0, 3.0, 4.0]) + instrument_model._Q = sc.Variable( + dims=['Q'], values=[1.0, 2.0, 3.0, 4.0], unit='1/angstrom' + ) # THEN instrument_model._generate_energy_offsets() @@ -429,7 +429,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): @@ -528,13 +528,13 @@ def test_fix_or_free_energy_offset_nonint_Q_index_raises(self, instrument_model) # WHEN / THEN / EXPECT with pytest.raises( TypeError, - match='Q_index must be an int or None, got str', + match='Q_index must be an int', ): instrument_model.fix_energy_offset(Q_index='invalid_index') with pytest.raises( TypeError, - match='Q_index must be an int or None, got str', + match='Q_index must be an int', ): instrument_model.free_energy_offset(Q_index='invalid_index') @@ -579,7 +579,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 312dcf7b1..0b19950b8 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,19 +39,22 @@ 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]), ) def test_init(self, model_base): # WHEN THEN - model = model_base # EXPECT - assert model.display_name == 'InitModel' - assert model.unit == 'meV' - assert len(model.components) == 2 - np.testing.assert_array_equal(model.Q.values, np.array([1.0, 2.0, 3.0])) + assert model_base.display_name == 'InitModel' + assert model_base.x_unit == 'meV' + assert model_base.y_unit == 'dimensionless' + assert len(model_base.components) == 2 + assert isinstance(model_base.Q, sc.Variable) + assert model_base.Q.dims == ('Q',) + assert model_base.Q.unit == sc.Unit('1/angstrom') + np.testing.assert_array_equal(model_base.Q.values, np.array([1.0, 2.0, 3.0])) def test_init_raises_with_invalid_components(self): # WHEN / THEN / EXPECT @@ -78,8 +81,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])) @@ -128,7 +131,7 @@ def test_get_all_variables(self, model_base): # WHEN all_vars = model_base.get_all_variables() - # THEN + # EXPECT expected_var_display_names = { 'TestGaussian1Name area', 'TestGaussian1Name center', @@ -198,7 +201,7 @@ def test_get_component_collection_invalid_index_raises(self, model_base): # WHEN THEN EXPECT with pytest.raises( IndexError, - match='Q_index 5 is out of bounds for ', + match='Q_index 5 is out of bounds for Q of length 3', ): model_base.get_component_collection(Q_index=5) @@ -246,36 +249,105 @@ 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_x_unit(self, model_base): + # Build collections before conversion so we can verify in-place update + _ = model_base.get_component_collection(0) + assert model_base._component_collections_is_dirty is False + collection_before = model_base._component_collections[0] - def test_convert_unit(self, model_base): # WHEN - model_base.convert_unit('eV') + model_base.convert_x_unit('eV') - # THEN / EXPECT - assert model_base.unit == 'eV' + # THEN / EXPECT: dirty flag NOT set and same collections reused (not rebuilt) + assert model_base._component_collections_is_dirty is False + assert model_base._component_collections[0] is collection_before + + assert model_base.x_unit == 'eV' for component in model_base.components: - assert component.unit == 'eV' + assert component.x_unit == 'eV' + for collection in model_base._component_collections: + for component in collection: + 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): + # WHEN THEN + model_base.components = None + # EXPECT + assert len(model_base.components) == 0 + + def test_convert_x_unit_rollback_when_old_unit_none(self): + # WHEN: model with _x_unit=None (rollback branch is skipped when old_unit is None) + 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 + # THEN + with pytest.raises(UnitError): + model.convert_x_unit('m') # incompatible unit triggers failure + # EXPECT: Gaussian's own atomic rollback keeps it at 'meV' even though + # ModelBase's outer rollback loop is skipped when old_unit is None + assert component.x_unit == 'meV' + + def test_convert_x_unit_rollback_on_failure(self, model_base): + # WHEN THEN + with pytest.raises(UnitError): + model_base.convert_x_unit('m') + # EXPECT: state rolled back + 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): + # WHEN THEN + with pytest.raises(UnitError): + model_base.convert_y_unit('K') + # EXPECT: state rolled back + assert model_base.y_unit == 'dimensionless' + + def test_convert_x_unit_rollback_restores_collections(self): + # WHEN: a model with built per-Q collections + component = Gaussian(name='G', area=1.0, center=0.0, width=0.5, x_unit='meV') + model = ModelBase(display_name='M', components=component, Q=[1.0, 2.0]) + collection = model.get_component_collection(0) + + # THEN: an incompatible unit fails and triggers the rollback of components and + # collections + with pytest.raises(UnitError): + model.convert_x_unit('m') + + # EXPECT + assert model.x_unit == 'meV' + assert component.x_unit == 'meV' + assert collection[0].x_unit == 'meV' + + def test_component_collections_empty_without_Q(self): + # WHEN: a model without Q regenerates its collections + model = ModelBase(display_name='M', components=Gaussian(name='G')) + + # THEN EXPECT: no per-Q collections and therefore no variables + assert model.get_all_variables() == [] + assert model._component_collections == [] def test_components_setter(self, model_base): # WHEN @@ -320,8 +392,9 @@ def test_Q_setter_raises_if_Q_is_not_similar(self, model_base): [1.0, 2.0, 3.0], np.array([1.0, 2.0, 3.0]), sc.Variable(dims=['Q'], values=[1.0, 2.0, 3.0], unit='1/angstrom'), + sc.Variable(dims=['Q'], values=[10.0, 20.0, 30.0], unit='1/nm'), ], - ids=['list', 'numpy_array', 'scipp_variable'], + ids=['list', 'numpy_array', 'scipp_variable', 'scipp_variable_other_unit'], ) def test_Q_setter_with_similar_Q(self, model_base, new_Q): # WHEN @@ -331,7 +404,7 @@ def test_Q_setter_with_similar_Q(self, model_base, new_Q): model_base.Q = new_Q # EXPECT - np.testing.assert_array_equal(model_base.Q, old_Q) + np.testing.assert_array_equal(model_base.Q.values, old_Q.values) def test_Q_setter_with_none(self, model_base): # WHEN @@ -354,6 +427,19 @@ def test_Q_setter_when_current_Q_is_none(self, model_base): # EXPECT np.testing.assert_array_equal(model_base.Q.values, np.array(new_Q)) + def test_Q_stored_as_scipp_in_inverse_angstrom(self, model_base): + # WHEN: a scipp Q in 1/nm + model_base._Q = None + new_Q = sc.Variable(dims=['Q'], values=[5.0, 10.0], unit='1/nm') + + # THEN + model_base.Q = new_Q + + # EXPECT: stored canonically in 1/angstrom + assert isinstance(model_base.Q, sc.Variable) + assert model_base.Q.unit == sc.Unit('1/angstrom') + np.testing.assert_allclose(model_base.Q.values, [0.5, 1.0]) + def test_clear_Q(self, model_base): # WHEN # @@ -388,3 +474,42 @@ def test_repr(self, model_base): assert 'unit' in repr_str assert 'Q=' in repr_str assert 'components=' in repr_str + + def test_y_unit_setter_raises(self, model_base): + # WHEN / THEN / EXPECT + with pytest.raises(AttributeError): + model_base.y_unit = '1/meV' + + def test_convert_y_unit(self): + # WHEN: 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])) + + # Build collections before conversion so we can verify in-place update + _ = model.get_component_collection(0) + assert model._component_collections_is_dirty is False + collection_before = model._component_collections[0] + + # THEN: convert y_unit to '1/eV' (same dimension, different scale) + model.convert_y_unit('1/eV') + + # EXPECT: dirty flag NOT set and same collections reused (not rebuilt) + assert model._component_collections_is_dirty is False + assert model._component_collections[0] is collection_before + + # EXPECT: model y_unit and template components updated + assert model.y_unit == '1/eV' + for component in model.components: + assert component.y_unit == '1/eV' + assert g.area.value == pytest.approx(1e3) + assert lor.area.value == pytest.approx(1e3) + # EXPECT: component collections updated in-place (not rebuilt from templates) + for component in collection_before: + assert component.y_unit == '1/eV' + + def test_convert_y_unit_invalid_raises(self, model_base): + # WHEN THEN 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 a2491c0dd..102363f60 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,20 +65,20 @@ 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, ) def test_init(self, resolution_model): # WHEN THEN - model = resolution_model # EXPECT - assert model.display_name == 'InitModel' - assert model.unit == 'meV' - assert len(model.components) == 2 - np.testing.assert_array_equal(model.Q.values, np.array([1.0, 2.0, 3.0])) + assert resolution_model.display_name == 'InitModel' + assert resolution_model.x_unit == 'meV' + assert resolution_model.y_unit == 'dimensionless' + assert len(resolution_model.components) == 2 + np.testing.assert_array_equal(resolution_model.Q.values, np.array([1.0, 2.0, 3.0])) @pytest.mark.parametrize( 'invalid_component, expected_error_msg', @@ -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.values, @@ -242,6 +242,21 @@ def test_from_sample_model( variables = resolution_model.get_all_variables() assert all(var.fixed for var in variables) is all_fixed + def test_from_sample_model_installed_collections_are_stable(self, sample_model): + # WHEN + resolution_model = ResolutionModel.from_sample_model(sample_model) + + # EXPECT: the installed collections are not scheduled for a rebuild (regression: init + # callbacks used to set the dirty flag, so the next access silently regenerated the + # collections from templates, discarding the normalization) + assert resolution_model._component_collections_is_dirty is False + + # and repeated access returns the same installed, normalized collections + first_access = resolution_model.get_component_collection(0) + second_access = resolution_model.get_component_collection(0) + assert first_access is second_access + assert (first_access[0].area.value + first_access[1].area.value) == pytest.approx(1.0) + def test_from_sample_model_with_no_Q(self, sample_model): # WHEN sample_model_no_Q = SampleModel( @@ -301,3 +316,19 @@ def test_from_sample_model_invalid_components(self, sample_model): match='cannot be a DeltaFunction', ): ResolutionModel.from_sample_model(sample_model) + + def test_y_unit_setter_raises(self, resolution_model): + # WHEN / THEN / EXPECT + with pytest.raises(AttributeError): + resolution_model.y_unit = '1/meV' + + def test_convert_y_unit(self): + # WHEN + g = Gaussian(area=1.0, x_unit='meV', y_unit='1/meV') + model = ResolutionModel(components=g, x_unit='meV') + # THEN: convert y_unit to '1/eV' (same dimension, different scale) + model.convert_y_unit('1/eV') + # EXPECT + assert model.y_unit == '1/eV' + assert model.components[0].y_unit == '1/eV' + assert g.area.value == pytest.approx(1e3) diff --git a/tests/unit/easydynamics/sample_model/test_sample_model.py b/tests/unit/easydynamics/sample_model/test_sample_model.py index a6c7f79f5..36ac3fb72 100644 --- a/tests/unit/easydynamics/sample_model/test_sample_model.py +++ b/tests/unit/easydynamics/sample_model/test_sample_model.py @@ -6,6 +6,7 @@ import numpy as np import pytest +import scipp as sc from scipp import UnitError from easydynamics.sample_model import ComponentCollection @@ -28,7 +29,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 +37,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 +51,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 +63,8 @@ def test_init(self, sample_model): # EXPECT assert model.display_name == 'InitModel' - assert model.unit == 'meV' + assert model.x_unit == 'meV' + assert model.y_unit == 'dimensionless' assert len(model.components) == 2 assert isinstance(model.diffusion_models, list) assert len(model.diffusion_models) == 1 @@ -308,6 +310,62 @@ def test_convert_temperature_unit_raises_with_invalid_unit(self, sample_model): ): model.convert_temperature_unit('invalid_unit') + def test_convert_x_unit_propagates_to_diffusion_models(self): + # WHEN: a SampleModel with an attached diffusion model + Q = np.array([1.0, 2.0]) + brownian = BrownianTranslationalDiffusion(diffusion_coefficient=2.4e-9, Q=Q) + model = SampleModel(Q=Q, diffusion_models=brownian) + width_mev = model.get_component_collection(0)[0].width.value + + # THEN + model.convert_x_unit('ueV') + + # EXPECT: the diffusion model follows the SampleModel's new unit, and the merged + # collections carry rescaled widths in the new unit + assert sc.Unit(str(brownian.x_unit)) == sc.Unit('ueV') + assert sc.Unit(str(brownian.scale.unit)) == sc.Unit('ueV') + collection = model.get_component_collection(0) + assert sc.Unit(str(collection[0].width.unit)) == sc.Unit('ueV') + assert collection[0].width.value == pytest.approx(width_mev * 1000) + + def test_convert_x_unit_does_not_mark_collections_dirty(self): + # WHEN: a SampleModel with an attached diffusion model and built collections + Q = np.array([1.0, 2.0]) + brownian = BrownianTranslationalDiffusion(diffusion_coefficient=2.4e-9, Q=Q) + model = SampleModel(Q=Q, diffusion_models=brownian) + collection_before = model.get_component_collection(0) + width = collection_before[0].width + + # THEN + model.convert_x_unit('ueV') + + # EXPECT: conversion works in place — no dirty flag, no rebuild, and the converted + # dependent width stays in the new unit through later dependency-graph updates + # (regression: conversion used to mark the collections dirty, and the rebuild + # discarded per-Q state and object references) + assert model._component_collections_is_dirty is False + assert model.get_component_collection(0) is collection_before + width_uev = width.value + brownian.diffusion_coefficient = 4.8e-9 + assert sc.Unit(str(width.unit)) == sc.Unit('ueV') + assert width.value == pytest.approx(2 * width_uev) + + def test_convert_y_unit_propagates_to_diffusion_models(self): + # WHEN: SampleModel and diffusion model sharing y_unit='1/meV' + Q = np.array([1.0]) + brownian = BrownianTranslationalDiffusion( + diffusion_coefficient=2.4e-9, Q=Q, y_unit='1/meV' + ) + model = SampleModel(Q=Q, y_unit='1/meV', diffusion_models=brownian) + + # THEN + model.convert_y_unit('1/eV') + + # EXPECT + assert sc.Unit(str(brownian.y_unit)) == sc.Unit('1/eV') + assert sc.Unit(str(brownian.scale.unit)) == sc.Unit('meV/eV') + assert sc.Unit(str(model.y_unit)) == sc.Unit('1/eV') + def test_normalize_detailed_balance_setter(self, sample_model): # WHEN model = sample_model @@ -402,12 +460,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 +510,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,9 +589,59 @@ def test_repr(self, sample_model): # THEN / EXPECT assert 'SampleModel' in repr_str - assert 'unit=' in repr_str - assert 'Q=' in repr_str + assert 'Q = ' in repr_str assert 'components' in repr_str assert 'diffusion_models' in repr_str assert 'temperature' in repr_str + # Regression: a stray ')' and a missing separator used to mangle this into + # 'x_unit=meV), y_unit=dimensionlessQ = ...', and the closing ')' was missing + assert 'x_unit=meV, y_unit=dimensionless' in repr_str + assert repr_str.rstrip().endswith(')') assert 'normalize_detailed_balance' in repr_str + + def test_y_unit_setter_raises(self, sample_model): + # WHEN / THEN / EXPECT + with pytest.raises(AttributeError): + sample_model.y_unit = '1/meV' + + def test_convert_y_unit(self): + # WHEN + g = Gaussian(area=1.0, x_unit='meV', y_unit='1/meV') + model = SampleModel(components=g, x_unit='meV') + # THEN: convert y_unit to '1/eV' (same dimension, different scale) + model.convert_y_unit('1/eV') + # EXPECT + assert model.y_unit == '1/eV' + assert model.components[0].y_unit == '1/eV' + assert g.area.value == pytest.approx(1e3) + + +def test_remove_diffusion_model_raises_with_duplicate_names(): + # GIVEN a SampleModel with two DiffusionModels sharing a name + Q = np.linspace(0.5, 2.0, 3) + model = SampleModel( + Q=Q, + diffusion_models=[ + BrownianTranslationalDiffusion(name='dup'), + BrownianTranslationalDiffusion(name='dup'), + ], + ) + # WHEN THEN EXPECT + with pytest.raises(ValueError, match=r'Multiple DiffusionModels share the name'): + model.remove_diffusion_model('dup') + + +def test_convert_x_unit_rolls_back_when_diffusion_model_conversion_fails(): + # GIVEN a SampleModel whose diffusion model raises during x-unit conversion + Q = np.linspace(0.5, 2.0, 3) + brownian = BrownianTranslationalDiffusion() + model = SampleModel(Q=Q, diffusion_models=brownian) + original_unit = model.x_unit + # WHEN the conversion fails partway through + with ( + patch.object(brownian, 'convert_x_unit', side_effect=RuntimeError('boom')), + pytest.raises(RuntimeError, match='boom'), + ): + model.convert_x_unit('ueV') + # EXPECT the model's own x_unit to be rolled back to the original + assert model.x_unit == original_unit diff --git a/tests/unit/easydynamics/settings/test_convolution_settings.py b/tests/unit/easydynamics/settings/test_convolution_settings.py index 202f5baea..a5c0ceb38 100644 --- a/tests/unit/easydynamics/settings/test_convolution_settings.py +++ b/tests/unit/easydynamics/settings/test_convolution_settings.py @@ -1,6 +1,8 @@ # SPDX-FileCopyrightText: 2026 EasyScience contributors # SPDX-License-Identifier: BSD-3-Clause +from copy import copy + import pytest from easydynamics.settings.convolution_settings import ConvolutionSettings @@ -20,7 +22,21 @@ def test_init(self, default_convolution_settings): assert isinstance(default_convolution_settings, ConvolutionSettings) assert default_convolution_settings.upsample_factor == 5 assert default_convolution_settings.extension_factor == pytest.approx(0.2) - assert default_convolution_settings.convolution_plan_is_valid is False + + def test_copy(self): + # WHEN + settings = ConvolutionSettings( + upsample_factor=10, extension_factor=0.5, suppress_warnings=True + ) + + # THEN + settings_copy = copy(settings) + + # EXPECT: a distinct instance with the same knob values + assert settings_copy is not settings + assert settings_copy.upsample_factor == 10 + assert settings_copy.extension_factor == pytest.approx(0.5) + assert settings_copy.suppress_warnings is True def test_init_with_custom_parameters(self): """ @@ -37,7 +53,6 @@ def test_init_with_custom_parameters(self): # THEN EXPECT assert convolution_settings.upsample_factor == 10 assert convolution_settings.extension_factor == pytest.approx(0.5) - assert convolution_settings.convolution_plan_is_valid is False assert convolution_settings.suppress_warnings is True def test_init_with_None(self): @@ -54,7 +69,6 @@ def test_init_with_None(self): # THEN EXPECT assert convolution_settings.upsample_factor is None assert convolution_settings.extension_factor is None - assert convolution_settings.convolution_plan_is_valid is False assert convolution_settings.suppress_warnings is False @pytest.mark.parametrize( @@ -98,16 +112,15 @@ def test_upsample_factor_setter_valid(self, default_convolution_settings, value) settings = default_convolution_settings # WHEN - # Ensure it's True first so we can test the reset - settings.convolution_plan_is_valid = True + version_before = settings._plan_version # THEN settings.upsample_factor = value - # EXPECT + # EXPECT: value stored and the plan invalidated for all convolvers expected = pytest.approx(float(value)) if value is not None else None assert settings.upsample_factor == expected - assert settings.convolution_plan_is_valid is False + assert settings._plan_version == version_before + 1 @pytest.mark.parametrize( 'value, expected_exception, match', @@ -146,23 +159,25 @@ def test_upsample_factor_setter_invalid( def test_extension_factor_setter_valid(self, default_convolution_settings, value): # WHEN - default_convolution_settings.convolution_plan_is_valid = True + version_before = default_convolution_settings._plan_version # THEN default_convolution_settings.extension_factor = value - # EXPECT + # EXPECT: value stored and the plan invalidated for all convolvers assert default_convolution_settings.extension_factor == pytest.approx(float(value)) - assert default_convolution_settings.convolution_plan_is_valid is False + assert default_convolution_settings._plan_version == version_before + 1 @pytest.mark.parametrize( 'value, expected_exception, match', [ ('0.2', TypeError, 'must be a number'), + (None, TypeError, 'must be a number'), (-0.1, ValueError, 'must be non-negative'), ], ids=[ 'not_numeric', + 'none', 'negative', ], ) @@ -178,51 +193,22 @@ def test_extension_factor_setter_invalid( with pytest.raises(expected_exception, match=match): default_convolution_settings.extension_factor = value - @pytest.mark.parametrize( - 'value', - [True, False], - ids=[ - 'true', - 'false', - ], - ) - def test_convolution_plan_is_valid_setter_valid( - self, - default_convolution_settings, - value, - ): + def test_invalidate_plan_bumps_version(self, default_convolution_settings): # WHEN - default_convolution_settings.convolution_plan_is_valid = not value + version_before = default_convolution_settings._plan_version # THEN - default_convolution_settings.convolution_plan_is_valid = value + default_convolution_settings._invalidate_plan() # EXPECT - assert default_convolution_settings.convolution_plan_is_valid is value - - @pytest.mark.parametrize( - 'value, expected_exception, match', - [ - ('True', TypeError, 'must be True or False'), - (1, TypeError, 'must be True or False'), - (None, TypeError, 'must be True or False'), - ], - ids=[ - 'string', - 'int', - 'none', - ], - ) - def test_convolution_plan_is_valid_setter_invalid( - self, - default_convolution_settings, - value, - expected_exception, - match, - ): - # WHEN / THEN / EXPECT - with pytest.raises(expected_exception, match=match): - default_convolution_settings.convolution_plan_is_valid = value + assert default_convolution_settings._plan_version == version_before + 1 + assert default_convolution_settings._plan_valid_for(version_before) is False + assert ( + default_convolution_settings._plan_valid_for( + default_convolution_settings._plan_version + ) + is True + ) def test_suppress_warnings_setter_valid(self, default_convolution_settings): # WHEN diff --git a/tests/unit/easydynamics/test_exceptions.py b/tests/unit/easydynamics/test_exceptions.py index 494f6f7c9..bc7731a5e 100644 --- a/tests/unit/easydynamics/test_exceptions.py +++ b/tests/unit/easydynamics/test_exceptions.py @@ -7,11 +7,14 @@ class TestAmbiguousNameError: def test_initialization(self): + # WHEN name = 'test' matches = ['test1', 'test2', 'test3'] + # THEN error = AmbiguousNameError(name, matches) + # EXPECT assert error.name == name assert error.matches == matches assert str(error) == ( @@ -19,11 +22,14 @@ def test_initialization(self): ) def test_empty_matches(self): + # WHEN name = 'unknown' matches = [] + # THEN error = AmbiguousNameError(name, matches) + # EXPECT assert error.name == name assert error.matches == matches assert str(error) == ("Ambiguous name 'unknown' matches 0 elements: []") diff --git a/tests/unit/easydynamics/test_import.py b/tests/unit/easydynamics/test_import.py index e062efcbe..d2ffe77ba 100644 --- a/tests/unit/easydynamics/test_import.py +++ b/tests/unit/easydynamics/test_import.py @@ -3,6 +3,5 @@ def test_import_easydynamics(): - import easydynamics - - assert easydynamics is not None + # WHEN THEN EXPECT: importing raises no error + import easydynamics # noqa: F401 diff --git a/tests/unit/easydynamics/utils/test_detailed_balance.py b/tests/unit/easydynamics/utils/test_detailed_balance.py index bebf5407a..2d2284d36 100644 --- a/tests/unit/easydynamics/utils/test_detailed_balance.py +++ b/tests/unit/easydynamics/utils/test_detailed_balance.py @@ -9,6 +9,7 @@ from scipp.constants import Boltzmann as kB from easydynamics.utils import detailed_balance_factor +from easydynamics.utils.detailed_balance import _convert_to_scipp_variable kB_meV_per_K = sc.to_unit(kB, 'meV/K').value @@ -305,3 +306,30 @@ 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.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): + # WHEN THEN EXPECT + with pytest.raises(TypeError, match=expected_match): + _convert_to_scipp_variable({'invalid': 'type'}, name=name, unit='meV') + + def test_invalid_unit_scalar_raises_unit_error(self): + # WHEN THEN EXPECT + with pytest.raises(UnitError, match='Invalid unit string'): + _convert_to_scipp_variable(1.0, name='energy', unit='not_a_real_unit_xyz') + + def test_invalid_unit_array_raises_unit_error(self): + # WHEN THEN EXPECT + with pytest.raises(UnitError, match='Invalid unit string'): + _convert_to_scipp_variable([1.0, 2.0], name='energy', unit='not_a_real_unit_xyz')