From 75e9485430d711c2cf51dc64bbc715dc1b18502e Mon Sep 17 00:00:00 2001 From: Neil Vaytet Date: Wed, 12 Nov 2025 15:19:40 +0100 Subject: [PATCH 1/8] use rotation setpoint if available, fix docs link, handle data array fields --- src/scippneutron/chopper/disk_chopper.py | 17 +++++++++-------- 1 file changed, 9 insertions(+), 8 deletions(-) diff --git a/src/scippneutron/chopper/disk_chopper.py b/src/scippneutron/chopper/disk_chopper.py index 65df0a04d..e746d796d 100644 --- a/src/scippneutron/chopper/disk_chopper.py +++ b/src/scippneutron/chopper/disk_chopper.py @@ -334,7 +334,12 @@ def from_nexus( ) return DiskChopper( axle_position=chopper['position'], - frequency=_get_1d_variable(chopper, 'rotation_speed'), + frequency=_get_1d_variable( + chopper, + 'rotation_speed_setpoint' + if 'rotation_speed_setpoint' in chopper + else 'rotation_speed', + ), beam_position=_get_1d_variable(chopper, 'beam_position'), phase=_get_1d_variable(chopper, 'phase'), slit_height=chopper.get('slit_height'), @@ -627,12 +632,6 @@ def _get_edges_from_nexus( } -def _len_or_1(x: sc.Variable) -> int: - if x.ndim == 0: - return 1 - return len(x) - - def _get_1d_variable( dg: Mapping[str, sc.Variable | sc.DataArray], name: str ) -> sc.Variable: @@ -642,9 +641,11 @@ def _get_1d_variable( msg = ( "Chopper field '{name}' must be a scalar variable, {got}. " "See the chopper user-guide for more information: " - "https://scipp.github.io/scippneutron/user-guide/chopper/pre-processing.html" + "https://scipp.github.io/scippneutron/user-guide/chopper/processing-nexus-choppers.html" ) + if isinstance(val, sc.DataArray): + val = val.data if not isinstance(val, sc.Variable): raise TypeError(msg.format(name=name, got=f'got a {type(val)}')) if val.ndim != 0: From 4351b9d5aa2a2b8f367b4fb33b257a1dcd780b0b Mon Sep 17 00:00:00 2001 From: Neil Vaytet Date: Wed, 12 Nov 2025 15:27:26 +0100 Subject: [PATCH 2/8] add method to convert chopper to a dict, making it easy to create a DataGroup from --- src/scippneutron/chopper/disk_chopper.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/scippneutron/chopper/disk_chopper.py b/src/scippneutron/chopper/disk_chopper.py index e746d796d..9c655f194 100644 --- a/src/scippneutron/chopper/disk_chopper.py +++ b/src/scippneutron/chopper/disk_chopper.py @@ -562,6 +562,10 @@ def _source_phase_factor(self, pulse_frequency: sc.Variable) -> int: # of the slits, so use `max` here: return round(max(quot.value, 1)) + def as_dict(self) -> dict[str, Any]: + """Return the DiskChopper fields as a dictionary.""" + return dataclasses.asdict(self) + def _field_eq(a: Any, b: Any) -> bool: if isinstance(a, sc.Variable | sc.DataArray): From be3bbfd9c7258019a29a31f08cc702cd060987ae Mon Sep 17 00:00:00 2001 From: Neil Vaytet Date: Tue, 25 Nov 2025 10:59:47 +0100 Subject: [PATCH 3/8] remove use of setpoint and rename 1d to 0d --- src/scippneutron/chopper/disk_chopper.py | 13 ++++--------- 1 file changed, 4 insertions(+), 9 deletions(-) diff --git a/src/scippneutron/chopper/disk_chopper.py b/src/scippneutron/chopper/disk_chopper.py index 9c655f194..13f579cb2 100644 --- a/src/scippneutron/chopper/disk_chopper.py +++ b/src/scippneutron/chopper/disk_chopper.py @@ -334,14 +334,9 @@ def from_nexus( ) return DiskChopper( axle_position=chopper['position'], - frequency=_get_1d_variable( - chopper, - 'rotation_speed_setpoint' - if 'rotation_speed_setpoint' in chopper - else 'rotation_speed', - ), - beam_position=_get_1d_variable(chopper, 'beam_position'), - phase=_get_1d_variable(chopper, 'phase'), + frequency=_get_0d_variable(chopper, 'rotation_speed'), + beam_position=_get_0d_variable(chopper, 'beam_position'), + phase=_get_0d_variable(chopper, 'phase'), slit_height=chopper.get('slit_height'), radius=chopper.get('radius'), **_get_edges_from_nexus(chopper), @@ -636,7 +631,7 @@ def _get_edges_from_nexus( } -def _get_1d_variable( +def _get_0d_variable( dg: Mapping[str, sc.Variable | sc.DataArray], name: str ) -> sc.Variable: if (val := dg.get(name)) is None: From f38aaab02a85a6ee434ccc234839fcfc1a8bec3f Mon Sep 17 00:00:00 2001 From: Neil Vaytet Date: Tue, 25 Nov 2025 15:25:48 +0100 Subject: [PATCH 4/8] use setpoint, and delay if phase is not present --- src/scippneutron/chopper/disk_chopper.py | 31 ++++++++++++++++++------ 1 file changed, 24 insertions(+), 7 deletions(-) diff --git a/src/scippneutron/chopper/disk_chopper.py b/src/scippneutron/chopper/disk_chopper.py index 13f579cb2..c63e4f31a 100644 --- a/src/scippneutron/chopper/disk_chopper.py +++ b/src/scippneutron/chopper/disk_chopper.py @@ -308,11 +308,16 @@ def from_nexus( Keys in the input correspond to the fields of `NXdisk_chopper `_. - The values have to be post-processed, e.g., with - :func:`~scippneutron.chopper.nexus_chopper.post_process_disk_chopper`. - See its documentation for the required steps. - Also, note the class docs of :class:`DiskChopper` - about time-dependent fields. + + The input data group must contain a `rotation_speed_setpoint`. If this is not + available, a single (scalar) value must be computed from processing the + time-dependent `rotation_speed` data. This can be done with + :func:`~scippneutron.chopper.nexus_chopper.post_process_disk_chopper` + (see its documentation for the required steps). The resulting single value + can then be passed as `rotation_speed_setpoint`. + + If a `phase` field is not present, it is computed from the `delay` field + and the `rotation_speed_setpoint` as `phase = 2*pi*frequency*delay`. Parameters ---------- @@ -332,11 +337,23 @@ def from_nexus( 'Class DiskChopper only supports single choppers,' f'got chopper type {typ}' ) + + frequency = _get_0d_variable(chopper, 'rotation_speed_setpoint') + if "phase" in chopper: + phase = _get_0d_variable(chopper, 'phase') + else: + if "delay" not in chopper: + raise ValueError( + "DiskChopper.from_nexus: Chopper field 'phase' is missing and " + "cannot be computed because field 'delay' is also missing." + ) + omega = 2 * sc.constants.pi * frequency * sc.scalar(1.0, unit='rad') + phase = (chopper["delay"].to(dtype=float) * omega).to(unit='rad') return DiskChopper( axle_position=chopper['position'], - frequency=_get_0d_variable(chopper, 'rotation_speed'), + frequency=frequency, beam_position=_get_0d_variable(chopper, 'beam_position'), - phase=_get_0d_variable(chopper, 'phase'), + phase=phase, slit_height=chopper.get('slit_height'), radius=chopper.get('radius'), **_get_edges_from_nexus(chopper), From 3df3643e955a25057f1871fe405801d00317d77f Mon Sep 17 00:00:00 2001 From: Neil Vaytet Date: Tue, 25 Nov 2025 15:59:54 +0100 Subject: [PATCH 5/8] allow both rotation_speed and rotation_speed_setpoint --- src/scippneutron/chopper/disk_chopper.py | 5 +- tests/chopper/disk_chopper_test.py | 65 +++++++++++++++--------- 2 files changed, 46 insertions(+), 24 deletions(-) diff --git a/src/scippneutron/chopper/disk_chopper.py b/src/scippneutron/chopper/disk_chopper.py index c63e4f31a..52e31ca77 100644 --- a/src/scippneutron/chopper/disk_chopper.py +++ b/src/scippneutron/chopper/disk_chopper.py @@ -338,7 +338,10 @@ def from_nexus( f'got chopper type {typ}' ) - frequency = _get_0d_variable(chopper, 'rotation_speed_setpoint') + if "rotation_speed_setpoint" in chopper: + frequency = _get_0d_variable(chopper, 'rotation_speed_setpoint') + else: + frequency = _get_0d_variable(chopper, 'rotation_speed') if "phase" in chopper: phase = _get_0d_variable(chopper, 'phase') else: diff --git a/tests/chopper/disk_chopper_test.py b/tests/chopper/disk_chopper_test.py index 2243b4ece..9a0240694 100644 --- a/tests/chopper/disk_chopper_test.py +++ b/tests/chopper/disk_chopper_test.py @@ -18,13 +18,14 @@ def deg_angle_to_time_factor(frequency: sc.Variable) -> sc.Variable: return sc.to_unit(to_rad / angular_frequency, 's/deg') -@pytest.fixture -def nexus_chopper(): +@pytest.fixture(params=['rotation_speed', 'rotation_speed_setpoint']) +def nexus_chopper(request): + rotation_speed_key = request.param return sc.DataGroup( { 'type': DiskChopperType.single, 'position': sc.vector([0.0, 0.0, 2.0], unit='m'), - 'rotation_speed': sc.scalar(12.0, unit='Hz'), + rotation_speed_key: sc.scalar(12.0, unit='Hz'), 'beam_position': sc.scalar(45.0, unit='deg'), 'phase': sc.scalar(-20.0, unit='deg'), 'slit_edges': sc.array( @@ -38,6 +39,14 @@ def nexus_chopper(): ) +def _get_rotation_speed_key(nexus_chopper): + return ( + 'rotation_speed_setpoint' + if 'rotation_speed_setpoint' in nexus_chopper + else 'rotation_speed' + ) + + @pytest.mark.parametrize( 'typ', [ @@ -54,7 +63,7 @@ def test_chopper_supports_only_single(nexus_chopper, typ): def test_frequency_must_be_frequency(nexus_chopper): - nexus_chopper['rotation_speed'] = sc.scalar(1.0, unit='m/s') + nexus_chopper[_get_rotation_speed_key(nexus_chopper)] = sc.scalar(1.0, unit='m/s') with pytest.raises(sc.UnitError): DiskChopper.from_nexus(nexus_chopper) @@ -78,6 +87,11 @@ def test_eq(nexus_chopper): ) def test_neq(nexus_chopper, replacement): ch1 = DiskChopper.from_nexus(nexus_chopper) + if ( + replacement[0] == 'rotation_speed' + and 'rotation_speed_setpoint' in nexus_chopper + ): + replacement = ('rotation_speed_setpoint', replacement[1]) ch2 = DiskChopper.from_nexus({**nexus_chopper, replacement[0]: replacement[1]}) assert ch1 != ch2 @@ -212,7 +226,7 @@ def test_time_offset_angle_at_beam_no_phase_zero_beam_pos_clockwise_single_angle **nexus_chopper, 'beam_position': sc.scalar(0.0, unit=beam_position_unit), 'phase': sc.scalar(0.0, unit=phase_unit), - 'rotation_speed': sc.scalar(-2.3, unit='Hz'), + _get_rotation_speed_key(nexus_chopper): sc.scalar(-2.3, unit='Hz'), } ) omega = 2 * pi * 2.3 @@ -250,7 +264,7 @@ def test_time_offset_angle_at_beam_no_phase_zero_beam_pos_anti_clockwise_single_ **nexus_chopper, 'beam_position': sc.scalar(0.0, unit=beam_position_unit), 'phase': sc.scalar(0.0, unit=phase_unit), - 'rotation_speed': sc.scalar(2.3, unit='Hz'), + _get_rotation_speed_key(nexus_chopper): sc.scalar(2.3, unit='Hz'), } ) omega = 2 * pi * 2.3 @@ -287,7 +301,7 @@ def test_time_offset_angle_at_beam_no_phase_zero_beam_pos_clockwise_multi_angle( **nexus_chopper, 'beam_position': sc.scalar(0.0, unit=beam_position_unit), 'phase': sc.scalar(0.0, unit=phase_unit), - 'rotation_speed': sc.scalar(-4.4, unit='Hz'), + _get_rotation_speed_key(nexus_chopper): sc.scalar(-4.4, unit='Hz'), } ) omega = 2 * pi * 4.4 @@ -312,7 +326,7 @@ def test_time_offset_angle_at_beam_no_phase_zero_beam_pos_anti_clockwise_multi_a **nexus_chopper, 'beam_position': sc.scalar(0.0, unit=beam_position_unit), 'phase': sc.scalar(0.0, unit=phase_unit), - 'rotation_speed': sc.scalar(4.4, unit='Hz'), + _get_rotation_speed_key(nexus_chopper): sc.scalar(4.4, unit='Hz'), } ) omega = 2 * pi * 4.4 @@ -335,7 +349,7 @@ def test_time_offset_angle_at_beam_no_phase_with_beam_pos_clockwise( **nexus_chopper, 'beam_position': sc.scalar(1.8, unit='rad'), 'phase': sc.scalar(0.0, unit=phase_unit), - 'rotation_speed': sc.scalar(-2.3, unit='Hz'), + _get_rotation_speed_key(nexus_chopper): sc.scalar(-2.3, unit='Hz'), } ) omega = 2 * pi * 2.3 @@ -368,7 +382,7 @@ def test_time_offset_angle_at_beam_no_phase_with_beam_pos_anti_clockwise( **nexus_chopper, 'beam_position': sc.scalar(1.8, unit='rad'), 'phase': sc.scalar(0.0, unit=phase_unit), - 'rotation_speed': sc.scalar(2.3, unit='Hz'), + _get_rotation_speed_key(nexus_chopper): sc.scalar(2.3, unit='Hz'), } ) omega = 2 * pi * 2.3 @@ -401,7 +415,7 @@ def test_time_offset_angle_at_beam_with_phase_zero_beam_pos_clockwise( **nexus_chopper, 'beam_position': sc.scalar(0.0, unit=beam_position_unit), 'phase': sc.scalar(-0.7, unit='rad'), - 'rotation_speed': sc.scalar(-1.1, unit='Hz'), + _get_rotation_speed_key(nexus_chopper): sc.scalar(-1.1, unit='Hz'), } ) omega = 2 * pi * 1.1 @@ -435,7 +449,7 @@ def test_time_offset_angle_at_beam_with_phase_zero_beam_pos_anti_clockwise( **nexus_chopper, 'beam_position': sc.scalar(0.0, unit=beam_position_unit), 'phase': sc.scalar(-0.7, unit='rad'), - 'rotation_speed': sc.scalar(1.1, unit='Hz'), + _get_rotation_speed_key(nexus_chopper): sc.scalar(1.1, unit='Hz'), } ) omega = 2 * pi * 1.1 @@ -492,7 +506,9 @@ def test_time_offset_open_close_only_slit(nexus_chopper, rotation_speed): **nexus_chopper, 'beam_position': sc.scalar(0.0, unit='rad'), 'phase': sc.scalar(0.0, unit='rad'), - 'rotation_speed': sc.scalar(rotation_speed, unit='Hz'), + _get_rotation_speed_key(nexus_chopper): sc.scalar( + rotation_speed, unit='Hz' + ), 'slit_edges': sc.array( dims=['slit'], values=[0.0, 360.0], @@ -538,7 +554,7 @@ def test_time_offset_open_close_one_slit_clockwise(nexus_chopper, phase, beam_po **nexus_chopper, 'beam_position': beam_position, 'phase': phase, - 'rotation_speed': sc.scalar(-7.21, unit='Hz'), + _get_rotation_speed_key(nexus_chopper): sc.scalar(-7.21, unit='Hz'), 'slit_edges': sc.array(dims=['slit'], values=[87.0, 177.0], unit='deg'), } ) @@ -584,7 +600,7 @@ def test_time_offset_open_close_one_slit_anticlockwise( **nexus_chopper, 'beam_position': beam_position, 'phase': phase, - 'rotation_speed': sc.scalar(7.21, unit='Hz'), + _get_rotation_speed_key(nexus_chopper): sc.scalar(7.21, unit='Hz'), 'slit_edges': sc.array(dims=['slit'], values=[87.0, 177.0], unit='deg'), } ) @@ -630,7 +646,7 @@ def test_time_offset_open_close_one_slit_across_tdc_clockwise( **nexus_chopper, 'beam_position': beam_position, 'phase': phase, - 'rotation_speed': sc.scalar(-7.21, unit='Hz'), + _get_rotation_speed_key(nexus_chopper): sc.scalar(-7.21, unit='Hz'), 'slit_edges': sc.array(dims=['slit'], values=[330.0, 380.0], unit='deg'), } ) @@ -676,7 +692,7 @@ def test_time_offset_open_close_one_slit_across_tdc_anticlockwise( **nexus_chopper, 'beam_position': beam_position, 'phase': phase, - 'rotation_speed': sc.scalar(7.21, unit='Hz'), + _get_rotation_speed_key(nexus_chopper): sc.scalar(7.21, unit='Hz'), 'slit_edges': sc.array(dims=['slit'], values=[330.0, 380.0], unit='deg'), } ) @@ -704,7 +720,7 @@ def test_time_offset_open_close_two_slits_clockwise(nexus_chopper): **nexus_chopper, 'beam_position': sc.scalar(-32.0, unit='deg'), 'phase': sc.scalar(0.0, unit='deg'), - 'rotation_speed': sc.scalar(-11.2, unit='Hz'), + _get_rotation_speed_key(nexus_chopper): sc.scalar(-11.2, unit='Hz'), 'slit_edges': sc.array( dims=['slit'], values=[87.0, 177.0, 280.0, 342.0], @@ -743,7 +759,7 @@ def test_time_offset_open_close_two_slits_anticlockwise(nexus_chopper): **nexus_chopper, 'beam_position': sc.scalar(-32.0, unit='deg'), 'phase': sc.scalar(0.0, unit='deg'), - 'rotation_speed': sc.scalar(11.2, unit='Hz'), + _get_rotation_speed_key(nexus_chopper): sc.scalar(11.2, unit='Hz'), 'slit_edges': sc.array( dims=['slit'], values=[87.0, 177.0, 280.0, 342.0], @@ -782,7 +798,7 @@ def test_time_offset_open_close_two_slits_clockwise_two_pulses(nexus_chopper): **nexus_chopper, 'beam_position': sc.scalar(0.0, unit='deg'), 'phase': sc.scalar(60.0, unit='deg'), - 'rotation_speed': sc.scalar(-14.0, unit='Hz'), + _get_rotation_speed_key(nexus_chopper): sc.scalar(-14.0, unit='Hz'), 'slit_edges': sc.array( dims=['slit'], values=[87.0, 177.0, 200.0, 240.0], @@ -837,7 +853,7 @@ def test_time_offset_open_close_two_slits_anticlockwise_two_pulses(nexus_chopper **nexus_chopper, 'beam_position': sc.scalar(0.0, unit='deg'), 'phase': sc.scalar(60.0, unit='deg'), - 'rotation_speed': sc.scalar(14.0, unit='Hz'), + _get_rotation_speed_key(nexus_chopper): sc.scalar(14.0, unit='Hz'), 'slit_edges': sc.array( dims=['slit'], values=[87.0, 177.0, 200.0, 240.0], @@ -892,7 +908,7 @@ def test_time_offset_open_close_two_slits_clockwise_half_pulse(nexus_chopper): **nexus_chopper, 'beam_position': sc.scalar(0.0, unit='deg'), 'phase': sc.scalar(60.0, unit='deg'), - 'rotation_speed': sc.scalar(-3.5, unit='Hz'), + _get_rotation_speed_key(nexus_chopper): sc.scalar(-3.5, unit='Hz'), 'slit_edges': sc.array( dims=['slit'], values=[87.0, 177.0, 200.0, 240.0], @@ -928,7 +944,10 @@ def test_time_offset_open_close_two_slits_clockwise_half_pulse(nexus_chopper): def test_time_offset_open_close_source_frequency_not_multiple_of_chopper(nexus_chopper): ch = DiskChopper.from_nexus( - {**nexus_chopper, 'rotation_speed': sc.scalar(4.52, unit='Hz')} + { + **nexus_chopper, + _get_rotation_speed_key(nexus_chopper): sc.scalar(4.52, unit='Hz'), + } ) with pytest.raises(ValueError, match='out of phase'): ch.time_offset_open(pulse_frequency=sc.scalar(4.3, unit='Hz')) From d823a3c2af278d586c6ed66693f873df5966053b Mon Sep 17 00:00:00 2001 From: Neil Vaytet Date: Tue, 25 Nov 2025 16:01:14 +0100 Subject: [PATCH 6/8] add comment --- src/scippneutron/chopper/disk_chopper.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/scippneutron/chopper/disk_chopper.py b/src/scippneutron/chopper/disk_chopper.py index 52e31ca77..c5d328eb6 100644 --- a/src/scippneutron/chopper/disk_chopper.py +++ b/src/scippneutron/chopper/disk_chopper.py @@ -339,8 +339,10 @@ def from_nexus( ) if "rotation_speed_setpoint" in chopper: + # `rotation_speed_setpoint` is preferred if available (ESS files) frequency = _get_0d_variable(chopper, 'rotation_speed_setpoint') else: + # Fallback to `rotation_speed` (standard NeXus) frequency = _get_0d_variable(chopper, 'rotation_speed') if "phase" in chopper: phase = _get_0d_variable(chopper, 'phase') From bd2218622e6e2184b94dfa9571a1b8bbfc4a2715 Mon Sep 17 00:00:00 2001 From: Neil Vaytet Date: Tue, 25 Nov 2025 16:11:33 +0100 Subject: [PATCH 7/8] add phase tests --- tests/chopper/disk_chopper_test.py | 37 ++++++++++++++++++++++++++++++ 1 file changed, 37 insertions(+) diff --git a/tests/chopper/disk_chopper_test.py b/tests/chopper/disk_chopper_test.py index 9a0240694..53b86cac3 100644 --- a/tests/chopper/disk_chopper_test.py +++ b/tests/chopper/disk_chopper_test.py @@ -964,3 +964,40 @@ def test_disk_chopper_svg_custom_dim_names(nexus_chopper): nexus_chopper['slit_edges'] = nexus_chopper['slit_edges'].rename_dims(slit='dim_0') ch = DiskChopper.from_nexus(nexus_chopper) assert ch.make_svg() + + +def test_phase_derived_from_delay_and_frequency(nexus_chopper): + delay = sc.scalar(5e7, unit='ns') + frequency = sc.scalar(14.0, unit='Hz') + expected_phase = (2 * pi * frequency * delay * sc.scalar(1.0, unit='rad')).to( + unit='deg' + ) + + chopper_params = nexus_chopper.copy() + del chopper_params['phase'] + + ch = DiskChopper.from_nexus( + { + **chopper_params, + 'delay': delay, + _get_rotation_speed_key(nexus_chopper): frequency, + } + ) + assert sc.allclose(ch.phase.to(unit='deg'), expected_phase) + + +def test_missing_both_phase_and_delay_raises(nexus_chopper): + chopper_params = nexus_chopper.copy() + del chopper_params['phase'] + + with pytest.raises( + ValueError, + match="DiskChopper.from_nexus: Chopper field 'phase' is missing and " + "cannot be computed because field 'delay' is also missing.", + ): + DiskChopper.from_nexus( + { + **chopper_params, + _get_rotation_speed_key(nexus_chopper): sc.scalar(14.0, unit='Hz'), + } + ) From 01340d454c4fdfd6e2f3c691be03dce502821f6e Mon Sep 17 00:00:00 2001 From: Neil Vaytet Date: Wed, 26 Nov 2025 16:38:41 +0100 Subject: [PATCH 8/8] update processing choppers notebook in docs --- .../chopper/processing-nexus-choppers.ipynb | 253 +++++++++++------- src/scippneutron/chopper/disk_chopper.py | 4 +- src/scippneutron/data/_chopper_mockup.py | 8 +- 3 files changed, 160 insertions(+), 105 deletions(-) diff --git a/docs/user-guide/chopper/processing-nexus-choppers.ipynb b/docs/user-guide/chopper/processing-nexus-choppers.ipynb index 8de328db3..5ff30c81e 100644 --- a/docs/user-guide/chopper/processing-nexus-choppers.ipynb +++ b/docs/user-guide/chopper/processing-nexus-choppers.ipynb @@ -5,10 +5,11 @@ "id": "0", "metadata": {}, "source": [ - "# Processing NeXus Choppers\n", + "# Reading and processing NeXus choppers\n", "\n", - "When choppers are loaded from NeXus, they typically contain a number of fields that need to be processed before they can be used for computing wavelength ranges, etc.\n", - "This guide shows how to extract the relevant data from such a NeXus chopper and create a [scippneutron.chopper.DiskChopper](../../generated/modules/scippneutron.chopper.disk_chopper.DiskChopper.rst) object." + "This guide shows how to extract the relevant data from a NeXus representation of a chopper and create a [scippneutron.chopper.DiskChopper](../../generated/modules/scippneutron.chopper.disk_chopper.DiskChopper.rst) object.\n", + "\n", + "We also demonstrate some utilities that Scippneutron provides to process and visualize the data inside NeXus choppers." ] }, { @@ -26,6 +27,8 @@ "id": "2", "metadata": {}, "source": [ + "## NeXus chopper data\n", + "\n", "Here, we use fake data which roughly represents what a real chopper loaded from NeXus looks like.\n", "ScippNeutron has a function for generating this data:" ] @@ -76,13 +79,10 @@ "id": "6", "metadata": {}, "source": [ - "Some data varies with time, which can complicate the data processing.\n", - "Instead, we compute corresponding time-independent quantities from the raw chopper data.\n", - "\n", - "## Identify In-phase Regions\n", + "## Converting to a `DiskChopper`\n", "\n", - "Frame unwrapping is only feasible when the chopper is in-phase with the neutron source pulses because, otherwise, the wavelength frames vary pulse-by-pulse.\n", - "To identify regions where the chopper is in-phase, we first find plateaus in the `rotation_speed` which is the rotation frequency of the chopper." + "We can assemble all data into a [scippneutron.chopper.DiskChopper](../../generated/modules/scippneutron.chopper.disk_chopper.DiskChopper.rst) object,\n", + "which enables better exploration/visualization of the chopper properties." ] }, { @@ -91,6 +91,59 @@ "id": "7", "metadata": {}, "outputs": [], + "source": [ + "from scippneutron.chopper import DiskChopper\n", + "\n", + "disk_chopper = DiskChopper.from_nexus(chopper)\n", + "disk_chopper" + ] + }, + { + "cell_type": "markdown", + "id": "8", + "metadata": {}, + "source": [ + "Note that the chopper phase $\\phi$ was derived from the rotation frequency $f$ and the `delay` $\\delta_{t}$ using $\\phi = 2 \\pi f \\delta_{t}$.\n", + "\n", + "With the `DiskChopper`, we can for example see at what times the chopper is open and closed:" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "9", + "metadata": {}, + "outputs": [], + "source": [ + "pulse_frequency = sc.scalar(14, unit=\"Hz\")\n", + "\n", + "print(\"Times open: \", disk_chopper.time_offset_open(pulse_frequency=pulse_frequency))\n", + "print(\"Times closed:\", disk_chopper.time_offset_close(pulse_frequency=pulse_frequency))" + ] + }, + { + "cell_type": "markdown", + "id": "10", + "metadata": {}, + "source": [ + "## Inspecting time-dependent logs\n", + "\n", + "Some of the entries in the raw NeXus data group are time-dependent.\n", + "In this section we take a closer look at these logs to gain more insights in how the chopper data is actually recorded.\n", + "\n", + "### Identifying in-phase regions\n", + "\n", + "Frame unwrapping and time-of-flight computation are only feasible when the choppers are in-phase with the neutron source pulses because, otherwise, the wavelength frames vary pulse-by-pulse.\n", + "\n", + "To identify regions where a chopper is in-phase, we can inspect the `rotation_speed` which is the rotation frequency of the chopper." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "11", + "metadata": {}, + "outputs": [], "source": [ "rotation_speed = chopper['rotation_speed']\n", "rotation_speed.name = 'rotation_speed'\n", @@ -99,7 +152,7 @@ }, { "cell_type": "markdown", - "id": "8", + "id": "12", "metadata": {}, "source": [ "The chopper has a long region of near-constant rotation speed surrounded by spin-up and spin-down regions:" @@ -108,7 +161,7 @@ { "cell_type": "code", "execution_count": null, - "id": "9", + "id": "13", "metadata": {}, "outputs": [], "source": [ @@ -117,10 +170,14 @@ }, { "cell_type": "markdown", - "id": "10", + "id": "14", "metadata": {}, "source": [ - "We use [find_plateaus](../../generated/modules/scippneutron.chopper.filtering.find_plateaus.rst) and [collapse_plateaus](../../generated/modules/scippneutron.chopper.filtering.collapse_plateaus.rst) to find those plateaus.\n", + "The central plateau is the section of the log where the chopper is in-phase with the source (the ESS source has a frequency of 14 Hz).\n", + "\n", + "We use [find_plateaus](../../generated/modules/scippneutron.chopper.filtering.find_plateaus.rst)\n", + "and [collapse_plateaus](../../generated/modules/scippneutron.chopper.filtering.collapse_plateaus.rst) to find one or more plateaus in the log data.\n", + "\n", "Note the `atol` and `min_n_points` parameters, they need to be tuned for the specific input data.\n", "\n", "
\n", @@ -136,20 +193,22 @@ { "cell_type": "code", "execution_count": null, - "id": "11", + "id": "15", "metadata": {}, "outputs": [], "source": [ "from scippneutron.chopper import collapse_plateaus, find_plateaus\n", "\n", - "plateaus = find_plateaus(rotation_speed, atol=sc.scalar(1e-3, unit='Hz / s'), min_n_points=10)\n", + "plateaus = find_plateaus(\n", + " rotation_speed, atol=sc.scalar(1e-3, unit='Hz / s'), min_n_points=10\n", + ")\n", "plateaus = collapse_plateaus(plateaus)\n", "plateaus" ] }, { "cell_type": "markdown", - "id": "12", + "id": "16", "metadata": {}, "source": [ "`find_plateaus` found two plateaus that we can plot with the following helper function:" @@ -158,7 +217,7 @@ { "cell_type": "code", "execution_count": null, - "id": "13", + "id": "17", "metadata": {}, "outputs": [], "source": [ @@ -168,7 +227,7 @@ " i = plateau.coords['plateau'].value\n", " to_plot[f'Plateau {i}'] = sc.DataArray(\n", " plateau.data.broadcast(dims=['time'], shape=[2]),\n", - " coords={'time': plateau.coords['time']}\n", + " coords={'time': plateau.coords['time']},\n", " )\n", " return to_plot.plot(\n", " ls={f'Plateau {i}': '-' for i in range(len(plateaus))},\n", @@ -180,7 +239,7 @@ { "cell_type": "code", "execution_count": null, - "id": "14", + "id": "18", "metadata": {}, "outputs": [], "source": [ @@ -189,7 +248,7 @@ }, { "cell_type": "markdown", - "id": "15", + "id": "19", "metadata": {}, "source": [ "In this case, the source has a frequency of 14Hz which means that plateau 0 is in phase.\n", @@ -201,7 +260,7 @@ { "cell_type": "code", "execution_count": null, - "id": "16", + "id": "20", "metadata": {}, "outputs": [], "source": [ @@ -211,23 +270,22 @@ { "cell_type": "code", "execution_count": null, - "id": "17", + "id": "21", "metadata": {}, "outputs": [], "source": [ "from scippneutron.chopper import filter_in_phase\n", "\n", "frequency_in_phase = filter_in_phase(\n", - " plateaus,\n", - " reference=pulse_frequency,\n", - " rtol=sc.scalar(1e-3))\n", + " plateaus, reference=pulse_frequency, rtol=sc.scalar(1e-3)\n", + ")\n", "frequency_in_phase" ] }, { "cell_type": "code", "execution_count": null, - "id": "18", + "id": "22", "metadata": {}, "outputs": [], "source": [ @@ -236,18 +294,16 @@ }, { "cell_type": "markdown", - "id": "19", + "id": "23", "metadata": {}, "source": [ - "## Extract Plateau\n", - "\n", "Since there is only one plateau left, we can simply index into it to get the chopper frequency:" ] }, { "cell_type": "code", "execution_count": null, - "id": "20", + "id": "24", "metadata": {}, "outputs": [], "source": [ @@ -257,16 +313,19 @@ }, { "cell_type": "markdown", - "id": "21", + "id": "25", "metadata": {}, "source": [ - "Next, we need the TDC timestamps for the in-phase region:" + "### Inspecting TDC timestamps\n", + "\n", + "The top-dead-center (TDC) timestamps are created every time a marker placed on the chopper disk passes in front of a sensor mounted on the chopper module.\n", + "It is essentially reporting at what times the chopper completed a full rotation." ] }, { "cell_type": "code", "execution_count": null, - "id": "22", + "id": "26", "metadata": {}, "outputs": [], "source": [ @@ -274,158 +333,145 @@ "tdc" ] }, + { + "cell_type": "markdown", + "id": "27", + "metadata": {}, + "source": [ + "The initial speed-up and late slow-down are visible when simply plotting all the TDC timestamps:" + ] + }, { "cell_type": "code", "execution_count": null, - "id": "23", + "id": "28", "metadata": {}, "outputs": [], "source": [ - "low = frequency.coords['time'][0]\n", - "high = frequency.coords['time'][1]\n", - "tdc_in_phase = tdc[(tdc > low) & (tdc < high)]\n", - "tdc_in_phase" + "tdc.plot()" ] }, { "cell_type": "markdown", - "id": "24", + "id": "29", "metadata": {}, "source": [ - "We can check that the rate at which the TDC triggers is indeed close to 14Hz." + "We can filter out the TDCs for the time when the chopper was in-phase by using the time range of the plateau we extracted above:" ] }, { "cell_type": "code", "execution_count": null, - "id": "25", + "id": "30", "metadata": {}, "outputs": [], "source": [ - "diff = tdc_in_phase[1:] - tdc_in_phase[:-1]\n", - "rate = 1 / diff.to(unit='s', dtype='float64')\n", - "rate.min(), rate.max()" + "low = frequency.coords['time'][0]\n", + "high = frequency.coords['time'][1]\n", + "tdc_in_phase = tdc[(tdc > low) & (tdc < high)]\n", + "tdc_in_phase" ] }, { "cell_type": "markdown", - "id": "26", + "id": "31", "metadata": {}, "source": [ - "## Compute Chopper Phase\n", - "\n", - "`DiskChopper` does not use TDC directly for time calculations but instead the chopper phase $\\phi$.\n", - "According to the [disk chopper docs](../../generated/modules/scippneutron.chopper.disk_chopper.rst), the phase is defined as\n", - "$$\\phi = \\omega (t_0 + \\delta_t - T_0),$$\n", - "where $t_0$ is a TDC timestamp and $T_0$ a pulse time.\n", - "\n", - "We already determined the TDC timestamps above.\n", - "In practice, we would get $T_0$ from the input NeXus file, but here, we simply make one up:" + "We can check that the rate at which the TDC triggers is indeed close to 14Hz." ] }, { "cell_type": "code", "execution_count": null, - "id": "27", + "id": "32", "metadata": {}, "outputs": [], "source": [ - "pulse_time = sc.datetime('2023-01-19T08:12:03.442912915', unit='ns')" + "diff = tdc_in_phase[1:] - tdc_in_phase[:-1]\n", + "rate = 1 / diff.to(unit='s', dtype='float64')\n", + "rate.min(), rate.max()" ] }, { "cell_type": "markdown", - "id": "28", + "id": "33", "metadata": {}, "source": [ - "
\n", - "\n", - "**Note**\n", + "### Computing chopper phase\n", "\n", - "The pulse time is typically an array of timestamps and it can be difficult to determine which pulse goes with which chopper period.\n", - "While the choice is technically arbitrary, the times calculated by `DiskChopper` are relative to the chosen pulse time.\n", + "When constructing the `DiskChopper` at the start of this notebook,\n", + "the phase was derived from a single `delay` value and the rotation speed setpoint.\n", "\n", - "If the chopper rotates at the pulse frequency or an integer multiple of it, we can select any pulse time and TDC timestamp and simply use `phase = phase % (2 * sc.constants.pi)` below.\n", - "This corresponds to selecting the pulse and TDC times that are closest to each other.\n", + "It is however sometimes useful for debugging to compute the actual (time-dependent) phase of the chopper from the TDC information.\n", "\n", - "
\n", + "The phase can be defined as $\\phi = \\omega (TDC - T_0)$, where $\\omega$ is the angular frequency of the chopper, $TDC$ is a TDC timestamp, and $T_0$ a pulse time.\n", "\n", - "(We multiply by 1 rad to get the proper `rad*Hz` unit in `omega`.)" + "We already determined the TDC timestamps above.\n", + "In practice, we would get $T_0$ from the input NeXus file, but here, we simply make one up:" ] }, { "cell_type": "code", "execution_count": null, - "id": "29", + "id": "34", "metadata": {}, "outputs": [], "source": [ - "omega = 2 * sc.constants.pi * frequency.data * sc.scalar(1, unit='rad')\n", - "phase = omega * (tdc_in_phase[0] + chopper['delay'].data - pulse_time)\n", - "phase = phase.to(unit='rad')\n", - "phase" + "pulse_time = sc.datetime('2023-01-19T08:12:03.484012915', unit='ns')" ] }, { "cell_type": "markdown", - "id": "30", + "id": "35", "metadata": {}, "source": [ - "## Build `DiskChopper`\n", - "\n", - "Finally, we can assemble all data into a [scippneutron.chopper.DiskChopper](../../generated/modules/scippneutron.chopper.disk_chopper.DiskChopper.rst) object.\n", + "
\n", "\n", - "The rotation speed gets rounded (resulting in 14Hz) because `DiskChopper` requires it to be a near exact integer multiple of the pulse frequency or vice versa:\n", + "**Note**\n", "\n", - "- `rotation_speed = N * pulse_frequency`\n", - "- `rotation_speed = pulse_frequency / N`\n", + "The pulse time is typically an array of timestamps and it can be difficult to determine which pulse goes with which chopper period.\n", "\n", - "where `N` is an integer number." + "
" ] }, { "cell_type": "code", "execution_count": null, - "id": "31", + "id": "36", "metadata": {}, "outputs": [], "source": [ - "processed = chopper.copy()\n", - "processed['rotation_speed'] = sc.round(frequency.data)\n", - "processed['phase'] = phase" + "# We multiply by 1 rad to get the proper `rad*Hz` unit in `omega`\n", + "omega = 2 * sc.constants.pi * frequency.data * sc.scalar(1, unit='rad')\n", + "phase = omega * (tdc - pulse_time)\n", + "phase = phase.to(unit='deg') % sc.scalar(360.0, unit='deg')\n", + "\n", + "phase.plot()" ] }, { "cell_type": "markdown", - "id": "32", - "metadata": {}, - "source": [ - "The input data does not contain a beam position (the angle between the beam and TDC).\n", - "This probably means that it is 0.\n", - "But since `DiskChopper` does not make that assumption we have to be explicit:" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "33", + "id": "37", "metadata": {}, - "outputs": [], "source": [ - "processed['beam_position'] = sc.scalar(0.0, unit='rad')" + "We can clearly see the a central region where the phase is almost constant, and close to the value of 153.7° listed by the `DiskChopper` table at the top of the notebook\n", + "(= 2.683 rad).\n", + "\n", + "To each side of the central region are areas where the chopper is completely out of phase, during the spin-up and spin-down periods.\n", + "\n", + "We can take a closer look at the data in the 'in-phase' region where we see some jitter around the target value:" ] }, { "cell_type": "code", "execution_count": null, - "id": "34", + "id": "38", "metadata": {}, "outputs": [], "source": [ - "from scippneutron.chopper import DiskChopper\n", - "\n", - "disk_chopper = DiskChopper.from_nexus(processed)\n", - "disk_chopper" + "phase = omega * (tdc_in_phase - pulse_time)\n", + "phase = phase.to(unit='deg') % sc.scalar(360.0, unit='deg')\n", + "phase.plot()" ] } ], @@ -444,7 +490,8 @@ "mimetype": "text/x-python", "name": "python", "nbconvert_exporter": "python", - "pygments_lexer": "ipython3" + "pygments_lexer": "ipython3", + "version": "3.12.7" } }, "nbformat": 4, diff --git a/src/scippneutron/chopper/disk_chopper.py b/src/scippneutron/chopper/disk_chopper.py index c5d328eb6..a0a935fa3 100644 --- a/src/scippneutron/chopper/disk_chopper.py +++ b/src/scippneutron/chopper/disk_chopper.py @@ -353,7 +353,9 @@ def from_nexus( "cannot be computed because field 'delay' is also missing." ) omega = 2 * sc.constants.pi * frequency * sc.scalar(1.0, unit='rad') - phase = (chopper["delay"].to(dtype=float) * omega).to(unit='rad') + phase = (_get_0d_variable(chopper, 'delay').to(dtype=float) * omega).to( + unit='rad' + ) return DiskChopper( axle_position=chopper['position'], frequency=frequency, diff --git a/src/scippneutron/data/_chopper_mockup.py b/src/scippneutron/data/_chopper_mockup.py index a4cfa6891..ce5b18fd3 100644 --- a/src/scippneutron/data/_chopper_mockup.py +++ b/src/scippneutron/data/_chopper_mockup.py @@ -17,10 +17,11 @@ def chopper_mockup() -> sc.DataGroup: rotation_speed = _rotation_speed() return sc.DataGroup( { + 'beam_position': sc.scalar(0.0, unit='rad'), 'delay': sc.DataGroup( { 'value': sc.DataArray( - sc.array(dims=['time'], values=[3050], unit='ns'), + sc.array(dims=['time'], values=[int(3.05e7)], unit='ns'), coords={ 'time': sc.datetimes( dims=['time'], values=['2023-01-19T08:11:06'], unit='ns' @@ -36,6 +37,11 @@ def chopper_mockup() -> sc.DataGroup: 'value': rotation_speed, } ), + 'rotation_speed_setpoint': sc.DataGroup( + { + 'value': sc.scalar(14.0, unit='Hz'), + } + ), 'slit_height': sc.scalar(0.1, unit='m'), 'slit_edges': sc.array( dims=['slit'], values=[30.0, 160.0, 210.0, 280.0], unit='deg'