Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion .github/workflows/docs.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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].
Expand Down
47 changes: 47 additions & 0 deletions src/easydynamics/utils/fit_target.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
# SPDX-FileCopyrightText: 2026 EasyScience contributors <https://github.com/easyscience>
# 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
170 changes: 170 additions & 0 deletions src/easydynamics/utils/utils.py
Original file line number Diff line number Diff line change
@@ -1,20 +1,166 @@
# SPDX-FileCopyrightText: 2026 EasyScience contributors <https://github.com/easyscience>
# 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

Q_type = np.ndarray | Numeric | list | ArrayLike | sc.Variable
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,
Expand Down Expand Up @@ -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.
Expand Down
58 changes: 58 additions & 0 deletions tests/unit/easydynamics/utils/test_fit_target.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
# SPDX-FileCopyrightText: 2026 EasyScience contributors <https://github.com/easyscience>
# 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'
Loading