From 0cabb8dc863cd2a15e57aa5eafb52298981e0244 Mon Sep 17 00:00:00 2001 From: Simon Heybrock Date: Tue, 11 Aug 2026 09:17:42 +0000 Subject: [PATCH 1/3] Source BIFROST Bragg peak monitor geometry from the NXmonitor The Bragg peak monitor is BIFROST's elastic monitor (cbm5), written as an NXmonitor in CODA files, in the pinned geometry artifacts, and in the McStas simulation file alike. The single-crystal workflow instead required it as NeXusComponent[NXdetector], so loading raised ValueError: The NeXus group 'elastic_monitor' was expected to be a NXdetector but is a NXmonitor against every real file. Only the user guide worked, by standing a detector triplet in for the monitor. Take the component, its transformation and its event data from ElasticMonitor, which the workflow already declares in monitor_types, so all the loading nodes exist. A monitor has no pixel offsets, so the position is the transformed origin as in get_calibrated_monitor rather than compute_detector_position, and no detector_number, so the single pixel is named explicitly for event assembly. The Analyzer dependency is dropped: get_base_calibrated_detector_bifrost never reads it, and a monitor in the direct beam has no analyzer. Ltotal is a straight line for the same reason, so insert that provider rather than leaving every caller to supply it. Fix a latent defect this exposes: a time-dependent position makes ltotal depend on 'time', but group_by_rotation has already renamed that same dimension to 'a4', so the broadcast in detector_wavelength_data rejected it. This bites whenever the tank rotates; the user guide escaped it only because its stand-in position is static. The simulated data contains no Bragg peak monitor, so the detector stand-in moves to simulation_providers and keeps the user guide working. --- .../ess/bifrost/single_crystal/detector.py | 84 ++++++++++++++++++- .../bifrost/single_crystal/time_of_flight.py | 7 ++ .../ess/bifrost/single_crystal/workflow.py | 15 +++- 3 files changed, 101 insertions(+), 5 deletions(-) diff --git a/packages/essspectroscopy/src/ess/bifrost/single_crystal/detector.py b/packages/essspectroscopy/src/ess/bifrost/single_crystal/detector.py index 4ee7b71bc..5bd01349a 100644 --- a/packages/essspectroscopy/src/ess/bifrost/single_crystal/detector.py +++ b/packages/essspectroscopy/src/ess/bifrost/single_crystal/detector.py @@ -1,19 +1,90 @@ # SPDX-License-Identifier: BSD-3-Clause # Copyright (c) 2026 Scipp contributors (https://github.com/scipp) -"""Bragg peak detector handling for BIFROST.""" +"""Bragg peak monitor handling for BIFROST.""" +import scipp as sc import scippnexus as snx from ess.spectroscopy.types import ( Analyzer, DetectorPositionOffset, + ElasticMonitor, EmptyDetector, NeXusComponent, + NeXusData, NeXusTransformation, + RawDetector, RunType, ) -from ..detector import get_base_calibrated_detector_bifrost +from ess.reduce.nexus.types import MonitorPositionOffset + +from ..detector import _assign_detector_position, get_base_calibrated_detector_bifrost + + +def get_calibrated_bragg_peak_monitor( + monitor: NeXusComponent[ElasticMonitor, RunType], + *, + transform: NeXusTransformation[ElasticMonitor, RunType], + offset: MonitorPositionOffset[RunType, ElasticMonitor], +) -> EmptyDetector[RunType]: + """Extract the data array corresponding to the Bragg peak monitor's signal field. + + BIFROST's Bragg peak monitor is the elastic monitor (``cbm5``), written as an + ``NXmonitor``. It has no pixel offsets, so its position is the transformed origin + as in :func:`ess.reduce.nexus.workflow.get_calibrated_monitor`, rather than the + per-pixel computation used for detectors. The position is assigned with the + BIFROST-specific broadcasting because the monitor is mounted on the detector tank + and therefore moves with the instrument angle. + + Parameters + ---------- + monitor: + Loaded NeXus monitor. + transform: + Transformation that determines the monitor position. + offset: + Offset to add to the monitor position. + + Returns + ------- + : + Monitor with geometry coordinates. + """ + from ess.reduce.nexus import extract_signal_data_array + + da = extract_signal_data_array(monitor) + unit = transform.value.unit + position = transform.value * sc.vector([0.0, 0.0, 0.0], unit=unit) + offset.to( + unit=unit + ) + # A monitor carries no detector_number; the Bragg peak monitor is a single pixel, + # so name it explicitly for the pixel-grouping done by the event assembly below. + da = da.assign_coords(detector_number=sc.index(1)) + return EmptyDetector[RunType](_assign_detector_position(da, position)) + + +def assemble_bragg_peak_monitor_data( + monitor: EmptyDetector[RunType], + data: NeXusData[ElasticMonitor, RunType], +) -> RawDetector[RunType]: + """Combine the Bragg peak monitor's geometry with its event data. + + Parameters + ---------- + monitor: + Monitor geometry from :func:`get_calibrated_bragg_peak_monitor`. + data: + Monitor event data. + + Returns + ------- + : + Events with geometry coordinates. + """ + from ess.reduce.nexus.workflow import assemble_detector_data + + return RawDetector[RunType](assemble_detector_data(monitor, data)) def get_calibrated_bragg_peak_detector( @@ -23,7 +94,11 @@ def get_calibrated_bragg_peak_detector( transform: NeXusTransformation[snx.NXdetector, RunType], offset: DetectorPositionOffset[RunType], ) -> EmptyDetector[RunType]: - """Extract the data array corresponding to the Bragg peak detector's signal field. + """Extract the data array corresponding to a detector's signal field. + + Simulated data contains no Bragg peak monitor, so a bank ('triplet') of the + regular inelastic detector stands in for it. Real data uses + :func:`get_calibrated_bragg_peak_monitor` instead. Parameters ---------- @@ -46,4 +121,5 @@ def get_calibrated_bragg_peak_detector( ) -providers = (get_calibrated_bragg_peak_detector,) +providers = (get_calibrated_bragg_peak_monitor, assemble_bragg_peak_monitor_data) +simulation_providers = (get_calibrated_bragg_peak_detector,) diff --git a/packages/essspectroscopy/src/ess/bifrost/single_crystal/time_of_flight.py b/packages/essspectroscopy/src/ess/bifrost/single_crystal/time_of_flight.py index eedf5f03f..1f26fa929 100644 --- a/packages/essspectroscopy/src/ess/bifrost/single_crystal/time_of_flight.py +++ b/packages/essspectroscopy/src/ess/bifrost/single_crystal/time_of_flight.py @@ -32,6 +32,13 @@ def detector_wavelength_data( :func:`ess.reduce.unwrap.detector_wavelength_data` for different input types. """ + # A time-dependent detector position (BIFROST's tank rotates, and the Bragg peak + # monitor is mounted on it) makes ``ltotal`` depend on 'time'. The instrument angle + # is the only dynamic parameter, so ``group_by_rotation`` has already turned that + # same 'time' dimension into 'a4'. Rename to match, or the broadcast below rejects + # ``ltotal`` as having a dimension the data does not. + if 'time' in ltotal.dims and 'time' not in sample_data.dims: + ltotal = ltotal.rename_dims(time='a4') return reduce_unwrap.to_wavelength.detector_wavelength_data( detector_data=RawDetector[RunType](sample_data), lookup=lookup, diff --git a/packages/essspectroscopy/src/ess/bifrost/single_crystal/workflow.py b/packages/essspectroscopy/src/ess/bifrost/single_crystal/workflow.py index 71bca0b8b..2c69ed99d 100644 --- a/packages/essspectroscopy/src/ess/bifrost/single_crystal/workflow.py +++ b/packages/essspectroscopy/src/ess/bifrost/single_crystal/workflow.py @@ -11,6 +11,7 @@ ) from ess.reduce import unwrap as reduce_unwrap +from ess.reduce.nexus.types import NeXusName from ..cutting import group_by_rotation from ..io import nexus @@ -30,7 +31,7 @@ _SIMULATION_PROVIDERS = ( *nexus.providers, *conversion.providers, - *detector.providers, + *detector.simulation_providers, *q_map.providers, *time_of_flight.providers, convert_simulated_time_to_event_time_offset, @@ -45,6 +46,12 @@ def BifrostBraggPeakMonitorWorkflow() -> sciline.Pipeline: ) # Use the vanilla implementation instead of the indirect geometry one: workflow.insert(reduce_unwrap.to_wavelength.detector_wavelength_data) + # The Bragg peak monitor sees the direct beam, so its flight path is a straight + # line rather than the analyzer-folded path of the inelastic detectors. + workflow.insert( + reduce_unwrap.to_wavelength.detector_ltotal_from_straight_line_approximation + ) + workflow[NeXusName[ElasticMonitor]] = 'elastic_monitor' for provider in _PROVIDERS: workflow.insert(provider) for key, val in default_parameters().items(): @@ -59,6 +66,12 @@ def BifrostSimulationBraggPeakMonitorWorkflow() -> sciline.Pipeline: ) # Use the vanilla implementation instead of the indirect geometry one: workflow.insert(reduce_unwrap.to_wavelength.detector_wavelength_data) + # The Bragg peak monitor sees the direct beam, so its flight path is a straight + # line rather than the analyzer-folded path of the inelastic detectors. + workflow.insert( + reduce_unwrap.to_wavelength.detector_ltotal_from_straight_line_approximation + ) + workflow[NeXusName[ElasticMonitor]] = 'elastic_monitor' for provider in _SIMULATION_PROVIDERS: workflow.insert(provider) for key, val in simulation_default_parameters().items(): From 475730b00452dd3efdd497c264602ceda27c854a Mon Sep 17 00:00:00 2001 From: Simon Heybrock Date: Tue, 11 Aug 2026 09:29:29 +0000 Subject: [PATCH 2/3] Assemble Bragg peak monitor events without pixel grouping Streamed monitor events carry no event_id, so assemble_detector_data cannot group them. The monitor is a single pixel, so assign its geometry onto the events as assemble_monitor_data does. Co-Authored-By: Claude Opus 5 --- .../src/ess/bifrost/single_crystal/detector.py | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/packages/essspectroscopy/src/ess/bifrost/single_crystal/detector.py b/packages/essspectroscopy/src/ess/bifrost/single_crystal/detector.py index 5bd01349a..46d9bf624 100644 --- a/packages/essspectroscopy/src/ess/bifrost/single_crystal/detector.py +++ b/packages/essspectroscopy/src/ess/bifrost/single_crystal/detector.py @@ -82,9 +82,13 @@ def assemble_bragg_peak_monitor_data( : Events with geometry coordinates. """ - from ess.reduce.nexus.workflow import assemble_detector_data + from ess.reduce.nexus.workflow import _add_variances - return RawDetector[RunType](assemble_detector_data(monitor, data)) + # Not ``assemble_detector_data``: that groups events by ``event_id``, which a + # monitor does not carry. The Bragg peak monitor is a single pixel, so its + # geometry is simply assigned onto the events, as for any other monitor. + da = data.assign_coords(monitor.coords).assign_masks(monitor.masks) + return RawDetector[RunType](_add_variances(da)) def get_calibrated_bragg_peak_detector( From 4216af237f7450557c8c525ddbab6f83f62c6fa0 Mon Sep 17 00:00:00 2001 From: Simon Heybrock Date: Fri, 14 Aug 2026 04:55:32 +0000 Subject: [PATCH 3/3] Assemble the Bragg peak monitor as a monitor, not a detector The reason for not using assemble_detector_data is not that streamed events lack an event_id. It is that assemble_detector_data groups events by event_id onto a detector_number grid, and the Bragg peak monitor is a single pixel with nothing to group by. The detector_number assigned to it existed only to satisfy that grouping and is now unused. Call assemble_monitor_data, which is exactly the remaining operation: assign the geometry onto the events. This drops the copy of its body and the private _add_variances import. The result is independent of whether the events carry an event_id, so one provider serves a file-loaded cbm5_events group (which has one, constant 1) and a stream (which, in esslivedata, does not: its adapter discards the ev44 pixel_id for monitors not registered as pixellated). Co-Authored-By: Claude Opus 5 --- .../src/ess/bifrost/single_crystal/detector.py | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/packages/essspectroscopy/src/ess/bifrost/single_crystal/detector.py b/packages/essspectroscopy/src/ess/bifrost/single_crystal/detector.py index 46d9bf624..16d1200dd 100644 --- a/packages/essspectroscopy/src/ess/bifrost/single_crystal/detector.py +++ b/packages/essspectroscopy/src/ess/bifrost/single_crystal/detector.py @@ -58,9 +58,6 @@ def get_calibrated_bragg_peak_monitor( position = transform.value * sc.vector([0.0, 0.0, 0.0], unit=unit) + offset.to( unit=unit ) - # A monitor carries no detector_number; the Bragg peak monitor is a single pixel, - # so name it explicitly for the pixel-grouping done by the event assembly below. - da = da.assign_coords(detector_number=sc.index(1)) return EmptyDetector[RunType](_assign_detector_position(da, position)) @@ -70,6 +67,13 @@ def assemble_bragg_peak_monitor_data( ) -> RawDetector[RunType]: """Combine the Bragg peak monitor's geometry with its event data. + Assembled as a monitor, not as a detector: ``assemble_detector_data`` groups + events by ``event_id`` onto a ``detector_number`` grid, and the Bragg peak + monitor is a single pixel with nothing to group by. Its geometry is therefore + assigned straight onto the events. The result is the same whether or not the + events carry an ``event_id`` -- a file-loaded ``cbm5_events`` group does, a + stream may not -- because the coordinate is simply left untouched. + Parameters ---------- monitor: @@ -82,13 +86,9 @@ def assemble_bragg_peak_monitor_data( : Events with geometry coordinates. """ - from ess.reduce.nexus.workflow import _add_variances + from ess.reduce.nexus.workflow import assemble_monitor_data - # Not ``assemble_detector_data``: that groups events by ``event_id``, which a - # monitor does not carry. The Bragg peak monitor is a single pixel, so its - # geometry is simply assigned onto the events, as for any other monitor. - da = data.assign_coords(monitor.coords).assign_masks(monitor.masks) - return RawDetector[RunType](_add_variances(da)) + return RawDetector[RunType](assemble_monitor_data(monitor, data)) def get_calibrated_bragg_peak_detector(