From 0829440bfd178e5120afd3bd962052030f6fc9eb Mon Sep 17 00:00:00 2001 From: Neil Vaytet Date: Tue, 15 Sep 2026 13:15:25 +0200 Subject: [PATCH 01/11] rotate the choppers for long enough to cover the entire range of possible arrival times --- .../essreduce/src/ess/reduce/unwrap/lut.py | 58 ++++++++++++++----- 1 file changed, 42 insertions(+), 16 deletions(-) diff --git a/packages/essreduce/src/ess/reduce/unwrap/lut.py b/packages/essreduce/src/ess/reduce/unwrap/lut.py index 13d4fdd6e..bcd6aacb7 100644 --- a/packages/essreduce/src/ess/reduce/unwrap/lut.py +++ b/packages/essreduce/src/ess/reduce/unwrap/lut.py @@ -26,6 +26,26 @@ WavelengthLutMode, ) +# We define a maximum instrument length which is used to determine how many chopper +# rotations should be performed when computing the chopper frame sequence. +# We need to rotate the choppers for long enough to make sure we capture cases where +# very slow neutrons pass through chopper openings multiple pulse periods later. +# The most robust way is to define the longest possible distance that could be traveled +# and compute how long it would take the slowest neutrons to reach it. +MAXIMUM_INSTRUMENT_LENGTH = sc.scalar(500.0, unit='m') + + +def _wavelength_to_speed(wavelength: sc.Variable) -> sc.Variable: + """ + Convert wavelength to speed. + + Parameters + ---------- + wavelength: + Wavelength of the neutrons. + """ + return (sc.constants.h / sc.constants.m_n) / wavelength + @dataclass class BeamlineComponentReading: @@ -58,7 +78,7 @@ class BeamlineComponentReading: distance: sc.Variable def __post_init__(self): - self.speed = (sc.constants.h / sc.constants.m_n) / self.wavelength + self.speed = _wavelength_to_speed(self.wavelength).to(unit='m/s') @dataclass @@ -623,6 +643,11 @@ def _estimate_wavelength_by_polygon_centers( # This is because neutrons that arrive after the frame period will wrap around and # appear in the next pulse, which is equivalent to the original pulse but shifted # by the frame period. + # We determine the number of frame periods to shift by calculating how many periods + # are needed to cover the maximum arrival time in the subframes. + max_time = sc.reduce([f.time.max() for f in subframes]).max() + nperiods = int(max_time.to(unit=time_unit).value / frame_period.value) + 1 + polygons = [ np.stack( [ @@ -632,7 +657,7 @@ def _estimate_wavelength_by_polygon_centers( axis=1, ) for f in subframes - for i in (0, 1) + for i in range(nperiods) ] wavs, stddevs = _polygon_intersections(polygons, time_edges.values) @@ -670,18 +695,14 @@ def compute_frame_sequence( # The `pulse_frequency` parameter in time_offset_open and time_offset_close below # decides how many rotations the chopper will perform when computing the open and - # close times. Because we want to cover a number of pulses equal to `pulse_stride`, - # we need to set the pulse frequency to be `pulse_stride` times smaller than the - # actual pulse frequency. - # - # In addition, the time_offset_open and time_offset_close below require the - # pulse_frequency to be an integer multiple of the pulse frequency or vice versa. - # A simple trick is to make sure that the requested pulse frequency is divided by - # an even number. We need to rotate the chopper for long enough to cover wrapping - # around the frame period, so we cover two pulses strides. - frequency_for_chopper_rotation = (1.0 / pulse_period.to(unit='s')) / ( - pulse_stride * 2 - ) + # close times. + # We need to cover the entire time range from 0 to the time it takes the slowest + # neutron to travel the maximum instrument length. + travel_time = source_bounds.time[1].to(unit='s') + ( + MAXIMUM_INSTRUMENT_LENGTH / _wavelength_to_speed(source_bounds.wavelength[1]) + ).to(unit='s') + nperiods = sc.ceil(travel_time / pulse_period) + frequency_for_chopper_rotation = 1.0 / (nperiods * pulse_period) chops = { key: chopper_cascade.Chopper( @@ -744,8 +765,13 @@ def make_wavelength_lut_from_polygons( pulse_period = pulse_period.to(unit=time_unit) frame_period = pulse_period * pulse_stride - min_dist = ltotal_range[0].to(unit=distance_unit) - max_dist = ltotal_range[1].to(unit=distance_unit) + dist0 = ltotal_range[0].to(unit=distance_unit) + dist1 = ltotal_range[1].to(unit=distance_unit) + # By default, the minimum and maximum distances should be the first and second + # elements of the total range. But if the user set them manually on the workflow + # we need to make sure we pick the minimum and maximum distances. + min_dist = min(dist0, dist1) + max_dist = max(dist0, dist1) # We want to give the 2d interpolator a table that covers the requested range, # hence we need to extend the range by at least half a resolution in each direction. From 858e54591c86dd15bbbdec851b1a17457e845bec Mon Sep 17 00:00:00 2001 From: Neil Vaytet Date: Tue, 15 Sep 2026 13:47:30 +0200 Subject: [PATCH 02/11] add test --- .../essreduce/src/ess/reduce/unwrap/lut.py | 2 +- packages/essreduce/tests/unwrap/lut_test.py | 55 +++++++++++++++++++ 2 files changed, 56 insertions(+), 1 deletion(-) diff --git a/packages/essreduce/src/ess/reduce/unwrap/lut.py b/packages/essreduce/src/ess/reduce/unwrap/lut.py index bcd6aacb7..1cfb1bff2 100644 --- a/packages/essreduce/src/ess/reduce/unwrap/lut.py +++ b/packages/essreduce/src/ess/reduce/unwrap/lut.py @@ -735,7 +735,7 @@ def make_wavelength_lut_from_polygons( time_resolution: TimeResolution, pulse_period: PulsePeriod, pulse_stride: PulseStride[RunType], - frames: ChopperFrameSequence, + frames: ChopperFrameSequence[RunType], ) -> LookupTable[RunType, Component]: """ Compute a lookup table for wavelength as a function of distance and diff --git a/packages/essreduce/tests/unwrap/lut_test.py b/packages/essreduce/tests/unwrap/lut_test.py index ede8b0d28..3789a6de5 100644 --- a/packages/essreduce/tests/unwrap/lut_test.py +++ b/packages/essreduce/tests/unwrap/lut_test.py @@ -487,3 +487,58 @@ def test_polygon_intersections_handles_uncovered_columns_without_warning(): # Columns 0 and 2 miss the polygon (all-NaN); column 1 is covered. np.testing.assert_array_equal(np.isnan(center), [True, False, True]) np.testing.assert_array_equal(np.isnan(spread), [True, False, True]) + + +def test_choppers_rotate_enough_times_to_catch_slow_neutrons(): + choppers = { + "chopper1": DiskChopper( + axle_position=sc.vector([0, 0, 28.4], unit='m'), + frequency=sc.scalar(-14, unit='Hz'), + beam_position=sc.scalar(0, unit='deg'), + phase=sc.scalar(-112.3, unit='deg'), + slit_begin=sc.array(dims=["cutout"], values=[-38.5], unit='deg'), + slit_end=sc.array(dims=["cutout"], values=[38.5], unit='deg'), + slit_height=None, + radius=None, + ), + "chopper2a": DiskChopper( + axle_position=sc.vector([0, 0, 50.9774], unit='m'), + frequency=sc.scalar(-14, unit='Hz'), + beam_position=sc.scalar(0, unit='deg'), + phase=sc.scalar(194.1, unit='deg'), + slit_begin=sc.array(dims=["cutout"], values=[-70.0], unit='deg'), + slit_end=sc.array(dims=["cutout"], values=[70.0], unit='deg'), + slit_height=None, + radius=None, + ), + "chopper2b": DiskChopper( + axle_position=sc.vector([0, 0, 51.0024], unit='m'), + frequency=sc.scalar(-14, unit='Hz'), + beam_position=sc.scalar(0, unit='deg'), + phase=sc.scalar(168.0, unit='deg'), + slit_begin=sc.array(dims=["cutout"], values=[-70.0], unit='deg'), + slit_end=sc.array(dims=["cutout"], values=[70.0], unit='deg'), + slit_height=None, + radius=None, + ), + } + wf = _make_workflow("analytical") + wf[unwrap.DiskChoppers[AnyRun]] = choppers + wf[Position[snx.NXsource, AnyRun]] = sc.vector([0, 0, 0], unit='m') + + frames = wf.compute(unwrap.ChopperFrameSequence[AnyRun]) + + # In this configuration (based on the NMX instrument), the last frame should have + # two subframes: a main subframe containing short wavelengths 1-5 Å and a secondary + # subframe containing longer wavelengths 12-15 Å. + last_frame = frames[-1] + assert len(last_frame.subframes) == 2 + main_subframe = last_frame.subframes[0] + secondary_subframe = last_frame.subframes[1] + + # Check the wavelength ranges for the subframes + assert main_subframe.wavelength.min() > sc.scalar(1, unit='angstrom') + assert main_subframe.wavelength.max() < sc.scalar(5, unit='angstrom') + + assert secondary_subframe.wavelength.min() > sc.scalar(12, unit='angstrom') + assert secondary_subframe.wavelength.max() < sc.scalar(15, unit='angstrom') From 731b8d4ac52558b70db81b1eee158647452e3f81 Mon Sep 17 00:00:00 2001 From: Neil Vaytet Date: Tue, 15 Sep 2026 13:56:59 +0200 Subject: [PATCH 03/11] fix test --- packages/essreduce/tests/unwrap/lut_test.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/packages/essreduce/tests/unwrap/lut_test.py b/packages/essreduce/tests/unwrap/lut_test.py index 3789a6de5..b9f3ffaee 100644 --- a/packages/essreduce/tests/unwrap/lut_test.py +++ b/packages/essreduce/tests/unwrap/lut_test.py @@ -395,10 +395,11 @@ def test_lut_workflow_guesses_pulse_stride(): def test_lut_does_not_raise_if_no_neutrons_make_it_through(wavelength_from): wf = _make_workflow(wavelength_from) # Add a very slowly rotating chopper that will block all neutrons. + freq = sc.scalar(0.1, unit='Hz') wf[unwrap.DiskChoppers[AnyRun]] = { 'chopper1': DiskChopper( axle_position=sc.vector([0, 0, -15.0], unit='m'), - frequency=sc.scalar(0.1, unit='Hz'), + frequency=freq, beam_position=sc.scalar(0.0, unit='deg'), phase=sc.scalar(0.0, unit='rad'), slit_begin=sc.array(dims=['cutout'], values=[0.0], unit='deg'), @@ -407,6 +408,8 @@ def test_lut_does_not_raise_if_no_neutrons_make_it_through(wavelength_from): radius=sc.scalar(0.35, unit='m'), ) } + # Need to synchronize the source period with the chopper frequency. + wf[unwrap.PulsePeriod] = 1.0 / freq wf[Position[snx.NXsource, AnyRun]] = sc.vector([0, 0, -25.0], unit='m') # Need to force the pulse stride so that it doesn't get set to a large value due to # the slow chopper. From 428e6cf5906767d509b9194dbe0a3c841600f897 Mon Sep 17 00:00:00 2001 From: Neil Vaytet Date: Tue, 22 Sep 2026 22:28:26 +0200 Subject: [PATCH 04/11] fix chopper rotations for pulse skipping and remove max instrument distance --- .../essreduce/src/ess/reduce/unwrap/lut.py | 64 +++++++------ packages/essreduce/tests/unwrap/lut_test.py | 96 ++++++++++++++++++- 2 files changed, 124 insertions(+), 36 deletions(-) diff --git a/packages/essreduce/src/ess/reduce/unwrap/lut.py b/packages/essreduce/src/ess/reduce/unwrap/lut.py index 1cfb1bff2..4201a5f64 100644 --- a/packages/essreduce/src/ess/reduce/unwrap/lut.py +++ b/packages/essreduce/src/ess/reduce/unwrap/lut.py @@ -26,14 +26,6 @@ WavelengthLutMode, ) -# We define a maximum instrument length which is used to determine how many chopper -# rotations should be performed when computing the chopper frame sequence. -# We need to rotate the choppers for long enough to make sure we capture cases where -# very slow neutrons pass through chopper openings multiple pulse periods later. -# The most robust way is to define the longest possible distance that could be traveled -# and compute how long it would take the slowest neutrons to reach it. -MAXIMUM_INSTRUMENT_LENGTH = sc.scalar(500.0, unit='m') - def _wavelength_to_speed(wavelength: sc.Variable) -> sc.Variable: """ @@ -646,7 +638,12 @@ def _estimate_wavelength_by_polygon_centers( # We determine the number of frame periods to shift by calculating how many periods # are needed to cover the maximum arrival time in the subframes. max_time = sc.reduce([f.time.max() for f in subframes]).max() - nperiods = int(max_time.to(unit=time_unit).value / frame_period.value) + 1 + # Why `- noffset` below: + # nperiods is computed from the absolute max_time, but the copies are shifted by + # noffset + i. So the first noffset extra copies end up at negative times and only + # contribute NaNs. This is correct, but for long flight paths it adds work in the + # per-distance loop, so int(max_time / frame_period) - noffset + 1 is sufficient. + nperiods = int(max_time.to(unit=time_unit).value / frame_period.value) - noffset + 1 polygons = [ np.stack( @@ -693,29 +690,34 @@ def compute_frame_sequence( pulse-skipping. """ - # The `pulse_frequency` parameter in time_offset_open and time_offset_close below - # decides how many rotations the chopper will perform when computing the open and - # close times. - # We need to cover the entire time range from 0 to the time it takes the slowest - # neutron to travel the maximum instrument length. - travel_time = source_bounds.time[1].to(unit='s') + ( - MAXIMUM_INSTRUMENT_LENGTH / _wavelength_to_speed(source_bounds.wavelength[1]) - ).to(unit='s') - nperiods = sc.ceil(travel_time / pulse_period) - frequency_for_chopper_rotation = 1.0 / (nperiods * pulse_period) - - chops = { - key: chopper_cascade.Chopper( - distance=chopper_distance_along_beam(ch.axle_position, source_position), - time_open=ch.time_offset_open( - pulse_frequency=frequency_for_chopper_rotation - ), - time_close=ch.time_offset_close( - pulse_frequency=frequency_for_chopper_rotation - ), + chops = {} + for key, ch in disk_choppers.items(): + chopper_distance = chopper_distance_along_beam( + ch.axle_position, source_position + ) + # The `pulse_frequency` parameter in time_offset_open and time_offset_close + # below decides how many rotations the chopper will perform when computing the + # open and close times. + # We need to cover the entire time range from 0 to the time it takes the + # slowest neutron to travel the distance to the chopper. + slowest_to_chopper = chopper_distance / _wavelength_to_speed( + source_bounds.wavelength[1] + ) + travel_time = ( + source_bounds.time[1].to(unit='s') + + (pulse_stride - 1) * pulse_period.to(unit='s') + + slowest_to_chopper.to(unit='s') + ) + + freq = abs(ch.frequency).to(unit='Hz') + nrot = int(np.ceil((travel_time * freq).value)) + 1 + pulse_frequency = freq / nrot + + chops[key] = chopper_cascade.Chopper( + distance=chopper_distance, + time_open=ch.time_offset_open(pulse_frequency=pulse_frequency), + time_close=ch.time_offset_close(pulse_frequency=pulse_frequency), ) - for key, ch in disk_choppers.items() - } frames = chopper_cascade.FrameSequence.from_source_pulse( time_min=source_bounds.time[0], diff --git a/packages/essreduce/tests/unwrap/lut_test.py b/packages/essreduce/tests/unwrap/lut_test.py index b9f3ffaee..48a638adc 100644 --- a/packages/essreduce/tests/unwrap/lut_test.py +++ b/packages/essreduce/tests/unwrap/lut_test.py @@ -10,7 +10,12 @@ from ess.reduce import unwrap from ess.reduce.nexus.types import AnyRun, Position -from ess.reduce.unwrap import GenericUnwrapWorkflow, LookupTableWorkflow, SourceBounds +from ess.reduce.unwrap import ( + GenericUnwrapWorkflow, + LookupTableWorkflow, + LtotalRange, + SourceBounds, +) from ess.reduce.unwrap.lut import _polygon_intersections, chopper_distance_along_beam sl = pytest.importorskip("sciline") @@ -395,11 +400,10 @@ def test_lut_workflow_guesses_pulse_stride(): def test_lut_does_not_raise_if_no_neutrons_make_it_through(wavelength_from): wf = _make_workflow(wavelength_from) # Add a very slowly rotating chopper that will block all neutrons. - freq = sc.scalar(0.1, unit='Hz') wf[unwrap.DiskChoppers[AnyRun]] = { 'chopper1': DiskChopper( axle_position=sc.vector([0, 0, -15.0], unit='m'), - frequency=freq, + frequency=sc.scalar(0.1, unit='Hz'), beam_position=sc.scalar(0.0, unit='deg'), phase=sc.scalar(0.0, unit='rad'), slit_begin=sc.array(dims=['cutout'], values=[0.0], unit='deg'), @@ -408,8 +412,6 @@ def test_lut_does_not_raise_if_no_neutrons_make_it_through(wavelength_from): radius=sc.scalar(0.35, unit='m'), ) } - # Need to synchronize the source period with the chopper frequency. - wf[unwrap.PulsePeriod] = 1.0 / freq wf[Position[snx.NXsource, AnyRun]] = sc.vector([0, 0, -25.0], unit='m') # Need to force the pulse stride so that it doesn't get set to a large value due to # the slow chopper. @@ -545,3 +547,87 @@ def test_choppers_rotate_enough_times_to_catch_slow_neutrons(): assert secondary_subframe.wavelength.min() > sc.scalar(12, unit='angstrom') assert secondary_subframe.wavelength.max() < sc.scalar(15, unit='angstrom') + + +def test_choppers_rotate_enough_times_with_slow_neutrons_to_pollute_lut(): + choppers = { + "chopper1": DiskChopper( + axle_position=sc.vector([0, 0, 28.4], unit='m'), + frequency=sc.scalar(-14, unit='Hz'), + beam_position=sc.scalar(0, unit='deg'), + phase=sc.scalar(-112.3, unit='deg'), + slit_begin=sc.array(dims=["cutout"], values=[-38.5], unit='deg'), + slit_end=sc.array(dims=["cutout"], values=[38.5], unit='deg'), + slit_height=None, + radius=None, + ), + "chopper2a": DiskChopper( + axle_position=sc.vector([0, 0, 50.9774], unit='m'), + frequency=sc.scalar(-14, unit='Hz'), + beam_position=sc.scalar(0, unit='deg'), + phase=sc.scalar(194.1, unit='deg'), + slit_begin=sc.array(dims=["cutout"], values=[-70.0], unit='deg'), + slit_end=sc.array(dims=["cutout"], values=[70.0], unit='deg'), + slit_height=None, + radius=None, + ), + "chopper2b": DiskChopper( + axle_position=sc.vector([0, 0, 51.0024], unit='m'), + frequency=sc.scalar(-14, unit='Hz'), + beam_position=sc.scalar(0, unit='deg'), + phase=sc.scalar(168.0, unit='deg'), + slit_begin=sc.array(dims=["cutout"], values=[-70.0], unit='deg'), + slit_end=sc.array(dims=["cutout"], values=[70.0], unit='deg'), + slit_height=None, + radius=None, + ), + } + wf = _make_workflow("analytical") + wf[unwrap.DiskChoppers[AnyRun]] = choppers + wf[Position[snx.NXsource, AnyRun]] = sc.vector([0, 0, 0], unit='m') + dist = sc.scalar(157.5, unit='m') + wf[LtotalRange[AnyRun, snx.NXdetector]] = dist, dist + + # In the center of the event time offset range, the table should be smooth, free + # from overlap artifacts. + eto_range = sc.scalar(10000, unit='us'), sc.scalar(60000, unit='us') + + # Using a normal wavelength range for the source pulse, we expect the choppers to + # rotate enough times to create the main and secondary subframes in the last frame. + # The secondary frame should travel all the way to the detector and pollute the + # lookup table results, meaning it should have large variances. + wf[SourceBounds] = SourceBounds( + time=(sc.scalar(0.0, unit='ms'), sc.scalar(5.0, unit='ms')), + wavelength=( + sc.scalar(0.001, unit='angstrom'), + sc.scalar(15.0, unit='angstrom'), + ), + ) + table = wf.compute(unwrap.LookupTable[AnyRun, snx.NXdetector]) + at_detector = table.array['distance', -1] + + assert ( + sc.variances( + at_detector['event_time_offset', slice(eto_range[0], eto_range[1])] + ).max() + > sc.scalar(10.0, unit='angstrom^2') + ).value + + # Using a narrower wavelength range for the source pulse, the secondary frame + # should be suppressed, and the variances at the detector should be lower. + wf[SourceBounds] = SourceBounds( + time=(sc.scalar(0.0, unit='ms'), sc.scalar(5.0, unit='ms')), + wavelength=( + sc.scalar(0.001, unit='angstrom'), + sc.scalar(10.0, unit='angstrom'), + ), + ) + table = wf.compute(unwrap.LookupTable[AnyRun, snx.NXdetector]) + at_detector = table.array['distance', -1] + + assert ( + sc.variances( + at_detector['event_time_offset', slice(eto_range[0], eto_range[1])] + ).max() + < sc.scalar(0.1, unit='angstrom^2') + ).value From fe4ebb7284bc6318ea612b83bd15e4023eac2959 Mon Sep 17 00:00:00 2001 From: Neil Vaytet Date: Tue, 22 Sep 2026 22:35:51 +0200 Subject: [PATCH 05/11] raise if ltotal range is reversed --- .../essreduce/src/ess/reduce/unwrap/lut.py | 24 ++++++++++++------- 1 file changed, 15 insertions(+), 9 deletions(-) diff --git a/packages/essreduce/src/ess/reduce/unwrap/lut.py b/packages/essreduce/src/ess/reduce/unwrap/lut.py index 4201a5f64..6d7e53590 100644 --- a/packages/essreduce/src/ess/reduce/unwrap/lut.py +++ b/packages/essreduce/src/ess/reduce/unwrap/lut.py @@ -253,6 +253,19 @@ def _compute_mean_wavelength( return mean_wavelength +def _unpack_ltotal_range( + ltotal_range: tuple[sc.Variable, sc.Variable], distance_unit: str +) -> tuple[sc.Variable, sc.Variable]: + min_dist = ltotal_range[0].to(unit=distance_unit) + max_dist = ltotal_range[1].to(unit=distance_unit) + if (min_dist > max_dist).value: + raise ValueError( + "Building the lookup table failed: the minimum distance in the total range " + f"({min_dist:c}) is greater than the maximum distance ({max_dist:c})." + ) + return min_dist, max_dist + + def make_wavelength_lut_from_simulation( simulation: SimulationResults[RunType], ltotal_range: LtotalRange[RunType, Component], @@ -326,8 +339,7 @@ def make_wavelength_lut_from_simulation( pulse_period = pulse_period.to(unit=time_unit) frame_period = pulse_period * pulse_stride - min_dist = ltotal_range[0].to(unit=distance_unit) - max_dist = ltotal_range[1].to(unit=distance_unit) + min_dist, max_dist = _unpack_ltotal_range(ltotal_range, distance_unit) # We need to bin the data below, to compute the weighted mean of the wavelength. # This results in data with bin edges. @@ -767,13 +779,7 @@ def make_wavelength_lut_from_polygons( pulse_period = pulse_period.to(unit=time_unit) frame_period = pulse_period * pulse_stride - dist0 = ltotal_range[0].to(unit=distance_unit) - dist1 = ltotal_range[1].to(unit=distance_unit) - # By default, the minimum and maximum distances should be the first and second - # elements of the total range. But if the user set them manually on the workflow - # we need to make sure we pick the minimum and maximum distances. - min_dist = min(dist0, dist1) - max_dist = max(dist0, dist1) + min_dist, max_dist = _unpack_ltotal_range(ltotal_range, distance_unit) # We want to give the 2d interpolator a table that covers the requested range, # hence we need to extend the range by at least half a resolution in each direction. From be5dc03fb4733c1e2977884729274cc4283bf0f1 Mon Sep 17 00:00:00 2001 From: Neil Vaytet Date: Tue, 22 Sep 2026 22:57:44 +0200 Subject: [PATCH 06/11] handle 0Hz choppers the same everywhere --- .../essreduce/src/ess/reduce/unwrap/lut.py | 9 ++++++++ packages/essreduce/tests/unwrap/lut_test.py | 22 +++++++++++++++++++ 2 files changed, 31 insertions(+) diff --git a/packages/essreduce/src/ess/reduce/unwrap/lut.py b/packages/essreduce/src/ess/reduce/unwrap/lut.py index 6d7e53590..7277df106 100644 --- a/packages/essreduce/src/ess/reduce/unwrap/lut.py +++ b/packages/essreduce/src/ess/reduce/unwrap/lut.py @@ -523,6 +523,11 @@ def simulate_chopper_cascade_using_tof( tof_choppers = [] for name, ch in choppers.items(): chop = tof.Chopper.from_diskchopper(ch, name=name) + # `tof` currently treats choppers with zero frequency as always open, which is + # what we want. However, to guard agains possible future changes in `tof`'s + # behavior, we explicitly omit choppers with zero frequency. + if ch.frequency.value == 0: + continue chop.distance = chopper_distance_along_beam(ch.axle_position, source_position) tof_choppers.append(chop) @@ -704,6 +709,10 @@ def compute_frame_sequence( chops = {} for key, ch in disk_choppers.items(): + # Skip choppers with zero frequency as they are treated as parked (not in use). + if ch.frequency.value == 0: + continue + chopper_distance = chopper_distance_along_beam( ch.axle_position, source_position ) diff --git a/packages/essreduce/tests/unwrap/lut_test.py b/packages/essreduce/tests/unwrap/lut_test.py index 48a638adc..80b0dce07 100644 --- a/packages/essreduce/tests/unwrap/lut_test.py +++ b/packages/essreduce/tests/unwrap/lut_test.py @@ -631,3 +631,25 @@ def test_choppers_rotate_enough_times_with_slow_neutrons_to_pollute_lut(): ).max() < sc.scalar(0.1, unit='angstrom^2') ).value + + +@pytest.mark.parametrize("wavelength_from", ["analytical", "simulation"]) +def test_lut_workflow_drops_choppers_with_zero_frequency(wavelength_from): + wf = _make_workflow(wavelength_from) + wf[unwrap.DiskChoppers[AnyRun]] = _make_choppers() + wf[Position[snx.NXsource, AnyRun]] = sc.vector([0, 0, 0], unit='m') + if wavelength_from == "simulation": + wf[unwrap.NumberOfSimulatedNeutrons] = 100_000 + wf[unwrap.SimulationSeed] = 77 + + wf[unwrap.LtotalRange[AnyRun, snx.NXdetector]] = ( + sc.scalar(35.0, unit='m'), + sc.scalar(65.0, unit='m'), + ) + wf[unwrap.DistanceResolution] = sc.scalar(0.1, unit='m') + wf[unwrap.TimeResolution] = sc.scalar(250.0, unit='us') + + table = wf.compute(unwrap.LookupTable[AnyRun, snx.NXdetector]) + at_detector = table.array['distance', -1] + + assert not np.isnan(at_detector.values).all() From bd16739f3365a34481b0a311d6d24b612ba268cd Mon Sep 17 00:00:00 2001 From: Neil Vaytet Date: Wed, 23 Sep 2026 00:20:23 +0200 Subject: [PATCH 07/11] close choppers out of phase with the source --- .../essreduce/src/ess/reduce/unwrap/lut.py | 68 +++++++++++++------ 1 file changed, 47 insertions(+), 21 deletions(-) diff --git a/packages/essreduce/src/ess/reduce/unwrap/lut.py b/packages/essreduce/src/ess/reduce/unwrap/lut.py index 7277df106..afd61325d 100644 --- a/packages/essreduce/src/ess/reduce/unwrap/lut.py +++ b/packages/essreduce/src/ess/reduce/unwrap/lut.py @@ -524,7 +524,7 @@ def simulate_chopper_cascade_using_tof( for name, ch in choppers.items(): chop = tof.Chopper.from_diskchopper(ch, name=name) # `tof` currently treats choppers with zero frequency as always open, which is - # what we want. However, to guard agains possible future changes in `tof`'s + # what we want. However, to guard against possible future changes in `tof`'s # behavior, we explicitly omit choppers with zero frequency. if ch.frequency.value == 0: continue @@ -681,6 +681,13 @@ def _estimate_wavelength_by_polygon_centers( ) +def _is_int_or_inverse_int(x: sc.Variable, *, rtol: sc.Variable) -> bool: + a = sc.all(abs(sc.round(x) - x) < rtol) + y = sc.reciprocal(x) + b = sc.all(abs(sc.round(y) - y) < rtol) + return bool(a | b) + + def compute_frame_sequence( pulse_period: PulsePeriod, disk_choppers: DiskChoppers[RunType], @@ -716,29 +723,48 @@ def compute_frame_sequence( chopper_distance = chopper_distance_along_beam( ch.axle_position, source_position ) - # The `pulse_frequency` parameter in time_offset_open and time_offset_close - # below decides how many rotations the chopper will perform when computing the - # open and close times. - # We need to cover the entire time range from 0 to the time it takes the - # slowest neutron to travel the distance to the chopper. - slowest_to_chopper = chopper_distance / _wavelength_to_speed( - source_bounds.wavelength[1] - ) - travel_time = ( - source_bounds.time[1].to(unit='s') - + (pulse_stride - 1) * pulse_period.to(unit='s') - + slowest_to_chopper.to(unit='s') - ) + # If the frequency is not synced to the source pulse frequency, we transform + # this chopper to always be closed. freq = abs(ch.frequency).to(unit='Hz') - nrot = int(np.ceil((travel_time * freq).value)) + 1 - pulse_frequency = freq / nrot + pulse_frequency = sc.reciprocal(pulse_period).to(unit=freq.unit) + quot = freq / pulse_frequency + if not _is_int_or_inverse_int(quot, rtol=sc.scalar(1e-8)): + chops[key] = chopper_cascade.Chopper( + distance=chopper_distance, + time_open=sc.array(dims=["cutout"], values=[], unit='s'), + time_close=sc.array(dims=["cutout"], values=[], unit='s'), + ) + else: + # The `pulse_frequency` parameter in time_offset_open and time_offset_close + # below decides how many rotations the chopper will perform when computing + # the open and close times. + # We need to cover the entire time range from 0 to the time it takes the + # slowest neutron to travel the distance to the chopper. + slowest_to_chopper = chopper_distance / _wavelength_to_speed( + source_bounds.wavelength[1] + ) + travel_time = ( + source_bounds.time[1].to(unit='s') + + (pulse_stride - 1) * pulse_period.to(unit='s') + + slowest_to_chopper.to(unit='s') + ) - chops[key] = chopper_cascade.Chopper( - distance=chopper_distance, - time_open=ch.time_offset_open(pulse_frequency=pulse_frequency), - time_close=ch.time_offset_close(pulse_frequency=pulse_frequency), - ) + # In addition, the time_offset_open and time_offset_close below require the + # pulse_frequency to be an integer multiple of the pulse frequency or vice + # versa. + nrot = int(np.ceil((travel_time * freq).value)) + 1 + pulse_frequency_for_diskchopper = freq / nrot + + chops[key] = chopper_cascade.Chopper( + distance=chopper_distance, + time_open=ch.time_offset_open( + pulse_frequency=pulse_frequency_for_diskchopper + ), + time_close=ch.time_offset_close( + pulse_frequency=pulse_frequency_for_diskchopper + ), + ) frames = chopper_cascade.FrameSequence.from_source_pulse( time_min=source_bounds.time[0], From ef853dfc9255a0bff5daf8f6c914464e450077f0 Mon Sep 17 00:00:00 2001 From: Neil Vaytet Date: Wed, 23 Sep 2026 00:51:52 +0200 Subject: [PATCH 08/11] add provider to centralise pre-processing of choppers --- .../essreduce/src/ess/reduce/nexus/types.py | 3 +- .../essreduce/src/ess/reduce/unwrap/lut.py | 64 +++++++++++++------ 2 files changed, 46 insertions(+), 21 deletions(-) diff --git a/packages/essreduce/src/ess/reduce/nexus/types.py b/packages/essreduce/src/ess/reduce/nexus/types.py index 6ed9ee98c..a42d7625e 100644 --- a/packages/essreduce/src/ess/reduce/nexus/types.py +++ b/packages/essreduce/src/ess/reduce/nexus/types.py @@ -377,7 +377,6 @@ class RawChoppers( class DiskChoppers( - sciline.Scope[RunType, sc.DataGroup[DiskChopper]], - sc.DataGroup[DiskChopper], + sciline.Scope[RunType, dict[str, DiskChopper]], dict[str, DiskChopper] ): """All disk choppers parsed from a NeXus file.""" diff --git a/packages/essreduce/src/ess/reduce/unwrap/lut.py b/packages/essreduce/src/ess/reduce/unwrap/lut.py index afd61325d..7b90ac775 100644 --- a/packages/essreduce/src/ess/reduce/unwrap/lut.py +++ b/packages/essreduce/src/ess/reduce/unwrap/lut.py @@ -5,7 +5,7 @@ """ from collections.abc import Callable -from dataclasses import dataclass +from dataclasses import dataclass, replace from typing import Generic, NewType import numpy as np @@ -167,6 +167,16 @@ class PulseStride(sl.Scope[RunType, int], int): """ +class ProcessedDiskChoppers( + sl.Scope[RunType, dict[str, DiskChopper]], dict[str, DiskChopper] +): + """Processed disk choppers: + If a chopper has 0 frequency, it is treated as parked/inactive, and is dropped. + If a chopper's frequency is not in sync with the source frequency, it is replaced + with a chopper which is always closed. + """ + + @dataclass class SourceBounds: """Time and wavelength bounds of the neutrons in the source pulse that encompass @@ -481,8 +491,38 @@ def chopper_distance_along_beam( return (axle_position - source_position).fields.z +def _is_int_or_inverse_int(x: sc.Variable, *, rtol: sc.Variable) -> bool: + a = sc.all(abs(sc.round(x) - x) < rtol) + y = sc.reciprocal(x) + b = sc.all(abs(sc.round(y) - y) < rtol) + return bool(a | b) + + +def process_disk_choppers( + choppers: DiskChoppers[RunType], pulse_period: PulsePeriod +) -> ProcessedDiskChoppers[RunType]: + out = {} + for key, ch in choppers.items(): + if ch.frequency.value == 0: + continue + + # If the frequency is not synced to the source pulse frequency, we transform + # this chopper to always be closed. + freq = abs(ch.frequency).to(unit='Hz') + pulse_frequency = sc.reciprocal(pulse_period).to(unit=freq.unit) + quot = freq / pulse_frequency + if not _is_int_or_inverse_int(quot, rtol=sc.scalar(1e-8)): + empty = sc.array(dims=['cutout'], values=[], unit='deg') + out[key] = replace( + ch, frequency=pulse_frequency, slit_begin=empty, slit_end=empty + ) + else: + out[key] = ch + return ProcessedDiskChoppers[RunType](out) + + def simulate_chopper_cascade_using_tof( - choppers: DiskChoppers[RunType], + choppers: ProcessedDiskChoppers[RunType], source_position: Position[snx.NXsource, RunType], neutrons: NumberOfSimulatedNeutrons, pulse_stride: PulseStride[RunType], @@ -523,11 +563,6 @@ def simulate_chopper_cascade_using_tof( tof_choppers = [] for name, ch in choppers.items(): chop = tof.Chopper.from_diskchopper(ch, name=name) - # `tof` currently treats choppers with zero frequency as always open, which is - # what we want. However, to guard against possible future changes in `tof`'s - # behavior, we explicitly omit choppers with zero frequency. - if ch.frequency.value == 0: - continue chop.distance = chopper_distance_along_beam(ch.axle_position, source_position) tof_choppers.append(chop) @@ -681,16 +716,9 @@ def _estimate_wavelength_by_polygon_centers( ) -def _is_int_or_inverse_int(x: sc.Variable, *, rtol: sc.Variable) -> bool: - a = sc.all(abs(sc.round(x) - x) < rtol) - y = sc.reciprocal(x) - b = sc.all(abs(sc.round(y) - y) < rtol) - return bool(a | b) - - def compute_frame_sequence( pulse_period: PulsePeriod, - disk_choppers: DiskChoppers[RunType], + disk_choppers: ProcessedDiskChoppers[RunType], source_position: Position[snx.NXsource, RunType], source_bounds: SourceBounds, pulse_stride: PulseStride[RunType], @@ -908,19 +936,16 @@ def ltotal_range_from_ltotal_monitor( def guess_pulse_stride_from_choppers( - choppers: DiskChoppers[RunType], pulse_period: PulsePeriod + choppers: ProcessedDiskChoppers[RunType], pulse_period: PulsePeriod ) -> PulseStride[RunType]: """ If the pulse stride is not provided, we try to guess it from the chopper parameters. If there is a chopper rotating slower than the pulse_period, we use its rotation frequency to estimate the pulse stride. - We omit choppers with a zero rotation frequency, as they are considered inactive. """ stride = 1 for chopper in choppers.values(): f = sc.abs(chopper.frequency) - if f.value == 0: - continue stride = max(stride, round((1 / pulse_period / f).to(unit="").value)) return PulseStride[RunType](stride) @@ -965,6 +990,7 @@ def providers( return (load_lookup_table_from_file,) common = ( + process_disk_choppers, ltotal_range_from_ltotal_detector, ltotal_range_from_ltotal_monitor, guess_pulse_stride_from_choppers, From d3dc2e540bd9b7c62d83a50a3a8175df6b8bbd59 Mon Sep 17 00:00:00 2001 From: Neil Vaytet Date: Wed, 23 Sep 2026 14:25:29 +0200 Subject: [PATCH 09/11] add comment about different strides edge case --- packages/essreduce/src/ess/reduce/unwrap/lut.py | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/packages/essreduce/src/ess/reduce/unwrap/lut.py b/packages/essreduce/src/ess/reduce/unwrap/lut.py index 7b90ac775..03a83528b 100644 --- a/packages/essreduce/src/ess/reduce/unwrap/lut.py +++ b/packages/essreduce/src/ess/reduce/unwrap/lut.py @@ -511,6 +511,13 @@ def process_disk_choppers( freq = abs(ch.frequency).to(unit='Hz') pulse_frequency = sc.reciprocal(pulse_period).to(unit=freq.unit) quot = freq / pulse_frequency + # Note on possible edge-cases: + # If we have two choppers, one at 14/3 Hz and another at 14/4 Hz, both pass + # the check here, and the table is built without error, even though the + # 14/3 Hz chopper turns 4/3 times per frame. This would most probably be the + # result of an error in the chopper settings. We delay implementing a proper + # handling of this for now, as the solution is not obvious, and it is + # unlikely to happen in practice. if not _is_int_or_inverse_int(quot, rtol=sc.scalar(1e-8)): empty = sc.array(dims=['cutout'], values=[], unit='deg') out[key] = replace( From 0cf568f24174f438ef2d2d9b5ff0da0531541eba Mon Sep 17 00:00:00 2001 From: Neil Vaytet Date: Wed, 23 Sep 2026 14:51:28 +0200 Subject: [PATCH 10/11] remove duplication and test that choppers with bas frequency are treated as closed --- .../src/ess/reduce/nexus/workflow.py | 2 + .../essreduce/src/ess/reduce/unwrap/lut.py | 94 ++++++++++--------- packages/essreduce/tests/unwrap/lut_test.py | 34 +++++++ 3 files changed, 85 insertions(+), 45 deletions(-) diff --git a/packages/essreduce/src/ess/reduce/nexus/workflow.py b/packages/essreduce/src/ess/reduce/nexus/workflow.py index 521abe006..79ebea837 100644 --- a/packages/essreduce/src/ess/reduce/nexus/workflow.py +++ b/packages/essreduce/src/ess/reduce/nexus/workflow.py @@ -570,6 +570,8 @@ def to_disk_choppers(choppers: RawChoppers[RunType]) -> DiskChoppers[RunType]: """ Convert the raw choppers (DataGroup with chopper information) to the ``scippneutron.DiskChopper`` objects used for wavelength calculation. + If a chopper has either empty rotation speed or phase/delay logs, it is dropped + from the final chopper list. Parameters ---------- diff --git a/packages/essreduce/src/ess/reduce/unwrap/lut.py b/packages/essreduce/src/ess/reduce/unwrap/lut.py index 03a83528b..1de08e86d 100644 --- a/packages/essreduce/src/ess/reduce/unwrap/lut.py +++ b/packages/essreduce/src/ess/reduce/unwrap/lut.py @@ -501,6 +501,25 @@ def _is_int_or_inverse_int(x: sc.Variable, *, rtol: sc.Variable) -> bool: def process_disk_choppers( choppers: DiskChoppers[RunType], pulse_period: PulsePeriod ) -> ProcessedDiskChoppers[RunType]: + """ + Iterate through the choppers and drop any choppers that have a frequency of 0 Hz. + They are considered to be parked/inactive. + In addition, if a chopper's frequency is not in sync with the source frequency + (neither a multiple of the source frequency, nor an integer fraction of it), it + is replaced with a chopper that is always closed. + This is because we cannot always find a frame_period over which we can find + periodicity for all choppers without making it arbitrary long. + Such chopper frequencies are most probably a result of an error in chopper settings, + and the simplest course of action is to treat them as always closed, which ensures + we do not compute an invalid wavelength lookup table from them. + + Parameters + ---------- + choppers: + A dict of DiskChopper objects representing the choppers in the beamline. + pulse_period: + Period of the source pulses, i.e., time between consecutive pulse starts. + """ out = {} for key, ch in choppers.items(): if ch.frequency.value == 0: @@ -516,8 +535,8 @@ def process_disk_choppers( # the check here, and the table is built without error, even though the # 14/3 Hz chopper turns 4/3 times per frame. This would most probably be the # result of an error in the chopper settings. We delay implementing a proper - # handling of this for now, as the solution is not obvious, and it is - # unlikely to happen in practice. + # handling of this for now, as the solution is not obvious (e.g. is it ok to + # have both 14/2 Hz and 14/4 Hz?), and it is unlikely to happen in practice. if not _is_int_or_inverse_int(quot, rtol=sc.scalar(1e-8)): empty = sc.array(dims=['cutout'], values=[], unit='deg') out[key] = replace( @@ -751,55 +770,40 @@ def compute_frame_sequence( chops = {} for key, ch in disk_choppers.items(): - # Skip choppers with zero frequency as they are treated as parked (not in use). - if ch.frequency.value == 0: - continue - chopper_distance = chopper_distance_along_beam( ch.axle_position, source_position ) - # If the frequency is not synced to the source pulse frequency, we transform - # this chopper to always be closed. - freq = abs(ch.frequency).to(unit='Hz') - pulse_frequency = sc.reciprocal(pulse_period).to(unit=freq.unit) - quot = freq / pulse_frequency - if not _is_int_or_inverse_int(quot, rtol=sc.scalar(1e-8)): - chops[key] = chopper_cascade.Chopper( - distance=chopper_distance, - time_open=sc.array(dims=["cutout"], values=[], unit='s'), - time_close=sc.array(dims=["cutout"], values=[], unit='s'), - ) - else: - # The `pulse_frequency` parameter in time_offset_open and time_offset_close - # below decides how many rotations the chopper will perform when computing - # the open and close times. - # We need to cover the entire time range from 0 to the time it takes the - # slowest neutron to travel the distance to the chopper. - slowest_to_chopper = chopper_distance / _wavelength_to_speed( - source_bounds.wavelength[1] - ) - travel_time = ( - source_bounds.time[1].to(unit='s') - + (pulse_stride - 1) * pulse_period.to(unit='s') - + slowest_to_chopper.to(unit='s') - ) + # The `pulse_frequency` parameter in time_offset_open and time_offset_close + # below decides how many rotations the chopper will perform when computing + # the open and close times. + # We need to cover the entire time range from 0 to the time it takes the + # slowest neutron to travel the distance to the chopper. + slowest_to_chopper = chopper_distance / _wavelength_to_speed( + source_bounds.wavelength[1] + ) + travel_time = ( + source_bounds.time[1].to(unit='s') + + (pulse_stride - 1) * pulse_period.to(unit='s') + + slowest_to_chopper.to(unit='s') + ) - # In addition, the time_offset_open and time_offset_close below require the - # pulse_frequency to be an integer multiple of the pulse frequency or vice - # versa. - nrot = int(np.ceil((travel_time * freq).value)) + 1 - pulse_frequency_for_diskchopper = freq / nrot + # In addition, the time_offset_open and time_offset_close below require the + # pulse_frequency to be an integer multiple of the pulse frequency or vice + # versa. + freq = abs(ch.frequency).to(unit='Hz') + nrot = int(np.ceil((travel_time * freq).value)) + 1 + pulse_frequency_for_diskchopper = freq / nrot - chops[key] = chopper_cascade.Chopper( - distance=chopper_distance, - time_open=ch.time_offset_open( - pulse_frequency=pulse_frequency_for_diskchopper - ), - time_close=ch.time_offset_close( - pulse_frequency=pulse_frequency_for_diskchopper - ), - ) + chops[key] = chopper_cascade.Chopper( + distance=chopper_distance, + time_open=ch.time_offset_open( + pulse_frequency=pulse_frequency_for_diskchopper + ), + time_close=ch.time_offset_close( + pulse_frequency=pulse_frequency_for_diskchopper + ), + ) frames = chopper_cascade.FrameSequence.from_source_pulse( time_min=source_bounds.time[0], diff --git a/packages/essreduce/tests/unwrap/lut_test.py b/packages/essreduce/tests/unwrap/lut_test.py index 80b0dce07..507d7f203 100644 --- a/packages/essreduce/tests/unwrap/lut_test.py +++ b/packages/essreduce/tests/unwrap/lut_test.py @@ -1,5 +1,6 @@ # SPDX-License-Identifier: BSD-3-Clause # Copyright (c) 2025 Scipp contributors (https://github.com/scipp) +import dataclasses from typing import NewType import numpy as np @@ -653,3 +654,36 @@ def test_lut_workflow_drops_choppers_with_zero_frequency(wavelength_from): at_detector = table.array['distance', -1] assert not np.isnan(at_detector.values).all() + + +@pytest.mark.parametrize("wavelength_from", ["analytical", "simulation"]) +def test_lut_workflow_treats_choppers_with_bad_frequency_as_closed(wavelength_from): + wf = _make_workflow(wavelength_from) + choppers = _make_choppers() + # Set one of the choppers to have a frequency out of sync with the source (14 Hz of + # the source is not divisible by 5 Hz). + choppers['FOC_1'] = dataclasses.replace( + choppers['FOC_1'], frequency=sc.scalar(5.0, unit='Hz') + ) + wf[unwrap.DiskChoppers[AnyRun]] = choppers + wf[Position[snx.NXsource, AnyRun]] = sc.vector([0, 0, 0], unit='m') + if wavelength_from == "simulation": + wf[unwrap.NumberOfSimulatedNeutrons] = 100_000 + wf[unwrap.SimulationSeed] = 78 + + wf[unwrap.LtotalRange[AnyRun, snx.NXdetector]] = ( + choppers['wfm1'].axle_position.fields.z, + choppers['FOC_5'].axle_position.fields.z, + ) + wf[unwrap.DistanceResolution] = sc.scalar(0.1, unit='m') + wf[unwrap.TimeResolution] = sc.scalar(250.0, unit='us') + + table = wf.compute(unwrap.LookupTable[AnyRun, snx.NXdetector]) + + # Before the bad chopper, the table should have some non-NaN values. + before_bad_chopper = table.array['distance', 1] + assert not np.isnan(before_bad_chopper.values).all() + + # After the bad chopper, the table should be all NaNs. + after_bad_chopper = table.array['distance', -1] + assert np.isnan(after_bad_chopper.values).all() From 3452a218a612a36ab8ec18cffd17174f5b7a26c0 Mon Sep 17 00:00:00 2001 From: Neil Vaytet Date: Wed, 23 Sep 2026 15:01:46 +0200 Subject: [PATCH 11/11] add tests for chopper processing --- .../essreduce/src/ess/reduce/unwrap/lut.py | 14 ++++- packages/essreduce/tests/unwrap/lut_test.py | 52 ++++++++++++++++++- 2 files changed, 63 insertions(+), 3 deletions(-) diff --git a/packages/essreduce/src/ess/reduce/unwrap/lut.py b/packages/essreduce/src/ess/reduce/unwrap/lut.py index 1de08e86d..200ace09b 100644 --- a/packages/essreduce/src/ess/reduce/unwrap/lut.py +++ b/packages/essreduce/src/ess/reduce/unwrap/lut.py @@ -538,9 +538,19 @@ def process_disk_choppers( # handling of this for now, as the solution is not obvious (e.g. is it ok to # have both 14/2 Hz and 14/4 Hz?), and it is unlikely to happen in practice. if not _is_int_or_inverse_int(quot, rtol=sc.scalar(1e-8)): - empty = sc.array(dims=['cutout'], values=[], unit='deg') + dim = ch.slit_begin.dim + empty = sc.array(dims=[dim], values=[], unit='deg') + height = ( + None + if ch.slit_height is None + else sc.array(dims=[dim], values=[], unit=ch.slit_height.unit) + ) out[key] = replace( - ch, frequency=pulse_frequency, slit_begin=empty, slit_end=empty + ch, + frequency=pulse_frequency, + slit_begin=empty, + slit_end=empty, + slit_height=height, ) else: out[key] = ch diff --git a/packages/essreduce/tests/unwrap/lut_test.py b/packages/essreduce/tests/unwrap/lut_test.py index 507d7f203..c66b8b00e 100644 --- a/packages/essreduce/tests/unwrap/lut_test.py +++ b/packages/essreduce/tests/unwrap/lut_test.py @@ -634,10 +634,60 @@ def test_choppers_rotate_enough_times_with_slow_neutrons_to_pollute_lut(): ).value +def test_chopper_processing_drops_choppers_with_zero_frequency(): + from ess.reduce.unwrap.lut import process_disk_choppers + + choppers = _make_choppers() + n_original = len(choppers) + key = next(iter(choppers)) + period = sc.scalar(1 / 14, unit='s') + + all_processed = process_disk_choppers(choppers, pulse_period=period) + assert len(all_processed) == n_original + assert key in all_processed + + choppers[key] = dataclasses.replace( + choppers[key], frequency=sc.scalar(0.0, unit='Hz') + ) + processed = process_disk_choppers(choppers, pulse_period=period) + assert len(processed) == n_original - 1 + assert key not in processed + + +def test_chopper_processing_treats_choppers_with_bad_frequency_as_closed(): + from ess.reduce.unwrap.lut import process_disk_choppers + + choppers = _make_choppers() + n_original = len(choppers) + key = next(iter(choppers)) + period = sc.scalar(1 / 14, unit='s') + + all_processed = process_disk_choppers(choppers, pulse_period=period) + assert len(all_processed) == n_original + assert key in all_processed + + # Set one of the choppers to have a frequency out of sync with the source (14 Hz of + # the source is not divisible by 5 Hz). + choppers[key] = dataclasses.replace( + choppers[key], frequency=sc.scalar(5.0, unit='Hz') + ) + processed = process_disk_choppers(choppers, pulse_period=period) + # The chopper is still there + assert len(processed) == n_original + assert key in processed + # The chopper should not have any slits + assert processed[key].slit_begin.size == 0 + assert processed[key].slit_end.size == 0 + + @pytest.mark.parametrize("wavelength_from", ["analytical", "simulation"]) def test_lut_workflow_drops_choppers_with_zero_frequency(wavelength_from): wf = _make_workflow(wavelength_from) - wf[unwrap.DiskChoppers[AnyRun]] = _make_choppers() + choppers = _make_choppers() + choppers['FOC_1'] = dataclasses.replace( + choppers['FOC_1'], frequency=sc.scalar(0.0, unit='Hz') + ) + wf[unwrap.DiskChoppers[AnyRun]] = choppers wf[Position[snx.NXsource, AnyRun]] = sc.vector([0, 0, 0], unit='m') if wavelength_from == "simulation": wf[unwrap.NumberOfSimulatedNeutrons] = 100_000