scipp / scipp/ess

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

Open
#750 0 comments 0 reactions 0 assignees View on GitHub

Nobody has claimed this yet.

bug essreduce
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

Open the contributing guide

First steps

  1. Read the whole issue, then the project's contributing guide.
  2. Comment on the issue to say you are picking it up — it saves two people doing the same work.
  3. Fork the repository and make your change on a branch.
  4. 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

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.