diff --git a/src/dodal/beamlines/b07.py b/src/dodal/beamlines/b07.py index 87cdf54b7b..71009f82df 100644 --- a/src/dodal/beamlines/b07.py +++ b/src/dodal/beamlines/b07.py @@ -9,7 +9,7 @@ ) from dodal.devices.electron_analyser.base import EnergySource from dodal.devices.electron_analyser.specs import SpecsDetector -from dodal.devices.motors import XYZPolarStage +from dodal.devices.motors import XYZAzimuthStage from dodal.devices.pgm import PlaneGratingMonochromator from dodal.log import set_beamline as set_log_beamline from dodal.utils import BeamlinePrefix, get_beamline_name @@ -54,8 +54,8 @@ def sm52b() -> B07SampleManipulator52B: @devices.factory() -def sm21b() -> XYZPolarStage: - """Sample manipulator. NOTE: The polar attribute is equivalent to GDA roty.""" - return XYZPolarStage( - prefix=f"{B_PREFIX.beamline_prefix}-EA-SM-21:", polar_infix="ROTY" +def sm21b() -> XYZAzimuthStage: + """Sample manipulator. NOTE: The azimuth attribute is equivalent to GDA roty.""" + return XYZAzimuthStage( + prefix=f"{B_PREFIX.beamline_prefix}-EA-SM-21:", azimuth_infix="ROTY" ) diff --git a/src/dodal/beamlines/b07_1.py b/src/dodal/beamlines/b07_1.py index b2c03102f4..782e399bfa 100644 --- a/src/dodal/beamlines/b07_1.py +++ b/src/dodal/beamlines/b07_1.py @@ -9,7 +9,7 @@ ) from dodal.devices.electron_analyser.base import EnergySource from dodal.devices.electron_analyser.specs import SpecsDetector -from dodal.devices.motors import XYZPolarAzimuthStage +from dodal.devices.motors import XYZAzimuthPolarStage from dodal.devices.pgm import PlaneGratingMonochromator from dodal.log import set_beamline as set_log_beamline from dodal.utils import BeamlinePrefix, get_beamline_name @@ -54,13 +54,13 @@ def analyser(energy_source: EnergySource) -> SpecsDetector[LensMode, PsuMode]: @devices.factory() -def sm() -> XYZPolarAzimuthStage: +def sm() -> XYZAzimuthPolarStage: """Sample manipulator.""" - return XYZPolarAzimuthStage( + return XYZAzimuthPolarStage( f"{C_PREFIX.beamline_prefix}-EA-SM-01:", x_infix="XP", y_infix="YP", z_infix="ZP", - polar_infix="ROTA", azimuth_infix="ROTB", + polar_infix="ROTA", ) diff --git a/src/dodal/beamlines/i05_1.py b/src/dodal/beamlines/i05_1.py index 93842aaa5f..04a9e4297a 100644 --- a/src/dodal/beamlines/i05_1.py +++ b/src/dodal/beamlines/i05_1.py @@ -1,7 +1,7 @@ from dodal.beamlines.i05_shared import devices as i05_shared_devices from dodal.common.beamlines.beamline_utils import set_beamline as set_utils_beamline from dodal.device_manager import DeviceManager -from dodal.devices.beamlines.i05_1 import XYZPolarAzimuthDefocusStage +from dodal.devices.beamlines.i05_1 import XYZAzimuthPolarDefocusStage from dodal.log import set_beamline as set_log_beamline from dodal.utils import BeamlinePrefix, get_beamline_name @@ -15,6 +15,6 @@ @devices.factory -def sm() -> XYZPolarAzimuthDefocusStage: +def sm() -> XYZAzimuthPolarDefocusStage: """Sample Manipulator.""" - return XYZPolarAzimuthDefocusStage(prefix=f"{PREFIX.beamline_prefix}-EA-SM-01:") + return XYZAzimuthPolarDefocusStage(prefix=f"{PREFIX.beamline_prefix}-EA-SM-01:") diff --git a/src/dodal/beamlines/i09.py b/src/dodal/beamlines/i09.py index 253c5e62c2..5cb3ebacf9 100644 --- a/src/dodal/beamlines/i09.py +++ b/src/dodal/beamlines/i09.py @@ -4,12 +4,12 @@ from dodal.beamlines.i09_2_shared import devices as i09_2_shared_devices from dodal.common.beamlines.beamline_utils import set_beamline as set_utils_beamline from dodal.device_manager import DeviceManager -from dodal.devices.beamlines.i09.enums import LensMode, PassEnergy, PsuMode +from dodal.devices.beamlines.i09 import LensMode, PassEnergy, PsuMode from dodal.devices.common_dcm import DoubleCrystalMonochromatorWithDSpacing from dodal.devices.electron_analyser.base import DualEnergySource from dodal.devices.electron_analyser.vgscienta import VGScientaDetector from dodal.devices.fast_shutter import DualFastShutter, GenericFastShutter -from dodal.devices.motors import XYZPolarAzimuthStage +from dodal.devices.motors import XYZAzimuthPolarStage from dodal.devices.pgm import PlaneGratingMonochromator from dodal.devices.selectable_source import SourceSelector from dodal.devices.synchrotron import Synchrotron @@ -101,6 +101,6 @@ def lakeshore() -> Lakeshore336: @devices.factory() -def smpm() -> XYZPolarAzimuthStage: +def smpm() -> XYZAzimuthPolarStage: """Sample Manipulator.""" - return XYZPolarAzimuthStage(prefix=f"{I_PREFIX.beamline_prefix}-MO-SMPM-01:") + return XYZAzimuthPolarStage(prefix=f"{I_PREFIX.beamline_prefix}-MO-SMPM-01:") diff --git a/src/dodal/beamlines/i09_1.py b/src/dodal/beamlines/i09_1.py index 769909f5ee..bb92effeec 100644 --- a/src/dodal/beamlines/i09_1.py +++ b/src/dodal/beamlines/i09_1.py @@ -5,7 +5,7 @@ from dodal.devices.common_dcm import DoubleCrystalMonochromatorWithDSpacing from dodal.devices.electron_analyser.base import EnergySource from dodal.devices.electron_analyser.specs import SpecsDetector -from dodal.devices.motors import XYZPolarAzimuthTiltStage +from dodal.devices.motors import XYZAzimuthTiltPolarStage from dodal.devices.synchrotron import Synchrotron from dodal.devices.temperture_controller import Lakeshore336 from dodal.log import set_beamline as set_log_beamline @@ -48,6 +48,6 @@ def lakeshore() -> Lakeshore336: @devices.factory() -def hsmpm() -> XYZPolarAzimuthTiltStage: +def hsmpm() -> XYZAzimuthTiltPolarStage: """Sample Manipulator.""" - return XYZPolarAzimuthTiltStage(prefix=f"{PREFIX.beamline_prefix}-MO-HSMPM-01:") + return XYZAzimuthTiltPolarStage(prefix=f"{PREFIX.beamline_prefix}-MO-HSMPM-01:") diff --git a/src/dodal/beamlines/i21.py b/src/dodal/beamlines/i21.py index 70e6fd10f8..7e1c17358c 100644 --- a/src/dodal/beamlines/i21.py +++ b/src/dodal/beamlines/i21.py @@ -4,7 +4,11 @@ from dodal.common.beamlines.beamline_utils import set_beamline as set_utils_beamline from dodal.device_manager import DeviceManager -from dodal.devices.beamlines.i21 import Grating +from dodal.devices.beamlines.i21 import ( + Grating, + I21SampleManipulatorStage, + ToolPointMotion, +) from dodal.devices.insertion_device import ( Apple2, Apple2EnforceLHMoveController, @@ -129,3 +133,13 @@ def energy( @devices.factory() def sample_temperature_controller() -> Lakeshore336: return Lakeshore336(prefix=f"{PREFIX.beamline_prefix}-EA-TCTRL-01:") + + +@devices.factory() +def smp() -> I21SampleManipulatorStage: + return I21SampleManipulatorStage(prefix=f"{PREFIX.beamline_prefix}-EA-SMPL-01:") + + +@devices.factory() +def uvw(smp: I21SampleManipulatorStage) -> ToolPointMotion: + return ToolPointMotion(smp) diff --git a/src/dodal/devices/beamlines/i05/i05_motors.py b/src/dodal/devices/beamlines/i05/i05_motors.py index a207e38755..53c0f56c99 100644 --- a/src/dodal/devices/beamlines/i05/i05_motors.py +++ b/src/dodal/devices/beamlines/i05/i05_motors.py @@ -1,6 +1,3 @@ -from dodal.devices.beamlines.i05_shared.rotation_signals import ( - create_rotational_ij_component_signals, -) from dodal.devices.motors import ( _AZIMUTH, _POLAR, @@ -8,13 +5,14 @@ _X, _Y, _Z, - XYZPolarAzimuthTiltStage, + XYZAzimuthTiltPolarStage, + create_rotational_ij_component_signals, ) -class I05Goniometer(XYZPolarAzimuthTiltStage): +class I05Goniometer(XYZAzimuthTiltPolarStage): """Six-physical-axis stage with a standard xyz translational stage and three axis of - rotation: polar, azimuth, and tilt. + rotation: azimuth, tilt and polar. In addition, it defines two virtual translational axes derived signals, `perp` and `long`, which form a rotated Cartesian frame within the x-y plane. @@ -36,9 +34,9 @@ def __init__( x_infix: str = _X, y_infix: str = _Y, z_infix: str = _Z, - polar_infix: str = _POLAR, azimuth_infix: str = _AZIMUTH, tilt_infix: str = _TILT, + polar_infix: str = _POLAR, rotation_angle_deg: float = 50.0, name: str = "", ): @@ -50,9 +48,9 @@ def __init__( x_infix=x_infix, y_infix=y_infix, z_infix=z_infix, - polar_infix=polar_infix, azimuth_infix=azimuth_infix, tilt_infix=tilt_infix, + polar_infix=polar_infix, ) with self.add_children_as_readables(): diff --git a/src/dodal/devices/beamlines/i05_1/__init__.py b/src/dodal/devices/beamlines/i05_1/__init__.py index 5cb81dab9d..27d4907b30 100644 --- a/src/dodal/devices/beamlines/i05_1/__init__.py +++ b/src/dodal/devices/beamlines/i05_1/__init__.py @@ -1,3 +1,3 @@ -from .i05_1_motors import XYZPolarAzimuthDefocusStage +from .i05_1_motors import XYZAzimuthPolarDefocusStage -__all__ = ["XYZPolarAzimuthDefocusStage"] +__all__ = ["XYZAzimuthPolarDefocusStage"] diff --git a/src/dodal/devices/beamlines/i05_1/i05_1_motors.py b/src/dodal/devices/beamlines/i05_1/i05_1_motors.py index 23c497e00f..a2b628636f 100644 --- a/src/dodal/devices/beamlines/i05_1/i05_1_motors.py +++ b/src/dodal/devices/beamlines/i05_1/i05_1_motors.py @@ -1,14 +1,14 @@ from ophyd_async.epics.motor import Motor -from dodal.devices.beamlines.i05_shared.rotation_signals import ( +from dodal.devices.motors import ( + XYZAzimuthPolarStage, create_rotational_ij_component_signals, ) -from dodal.devices.motors import XYZPolarAzimuthStage -class XYZPolarAzimuthDefocusStage(XYZPolarAzimuthStage): - """Six-physical-axis stage with a standard xyz stage, 2 axis of rotation: polar, - azimuth and one extra tranlastional axis defocus. +class XYZAzimuthPolarDefocusStage(XYZAzimuthPolarStage): + """Six-physical-axis stage with a standard xyz stage, 2 axis of rotation: azimuth, + polar and one extra tranlastional axis defocus. This device exposes four virtual translational axes that are defined in frames of reference attached to the sample: @@ -40,8 +40,8 @@ def __init__( x_infix: str = "SMX", y_infix: str = "SMY", z_infix: str = "SMZ", - polar_infix: str = "POL", azimuth_infix: str = "AZM", + polar_infix: str = "POL", defocus_infix: str = "SMDF", name: str = "", ): @@ -51,8 +51,8 @@ def __init__( x_infix=x_infix, y_infix=y_infix, z_infix=z_infix, - polar_infix=polar_infix, azimuth_infix=azimuth_infix, + polar_infix=polar_infix, ) with self.add_children_as_readables(): diff --git a/src/dodal/devices/beamlines/i21/__init__.py b/src/dodal/devices/beamlines/i21/__init__.py index 08df37b271..86cf42c720 100644 --- a/src/dodal/devices/beamlines/i21/__init__.py +++ b/src/dodal/devices/beamlines/i21/__init__.py @@ -1,3 +1,11 @@ from .enums import Grating +from .i21_motors import I21SampleManipulatorStage +from .toolpoint_motion import ToolPointMotion, UVWMotorPositions, XYZMotorPositions -__all__ = ["Grating"] +__all__ = [ + "Grating", + "I21SampleManipulatorStage", + "ToolPointMotion", + "UVWMotorPositions", + "XYZMotorPositions", +] diff --git a/src/dodal/devices/beamlines/i21/i21_motors.py b/src/dodal/devices/beamlines/i21/i21_motors.py new file mode 100644 index 0000000000..4e39477139 --- /dev/null +++ b/src/dodal/devices/beamlines/i21/i21_motors.py @@ -0,0 +1,75 @@ +from ophyd_async.epics.motor import Motor + +from dodal.devices.motors import ( + _AZIMUTH, + _TILT, + _X, + _Y, + _Z, + XYZAzimuthTiltPolarStage, + create_rotational_ij_component_signals, +) + + +class I21SampleManipulatorStage(XYZAzimuthTiltPolarStage): + """Seven-axis stage with a standard xyz stage and four axis of rotation: azimuth, + tilt, polar, and difftth (diffraction diode rotation). It also exposes two + virtual translational axes that are defined in frames of reference attached to the + sample. + + - `para` and `perp`: + Parallel and perpendicular translation axes in the sample frame. + These axes are derived from the lab-frame x and y motors and rotate + with the polar angle, so that motion along `para` and `perp` + remains aligned with the sample regardless of its polar rotation. + + Both virtual axes behave as ordinary orthogonal Cartesian translations + from the user's point of view, while internally coordinating motion of the + underlying motors to account for the current rotation angles. + + This allows users to position the sample in physically meaningful, sample-aligned + coordinates without needing to manually compensate for polar rotations. + + Note: I21 currently uses the following PV to variable + - PV AZIMUTH but calls the variable phi. + - PV TILT but calls variable chi. + - PV RZ but calls it th (theta). + + Inheriting from standard motor class until decided if i21 uses standard name + convention or need to update variables for this class. + """ + + def __init__( + self, + prefix: str, + name: str = "", + x_infix: str = _X, + y_infix: str = _Y, + z_infix: str = _Z, + azimuth_infix: str = _AZIMUTH, + tilt_infix: str = _TILT, + polar_infix: str = "RZ", + difftth_infix: str = "DRING", + ): + super().__init__( + prefix=prefix, + name=name, + x_infix=x_infix, + y_infix=y_infix, + z_infix=z_infix, + azimuth_infix=azimuth_infix, + tilt_infix=tilt_infix, + polar_infix=polar_infix, + ) + with self.add_children_as_readables(): + # Diffraction diode rotation. + self.difftth = Motor(prefix + difftth_infix) + + self.para, self.perp = create_rotational_ij_component_signals( + i_read=self.x.user_readback, + j_read=self.y.user_readback, + i_write=self.x, + j_write=self.y, + angle_deg=self.polar.user_readback, + clockwise_frame=False, + ) diff --git a/src/dodal/devices/beamlines/i21/toolpoint_motion.py b/src/dodal/devices/beamlines/i21/toolpoint_motion.py new file mode 100644 index 0000000000..79c462ba86 --- /dev/null +++ b/src/dodal/devices/beamlines/i21/toolpoint_motion.py @@ -0,0 +1,218 @@ +import asyncio +from dataclasses import dataclass +from math import cos, radians, sin + +import numpy as np +from bluesky.protocols import Movable +from ophyd_async.core import ( + AsyncStatus, + Reference, + SignalRW, + StandardReadable, + derived_signal_rw, +) + +from dodal.devices.motors import XYZAzimuthTiltStage + +DEFAULT_AXES_ZERO = (5.667, 0.2408, 1.221) + + +def toolpoint_rotation_matrix(tilt_deg: float, azimuth_deg: float) -> np.ndarray: + tilt = radians(tilt_deg) + azimuth = radians(azimuth_deg) + + return np.array( + [ + [cos(tilt), sin(tilt) * sin(azimuth), sin(tilt) * cos(azimuth)], + [0.0, cos(azimuth), -sin(azimuth)], + [-sin(tilt), cos(tilt) * sin(azimuth), cos(tilt) * cos(azimuth)], + ] + ) + + +@dataclass(kw_only=True) +class AngleMotorPositions: + tilt_deg: float + azimuth_deg: float + + +@dataclass(kw_only=True) +class UVWMotorPositions(AngleMotorPositions): + u: float + v: float + w: float + + +@dataclass(kw_only=True) +class XYZMotorPositions(AngleMotorPositions): + x: float + y: float + z: float + + +def uvw_to_xyz( + pos: UVWMotorPositions, + zero: tuple[float, float, float], +) -> XYZMotorPositions: + rotation = toolpoint_rotation_matrix(pos.tilt_deg, pos.azimuth_deg) + uvw = np.array([pos.u, pos.v, pos.w]) + offset = np.array(zero) + xyz = offset + rotation @ uvw + x, y, z = xyz + return XYZMotorPositions( + x=x, y=y, z=z, tilt_deg=pos.tilt_deg, azimuth_deg=pos.azimuth_deg + ) + + +def xyz_to_uvw( + pos: XYZMotorPositions, + zero: tuple[float, float, float], +) -> UVWMotorPositions: + rotation = toolpoint_rotation_matrix(pos.tilt_deg, pos.azimuth_deg) + xyz = np.array([pos.x, pos.y, pos.z]) + offset = np.array(zero) + + tool = rotation.T @ (xyz - offset) + u, v, w = tool + return UVWMotorPositions( + u=u, v=v, w=w, tilt_deg=pos.tilt_deg, azimuth_deg=pos.azimuth_deg + ) + + +class ToolPointMotion(StandardReadable, Movable): + """Virtual manipulator translations of the sample stage. It is mounted on top + of the diffractometer and circles tilt and azimuth angles. It defines three virtual + axes u, v, and w as signals. + """ + + def __init__( + self, + smp: XYZAzimuthTiltStage, + zero: tuple[float, float, float] = DEFAULT_AXES_ZERO, + name: str = "", + ): + self.smp_ref = Reference(smp) + self._zero = zero + + with self.add_children_as_readables(): + self.u, self.v, self.w = self._create_uvws() + + self.add_readables([smp]) + + super().__init__(name=name) + + async def check_motor_limits( + self, start: XYZMotorPositions, end: XYZMotorPositions + ) -> None: + await asyncio.gather( + self.smp_ref().x.check_motor_limit(start.x, end.x), + self.smp_ref().y.check_motor_limit(start.y, end.y), + self.smp_ref().z.check_motor_limit(start.z, end.z), + self.smp_ref().tilt.check_motor_limit(start.tilt_deg, end.tilt_deg), + self.smp_ref().azimuth.check_motor_limit( + start.azimuth_deg, end.azimuth_deg + ), + ) + + async def _get_xyz_motor_positions( + self, + ) -> XYZMotorPositions: + x, y, z, tilt, azimuth = await asyncio.gather( + self.smp_ref().x.user_readback.get_value(), + self.smp_ref().y.user_readback.get_value(), + self.smp_ref().z.user_readback.get_value(), + self.smp_ref().tilt.user_readback.get_value(), + self.smp_ref().azimuth.user_readback.get_value(), + ) + return XYZMotorPositions(x=x, y=y, z=z, tilt_deg=tilt, azimuth_deg=azimuth) + + async def _read_all_uvw(self) -> UVWMotorPositions: + xyz_pos = await self._get_xyz_motor_positions() + uvw_pos = xyz_to_uvw(xyz_pos, zero=self._zero) + return uvw_pos + + async def _write_all_uvw(self, uvw_pos: UVWMotorPositions) -> None: + xyz_start = await self._get_xyz_motor_positions() + xyz_end = uvw_to_xyz(uvw_pos, self._zero) + + await self.check_motor_limits(xyz_start, xyz_end) + await asyncio.gather( + self.smp_ref().x.set(xyz_end.x), + self.smp_ref().y.set(xyz_end.y), + self.smp_ref().z.set(xyz_end.z), + self.smp_ref().tilt.set(xyz_end.tilt_deg), + self.smp_ref().azimuth.set(xyz_end.azimuth_deg), + ) + + def _create_uvws(self) -> tuple[SignalRW[float], SignalRW[float], SignalRW[float]]: + def read_u( + x: float, y: float, z: float, tilt_deg: float, azimuth_deg: float + ) -> float: + pos = XYZMotorPositions( + x=x, y=y, z=z, tilt_deg=tilt_deg, azimuth_deg=azimuth_deg + ) + return float(xyz_to_uvw(pos, self._zero).u) + + async def set_u(value: float) -> None: + uvw_pos = await self._read_all_uvw() + uvw_pos.u = value + await self._write_all_uvw(uvw_pos) + + def read_v( + x: float, y: float, z: float, tilt_deg: float, azimuth_deg: float + ) -> float: + pos = XYZMotorPositions( + x=x, y=y, z=z, tilt_deg=tilt_deg, azimuth_deg=azimuth_deg + ) + return float(xyz_to_uvw(pos, self._zero).v) + + async def set_v(value: float) -> None: + uvw_pos = await self._read_all_uvw() + uvw_pos.v = value + await self._write_all_uvw(uvw_pos) + + def read_w( + x: float, y: float, z: float, tilt_deg: float, azimuth_deg: float + ) -> float: + pos = XYZMotorPositions( + x=x, y=y, z=z, tilt_deg=tilt_deg, azimuth_deg=azimuth_deg + ) + return float(xyz_to_uvw(pos, self._zero).w) + + async def set_w(value: float) -> None: + uvw_pos = await self._read_all_uvw() + uvw_pos.w = value + await self._write_all_uvw(uvw_pos) + + u = derived_signal_rw( + read_u, + set_u, + x=self.smp_ref().x, + y=self.smp_ref().y, + z=self.smp_ref().z, + tilt_deg=self.smp_ref().tilt, + azimuth_deg=self.smp_ref().azimuth, + ) + v = derived_signal_rw( + read_v, + set_v, + x=self.smp_ref().x, + y=self.smp_ref().y, + z=self.smp_ref().z, + tilt_deg=self.smp_ref().tilt, + azimuth_deg=self.smp_ref().azimuth, + ) + w = derived_signal_rw( + read_w, + set_w, + x=self.smp_ref().x, + y=self.smp_ref().y, + z=self.smp_ref().z, + tilt_deg=self.smp_ref().tilt, + azimuth_deg=self.smp_ref().azimuth, + ) + return u, v, w + + @AsyncStatus.wrap + async def set(self, value: UVWMotorPositions): + await self._write_all_uvw(value) diff --git a/src/dodal/devices/motors.py b/src/dodal/devices/motors.py index fc84bcc5a2..10c3d36387 100644 --- a/src/dodal/devices/motors.py +++ b/src/dodal/devices/motors.py @@ -1,8 +1,16 @@ import asyncio import math from abc import ABC - -from ophyd_async.core import SignalRW, StandardReadable, derived_signal_rw +from math import radians + +from bluesky.protocols import Movable +from bluesky.utils import maybe_await +from ophyd_async.core import ( + SignalR, + SignalRW, + StandardReadable, + derived_signal_rw, +) from ophyd_async.epics.motor import Motor from dodal.common.maths import rotate_clockwise, rotate_counter_clockwise @@ -106,8 +114,8 @@ def __init__( super().__init__(prefix, name, x_infix, y_infix, z_infix) -class XYZPolarStage(XYZStage): - """Four-axis stage with a standard xyz stage and one axis of rotation: polar.""" +class XYZAzimuthStage(XYZStage): + """Four-axis stage with a standard xyz stage and one axis of rotation: azimuth.""" def __init__( self, @@ -116,16 +124,16 @@ def __init__( x_infix: str = _X, y_infix: str = _Y, z_infix: str = _Z, - polar_infix: str = _POLAR, + azimuth_infix: str = _AZIMUTH, ) -> None: with self.add_children_as_readables(): - self.polar = Motor(prefix + polar_infix) + self.azimuth = Motor(prefix + azimuth_infix) super().__init__(prefix, name, x_infix, y_infix, z_infix) -class XYZPolarAzimuthStage(XYZPolarStage): - """Five-axis stage with a standard xyz stage and two axis of rotation: polar and - azimuth. +class XYZAzimuthPolarStage(XYZAzimuthStage): + """Four-axis stage with a standard xyz stage and two axis of rotation: azimuth + and polar. """ def __init__( @@ -135,17 +143,37 @@ def __init__( x_infix: str = _X, y_infix: str = _Y, z_infix: str = _Z, + azimuth_infix: str = _AZIMUTH, polar_infix: str = _POLAR, + ) -> None: + with self.add_children_as_readables(): + self.polar = Motor(prefix + polar_infix) + super().__init__(prefix, name, x_infix, y_infix, z_infix, azimuth_infix) + + +class XYZAzimuthTiltStage(XYZAzimuthStage): + """Five-axis stage with a standard xyz stage and two axis of rotation: azimuth and + tilt. + """ + + def __init__( + self, + prefix: str, + name: str = "", + x_infix: str = _X, + y_infix: str = _Y, + z_infix: str = _Z, azimuth_infix: str = _AZIMUTH, + tilt_infix: str = _TILT, ): with self.add_children_as_readables(): - self.azimuth = Motor(prefix + azimuth_infix) - super().__init__(prefix, name, x_infix, y_infix, z_infix, polar_infix) + self.tilt = Motor(prefix + tilt_infix) + super().__init__(prefix, name, x_infix, y_infix, z_infix, azimuth_infix) -class XYZPolarAzimuthTiltStage(XYZPolarAzimuthStage): - """Six-axis stage with a standard xyz stage and three axis of rotation: polar, - azimuth and tilt. +class XYZAzimuthTiltPolarStage(XYZAzimuthTiltStage): + """Six-axis stage with a standard xyz stage and three axis of rotation: azimuth, + tilt and polar. """ def __init__( @@ -155,14 +183,14 @@ def __init__( x_infix: str = _X, y_infix: str = _Y, z_infix: str = _Z, - polar_infix: str = _POLAR, azimuth_infix: str = _AZIMUTH, tilt_infix: str = _TILT, + polar_infix: str = _POLAR, ): with self.add_children_as_readables(): - self.tilt = Motor(prefix + tilt_infix) + self.polar = Motor(prefix + polar_infix) super().__init__( - prefix, name, x_infix, y_infix, z_infix, polar_infix, azimuth_infix + prefix, name, x_infix, y_infix, z_infix, azimuth_infix, tilt_infix ) @@ -362,3 +390,81 @@ async def _set(vertical_value: float) -> None: j_val=motor_j, rot_deg_value=motor_theta, ) + + +async def _get_angle_deg(angle_deg: SignalR[float] | float) -> float: + if isinstance(angle_deg, SignalR): + return await angle_deg.get_value() + return angle_deg + + +def create_rotational_ij_component_signals( + i_read: SignalR[float], + j_read: SignalR[float], + i_write: Movable[float], + j_write: Movable[float], + angle_deg: float | SignalR[float], + clockwise_frame: bool = True, +) -> tuple[SignalRW[float], SignalRW[float]]: + """Create virtual i/j signals representing a Cartesian coordinate frame + that is rotated by a given angle relative to the underlying equipment axes. + + The returned signals expose the position of the system in a *rotated frame + of reference* (e.g. the sample or stage frame), while transparently mapping + reads and writes onto the real i/j signals in the fixed equipment (lab) frame. + + From the user's point of view, i and j behave like ordinary orthogonal + Cartesian axes attached to the rotating object. Internally, all reads apply + a rotation to the real motor positions, and all writes apply the inverse + rotation so that the requested motion is achieved in the rotated frame. + + Args: + i_read (SignalR[float]): SignalR representing the i motor readback. + j_read (SignalR[float]): representing the j motor readback. + i_write (Movable[float]): object for setting the i position. + j_write (Movable[float]): object for setting the j position. + angle_deg (float | SignalR[float]): Rotation angle in degrees. + clockwise_frame (boolean, optional): If True, the rotated frame is using a + clockwise rotation; otherwise, a counter-clockwise rotation is used. Default + is True. + + Returns: + tuple[SignalRW[float], SignalRW[float]] Two virtual read/write signals + corresponding to the rotated i and j components. + """ + rotate = rotate_clockwise if clockwise_frame else rotate_counter_clockwise + inverse_rotate = rotate_counter_clockwise if clockwise_frame else rotate_clockwise + + async def _read_rotated() -> tuple[float, float, float]: + i, j, ang = await asyncio.gather( + i_read.get_value(), + j_read.get_value(), + _get_angle_deg(angle_deg), + ) + return (*rotate(radians(ang), i, j), ang) + + async def _write_rotated(i_rot: float, j_rot: float, ang: float) -> None: + i_new, j_new = inverse_rotate(radians(ang), i_rot, j_rot) + await asyncio.gather( + maybe_await(i_write.set(i_new)), + maybe_await(j_write.set(j_new)), + ) + + def _read_i(i: float, j: float, ang: float) -> float: + return rotate(radians(ang), i, j)[0] + + async def _set_i(value: float) -> None: + i_rot, j_rot, ang = await _read_rotated() + await _write_rotated(value, j_rot, ang) + + def _read_j(i: float, j: float, ang: float) -> float: + return rotate(radians(ang), i, j)[1] + + async def _set_j(value: float) -> None: + i_rot, j_rot, ang = await _read_rotated() + await _write_rotated(i_rot, value, ang) + + return ( + derived_signal_rw(_read_i, _set_i, i=i_read, j=j_read, ang=angle_deg), + derived_signal_rw(_read_j, _set_j, i=i_read, j=j_read, ang=angle_deg), + ) diff --git a/tests/devices/beamlines/i05_1/test_i05_1_motors.py b/tests/devices/beamlines/i05_1/test_i05_1_motors.py index 6978e2e9c5..a07a3381d4 100644 --- a/tests/devices/beamlines/i05_1/test_i05_1_motors.py +++ b/tests/devices/beamlines/i05_1/test_i05_1_motors.py @@ -5,7 +5,7 @@ from ophyd_async.core import init_devices from ophyd_async.testing import assert_reading, partial_reading -from dodal.devices.beamlines.i05_1 import XYZPolarAzimuthDefocusStage +from dodal.devices.beamlines.i05_1 import XYZAzimuthPolarDefocusStage from tests.devices.beamlines.i05_shared.rotation_signal_test_util import ( RotatedCartesianFrameTestConfig, assert_rotated_axes_are_orthogonal, @@ -13,9 +13,9 @@ @pytest.fixture -def sm() -> XYZPolarAzimuthDefocusStage: +def sm() -> XYZAzimuthPolarDefocusStage: with init_devices(mock=True): - sm = XYZPolarAzimuthDefocusStage("TEST:") + sm = XYZAzimuthPolarDefocusStage("TEST:") return sm @@ -35,7 +35,7 @@ def expected_perp_read(z: float, hor: float, polar_theta: float) -> float: return z * sin(polar_theta) + hor * cos(polar_theta) -async def test_xyzpad_stage_read(sm: XYZPolarAzimuthDefocusStage) -> None: +async def test_xyzpad_stage_read(sm: XYZAzimuthPolarDefocusStage) -> None: x, y = 10, 5 azimuth_angle_deg = 45 azimuth_theta = radians(azimuth_angle_deg) @@ -73,7 +73,7 @@ async def test_xyzpad_stage_read(sm: XYZPolarAzimuthDefocusStage) -> None: async def test_xyzpad_hor_and_vert_set( - sm: XYZPolarAzimuthDefocusStage, + sm: XYZAzimuthPolarDefocusStage, ) -> None: frame_config = RotatedCartesianFrameTestConfig( i_read=sm.x.user_readback, @@ -97,7 +97,7 @@ async def test_xyzpad_hor_and_vert_set( async def test_xyzpad_long_and_perp_set( - sm: XYZPolarAzimuthDefocusStage, + sm: XYZAzimuthPolarDefocusStage, ) -> None: frame_config = RotatedCartesianFrameTestConfig( i_read=sm.z.user_readback, diff --git a/tests/devices/beamlines/i21/__init__.py b/tests/devices/beamlines/i21/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/tests/devices/beamlines/i21/test_i21_motors.py b/tests/devices/beamlines/i21/test_i21_motors.py new file mode 100644 index 0000000000..e87d00b6fb --- /dev/null +++ b/tests/devices/beamlines/i21/test_i21_motors.py @@ -0,0 +1,15 @@ +import pytest +from ophyd_async.core import init_devices + +from dodal.devices.beamlines.i21 import I21SampleManipulatorStage + + +@pytest.fixture +def smp() -> I21SampleManipulatorStage: + with init_devices(mock=True): + smp = I21SampleManipulatorStage("TEST:") + return smp + + +def test_smp_read(smp: I21SampleManipulatorStage) -> None: + pass diff --git a/tests/devices/beamlines/i21/test_toolpoint_motion.py b/tests/devices/beamlines/i21/test_toolpoint_motion.py new file mode 100644 index 0000000000..3dbeaf985f --- /dev/null +++ b/tests/devices/beamlines/i21/test_toolpoint_motion.py @@ -0,0 +1,246 @@ +import asyncio +from dataclasses import replace +from math import cos, radians, sin +from unittest.mock import AsyncMock + +import numpy as np +import pytest +from ophyd_async.core import init_devices, set_mock_value +from ophyd_async.epics.motor import MotorLimitsError +from ophyd_async.testing import assert_reading, partial_reading + +from dodal.devices.beamlines.i21 import ( + I21SampleManipulatorStage, + ToolPointMotion, + UVWMotorPositions, + XYZMotorPositions, +) +from dodal.devices.beamlines.i21.toolpoint_motion import ( + DEFAULT_AXES_ZERO, + uvw_to_xyz, + xyz_to_uvw, +) + + +@pytest.fixture +def smp() -> I21SampleManipulatorStage: + with init_devices(mock=True): + smp = I21SampleManipulatorStage("TEST:") + return smp + + +@pytest.fixture +def uvw(smp: I21SampleManipulatorStage) -> ToolPointMotion: + with init_devices(mock=True): + uvw = ToolPointMotion(smp) + return uvw + + +def expected_uvw_read( + pos: XYZMotorPositions, zero: tuple[float, float, float] +) -> tuple[float, float, float]: + dx, dy, dz = pos.x - zero[0], pos.y - zero[1], pos.z - zero[2] + azimuth, tilt = radians(pos.azimuth_deg), radians(pos.tilt_deg) + expected_v = ( + dx * sin(tilt) * sin(azimuth) + + dy * cos(azimuth) + + dz * cos(tilt) * sin(azimuth) + ) + expected_v = ( + dx * sin(tilt) * sin(azimuth) + + dy * cos(azimuth) + + dz * cos(tilt) * sin(azimuth) + ) + expected_w = ( + dx * cos(azimuth) * sin(tilt) + - dy * sin(azimuth) + + dz * cos(tilt) * cos(azimuth) + ) + return expected_v, expected_v, expected_w + + +def test_uvw_to_xyz_math_matches_legacy(): + zero = DEFAULT_AXES_ZERO + u, v, w = 1.2, -3.4, 5.6 + tilt, azimuth = 30.0, 10.0 + + uvw = UVWMotorPositions(u=u, v=v, w=w, tilt_deg=tilt, azimuth_deg=azimuth) + + expected_xyz = uvw_to_xyz(uvw, zero) + + chi = radians(tilt) + phi = radians(azimuth) + + x_legacy = ( + zero[0] + u * cos(chi) + v * sin(chi) * sin(phi) + w * cos(phi) * sin(chi) + ) + y_legacy = zero[1] + v * cos(phi) - w * sin(phi) + z_legacy = ( + zero[2] - u * sin(chi) + v * cos(chi) * sin(phi) + w * cos(chi) * cos(phi) + ) + assert np.allclose( + [x_legacy, y_legacy, z_legacy], [expected_xyz.x, expected_xyz.y, expected_xyz.z] + ) + + +def test_xyz_to_uvw_math_matches_legacy(): + zero = DEFAULT_AXES_ZERO + + x, y, z = 12.3, -4.5, 6.7 + tilt, azimuth = 30.0, 10.0 + xyz = XYZMotorPositions(x=x, y=y, z=z, tilt_deg=tilt, azimuth_deg=azimuth) + + expected_uvw = xyz_to_uvw(xyz, zero) + + chi = radians(tilt) + phi = radians(azimuth) + + dx = x - zero[0] + dy = y - zero[1] + dz = z - zero[2] + + u_legacy = dx * cos(chi) - dz * sin(chi) + v_legacy = dx * sin(chi) * sin(phi) + dy * cos(phi) + dz * cos(chi) * sin(phi) + w_legacy = dx * cos(phi) * sin(chi) - dy * sin(phi) + dz * cos(chi) * cos(phi) + + assert np.allclose( + [u_legacy, v_legacy, w_legacy], [expected_uvw.u, expected_uvw.v, expected_uvw.w] + ) + + +async def test_uvw_read(uvw: ToolPointMotion) -> None: + x, y, z = 5.0, 10.0, 15.0 + azimuth_deg, tilt_deg = 10.0, 30.0 + + smp = uvw.smp_ref() + await asyncio.gather( + smp.x.set(x), + smp.y.set(y), + smp.z.set(z), + smp.azimuth.set(azimuth_deg), + smp.tilt.set(tilt_deg), + ) + xyz_pos = XYZMotorPositions( + x=x, y=y, z=z, tilt_deg=tilt_deg, azimuth_deg=azimuth_deg + ) + expected_uvw = xyz_to_uvw(xyz_pos, zero=uvw._zero) + + smp_read = await smp.read() + await assert_reading( + uvw, + { + uvw.u.name: partial_reading(expected_uvw.u), + uvw.v.name: partial_reading(expected_uvw.v), + uvw.w.name: partial_reading(expected_uvw.w), + } + | smp_read, + ) + + +@pytest.mark.parametrize( + "axis_name,new_value", + [ + ("u", 50.0), + ("v", 40.0), + ("w", 25.0), + ], +) +async def test_uvw_axis_set( + uvw: ToolPointMotion, + axis_name: str, + new_value: float, +) -> None: + azimuth_deg, tilt_deg = 10.0, 30.0 + smp = uvw.smp_ref() + + await asyncio.gather( + smp.azimuth.set(azimuth_deg), + smp.tilt.set(tilt_deg), + ) + + # Read initial toolpoint position + uvw_pos0 = await uvw._read_all_uvw() + + # Set the selected axis + axis = getattr(uvw, axis_name) + await axis.set(new_value) + + # Build expected toolpoint values + uvw_pos = replace(uvw_pos0, **{axis_name: new_value}) + + expected_xyz = uvw_to_xyz(uvw_pos, uvw._zero) + + actual_x, actual_y, actual_z = await asyncio.gather( + smp.x.user_readback.get_value(), + smp.y.user_readback.get_value(), + smp.z.user_readback.get_value(), + ) + + assert np.isclose(actual_x, expected_xyz.x) + assert np.isclose(actual_y, expected_xyz.y) + assert np.isclose(actual_z, expected_xyz.z) + + await assert_reading( + uvw, + { + uvw.u.name: partial_reading(uvw_pos.u), + uvw.v.name: partial_reading(uvw_pos.v), + uvw.w.name: partial_reading(uvw_pos.w), + }, + full_match=False, + ) + + +async def test_uvw_set(uvw: ToolPointMotion) -> None: + pos = UVWMotorPositions(u=10, v=20, w=30, tilt_deg=40, azimuth_deg=50) + await uvw.set(pos) + + await assert_reading( + uvw, + { + uvw.u.name: partial_reading(pos.u), + uvw.v.name: partial_reading(pos.v), + uvw.w.name: partial_reading(pos.w), + }, + full_match=False, + ) + + assert await uvw.smp_ref().azimuth.user_readback.get_value() == pos.azimuth_deg + assert await uvw.smp_ref().tilt.user_readback.get_value() == pos.tilt_deg + + +async def test_uvw_check_motor_limits_calls_all_motors( + uvw: ToolPointMotion, +) -> None: + smp = uvw.smp_ref() + smp.x.check_motor_limit = AsyncMock() + smp.y.check_motor_limit = AsyncMock() + smp.z.check_motor_limit = AsyncMock() + smp.tilt.check_motor_limit = AsyncMock() + smp.azimuth.check_motor_limit = AsyncMock() + + start = XYZMotorPositions(x=1.0, y=2.0, z=3.0, tilt_deg=10.0, azimuth_deg=20.0) + end = XYZMotorPositions(x=4.0, y=5.0, z=6.0, tilt_deg=30.0, azimuth_deg=40.0) + + await uvw.check_motor_limits(start, end) + + smp.x.check_motor_limit.assert_awaited_once_with(start.x, end.x) + smp.y.check_motor_limit.assert_awaited_once_with(start.y, end.y) + smp.z.check_motor_limit.assert_awaited_once_with(start.z, end.z) + smp.tilt.check_motor_limit.assert_awaited_once_with(start.tilt_deg, end.tilt_deg) + smp.azimuth.check_motor_limit.assert_awaited_once_with( + start.azimuth_deg, end.azimuth_deg + ) + + +async def test_check_motor_limits_raises_on_failure( + uvw: ToolPointMotion, +) -> None: + set_mock_value(uvw.smp_ref().z.high_limit_travel, 500) + set_mock_value(uvw.smp_ref().z.dial_high_limit_travel, 500) + + start = XYZMotorPositions(x=0.0, y=0.0, z=0.0, tilt_deg=0.0, azimuth_deg=0.0) + end = XYZMotorPositions(x=1.0, y=1.0, z=600, tilt_deg=5.0, azimuth_deg=5.0) + + with pytest.raises(MotorLimitsError): + await uvw.check_motor_limits(start, end) diff --git a/tests/devices/test_motors.py b/tests/devices/test_motors.py index daa8335af5..ee8c66262b 100644 --- a/tests/devices/test_motors.py +++ b/tests/devices/test_motors.py @@ -9,10 +9,10 @@ SixAxisGonio, XThetaStage, XYStage, + XYZAzimuthStage, + XYZAzimuthTiltPolarStage, + XYZAzimuthTiltStage, XYZPitchYawRollStage, - XYZPolarAzimuthStage, - XYZPolarAzimuthTiltStage, - XYZPolarStage, XYZThetaStage, ) @@ -25,24 +25,24 @@ async def xyzt_stage() -> XYZThetaStage: @pytest.fixture -async def xyzp_stage() -> XYZPolarStage: +async def xyza_stage() -> XYZAzimuthStage: async with init_devices(mock=True): - xyzp_stage = XYZPolarStage("") - return xyzp_stage + xyza_stage = XYZAzimuthStage("") + return xyza_stage @pytest.fixture -async def xyzpa_stage() -> XYZPolarAzimuthStage: +async def xyzat_stage() -> XYZAzimuthTiltStage: async with init_devices(mock=True): - xyzpa_stage = XYZPolarAzimuthStage("") - return xyzpa_stage + xyzat_stage = XYZAzimuthTiltStage("") + return xyzat_stage @pytest.fixture -async def xyzpat_stage() -> XYZPolarAzimuthTiltStage: +async def xyzatp_stage() -> XYZAzimuthTiltPolarStage: async with init_devices(mock=True): - xyzpat_stage = XYZPolarAzimuthTiltStage("") - return xyzpat_stage + xyzatp_stage = XYZAzimuthTiltPolarStage("") + return xyzatp_stage @pytest.fixture @@ -123,103 +123,103 @@ async def test_setting_xyztheta_position_table(xyzt_stage: XYZThetaStage): @pytest.mark.parametrize( - "x, y, z, polar", + "x, y, z, azimuth", [ (0, 0, 0, 0), (1.23, 2.40, 0.0, 0.0), (1.23, 2.40, 3.51, 24.0), ], ) -async def test_setting_xyzp_position_table( - xyzp_stage: XYZPolarStage, +async def test_setting_xyza_position_table( + xyza_stage: XYZAzimuthStage, x: float, y: float, z: float, - polar: float, + azimuth: float, ): - set_mock_value(xyzp_stage.x.user_readback, x) - set_mock_value(xyzp_stage.y.user_readback, y) - set_mock_value(xyzp_stage.z.user_readback, z) - set_mock_value(xyzp_stage.polar.user_readback, polar) + set_mock_value(xyza_stage.x.user_readback, x) + set_mock_value(xyza_stage.y.user_readback, y) + set_mock_value(xyza_stage.z.user_readback, z) + set_mock_value(xyza_stage.azimuth.user_readback, azimuth) await assert_reading( - xyzp_stage, + xyza_stage, { - "xyzp_stage-x": partial_reading(x), - "xyzp_stage-y": partial_reading(y), - "xyzp_stage-z": partial_reading(z), - "xyzp_stage-polar": partial_reading(polar), + "xyza_stage-x": partial_reading(x), + "xyza_stage-y": partial_reading(y), + "xyza_stage-z": partial_reading(z), + "xyza_stage-azimuth": partial_reading(azimuth), }, ) @pytest.mark.parametrize( - "x, y, z, polar, azimuth", + "x, y, z, azimuth, tilt", [ (0, 0, 0, 0, 0), (1.23, 2.40, 0.0, 0.0, 0), (1.23, 2.40, 3.51, 24.0, 1.0), ], ) -async def test_setting_xyzpa_position_table( - xyzpa_stage: XYZPolarAzimuthStage, +async def test_setting_xyzat_position_table( + xyzat_stage: XYZAzimuthTiltStage, x: float, y: float, z: float, - polar: float, azimuth: float, + tilt: float, ): - set_mock_value(xyzpa_stage.x.user_readback, x) - set_mock_value(xyzpa_stage.y.user_readback, y) - set_mock_value(xyzpa_stage.z.user_readback, z) - set_mock_value(xyzpa_stage.polar.user_readback, polar) - set_mock_value(xyzpa_stage.azimuth.user_readback, azimuth) + set_mock_value(xyzat_stage.x.user_readback, x) + set_mock_value(xyzat_stage.y.user_readback, y) + set_mock_value(xyzat_stage.z.user_readback, z) + set_mock_value(xyzat_stage.azimuth.user_readback, azimuth) + set_mock_value(xyzat_stage.tilt.user_readback, tilt) await assert_reading( - xyzpa_stage, + xyzat_stage, { - "xyzpa_stage-x": partial_reading(x), - "xyzpa_stage-y": partial_reading(y), - "xyzpa_stage-z": partial_reading(z), - "xyzpa_stage-polar": partial_reading(polar), - "xyzpa_stage-azimuth": partial_reading(azimuth), + "xyzat_stage-x": partial_reading(x), + "xyzat_stage-y": partial_reading(y), + "xyzat_stage-z": partial_reading(z), + "xyzat_stage-azimuth": partial_reading(azimuth), + "xyzat_stage-tilt": partial_reading(tilt), }, ) @pytest.mark.parametrize( - "x, y, z, polar, azimuth, tilt", + "x, y, z, azimuth, tilt, polar", [ (0, 0, 0, 0, 0, 0), (1.23, 2.40, 0.0, 0.0, 0.0, 0.0), (1.23, 2.40, 3.51, 24.0, 1.0, 2.0), ], ) -async def test_setting_xyzpat_position_table( - xyzpat_stage: XYZPolarAzimuthTiltStage, +async def test_setting_xyzatp_position_table( + xyzatp_stage: XYZAzimuthTiltPolarStage, x: float, y: float, z: float, - polar: float, azimuth: float, tilt: float, + polar: float, ) -> None: - set_mock_value(xyzpat_stage.x.user_readback, x) - set_mock_value(xyzpat_stage.y.user_readback, y) - set_mock_value(xyzpat_stage.z.user_readback, z) - set_mock_value(xyzpat_stage.polar.user_readback, polar) - set_mock_value(xyzpat_stage.azimuth.user_readback, azimuth) - set_mock_value(xyzpat_stage.tilt.user_readback, tilt) + set_mock_value(xyzatp_stage.x.user_readback, x) + set_mock_value(xyzatp_stage.y.user_readback, y) + set_mock_value(xyzatp_stage.z.user_readback, z) + set_mock_value(xyzatp_stage.polar.user_readback, polar) + set_mock_value(xyzatp_stage.azimuth.user_readback, azimuth) + set_mock_value(xyzatp_stage.tilt.user_readback, tilt) await assert_reading( - xyzpat_stage, + xyzatp_stage, { - "xyzpat_stage-x": partial_reading(x), - "xyzpat_stage-y": partial_reading(y), - "xyzpat_stage-z": partial_reading(z), - "xyzpat_stage-polar": partial_reading(polar), - "xyzpat_stage-azimuth": partial_reading(azimuth), - "xyzpat_stage-tilt": partial_reading(tilt), + "xyzatp_stage-x": partial_reading(x), + "xyzatp_stage-y": partial_reading(y), + "xyzatp_stage-z": partial_reading(z), + "xyzatp_stage-azimuth": partial_reading(azimuth), + "xyzatp_stage-tilt": partial_reading(tilt), + "xyzatp_stage-polar": partial_reading(polar), }, ) @@ -263,7 +263,7 @@ async def test_setting_xyzpyr_position_table( ) -async def test_setting(xy_stage: XYStage): +async def test_setting_xy_stage(xy_stage: XYStage): """Test setting x and y positions on the XYStage using ophyd_async mock tools.""" await assert_reading( xy_stage,