From 88cfeddf563fa559d2660aaea5f789551b8f0d1d Mon Sep 17 00:00:00 2001 From: Gregory Tucker Date: Fri, 27 Feb 2026 10:21:31 +0100 Subject: [PATCH 01/21] [Fix] Remove time-dependence from geometry calcs The positions and orientation of components after the sample position are, strictly speaking, time-dependent parameters. Previously their transformation chains lacked this fidelity due to limitations in creating the NeXus Structure JSON via `moreniius`. Those limitations have been lifted and newly-generated BIFROST simulation files include NXtransformation groups with NXlog constituents. In order to calculate the correct analyzer and sample scattering angle for each detector pixel, the BIFROST workflow performs some geometry calculations in the coordinate frame which rotates with the detector tank, around the sample position. Since the analyzers and detectors are stationary in this frame, we can (and must) remove any time-dependence from their positions and orientations. This PR introduces a quick-and-dirty hack to only use the first time-point from any time-dependent transformation result for the - sample - analyzers - detectors --- src/ess/spectroscopy/indirect/kf.py | 29 +++++++++++++++++++++++++++++ 1 file changed, 29 insertions(+) diff --git a/src/ess/spectroscopy/indirect/kf.py b/src/ess/spectroscopy/indirect/kf.py index e62d09a..54f03e4 100644 --- a/src/ess/spectroscopy/indirect/kf.py +++ b/src/ess/spectroscopy/indirect/kf.py @@ -19,6 +19,14 @@ ) +def _no_time(x: sc.Variable | sc.DataArray) -> sc.Variable: + if isinstance(x, sc.DataArray) and 'time' in x.coords: + return x['time', 0].data + elif isinstance(x, sc.DataArray): + raise ValueError("Only `DataArray`s with a time-coordinate allowed") + return x + + def sample_analyzer_vector( sample_position: sc.Variable, analyzer_position: sc.Variable, @@ -52,6 +60,17 @@ def sample_analyzer_vector( The vector from the sample position to the interaction point on the analyzer for each detector element. """ + # FIXME time-dependent depends-on chains produce positions and transformations + # which are `scipp.DataArray`s with a 'time' coordinate. We don't need the + # time-dependence here since all calculations are done in the rotating + # detector-tank coordinate system where these *have no* time-dependence + # TODO: Verify that we are actually using the rotating detector-tank coordinate + # frame, otherwise we will misidentify the Q vector for each detector + sample_position = _no_time(sample_position) + analyzer_position = _no_time(analyzer_position) + analyzer_transform = _no_time(analyzer_transform) + detector_position = _no_time(detector_position) + # Scipp does not distinguish between coordinates and directions, so we need to do # some extra legwork to ensure we can apply the orientation transformation # _and_ obtain a dimensionless direction vector @@ -123,6 +142,16 @@ def analyzer_detector_vector( detector_position: sc.Variable, ) -> sc.Variable: """Calculate the analyzer-detector vector""" + # FIXME time-dependent depends-on chains produce positions and transformations + # which are `scipp.DataArray`s with a 'time' coordinate. We don't need the + # time-dependence here since all calculations are done in the rotating + # detector-tank coordinate system where these *have no* time-dependence + # TODO: Verify that we are actually using the rotating detector-tank coordinate + # frame, otherwise we will misidentify the Q vector for each detector + sample_position = _no_time(sample_position) + sample_analyzer_vector = _no_time(sample_analyzer_vector) # FIXME unnecessary? + detector_position = _no_time(detector_position) + analyzer_position = sample_position + sample_analyzer_vector.to( unit=sample_analyzer_vector.unit ) From a54746e631c2fcb70f2f0707d0a328e2248c4c55 Mon Sep 17 00:00:00 2001 From: Gregory Tucker Date: Fri, 27 Feb 2026 11:19:08 +0100 Subject: [PATCH 02/21] [Add] @inputs for detector->analyzer resolution The NeXus format specification now allows most base objects to specify two attributes which help define the *expected* beam path through a collection of instrument components: `@inputs` and `@outputs`. Updates to `moreniius` mean that the BIFROST NeXus Structure JSON now contains these attributes, including the one-to-many and many-to-one transitions between, e.g., the sample position and the 45 analyzers. Given a detector's HDF5 Group, it is possible to follow the @inputs attributes analagously to a depends-on chain to identify which analyzer the detector should have received its neutron events from. This commit adds functionality which has only been tested outside of the workflow, by defining the `DetectorAnalyzerMap` at repl scope and the patching `ess.bifrost.io.nexus._get_analyzer_for_detector_name` to use the static relational information. I imagine it needs work to mold it into an appropriate `sciline` workflow. --- src/ess/bifrost/io/nexus.py | 93 +++++++++++++++++++++++++++++++++---- src/ess/bifrost/types.py | 3 ++ 2 files changed, 86 insertions(+), 10 deletions(-) diff --git a/src/ess/bifrost/io/nexus.py b/src/ess/bifrost/io/nexus.py index 41b3297..25cd0ab 100644 --- a/src/ess/bifrost/io/nexus.py +++ b/src/ess/bifrost/io/nexus.py @@ -6,6 +6,7 @@ import scipp as sc import scippnexus as snx +from ess.bifrost.types import DetectorAnalyzerMap from ess.reduce.nexus import load_all_components, open_component_group from ess.reduce.nexus.types import NeXusAllLocationSpec, NeXusLocationSpec from ess.spectroscopy.types import ( @@ -59,20 +60,85 @@ def load_analyzers(file_spec: NeXusFileSpec[RunType]) -> Analyzers[RunType]: ) -def _get_analyzer_for_detector_name( - detector_name: str, analyzers: Analyzers[RunType] -) -> Analyzers[RunType]: - detector_index = int(detector_name.split('_', 1)[0]) - analyzer_index = str(detector_index - 2) - for name, analyzer in analyzers.items(): - if name.startswith(analyzer_index): - return analyzer - raise RuntimeError(f"No analyzer found for detector {detector_name}") +def _do_breadth_first_search(group, targets, obj_deque, obj_next): + """ + Look for a unique element of targets by following the 'next' for object in a queue + + Parameters + ---------- + group: HDF5 Group + The group that contains all possible next named groups + targets: + A structure with named targets that supports `name in targets` + obj_deque: + A queue.deque of HDF5 Groups to be checked + obj_next: + A function that extracts a list of named groups to check from a given group + """ + while len(obj_deque) > 0: + check = obj_next(obj_deque.popleft()) + matches = [element for element in check if element in targets] + if len(matches) > 1: + raise ValueError("Non-unique elmeent match") + if len(matches) == 1: + return matches[0] + for element in check: + obj_deque.append(group[element]) + raise ValueError("No unique element found") + + +def analyzer_search(hdf5_instrument_group, analyzers, hdf5_detector_group): + """ + Use a NeXus Group's @inputs attribute to find an analyzer given a detector group + + Parameters + ---------- + hdf5_instrument_group: hdf5.Group + works if inside of a context group + ``` + scippnexus.File(filename, 'r') as f: + hdf5_instrument_group = f['/entry/instrument'] + ``` + analyzers: Anything with __contains__(str), e.g. dict[str, hdf5.Group] + Something to identify whether we've found a valid analyzer (by name) + hdf5_detector_group: hdf5.Group + any of f['/entry/detector'][scippnexus.NXdectector].values() + """ + from queue import deque + + from h5py import Group + + def obj_inputs(obj: Group) -> list[str]: + """Return the specified preceding component(s) list""" + if 'inputs' not in obj.attrs: + raise ValueError('@inputs attribute required for this search to work') + val = obj.attrs['inputs'] + # Deal with nexusformat (Python module) or kafka-to-nexus (filewriter) + # silently converting a len(list[str]) == 1 attribute to a str attribute: + return [val] if isinstance(val, str) else val + + return _do_breadth_first_search( + hdf5_instrument_group, analyzers, deque([hdf5_detector_group]), obj_inputs + ) + + +def get_detector_analyzer_map(file_spec: NeXusFileSpec[RunType]) -> DetectorAnalyzerMap: + """Probably not the right sciline way to do this.""" + + from scippnexus import File, NXcrystal, NXdetector + + filename = file_spec.value + with File(filename, 'r') as file: + inst = file['entry/instrument'] + analyzers = inst[NXcrystal] + detectors = inst[NXdetector] + return {k: analyzer_search(inst, analyzers, v) for k, v in detectors.items()} def analyzer_for_detector( analyzers: Analyzers[RunType], detector_location: NeXusComponentLocationSpec[snx.NXdetector, RunType], + detector_analyzer_map: DetectorAnalyzerMap, ) -> Analyzer[RunType]: """Extract the analyzer for a given detector. @@ -98,8 +164,14 @@ def analyzer_for_detector( """ if detector_location.component_name is None: raise ValueError("Detector component name is None") + if ( + analyzer_name := detector_analyzer_map.get(detector_location.component_name) + ) is None: + raise RuntimeError( + f"No analyzer found for detector {detector_location.component_name}" + ) analyzer = snx.compute_positions( - _get_analyzer_for_detector_name(detector_location.component_name, analyzers), + analyzers[analyzer_name], store_transform='transform', ) return Analyzer[RunType]( @@ -117,4 +189,5 @@ def analyzer_for_detector( load_instrument_angle, load_sample_angle, moderator_class_for_source, + get_detector_analyzer_map, ) diff --git a/src/ess/bifrost/types.py b/src/ess/bifrost/types.py index 46a0018..38647c2 100644 --- a/src/ess/bifrost/types.py +++ b/src/ess/bifrost/types.py @@ -21,3 +21,6 @@ class McStasRawDetector(sciline.Scope[RunType, sc.DataArray], sc.DataArray): ... ArcEnergy = NewType('ArcEnergy', sc.Variable) + + +DetectorAnalyzerMap = NewType('DetectorAnalyzerMap', dict[str, str]) From 515e6471c8fc6faa042d1b128da48deaab03551a Mon Sep 17 00:00:00 2001 From: Jan-Lukas Wynen Date: Wed, 4 Mar 2026 16:58:35 +0100 Subject: [PATCH 03/21] Patches to use workflow --- ...bifrost-make-wavelength-lookup-table.ipynb | 2 +- src/ess/bifrost/io/nexus.py | 12 ++++--- src/ess/bifrost/normalization.py | 4 +-- src/ess/bifrost/single_crystal/workflow.py | 9 +++-- src/ess/bifrost/types.py | 2 +- src/ess/bifrost/workflow.py | 36 ++++++++++++------- src/ess/spectroscopy/types.py | 15 ++++---- tests/bifrost/workflow_test.py | 6 ++-- 8 files changed, 48 insertions(+), 38 deletions(-) diff --git a/docs/user-guide/bifrost/bifrost-make-wavelength-lookup-table.ipynb b/docs/user-guide/bifrost/bifrost-make-wavelength-lookup-table.ipynb index e864781..a8533cf 100644 --- a/docs/user-guide/bifrost/bifrost-make-wavelength-lookup-table.ipynb +++ b/docs/user-guide/bifrost/bifrost-make-wavelength-lookup-table.ipynb @@ -69,7 +69,7 @@ "bifrost_workflow = BifrostSimulationWorkflow(detector_names)\n", "bifrost_workflow[Filename[SampleRun]] = input_filename\n", "\n", - "M = nexus.types.EmptyMonitor[SampleRun, FrameMonitor3]\n", + "M = nexus.types.EmptyMonitor[SampleRun, NormalizationMonitor]\n", "C = RawChoppers[SampleRun]\n", "SOURCE = Position[snx.NXsource, SampleRun]\n", "SAMPLE = Position[snx.NXsample, SampleRun]\n", diff --git a/src/ess/bifrost/io/nexus.py b/src/ess/bifrost/io/nexus.py index 25cd0ab..88716d3 100644 --- a/src/ess/bifrost/io/nexus.py +++ b/src/ess/bifrost/io/nexus.py @@ -7,7 +7,7 @@ import scippnexus as snx from ess.bifrost.types import DetectorAnalyzerMap -from ess.reduce.nexus import load_all_components, open_component_group +from ess.reduce.nexus import load_all_components, open_component_group, open_nexus_file from ess.reduce.nexus.types import NeXusAllLocationSpec, NeXusLocationSpec from ess.spectroscopy.types import ( Analyzer, @@ -122,13 +122,15 @@ def obj_inputs(obj: Group) -> list[str]: ) -def get_detector_analyzer_map(file_spec: NeXusFileSpec[RunType]) -> DetectorAnalyzerMap: +def get_detector_analyzer_map( + file_spec: NeXusFileSpec[RunType], +) -> DetectorAnalyzerMap[RunType]: """Probably not the right sciline way to do this.""" - from scippnexus import File, NXcrystal, NXdetector + from scippnexus import NXcrystal, NXdetector filename = file_spec.value - with File(filename, 'r') as file: + with open_nexus_file(filename) as file: inst = file['entry/instrument'] analyzers = inst[NXcrystal] detectors = inst[NXdetector] @@ -138,7 +140,7 @@ def get_detector_analyzer_map(file_spec: NeXusFileSpec[RunType]) -> DetectorAnal def analyzer_for_detector( analyzers: Analyzers[RunType], detector_location: NeXusComponentLocationSpec[snx.NXdetector, RunType], - detector_analyzer_map: DetectorAnalyzerMap, + detector_analyzer_map: DetectorAnalyzerMap[RunType], ) -> Analyzer[RunType]: """Extract the analyzer for a given detector. diff --git a/src/ess/bifrost/normalization.py b/src/ess/bifrost/normalization.py index 074bf3b..41bfa23 100644 --- a/src/ess/bifrost/normalization.py +++ b/src/ess/bifrost/normalization.py @@ -7,8 +7,8 @@ from ess.reduce.uncertainty import broadcast_uncertainties from ess.spectroscopy.types import ( - FrameMonitor3, IncidentEnergyDetector, + NormalizationMonitor, NormalizedIncidentEnergyDetector, ProtonCharge, RunType, @@ -19,7 +19,7 @@ def normalize_by_monitor_and_proton_charge( detector: IncidentEnergyDetector[RunType], - monitor: WavelengthMonitor[RunType, FrameMonitor3], + monitor: WavelengthMonitor[RunType, NormalizationMonitor], proton_charge: ProtonCharge[RunType], uncertainty_broadcast_mode: UncertaintyBroadcastMode, ) -> NormalizedIncidentEnergyDetector[RunType]: diff --git a/src/ess/bifrost/single_crystal/workflow.py b/src/ess/bifrost/single_crystal/workflow.py index 88c7fdf..49addb6 100644 --- a/src/ess/bifrost/single_crystal/workflow.py +++ b/src/ess/bifrost/single_crystal/workflow.py @@ -7,9 +7,8 @@ from ess.reduce import unwrap as reduce_unwrap from ess.spectroscopy.indirect.time_of_flight import TofWorkflow from ess.spectroscopy.types import ( - FrameMonitor1, - FrameMonitor2, - FrameMonitor3, + ElasticMonitor, + NormalizationMonitor, SampleRun, ) @@ -40,7 +39,7 @@ def BifrostBraggPeakMonitorWorkflow() -> sciline.Pipeline: workflow = TofWorkflow( run_types=(SampleRun,), - monitor_types=(FrameMonitor1, FrameMonitor2, FrameMonitor3), + monitor_types=(ElasticMonitor, NormalizationMonitor), ) # Use the vanilla implementation instead of the indirect geometry one: workflow.insert(reduce_unwrap.to_wavelength.detector_wavelength_data) @@ -54,7 +53,7 @@ def BifrostBraggPeakMonitorWorkflow() -> sciline.Pipeline: def BifrostSimulationBraggPeakMonitorWorkflow() -> sciline.Pipeline: workflow = TofWorkflow( run_types=(SampleRun,), - monitor_types=(FrameMonitor1, FrameMonitor2, FrameMonitor3), + monitor_types=(ElasticMonitor, NormalizationMonitor), ) # Use the vanilla implementation instead of the indirect geometry one: workflow.insert(reduce_unwrap.to_wavelength.detector_wavelength_data) diff --git a/src/ess/bifrost/types.py b/src/ess/bifrost/types.py index 38647c2..16af2e8 100644 --- a/src/ess/bifrost/types.py +++ b/src/ess/bifrost/types.py @@ -23,4 +23,4 @@ class McStasRawDetector(sciline.Scope[RunType, sc.DataArray], sc.DataArray): ... ArcEnergy = NewType('ArcEnergy', sc.Variable) -DetectorAnalyzerMap = NewType('DetectorAnalyzerMap', dict[str, str]) +class DetectorAnalyzerMap(sciline.Scope[RunType, dict[str, str]], dict): ... diff --git a/src/ess/bifrost/workflow.py b/src/ess/bifrost/workflow.py index ff158bb..b328a5c 100644 --- a/src/ess/bifrost/workflow.py +++ b/src/ess/bifrost/workflow.py @@ -14,15 +14,15 @@ from ess.spectroscopy.indirect.ki import providers as ki_providers from ess.spectroscopy.indirect.time_of_flight import TofWorkflow from ess.spectroscopy.types import ( + ElasticMonitor, EmptyDetector, - FrameMonitor0, - FrameMonitor1, - FrameMonitor2, - FrameMonitor3, NeXusData, NeXusDetectorName, NeXusMonitorName, + NormalizationMonitor, + OverlapMonitor, ProtonCharge, + PSCMonitor, PulsePeriod, RawDetector, SampleRun, @@ -39,9 +39,10 @@ def default_parameters() -> dict[type, Any]: """Default parameters for BifrostWorkflow.""" return { - NeXusMonitorName[FrameMonitor1]: '090_frame_1', - NeXusMonitorName[FrameMonitor2]: '097_frame_2', - NeXusMonitorName[FrameMonitor3]: '110_frame_3', + NeXusMonitorName[ElasticMonitor]: "elastic_monitor", + NeXusMonitorName[NormalizationMonitor]: "normalization_monitor", + NeXusMonitorName[OverlapMonitor]: "overlap_monitor", + NeXusMonitorName[PSCMonitor]: "psc_monitor", PulsePeriod: 1.0 / sc.scalar(14.0, unit="Hz"), UncertaintyBroadcastMode: UncertaintyBroadcastMode.fail, } @@ -50,9 +51,10 @@ def default_parameters() -> dict[type, Any]: def simulation_default_parameters() -> dict[type, Any]: """Default parameters for BifrostSimulationWorkflow.""" return { - NeXusMonitorName[FrameMonitor1]: '090_frame_1', - NeXusMonitorName[FrameMonitor2]: '097_frame_2', - NeXusMonitorName[FrameMonitor3]: '110_frame_3', + NeXusMonitorName[ElasticMonitor]: "elastic_monitor", + NeXusMonitorName[NormalizationMonitor]: "normalization_monitor", + NeXusMonitorName[OverlapMonitor]: "overlap_monitor", + NeXusMonitorName[PSCMonitor]: "psc_monitor", PulsePeriod: 1.0 / sc.scalar(14.0, unit="Hz"), ProtonCharge[SampleRun]: sc.DataArray(sc.scalar(1.0, unit='pC')), UncertaintyBroadcastMode: UncertaintyBroadcastMode.fail, @@ -98,7 +100,12 @@ def BifrostSimulationWorkflow( """ workflow = TofWorkflow( run_types=(SampleRun,), - monitor_types=(FrameMonitor0, FrameMonitor1, FrameMonitor2, FrameMonitor3), + monitor_types=( + ElasticMonitor, + NormalizationMonitor, + OverlapMonitor, + PSCMonitor, + ), ) for provider in _SIMULATION_PROVIDERS: workflow.insert(provider) @@ -120,7 +127,12 @@ def BifrostWorkflow( """Data reduction workflow for BIFROST.""" workflow = TofWorkflow( run_types=(SampleRun,), - monitor_types=(FrameMonitor0, FrameMonitor1, FrameMonitor2, FrameMonitor3), + monitor_types=( + ElasticMonitor, + NormalizationMonitor, + OverlapMonitor, + PSCMonitor, + ), ) for provider in _PROVIDERS: workflow.insert(provider) diff --git a/src/ess/spectroscopy/types.py b/src/ess/spectroscopy/types.py index 2d85061..161f2da 100644 --- a/src/ess/spectroscopy/types.py +++ b/src/ess/spectroscopy/types.py @@ -36,20 +36,17 @@ SampleRun = reduce_t.SampleRun VanadiumRun = reduce_t.VanadiumRun -FrameMonitor0 = reduce_t.FrameMonitor0 -FrameMonitor1 = reduce_t.FrameMonitor1 -FrameMonitor2 = reduce_t.FrameMonitor2 -FrameMonitor3 = reduce_t.FrameMonitor3 +# BIFROST monitors +ElasticMonitor = NewType('ElasticMonitor', int) +NormalizationMonitor = NewType('NormalizationMonitor', int) +PSCMonitor = NewType('PSCMonitor', int) +OverlapMonitor = NewType('OverlapMonitor', int) # Type vars RunType = TypeVar("RunType", SampleRun, VanadiumRun) MonitorType = TypeVar( - "MonitorType", - FrameMonitor0, - FrameMonitor1, - FrameMonitor2, - FrameMonitor3, + "MonitorType", ElasticMonitor, NormalizationMonitor, PSCMonitor, OverlapMonitor ) # Time-of-flight types diff --git a/tests/bifrost/workflow_test.py b/tests/bifrost/workflow_test.py index 43206ad..5d5b1a1 100644 --- a/tests/bifrost/workflow_test.py +++ b/tests/bifrost/workflow_test.py @@ -16,10 +16,10 @@ from ess.spectroscopy.types import ( EnergyQDetector, Filename, - FrameMonitor3, LookupTableFilename, LookupTableRelativeErrorThreshold, NeXusDetectorName, + NormalizationMonitor, RawDetector, RawMonitor, SampleRun, @@ -63,7 +63,7 @@ def test_simulation_workflow_can_load_detector() -> None: def test_simulation_workflow_can_load_monitor(workflow: sciline.Pipeline) -> None: - result = workflow.compute(RawMonitor[SampleRun, FrameMonitor3]) + result = workflow.compute(RawMonitor[SampleRun, NormalizationMonitor]) assert result.bins is None assert 'position' in result.coords @@ -100,7 +100,7 @@ def test_simulation_workflow_can_compute_energy_data( def test_simulation_workflow_can_compute_wavelength_monitor( workflow: sciline.Pipeline, ) -> None: - monitor = workflow.compute(WavelengthMonitor[SampleRun, FrameMonitor3]) + monitor = workflow.compute(WavelengthMonitor[SampleRun, NormalizationMonitor]) assert set(monitor.dims) == {'time', 'incident_wavelength'} expected_coords = {'position', 'incident_wavelength', 'time'} assert expected_coords.issubset(monitor.coords) From d689bbb7b9512b695d1c790507fcfb89de3b6ab7 Mon Sep 17 00:00:00 2001 From: Jan-Lukas Wynen Date: Wed, 11 Mar 2026 10:07:42 +0100 Subject: [PATCH 04/21] Start handling time-dep analyzers --- src/ess/bifrost/detector.py | 6 +++++- src/ess/bifrost/workflow.py | 7 +------ src/ess/spectroscopy/indirect/conversion.py | 17 +++++++++++++---- src/ess/spectroscopy/indirect/kf.py | 17 ++++------------- 4 files changed, 23 insertions(+), 24 deletions(-) diff --git a/src/ess/bifrost/detector.py b/src/ess/bifrost/detector.py index 24acd57..cad5cce 100644 --- a/src/ess/bifrost/detector.py +++ b/src/ess/bifrost/detector.py @@ -8,6 +8,7 @@ from ess.spectroscopy.indirect.conversion import add_spectrometer_coords from ess.spectroscopy.types import ( + Analyzer, DetectorPositionOffset, EmptyDetector, NeXusComponent, @@ -78,6 +79,7 @@ def arc_and_channel_from_detector_number( def get_calibrated_detector_bifrost( detector: NeXusComponent[snx.NXdetector, RunType], + analyzer: Analyzer[RunType], *, transform: NeXusTransformation[snx.NXdetector, RunType], offset: DetectorPositionOffset[RunType], @@ -97,6 +99,8 @@ def get_calibrated_detector_bifrost( ---------- detector: Loaded NeXus detector. + analyzer: + Loaded analyzer parameters. transform: Transformation that determines the detector position. offset: @@ -131,7 +135,7 @@ def get_calibrated_detector_bifrost( da.coords['arc'] = arc da.coords['channel'] = channel - da = add_spectrometer_coords(da, primary_graph, secondary_graph) + da = add_spectrometer_coords(da, analyzer, primary_graph, secondary_graph) return EmptyDetector[RunType](da) diff --git a/src/ess/bifrost/workflow.py b/src/ess/bifrost/workflow.py index b328a5c..4f3fb57 100644 --- a/src/ess/bifrost/workflow.py +++ b/src/ess/bifrost/workflow.py @@ -51,13 +51,8 @@ def default_parameters() -> dict[type, Any]: def simulation_default_parameters() -> dict[type, Any]: """Default parameters for BifrostSimulationWorkflow.""" return { - NeXusMonitorName[ElasticMonitor]: "elastic_monitor", - NeXusMonitorName[NormalizationMonitor]: "normalization_monitor", - NeXusMonitorName[OverlapMonitor]: "overlap_monitor", - NeXusMonitorName[PSCMonitor]: "psc_monitor", - PulsePeriod: 1.0 / sc.scalar(14.0, unit="Hz"), + **default_parameters(), ProtonCharge[SampleRun]: sc.DataArray(sc.scalar(1.0, unit='pC')), - UncertaintyBroadcastMode: UncertaintyBroadcastMode.fail, } diff --git a/src/ess/spectroscopy/indirect/conversion.py b/src/ess/spectroscopy/indirect/conversion.py index fa4196b..d1fe9e3 100644 --- a/src/ess/spectroscopy/indirect/conversion.py +++ b/src/ess/spectroscopy/indirect/conversion.py @@ -13,6 +13,7 @@ ) from ..types import ( + Analyzer, EnergyQDetector, GravityVector, IncidentEnergyDetector, @@ -244,7 +245,8 @@ def add_incident_energy( def add_spectrometer_coords( - data: sc.DataArray, + detector: sc.DataArray, + analyzer: Analyzer[RunType], primary_graph: PrimarySpecCoordTransformGraph[RunType], secondary_graph: SecondarySpecCoordTransformGraph[RunType], ) -> sc.DataArray: @@ -252,10 +254,12 @@ def add_spectrometer_coords( Parameters ---------- - data: + detector: Data array with beamline coordinates "position", "source_position", and "sample_position". Does not need to contain events or flight times. + analyzer: + Data group with analyzer parameters. primary_graph: Coordinate transformation graph for the primary spectrometer. secondary_graph: @@ -269,7 +273,12 @@ def add_spectrometer_coords( Input data with added spectrometer coordinates. This includes "final_energy", "secondary_flight_time", and "L1". """ - return data.transform_coords( + # TODO lookup time-dep analyzers based on `data` and isert into graph + + # "analyzer_dspacing": lambda: analyzer["dspacing"], + # "analyzer_position": lambda: analyzer["position"], + # "analyzer_transform": lambda: analyzer["transform"], + return detector.transform_coords( ( 'final_energy', 'final_wavevector', @@ -278,7 +287,7 @@ def add_spectrometer_coords( 'secondary_flight_time', ), graph={**primary_graph, **secondary_graph}, - keep_intermediate=False, + keep_intermediate=True, # TODO keep_aliases=False, rename_dims=False, ) diff --git a/src/ess/spectroscopy/indirect/kf.py b/src/ess/spectroscopy/indirect/kf.py index 54f03e4..080a77d 100644 --- a/src/ess/spectroscopy/indirect/kf.py +++ b/src/ess/spectroscopy/indirect/kf.py @@ -10,7 +10,6 @@ ) from ess.spectroscopy.types import ( - Analyzer, DataAtSample, DataGroupedByRotation, PulsePeriod, @@ -19,6 +18,7 @@ ) +# TODO remove? def _no_time(x: sc.Variable | sc.DataArray) -> sc.Variable: if isinstance(x, sc.DataArray) and 'time' in x.coords: return x['time', 0].data @@ -240,30 +240,21 @@ def secondary_flight_time( return sc.to_unit(L2 / velocity, 'ms', copy=False) -def secondary_spectrometer_coordinate_transformation_graph( - analyzer: Analyzer[RunType], -) -> SecondarySpecCoordTransformGraph[RunType]: +def secondary_spectrometer_coordinate_transformation_graph() -> ( + SecondarySpecCoordTransformGraph[RunType] +): """Return a coordinate transformation graph for the secondary spectrometer. - Parameters - ---------- - analyzer: - Data group with analyzer parameters. - Returns ------- : Coordinate transformation graph for the secondary spectrometer. - The graph captures the relevant parameters of ``analyzer``. """ from scippneutron.conversion.beamline import beam_aligned_unit_vectors return SecondarySpecCoordTransformGraph[RunType]( { "a4": detector_geometric_a4, - "analyzer_dspacing": lambda: analyzer["dspacing"], - "analyzer_position": lambda: analyzer["position"], - "analyzer_transform": lambda: analyzer["transform"], "beam_aligned_unit_vectors": beam_aligned_unit_vectors, "detector_position": "position", "sample_analyzer_vector": sample_analyzer_vector, From 11ea8096c8bfe1f9b55b1a51f9cba816645d698f Mon Sep 17 00:00:00 2001 From: Jan-Lukas Wynen Date: Wed, 11 Mar 2026 14:13:21 +0100 Subject: [PATCH 05/21] Load analyzer w default mechanism --- src/ess/bifrost/io/nexus.py | 191 ++++++++++++++-------------------- src/ess/bifrost/types.py | 3 - src/ess/spectroscopy/types.py | 4 - 3 files changed, 76 insertions(+), 122 deletions(-) diff --git a/src/ess/bifrost/io/nexus.py b/src/ess/bifrost/io/nexus.py index 88716d3..eac6e37 100644 --- a/src/ess/bifrost/io/nexus.py +++ b/src/ess/bifrost/io/nexus.py @@ -3,19 +3,22 @@ """NeXus input/output for BIFROST.""" +import warnings + import scipp as sc import scippnexus as snx -from ess.bifrost.types import DetectorAnalyzerMap -from ess.reduce.nexus import load_all_components, open_component_group, open_nexus_file -from ess.reduce.nexus.types import NeXusAllLocationSpec, NeXusLocationSpec +from ess.reduce.nexus import open_component_group +from ess.reduce.nexus.types import NeXusLocationSpec from ess.spectroscopy.types import ( Analyzer, - Analyzers, InstrumentAngle, NeXusClass, + NeXusComponent, NeXusComponentLocationSpec, NeXusFileSpec, + NeXusTransformation, + Position, RunType, SampleAngle, ) @@ -50,146 +53,104 @@ def _load_experiment_parameter( return group[param_name][()]['value'] -def load_analyzers(file_spec: NeXusFileSpec[RunType]) -> Analyzers[RunType]: - """Load all analyzers in a NeXus file.""" - return Analyzers[RunType]( - load_all_components( - NeXusAllLocationSpec(filename=file_spec.value), - nx_class=snx.NXcrystal, +def load_analyzer_for_detector( + detector_location: NeXusComponentLocationSpec[snx.NXdetector, RunType], +) -> NeXusComponent[snx.NXcrystal, RunType]: + """Load the analyzer component for the given detector. + + This function searches for an ``NXcrystal`` in the inputs (via the + 'input' attribute) of the detector and loads the first NeXus group it finds. + """ + with open_component_group(detector_location, nx_class=snx.NXdetector) as det_group: + analyzer_group = _find_class_in_inputs( + group=det_group.parent, target=snx.NXcrystal, start=det_group ) - ) + return analyzer_group[()] -def _do_breadth_first_search(group, targets, obj_deque, obj_next): - """ - Look for a unique element of targets by following the 'next' for object in a queue +def _find_class_in_inputs( + group: snx.Group, target: type, start: snx.Group +) -> snx.Group: + """Search for a NeXus class in a group's inputs. + + This function uses a breadth-first search through ``'input'`` attributes. + It begins at ``start`` and walks along chains of inputs until a group with the + given class is found, the chain ends, or the chain leads outside ``group``. Parameters ---------- group: HDF5 Group The group that contains all possible next named groups - targets: - A structure with named targets that supports `name in targets` - obj_deque: - A queue.deque of HDF5 Groups to be checked - obj_next: - A function that extracts a list of named groups to check from a given group - """ - while len(obj_deque) > 0: - check = obj_next(obj_deque.popleft()) - matches = [element for element in check if element in targets] - if len(matches) > 1: - raise ValueError("Non-unique elmeent match") - if len(matches) == 1: - return matches[0] - for element in check: - obj_deque.append(group[element]) - raise ValueError("No unique element found") - - -def analyzer_search(hdf5_instrument_group, analyzers, hdf5_detector_group): - """ - Use a NeXus Group's @inputs attribute to find an analyzer given a detector group + target: + The NeXus class to look for. - Parameters - ---------- - hdf5_instrument_group: hdf5.Group - works if inside of a context group - ``` - scippnexus.File(filename, 'r') as f: - hdf5_instrument_group = f['/entry/instrument'] - ``` - analyzers: Anything with __contains__(str), e.g. dict[str, hdf5.Group] - Something to identify whether we've found a valid analyzer (by name) - hdf5_detector_group: hdf5.Group - any of f['/entry/detector'][scippnexus.NXdectector].values() + Returns + ------- + : + The group with the target NeXus class found within ``group``. """ - from queue import deque - - from h5py import Group - - def obj_inputs(obj: Group) -> list[str]: - """Return the specified preceding component(s) list""" - if 'inputs' not in obj.attrs: - raise ValueError('@inputs attribute required for this search to work') - val = obj.attrs['inputs'] - # Deal with nexusformat (Python module) or kafka-to-nexus (filewriter) - # silently converting a len(list[str]) == 1 attribute to a str attribute: - return [val] if isinstance(val, str) else val - - return _do_breadth_first_search( - hdf5_instrument_group, analyzers, deque([hdf5_detector_group]), obj_inputs - ) - - -def get_detector_analyzer_map( - file_spec: NeXusFileSpec[RunType], -) -> DetectorAnalyzerMap[RunType]: - """Probably not the right sciline way to do this.""" - - from scippnexus import NXcrystal, NXdetector - - filename = file_spec.value - with open_nexus_file(filename) as file: - inst = file['entry/instrument'] - analyzers = inst[NXcrystal] - detectors = inst[NXdetector] - return {k: analyzer_search(inst, analyzers, v) for k, v in detectors.items()} - - -def analyzer_for_detector( - analyzers: Analyzers[RunType], - detector_location: NeXusComponentLocationSpec[snx.NXdetector, RunType], - detector_analyzer_map: DetectorAnalyzerMap[RunType], + pending = [start] + while pending: + element = pending.pop(0) + if element.nx_class == target: + return element + for name in _get_inputs(element): + try: + pending.append(group[name]) + except KeyError: + warnings.warn(f"No '{name}' in NeXus group {group.name}", stacklevel=2) + continue + raise ValueError(f"No {target} found in the inputs of {start.name}") + + +def _get_inputs(group: snx.Group) -> list[str]: + try: + inputs = group.attrs['inputs'] + except KeyError: + return [] + # Deal with nexusformat (Python module) or kafka-to-nexus (filewriter) + # silently converting a len(list[str]) == 1 attribute to a str attribute: + return [inputs] if isinstance(inputs, str) else inputs + + +# This function is separate from load_analyzer_for_detector so we get the default +# behavior for resolving NXtransformations. +def get_calibrated_analyzer( + analyzer_component: NeXusComponent[snx.NXcrystal, RunType], + analyzer_transform: NeXusTransformation[snx.NXcrystal, RunType], + analyzer_position: Position[snx.NXcrystal, RunType], ) -> Analyzer[RunType]: - """Extract the analyzer for a given detector. - - Note - ---- - Depends heavily on the names of components being preceded by an instrument index, - and the analyzer and detector components being separated in index by 2. - If either condition changes, this function will need to be modified. + """Collect the data for a single analyzer. Parameters ---------- - analyzers: + analyzer_component: Data group of loaded analyzers. - detector_location: - The location of an NXdetector in the NeXus file. - The analyzer is identified based on this location. + analyzer_transform: + Transformation matrix of the analyzer. + analyzer_position: + The computed position vector of the analyzer. Returns ------- : - The analyzer for the given detector triplet. + A given analyzer. Only a subset of fields is returned. """ - if detector_location.component_name is None: - raise ValueError("Detector component name is None") - if ( - analyzer_name := detector_analyzer_map.get(detector_location.component_name) - ) is None: - raise RuntimeError( - f"No analyzer found for detector {detector_location.component_name}" - ) - analyzer = snx.compute_positions( - analyzers[analyzer_name], - store_transform='transform', - ) + return Analyzer[RunType]( sc.DataGroup( - dspacing=analyzer['d_spacing'], - position=analyzer['position'], - transform=analyzer['transform'], + dspacing=analyzer_component['d_spacing'], + position=analyzer_position, + transform=analyzer_transform, ) ) providers = ( - analyzer_for_detector, - load_analyzers, + get_calibrated_analyzer, + load_analyzer_for_detector, load_instrument_angle, load_sample_angle, moderator_class_for_source, - get_detector_analyzer_map, ) diff --git a/src/ess/bifrost/types.py b/src/ess/bifrost/types.py index 16af2e8..46a0018 100644 --- a/src/ess/bifrost/types.py +++ b/src/ess/bifrost/types.py @@ -21,6 +21,3 @@ class McStasRawDetector(sciline.Scope[RunType, sc.DataArray], sc.DataArray): ... ArcEnergy = NewType('ArcEnergy', sc.Variable) - - -class DetectorAnalyzerMap(sciline.Scope[RunType, dict[str, str]], dict): ... diff --git a/src/ess/spectroscopy/types.py b/src/ess/spectroscopy/types.py index 161f2da..3ad4c59 100644 --- a/src/ess/spectroscopy/types.py +++ b/src/ess/spectroscopy/types.py @@ -73,10 +73,6 @@ class Analyzer(sciline.Scope[RunType, sc.DataGroup[Any]], sc.DataGroup[Any]): """ -class Analyzers(sciline.Scope[RunType, sc.DataGroup[Any]], sc.DataGroup[Any]): - """All wavelength analyzers loaded from a NXcrystals.""" - - class DataAtSample(sciline.Scope[RunType, sc.DataArray], sc.DataArray): ... From f1d87fed488edad5bad2da95458e2916262d7009 Mon Sep 17 00:00:00 2001 From: Jan-Lukas Wynen Date: Wed, 11 Mar 2026 15:12:07 +0100 Subject: [PATCH 06/21] Add analyzer coords to calibrated detector --- src/ess/bifrost/detector.py | 34 ++++++++++++++++++++- src/ess/spectroscopy/indirect/conversion.py | 11 +------ 2 files changed, 34 insertions(+), 11 deletions(-) diff --git a/src/ess/bifrost/detector.py b/src/ess/bifrost/detector.py index cad5cce..1ec3524 100644 --- a/src/ess/bifrost/detector.py +++ b/src/ess/bifrost/detector.py @@ -135,11 +135,43 @@ def get_calibrated_detector_bifrost( da.coords['arc'] = arc da.coords['channel'] = channel - da = add_spectrometer_coords(da, analyzer, primary_graph, secondary_graph) + da = _add_analyzer_coords(da, analyzer) + da = add_spectrometer_coords(da, primary_graph, secondary_graph) return EmptyDetector[RunType](da) +def _add_analyzer_coords( + detector: sc.DataArray, + analyzer: Analyzer[RunType], +) -> sc.DataArray: + ana_pos = analyzer['position'] + if isinstance(ana_pos, sc.DataArray): + if 'time' not in detector.coords: + raise sc.CoordError( + "The analyzer position is time-dependent but the detector is not" + ) + if not sc.identical(ana_pos.coords['time'], detector.coords['time']): + raise sc.CoordError( + f"The analyzer and detector positions are not at the same times.\n" + f"Analyzer: {ana_pos.coords['time']}\n" + f"Detector: {detector.coords['time']}\n" + "This is likely due to a change in the NeXus structure. It used to " + "guarantee that the times are identical." + ) + analyzer_position = ana_pos.data + analyzer_transform = analyzer['transform'].value.data + else: + analyzer_position = ana_pos + analyzer_transform = analyzer['transform'].value + + return detector.assign_coords( + analyzer_dspacing=analyzer['dspacing'], + analyzer_position=analyzer_position, + analyzer_transform=analyzer_transform, + ) + + def merge_triplets( *triplets: sc.DataArray, ) -> sc.DataArray: diff --git a/src/ess/spectroscopy/indirect/conversion.py b/src/ess/spectroscopy/indirect/conversion.py index d1fe9e3..6fa1f13 100644 --- a/src/ess/spectroscopy/indirect/conversion.py +++ b/src/ess/spectroscopy/indirect/conversion.py @@ -13,7 +13,6 @@ ) from ..types import ( - Analyzer, EnergyQDetector, GravityVector, IncidentEnergyDetector, @@ -246,7 +245,6 @@ def add_incident_energy( def add_spectrometer_coords( detector: sc.DataArray, - analyzer: Analyzer[RunType], primary_graph: PrimarySpecCoordTransformGraph[RunType], secondary_graph: SecondarySpecCoordTransformGraph[RunType], ) -> sc.DataArray: @@ -258,8 +256,6 @@ def add_spectrometer_coords( Data array with beamline coordinates "position", "source_position", and "sample_position". Does not need to contain events or flight times. - analyzer: - Data group with analyzer parameters. primary_graph: Coordinate transformation graph for the primary spectrometer. secondary_graph: @@ -273,11 +269,6 @@ def add_spectrometer_coords( Input data with added spectrometer coordinates. This includes "final_energy", "secondary_flight_time", and "L1". """ - # TODO lookup time-dep analyzers based on `data` and isert into graph - - # "analyzer_dspacing": lambda: analyzer["dspacing"], - # "analyzer_position": lambda: analyzer["position"], - # "analyzer_transform": lambda: analyzer["transform"], return detector.transform_coords( ( 'final_energy', @@ -287,7 +278,7 @@ def add_spectrometer_coords( 'secondary_flight_time', ), graph={**primary_graph, **secondary_graph}, - keep_intermediate=True, # TODO + keep_intermediate=False, keep_aliases=False, rename_dims=False, ) From f1330a89d038e5a13ee66a22b654cecd80f3e702 Mon Sep 17 00:00:00 2001 From: Jan-Lukas Wynen Date: Wed, 11 Mar 2026 15:18:19 +0100 Subject: [PATCH 07/21] Provider analyzer coords to coord transform --- src/ess/bifrost/detector.py | 29 ++++++++++++++++++++--------- 1 file changed, 20 insertions(+), 9 deletions(-) diff --git a/src/ess/bifrost/detector.py b/src/ess/bifrost/detector.py index 1ec3524..f50446d 100644 --- a/src/ess/bifrost/detector.py +++ b/src/ess/bifrost/detector.py @@ -3,6 +3,8 @@ """Detector handling for BIFROST.""" +from collections.abc import Callable + import scipp as sc import scippnexus as snx @@ -135,16 +137,25 @@ def get_calibrated_detector_bifrost( da.coords['arc'] = arc da.coords['channel'] = channel - da = _add_analyzer_coords(da, analyzer) - da = add_spectrometer_coords(da, primary_graph, secondary_graph) + da = add_spectrometer_coords( + da, + primary_graph, + SecondarySpecCoordTransformGraph[RunType]( + {**secondary_graph, **_make_analyzer_coord_graph(da, analyzer)} + ), + ) return EmptyDetector[RunType](da) -def _add_analyzer_coords( +# We insert the analyzer coords into the graph so that they don't end up as coords +# in the output. This could be done in the provider of SecondarySpecCoordTransformGraph +# but that provider would then have to request the detector component to check +# the time coordinates. +def _make_analyzer_coord_graph( detector: sc.DataArray, analyzer: Analyzer[RunType], -) -> sc.DataArray: +) -> dict[str, Callable[[], sc.Variable]]: ana_pos = analyzer['position'] if isinstance(ana_pos, sc.DataArray): if 'time' not in detector.coords: @@ -165,11 +176,11 @@ def _add_analyzer_coords( analyzer_position = ana_pos analyzer_transform = analyzer['transform'].value - return detector.assign_coords( - analyzer_dspacing=analyzer['dspacing'], - analyzer_position=analyzer_position, - analyzer_transform=analyzer_transform, - ) + return { + 'analyzer_dspacing': lambda: analyzer['dspacing'], + 'analyzer_position': lambda: analyzer_position, + 'analyzer_transform': lambda: analyzer_transform, + } def merge_triplets( From cc106e69db9d22f9030c62600e451562dda46c32 Mon Sep 17 00:00:00 2001 From: Jan-Lukas Wynen Date: Wed, 11 Mar 2026 15:28:33 +0100 Subject: [PATCH 08/21] Remove time slicing --- src/ess/spectroscopy/indirect/kf.py | 30 ----------------------------- 1 file changed, 30 deletions(-) diff --git a/src/ess/spectroscopy/indirect/kf.py b/src/ess/spectroscopy/indirect/kf.py index 080a77d..a191992 100644 --- a/src/ess/spectroscopy/indirect/kf.py +++ b/src/ess/spectroscopy/indirect/kf.py @@ -18,15 +18,6 @@ ) -# TODO remove? -def _no_time(x: sc.Variable | sc.DataArray) -> sc.Variable: - if isinstance(x, sc.DataArray) and 'time' in x.coords: - return x['time', 0].data - elif isinstance(x, sc.DataArray): - raise ValueError("Only `DataArray`s with a time-coordinate allowed") - return x - - def sample_analyzer_vector( sample_position: sc.Variable, analyzer_position: sc.Variable, @@ -60,17 +51,6 @@ def sample_analyzer_vector( The vector from the sample position to the interaction point on the analyzer for each detector element. """ - # FIXME time-dependent depends-on chains produce positions and transformations - # which are `scipp.DataArray`s with a 'time' coordinate. We don't need the - # time-dependence here since all calculations are done in the rotating - # detector-tank coordinate system where these *have no* time-dependence - # TODO: Verify that we are actually using the rotating detector-tank coordinate - # frame, otherwise we will misidentify the Q vector for each detector - sample_position = _no_time(sample_position) - analyzer_position = _no_time(analyzer_position) - analyzer_transform = _no_time(analyzer_transform) - detector_position = _no_time(detector_position) - # Scipp does not distinguish between coordinates and directions, so we need to do # some extra legwork to ensure we can apply the orientation transformation # _and_ obtain a dimensionless direction vector @@ -142,16 +122,6 @@ def analyzer_detector_vector( detector_position: sc.Variable, ) -> sc.Variable: """Calculate the analyzer-detector vector""" - # FIXME time-dependent depends-on chains produce positions and transformations - # which are `scipp.DataArray`s with a 'time' coordinate. We don't need the - # time-dependence here since all calculations are done in the rotating - # detector-tank coordinate system where these *have no* time-dependence - # TODO: Verify that we are actually using the rotating detector-tank coordinate - # frame, otherwise we will misidentify the Q vector for each detector - sample_position = _no_time(sample_position) - sample_analyzer_vector = _no_time(sample_analyzer_vector) # FIXME unnecessary? - detector_position = _no_time(detector_position) - analyzer_position = sample_position + sample_analyzer_vector.to( unit=sample_analyzer_vector.unit ) From 39188487d6437a785391a9ba5cb35038a1aa0f6d Mon Sep 17 00:00:00 2001 From: Jan-Lukas Wynen Date: Fri, 13 Mar 2026 14:57:16 +0100 Subject: [PATCH 09/21] Working group by a4 for time-dep det --- src/ess/bifrost/cutting.py | 30 ++++++++++++++++++++++++------ 1 file changed, 24 insertions(+), 6 deletions(-) diff --git a/src/ess/bifrost/cutting.py b/src/ess/bifrost/cutting.py index 6388e05..52d4b29 100644 --- a/src/ess/bifrost/cutting.py +++ b/src/ess/bifrost/cutting.py @@ -39,11 +39,29 @@ def group_by_rotation( : ``data`` grouped by rotation angles "a3" and "a4". """ - graph = { - 'a3': _make_angle_from_time_calculator(sample_angle), - 'a4': _make_angle_from_time_calculator(instrument_angle), - } - grouped = data.transform_coords(('a3', 'a4'), graph=graph).group('a3', 'a4') + graph = {'a3': _make_angle_from_time_calculator(sample_angle)} + group_coords = ['a3'] + drop_coords = [] + + if 'time' in data.dims: + # If the data is time-dependent, a4 corresponds to that time-dependence + # (the instrument angle is the only dynamic parameter). The data has a 'time' + # bin-edge coord in this case. Since a4 changes at the bin edges, not within + # each bin, we can look it up safely using midpoints. + graph['a4'] = lambda time: sc.lookup( + instrument_angle, dim='time', mode='previous' + )[sc.midpoints(time)] + # a4 replaces the 'time' coord. + drop_coords.append('time') + else: + graph['a4'] = _make_angle_from_time_calculator(instrument_angle) + group_coords.append('a4') + + grouped = ( + data.transform_coords(('a3', 'a4'), graph=graph) + .group(*group_coords) + .drop_coords(drop_coords) + ) return DataGroupedByRotation[RunType](grouped) @@ -51,7 +69,7 @@ def _make_angle_from_time_calculator(angle: sc.DataArray) -> Callable[..., sc.Va if angle.ndim == 0: return lambda: angle.data else: - lut = sc.lookup(angle, 'time') + lut = sc.lookup(angle, 'time', mode='previous') return lambda event_time_zero: lut[event_time_zero] From 3b4279a16ad09cc167f256c5bf0e641a6e1c47e0 Mon Sep 17 00:00:00 2001 From: Jan-Lukas Wynen Date: Tue, 17 Mar 2026 13:33:14 +0100 Subject: [PATCH 10/21] Use more explicit name --- src/ess/spectroscopy/indirect/time_of_flight.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/ess/spectroscopy/indirect/time_of_flight.py b/src/ess/spectroscopy/indirect/time_of_flight.py index 496df3e..f19ef09 100644 --- a/src/ess/spectroscopy/indirect/time_of_flight.py +++ b/src/ess/spectroscopy/indirect/time_of_flight.py @@ -85,7 +85,7 @@ def monitor_wavelength_data( for indirect geometry spectrometers. """ result = reduce_unwrap.to_wavelength.monitor_wavelength_data( - monitor_data=monitor_data.rename(t='tof'), + monitor_data=monitor_data.rename(t='frame_time'), lookup=lookup, ltotal=ltotal, pulse_stride_offset=pulse_stride_offset, From 4ac942f225356020d929a485121e8e403b1710c6 Mon Sep 17 00:00:00 2001 From: Jan-Lukas Wynen Date: Tue, 7 Apr 2026 14:17:48 +0200 Subject: [PATCH 11/21] Use new Position class --- src/ess/bifrost/detector.py | 14 ++++++++------ src/ess/spectroscopy/indirect/conversion.py | 7 +++---- src/ess/spectroscopy/indirect/ki.py | 4 ++-- 3 files changed, 13 insertions(+), 12 deletions(-) diff --git a/src/ess/bifrost/detector.py b/src/ess/bifrost/detector.py index f50446d..b81a67d 100644 --- a/src/ess/bifrost/detector.py +++ b/src/ess/bifrost/detector.py @@ -8,6 +8,7 @@ import scipp as sc import scippnexus as snx +from ess.reduce.nexus.types import Position from ess.spectroscopy.indirect.conversion import add_spectrometer_coords from ess.spectroscopy.types import ( Analyzer, @@ -156,24 +157,25 @@ def _make_analyzer_coord_graph( detector: sc.DataArray, analyzer: Analyzer[RunType], ) -> dict[str, Callable[[], sc.Variable]]: - ana_pos = analyzer['position'] - if isinstance(ana_pos, sc.DataArray): + ana_pos: Position[snx.NXcrystal, RunType] = analyzer['position'] + if ana_pos.is_dynamic: if 'time' not in detector.coords: raise sc.CoordError( "The analyzer position is time-dependent but the detector is not" ) - if not sc.identical(ana_pos.coords['time'], detector.coords['time']): + analyzer_positions = ana_pos.positions + if not sc.identical(analyzer_positions.coords['time'], detector.coords['time']): raise sc.CoordError( f"The analyzer and detector positions are not at the same times.\n" - f"Analyzer: {ana_pos.coords['time']}\n" + f"Analyzer: {analyzer_positions.coords['time']}\n" f"Detector: {detector.coords['time']}\n" "This is likely due to a change in the NeXus structure. It used to " "guarantee that the times are identical." ) - analyzer_position = ana_pos.data + analyzer_position = analyzer_positions.data analyzer_transform = analyzer['transform'].value.data else: - analyzer_position = ana_pos + analyzer_position = ana_pos.position analyzer_transform = analyzer['transform'].value return { diff --git a/src/ess/spectroscopy/indirect/conversion.py b/src/ess/spectroscopy/indirect/conversion.py index 6fa1f13..d60b7f1 100644 --- a/src/ess/spectroscopy/indirect/conversion.py +++ b/src/ess/spectroscopy/indirect/conversion.py @@ -253,9 +253,8 @@ def add_spectrometer_coords( Parameters ---------- detector: - Data array with beamline coordinates "position", "source_position", and - "sample_position". - Does not need to contain events or flight times. + Data array with a "position" coordinate. + Does not need to contain events, wavelength, or flight times. primary_graph: Coordinate transformation graph for the primary spectrometer. secondary_graph: @@ -293,7 +292,7 @@ def monitor_coordinate_transformation_graph( { **beamline.beamline(scatter=False), **tof.elastic_wavelength(start='tof'), - 'source_position': lambda: source_position, + 'source_position': lambda: source_position.position, } ) diff --git a/src/ess/spectroscopy/indirect/ki.py b/src/ess/spectroscopy/indirect/ki.py index 0b8c290..da640e0 100644 --- a/src/ess/spectroscopy/indirect/ki.py +++ b/src/ess/spectroscopy/indirect/ki.py @@ -50,8 +50,8 @@ def primary_spectrometer_coordinate_transformation_graph( { "incident_beam": straight_incident_beam, "L1": L1, - "sample_position": lambda: sample_position, - "source_position": lambda: source_position, + "sample_position": lambda: sample_position.position, + "source_position": lambda: source_position.position, "gravity": lambda: gravity, } ) From 8fbb28004ac2fd1e2b94914a723ba40c15236633 Mon Sep 17 00:00:00 2001 From: Jan-Lukas Wynen Date: Fri, 10 Apr 2026 13:27:09 +0200 Subject: [PATCH 12/21] Customize time-dep transform loading --- src/ess/bifrost/detector.py | 27 +++++++++++++-------- src/ess/bifrost/io/nexus.py | 47 ++++++++++++++++++++++++++++++++++++- src/ess/bifrost/workflow.py | 1 + 3 files changed, 64 insertions(+), 11 deletions(-) diff --git a/src/ess/bifrost/detector.py b/src/ess/bifrost/detector.py index b81a67d..e8c678b 100644 --- a/src/ess/bifrost/detector.py +++ b/src/ess/bifrost/detector.py @@ -122,18 +122,14 @@ def get_calibrated_detector_bifrost( This includes "final_energy", "secondary_flight_time", and "L1". """ - from ess.reduce.nexus.types import DetectorBankSizes - from ess.reduce.nexus.workflow import get_calibrated_detector - - da = get_calibrated_detector( - detector=detector, - transform=transform, - offset=offset, - # The detectors are folded in the file, no need to do that here. - bank_sizes=DetectorBankSizes({}), - ) + from ess.reduce.nexus import compute_detector_position, extract_signal_data_array + + da = extract_signal_data_array(detector) da = da.rename(dim_0='tube', dim_1='length') + position = compute_detector_position(da, transform=transform, offset=offset) + da = _assign_detector_position(da, position) + arc, channel = arc_and_channel_from_detector_number(da.coords['detector_number']) da.coords['arc'] = arc da.coords['channel'] = channel @@ -149,6 +145,17 @@ def get_calibrated_detector_bifrost( return EmptyDetector[RunType](da) +def _assign_detector_position( + da: sc.DataArray, position: sc.Variable | sc.DataArray +) -> sc.DataArray: + if isinstance(position, sc.DataArray): # time-dependent transform + # Store position and time as separate coords because we can't store data arrays. + return da.broadcast( + dims=['time', *da.dims], shape=[position.sizes['time'], *da.shape] + ).assign_coords(position=position.data, time=position.coords['time']) + return da.assign_coords(position=position) + + # We insert the analyzer coords into the graph so that they don't end up as coords # in the output. This could be done in the provider of SecondarySpecCoordTransformGraph # but that provider would then have to request the detector component to check diff --git a/src/ess/bifrost/io/nexus.py b/src/ess/bifrost/io/nexus.py index eac6e37..bccee96 100644 --- a/src/ess/bifrost/io/nexus.py +++ b/src/ess/bifrost/io/nexus.py @@ -5,11 +5,12 @@ import warnings +import numpy as np import scipp as sc import scippnexus as snx from ess.reduce.nexus import open_component_group -from ess.reduce.nexus.types import NeXusLocationSpec +from ess.reduce.nexus.types import NeXusLocationSpec, TransformationTimeFilter from ess.spectroscopy.types import ( Analyzer, InstrumentAngle, @@ -147,6 +148,46 @@ def get_calibrated_analyzer( ) +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( + [ + # 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 stepwise_transformation_time_filter(transform: sc.DataArray) -> sc.DataArray: + """Collapse runs of equal values into a single value. + + This can be used as a time filter for NeXus transformations when the component + mostly stays at a position and only rarely moves. + For example, a stepwise scan across detector rotations. + """ + collapsed = _collapse_runs(transform, 'time') + if collapsed.sizes['time'] == 1: + return collapsed.squeeze('time') + return collapsed + + providers = ( get_calibrated_analyzer, load_analyzer_for_detector, @@ -154,3 +195,7 @@ def get_calibrated_analyzer( load_sample_angle, moderator_class_for_source, ) + +parameters = { + TransformationTimeFilter: stepwise_transformation_time_filter, +} diff --git a/src/ess/bifrost/workflow.py b/src/ess/bifrost/workflow.py index 4f3fb57..4b1b796 100644 --- a/src/ess/bifrost/workflow.py +++ b/src/ess/bifrost/workflow.py @@ -45,6 +45,7 @@ def default_parameters() -> dict[type, Any]: NeXusMonitorName[PSCMonitor]: "psc_monitor", PulsePeriod: 1.0 / sc.scalar(14.0, unit="Hz"), UncertaintyBroadcastMode: UncertaintyBroadcastMode.fail, + **nexus.parameters, } From 6bee451edceef4c9b0097180d064a79f2dec53f9 Mon Sep 17 00:00:00 2001 From: Jan-Lukas Wynen Date: Thu, 16 Apr 2026 13:05:56 +0200 Subject: [PATCH 13/21] Use new DynamicPosition --- src/ess/bifrost/io/nexus.py | 4 ++-- src/ess/spectroscopy/indirect/conversion.py | 2 +- src/ess/spectroscopy/indirect/ki.py | 4 ++-- src/ess/spectroscopy/types.py | 1 + 4 files changed, 6 insertions(+), 5 deletions(-) diff --git a/src/ess/bifrost/io/nexus.py b/src/ess/bifrost/io/nexus.py index bccee96..3ebe77f 100644 --- a/src/ess/bifrost/io/nexus.py +++ b/src/ess/bifrost/io/nexus.py @@ -13,13 +13,13 @@ from ess.reduce.nexus.types import NeXusLocationSpec, TransformationTimeFilter from ess.spectroscopy.types import ( Analyzer, + DynamicPosition, InstrumentAngle, NeXusClass, NeXusComponent, NeXusComponentLocationSpec, NeXusFileSpec, NeXusTransformation, - Position, RunType, SampleAngle, ) @@ -119,7 +119,7 @@ def _get_inputs(group: snx.Group) -> list[str]: def get_calibrated_analyzer( analyzer_component: NeXusComponent[snx.NXcrystal, RunType], analyzer_transform: NeXusTransformation[snx.NXcrystal, RunType], - analyzer_position: Position[snx.NXcrystal, RunType], + analyzer_position: DynamicPosition[snx.NXcrystal, RunType], ) -> Analyzer[RunType]: """Collect the data for a single analyzer. diff --git a/src/ess/spectroscopy/indirect/conversion.py b/src/ess/spectroscopy/indirect/conversion.py index d60b7f1..37fcaea 100644 --- a/src/ess/spectroscopy/indirect/conversion.py +++ b/src/ess/spectroscopy/indirect/conversion.py @@ -292,7 +292,7 @@ def monitor_coordinate_transformation_graph( { **beamline.beamline(scatter=False), **tof.elastic_wavelength(start='tof'), - 'source_position': lambda: source_position.position, + 'source_position': lambda: source_position, } ) diff --git a/src/ess/spectroscopy/indirect/ki.py b/src/ess/spectroscopy/indirect/ki.py index da640e0..0b8c290 100644 --- a/src/ess/spectroscopy/indirect/ki.py +++ b/src/ess/spectroscopy/indirect/ki.py @@ -50,8 +50,8 @@ def primary_spectrometer_coordinate_transformation_graph( { "incident_beam": straight_incident_beam, "L1": L1, - "sample_position": lambda: sample_position.position, - "source_position": lambda: source_position.position, + "sample_position": lambda: sample_position, + "source_position": lambda: source_position, "gravity": lambda: gravity, } ) diff --git a/src/ess/spectroscopy/types.py b/src/ess/spectroscopy/types.py index 3ad4c59..d2be4e0 100644 --- a/src/ess/spectroscopy/types.py +++ b/src/ess/spectroscopy/types.py @@ -16,6 +16,7 @@ Beamline = reduce_t.Beamline DetectorPositionOffset = reduce_t.DetectorPositionOffset +DynamicPosition = reduce_t.DynamicPosition EmptyDetector = reduce_t.EmptyDetector Filename = reduce_t.Filename GravityVector = reduce_t.GravityVector From 00c4371878db305f98e036ec712c1ef5598d9145 Mon Sep 17 00:00:00 2001 From: Jan-Lukas Wynen Date: Mon, 20 Apr 2026 14:48:36 +0200 Subject: [PATCH 14/21] Require essreduce>=26.4.1 For time-dependent NeXus transformations. --- pyproject.toml | 2 +- requirements/base.in | 2 +- requirements/base.txt | 24 ++++++++++++------------ requirements/basetest.txt | 12 ++++++------ requirements/ci.txt | 20 +++++++++++--------- requirements/dev.txt | 18 +++++++++--------- requirements/docs.txt | 8 ++++---- requirements/mypy.txt | 4 ++-- requirements/nightly.txt | 22 +++++++++++----------- requirements/static.txt | 10 +++++----- requirements/wheels.txt | 4 ++-- 11 files changed, 64 insertions(+), 62 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 3a7d263..51c8435 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -30,7 +30,7 @@ requires-python = ">=3.11" # Run 'tox -e deps' after making changes here. This will update requirement files. # Make sure to list one dependency per line. dependencies = [ - "essreduce>=26.4.0", + "essreduce>=26.4.1", "graphviz>=0.20", "pandas>=2.1.2", "sciline>=25.4.1", diff --git a/requirements/base.in b/requirements/base.in index e6807c8..9b9d0b5 100644 --- a/requirements/base.in +++ b/requirements/base.in @@ -2,7 +2,7 @@ # will not be touched by ``make_base.py`` # --- END OF CUSTOM SECTION --- # The following was generated by 'tox -e deps', DO NOT EDIT MANUALLY! -essreduce>=26.4.0 +essreduce>=26.4.1 graphviz>=0.20 pandas>=2.1.2 sciline>=25.4.1 diff --git a/requirements/base.txt b/requirements/base.txt index 8467d71..3f66a0c 100644 --- a/requirements/base.txt +++ b/requirements/base.txt @@ -1,4 +1,4 @@ -# SHA1:b30fe4f8b8899208801c1e28bbca8fdf94dbc938 +# SHA1:db0b6133057674a1a7c9f64ad9fe7e4f411c9e8f # # This file was generated by pip-compile-multi. # To update, run: @@ -9,7 +9,7 @@ annotated-types==0.7.0 # via pydantic certifi==2026.2.25 # via requests -charset-normalizer==3.4.6 +charset-normalizer==3.4.7 # via requests contourpy==1.3.3 # via matplotlib @@ -21,7 +21,7 @@ dnspython==2.8.0 # via email-validator email-validator==2.3.0 # via scippneutron -essreduce==26.4.0 +essreduce==26.4.1 # via -r base.in fonttools==4.62.1 # via matplotlib @@ -50,7 +50,7 @@ mpltoolbox==26.2.0 # via scippneutron networkx==3.6.1 # via cyclebane -numpy==2.4.3 +numpy==2.4.4 # via # contourpy # h5py @@ -59,26 +59,26 @@ numpy==2.4.3 # scipp # scippneutron # scipy -packaging==26.0 +packaging==26.1 # via # lazy-loader # matplotlib # pooch -pandas==3.0.1 +pandas==3.0.2 # via -r base.in -pillow==12.1.1 +pillow==12.2.0 # via matplotlib -platformdirs==4.9.4 +platformdirs==4.9.6 # via pooch -plopp==26.3.1 +plopp==26.4.0 # via # scippneutron # tof pooch==1.9.0 # via tof -pydantic==2.12.5 +pydantic==2.13.2 # via scippneutron -pydantic-core==2.41.5 +pydantic-core==2.46.2 # via pydantic pyparsing==3.3.2 # via matplotlib @@ -87,7 +87,7 @@ python-dateutil==2.9.0.post0 # matplotlib # pandas # scippneutron -requests==2.32.5 +requests==2.33.1 # via pooch sciline==25.11.1 # via diff --git a/requirements/basetest.txt b/requirements/basetest.txt index 4eb0e5c..acd4742 100644 --- a/requirements/basetest.txt +++ b/requirements/basetest.txt @@ -7,27 +7,27 @@ # certifi==2026.2.25 # via requests -charset-normalizer==3.4.6 +charset-normalizer==3.4.7 # via requests idna==3.11 # via requests iniconfig==2.3.0 # via pytest -packaging==26.0 +packaging==26.1 # via # pooch # pytest -platformdirs==4.9.4 +platformdirs==4.9.6 # via pooch pluggy==1.6.0 # via pytest pooch==1.9.0 # via -r basetest.in -pygments==2.19.2 +pygments==2.20.0 # via pytest -pytest==9.0.2 +pytest==9.0.3 # via -r basetest.in -requests==2.32.5 +requests==2.33.1 # via pooch urllib3==2.6.3 # via requests diff --git a/requirements/ci.txt b/requirements/ci.txt index dedea3a..d1c46af 100644 --- a/requirements/ci.txt +++ b/requirements/ci.txt @@ -9,13 +9,13 @@ cachetools==7.0.5 # via tox certifi==2026.2.25 # via requests -charset-normalizer==3.4.6 +charset-normalizer==3.4.7 # via requests colorama==0.4.6 # via tox distlib==0.4.0 # via virtualenv -filelock==3.25.2 +filelock==3.29.0 # via # python-discovery # tox @@ -26,12 +26,12 @@ gitpython==3.1.46 # via -r ci.in idna==3.11 # via requests -packaging==26.0 +packaging==26.1 # via # -r ci.in # pyproject-api # tox -platformdirs==4.9.4 +platformdirs==4.9.6 # via # python-discovery # tox @@ -40,17 +40,19 @@ pluggy==1.6.0 # via tox pyproject-api==1.10.0 # via tox -python-discovery==1.2.0 - # via virtualenv -requests==2.32.5 +python-discovery==1.2.2 + # via + # tox + # virtualenv +requests==2.33.1 # via -r ci.in smmap==5.0.3 # via gitdb tomli-w==1.2.0 # via tox -tox==4.50.3 +tox==4.53.0 # via -r ci.in urllib3==2.6.3 # via requests -virtualenv==21.2.0 +virtualenv==21.2.4 # via tox diff --git a/requirements/dev.txt b/requirements/dev.txt index e6f94c4..eb4972c 100644 --- a/requirements/dev.txt +++ b/requirements/dev.txt @@ -26,13 +26,13 @@ async-lru==2.3.0 # via jupyterlab cffi==2.0.0 # via argon2-cffi-bindings -click==8.3.1 +click==8.3.2 # via # pip-compile-multi # pip-tools -copier==9.14.0 +copier==9.14.3 # via -r dev.in -dunamai==1.26.0 +dunamai==1.26.1 # via copier fqdn==1.5.1 # via jsonschema @@ -48,7 +48,7 @@ isoduration==20.11.0 # via jsonschema jinja2-ansible-filters==1.3.2 # via copier -json5==0.13.0 +json5==0.14.0 # via jupyterlab-server jsonpointer==3.1.1 # via jsonschema @@ -59,7 +59,7 @@ jsonschema[format-nongpl]==4.26.0 # nbformat jupyter-events==0.12.0 # via jupyter-server -jupyter-lsp==2.3.0 +jupyter-lsp==2.3.1 # via jupyterlab jupyter-server==2.17.0 # via @@ -79,17 +79,17 @@ notebook-shim==0.2.4 # via jupyterlab overrides==7.7.0 # via jupyter-server -pip-compile-multi==3.2.2 +pip-compile-multi==3.3.1 # via -r dev.in pip-tools==7.5.3 # via pip-compile-multi plumbum==1.10.0 # via copier -prometheus-client==0.24.1 +prometheus-client==0.25.0 # via jupyter-server pycparser==3.0 # via cffi -python-json-logger==4.0.0 +python-json-logger==4.1.0 # via jupyter-events questionary==2.1.1 # via copier @@ -111,7 +111,7 @@ terminado==0.18.1 # jupyter-server-terminals toposort==1.10 # via pip-compile-multi -tzdata==2025.3 +tzdata==2026.1 # via arrow uri-template==1.3.0 # via jsonschema diff --git a/requirements/docs.txt b/requirements/docs.txt index 2793e92..0ceeccc 100644 --- a/requirements/docs.txt +++ b/requirements/docs.txt @@ -54,7 +54,7 @@ ipykernel==7.2.0 # via -r docs.in ipympl==0.10.0 # via -r docs.in -ipython==9.10.0 +ipython==9.10.1 # via # -r docs.in # ipykernel @@ -115,7 +115,7 @@ myst-parser==5.0.0 # via -r docs.in nbclient==0.10.4 # via nbconvert -nbconvert==7.17.0 +nbconvert==7.17.1 # via nbsphinx nbformat==5.10.4 # via @@ -142,9 +142,9 @@ pure-eval==0.2.3 # via stack-data pydantic-settings==2.13.1 # via autodoc-pydantic -pydata-sphinx-theme==0.16.1 +pydata-sphinx-theme==0.17.0 # via -r docs.in -pygments==2.19.2 +pygments==2.20.0 # via # accessible-pygments # ipython diff --git a/requirements/mypy.txt b/requirements/mypy.txt index f90f757..725c546 100644 --- a/requirements/mypy.txt +++ b/requirements/mypy.txt @@ -6,9 +6,9 @@ # requirements upgrade # -r test.txt -librt==0.8.1 +librt==0.9.0 # via mypy -mypy==1.19.1 +mypy==1.20.1 # via -r mypy.in mypy-extensions==1.1.0 # via mypy diff --git a/requirements/nightly.txt b/requirements/nightly.txt index 1df764c..e6115d5 100644 --- a/requirements/nightly.txt +++ b/requirements/nightly.txt @@ -12,7 +12,7 @@ annotated-types==0.7.0 # via pydantic certifi==2026.2.25 # via requests -charset-normalizer==3.4.6 +charset-normalizer==3.4.7 # via requests contourpy==1.3.3 # via matplotlib @@ -55,7 +55,7 @@ mpltoolbox==26.2.0 # via scippneutron networkx==3.6.1 # via cyclebane -numpy==2.4.3 +numpy==2.4.4 # via # contourpy # h5py @@ -64,17 +64,17 @@ numpy==2.4.3 # scipp # scippneutron # scipy -packaging==26.0 +packaging==26.1 # via # lazy-loader # matplotlib # pooch # pytest -pandas==3.0.1 +pandas==3.0.2 # via -r nightly.in -pillow==12.1.1 +pillow==12.2.0 # via matplotlib -platformdirs==4.9.4 +platformdirs==4.9.6 # via pooch plopp @ git+https://github.com/scipp/plopp@main # via @@ -87,22 +87,22 @@ pooch==1.9.0 # via # -r nightly.in # tof -pydantic==2.13.0b2 +pydantic==2.13.2 # via scippneutron -pydantic-core==2.42.0 +pydantic-core==2.46.2 # via pydantic -pygments==2.19.2 +pygments==2.20.0 # via pytest pyparsing==3.3.2 # via matplotlib -pytest==9.0.2 +pytest==9.0.3 # via -r nightly.in python-dateutil==2.9.0.post0 # via # matplotlib # pandas # scippneutron -requests==2.32.5 +requests==2.33.1 # via pooch sciline @ git+https://github.com/scipp/sciline@main # via diff --git a/requirements/static.txt b/requirements/static.txt index 02cb746..ee45896 100644 --- a/requirements/static.txt +++ b/requirements/static.txt @@ -9,23 +9,23 @@ cfgv==3.5.0 # via pre-commit distlib==0.4.0 # via virtualenv -filelock==3.25.2 +filelock==3.29.0 # via # python-discovery # virtualenv -identify==2.6.18 +identify==2.6.19 # via pre-commit nodeenv==1.10.0 # via pre-commit -platformdirs==4.9.4 +platformdirs==4.9.6 # via # python-discovery # virtualenv pre-commit==4.5.1 # via -r static.in -python-discovery==1.2.0 +python-discovery==1.2.2 # via virtualenv pyyaml==6.0.3 # via pre-commit -virtualenv==21.2.0 +virtualenv==21.2.4 # via pre-commit diff --git a/requirements/wheels.txt b/requirements/wheels.txt index 0d70d60..0a50fd0 100644 --- a/requirements/wheels.txt +++ b/requirements/wheels.txt @@ -5,9 +5,9 @@ # # requirements upgrade # -build==1.4.0 +build==1.4.3 # via -r wheels.in -packaging==26.0 +packaging==26.1 # via build pyproject-hooks==1.2.0 # via build From b6afacd7be15a53c24face9384bc02dd154137e4 Mon Sep 17 00:00:00 2001 From: Jan-Lukas Wynen Date: Mon, 4 May 2026 14:59:49 +0200 Subject: [PATCH 15/21] Tmp fix: invert chopper phase --- .../bifrost/bifrost-make-wavelength-lookup-table.ipynb | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/docs/user-guide/bifrost/bifrost-make-wavelength-lookup-table.ipynb b/docs/user-guide/bifrost/bifrost-make-wavelength-lookup-table.ipynb index a8533cf..716cfac 100644 --- a/docs/user-guide/bifrost/bifrost-make-wavelength-lookup-table.ipynb +++ b/docs/user-guide/bifrost/bifrost-make-wavelength-lookup-table.ipynb @@ -128,6 +128,13 @@ " processed['phase'] = processed['phase'].data.mean()\n", " # Guessing here as this is not stored in the file.\n", " processed['beam_position'] = sc.scalar(0.0, unit='deg')\n", + "\n", + " # FIXME Get rid of this bad hack if/when McStas' DiskChopper is fixed\n", + " # Currently, it sets a delay-time by `delta = 2 * pi * phase/|frequency|`\n", + " # But it should set `delta = 2 * pi * phase / frequency`\n", + " # So phases reported here have the wrong sign if frequency < 0\n", + " processed['phase'] = -processed['phase'] if processed['rotation_speed'].value < 0 else processed['phase']\n", + "\n", " return DiskChopper.from_nexus(processed)\n", "\n", "\n", @@ -163,7 +170,7 @@ "workflow[SourcePosition] = source_position\n", "\n", "# Increase this number for more reliable results:\n", - "workflow[NumberOfSimulatedNeutrons] = 6000_000" + "workflow[NumberOfSimulatedNeutrons] = 6_000_000" ] }, { From 551dc92b2e53904f93171d760dafe68b5553ce90 Mon Sep 17 00:00:00 2001 From: Jan-Lukas Wynen Date: Mon, 4 May 2026 15:00:48 +0200 Subject: [PATCH 16/21] Do not slice a4 in docs --- docs/user-guide/bifrost/bifrost-reduction.ipynb | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/docs/user-guide/bifrost/bifrost-reduction.ipynb b/docs/user-guide/bifrost/bifrost-reduction.ipynb index 7da4618..497ba21 100644 --- a/docs/user-guide/bifrost/bifrost-reduction.ipynb +++ b/docs/user-guide/bifrost/bifrost-reduction.ipynb @@ -96,7 +96,7 @@ "# For this example, we do not mask anything:\n", "workflow[LookupTableRelativeErrorThreshold] = {\n", " 'detector': np.inf,\n", - " '110_frame_3': np.inf,\n", + " 'normalization_monitor': np.inf,\n", "}\n", "# We need to read many objects from the file,\n", "# keeping it open improves performance: (optional)\n", @@ -183,9 +183,9 @@ "outputs": [], "source": [ "(\n", - " data['a4', 0]\n", + " data\n", " .bins.concat()\n", - " .hist(energy_transfer=sc.linspace('energy_transfer', -0.05, 0.05, 200, unit='meV'))\n", + " .hist(energy_transfer=500)\n", ").plot()" ] }, @@ -211,7 +211,7 @@ "metadata": {}, "outputs": [], "source": [ - "d = data['a4', 0].bins.concat().copy()\n", + "d = data.bins.concat().copy()\n", "x = sc.vector([1, 0, 0])\n", "z = sc.vector([0, 0, 1])\n", "d.bins.coords['Qx'] = sc.dot(x, d.bins.coords['sample_table_momentum_transfer'])\n", From a44582b54baa4553390e8173d72ed77a07f0a5ab Mon Sep 17 00:00:00 2001 From: Jan-Lukas Wynen Date: Mon, 4 May 2026 15:29:13 +0200 Subject: [PATCH 17/21] Use new data and fix tests --- src/ess/bifrost/data.py | 11 +++++------ tests/bifrost/cutting_test.py | 8 ++++---- tests/bifrost/io/sqw_test.py | 2 +- tests/bifrost/live_test.py | 2 +- tests/bifrost/workflow_test.py | 9 ++++----- 5 files changed, 15 insertions(+), 17 deletions(-) diff --git a/src/ess/bifrost/data.py b/src/ess/bifrost/data.py index 5a27e37..231e99b 100644 --- a/src/ess/bifrost/data.py +++ b/src/ess/bifrost/data.py @@ -10,12 +10,11 @@ _registry = make_registry( 'ess/bifrost', files={ - "BIFROST_20240914T053723.h5": "md5:0f2fa5c9a851f8e3a4fa61defaa3752e", - "computed_energy_data_simulated_5x2.h5": "md5:57408fa10aa4689c43630f994cff8d30", - "BIFROST-simulation-tof-lookup-table.h5": "blake2b:682021920a355f789da37b18029719fe20569d86db26cdaf5f3d916d2f76f9360907960ba86903be4cab489d39f1b6f9f265f3a4ab3f82c5e095afa4a2c456af", # noqa: E501 - "BIFROST-simulation-lookup-table.h5": "md5:6d776afa591d4a83c91ad0142bbfc53d", + "bifrost_260418T170408.h5": "md5:5fe544c2eccfb6c4ec52beca9957f528", + "computed_energy_data_simulated_5x2.h5": "md5:1a24a1067ae2968dfc162c5c72dcb073", + "BIFROST-simulation-lookup-table.h5": "md5:237e26125b22aa9cb0c68454206896a2", }, - version="7", + version="8", ) @@ -31,7 +30,7 @@ def get_path(name: str) -> Path: def simulated_elastic_incoherent_with_phonon() -> Path: """Simulated data for elastic incoherent scattering including a phonon.""" - return get_path("BIFROST_20240914T053723.h5") + return get_path("bifrost_260418T170408.h5") def lookup_table_simulation() -> Path: diff --git a/tests/bifrost/cutting_test.py b/tests/bifrost/cutting_test.py index 225b91f..d49fe1d 100644 --- a/tests/bifrost/cutting_test.py +++ b/tests/bifrost/cutting_test.py @@ -39,7 +39,7 @@ def energy_data( workflow[LookupTableFilename] = lookup_table_simulation() workflow[LookupTableRelativeErrorThreshold] = { 'detector': np.inf, - '110_frame_3': np.inf, + 'normalization_monitor': np.inf, } workflow[UncertaintyBroadcastMode] = UncertaintyBroadcastMode.drop return workflow.compute(EnergyQDetector[SampleRun]) @@ -59,7 +59,7 @@ def test_cut_along_q_norm_and_energy_transfer_preserves_counts( axis_2 = CutAxis( output='E', fn=lambda energy_transfer: energy_transfer, - bins=sc.linspace(dim='E', start=-10.0, stop=10.0, num=50, unit='meV'), + bins=sc.linspace(dim='E', start=-10.0, stop=15.0, num=50, unit='meV'), ) cut_data = cut( @@ -84,12 +84,12 @@ def test_cut_along_qx_direction_preserves_counts( axis_1 = CutAxis.from_q_vector( output='Qx', vec=sc.vector([1, 0, 0]), - bins=sc.linspace(dim='Qx', start=-2.0, stop=2.0, num=40, unit='1/angstrom'), + bins=sc.linspace(dim='Qx', start=-3.0, stop=2.0, num=40, unit='1/angstrom'), ) axis_2 = CutAxis( output='E', fn=lambda energy_transfer: energy_transfer, - bins=sc.linspace(dim='E', start=-5.0, stop=5.0, num=30, unit='meV'), + bins=sc.linspace(dim='E', start=-10.0, stop=15.0, num=30, unit='meV'), ) cut_data = cut( diff --git a/tests/bifrost/io/sqw_test.py b/tests/bifrost/io/sqw_test.py index f8762f8..df13db2 100644 --- a/tests/bifrost/io/sqw_test.py +++ b/tests/bifrost/io/sqw_test.py @@ -77,7 +77,7 @@ def common_workflow( wf[LookupTableFilename] = lookup_table_simulation() wf[LookupTableRelativeErrorThreshold] = { 'detector': np.inf, - '110_frame_3': np.inf, + 'normalization_monitor': np.inf, } wf[PreopenNeXusFile] = PreopenNeXusFile(True) wf[UncertaintyBroadcastMode] = UncertaintyBroadcastMode.drop diff --git a/tests/bifrost/live_test.py b/tests/bifrost/live_test.py index 22823d2..ada00c1 100644 --- a/tests/bifrost/live_test.py +++ b/tests/bifrost/live_test.py @@ -40,7 +40,7 @@ def qcut_workflow( workflow[LookupTableFilename] = lookup_table_simulation() workflow[LookupTableRelativeErrorThreshold] = { 'detector': np.inf, - '110_frame_3': np.inf, + 'normalization_monitor': np.inf, } workflow[UncertaintyBroadcastMode] = UncertaintyBroadcastMode.drop workflow[ProtonCharge[SampleRun]] = sc.DataArray(sc.scalar(1.0, unit='pC')) diff --git a/tests/bifrost/workflow_test.py b/tests/bifrost/workflow_test.py index 5d5b1a1..b5abc00 100644 --- a/tests/bifrost/workflow_test.py +++ b/tests/bifrost/workflow_test.py @@ -42,7 +42,7 @@ def workflow(simulation_detector_names: list[NeXusDetectorName]) -> sciline.Pipe workflow[LookupTableFilename] = lookup_table_simulation() workflow[LookupTableRelativeErrorThreshold] = { 'detector': np.inf, - '110_frame_3': np.inf, + 'normalization_monitor': np.inf, } workflow[UncertaintyBroadcastMode] = UncertaintyBroadcastMode.drop return workflow @@ -50,14 +50,14 @@ def workflow(simulation_detector_names: list[NeXusDetectorName]) -> sciline.Pipe def test_simulation_workflow_can_load_detector() -> None: workflow = bifrost.BifrostSimulationWorkflow( - [NeXusDetectorName("125_channel_1_1_triplet")] + [NeXusDetectorName("channel_3_1_triplet")] ) workflow[Filename[SampleRun]] = simulated_elastic_incoherent_with_phonon() results = sciline.compute_mapped(workflow, RawDetector[SampleRun]) result = results.iloc[0] assert result.bins is not None - assert set(result.dims) == {'tube', 'length'} + assert set(result.dims) == {'tube', 'length', 'time'} assert result.sizes['tube'] == 3 assert 'position' in result.coords @@ -80,7 +80,7 @@ def test_simulation_workflow_can_compute_energy_data( 'tube': 3, 'length': 100, 'a3': 180, - 'a4': 1, + 'a4': 2, } expected_coords = {'a3', 'a4', 'detector_number'} assert expected_coords.issubset(energy_data.coords) @@ -111,7 +111,6 @@ def test_simulation_workflow_produces_the_same_data_as_before( workflow: sciline.Pipeline, ) -> None: energy_data = workflow.compute(EnergyQDetector[SampleRun]) - sc.io.save_hdf5(energy_data, 'computed_energy_data_simulated_5x2.h5') expected = sc.io.load_hdf5(computed_energy_data_simulated_5x2()) assert not energy_data.masks From e10832a77c4df705dec639d1607c5776934b14a4 Mon Sep 17 00:00:00 2001 From: Jan-Lukas Wynen Date: Wed, 6 May 2026 09:38:55 +0200 Subject: [PATCH 18/21] Support det rotation in single crystal workflow --- .../bifrost/bifrost-bragg-peak-monitor.ipynb | 22 +++---- docs/user-guide/bifrost/index.md | 5 +- src/ess/bifrost/detector.py | 57 +++++++++++++++---- src/ess/bifrost/single_crystal/detector.py | 50 ++++++++++++++++ src/ess/bifrost/single_crystal/q_map.py | 3 + src/ess/bifrost/single_crystal/workflow.py | 4 +- 6 files changed, 112 insertions(+), 29 deletions(-) create mode 100644 src/ess/bifrost/single_crystal/detector.py diff --git a/docs/user-guide/bifrost/bifrost-bragg-peak-monitor.ipynb b/docs/user-guide/bifrost/bifrost-bragg-peak-monitor.ipynb index 02d0f3f..b8eaf3d 100644 --- a/docs/user-guide/bifrost/bifrost-bragg-peak-monitor.ipynb +++ b/docs/user-guide/bifrost/bifrost-bragg-peak-monitor.ipynb @@ -62,7 +62,7 @@ "workflow[LookupTableFilename] = lookup_table_simulation()\n", "workflow[LookupTableRelativeErrorThreshold] = {\n", " 'detector': np.inf,\n", - " '110_frame_3': np.inf,\n", + " 'normalization_monitor': np.inf,\n", "}" ] }, @@ -89,7 +89,7 @@ "metadata": {}, "outputs": [], "source": [ - "workflow[NeXusDetectorName] = \"309_channel_9_5_triplet\"\n", + "workflow[NeXusDetectorName] = \"channel_9_5_triplet\"\n", "\n", "\n", "def assemble_detector_data_flatten(\n", @@ -139,18 +139,10 @@ "workflow.visualize(CountsWithQMapCoords[SampleRun], graph_attr={\"rankdir\": \"LR\"})" ] }, - { - "cell_type": "markdown", - "id": "8", - "metadata": {}, - "source": [ - "Compute the data projected onto Q:" - ] - }, { "cell_type": "code", "execution_count": null, - "id": "9", + "id": "8", "metadata": {}, "outputs": [], "source": [ @@ -159,7 +151,7 @@ }, { "cell_type": "markdown", - "id": "10", + "id": "9", "metadata": {}, "source": [ "Make an interactive figure.\n", @@ -169,13 +161,13 @@ { "cell_type": "code", "execution_count": null, - "id": "11", + "id": "10", "metadata": {}, "outputs": [], "source": [ "make_q_map(counts,\n", - " q_parallel_bins=sc.linspace('Q_parallel', -3, 3, 100, unit='1/Å'),\n", - " q_perpendicular_bins=sc.linspace('Q_perpendicular', -3, 3, 100, unit='1/Å'),\n", + " q_parallel_bins=sc.linspace('Q_parallel', -4.5, 4.5, 100, unit='1/Å'),\n", + " q_perpendicular_bins=sc.linspace('Q_perpendicular', -4.5, 4.5, 100, unit='1/Å'),\n", " sample_rotation_bins=sc.scalar(1.0, unit='deg'),\n", " )" ] diff --git a/docs/user-guide/bifrost/index.md b/docs/user-guide/bifrost/index.md index c982681..380fe16 100644 --- a/docs/user-guide/bifrost/index.md +++ b/docs/user-guide/bifrost/index.md @@ -1,4 +1,5 @@ # BIFROST + ## Reduction Workflows ::::{grid} 3 @@ -11,10 +12,12 @@ :class: only-light :width: 100% ``` + ```{image} ../../_static/thumbnails/bifrost_reduction_dark.svg :class: only-dark :width: 100% ``` + ::: :::: @@ -25,7 +28,6 @@ hidden: --- bifrost-reduction -bifrost-bragg-peak-monitor ``` ## Advanced Tools @@ -35,5 +37,6 @@ bifrost-bragg-peak-monitor maxdepth: 1 --- +bifrost-bragg-peak-monitor bifrost-make-wavelength-lookup-table ``` diff --git a/src/ess/bifrost/detector.py b/src/ess/bifrost/detector.py index e8c678b..9b25055 100644 --- a/src/ess/bifrost/detector.py +++ b/src/ess/bifrost/detector.py @@ -91,12 +91,12 @@ def get_calibrated_detector_bifrost( ) -> EmptyDetector[RunType]: """Extract the data array corresponding to a detector's signal field. - The data array is reshaped to the logical detector shape. + This includes: - This function is specific to BIFROST and differs from the generic - :func:`ess.reduce.nexus.workflow.get_calibrated_detector` in that it does not - fold the detectors into logical dimensions because the files already contain - the detectors in the correct shape. + - Reshaping the data array is reshaped to the logical detector shape. + - Assigning geometry coordinate "position". + - Assigning spectrometer coordinates such as "final_energy", + "secondary_flight_time", and "L1". Parameters ---------- @@ -119,17 +119,13 @@ def get_calibrated_detector_bifrost( ------- : Detector geometry and spectrometer coordinates. - This includes "final_energy", "secondary_flight_time", and "L1". """ - from ess.reduce.nexus import compute_detector_position, extract_signal_data_array - - da = extract_signal_data_array(detector) + da = get_base_calibrated_detector_bifrost( + detector, analyzer, transform=transform, offset=offset + ) da = da.rename(dim_0='tube', dim_1='length') - position = compute_detector_position(da, transform=transform, offset=offset) - da = _assign_detector_position(da, position) - arc, channel = arc_and_channel_from_detector_number(da.coords['detector_number']) da.coords['arc'] = arc da.coords['channel'] = channel @@ -145,6 +141,43 @@ def get_calibrated_detector_bifrost( return EmptyDetector[RunType](da) +def get_base_calibrated_detector_bifrost( + detector: NeXusComponent[snx.NXdetector, RunType], + analyzer: Analyzer[RunType], + *, + transform: NeXusTransformation[snx.NXdetector, RunType], + offset: DetectorPositionOffset[RunType], +) -> sc.DataArray: + """Extract the data array corresponding to a detector's signal field. + + This function is specific to BIFROST and differs from the generic + :func:`ess.reduce.nexus.workflow.get_calibrated_detector` in that it + assigns time-dependent positions by broadcasting the data into the 'time' dimension. + + Parameters + ---------- + detector: + Loaded NeXus detector. + analyzer: + Loaded analyzer parameters. + transform: + Transformation that determines the detector position. + offset: + Offset to add to the detector position. + + Returns + ------- + : + Detector with geometry coordinates. + """ + + from ess.reduce.nexus import compute_detector_position, extract_signal_data_array + + da = extract_signal_data_array(detector) + position = compute_detector_position(da, transform=transform, offset=offset) + return _assign_detector_position(da, position) + + def _assign_detector_position( da: sc.DataArray, position: sc.Variable | sc.DataArray ) -> sc.DataArray: diff --git a/src/ess/bifrost/single_crystal/detector.py b/src/ess/bifrost/single_crystal/detector.py new file mode 100644 index 0000000..320b9a6 --- /dev/null +++ b/src/ess/bifrost/single_crystal/detector.py @@ -0,0 +1,50 @@ +# SPDX-License-Identifier: BSD-3-Clause +# Copyright (c) 2026 Scipp contributors (https://github.com/scipp) + +"""Bragg peak detector handling for BIFROST.""" + +import scippnexus as snx + +from ess.spectroscopy.types import ( + Analyzer, + DetectorPositionOffset, + EmptyDetector, + NeXusComponent, + NeXusTransformation, + RunType, +) + +from ..detector import get_base_calibrated_detector_bifrost + + +def get_calibrated_bragg_peak_detector( + detector: NeXusComponent[snx.NXdetector, RunType], + analyzer: Analyzer[RunType], + *, + transform: NeXusTransformation[snx.NXdetector, RunType], + offset: DetectorPositionOffset[RunType], +) -> EmptyDetector[RunType]: + """Extract the data array corresponding to the Bragg peak detector's signal field. + + Parameters + ---------- + detector: + Loaded NeXus detector. + analyzer: + Loaded analyzer parameters. + transform: + Transformation that determines the detector position. + offset: + Offset to add to the detector position. + + Returns + ------- + : + Detector with geometry coordinates. + """ + return get_base_calibrated_detector_bifrost( + detector, analyzer, transform=transform, offset=offset + ) + + +providers = (get_calibrated_bragg_peak_detector,) diff --git a/src/ess/bifrost/single_crystal/q_map.py b/src/ess/bifrost/single_crystal/q_map.py index a1cb863..9914fc5 100644 --- a/src/ess/bifrost/single_crystal/q_map.py +++ b/src/ess/bifrost/single_crystal/q_map.py @@ -47,6 +47,9 @@ def project_momentum_transfer( transformed.bins.coords['a3'] = sc.bins_like( transformed, transformed.coords['a3'] ) + transformed.bins.coords['a4'] = sc.bins_like( + transformed, transformed.coords['a4'] + ) transformed = transformed.bins.concat() return CountsWithQMapCoords[RunType](transformed) diff --git a/src/ess/bifrost/single_crystal/workflow.py b/src/ess/bifrost/single_crystal/workflow.py index 49addb6..ac4bb98 100644 --- a/src/ess/bifrost/single_crystal/workflow.py +++ b/src/ess/bifrost/single_crystal/workflow.py @@ -16,11 +16,12 @@ from ..io import nexus from ..io.mcstas import convert_simulated_time_to_event_time_offset from ..workflow import default_parameters, simulation_default_parameters -from . import conversion, q_map, time_of_flight +from . import conversion, detector, q_map, time_of_flight _PROVIDERS = ( *nexus.providers, *conversion.providers, + *detector.providers, *q_map.providers, *time_of_flight.providers, group_by_rotation, @@ -29,6 +30,7 @@ _SIMULATION_PROVIDERS = ( *nexus.providers, *conversion.providers, + *detector.providers, *q_map.providers, *time_of_flight.providers, convert_simulated_time_to_event_time_offset, From 299d873bf9eb8240293d27f83f816bf534c7e67b Mon Sep 17 00:00:00 2001 From: Jan-Lukas Wynen Date: Wed, 6 May 2026 13:32:41 +0200 Subject: [PATCH 19/21] Support multi a4 in SQW --- src/ess/bifrost/io/sqw.py | 22 +++++++++++----------- tests/bifrost/io/sqw_test.py | 30 ++++++++++++++++++++++-------- 2 files changed, 33 insertions(+), 19 deletions(-) diff --git a/src/ess/bifrost/io/sqw.py b/src/ess/bifrost/io/sqw.py index 28444ab..9528354 100644 --- a/src/ess/bifrost/io/sqw.py +++ b/src/ess/bifrost/io/sqw.py @@ -106,19 +106,13 @@ def save_sqw( scippneutron.io.sqw: For low-level SQW I/O and the underlying implementation of ``save_sqw``. """ - if np.unique(events.coords['a4'].values).size != 1: - # We need to support this eventually, but we don't - # have data for a moving detector vessel yet. - raise NotImplementedError("a4 must be constant for all events") - flat_events = _flatten_events(events) del events # 'move' events into _flatten_events _filter_and_convert_coords_in_place(flat_events) - sample_angle = flat_events.coords['a3'] - observations = _histogram_detector_setting_ei(flat_events, energy_bins=energy_bins) del flat_events # 'move' flat_events into _histogram_detector_setting_ei + sample_angle = observations.coords['a3'] final_energy = observations.coords['final_energy'] observations = _with_inelastic_coords(observations, gravity) energy_transfer = observations.coords['energy_transfer'].rename_dims( @@ -161,7 +155,7 @@ def _flatten_events( n_a3 = aux.sizes['a3'] aux.coords['i_a3'] = sc.arange('a3', n_a3, dtype='float32', unit=None) aux.coords['i_a4'] = sc.arange('a4', aux.sizes['a4'], dtype='float32', unit=None) - flat = aux.flatten(['a3', 'a4'], 'setting') + flat = aux.transpose(['detector', 'a4', 'a3']).flatten(['a4', 'a3'], 'setting') return flat.assign_coords( irun=flat.coords.pop('i_a3') + flat.coords.pop('i_a4') * sc.index(n_a3) @@ -422,9 +416,9 @@ def _make_experiments( ) -> list[sqw.SqwIXExperiment]: experiment_template = sqw.SqwIXExperiment( run_id=0, # converted to 1-based by ScippNeutron - efix=final_energy, emode=sqw.EnergyMode.indirect, - en=energy_transfer, + efix=None, # type: ignore[assignment] (overridden below) + en=None, # type: ignore[assignment] psi=sc.scalar(0.0, unit="rad"), u=_AXIS_U, v=_AXIS_V, @@ -434,6 +428,12 @@ def _make_experiments( gs=sc.scalar(0.0, unit="rad"), ) return [ - dataclasses.replace(experiment_template, run_id=i, psi=a3) + dataclasses.replace( + experiment_template, + run_id=i, + psi=a3, + efix=final_energy['setting', i], + en=energy_transfer['setting', i], + ) for i, a3 in enumerate(sample_angle) ] diff --git a/tests/bifrost/io/sqw_test.py b/tests/bifrost/io/sqw_test.py index df13db2..6bd5d4d 100644 --- a/tests/bifrost/io/sqw_test.py +++ b/tests/bifrost/io/sqw_test.py @@ -7,6 +7,7 @@ # Function-scoped fixtures allow accessing that file for reading. import itertools +from collections import Counter from collections.abc import Generator from pathlib import Path @@ -38,7 +39,8 @@ ) N_DETECTORS = 3 -N_ANGLES = 180 +N_ANGLES = 178 +N_DET_ROTATIONS = 2 BIN_SIZES = {'u1': 6, 'u2': 7, 'u3': 8, 'u4': 9} ENERGY_BIN_SIZE = 13 @@ -108,13 +110,13 @@ def output_file(write_file: Path) -> Generator[sqw.Sqw, None, None]: def test_save_sqw_writes_instrument_metadata(output_file: sqw.Sqw) -> None: instruments = output_file.read_data_block("experiment_info", "instruments") - assert len(instruments) == N_ANGLES + assert len(instruments) == N_ANGLES * N_DET_ROTATIONS # All instruments are the same: for instrument in instruments[1:]: sc.testing.assert_identical(instrument, instruments[0]) instrument = instruments[0] - assert instrument.name == "BIFROST" + assert instrument.name == "bifrost" sc.testing.assert_identical(instrument.source.frequency, sc.scalar(14.0, unit="Hz")) @@ -123,7 +125,7 @@ def test_save_sqw_writes_sample_metadata( ) -> None: samples = output_file.read_data_block("experiment_info", "samples") - assert len(samples) == N_ANGLES + assert len(samples) == N_ANGLES * N_DET_ROTATIONS # All samples are the same: for s in samples: sc.testing.assert_identical(s, sample) @@ -132,14 +134,22 @@ def test_save_sqw_writes_sample_metadata( def test_save_sqw_writes_experiment_metadata(output_file: sqw.Sqw) -> None: experiments = output_file.read_data_block("experiment_info", "expdata") - assert len(experiments) == N_ANGLES + assert len(experiments) == N_ANGLES * N_DET_ROTATIONS # N unique run ids - assert len({experiment.run_id for experiment in experiments}) == N_ANGLES + assert ( + len({experiment.run_id for experiment in experiments}) + == N_ANGLES * N_DET_ROTATIONS + ) for experiment in experiments: assert experiment.emode == sqw.EnergyMode.indirect assert experiment.u == U assert experiment.v == V + # Every a3 (psi) is represented once per detector rotation + # because we have a regular grid. + psi_counts = Counter(experiment.psi.value for experiment in experiments) + assert all(count == N_DET_ROTATIONS for count in psi_counts.values()) + def test_save_sqw_writes_dnd_metadata( output_file: sqw.Sqw, sample: sqw.SqwIXSample @@ -176,7 +186,11 @@ def test_save_sqw_writes_pixel_data(output_file: sqw.Sqw) -> None: pix = output_file.read_data_block("pix", "data_wrap") assert pix.shape == ( - N_DETECTORS * N_PIXELS_PER_DETECTOR * N_ANGLES * ENERGY_BIN_SIZE, + N_DETECTORS + * N_PIXELS_PER_DETECTOR + * N_ANGLES + * ENERGY_BIN_SIZE + * N_DET_ROTATIONS, 9, ) @@ -222,7 +236,7 @@ def check_pixel_indices_in_ranges(pix: npt.NDArray[np.float32]) -> None: # 1-based indices! assert irun.min() == 1 - assert irun.max() == N_ANGLES + assert irun.max() == N_ANGLES * N_DET_ROTATIONS assert ien.min() == 1 assert ien.max() == ENERGY_BIN_SIZE From 582360429653e794641f835967a73554ec513b99 Mon Sep 17 00:00:00 2001 From: Jan-Lukas Wynen Date: Fri, 8 May 2026 12:52:16 +0200 Subject: [PATCH 20/21] Explain analyzer loading --- src/ess/bifrost/io/nexus.py | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/src/ess/bifrost/io/nexus.py b/src/ess/bifrost/io/nexus.py index 3ebe77f..ff32b9a 100644 --- a/src/ess/bifrost/io/nexus.py +++ b/src/ess/bifrost/io/nexus.py @@ -61,6 +61,11 @@ def load_analyzer_for_detector( This function searches for an ``NXcrystal`` in the inputs (via the 'input' attribute) of the detector and loads the first NeXus group it finds. + + See Also + -------- + get_calibrated_analyzer: + A provider that combines loaded analyzer data into an ``Analyzer`` object. """ with open_component_group(detector_location, nx_class=snx.NXdetector) as det_group: analyzer_group = _find_class_in_inputs( @@ -114,8 +119,6 @@ def _get_inputs(group: snx.Group) -> list[str]: return [inputs] if isinstance(inputs, str) else inputs -# This function is separate from load_analyzer_for_detector so we get the default -# behavior for resolving NXtransformations. def get_calibrated_analyzer( analyzer_component: NeXusComponent[snx.NXcrystal, RunType], analyzer_transform: NeXusTransformation[snx.NXcrystal, RunType], @@ -123,6 +126,13 @@ def get_calibrated_analyzer( ) -> Analyzer[RunType]: """Collect the data for a single analyzer. + This provider works together with :func:`load_analyzer_for_detector` and the + generic NeXus workflow from ESSreduce. + ``load_analyzer_for_detector`` loads a raw analyzer component. + Then the default providers from ESSreduce extract a transform and position like + for any other component. + Finally, this provider combines the data into a single Analyzer object. + Parameters ---------- analyzer_component: From f30776d605ac75af8bae4f559886f3ebc38c5d24 Mon Sep 17 00:00:00 2001 From: Jan-Lukas Wynen Date: Mon, 11 May 2026 10:24:17 +0200 Subject: [PATCH 21/21] Explain stepwise time filter --- src/ess/bifrost/io/nexus.py | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/src/ess/bifrost/io/nexus.py b/src/ess/bifrost/io/nexus.py index ff32b9a..1a7ccbc 100644 --- a/src/ess/bifrost/io/nexus.py +++ b/src/ess/bifrost/io/nexus.py @@ -189,8 +189,20 @@ def stepwise_transformation_time_filter(transform: sc.DataArray) -> sc.DataArray """Collapse runs of equal values into a single value. This can be used as a time filter for NeXus transformations when the component - mostly stays at a position and only rarely moves. + mostly stays at one position and only rarely moves. For example, a stepwise scan across detector rotations. + + Repeated values are identified using :func:`numpy.isclose` with default tolerances + applied to the individual transformation components. + I.e., for the BIFROST detector, the detector angle (currently in degrees) + is checked for approximate equality between consecutive values. + + Note + ---- + This approach is meant to handle noisy NXlogs if they are written + from readback values or repeated setpoint values. + We currently do not know enough about how ESS NeXus files will be written for + real measurements, so we may need to revisit this approach. """ collapsed = _collapse_runs(transform, 'time') if collapsed.sizes['time'] == 1: