Skip to content

essreduce: event monitors cannot be converted to wavelength when pulse skipping #750

Description

@SimonHeybrock

Summary

monitor_wavelength_data raises KeyError: 'event_time_zero' for event monitors whenever pulse_stride > 1, so no event monitor can be converted to wavelength on a pulse-skipping instrument.

Cause

_prepare_wavelength_interpolation_inputs computes the pulse index from da.bins.coords["event_time_zero"] (the if pulse_stride > 1: branch in to_wavelength.py), so it expects event_time_zero on each event. That holds for detectors, because group_event_data copies the pulse time onto the events when grouping by pixel. An event monitor keeps the NXevent_data layout, with event_time_zero only on the pulse bins. _compute_wavelength_events does handle that layout (the if "event_time_zero" in out.dims: block), but only after the pulse index has already been computed.

Reproducer (essreduce 26.6.3, scipp 26.7.0; the relevant lines on main are unchanged):

import scipp as sc
from ess.reduce.unwrap import LookupTable
from ess.reduce.unwrap.to_wavelength import monitor_wavelength_data

pulse_period = sc.scalar(1e9 / 14, unit='ns')

# Event monitor as NXevent_data is loaded: event_time_zero lives on the pulse bins only.
events = sc.DataArray(
    sc.ones(sizes={'event': 4}, unit='counts'),
    coords={'event_time_offset': sc.linspace('event', 1e6, 5e7, 4, unit='ns')},
)
sizes = sc.array(dims=['event_time_zero'], values=[2, 2], unit=None, dtype='int64')
monitor = sc.DataArray(
    sc.bins(begin=sc.cumsum(sizes, mode='exclusive'), dim='event', data=events)
)
monitor.coords['event_time_zero'] = sc.datetime(
    '2026-09-10T08:00:00', unit='ns'
) + sc.array(dims=['event_time_zero'], values=[0, 71_428_571], unit='ns')

for stride in (1, 2):
    eto = sc.linspace('event_time_offset', 0.0, stride * 1e9 / 14, 5, unit='ns')
    table = LookupTable(
        array=sc.DataArray(
            sc.ones(sizes={'distance': 3, 'event_time_offset': 5}, unit='angstrom'),
            coords={
                'distance': sc.linspace('distance', 5.0, 7.0, 3, unit='m'),
                'event_time_offset': eto,
            },
        ),
        pulse_period=pulse_period,
        pulse_stride=stride,
        distance_resolution=sc.scalar(1.0, unit='m'),
        time_resolution=eto[1] - eto[0],
    )
    try:
        monitor_wavelength_data(
            monitor, table, sc.scalar(6.0, unit='m'), pulse_stride_offset=0
        )
        print(f'pulse_stride={stride}: ok')
    except KeyError as err:
        print(f'pulse_stride={stride}: KeyError: {err}')
pulse_stride=1: ok
pulse_stride=2: KeyError: "Expected 'event_time_zero' in <scipp.Dict.keys {event_time_offset}>."

Monitors upstream of the pulse-skipping chopper

Unlike detectors, a monitor can sit upstream of the chopper that skips pulses, and then it sees neutrons from every source pulse. On LoKI, for example, beam monitor m0 (6.8 m from the source) lies between the bandwidth choppers (6.5 m) and the frame-overlap choppers (15.03 m).

We propose that all monitors take part in the pulse skipping: they use the run's PulseStride and PulseStrideOffset like the detectors, wherever they sit. On an axis of time within the frame, an upstream monitor then shows pulse_stride identical-looking frames. After conversion it shows one wavelength spectrum summed over all pulses in the frame. This keeps a single stride per run and needs no knowledge of which chopper skips.

The lookup table already supports this, because compute_frame_sequence propagates pulse_stride pulses through the whole cascade. Checked with a synthetic cascade (a 14 Hz chopper at 6 m, a 7 Hz pulse-skipping chopper at 12 m, pulse_stride=2), with events arriving in every pulse and event_time_zero copied onto the events by hand to get past the crash:

Monitor position Table rows, first vs second pulse Converted spectrum, pulse_stride_offset 0 vs 1
8 m, upstream identical (max difference 2e-14) identical
18 m, downstream only the second pulse has finite entries different

Upstream rows repeat exactly with the pulse period, so every neutron gets its wavelength and the stride offset makes no difference there. Downstream, the offset selects the transmitted pulse as usual.

Suggested fix

Copy event_time_zero from the pulse bins onto the events before the pulse index is computed, as the later block in _compute_wavelength_events already does. No per-component stride handling is needed.

The crash was found in ESSlivedata (scipp/esslivedata#1314), where a wavelength-mode monitor view received a table built with pulse_stride=2.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't workingessreduceIssues for essreduce.

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions