diff --git a/packages/essspectroscopy/src/ess/bifrost/single_crystal/detector.py b/packages/essspectroscopy/src/ess/bifrost/single_crystal/detector.py index 4ee7b71bc..16d1200dd 100644 --- a/packages/essspectroscopy/src/ess/bifrost/single_crystal/detector.py +++ b/packages/essspectroscopy/src/ess/bifrost/single_crystal/detector.py @@ -1,19 +1,94 @@ # 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 + ) + 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. + + 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: + 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_monitor_data + + return RawDetector[RunType](assemble_monitor_data(monitor, data)) def get_calibrated_bragg_peak_detector( @@ -23,7 +98,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 +125,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():