From e34c4aedaff6c17105b0294cba5c8dd396d80147 Mon Sep 17 00:00:00 2001 From: Jan-Lukas Wynen Date: Thu, 5 Mar 2026 16:14:09 +0100 Subject: [PATCH 01/16] Load time-dependent transformations --- .../essreduce/src/ess/reduce/nexus/types.py | 6 +- .../src/ess/reduce/nexus/workflow.py | 84 +++++++++++++++---- 2 files changed, 72 insertions(+), 18 deletions(-) diff --git a/packages/essreduce/src/ess/reduce/nexus/types.py b/packages/essreduce/src/ess/reduce/nexus/types.py index 05dcbb62e..eb775a44e 100644 --- a/packages/essreduce/src/ess/reduce/nexus/types.py +++ b/packages/essreduce/src/ess/reduce/nexus/types.py @@ -280,7 +280,7 @@ class NeXusTransformationChain( @dataclass class NeXusTransformation(Generic[Component, RunType]): - value: sc.Variable + value: sc.Variable | sc.DataArray @staticmethod def from_chain( @@ -295,8 +295,8 @@ def from_chain( therefore currently raise an error if the transformation chain does not compute to a scalar. """ - if chain.transformations.sizes != {}: - raise ValueError(f"Expected scalar transformation, got {chain}") + # if chain.transformations.sizes != {}: + # raise ValueError(f"Expected scalar transformation, got {chain}") transform = chain.compute() return NeXusTransformation(value=transform) diff --git a/packages/essreduce/src/ess/reduce/nexus/workflow.py b/packages/essreduce/src/ess/reduce/nexus/workflow.py index 8d1939cb9..7926d70be 100644 --- a/packages/essreduce/src/ess/reduce/nexus/workflow.py +++ b/packages/essreduce/src/ess/reduce/nexus/workflow.py @@ -8,6 +8,7 @@ from copy import deepcopy from typing import Any, TypeVar +import numpy as np import sciline import sciline.typing import scipp as sc @@ -275,26 +276,51 @@ def load_nexus_data( def get_transformation_chain( - detector: NeXusComponent[Component, RunType], + component: NeXusComponent[Component, RunType], ) -> NeXusTransformationChain[Component, RunType]: """ - Extract the transformation chain from a NeXus detector group. + Extract the transformation chain from a NeXus component group. Parameters ---------- - detector: - NeXus detector group. + component: + NeXus component group. """ - chain = detector['depends_on'] + chain = component['depends_on'] return NeXusTransformationChain[Component, RunType](chain) -def _time_filter(transform: sc.DataArray) -> sc.Variable: +def _collapse_runs(transform: sc.DataArray, dim: str) -> sc.DataArray: + """Collapse runs of equal values into a single value.""" + # Find indices where the data changes + different_from_previous = np.hstack( + [True, ~np.isclose(transform.values[:-1], transform.values[1:])] + ) + change_indices = np.flatnonzero(different_from_previous) + if change_indices.shape == transform.shape: + return transform # Return early to avoid expensive indexing + # Get unique values + unique_values = transform[change_indices] + + # Make bin-edges and extend range to include the whole measurement + last = unique_values.coords[dim][-1] + unique_values.coords[dim] = sc.concat( + [ + sc.epoch(unit=last.unit), + unique_values.coords[dim][1:], + # Surely, no experiment will last more than 10 years... + last + sc.scalar(10, unit='Y').to(unit=last.unit), + ], + dim=dim, + ) + + return unique_values + + +def _time_filter(transform: sc.DataArray) -> sc.Variable | sc.DataArray: if transform.ndim == 0 or transform.sizes == {'time': 1}: return transform.data.squeeze() - raise ValueError( - f"Transform is time-dependent: {transform}, but no filter is provided." - ) + return _collapse_runs(transform, dim='time') def to_transformation( @@ -369,6 +395,10 @@ def get_calibrated_detector( The data array is reshaped to the logical detector shape, by folding the data array along the detector_number dimension. + The output contains pixel positions computed from ``transform`` and ``offset``. + If ``transform`` is time-dependent, the output contains a 'time' dimension + and coordinate corresponding to the time coordinate of ``transform``. + Parameters ---------- detector: @@ -401,9 +431,17 @@ def get_calibrated_detector( else: transform_value = transform.value position = transform_value * offsets - return EmptyDetector[RunType]( - da.assign_coords(position=position + offset.to(unit=position.unit)) - ) + + position = position + offset.to(unit=position.unit) + if isinstance(position, sc.DataArray): # time-dependent transform + # Store position and time as separate coords because we can't store data arrays. + return EmptyDetector[RunType]( + da.broadcast( + dims=['time', *da.dims], shape=[position.sizes['time'], *da.shape] + ).assign_coords(position=position.data, time=position.coords['time']) + ) + + return EmptyDetector[RunType](da.assign_coords(position=position)) def assemble_detector_data( @@ -422,13 +460,30 @@ def assemble_detector_data( neutron_data: Neutron data array (events or histogram). """ - if neutron_data.bins is not None: + detector_coords = dict(detector.coords) + position = detector_coords.get('position') + if neutron_data.is_binned: neutron_data = nexus.group_event_data( event_data=neutron_data, detector_number=detector.coords['detector_number'] ) + if position is not None and 'time' in position.dims: + del detector_coords['position'] + time = detector_coords.pop('time') + pos_lookup = sc.lookup( + sc.DataArray(position, coords={'time': time}), dim='time' + ) + neutron_data.bins.coords['position'] = pos_lookup[ + neutron_data.bins.coords['event_time_zero'] + ] + else: + if position is not None and 'time' in position.dims: + raise NotImplementedError( + "Time-dependent positions are not yet supported for histogram data." + ) + return RawDetector[RunType]( _add_variances(neutron_data) - .assign_coords(detector.coords) + .assign_coords(detector_coords) .assign_masks(detector.masks) ) @@ -659,7 +714,6 @@ def load_source_metadata_from_nexus( definitions["NXdetector"] = _StrippedDetector definitions["NXmonitor"] = _StrippedMonitor - _common_providers = ( gravity_vector_neg_y, file_path_to_file_spec, From 2f58b7c32dd9042ac35525350a060121bc5e9e23 Mon Sep 17 00:00:00 2001 From: Jan-Lukas Wynen Date: Wed, 11 Mar 2026 10:52:21 +0100 Subject: [PATCH 02/16] Update NXtransformation docs --- .../essreduce/src/ess/reduce/nexus/types.py | 22 ++++++++----------- 1 file changed, 9 insertions(+), 13 deletions(-) diff --git a/packages/essreduce/src/ess/reduce/nexus/types.py b/packages/essreduce/src/ess/reduce/nexus/types.py index eb775a44e..07161313d 100644 --- a/packages/essreduce/src/ess/reduce/nexus/types.py +++ b/packages/essreduce/src/ess/reduce/nexus/types.py @@ -280,25 +280,21 @@ class NeXusTransformationChain( @dataclass class NeXusTransformation(Generic[Component, RunType]): + """A NeXus transformation computed from a transformation chain. + + If the transformation is time-dependent, it is stored as a data array + with a 'time' coordinate. + Otherwise, the transformation is stored as a variable. + """ + value: sc.Variable | sc.DataArray @staticmethod def from_chain( chain: NeXusTransformationChain[Component, RunType], ) -> 'NeXusTransformation[Component, RunType]': - """ - Convert a transformation chain to a single transformation. - - As transformation chains may be time-dependent, this method will need to select - a specific time point to convert to a single transformation. This may include - averaging as well as threshold checks. This is not implemented yet and we - therefore currently raise an error if the transformation chain does not compute - to a scalar. - """ - # if chain.transformations.sizes != {}: - # raise ValueError(f"Expected scalar transformation, got {chain}") - transform = chain.compute() - return NeXusTransformation(value=transform) + """Convert a transformation chain to a single transformation.""" + return NeXusTransformation(value=chain.compute()) class RawChoppers( From 930c1d2c8c8402a47c97970e530a2ac7b33286cb Mon Sep 17 00:00:00 2001 From: Jan-Lukas Wynen Date: Fri, 13 Mar 2026 14:59:30 +0100 Subject: [PATCH 03/16] Use tighter lower bound --- packages/essreduce/src/ess/reduce/nexus/workflow.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/essreduce/src/ess/reduce/nexus/workflow.py b/packages/essreduce/src/ess/reduce/nexus/workflow.py index 7926d70be..903fc2b47 100644 --- a/packages/essreduce/src/ess/reduce/nexus/workflow.py +++ b/packages/essreduce/src/ess/reduce/nexus/workflow.py @@ -306,8 +306,8 @@ def _collapse_runs(transform: sc.DataArray, dim: str) -> sc.DataArray: last = unique_values.coords[dim][-1] unique_values.coords[dim] = sc.concat( [ - sc.epoch(unit=last.unit), - unique_values.coords[dim][1:], + # bin-edges are left-inclusive, so we can start with coord[0] as first edge + unique_values.coords[dim], # Surely, no experiment will last more than 10 years... last + sc.scalar(10, unit='Y').to(unit=last.unit), ], From 34223b7324992ba7fa905f6a83040a8c5ea0fc48 Mon Sep 17 00:00:00 2001 From: Jan-Lukas Wynen Date: Fri, 13 Mar 2026 15:02:35 +0100 Subject: [PATCH 04/16] Add time dimension The previous attempt added the time as an event coord. This makes grouping by time-dependent coords more difficult down the line. It is not apparent which event coords depend on a slow timescale that we might later want to group or bin into and which coords truly depend on the events. --- .../src/ess/reduce/nexus/workflow.py | 19 +++++++++---------- 1 file changed, 9 insertions(+), 10 deletions(-) diff --git a/packages/essreduce/src/ess/reduce/nexus/workflow.py b/packages/essreduce/src/ess/reduce/nexus/workflow.py index 903fc2b47..4cc2d0578 100644 --- a/packages/essreduce/src/ess/reduce/nexus/workflow.py +++ b/packages/essreduce/src/ess/reduce/nexus/workflow.py @@ -461,21 +461,20 @@ def assemble_detector_data( Neutron data array (events or histogram). """ detector_coords = dict(detector.coords) - position = detector_coords.get('position') if neutron_data.is_binned: neutron_data = nexus.group_event_data( event_data=neutron_data, detector_number=detector.coords['detector_number'] ) - if position is not None and 'time' in position.dims: - del detector_coords['position'] - time = detector_coords.pop('time') - pos_lookup = sc.lookup( - sc.DataArray(position, coords={'time': time}), dim='time' - ) - neutron_data.bins.coords['position'] = pos_lookup[ - neutron_data.bins.coords['event_time_zero'] - ] + if 'time' in detector.dims: + # Give the neutron data a 'time' dimension matching the times in the + # detector data. Preserve the `event_time_zero` event coord. + # This is needed to add time-dependent detector coords and masks below. + neutron_data = neutron_data.bin( + event_time_zero=detector_coords['time'].rename(time='event_time_zero') + ).rename_dims(event_time_zero='time') + neutron_data.coords['time'] = neutron_data.coords.pop('event_time_zero') else: + position = detector_coords.get('position') if position is not None and 'time' in position.dims: raise NotImplementedError( "Time-dependent positions are not yet supported for histogram data." From 9f50700a547b3f4304f10d4e477624220357149b Mon Sep 17 00:00:00 2001 From: Jan-Lukas Wynen Date: Mon, 16 Mar 2026 09:24:42 +0100 Subject: [PATCH 05/16] Bump scipp to 26.3.1 Needed for vector lookup. --- packages/essreduce/pyproject.toml | 2 +- pixi.lock | 14 +++++++------- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/packages/essreduce/pyproject.toml b/packages/essreduce/pyproject.toml index 374d65426..67985d8bd 100644 --- a/packages/essreduce/pyproject.toml +++ b/packages/essreduce/pyproject.toml @@ -31,7 +31,7 @@ dynamic = ["version"] dependencies = [ "sciline>=25.11.0", - "scipp>=26.3.0", + "scipp>=26.3.1", "scippneutron>=25.11.1", "scippnexus>=25.06.0", ] diff --git a/pixi.lock b/pixi.lock index 214a9f289..caea026e5 100644 --- a/pixi.lock +++ b/pixi.lock @@ -10998,7 +10998,7 @@ packages: timestamp: 1758743805063 - pypi: ./packages/essdiffraction name: essdiffraction - version: 0.1.dev2567+g6d663e796.d20260417 + version: 26.4.2.dev3494+g34223b73.d20260420 sha256: f7442fcb8892eb5baf1f2ab6b3206185a64df9d910462810d00d61961a1eb16f requires_dist: - dask>=2022.1.0 @@ -11036,7 +11036,7 @@ packages: requires_python: '>=3.11' - pypi: ./packages/essimaging name: essimaging - version: 26.4.1.dev332+g6d663e79.d20260417 + version: 26.4.1.dev2050+g34223b73.d20260420 sha256: f0070a5ae1f7957e8ed16a9a8e451dc47af25af14056a52bea18969ccdfa3aff requires_dist: - dask>=2022.1.0 @@ -11069,7 +11069,7 @@ packages: requires_python: '>=3.11' - pypi: ./packages/essnmx name: essnmx - version: 26.4.1.dev332+g6d663e79.d20260417 + version: 26.4.1.dev2050+g34223b73.d20260420 sha256: 55671f87213d0cad915b5def554cb8380e6bcc8f2a0af5acbcdc8a8ebcfd9531 requires_dist: - dask>=2022.1.0 @@ -11119,11 +11119,11 @@ packages: requires_python: '>=3.11' - pypi: ./packages/essreduce name: essreduce - version: 26.4.1.dev347+g6d663e79.d20260417 - sha256: 13cb0465a26df32340f26d3c9b42d1765542d30cdf0d09b87298285872d5fdee + version: 26.4.1.dev2065+g34223b73.d20260420 + sha256: 043a6aefd64757fb4d669459aa893cb3a9d7e81aa6591d9d6ce8fcc0f79a545b requires_dist: - sciline>=25.11.0 - - scipp>=26.3.0 + - scipp>=26.3.1 - scippneutron>=25.11.1 - scippnexus>=25.6.0 - graphviz>=0.20 ; extra == 'test' @@ -11152,7 +11152,7 @@ packages: requires_python: '>=3.11' - pypi: ./packages/essreflectometry name: essreflectometry - version: 0.1.dev2567+g6d663e796.d20260417 + version: 26.4.1.dev3398+g34223b73.d20260420 sha256: 1bdaf0414f6474e2d20b379284338a01705165a8ebabc9848312675ca2a89b0e requires_dist: - dask>=2022.1.0 From a820414920f03db61636eafe5857043523e617c8 Mon Sep 17 00:00:00 2001 From: Jan-Lukas Wynen Date: Wed, 18 Mar 2026 10:32:49 +0100 Subject: [PATCH 06/16] Fix test for time dependent transform --- .../essreduce/tests/nexus/workflow_test.py | 33 +++++++++++++------ 1 file changed, 23 insertions(+), 10 deletions(-) diff --git a/packages/essreduce/tests/nexus/workflow_test.py b/packages/essreduce/tests/nexus/workflow_test.py index 9128c47e8..118691aa8 100644 --- a/packages/essreduce/tests/nexus/workflow_test.py +++ b/packages/essreduce/tests/nexus/workflow_test.py @@ -117,6 +117,29 @@ def test_can_compute_position_of_group(depends_on: snx.TransformationChain) -> N assert_identical(workflow.compute_position(trans), position) +def test_can_compute_position_of_group_time_dependent( + time_dependent_depends_on: snx.TransformationChain, +) -> None: + position = sc.DataArray( + sc.vectors( + dims=['time'], + values=[[1.0, 1.0, 0.0], [1.0, 2.0, 0.0], [1.0, 3.0, 0.0]], + unit='m', + ), + coords={'time': sc.array(dims=['time'], values=[0.0, 1.0, 2.0], unit='s')}, + ) + + group = workflow.NeXusComponent[snx.NXsource, SampleRun]( + sc.DataGroup(depends_on=time_dependent_depends_on) + ) + chain = workflow.get_transformation_chain(group) + trans = workflow.to_transformation( + chain, + interval=TimeInterval(slice(None, None)), + ) + assert_identical(workflow.compute_position(trans), position) + + def test_to_transform_with_positional_time_interval( time_dependent_depends_on: snx.TransformationChain, ) -> None: @@ -172,16 +195,6 @@ def test_to_transform_with_label_based_time_interval_single_point( assert sc.identical(transform * origin, sc.vector([1.0, 3.0, 0.0], unit='m')) -def test_to_transform_raises_if_interval_does_not_yield_unique_value( - time_dependent_depends_on: snx.TransformationChain, -) -> None: - with pytest.raises(ValueError, match='Transform is time-dependent'): - workflow.to_transformation( - time_dependent_depends_on, - TimeInterval(slice(sc.scalar(0.1, unit='s'), sc.scalar(1.9, unit='s'))), - ) - - def test_given_no_sample_load_nexus_sample_returns_group_with_origin_depends_on( loki_tutorial_sample_run_60250: Path, ) -> None: From 38e7f50c4e5ecce7c0027b53f7348a425358739e Mon Sep 17 00:00:00 2001 From: Jan-Lukas Wynen Date: Thu, 19 Mar 2026 13:59:30 +0100 Subject: [PATCH 07/16] Remove nx_class_for_crystal --- packages/essreduce/src/ess/reduce/nexus/workflow.py | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/essreduce/src/ess/reduce/nexus/workflow.py b/packages/essreduce/src/ess/reduce/nexus/workflow.py index 4cc2d0578..4cbeedfbd 100644 --- a/packages/essreduce/src/ess/reduce/nexus/workflow.py +++ b/packages/essreduce/src/ess/reduce/nexus/workflow.py @@ -727,7 +727,6 @@ def load_source_metadata_from_nexus( load_nexus_component, load_all_nexus_components, data_by_name, - nx_class_for_crystal, nx_class_for_detector, nx_class_for_monitor, nx_class_for_source, From eda688241979485380c0e6f3a233249257e4c585 Mon Sep 17 00:00:00 2001 From: Jan-Lukas Wynen Date: Thu, 19 Mar 2026 15:37:18 +0100 Subject: [PATCH 08/16] Add dedicated Position class --- packages/essnmx/src/ess/nmx/workflows.py | 10 ++-- .../essreduce/src/ess/reduce/nexus/types.py | 51 +++++++++++++++++-- .../src/ess/reduce/unwrap/to_wavelength.py | 6 +-- .../essreduce/tests/nexus/workflow_test.py | 6 +-- 4 files changed, 59 insertions(+), 14 deletions(-) diff --git a/packages/essnmx/src/ess/nmx/workflows.py b/packages/essnmx/src/ess/nmx/workflows.py index cdf6496a2..32e713469 100644 --- a/packages/essnmx/src/ess/nmx/workflows.py +++ b/packages/essnmx/src/ess/nmx/workflows.py @@ -134,8 +134,8 @@ def assemble_sample_metadata( return NMXSampleMetadata( name=sample_name, - crystal_rotation=crystal_rotation, - position=sample_position, + crystal_rotation=crystal_rotation.position, + position=sample_position.position, ) @@ -143,7 +143,7 @@ def assemble_source_metadata( source_position: Position[snx.NXsource, SampleRun], ) -> NMXSourceMetadata: """Assemble source metadata for NMX reduction workflow.""" - return NMXSourceMetadata(position=source_position) + return NMXSourceMetadata(position=source_position.position) def _decide_fast_axis(da: sc.DataArray) -> str: @@ -226,7 +226,7 @@ def assemble_detector_metadata( slow_axis_vector = axis_vectors[_slow_axis].to(unit=t_unit) x_pixel_size = _decide_step(empty_detector.coords['x_pixel_offset']) y_pixel_size = _decide_step(empty_detector.coords['y_pixel_offset']) - distance = sc.norm(origin - source_position.to(unit=origin.unit)) + distance = sc.norm(origin - source_position.position.to(unit=origin.unit)) # We save the first pixel position so that DIALS can read use it. flattened = empty_detector.flatten(to='detector_number') @@ -234,7 +234,7 @@ def assemble_detector_metadata( first_pixel_position = flattened['detector_number', first_pixel_number].coords[ 'position' ] - first_pixel_position_from_sample = first_pixel_position - sample_position + first_pixel_position_from_sample = first_pixel_position - sample_position.position return NMXDetectorMetadata( detector_name=detector_component['nexus_component_name'], diff --git a/packages/essreduce/src/ess/reduce/nexus/types.py b/packages/essreduce/src/ess/reduce/nexus/types.py index 07161313d..2d4ad93df 100644 --- a/packages/essreduce/src/ess/reduce/nexus/types.py +++ b/packages/essreduce/src/ess/reduce/nexus/types.py @@ -2,7 +2,7 @@ from dataclasses import dataclass from pathlib import Path -from typing import Any, BinaryIO, Generic, NewType, TypeVar +from typing import Any, BinaryIO, Generic, NewType, TypeVar,Self import sciline import scipp as sc @@ -185,8 +185,53 @@ class NeXusData(sciline.Scope[Component, RunType, sc.DataArray], sc.DataArray): """ -class Position(sciline.Scope[Component, RunType, sc.Variable], sc.Variable): - """Position of a component such as source, sample, monitor, or detector.""" +@dataclass(init=False, repr=False, slots=True) +class Position(Generic[Component, RunType]): + """Position of a component such as source, sample, monitor, or detector. + + The position can depend on time. In this case, a time coordinate is also stored. + Use ``position`` to get the position if it is scalar, or ``positions`` + to get the position as a (potentially time-dependent) DataArray. + """ + _position: sc.Variable + _time: sc.Variable | None + + def __init__(self, pos: sc.DataArray|sc.Variable)->None: + if pos.ndim == 0: + self._position = pos.data if isinstance(pos, sc.DataArray) else pos + self._time = None + else: + if not isinstance(pos, sc.DataArray): + raise sc.DimensionError("Position is not a scalar, so it must be a DataArray") + self._position = pos.data + self._time = pos.coords['time'] + + @property + def is_dynamic(self)->bool: + return self._time is not None + + @property + def position(self) -> sc.Variable: + if self.is_dynamic: + raise sc.DimensionError("Position is time-dependent, use `positions` instead.") + return self._position + + @property + def positions(self) -> sc.DataArray: + da = sc.DataArray(self._position) + if self._time is not None: + da.coords['time'] = self._time + return da + + def __str__(self)->str: + if self.is_dynamic: + time_str = f", time={self._time}" + else: + time_str = "" + return f"Position(position={self._position}{time_str})" + + def __repr__(self)->str: + return f"Position(position={self._position}, time={self._time})" class DetectorPositionOffset(sciline.Scope[RunType, sc.Variable], sc.Variable): diff --git a/packages/essreduce/src/ess/reduce/unwrap/to_wavelength.py b/packages/essreduce/src/ess/reduce/unwrap/to_wavelength.py index e3a40e777..de529ddcf 100644 --- a/packages/essreduce/src/ess/reduce/unwrap/to_wavelength.py +++ b/packages/essreduce/src/ess/reduce/unwrap/to_wavelength.py @@ -398,8 +398,8 @@ def detector_ltotal_from_straight_line_approximation( # TODO: scatter=True should not be hard-coded here graph = { **scn.conversion.graph.beamline.beamline(scatter=True), - 'source_position': lambda: source_position, - 'sample_position': lambda: sample_position, + 'source_position': lambda: source_position.position, + 'sample_position': lambda: sample_position.position, 'gravity': lambda: gravity, } return DetectorLtotal[RunType]( @@ -426,7 +426,7 @@ def monitor_ltotal_from_straight_line_approximation( """ graph = { **scn.conversion.graph.beamline.beamline(scatter=False), - 'source_position': lambda: source_position, + 'source_position': lambda: source_position.position, } return MonitorLtotal[RunType, MonitorType]( monitor_beamline.transform_coords( diff --git a/packages/essreduce/tests/nexus/workflow_test.py b/packages/essreduce/tests/nexus/workflow_test.py index 118691aa8..a5ba59f9d 100644 --- a/packages/essreduce/tests/nexus/workflow_test.py +++ b/packages/essreduce/tests/nexus/workflow_test.py @@ -114,7 +114,7 @@ def test_can_compute_position_of_group(depends_on: snx.TransformationChain) -> N chain, interval=TimeInterval(slice(None, None)), ) - assert_identical(workflow.compute_position(trans), position) + assert_identical(workflow.compute_position(trans).position, position) def test_can_compute_position_of_group_time_dependent( @@ -137,7 +137,7 @@ def test_can_compute_position_of_group_time_dependent( chain, interval=TimeInterval(slice(None, None)), ) - assert_identical(workflow.compute_position(trans), position) + assert_identical(workflow.compute_position(trans).positions, position) def test_to_transform_with_positional_time_interval( @@ -211,7 +211,7 @@ def test_given_no_sample_load_nexus_sample_returns_group_with_origin_depends_on( interval=TimeInterval(slice(None, None)), ) position = workflow.compute_position(transformation) - assert_identical(position, sc.vector([0.0, 0.0, 0.0], unit='m')) + assert_identical(position.position, sc.vector([0.0, 0.0, 0.0], unit='m')) def test_get_transformation_chain_raises_exception_if_position_not_found( From 5786260483a4915ce4286b5a050502f83a5a046b Mon Sep 17 00:00:00 2001 From: Jan-Lukas Wynen Date: Fri, 10 Apr 2026 11:13:29 +0200 Subject: [PATCH 09/16] Make time_filter a public parameter --- .../essreduce/src/ess/reduce/nexus/types.py | 28 +++++-- .../src/ess/reduce/nexus/workflow.py | 75 ++++++++++--------- .../essreduce/tests/nexus/workflow_test.py | 4 +- 3 files changed, 62 insertions(+), 45 deletions(-) diff --git a/packages/essreduce/src/ess/reduce/nexus/types.py b/packages/essreduce/src/ess/reduce/nexus/types.py index 2d4ad93df..28083061d 100644 --- a/packages/essreduce/src/ess/reduce/nexus/types.py +++ b/packages/essreduce/src/ess/reduce/nexus/types.py @@ -2,7 +2,7 @@ from dataclasses import dataclass from pathlib import Path -from typing import Any, BinaryIO, Generic, NewType, TypeVar,Self +from typing import Any, BinaryIO, Generic, NewType, TypeVar import sciline import scipp as sc @@ -193,27 +193,32 @@ class Position(Generic[Component, RunType]): Use ``position`` to get the position if it is scalar, or ``positions`` to get the position as a (potentially time-dependent) DataArray. """ + _position: sc.Variable _time: sc.Variable | None - def __init__(self, pos: sc.DataArray|sc.Variable)->None: + def __init__(self, pos: sc.DataArray | sc.Variable) -> None: if pos.ndim == 0: self._position = pos.data if isinstance(pos, sc.DataArray) else pos self._time = None else: if not isinstance(pos, sc.DataArray): - raise sc.DimensionError("Position is not a scalar, so it must be a DataArray") + raise sc.DimensionError( + "Position is not a scalar, so it must be a DataArray" + ) self._position = pos.data self._time = pos.coords['time'] @property - def is_dynamic(self)->bool: + def is_dynamic(self) -> bool: return self._time is not None @property def position(self) -> sc.Variable: if self.is_dynamic: - raise sc.DimensionError("Position is time-dependent, use `positions` instead.") + raise sc.DimensionError( + "Position is time-dependent, use `positions` instead." + ) return self._position @property @@ -223,14 +228,14 @@ def positions(self) -> sc.DataArray: da.coords['time'] = self._time return da - def __str__(self)->str: + def __str__(self) -> str: if self.is_dynamic: time_str = f", time={self._time}" else: time_str = "" return f"Position(position={self._position}{time_str})" - def __repr__(self)->str: + def __repr__(self) -> str: return f"Position(position={self._position}, time={self._time})" @@ -270,6 +275,15 @@ class TimeInterval(Generic[RunType]): value: slice +class TransformationTimeFilter(Generic[Component, RunType]): + """Filter for time-dependent transformations.""" + + def __new__(cls, x: Any) -> Any: + return x + + def __call__(self, transform: sc.DataArray) -> sc.Variable | sc.DataArray: ... + + @dataclass class NeXusFileSpec(Generic[RunType]): value: FilePath | NeXusFile | NeXusGroup diff --git a/packages/essreduce/src/ess/reduce/nexus/workflow.py b/packages/essreduce/src/ess/reduce/nexus/workflow.py index 4cbeedfbd..d91706170 100644 --- a/packages/essreduce/src/ess/reduce/nexus/workflow.py +++ b/packages/essreduce/src/ess/reduce/nexus/workflow.py @@ -6,9 +6,8 @@ import warnings from collections.abc import Iterable from copy import deepcopy -from typing import Any, TypeVar +from typing import Any, Never, TypeVar -import numpy as np import sciline import sciline.typing import scipp as sc @@ -51,6 +50,7 @@ RunType, Source, TimeInterval, + TransformationTimeFilter, UniqueComponent, ) @@ -290,44 +290,33 @@ def get_transformation_chain( return NeXusTransformationChain[Component, RunType](chain) -def _collapse_runs(transform: sc.DataArray, dim: str) -> sc.DataArray: - """Collapse runs of equal values into a single value.""" - # Find indices where the data changes - different_from_previous = np.hstack( - [True, ~np.isclose(transform.values[:-1], transform.values[1:])] +def reject_time_dependent_transform( + transform: sc.DataArray, +) -> Never: + """Raise a value error to forbid time-dependent transformations by default.""" + raise ValueError( + f"Transform is time-dependent: {transform}, but no filter is provided." ) - change_indices = np.flatnonzero(different_from_previous) - if change_indices.shape == transform.shape: - return transform # Return early to avoid expensive indexing - # Get unique values - unique_values = transform[change_indices] - - # Make bin-edges and extend range to include the whole measurement - last = unique_values.coords[dim][-1] - unique_values.coords[dim] = sc.concat( - [ - # bin-edges are left-inclusive, so we can start with coord[0] as first edge - unique_values.coords[dim], - # Surely, no experiment will last more than 10 years... - last + sc.scalar(10, unit='Y').to(unit=last.unit), - ], - dim=dim, - ) - - return unique_values -def _time_filter(transform: sc.DataArray) -> sc.Variable | sc.DataArray: +def _time_filter( + transform: sc.DataArray, + user_filter: TransformationTimeFilter[Component, RunType], +) -> sc.Variable | sc.DataArray: if transform.ndim == 0 or transform.sizes == {'time': 1}: return transform.data.squeeze() - return _collapse_runs(transform, dim='time') + return user_filter(transform) def to_transformation( - chain: NeXusTransformationChain[Component, RunType], interval: TimeInterval[RunType] + chain: NeXusTransformationChain[Component, RunType], + interval: TimeInterval[RunType], + time_filter: TransformationTimeFilter[ + Component, RunType + ] = reject_time_dependent_transform, ) -> NeXusTransformation[Component, RunType]: """ - Convert transformation chain into a single transformation matrix. + Convert a transformation chain into a single transformation matrix. If one or more transformations in the chain are time-dependent, the time interval is used to select a specific time point. If the interval is not a single time point, @@ -340,6 +329,9 @@ def to_transformation( Transformation chain. interval: Time interval to select from the transformation chain. + time_filter: + Callable to apply to time-dependent transformations. + Defaults to raising a :class:`ValueError`. """ chain = deepcopy(chain) @@ -362,9 +354,9 @@ def to_transformation( idx = label_based_index_to_positional_index( sizes=t.sizes, coord=time, index=interval.value ) - t.value = _time_filter(t.value[idx]) + t.value = _time_filter(t.value[idx], time_filter) else: - t.value = _time_filter(t.value['time', interval.value]) + t.value = _time_filter(t.value['time', interval.value], time_filter) return NeXusTransformation[Component, RunType].from_chain(chain) @@ -764,11 +756,14 @@ def LoadMonitorWorkflow( """Generic workflow for loading monitor data from a NeXus file.""" wf = sciline.Pipeline( (*_common_providers, *_monitor_providers), + params={ + PreopenNeXusFile: PreopenNeXusFile(False), + TransformationTimeFilter: reject_time_dependent_transform, + }, constraints=_gather_constraints( run_types=run_types, monitor_types=monitor_types ), ) - wf[PreopenNeXusFile] = PreopenNeXusFile(False) return wf @@ -778,10 +773,13 @@ def LoadDetectorWorkflow( """Generic workflow for loading detector data from a NeXus file.""" wf = sciline.Pipeline( (*_common_providers, *_detector_providers), + params={ + DetectorBankSizes: DetectorBankSizes({}), + PreopenNeXusFile: PreopenNeXusFile(False), + TransformationTimeFilter: reject_time_dependent_transform, + }, constraints=_gather_constraints(run_types=run_types, monitor_types=[]), ) - wf[DetectorBankSizes] = DetectorBankSizes({}) - wf[PreopenNeXusFile] = PreopenNeXusFile(False) return wf @@ -827,12 +825,15 @@ def GenericNeXusWorkflow( *_chopper_providers, *_metadata_providers, ), + params={ + DetectorBankSizes: DetectorBankSizes({}), + PreopenNeXusFile: PreopenNeXusFile(False), + TransformationTimeFilter: reject_time_dependent_transform, + }, constraints=_gather_constraints( run_types=run_types, monitor_types=monitor_types ), ) - wf[DetectorBankSizes] = DetectorBankSizes({}) - wf[PreopenNeXusFile] = PreopenNeXusFile(False) return wf diff --git a/packages/essreduce/tests/nexus/workflow_test.py b/packages/essreduce/tests/nexus/workflow_test.py index a5ba59f9d..50ba8c511 100644 --- a/packages/essreduce/tests/nexus/workflow_test.py +++ b/packages/essreduce/tests/nexus/workflow_test.py @@ -35,6 +35,7 @@ SampleRun, Source, TimeInterval, + TransformationTimeFilter, TransmissionMonitor, ) from ess.reduce.nexus.workflow import ( @@ -117,7 +118,7 @@ def test_can_compute_position_of_group(depends_on: snx.TransformationChain) -> N assert_identical(workflow.compute_position(trans).position, position) -def test_can_compute_position_of_group_time_dependent( +def test_time_dependent_position_with_filter( time_dependent_depends_on: snx.TransformationChain, ) -> None: position = sc.DataArray( @@ -136,6 +137,7 @@ def test_can_compute_position_of_group_time_dependent( trans = workflow.to_transformation( chain, interval=TimeInterval(slice(None, None)), + time_filter=TransformationTimeFilter(lambda t: t), ) assert_identical(workflow.compute_position(trans).positions, position) From 846c4119f6dad84c8c19f752082bf05b7ca9685b Mon Sep 17 00:00:00 2001 From: Jan-Lukas Wynen Date: Fri, 10 Apr 2026 13:26:31 +0200 Subject: [PATCH 10/16] Do not broadcast in time by default --- .../src/ess/reduce/nexus/__init__.py | 2 + .../src/ess/reduce/nexus/_nexus_loader.py | 51 +++++++++++++++++++ .../src/ess/reduce/nexus/workflow.py | 27 ++-------- 3 files changed, 58 insertions(+), 22 deletions(-) diff --git a/packages/essreduce/src/ess/reduce/nexus/__init__.py b/packages/essreduce/src/ess/reduce/nexus/__init__.py index c310ca4a0..e739a622f 100644 --- a/packages/essreduce/src/ess/reduce/nexus/__init__.py +++ b/packages/essreduce/src/ess/reduce/nexus/__init__.py @@ -15,6 +15,7 @@ from . import types from ._nexus_loader import ( compute_component_position, + compute_detector_position, extract_signal_data_array, group_event_data, load_all_components, @@ -29,6 +30,7 @@ __all__ = [ 'GenericNeXusWorkflow', 'compute_component_position', + 'compute_detector_position', 'extract_signal_data_array', 'group_event_data', 'load_all_components', diff --git a/packages/essreduce/src/ess/reduce/nexus/_nexus_loader.py b/packages/essreduce/src/ess/reduce/nexus/_nexus_loader.py index 100e9efc2..d14c7d298 100644 --- a/packages/essreduce/src/ess/reduce/nexus/_nexus_loader.py +++ b/packages/essreduce/src/ess/reduce/nexus/_nexus_loader.py @@ -23,6 +23,8 @@ NeXusFile, NeXusGroup, NeXusLocationSpec, + NeXusTransformation, + RunType, ) @@ -454,6 +456,55 @@ def _to_snx_selection(selection, *, for_events: bool) -> snx.typing.ScippIndex: return selection +def compute_detector_position( + da: sc.DataArray, + *, + transform: NeXusTransformation[snx.NXdetector, RunType], + # Strictly speaking we could apply an offset by modifying the transformation chain, + # using a more generic implementation. However, this may in general require + # extending the chain and it is currently not clear if that is desirable. As far as + # I am aware the offset is currently mainly used for handling files from other + # facilities and it is not clear if it is needed for ESS data and should be kept at + # all. + offset: sc.Variable, +) -> sc.Variable | sc.DataArray: + """Compute the positions of detector pixels. + + Parameters + ---------- + da: + Detector (event) data as returned by :func:`extract_signal_data_array`. + transform: + Transformation matrix for the detector. + offset: + Offset to add to the detector position. + + Returns + ------- + : + The detector position as a data array if ``transform`` is time-dependent + or as a variable otherwise. + """ + # Note: We apply offset as early as possible, i.e., right in this function + # the detector array from the raw loader NeXus group, to prevent a source of bugs. + # If the NXdetector in the file is not 1-D, we want to match the order of dims. + # zip_pixel_offsets otherwise yields a vector with dimensions in the order given + # by the x/y/z offsets. + offsets = snx.zip_pixel_offsets(da.coords) + # Get the dims in the order of the detector data array, but filter out dims that + # don't exist in the offsets (e.g. the detector data may have a 'time' dimension). + dims = [dim for dim in da.dims if dim in offsets.dims] + offsets = offsets.transpose(dims).copy() + # We use the unit of the offsets as this is likely what the user expects. + if transform.value.unit is not None and transform.value.unit != '': + transform_value = transform.value.to(unit=offsets.unit) + else: + transform_value = transform.value + position = transform_value * offsets + position += offset.to(unit=position.unit, copy=False) + return position + + def load_data( file_path: FilePath | NeXusFile | NeXusGroup, selection: snx.typing.ScippIndex | slice = (), diff --git a/packages/essreduce/src/ess/reduce/nexus/workflow.py b/packages/essreduce/src/ess/reduce/nexus/workflow.py index d91706170..6672ace43 100644 --- a/packages/essreduce/src/ess/reduce/nexus/workflow.py +++ b/packages/essreduce/src/ess/reduce/nexus/workflow.py @@ -407,30 +407,13 @@ def get_calibrated_detector( sizes := (bank_sizes or {}).get(detector.get('nexus_component_name')) ) is not None: da = da.fold(dim="detector_number", sizes=sizes) - # Note: We apply offset as early as possible, i.e., right in this function - # the detector array from the raw loader NeXus group, to prevent a source of bugs. - # If the NXdetector in the file is not 1-D, we want to match the order of dims. - # zip_pixel_offsets otherwise yields a vector with dimensions in the order given - # by the x/y/z offsets. - offsets = snx.zip_pixel_offsets(da.coords) - # Get the dims in the order of the detector data array, but filter out dims that - # don't exist in the offsets (e.g. the detector data may have a 'time' dimension). - dims = [dim for dim in da.dims if dim in offsets.dims] - offsets = offsets.transpose(dims).copy() - # We use the unit of the offsets as this is likely what the user expects. - if transform.value.unit is not None and transform.value.unit != '': - transform_value = transform.value.to(unit=offsets.unit) - else: - transform_value = transform.value - position = transform_value * offsets - position = position + offset.to(unit=position.unit) + position = nexus.compute_detector_position(da, transform=transform, offset=offset) + if isinstance(position, sc.DataArray): # time-dependent transform - # Store position and time as separate coords because we can't store data arrays. - return EmptyDetector[RunType]( - da.broadcast( - dims=['time', *da.dims], shape=[position.sizes['time'], *da.shape] - ).assign_coords(position=position.data, time=position.coords['time']) + raise ValueError( + "Time-dependent positions are not supported by default. Either select a " + "time interval or override `get_calibrated_detector`." ) return EmptyDetector[RunType](da.assign_coords(position=position)) From c983c9c9dd6011e050f0370b244ed675719af643 Mon Sep 17 00:00:00 2001 From: Jan-Lukas Wynen Date: Thu, 16 Apr 2026 13:05:32 +0200 Subject: [PATCH 11/16] Split Position and DynamicPosition --- packages/essreduce/src/ess/reduce/nexus/types.py | 8 ++++++-- .../essreduce/src/ess/reduce/nexus/workflow.py | 15 +++++++++++++++ 2 files changed, 21 insertions(+), 2 deletions(-) diff --git a/packages/essreduce/src/ess/reduce/nexus/types.py b/packages/essreduce/src/ess/reduce/nexus/types.py index 28083061d..b135bd473 100644 --- a/packages/essreduce/src/ess/reduce/nexus/types.py +++ b/packages/essreduce/src/ess/reduce/nexus/types.py @@ -185,9 +185,13 @@ class NeXusData(sciline.Scope[Component, RunType, sc.DataArray], sc.DataArray): """ +class Position(sciline.Scope[Component, RunType, sc.Variable], sc.Variable): + """Position of a component that does not move, such as source or sample.""" + + @dataclass(init=False, repr=False, slots=True) -class Position(Generic[Component, RunType]): - """Position of a component such as source, sample, monitor, or detector. +class DynamicPosition(Generic[Component, RunType]): + """Position of a potentially moving component such as an analyzer or detector. The position can depend on time. In this case, a time coordinate is also stored. Use ``position`` to get the position if it is scalar, or ``positions`` diff --git a/packages/essreduce/src/ess/reduce/nexus/workflow.py b/packages/essreduce/src/ess/reduce/nexus/workflow.py index 6672ace43..e655ddd24 100644 --- a/packages/essreduce/src/ess/reduce/nexus/workflow.py +++ b/packages/essreduce/src/ess/reduce/nexus/workflow.py @@ -25,6 +25,7 @@ Component, DetectorBankSizes, DetectorPositionOffset, + DynamicPosition, EmptyDetector, EmptyMonitor, Filename, @@ -365,9 +366,22 @@ def compute_position( transformation: NeXusTransformation[Component, RunType], ) -> Position[Component, RunType]: """Compute the position of a component from a transformation matrix.""" + if isinstance(transformation.value, sc.DataArray): + raise ValueError( + "Attempted to compute a static position from a time-dependent " + "transformation. Either provide a time interval parameter or " + "time filter." + ) return Position[Component, RunType](transformation.value * origin) +def compute_dynamic_position( + transformation: NeXusTransformation[Component, RunType], +) -> DynamicPosition[Component, RunType]: + """Compute the position of a component from a transformation matrix.""" + return DynamicPosition[Component, RunType](transformation.value * origin) + + def get_calibrated_detector( detector: NeXusComponent[snx.NXdetector, RunType], *, @@ -698,6 +712,7 @@ def load_source_metadata_from_nexus( get_transformation_chain, to_transformation, compute_position, + compute_dynamic_position, load_nexus_data, load_nexus_component, load_all_nexus_components, From 73341dc6a14a01a8af8bd061f3230aabad944ca4 Mon Sep 17 00:00:00 2001 From: Jan-Lukas Wynen Date: Fri, 17 Apr 2026 08:47:21 +0200 Subject: [PATCH 12/16] Fixup: Remove .position access --- packages/essnmx/src/ess/nmx/workflows.py | 10 +++---- .../src/ess/reduce/unwrap/to_wavelength.py | 6 ++-- .../essreduce/tests/nexus/workflow_test.py | 28 ++----------------- 3 files changed, 10 insertions(+), 34 deletions(-) diff --git a/packages/essnmx/src/ess/nmx/workflows.py b/packages/essnmx/src/ess/nmx/workflows.py index 32e713469..cdf6496a2 100644 --- a/packages/essnmx/src/ess/nmx/workflows.py +++ b/packages/essnmx/src/ess/nmx/workflows.py @@ -134,8 +134,8 @@ def assemble_sample_metadata( return NMXSampleMetadata( name=sample_name, - crystal_rotation=crystal_rotation.position, - position=sample_position.position, + crystal_rotation=crystal_rotation, + position=sample_position, ) @@ -143,7 +143,7 @@ def assemble_source_metadata( source_position: Position[snx.NXsource, SampleRun], ) -> NMXSourceMetadata: """Assemble source metadata for NMX reduction workflow.""" - return NMXSourceMetadata(position=source_position.position) + return NMXSourceMetadata(position=source_position) def _decide_fast_axis(da: sc.DataArray) -> str: @@ -226,7 +226,7 @@ def assemble_detector_metadata( slow_axis_vector = axis_vectors[_slow_axis].to(unit=t_unit) x_pixel_size = _decide_step(empty_detector.coords['x_pixel_offset']) y_pixel_size = _decide_step(empty_detector.coords['y_pixel_offset']) - distance = sc.norm(origin - source_position.position.to(unit=origin.unit)) + distance = sc.norm(origin - source_position.to(unit=origin.unit)) # We save the first pixel position so that DIALS can read use it. flattened = empty_detector.flatten(to='detector_number') @@ -234,7 +234,7 @@ def assemble_detector_metadata( first_pixel_position = flattened['detector_number', first_pixel_number].coords[ 'position' ] - first_pixel_position_from_sample = first_pixel_position - sample_position.position + first_pixel_position_from_sample = first_pixel_position - sample_position return NMXDetectorMetadata( detector_name=detector_component['nexus_component_name'], diff --git a/packages/essreduce/src/ess/reduce/unwrap/to_wavelength.py b/packages/essreduce/src/ess/reduce/unwrap/to_wavelength.py index de529ddcf..e3a40e777 100644 --- a/packages/essreduce/src/ess/reduce/unwrap/to_wavelength.py +++ b/packages/essreduce/src/ess/reduce/unwrap/to_wavelength.py @@ -398,8 +398,8 @@ def detector_ltotal_from_straight_line_approximation( # TODO: scatter=True should not be hard-coded here graph = { **scn.conversion.graph.beamline.beamline(scatter=True), - 'source_position': lambda: source_position.position, - 'sample_position': lambda: sample_position.position, + 'source_position': lambda: source_position, + 'sample_position': lambda: sample_position, 'gravity': lambda: gravity, } return DetectorLtotal[RunType]( @@ -426,7 +426,7 @@ def monitor_ltotal_from_straight_line_approximation( """ graph = { **scn.conversion.graph.beamline.beamline(scatter=False), - 'source_position': lambda: source_position.position, + 'source_position': lambda: source_position, } return MonitorLtotal[RunType, MonitorType]( monitor_beamline.transform_coords( diff --git a/packages/essreduce/tests/nexus/workflow_test.py b/packages/essreduce/tests/nexus/workflow_test.py index 50ba8c511..c8a8b52be 100644 --- a/packages/essreduce/tests/nexus/workflow_test.py +++ b/packages/essreduce/tests/nexus/workflow_test.py @@ -115,31 +115,7 @@ def test_can_compute_position_of_group(depends_on: snx.TransformationChain) -> N chain, interval=TimeInterval(slice(None, None)), ) - assert_identical(workflow.compute_position(trans).position, position) - - -def test_time_dependent_position_with_filter( - time_dependent_depends_on: snx.TransformationChain, -) -> None: - position = sc.DataArray( - sc.vectors( - dims=['time'], - values=[[1.0, 1.0, 0.0], [1.0, 2.0, 0.0], [1.0, 3.0, 0.0]], - unit='m', - ), - coords={'time': sc.array(dims=['time'], values=[0.0, 1.0, 2.0], unit='s')}, - ) - - group = workflow.NeXusComponent[snx.NXsource, SampleRun]( - sc.DataGroup(depends_on=time_dependent_depends_on) - ) - chain = workflow.get_transformation_chain(group) - trans = workflow.to_transformation( - chain, - interval=TimeInterval(slice(None, None)), - time_filter=TransformationTimeFilter(lambda t: t), - ) - assert_identical(workflow.compute_position(trans).positions, position) + assert_identical(workflow.compute_position(trans), position) def test_to_transform_with_positional_time_interval( @@ -213,7 +189,7 @@ def test_given_no_sample_load_nexus_sample_returns_group_with_origin_depends_on( interval=TimeInterval(slice(None, None)), ) position = workflow.compute_position(transformation) - assert_identical(position.position, sc.vector([0.0, 0.0, 0.0], unit='m')) + assert_identical(position, sc.vector([0.0, 0.0, 0.0], unit='m')) def test_get_transformation_chain_raises_exception_if_position_not_found( From 96b6b2633f8f27d855cac8740d1fc9437055ffd2 Mon Sep 17 00:00:00 2001 From: Jan-Lukas Wynen Date: Fri, 17 Apr 2026 09:16:19 +0200 Subject: [PATCH 13/16] Use clearer name --- packages/essreduce/src/ess/reduce/nexus/workflow.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/essreduce/src/ess/reduce/nexus/workflow.py b/packages/essreduce/src/ess/reduce/nexus/workflow.py index e655ddd24..f5472edd0 100644 --- a/packages/essreduce/src/ess/reduce/nexus/workflow.py +++ b/packages/essreduce/src/ess/reduce/nexus/workflow.py @@ -300,7 +300,7 @@ def reject_time_dependent_transform( ) -def _time_filter( +def _apply_time_filter( transform: sc.DataArray, user_filter: TransformationTimeFilter[Component, RunType], ) -> sc.Variable | sc.DataArray: @@ -355,9 +355,9 @@ def to_transformation( idx = label_based_index_to_positional_index( sizes=t.sizes, coord=time, index=interval.value ) - t.value = _time_filter(t.value[idx], time_filter) + t.value = _apply_time_filter(t.value[idx], time_filter) else: - t.value = _time_filter(t.value['time', interval.value], time_filter) + t.value = _apply_time_filter(t.value['time', interval.value], time_filter) return NeXusTransformation[Component, RunType].from_chain(chain) From 3c59b324020b80d695b0ce5138ea07cf03c9f3d1 Mon Sep 17 00:00:00 2001 From: Jan-Lukas Wynen Date: Fri, 17 Apr 2026 09:17:00 +0200 Subject: [PATCH 14/16] Remove unused import --- packages/essreduce/tests/nexus/workflow_test.py | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/essreduce/tests/nexus/workflow_test.py b/packages/essreduce/tests/nexus/workflow_test.py index c8a8b52be..c01fc2fb5 100644 --- a/packages/essreduce/tests/nexus/workflow_test.py +++ b/packages/essreduce/tests/nexus/workflow_test.py @@ -35,7 +35,6 @@ SampleRun, Source, TimeInterval, - TransformationTimeFilter, TransmissionMonitor, ) from ess.reduce.nexus.workflow import ( From a4c098054d06296d4e669c3f392752e788c7798b Mon Sep 17 00:00:00 2001 From: Jan-Lukas Wynen Date: Fri, 17 Apr 2026 09:31:48 +0200 Subject: [PATCH 15/16] Update docstring --- packages/essreduce/src/ess/reduce/nexus/workflow.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/essreduce/src/ess/reduce/nexus/workflow.py b/packages/essreduce/src/ess/reduce/nexus/workflow.py index f5472edd0..f332d4137 100644 --- a/packages/essreduce/src/ess/reduce/nexus/workflow.py +++ b/packages/essreduce/src/ess/reduce/nexus/workflow.py @@ -321,8 +321,8 @@ def to_transformation( If one or more transformations in the chain are time-dependent, the time interval is used to select a specific time point. If the interval is not a single time point, - an error is raised. This may be extended in the future to a more sophisticated - mechanism, e.g., averaging over the interval to remove noise. + ``time_filter`` is applied to the transformation. By default, this will raise an + exception. Provide a different filter to customize how time-dependence is handled. Parameters ---------- From 74b33c0482176b96c93706d31ec3d90637540d2e Mon Sep 17 00:00:00 2001 From: Jan-Lukas Wynen Date: Fri, 17 Apr 2026 13:06:42 +0200 Subject: [PATCH 16/16] Restore time-dependent tests --- .../essreduce/tests/nexus/workflow_test.py | 34 +++++++++++++++++++ 1 file changed, 34 insertions(+) diff --git a/packages/essreduce/tests/nexus/workflow_test.py b/packages/essreduce/tests/nexus/workflow_test.py index c01fc2fb5..74e888265 100644 --- a/packages/essreduce/tests/nexus/workflow_test.py +++ b/packages/essreduce/tests/nexus/workflow_test.py @@ -172,6 +172,40 @@ def test_to_transform_with_label_based_time_interval_single_point( assert sc.identical(transform * origin, sc.vector([1.0, 3.0, 0.0], unit='m')) +def test_to_transform_raises_if_interval_does_not_yield_unique_value( + time_dependent_depends_on: snx.TransformationChain, +) -> None: + with pytest.raises(ValueError, match='Transform is time-dependent'): + workflow.to_transformation( + time_dependent_depends_on, + TimeInterval(slice(sc.scalar(0.1, unit='s'), sc.scalar(1.9, unit='s'))), + ) + + +def test_to_transform_with_custom_time_filter( + time_dependent_depends_on: snx.TransformationChain, +) -> None: + def time_filter(transformation: sc.DataArray) -> sc.DataArray: + # -1* so we can see that the filter does something + return -1 * transformation + + transform = workflow.to_transformation( + time_dependent_depends_on, + TimeInterval(slice(sc.scalar(0.1, unit='s'), sc.scalar(1.9, unit='s'))), + time_filter=time_filter, + ).value + + expected = sc.DataArray( + sc.vectors( + dims=['time'], values=[[1.0, -1.0, 0.0], [1.0, -2.0, 0.0]], unit='m' + ), + coords={'time': sc.array(dims=['time'], values=[0.0, 1.0], unit='s')}, + ) + sc.testing.assert_identical( + transform * sc.vector([0.0, 0.0, 0.0], unit='m'), expected + ) + + def test_given_no_sample_load_nexus_sample_returns_group_with_origin_depends_on( loki_tutorial_sample_run_60250: Path, ) -> None: