diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml index 4b7618c9..442683a2 100644 --- a/.github/workflows/docs.yml +++ b/.github/workflows/docs.yml @@ -83,6 +83,9 @@ jobs: fi echo "RELEASE_VERSION=${RELEASE_VERSION}" >> "$GITHUB_ENV" echo "DOCS_VERSION=${DOCS_VERSION}" >> "$GITHUB_ENV" + # Artifact names cannot contain '/', so sanitize the (possibly slash-containing) + # branch name for use in the uploaded artifact name. + echo "ARTIFACT_SUFFIX=${RELEASE_VERSION//\//-}" >> "$GITHUB_ENV" # Check out the repository source code. # Note: The gh-pages branch is fetched separately later for mike deployment. @@ -135,7 +138,7 @@ jobs: - name: Upload built site as artifact uses: ./.github/actions/upload-artifact with: - name: site-local_easydynamics-lib-${{ env.RELEASE_VERSION }} + name: site-local_easydynamics-lib-${{ env.ARTIFACT_SUFFIX }} path: docs/site/ # Create GitHub App token for pushing to gh-pages as easyscience[bot]. diff --git a/src/easydynamics/utils/fit_target.py b/src/easydynamics/utils/fit_target.py new file mode 100644 index 00000000..19ed850f --- /dev/null +++ b/src/easydynamics/utils/fit_target.py @@ -0,0 +1,47 @@ +# SPDX-FileCopyrightText: 2026 EasyScience contributors +# SPDX-License-Identifier: BSD-3-Clause + +from __future__ import annotations + +from dataclasses import dataclass +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from collections.abc import Callable + + +@dataclass(frozen=True) +class FitTarget: + """ + One fittable prediction of a model, bound to a key in a parameters Dataset. + + Models declare their predictions by returning FitTargets (see + ``DiffusionModelBase.get_fit_targets``), and ``FitBinding`` maps them onto the dataset keys + they should be fitted against. Instances are immutable snapshots created on demand, so the + units always reflect the model state at the time the targets are built. + + Attributes + ---------- + name : str + The prediction's name (e.g. ``'width'``, ``'area'``, ``'delta_area'``, ``'value'``). + dataset_key : str | None + The key in the parameters Dataset holding the data this prediction is fitted against. None + when the prediction has no default key (component models); ``FitBinding`` supplies the key + in that case. + function : Callable + The fit function; called as ``function(x)`` with raw x values expressed in *x_unit* and + returning raw values expressed in *y_unit*. + label : str + Display label used for plots and results (e.g. ``'DeltaLorentz width'``). + x_unit : str | None + The unit *function* expects its input in, or None if no unit conversion applies. + y_unit : str | None + The unit of *function*'s output, or None if no unit conversion applies. + """ + + name: str + dataset_key: str | None + function: Callable + label: str + x_unit: str | None + y_unit: str | None diff --git a/src/easydynamics/utils/utils.py b/src/easydynamics/utils/utils.py index d9fc27c0..54c1b46a 100644 --- a/src/easydynamics/utils/utils.py +++ b/src/easydynamics/utils/utils.py @@ -1,11 +1,16 @@ # SPDX-FileCopyrightText: 2026 EasyScience contributors # SPDX-License-Identifier: BSD-3-Clause +import contextlib +from collections.abc import Callable + import numpy as np import scipp as sc from easyscience.variable import DescriptorNumber +from easyscience.variable import Parameter from numpy.typing import ArrayLike from scipp.constants import hbar as scipp_hbar +from scipp.constants import k as scipp_k Numeric = float | int @@ -13,8 +18,149 @@ energy_type = np.ndarray | Numeric | list | ArrayLike | sc.Variable hbar = DescriptorNumber.from_scipp('hbar', scipp_hbar) +kb = DescriptorNumber.from_scipp('kb', scipp_k) angstrom = DescriptorNumber('angstrom', 1e-10, unit='m') +# The unit all Q values are normalized to and assumed to be in when given as raw numbers. +CANONICAL_Q_UNIT = '1/angstrom' + + +def verify_Q_index(Q_index: int, Q: sc.Variable | None, allow_none: bool = False) -> None: + """ + Verify that Q_index is a valid integer index into Q. + + When Q is None (e.g. no data has been loaded yet), only the type and sign of Q_index are + checked; the upper-bound check is deferred until Q is available. + + Parameters + ---------- + Q_index : int + Index to validate. + Q : sc.Variable | None + The Q values (may be None if no data is loaded). + allow_none : bool, default=False + Whether or not to allow Q_index to be None + + Raises + ------ + TypeError + If Q_index is not an int (or not an int or None when allow_none=True). + IndexError + If Q_index is negative, or out of range when Q is available. + """ + if allow_none and Q_index is None: + return + + if Q_index is None or not isinstance(Q_index, int): + if allow_none: + raise TypeError(f'Q_index must be an int or None, got {type(Q_index).__name__}') + raise TypeError(f'Q_index must be an int, got {type(Q_index).__name__}') + + if Q_index < 0: + raise IndexError(f'Q_index must be non-negative, got {Q_index}') + + if Q is not None and Q_index >= len(Q): + raise IndexError(f'Q_index {Q_index} is out of bounds for Q of length {len(Q)}') + + +def convert_units_with_rollback( + conversions: list[tuple[Callable[[str | sc.Unit], None], str | sc.Unit, str | sc.Unit]], +) -> None: + """ + Apply a sequence of unit conversions, rolling all of them back if any fails. + + Each item is ``(convert, new_unit, old_unit)`` where ``convert`` is a callable applying a unit + (e.g. a bound ``convert_x_unit`` or a ``functools.partial`` around + :func:`convert_parameter_unit`). The conversions are applied in order; if any raises, every + item is converted back to its old unit best-effort (converting a not-yet-converted item back to + its old unit is a no-op) and the original exception is re-raised. + + Parameters + ---------- + conversions : list[tuple[Callable[[str | sc.Unit], None], str | sc.Unit, str | sc.Unit]] + The conversions to apply, each as ``(convert, new_unit, old_unit)``. + + Raises + ------ + Exception + Whatever the failing conversion raised, after the rollback attempt. + """ + try: + for convert, new_unit, _ in conversions: + convert(new_unit) + except Exception: + for convert, _, old_unit in conversions: + with contextlib.suppress(Exception): + convert(old_unit) + raise + + +def convert_value_unit(value: float, from_unit: str | sc.Unit, to_unit: str | sc.Unit) -> float: + """ + Convert a numeric value from one unit to another without mutating anything. + + Returns the value unchanged when the two units compare equal as strings (the common + no-conversion case, kept cheap for hot paths). + + Parameters + ---------- + value : float + The value to convert. + from_unit : str | sc.Unit + The unit the value is currently expressed in. + to_unit : str | sc.Unit + The unit to convert the value to. + + Returns + ------- + float + The value expressed in *to_unit*. + """ + if str(from_unit) == str(to_unit): + return value + return sc.to_unit(sc.scalar(value, unit=str(from_unit)), str(to_unit)).value + + +def convert_parameter_unit(parameter: Parameter, unit: str | sc.Unit) -> None: + """ + Convert a parameter to a new unit, keeping dependent parameters consistent. + + Independent parameters are converted with ``convert_unit``. Dependent parameters are converted + with ``set_desired_unit``, so the new unit survives later dependency-graph recomputations (a + plain ``convert_unit`` would be reverted to the old desired unit the next time the dependency + expression is re-evaluated). + + Parameters + ---------- + parameter : Parameter + The parameter to convert. + unit : str | sc.Unit + The unit to convert to. + """ + if parameter.independent: + parameter.convert_unit(str(unit)) + else: + parameter.set_desired_unit(str(unit)) + + +def energy_to_scipp(energy: np.ndarray, unit: str | sc.Unit) -> sc.Variable: + """ + Convert a numpy energy array to a scipp Variable with dimension 'energy'. + + Parameters + ---------- + energy : np.ndarray + The energy array to be converted + unit : str | sc.Unit + The unit of the energy + + Returns + ------- + sc.Variable + Energy as sc.Variable. + """ + return sc.array(dims=['energy'], values=energy, unit=unit) + def _validate_and_convert_Q( Q: np.ndarray | Numeric | list | ArrayLike | sc.Variable | None, @@ -92,6 +238,30 @@ def _validate_unit(unit: str | sc.Unit | None) -> sc.Unit | None: return unit +def _assert_valid_unit(unit: str | sc.Unit) -> None: + """ + Assert that the given unit is recognised by scipp. + + Parameters + ---------- + unit : str | sc.Unit + The unit to validate. + + Raises + ------ + TypeError + If unit is not a string or scipp Unit. + ValueError + If the string is not a valid scipp unit. + """ + if not isinstance(unit, (str, sc.Unit)): + raise TypeError(f'unit must be a string or sc.Unit, got {type(unit).__name__}') + try: + sc.Unit(str(unit)) + except sc.UnitError as e: + raise ValueError(f"'{unit}' is not a valid scipp unit.") from e + + def _in_notebook() -> bool: """ Check if the code is running in a Jupyter notebook. diff --git a/tests/unit/easydynamics/utils/test_fit_target.py b/tests/unit/easydynamics/utils/test_fit_target.py new file mode 100644 index 00000000..bde22cb3 --- /dev/null +++ b/tests/unit/easydynamics/utils/test_fit_target.py @@ -0,0 +1,58 @@ +# SPDX-FileCopyrightText: 2026 EasyScience contributors +# SPDX-License-Identifier: BSD-3-Clause + +from dataclasses import FrozenInstanceError + +import pytest + +from easydynamics.utils.fit_target import FitTarget + + +def test_fit_target_holds_prediction_metadata(): + # WHEN a FitTarget is created + target = FitTarget( + name='width', + dataset_key='Lorentzian width', + function=lambda x: x * 2, + label='DeltaLorentz width', + x_unit='1/angstrom', + y_unit='meV', + ) + # EXPECT its attributes to be preserved and the function callable + assert target.name == 'width' + assert target.dataset_key == 'Lorentzian width' + assert target.function(3) == 6 + assert target.label == 'DeltaLorentz width' + assert target.x_unit == '1/angstrom' + assert target.y_unit == 'meV' + + +def test_fit_target_allows_none_key_and_units(): + # WHEN a component-style FitTarget without a default key/units is created + target = FitTarget( + name='value', + dataset_key=None, + function=lambda x: x, + label='value', + x_unit=None, + y_unit=None, + ) + # EXPECT the optional fields to be None + assert target.dataset_key is None + assert target.x_unit is None + assert target.y_unit is None + + +def test_fit_target_is_frozen(): + # GIVEN a FitTarget + target = FitTarget( + name='value', + dataset_key=None, + function=lambda x: x, + label='value', + x_unit=None, + y_unit=None, + ) + # WHEN THEN EXPECT: it is immutable + with pytest.raises(FrozenInstanceError): + target.name = 'other' diff --git a/tests/unit/easydynamics/utils/test_utils.py b/tests/unit/easydynamics/utils/test_utils.py index 680d2a28..3044d2f4 100644 --- a/tests/unit/easydynamics/utils/test_utils.py +++ b/tests/unit/easydynamics/utils/test_utils.py @@ -1,13 +1,116 @@ # SPDX-FileCopyrightText: 2026 EasyScience contributors # SPDX-License-Identifier: BSD-3-Clause +from unittest.mock import Mock + import numpy as np import pytest import scipp as sc +from easyscience.variable import Parameter +from easydynamics.utils.utils import _assert_valid_unit from easydynamics.utils.utils import _in_notebook 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 convert_value_unit +from easydynamics.utils.utils import energy_to_scipp +from easydynamics.utils.utils import verify_Q_index + + +class TestVerifyQIndex: + def test_valid_index(self): + # WHEN THEN EXPECT: no exception + verify_Q_index(1, sc.array(dims=['Q'], values=[0.5, 1.0], unit='1/angstrom')) + + def test_none_allowed(self): + # WHEN THEN EXPECT: no exception + verify_Q_index(None, None, allow_none=True) + + def test_none_not_allowed_raises(self): + # WHEN THEN EXPECT + with pytest.raises(TypeError, match='Q_index must be an int'): + verify_Q_index(None, None) + + def test_non_int_raises(self): + # WHEN THEN EXPECT + with pytest.raises(TypeError, match='Q_index must be an int'): + verify_Q_index('0', None) + + def test_negative_raises_even_when_Q_is_none(self): + # WHEN THEN EXPECT + with pytest.raises(IndexError, match='Q_index must be non-negative'): + verify_Q_index(-1, None) + + def test_out_of_bounds_raises(self): + # WHEN THEN EXPECT + Q = sc.array(dims=['Q'], values=[0.5, 1.0], unit='1/angstrom') + with pytest.raises(IndexError, match='Q_index 2 is out of bounds'): + verify_Q_index(2, Q) + + def test_upper_bound_deferred_when_Q_is_none(self): + # WHEN: Q is None (no data loaded yet) + # THEN EXPECT: a non-negative index is accepted; the bound check is deferred + verify_Q_index(100, None) + + +class TestConvertValueUnit: + def test_same_unit_returns_value_unchanged(self): + # WHEN THEN EXPECT: string-equal units short-circuit without scipp round-trip + assert convert_value_unit(2.5, 'meV', 'meV') == pytest.approx(2.5) + + def test_converts_between_compatible_units(self): + # WHEN THEN EXPECT + assert convert_value_unit(1.0, 'meV', 'ueV') == pytest.approx(1000.0) + + def test_accepts_scipp_units(self): + # WHEN THEN EXPECT + assert convert_value_unit(1.0, sc.Unit('meV'), sc.Unit('ueV')) == pytest.approx(1000.0) + + def test_incompatible_units_raise(self): + # WHEN THEN EXPECT + with pytest.raises(sc.UnitError): + convert_value_unit(1.0, 'meV', 'K') + + +class TestConvertUnitsWithRollback: + def test_applies_all_conversions(self): + # WHEN + p1 = Parameter(name='p1', value=1.0, unit='meV') + p2 = Parameter(name='p2', value=2.0, unit='meV') + + # THEN + convert_units_with_rollback([ + (lambda u, p=p1: convert_parameter_unit(p, u), 'ueV', 'meV'), + (lambda u, p=p2: convert_parameter_unit(p, u), 'ueV', 'meV'), + ]) + + # EXPECT + assert sc.Unit(str(p1.unit)) == sc.Unit('ueV') + assert p1.value == pytest.approx(1000.0) + assert sc.Unit(str(p2.unit)) == sc.Unit('ueV') + assert p2.value == pytest.approx(2000.0) + + def test_rolls_back_on_failure_and_reraises(self): + # WHEN: the second conversion fails (meV -> K is not valid) + p1 = Parameter(name='p1', value=1.0, unit='meV') + p2 = Parameter(name='p2', value=2.0, unit='meV') + + def fail(_unit): + raise RuntimeError('conversion failed') + + # THEN EXPECT: the original exception propagates and p1 is rolled back + with pytest.raises(RuntimeError, match='conversion failed'): + convert_units_with_rollback([ + (lambda u, p=p1: convert_parameter_unit(p, u), 'ueV', 'meV'), + (fail, 'ueV', 'meV'), + ]) + + assert str(p1.unit) == 'meV' + assert p1.value == pytest.approx(1.0) + assert str(p2.unit) == 'meV' + assert p2.value == pytest.approx(2.0) class TestValidateAndConvertQ: @@ -87,16 +190,24 @@ class TestValidateUnit: ], ) def test_validate_unit_valid(self, unit_input): + # WHEN + + # THEN unit = _validate_unit(unit_input) + # EXPECT if unit_input is None: assert unit is None else: assert isinstance(unit, str) def test_validate_unit_string_conversion(self): + # WHEN + + # THEN unit = _validate_unit(sc.Unit('meV')) + # EXPECT assert isinstance(unit, str) assert unit == 'meV' @@ -111,6 +222,7 @@ def test_validate_unit_string_conversion(self): ], ) def test_validate_unit_invalid_type(self, unit_input): + # WHEN THEN EXPECT with pytest.raises(TypeError, match='unit must be None, a string, or a scipp Unit'): _validate_unit(unit_input) @@ -174,3 +286,42 @@ def raise_import_error(*args, **kwargs): # noqa: ARG001 # EXPECT assert _in_notebook() is False + + +def test_verify_Q_index_allow_none_rejects_non_int(): + # WHEN THEN EXPECT: a non-int, non-None Q_index is rejected even when None is allowed + with pytest.raises(TypeError, match=r'Q_index must be an int or None'): + verify_Q_index('not an int', Q=None, allow_none=True) + + +def test_convert_parameter_unit_dependent_sets_desired_unit(): + # GIVEN a dependent parameter (cannot be converted directly) + param = Mock() + param.independent = False + # WHEN converting its unit + convert_parameter_unit(param, 'meV') + # EXPECT the desired unit is recorded instead of an in-place conversion + param.set_desired_unit.assert_called_once_with('meV') + param.convert_unit.assert_not_called() + + +def test_energy_to_scipp_returns_variable_with_unit(): + # WHEN converting a numpy energy array + result = energy_to_scipp(np.array([1.0, 2.0, 3.0]), 'meV') + # EXPECT a scipp Variable on the 'energy' dimension with the given unit + assert isinstance(result, sc.Variable) + assert result.unit == sc.Unit('meV') + assert result.dims == ('energy',) + np.testing.assert_allclose(result.values, [1.0, 2.0, 3.0]) + + +def test_assert_valid_unit_rejects_non_unit_type(): + # WHEN THEN EXPECT + with pytest.raises(TypeError, match=r'unit must be a string or sc.Unit'): + _assert_valid_unit(123) + + +def test_assert_valid_unit_rejects_invalid_unit_string(): + # WHEN THEN EXPECT + with pytest.raises(ValueError, match=r'is not a valid scipp unit'): + _assert_valid_unit('not_a_real_unit')