diff --git a/packages/essspectroscopy/docs/user-guide/bifrost/bifrost-bragg-peak-monitor.ipynb b/packages/essspectroscopy/docs/user-guide/bifrost/bifrost-bragg-peak-monitor.ipynb index cb789c8b3..a38a2ca71 100644 --- a/packages/essspectroscopy/docs/user-guide/bifrost/bifrost-bragg-peak-monitor.ipynb +++ b/packages/essspectroscopy/docs/user-guide/bifrost/bifrost-bragg-peak-monitor.ipynb @@ -29,7 +29,6 @@ "from ess.bifrost.single_crystal import BifrostSimulationBraggPeakMonitorWorkflow, make_q_map\n", "from ess.bifrost.single_crystal.types import *\n", "from ess.bifrost.types import McStasRawDetector\n", - "from ess.reduce.unwrap import DetectorLtotal\n", "from ess.spectroscopy.types import *" ] }, @@ -113,24 +112,7 @@ " return McStasRawDetector[RunType](monitor)\n", "\n", "\n", - "def detector_ltotal_from_straight_line_approximation(\n", - " detector_beamline: RawDetector[RunType],\n", - " source_position: Position[snx.NXsource, RunType],\n", - " sample_position: Position[snx.NXsample, RunType],\n", - " gravity: GravityVector,\n", - ") -> DetectorLtotal[RunType]:\n", - " from ess.reduce.unwrap import to_wavelength\n", - " return to_wavelength.detector_ltotal_from_straight_line_approximation(\n", - " detector_beamline, # type: ignore[arg-type]\n", - "\n", - " source_position=source_position,\n", - " sample_position=sample_position,\n", - " gravity=gravity,\n", - " )\n", - "\n", - "\n", - "workflow.insert(assemble_detector_data_flatten)\n", - "workflow.insert(detector_ltotal_from_straight_line_approximation)" + "workflow.insert(assemble_detector_data_flatten)" ] }, { diff --git a/packages/essspectroscopy/src/ess/bifrost/detector.py b/packages/essspectroscopy/src/ess/bifrost/detector.py index ee678ba8f..b159d4a63 100644 --- a/packages/essspectroscopy/src/ess/bifrost/detector.py +++ b/packages/essspectroscopy/src/ess/bifrost/detector.py @@ -11,7 +11,6 @@ from ess.spectroscopy.indirect.conversion import add_spectrometer_coords from ess.spectroscopy.types import ( Analyzer, - DetectorPositionOffset, EmptyDetector, NeXusComponent, NeXusTransformation, @@ -86,7 +85,6 @@ def get_calibrated_detector_bifrost( analyzer: Analyzer[RunType], *, transform: NeXusTransformation[snx.NXdetector, RunType], - offset: DetectorPositionOffset[RunType], primary_graph: PrimarySpecCoordTransformGraph[RunType], secondary_graph: SecondarySpecCoordTransformGraph[RunType], ) -> EmptyDetector[RunType]: @@ -107,8 +105,6 @@ def get_calibrated_detector_bifrost( Loaded analyzer parameters. transform: Transformation that determines the detector position. - offset: - Offset to add to the detector position. primary_graph: Coordinate transformation graph for the primary spectrometer. secondary_graph: @@ -122,9 +118,7 @@ def get_calibrated_detector_bifrost( Detector geometry and spectrometer coordinates. """ - da = get_base_calibrated_detector_bifrost( - detector, analyzer, transform=transform, offset=offset - ) + da = get_base_calibrated_detector_bifrost(detector, analyzer, transform=transform) da = da.rename(dim_0='tube', dim_1='length') arc, channel = arc_and_channel_from_detector_number(da.coords['detector_number']) @@ -147,7 +141,6 @@ def get_base_calibrated_detector_bifrost( analyzer: Analyzer[RunType], *, transform: NeXusTransformation[snx.NXdetector, RunType], - offset: DetectorPositionOffset[RunType], ) -> sc.DataArray: """Extract the data array corresponding to a detector's signal field. @@ -163,8 +156,6 @@ def get_base_calibrated_detector_bifrost( Loaded analyzer parameters. transform: Transformation that determines the detector position. - offset: - Offset to add to the detector position. Returns ------- @@ -173,9 +164,14 @@ def get_base_calibrated_detector_bifrost( """ from ess.reduce.nexus import compute_detector_position, extract_signal_data_array + from ess.reduce.nexus.workflow import no_offset da = extract_signal_data_array(detector) - position = compute_detector_position(da, transform=transform, offset=offset) + # BIFROST's detectors ride the rotating tank, so a lab-frame offset added after + # the transform cannot express any correction one would actually want; the + # transformation chain is the handle. compute_detector_position requires the + # argument, so pin it to zero here. + position = compute_detector_position(da, transform=transform, offset=no_offset) return _assign_detector_position(da, position) diff --git a/packages/essspectroscopy/src/ess/bifrost/single_crystal/detector.py b/packages/essspectroscopy/src/ess/bifrost/single_crystal/detector.py index 4ee7b71bc..cd45a547c 100644 --- a/packages/essspectroscopy/src/ess/bifrost/single_crystal/detector.py +++ b/packages/essspectroscopy/src/ess/bifrost/single_crystal/detector.py @@ -1,19 +1,87 @@ # 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 ..detector import _assign_detector_position, get_base_calibrated_detector_bifrost + + +def get_calibrated_bragg_peak_monitor( + monitor: NeXusComponent[ElasticMonitor, RunType], + *, + transform: NeXusTransformation[ElasticMonitor, RunType], +) -> EmptyDetector[RunType]: + """Extract the data array corresponding to the Bragg peak monitor's signal field. + + BIFROST's Bragg peak monitor is the elastic monitor, named ``elastic_monitor`` + in NeXus files (``cbm5`` in instrument documentation) and 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. + + 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) + 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`` -- the event group in a file 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( @@ -21,9 +89,12 @@ def get_calibrated_bragg_peak_detector( 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. + """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 ---------- @@ -33,17 +104,14 @@ def get_calibrated_bragg_peak_detector( 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 - ) + return get_base_calibrated_detector_bifrost(detector, analyzer, transform=transform) -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..7983a8694 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 @@ -6,7 +6,10 @@ import scippnexus as snx from ess.spectroscopy.types import ( DataGroupedByRotation, + EmptyDetector, ErrorLimitedLookupTable, + GravityVector, + Position, PulseStrideOffset, RawDetector, RunType, @@ -17,6 +20,36 @@ from ess.reduce.unwrap.types import DetectorLtotal +def detector_ltotal( + sample_data: DataGroupedByRotation[RunType], + source_position: Position[snx.NXsource, RunType], + sample_position: Position[snx.NXsample, RunType], + gravity: GravityVector, +) -> DetectorLtotal[RunType]: + """ + Compute Ltotal from the straight-line approximation. + + The Bragg peak monitor views the beam direct from the sample, without an analyzer + in between, so a straight line is the correct flight path. + + Computed after grouping so that ``Ltotal`` carries the dimensions of the grouped + data. BIFROST's tank rotates and the Bragg peak monitor is mounted on it, so the + monitor position -- and hence ``Ltotal`` -- is time-dependent; ``group_by_rotation`` + turns that 'time' dimension into 'a4'. Deriving ``Ltotal`` from the ungrouped + geometry instead would leave it labelled with a dimension the data no longer has. + + This is a wrapper around + :func:`ess.reduce.unwrap.detector_ltotal_from_straight_line_approximation` + for different input types. + """ + return reduce_unwrap.to_wavelength.detector_ltotal_from_straight_line_approximation( + detector=EmptyDetector[RunType](sample_data), + source_position=source_position, + sample_position=sample_position, + gravity=gravity, + ) + + def detector_wavelength_data( sample_data: DataGroupedByRotation[RunType], lookup: ErrorLimitedLookupTable[RunType, snx.NXdetector], @@ -40,4 +73,4 @@ def detector_wavelength_data( ) -providers = (detector_wavelength_data,) +providers = (detector_ltotal, detector_wavelength_data) diff --git a/packages/essspectroscopy/src/ess/bifrost/single_crystal/workflow.py b/packages/essspectroscopy/src/ess/bifrost/single_crystal/workflow.py index 71bca0b8b..20b435a75 100644 --- a/packages/essspectroscopy/src/ess/bifrost/single_crystal/workflow.py +++ b/packages/essspectroscopy/src/ess/bifrost/single_crystal/workflow.py @@ -10,7 +10,7 @@ SampleRun, ) -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 +30,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, @@ -43,8 +43,7 @@ def BifrostBraggPeakMonitorWorkflow() -> sciline.Pipeline: run_types=(SampleRun,), monitor_types=(ElasticMonitor, NormalizationMonitor), ) - # Use the vanilla implementation instead of the indirect geometry one: - workflow.insert(reduce_unwrap.to_wavelength.detector_wavelength_data) + workflow[NeXusName[ElasticMonitor]] = 'elastic_monitor' for provider in _PROVIDERS: workflow.insert(provider) for key, val in default_parameters().items(): @@ -57,8 +56,7 @@ def BifrostSimulationBraggPeakMonitorWorkflow() -> sciline.Pipeline: run_types=(SampleRun,), monitor_types=(ElasticMonitor, NormalizationMonitor), ) - # Use the vanilla implementation instead of the indirect geometry one: - workflow.insert(reduce_unwrap.to_wavelength.detector_wavelength_data) + workflow[NeXusName[ElasticMonitor]] = 'elastic_monitor' for provider in _SIMULATION_PROVIDERS: workflow.insert(provider) for key, val in simulation_default_parameters().items():