Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 7 additions & 11 deletions packages/essspectroscopy/src/ess/bifrost/detector.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,6 @@
from ess.spectroscopy.indirect.conversion import add_spectrometer_coords
from ess.spectroscopy.types import (
Analyzer,
DetectorPositionOffset,
EmptyDetector,
NeXusComponent,
NeXusTransformation,
Expand Down Expand Up @@ -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]:
Expand All @@ -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:
Expand All @@ -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'])
Expand All @@ -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.

Expand All @@ -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
-------
Expand All @@ -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)


Expand Down
89 changes: 78 additions & 11 deletions packages/essspectroscopy/src/ess/bifrost/single_crystal/detector.py
Original file line number Diff line number Diff line change
@@ -1,29 +1,99 @@
# 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 (``cbm5``), written as an

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

'cmb5 doesn't really have a meaning in ESSspectroscopy. Can you mention that it is called 'elastic_monitor' in files?

``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`` -- 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(
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.
"""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
----------
Expand All @@ -33,17 +103,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,)
Original file line number Diff line number Diff line change
Expand Up @@ -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')

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is ltotal actually grouped by a4 here?

Can we instead compute ltotal from the detector after grouping? That way, we would always get the correct dimension.

return reduce_unwrap.to_wavelength.detector_wavelength_data(
detector_data=RawDetector[RunType](sample_data),
lookup=lookup,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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,
Expand All @@ -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

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What does 'direct beam' mean here? It is not the transmitted beam.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Meant direct from sample, not going through the analyzer.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can you adjust the comment so that this doesn't get confused with the definition in SANS?

# 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():
Expand All @@ -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():
Expand Down
Loading