essreduce: event monitors cannot be converted to wavelength when pulse skipping
Nobody has claimed this yet.
- Dominant language
- Python
- Stars
- 1
- Forks
- 3
- Avg merge
- 2d 11h
- Merged PRs (30d)
- 17
Description
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.
Contributor guide
First steps
- Read the whole issue, then the project's contributing guide.
- Comment on the issue to say you are picking it up — it saves two people doing the same work.
- Fork the repository and make your change on a branch.
- Open a pull request that references the issue number.
Research direction
Start in to_wavelength.py at _prepare_wavelength_interpolation_inputs and compare its event layout assumptions with _compute_wavelength_events, especially the event_time_zero handling. Use the reproducer in the issue with pulse_stride values 1 and 2. Done means monitor_wavelength_data converts the NXevent_data monitor without a KeyError and preserves the expected pulse-stride behavior.
Written by the indexing model from the issue text.
Assessment
- Tech stack
- python
- Domain
- data
- Issue type
- Bug
- Difficulty
- 3/5
- Estimated time
- 1-2 days
- Activity status
- Active
- Clarity
- Clearly specified
- Newbie friendliness
- 82/100