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 65df0a04d..a0a935fa3 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,30 @@ def from_nexus(
'Class DiskChopper only supports single choppers,'
f'got chopper type {typ}'
)
+
+ 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')
+ 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 = (_get_0d_variable(chopper, 'delay').to(dtype=float) * omega).to(
+ unit='rad'
+ )
return DiskChopper(
axle_position=chopper['position'],
- frequency=_get_1d_variable(chopper, 'rotation_speed'),
- beam_position=_get_1d_variable(chopper, 'beam_position'),
- phase=_get_1d_variable(chopper, 'phase'),
+ frequency=frequency,
+ beam_position=_get_0d_variable(chopper, 'beam_position'),
+ phase=phase,
slit_height=chopper.get('slit_height'),
radius=chopper.get('radius'),
**_get_edges_from_nexus(chopper),
@@ -557,6 +581,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):
@@ -627,13 +655,7 @@ 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(
+def _get_0d_variable(
dg: Mapping[str, sc.Variable | sc.DataArray], name: str
) -> sc.Variable:
if (val := dg.get(name)) is None:
@@ -642,9 +664,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:
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'
diff --git a/tests/chopper/disk_chopper_test.py b/tests/chopper/disk_chopper_test.py
index 2243b4ece..53b86cac3 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'))
@@ -945,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'),
+ }
+ )