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.
Summary
monitor_wavelength_dataraisesKeyError: 'event_time_zero'for event monitors wheneverpulse_stride > 1, so no event monitor can be converted to wavelength on a pulse-skipping instrument.Cause
_prepare_wavelength_interpolation_inputscomputes the pulse index fromda.bins.coords["event_time_zero"](theif pulse_stride > 1:branch into_wavelength.py), so it expectsevent_time_zeroon each event. That holds for detectors, becausegroup_event_datacopies the pulse time onto the events when grouping by pixel. An event monitor keeps the NXevent_data layout, withevent_time_zeroonly on the pulse bins._compute_wavelength_eventsdoes handle that layout (theif "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
mainare unchanged):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
PulseStrideandPulseStrideOffsetlike the detectors, wherever they sit. On an axis of time within the frame, an upstream monitor then showspulse_strideidentical-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_sequencepropagatespulse_stridepulses 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 andevent_time_zerocopied onto the events by hand to get past the crash:pulse_stride_offset0 vs 1Upstream 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_zerofrom the pulse bins onto the events before the pulse index is computed, as the later block in_compute_wavelength_eventsalready 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.