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/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 13d4fdd6e..200ace09b 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 @@ -27,6 +27,18 @@ ) +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 +70,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 @@ -155,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 @@ -241,6 +263,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], @@ -314,8 +349,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. @@ -457,8 +491,74 @@ 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]: + """ + 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: + 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 + # 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 (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)): + 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, + slit_height=height, + ) + 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], @@ -623,6 +723,16 @@ 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() + # 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( [ @@ -632,7 +742,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) @@ -644,7 +754,7 @@ def _estimate_wavelength_by_polygon_centers( 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], @@ -668,33 +778,42 @@ 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. 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 - ) + 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') + ) - chops = { - key: chopper_cascade.Chopper( - distance=chopper_distance_along_beam(ch.axle_position, source_position), + # 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=frequency_for_chopper_rotation + pulse_frequency=pulse_frequency_for_diskchopper ), time_close=ch.time_offset_close( - pulse_frequency=frequency_for_chopper_rotation + pulse_frequency=pulse_frequency_for_diskchopper ), ) - for key, ch in disk_choppers.items() - } frames = chopper_cascade.FrameSequence.from_source_pulse( time_min=source_bounds.time[0], @@ -714,7 +833,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 @@ -744,8 +863,7 @@ 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) + 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. @@ -839,19 +957,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) @@ -896,6 +1011,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, diff --git a/packages/essreduce/tests/unwrap/lut_test.py b/packages/essreduce/tests/unwrap/lut_test.py index ede8b0d28..c66b8b00e 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 @@ -10,7 +11,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") @@ -487,3 +493,247 @@ 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') + + +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 + + +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) + 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 + 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() + + +@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()