From 462ece51e91a2e14c75d436dc8fcaaf41018e144 Mon Sep 17 00:00:00 2001 From: Oli Wenman Date: Tue, 27 Jan 2026 16:24:32 +0000 Subject: [PATCH 01/81] Add I09 and I09-1 sample stages --- src/dodal/beamlines/i09.py | 8 +++++- src/dodal/beamlines/i09_1.py | 6 ++++ src/dodal/devices/i09_1/sample_stage.py | 11 +++++++ src/dodal/devices/motors.py | 38 +++++++++++++++++++++++++ 4 files changed, 62 insertions(+), 1 deletion(-) create mode 100644 src/dodal/devices/i09_1/sample_stage.py diff --git a/src/dodal/beamlines/i09.py b/src/dodal/beamlines/i09.py index d32efc550c..94754a5cc0 100644 --- a/src/dodal/beamlines/i09.py +++ b/src/dodal/beamlines/i09.py @@ -9,6 +9,7 @@ from dodal.devices.electron_analyser.vgscienta import VGScientaDetector from dodal.devices.fast_shutter import DualFastShutter, GenericFastShutter from dodal.devices.i09 import LensMode, PassEnergy, PsuMode +from dodal.devices.motors import XYZPolarAzimuthStage from dodal.devices.pgm import PlaneGratingMonochromator from dodal.devices.selectable_source import SourceSelector from dodal.devices.synchrotron import Synchrotron @@ -77,7 +78,7 @@ def dual_fast_shutter( # CAM:IMAGE will fail to connect outside the beamline network, # see https://github.com/DiamondLightSource/dodal/issues/1852 -@devices.factory() +@devices.factory(skip=True) def ew4000( dual_fast_shutter: DualFastShutter, dual_energy_source: DualEnergySource, @@ -97,3 +98,8 @@ def ew4000( @devices.factory() def lakeshore() -> Lakeshore336: return Lakeshore336(prefix="BL09L-VA-LAKE-01:") + + +@devices.factory() +def smpm() -> XYZPolarAzimuthStage: + return XYZPolarAzimuthStage(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 0d9190d62a..2fb0056f6f 100644 --- a/src/dodal/beamlines/i09_1.py +++ b/src/dodal/beamlines/i09_1.py @@ -5,6 +5,7 @@ from dodal.devices.electron_analyser.base import EnergySource from dodal.devices.electron_analyser.specs import SpecsDetector from dodal.devices.i09_1 import LensMode, PsuMode +from dodal.devices.i09_1.sample_stage import SampleManipulator from dodal.devices.synchrotron import Synchrotron from dodal.devices.temperture_controller import Lakeshore336 from dodal.log import set_beamline as set_log_beamline @@ -44,3 +45,8 @@ def analyser(energy_source: EnergySource) -> SpecsDetector[LensMode, PsuMode]: @devices.factory() def lakeshore() -> Lakeshore336: return Lakeshore336(prefix=f"{PREFIX.beamline_prefix}-EA-TCTRL-01:") + + +@devices.factory() +def hsmpm() -> SampleManipulator: + return SampleManipulator(prefix=f"{PREFIX.beamline_prefix}-MO-HSMPM-01:") diff --git a/src/dodal/devices/i09_1/sample_stage.py b/src/dodal/devices/i09_1/sample_stage.py new file mode 100644 index 0000000000..f843e9e0c2 --- /dev/null +++ b/src/dodal/devices/i09_1/sample_stage.py @@ -0,0 +1,11 @@ +from ophyd_async.epics.motor import Motor + +from dodal.devices.motors import XYZPolarAzimuthStage + + +class SampleManipulator(XYZPolarAzimuthStage): + def __init__(self, prefix: str, name: str = ""): + with self.add_children_as_readables(): + self.tilt = Motor(prefix + "TILT") + + super().__init__(prefix=prefix, name=name) diff --git a/src/dodal/devices/motors.py b/src/dodal/devices/motors.py index a44ca7c201..4e260c52e0 100644 --- a/src/dodal/devices/motors.py +++ b/src/dodal/devices/motors.py @@ -8,6 +8,7 @@ _X, _Y, _Z = "X", "Y", "Z" _OMEGA = "OMEGA" +_POLAR = "POLAR" class Stage(StandardReadable, ABC): @@ -114,6 +115,43 @@ 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.""" + + def __init__( + self, + prefix: str, + name: str = "", + x_infix: str = _X, + y_infix: str = _Y, + z_infix: str = _Z, + 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) + + +class XYZPolarAzimuthStage(XYZPolarStage): + """ + Four-axis stage with a standard xyz stage and two axis of rotation: polar and azimuth. + """ + + def __init__( + self, + prefix: str, + name: str = "", + x_infix: str = _X, + y_infix: str = _Y, + z_infix: str = _Z, + polar_infix: str = _POLAR, + ): + with self.add_children_as_readables(): + self.azimuth = Motor(prefix + "AZIMUTH") + + super().__init__(prefix, name, x_infix, y_infix, z_infix, polar_infix) + + class XYPhiStage(XYStage): """ Three-axis stage with a standard xy stage and one axis of rotation: phi. From 3756de7bb8a73cca23d1b0962494992ef2afa260 Mon Sep 17 00:00:00 2001 From: Oli Wenman Date: Tue, 27 Jan 2026 16:48:11 +0000 Subject: [PATCH 02/81] Add tests for new stages --- src/dodal/devices/motors.py | 4 +- tests/devices/test_motors.py | 89 ++++++++++++++++++++++++++++++++++++ 2 files changed, 92 insertions(+), 1 deletion(-) diff --git a/src/dodal/devices/motors.py b/src/dodal/devices/motors.py index 4e260c52e0..379c7b3f83 100644 --- a/src/dodal/devices/motors.py +++ b/src/dodal/devices/motors.py @@ -9,6 +9,7 @@ _OMEGA = "OMEGA" _POLAR = "POLAR" +_AZIMUTH = "AZIMUTH" class Stage(StandardReadable, ABC): @@ -145,9 +146,10 @@ def __init__( y_infix: str = _Y, z_infix: str = _Z, polar_infix: str = _POLAR, + azimuth_infix: str = _AZIMUTH, ): with self.add_children_as_readables(): - self.azimuth = Motor(prefix + "AZIMUTH") + self.azimuth = Motor(prefix + azimuth_infix) super().__init__(prefix, name, x_infix, y_infix, z_infix, polar_infix) diff --git a/tests/devices/test_motors.py b/tests/devices/test_motors.py index 731ddabfcb..1f7cc98ead 100644 --- a/tests/devices/test_motors.py +++ b/tests/devices/test_motors.py @@ -10,6 +10,8 @@ XThetaStage, XYStage, XYZPitchYawRollStage, + XYZPolarAzimuthStage, + XYZPolarStage, XYZThetaStage, ) @@ -21,6 +23,20 @@ async def xyzt_stage() -> XYZThetaStage: return xyzt_stage +@pytest.fixture +async def xyzp_stage() -> XYZPolarStage: + async with init_devices(mock=True): + xyzp_stage = XYZPolarStage("") + return xyzp_stage + + +@pytest.fixture +async def xyzpa_stage() -> XYZPolarAzimuthStage: + async with init_devices(mock=True): + xyzpa_stage = XYZPolarAzimuthStage("") + return xyzpa_stage + + @pytest.fixture async def xy_stage() -> XYStage: async with init_devices(mock=True): @@ -103,6 +119,79 @@ async def test_setting_xyztheta_position_table(xyzt_stage: XYZThetaStage): ) +@pytest.mark.parametrize( + "x, y, z, polar", + [ + (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, + x: float, + y: float, + z: float, + polar: float, +): + """ + Test setting positions on the Table using the ophyd_async mock tools. + """ + # Call set to update the position + 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) + + await assert_reading( + xyzp_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), + }, + ) + + +@pytest.mark.parametrize( + "x, y, z, polar, azimuth", + [ + (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, + x: float, + y: float, + z: float, + polar: float, + azimuth: float, +): + """ + Test setting positions on the Table using the ophyd_async mock tools. + """ + # Call set to update the position + 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) + + await assert_reading( + xyzpa_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), + }, + ) + + @pytest.mark.parametrize( "x, y, z, pitch, yaw, roll", [ From 0fc5aecdadced9efa1174e67d4d803a9b5961b27 Mon Sep 17 00:00:00 2001 From: Oli Wenman Date: Tue, 27 Jan 2026 16:53:59 +0000 Subject: [PATCH 03/81] Update doc strings --- src/dodal/devices/i09_1/sample_stage.py | 5 +++++ src/dodal/devices/motors.py | 2 +- 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/src/dodal/devices/i09_1/sample_stage.py b/src/dodal/devices/i09_1/sample_stage.py index f843e9e0c2..f9de61ff4f 100644 --- a/src/dodal/devices/i09_1/sample_stage.py +++ b/src/dodal/devices/i09_1/sample_stage.py @@ -4,6 +4,11 @@ class SampleManipulator(XYZPolarAzimuthStage): + """ + Six-axis stage with a standard xyz stage and two axis of rotation: polar, azimuth + and tilt. + """ + def __init__(self, prefix: str, name: str = ""): with self.add_children_as_readables(): self.tilt = Motor(prefix + "TILT") diff --git a/src/dodal/devices/motors.py b/src/dodal/devices/motors.py index 379c7b3f83..8036ed332f 100644 --- a/src/dodal/devices/motors.py +++ b/src/dodal/devices/motors.py @@ -135,7 +135,7 @@ def __init__( class XYZPolarAzimuthStage(XYZPolarStage): """ - Four-axis stage with a standard xyz stage and two axis of rotation: polar and azimuth. + Five-axis stage with a standard xyz stage and two axis of rotation: polar and azimuth. """ def __init__( From cbd692a2745deb930233df14d62209f0ed1c7c2f Mon Sep 17 00:00:00 2001 From: Oli Wenman Date: Tue, 27 Jan 2026 16:59:50 +0000 Subject: [PATCH 04/81] Correct doc string --- src/dodal/devices/i09_1/sample_stage.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/dodal/devices/i09_1/sample_stage.py b/src/dodal/devices/i09_1/sample_stage.py index f9de61ff4f..2f18012502 100644 --- a/src/dodal/devices/i09_1/sample_stage.py +++ b/src/dodal/devices/i09_1/sample_stage.py @@ -5,7 +5,7 @@ class SampleManipulator(XYZPolarAzimuthStage): """ - Six-axis stage with a standard xyz stage and two axis of rotation: polar, azimuth + Six-axis stage with a standard xyz stage and three axis of rotation: polar, azimuth and tilt. """ From a107355df3a1da951ef6312ee123667935f781d7 Mon Sep 17 00:00:00 2001 From: Oli Wenman Date: Tue, 27 Jan 2026 17:11:07 +0000 Subject: [PATCH 05/81] Add test --- src/dodal/beamlines/i09_1.py | 3 +- src/dodal/devices/i09_1/__init__.py | 3 +- tests/devices/beamlines/i09_1/__init__.py | 0 .../beamlines/i09_1/test_sample_state.py | 49 +++++++++++++++++++ 4 files changed, 52 insertions(+), 3 deletions(-) create mode 100644 tests/devices/beamlines/i09_1/__init__.py create mode 100644 tests/devices/beamlines/i09_1/test_sample_state.py diff --git a/src/dodal/beamlines/i09_1.py b/src/dodal/beamlines/i09_1.py index 2fb0056f6f..47389e405e 100644 --- a/src/dodal/beamlines/i09_1.py +++ b/src/dodal/beamlines/i09_1.py @@ -4,8 +4,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.i09_1 import LensMode, PsuMode -from dodal.devices.i09_1.sample_stage import SampleManipulator +from dodal.devices.i09_1 import LensMode, PsuMode, SampleManipulator from dodal.devices.synchrotron import Synchrotron from dodal.devices.temperture_controller import Lakeshore336 from dodal.log import set_beamline as set_log_beamline diff --git a/src/dodal/devices/i09_1/__init__.py b/src/dodal/devices/i09_1/__init__.py index b54345b114..8cad23b153 100644 --- a/src/dodal/devices/i09_1/__init__.py +++ b/src/dodal/devices/i09_1/__init__.py @@ -1,3 +1,4 @@ from .enums import LensMode, PsuMode +from .sample_stage import SampleManipulator -__all__ = ["LensMode", "PsuMode"] +__all__ = ["LensMode", "PsuMode", "SampleManipulator"] diff --git a/tests/devices/beamlines/i09_1/__init__.py b/tests/devices/beamlines/i09_1/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/tests/devices/beamlines/i09_1/test_sample_state.py b/tests/devices/beamlines/i09_1/test_sample_state.py new file mode 100644 index 0000000000..f928215366 --- /dev/null +++ b/tests/devices/beamlines/i09_1/test_sample_state.py @@ -0,0 +1,49 @@ +import pytest +from ophyd_async.core import init_devices, set_mock_value +from ophyd_async.testing import assert_reading, partial_reading + +from dodal.devices.i09_1 import SampleManipulator + + +@pytest.fixture +def hsmpm() -> SampleManipulator: + with init_devices(mock=True): + hsmpm = SampleManipulator("TEST:") + return hsmpm + + +@pytest.mark.parametrize( + "x, y, z, polar, azimuth, tilt", + [ + (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_hsmpm_positions_and_read( + hsmpm: SampleManipulator, + x: float, + y: float, + z: float, + polar: float, + azimuth: float, + tilt: float, +) -> None: + set_mock_value(hsmpm.x.user_readback, x) + set_mock_value(hsmpm.y.user_readback, y) + set_mock_value(hsmpm.z.user_readback, z) + set_mock_value(hsmpm.polar.user_readback, polar) + set_mock_value(hsmpm.azimuth.user_readback, azimuth) + set_mock_value(hsmpm.tilt.user_readback, tilt) + + await assert_reading( + hsmpm, + { + "hsmpm-x": partial_reading(x), + "hsmpm-y": partial_reading(y), + "hsmpm-z": partial_reading(z), + "hsmpm-polar": partial_reading(polar), + "hsmpm-azimuth": partial_reading(azimuth), + "hsmpm-tilt": partial_reading(tilt), + }, + ) From 0eda4068163389d38d88876fa8433faf9ef20b48 Mon Sep 17 00:00:00 2001 From: Oli Wenman Date: Tue, 27 Jan 2026 17:14:30 +0000 Subject: [PATCH 06/81] Remove skip --- src/dodal/beamlines/i09.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/dodal/beamlines/i09.py b/src/dodal/beamlines/i09.py index 94754a5cc0..5ce713f230 100644 --- a/src/dodal/beamlines/i09.py +++ b/src/dodal/beamlines/i09.py @@ -78,7 +78,7 @@ def dual_fast_shutter( # CAM:IMAGE will fail to connect outside the beamline network, # see https://github.com/DiamondLightSource/dodal/issues/1852 -@devices.factory(skip=True) +@devices.factory() def ew4000( dual_fast_shutter: DualFastShutter, dual_energy_source: DualEnergySource, From 031e4ddb7a16cb232691b3ee27c75d7bbd377062 Mon Sep 17 00:00:00 2001 From: Oli Wenman Date: Tue, 27 Jan 2026 17:16:17 +0000 Subject: [PATCH 07/81] Remove comments --- tests/devices/test_motors.py | 8 -------- 1 file changed, 8 deletions(-) diff --git a/tests/devices/test_motors.py b/tests/devices/test_motors.py index 1f7cc98ead..ef268e1b91 100644 --- a/tests/devices/test_motors.py +++ b/tests/devices/test_motors.py @@ -134,10 +134,6 @@ async def test_setting_xyzp_position_table( z: float, polar: float, ): - """ - Test setting positions on the Table using the ophyd_async mock tools. - """ - # Call set to update the position 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) @@ -170,10 +166,6 @@ async def test_setting_xyzpa_position_table( polar: float, azimuth: float, ): - """ - Test setting positions on the Table using the ophyd_async mock tools. - """ - # Call set to update the position 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) From 99dd78a2b447c756bc12f60f89e31d763c37648d Mon Sep 17 00:00:00 2001 From: Oli Wenman Date: Wed, 28 Jan 2026 10:27:12 +0000 Subject: [PATCH 08/81] Moved tilt stage to core motors --- src/dodal/beamlines/i09_1.py | 7 ++++--- src/dodal/devices/i09_1/__init__.py | 3 +-- src/dodal/devices/i09_1/sample_stage.py | 16 --------------- src/dodal/devices/motors.py | 26 ++++++++++++++++++++++++- 4 files changed, 30 insertions(+), 22 deletions(-) delete mode 100644 src/dodal/devices/i09_1/sample_stage.py diff --git a/src/dodal/beamlines/i09_1.py b/src/dodal/beamlines/i09_1.py index 47389e405e..1fde9090f7 100644 --- a/src/dodal/beamlines/i09_1.py +++ b/src/dodal/beamlines/i09_1.py @@ -4,7 +4,8 @@ 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.i09_1 import LensMode, PsuMode, SampleManipulator +from dodal.devices.i09_1 import LensMode, PsuMode +from dodal.devices.motors import XYZPolarAzimuthTiltStage from dodal.devices.synchrotron import Synchrotron from dodal.devices.temperture_controller import Lakeshore336 from dodal.log import set_beamline as set_log_beamline @@ -47,5 +48,5 @@ def lakeshore() -> Lakeshore336: @devices.factory() -def hsmpm() -> SampleManipulator: - return SampleManipulator(prefix=f"{PREFIX.beamline_prefix}-MO-HSMPM-01:") +def hsmpm() -> XYZPolarAzimuthTiltStage: + return XYZPolarAzimuthTiltStage(prefix=f"{PREFIX.beamline_prefix}-MO-HSMPM-01:") diff --git a/src/dodal/devices/i09_1/__init__.py b/src/dodal/devices/i09_1/__init__.py index 8cad23b153..b54345b114 100644 --- a/src/dodal/devices/i09_1/__init__.py +++ b/src/dodal/devices/i09_1/__init__.py @@ -1,4 +1,3 @@ from .enums import LensMode, PsuMode -from .sample_stage import SampleManipulator -__all__ = ["LensMode", "PsuMode", "SampleManipulator"] +__all__ = ["LensMode", "PsuMode"] diff --git a/src/dodal/devices/i09_1/sample_stage.py b/src/dodal/devices/i09_1/sample_stage.py deleted file mode 100644 index 2f18012502..0000000000 --- a/src/dodal/devices/i09_1/sample_stage.py +++ /dev/null @@ -1,16 +0,0 @@ -from ophyd_async.epics.motor import Motor - -from dodal.devices.motors import XYZPolarAzimuthStage - - -class SampleManipulator(XYZPolarAzimuthStage): - """ - Six-axis stage with a standard xyz stage and three axis of rotation: polar, azimuth - and tilt. - """ - - def __init__(self, prefix: str, name: str = ""): - with self.add_children_as_readables(): - self.tilt = Motor(prefix + "TILT") - - super().__init__(prefix=prefix, name=name) diff --git a/src/dodal/devices/motors.py b/src/dodal/devices/motors.py index 8036ed332f..b2c240db0f 100644 --- a/src/dodal/devices/motors.py +++ b/src/dodal/devices/motors.py @@ -10,6 +10,7 @@ _OMEGA = "OMEGA" _POLAR = "POLAR" _AZIMUTH = "AZIMUTH" +_TILT = "TILT" class Stage(StandardReadable, ABC): @@ -150,10 +151,33 @@ def __init__( ): with self.add_children_as_readables(): self.azimuth = Motor(prefix + azimuth_infix) - super().__init__(prefix, name, x_infix, y_infix, z_infix, polar_infix) +class XYZPolarAzimuthTiltStage(XYZPolarAzimuthStage): + """ + Six-axis stage with a standard xyz stage and three axis of rotation: polar, azimuth + and tilt. + """ + + def __init__( + self, + prefix: str, + name: str = "", + x_infix: str = _X, + y_infix: str = _Y, + z_infix: str = _Z, + polar_infix: str = _POLAR, + azimuth_infix: str = _AZIMUTH, + tilt_infix: str = _TILT, + ): + with self.add_children_as_readables(): + self.tilt = Motor(prefix + tilt_infix) + super().__init__( + prefix, name, x_infix, y_infix, z_infix, polar_infix, azimuth_infix + ) + + class XYPhiStage(XYStage): """ Three-axis stage with a standard xy stage and one axis of rotation: phi. From 09fb2a5250c37557b2c4d3b856c98a2cf356c9bb Mon Sep 17 00:00:00 2001 From: Oli Wenman Date: Wed, 28 Jan 2026 10:31:25 +0000 Subject: [PATCH 09/81] Also moved tests --- tests/devices/beamlines/i09_1/__init__.py | 0 .../beamlines/i09_1/test_sample_state.py | 49 ------------------- tests/devices/test_motors.py | 45 +++++++++++++++++ 3 files changed, 45 insertions(+), 49 deletions(-) delete mode 100644 tests/devices/beamlines/i09_1/__init__.py delete mode 100644 tests/devices/beamlines/i09_1/test_sample_state.py diff --git a/tests/devices/beamlines/i09_1/__init__.py b/tests/devices/beamlines/i09_1/__init__.py deleted file mode 100644 index e69de29bb2..0000000000 diff --git a/tests/devices/beamlines/i09_1/test_sample_state.py b/tests/devices/beamlines/i09_1/test_sample_state.py deleted file mode 100644 index f928215366..0000000000 --- a/tests/devices/beamlines/i09_1/test_sample_state.py +++ /dev/null @@ -1,49 +0,0 @@ -import pytest -from ophyd_async.core import init_devices, set_mock_value -from ophyd_async.testing import assert_reading, partial_reading - -from dodal.devices.i09_1 import SampleManipulator - - -@pytest.fixture -def hsmpm() -> SampleManipulator: - with init_devices(mock=True): - hsmpm = SampleManipulator("TEST:") - return hsmpm - - -@pytest.mark.parametrize( - "x, y, z, polar, azimuth, tilt", - [ - (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_hsmpm_positions_and_read( - hsmpm: SampleManipulator, - x: float, - y: float, - z: float, - polar: float, - azimuth: float, - tilt: float, -) -> None: - set_mock_value(hsmpm.x.user_readback, x) - set_mock_value(hsmpm.y.user_readback, y) - set_mock_value(hsmpm.z.user_readback, z) - set_mock_value(hsmpm.polar.user_readback, polar) - set_mock_value(hsmpm.azimuth.user_readback, azimuth) - set_mock_value(hsmpm.tilt.user_readback, tilt) - - await assert_reading( - hsmpm, - { - "hsmpm-x": partial_reading(x), - "hsmpm-y": partial_reading(y), - "hsmpm-z": partial_reading(z), - "hsmpm-polar": partial_reading(polar), - "hsmpm-azimuth": partial_reading(azimuth), - "hsmpm-tilt": partial_reading(tilt), - }, - ) diff --git a/tests/devices/test_motors.py b/tests/devices/test_motors.py index ef268e1b91..35bd5fa22a 100644 --- a/tests/devices/test_motors.py +++ b/tests/devices/test_motors.py @@ -11,6 +11,7 @@ XYStage, XYZPitchYawRollStage, XYZPolarAzimuthStage, + XYZPolarAzimuthTiltStage, XYZPolarStage, XYZThetaStage, ) @@ -37,6 +38,13 @@ async def xyzpa_stage() -> XYZPolarAzimuthStage: return xyzpa_stage +@pytest.fixture +async def xyzpat_stage() -> XYZPolarAzimuthTiltStage: + async with init_devices(mock=True): + xyzpat_stage = XYZPolarAzimuthTiltStage("") + return xyzpat_stage + + @pytest.fixture async def xy_stage() -> XYStage: async with init_devices(mock=True): @@ -184,6 +192,43 @@ async def test_setting_xyzpa_position_table( ) +@pytest.mark.parametrize( + "x, y, z, polar, azimuth, tilt", + [ + (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, + x: float, + y: float, + z: float, + polar: float, + azimuth: float, + tilt: 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) + + await assert_reading( + xyzpat_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), + }, + ) + + @pytest.mark.parametrize( "x, y, z, pitch, yaw, roll", [ From 7c3f2d465d8cc18f3e94cc97f797934c2b7a2ac4 Mon Sep 17 00:00:00 2001 From: Oli Wenman Date: Wed, 28 Jan 2026 10:39:49 +0000 Subject: [PATCH 10/81] Add i05 sample stage --- src/dodal/beamlines/i05.py | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/src/dodal/beamlines/i05.py b/src/dodal/beamlines/i05.py index 511047ca1b..0b00796b5d 100644 --- a/src/dodal/beamlines/i05.py +++ b/src/dodal/beamlines/i05.py @@ -1,6 +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.motors import XYZPolarAzimuthTiltStage from dodal.devices.temperture_controller import Lakeshore336 from dodal.log import set_beamline as set_log_beamline from dodal.utils import BeamlinePrefix, get_beamline_name @@ -17,3 +18,13 @@ @devices.factory() def sample_temperature_controller() -> Lakeshore336: return Lakeshore336(prefix=f"{PREFIX.beamline_prefix}-EA-TCTRL-02:") + + +@devices.factory() +def sa() -> XYZPolarAzimuthTiltStage: + return XYZPolarAzimuthTiltStage( + f"{PREFIX.beamline_prefix}-EA-SM-01:", + x_infix="SAX", + y_infix="SAY", + z_infix="SAZ", + ) From 3b090187853fb0f903f7745d42b9d2f915e4c07b Mon Sep 17 00:00:00 2001 From: Oli Wenman Date: Wed, 28 Jan 2026 11:04:46 +0000 Subject: [PATCH 11/81] Added I05-1 --- src/dodal/beamlines/i05_1.py | 7 +++++++ src/dodal/devices/i05_1/__init__.py | 0 src/dodal/devices/i05_1/motors.py | 23 +++++++++++++++++++++++ 3 files changed, 30 insertions(+) create mode 100644 src/dodal/devices/i05_1/__init__.py create mode 100644 src/dodal/devices/i05_1/motors.py diff --git a/src/dodal/beamlines/i05_1.py b/src/dodal/beamlines/i05_1.py index 6381d4b034..e4c40c5137 100644 --- a/src/dodal/beamlines/i05_1.py +++ b/src/dodal/beamlines/i05_1.py @@ -1,6 +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.i05_1.motors import XYZPolarAzimuthDefocusStage from dodal.log import set_beamline as set_log_beamline from dodal.utils import BeamlinePrefix, get_beamline_name @@ -11,3 +12,9 @@ devices = DeviceManager() devices.include(i05_shared_devices) + + +@devices.factory +def sm() -> XYZPolarAzimuthDefocusStage: + "Sample manipulator" + return XYZPolarAzimuthDefocusStage(prefix=f"{PREFIX.beamline_prefix}-EA-SM-01:") diff --git a/src/dodal/devices/i05_1/__init__.py b/src/dodal/devices/i05_1/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/src/dodal/devices/i05_1/motors.py b/src/dodal/devices/i05_1/motors.py new file mode 100644 index 0000000000..97ddab094e --- /dev/null +++ b/src/dodal/devices/i05_1/motors.py @@ -0,0 +1,23 @@ +from ophyd_async.epics.motor import Motor + +from dodal.devices.motors import XYZPolarAzimuthStage + + +class XYZPolarAzimuthDefocusStage(XYZPolarAzimuthStage): + """ + Six-axis stage with a standard xyz stage and three axis of rotation: polar, azimuth + and defocus. + """ + + def __init__(self, prefix: str, name=""): + with self.add_children_as_readables(): + self.defocus = Motor(prefix + "SMDF") + super().__init__( + prefix, + name, + x_infix="SMX", + y_infix="SMY", + z_infix="SMZ", + polar_infix="POL", + azimuth_infix="AZM", + ) From 19d8bafb80c68792e3c4302323137298e6c55eb7 Mon Sep 17 00:00:00 2001 From: Oli Wenman Date: Wed, 28 Jan 2026 11:04:54 +0000 Subject: [PATCH 12/81] Added doc string --- src/dodal/beamlines/i05.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/src/dodal/beamlines/i05.py b/src/dodal/beamlines/i05.py index 0b00796b5d..06879351ab 100644 --- a/src/dodal/beamlines/i05.py +++ b/src/dodal/beamlines/i05.py @@ -6,14 +6,14 @@ from dodal.log import set_beamline as set_log_beamline from dodal.utils import BeamlinePrefix, get_beamline_name -devices = DeviceManager() -devices.include(i05_shared_devices) - BL = get_beamline_name("i05") PREFIX = BeamlinePrefix(BL) set_log_beamline(BL) set_utils_beamline(BL) +devices = DeviceManager() +devices.include(i05_shared_devices) + @devices.factory() def sample_temperature_controller() -> Lakeshore336: @@ -22,6 +22,7 @@ def sample_temperature_controller() -> Lakeshore336: @devices.factory() def sa() -> XYZPolarAzimuthTiltStage: + "Sample manipulator." return XYZPolarAzimuthTiltStage( f"{PREFIX.beamline_prefix}-EA-SM-01:", x_infix="SAX", From 28fd04c6d01b435fd9d108002df73e1239b2daff Mon Sep 17 00:00:00 2001 From: Oli Wenman Date: Wed, 28 Jan 2026 11:06:59 +0000 Subject: [PATCH 13/81] Correct doc strings --- src/dodal/beamlines/i05.py | 2 +- src/dodal/beamlines/i05_1.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/dodal/beamlines/i05.py b/src/dodal/beamlines/i05.py index 06879351ab..5f99daf9c9 100644 --- a/src/dodal/beamlines/i05.py +++ b/src/dodal/beamlines/i05.py @@ -22,7 +22,7 @@ def sample_temperature_controller() -> Lakeshore336: @devices.factory() def sa() -> XYZPolarAzimuthTiltStage: - "Sample manipulator." + """Sample manipulator.""" return XYZPolarAzimuthTiltStage( f"{PREFIX.beamline_prefix}-EA-SM-01:", x_infix="SAX", diff --git a/src/dodal/beamlines/i05_1.py b/src/dodal/beamlines/i05_1.py index e4c40c5137..ce7c29af69 100644 --- a/src/dodal/beamlines/i05_1.py +++ b/src/dodal/beamlines/i05_1.py @@ -16,5 +16,5 @@ @devices.factory def sm() -> XYZPolarAzimuthDefocusStage: - "Sample manipulator" + """Sample manipulator.""" return XYZPolarAzimuthDefocusStage(prefix=f"{PREFIX.beamline_prefix}-EA-SM-01:") From a9d07f1934ad82ec2260a34eec05a4a9b57f355d Mon Sep 17 00:00:00 2001 From: Oli Wenman Date: Wed, 28 Jan 2026 12:03:04 +0000 Subject: [PATCH 14/81] Add test for XYZPolarAzimuthDefocusStage --- src/dodal/devices/i05_1/__init__.py | 3 ++ tests/devices/i05_1/__init__.py | 0 tests/devices/i05_1/test_motors.py | 49 +++++++++++++++++++++++++++++ 3 files changed, 52 insertions(+) create mode 100644 tests/devices/i05_1/__init__.py create mode 100644 tests/devices/i05_1/test_motors.py diff --git a/src/dodal/devices/i05_1/__init__.py b/src/dodal/devices/i05_1/__init__.py index e69de29bb2..3c3f4509f6 100644 --- a/src/dodal/devices/i05_1/__init__.py +++ b/src/dodal/devices/i05_1/__init__.py @@ -0,0 +1,3 @@ +from .motors import XYZPolarAzimuthDefocusStage + +__all__ = ["XYZPolarAzimuthDefocusStage"] diff --git a/tests/devices/i05_1/__init__.py b/tests/devices/i05_1/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/tests/devices/i05_1/test_motors.py b/tests/devices/i05_1/test_motors.py new file mode 100644 index 0000000000..7e8aea6a68 --- /dev/null +++ b/tests/devices/i05_1/test_motors.py @@ -0,0 +1,49 @@ +import pytest +from ophyd_async.core import init_devices, set_mock_value +from ophyd_async.testing import assert_reading, partial_reading + +from dodal.devices.i05_1 import XYZPolarAzimuthDefocusStage + + +@pytest.fixture +def xyzpad_stage() -> XYZPolarAzimuthDefocusStage: + with init_devices(mock=True): + xyzpad_stage = XYZPolarAzimuthDefocusStage("TEST:") + return xyzpad_stage + + +@pytest.mark.parametrize( + "x, y, z, polar, azimuth, defocus", + [ + (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_xyzpad_position_table( + xyzpad_stage: XYZPolarAzimuthDefocusStage, + x: float, + y: float, + z: float, + polar: float, + azimuth: float, + defocus: float, +) -> None: + set_mock_value(xyzpad_stage.x.user_readback, x) + set_mock_value(xyzpad_stage.y.user_readback, y) + set_mock_value(xyzpad_stage.z.user_readback, z) + set_mock_value(xyzpad_stage.polar.user_readback, polar) + set_mock_value(xyzpad_stage.azimuth.user_readback, azimuth) + set_mock_value(xyzpad_stage.defocus.user_readback, defocus) + + await assert_reading( + xyzpad_stage, + { + "xyzpad_stage-x": partial_reading(x), + "xyzpad_stage-y": partial_reading(y), + "xyzpad_stage-z": partial_reading(z), + "xyzpad_stage-polar": partial_reading(polar), + "xyzpad_stage-azimuth": partial_reading(azimuth), + "xyzpad_stage-defoucs": partial_reading(defocus), + }, + ) From 578e20083a65e6979215de7e231084e4d81e36df Mon Sep 17 00:00:00 2001 From: Oli Wenman Date: Wed, 28 Jan 2026 12:04:40 +0000 Subject: [PATCH 15/81] Fix typo --- tests/devices/i05_1/test_motors.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/devices/i05_1/test_motors.py b/tests/devices/i05_1/test_motors.py index 7e8aea6a68..ad728fde16 100644 --- a/tests/devices/i05_1/test_motors.py +++ b/tests/devices/i05_1/test_motors.py @@ -44,6 +44,6 @@ async def test_setting_xyzpad_position_table( "xyzpad_stage-z": partial_reading(z), "xyzpad_stage-polar": partial_reading(polar), "xyzpad_stage-azimuth": partial_reading(azimuth), - "xyzpad_stage-defoucs": partial_reading(defocus), + "xyzpad_stage-defocus": partial_reading(defocus), }, ) From 143ce9722462769921c9e23f97c22587ca77e357 Mon Sep 17 00:00:00 2001 From: Oli Wenman Date: Wed, 28 Jan 2026 15:07:02 +0000 Subject: [PATCH 16/81] Add configuration back to stage --- src/dodal/beamlines/i05.py | 2 +- src/dodal/beamlines/i05_1.py | 2 +- src/dodal/devices/i05_1/motors.py | 24 +++++++++++++++++------- 3 files changed, 19 insertions(+), 9 deletions(-) diff --git a/src/dodal/beamlines/i05.py b/src/dodal/beamlines/i05.py index 5f99daf9c9..e2f356e3ac 100644 --- a/src/dodal/beamlines/i05.py +++ b/src/dodal/beamlines/i05.py @@ -22,7 +22,7 @@ def sample_temperature_controller() -> Lakeshore336: @devices.factory() def sa() -> XYZPolarAzimuthTiltStage: - """Sample manipulator.""" + """Sample Manipulator.""" return XYZPolarAzimuthTiltStage( f"{PREFIX.beamline_prefix}-EA-SM-01:", x_infix="SAX", diff --git a/src/dodal/beamlines/i05_1.py b/src/dodal/beamlines/i05_1.py index ce7c29af69..fba4f0e38e 100644 --- a/src/dodal/beamlines/i05_1.py +++ b/src/dodal/beamlines/i05_1.py @@ -16,5 +16,5 @@ @devices.factory def sm() -> XYZPolarAzimuthDefocusStage: - """Sample manipulator.""" + """Sample Manipulator.""" return XYZPolarAzimuthDefocusStage(prefix=f"{PREFIX.beamline_prefix}-EA-SM-01:") diff --git a/src/dodal/devices/i05_1/motors.py b/src/dodal/devices/i05_1/motors.py index 97ddab094e..a594d24eb5 100644 --- a/src/dodal/devices/i05_1/motors.py +++ b/src/dodal/devices/i05_1/motors.py @@ -9,15 +9,25 @@ class XYZPolarAzimuthDefocusStage(XYZPolarAzimuthStage): and defocus. """ - def __init__(self, prefix: str, name=""): + def __init__( + self, + prefix: str, + x_infix="SMX", + y_infix="SMY", + z_infix="SMZ", + polar_infix="POL", + azimuth_infix="AZM", + defocus_infix="SDMF", + name="", + ): with self.add_children_as_readables(): - self.defocus = Motor(prefix + "SMDF") + self.defocus = Motor(prefix + defocus_infix) super().__init__( prefix, name, - x_infix="SMX", - y_infix="SMY", - z_infix="SMZ", - polar_infix="POL", - azimuth_infix="AZM", + x_infix=x_infix, + y_infix=y_infix, + z_infix=z_infix, + polar_infix=polar_infix, + azimuth_infix=azimuth_infix, ) From 8c68c3f5f6680671c398ebb106f5a5d9df25ab27 Mon Sep 17 00:00:00 2001 From: Oli Wenman Date: Wed, 28 Jan 2026 16:02:47 +0000 Subject: [PATCH 17/81] Added perp and long signals --- src/dodal/beamlines/i05.py | 6 +-- src/dodal/devices/i05/motors.py | 92 ++++++++++++++++++++++++++++++++ src/dodal/devices/motors.py | 22 ++++---- tests/devices/i05/__init__.py | 0 tests/devices/i05/test_motors.py | 77 ++++++++++++++++++++++++++ 5 files changed, 184 insertions(+), 13 deletions(-) create mode 100644 src/dodal/devices/i05/motors.py create mode 100644 tests/devices/i05/__init__.py create mode 100644 tests/devices/i05/test_motors.py diff --git a/src/dodal/beamlines/i05.py b/src/dodal/beamlines/i05.py index e2f356e3ac..947afe2064 100644 --- a/src/dodal/beamlines/i05.py +++ b/src/dodal/beamlines/i05.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.motors import XYZPolarAzimuthTiltStage +from dodal.devices.i05.motors import I05Goniometer from dodal.devices.temperture_controller import Lakeshore336 from dodal.log import set_beamline as set_log_beamline from dodal.utils import BeamlinePrefix, get_beamline_name @@ -21,9 +21,9 @@ def sample_temperature_controller() -> Lakeshore336: @devices.factory() -def sa() -> XYZPolarAzimuthTiltStage: +def sa() -> I05Goniometer: """Sample Manipulator.""" - return XYZPolarAzimuthTiltStage( + return I05Goniometer( f"{PREFIX.beamline_prefix}-EA-SM-01:", x_infix="SAX", y_infix="SAY", diff --git a/src/dodal/devices/i05/motors.py b/src/dodal/devices/i05/motors.py new file mode 100644 index 0000000000..bcc9091bb9 --- /dev/null +++ b/src/dodal/devices/i05/motors.py @@ -0,0 +1,92 @@ +import asyncio +from math import cos, radians, sin + +from ophyd_async.core import derived_signal_rw + +from dodal.devices.motors import ( + _AZIMUTH, + _POLAR, + _TILT, + _X, + _Y, + _Z, + XYZPolarAzimuthTiltStage, +) + +ANGLE_DEG = 50 +THETA = radians(ANGLE_DEG) + + +class I05Goniometer(XYZPolarAzimuthTiltStage): + """ + Six-axis stage with a standard xyz stage and three axis of rotation: polar, azimuth + and tilt. This implementation extends to add perp and long coordinate transformation + derived signals. + """ + + def __init__( + self, + prefix: str, + x_infix: str = _X, + y_infix: str = _Y, + z_infix: str = _Z, + polar_infix: str = _POLAR, + azimuth_infix: str = _AZIMUTH, + tilt_infix: str = _TILT, + name: str = "", + ): + super().__init__( + prefix, + name, + x_infix=x_infix, + y_infix=y_infix, + z_infix=z_infix, + polar_infix=polar_infix, + azimuth_infix=azimuth_infix, + tilt_infix=tilt_infix, + ) + + with self.add_children_as_readables(): + self.long = derived_signal_rw( + self._read_long_calc, + self._set_long_calc, + x=self.x, + y=self.y, + ) + self.perp = derived_signal_rw( + self._read_perp_calc, + self._set_perp_calc, + x=self.x, + y=self.y, + ) + + def _read_long_calc(self, x: float, y: float) -> float: + return y * cos(THETA) - x * sin(THETA) + + async def _set_long_calc(self, value: float) -> None: + x_pos, y_pos = await asyncio.gather( + self.x.user_readback.get_value(), self.y.user_readback.get_value() + ) + perp = self._read_perp_calc(x_pos, y_pos) + long = value + + new_x_pos = -1 * long * sin(THETA) + perp * cos(THETA) + new_y_pos = long * cos(THETA) + perp * sin(THETA) + + await asyncio.gather(self.x.set(new_x_pos), self.y.set(new_y_pos)) + + def _read_perp_calc(self, x: float, y: float) -> float: + return y * sin(THETA) + x * cos(THETA) + + async def _set_perp_calc(self, value: float) -> None: + x_pos, y_pos = await asyncio.gather( + self.x.user_readback.get_value(), self.y.user_readback.get_value() + ) + + long = self._read_long_calc(x_pos, y_pos) + perp = value + + new_x_pos = -1 * long * sin(THETA) + perp * cos(THETA) + new_y_pos = long * cos(THETA) + perp * sin(THETA) + + await asyncio.gather(self.x.set(new_x_pos), self.y.set(new_y_pos)) diff --git a/src/dodal/devices/motors.py b/src/dodal/devices/motors.py index b2c240db0f..a30d1bcb8b 100644 --- a/src/dodal/devices/motors.py +++ b/src/dodal/devices/motors.py @@ -2,7 +2,7 @@ import math from abc import ABC -from ophyd_async.core import StandardReadable, derived_signal_rw +from ophyd_async.core import SignalRW, StandardReadable, derived_signal_rw from ophyd_async.epics.motor import Motor _X, _Y, _Z = "X", "Y", "Z" @@ -340,7 +340,9 @@ def __init__( super().__init__(name) -def create_axis_perp_to_rotation(motor_theta: Motor, motor_i: Motor, motor_j: Motor): +def create_axis_perp_to_rotation( + motor_theta: Motor, motor_i: Motor, motor_j: Motor +) -> SignalRW[float]: """Given a signal that controls a motor in a rotation axis and two other signals controlling motors on a pair of orthogonal axes, these axes being in the rotating frame of reference created by the first axis, create a derived signal @@ -362,19 +364,19 @@ def create_axis_perp_to_rotation(motor_theta: Motor, motor_i: Motor, motor_j: Mo a move here is entirely parallel with the derived axis. """ - def _get(j_val: float, i_val: float, rot_value: float) -> float: - i_component = i_val * math.cos(math.radians(rot_value)) - j_component = j_val * math.sin(math.radians(rot_value)) + def _get(j_val: float, i_val: float, rot_deg_value: float) -> float: + i_component = i_val * math.cos(math.radians(rot_deg_value)) + j_component = j_val * math.sin(math.radians(rot_deg_value)) return i_component + j_component async def _set(vertical_value: float) -> None: - rot_value = await motor_theta.user_readback.get_value() - i_component = vertical_value * math.cos(math.radians(rot_value)) - j_component = vertical_value * math.sin(math.radians(rot_value)) + rot_deg_value = await motor_theta.user_readback.get_value() + i_component = vertical_value * math.cos(math.radians(rot_deg_value)) + j_component = vertical_value * math.sin(math.radians(rot_deg_value)) await asyncio.gather( motor_i.set(i_component), motor_j.set(j_component), - motor_theta.set(rot_value), + motor_theta.set(rot_deg_value), ) return derived_signal_rw( @@ -382,5 +384,5 @@ async def _set(vertical_value: float) -> None: _set, i_val=motor_i, j_val=motor_j, - rot_value=motor_theta, + rot_deg_value=motor_theta, ) diff --git a/tests/devices/i05/__init__.py b/tests/devices/i05/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/tests/devices/i05/test_motors.py b/tests/devices/i05/test_motors.py new file mode 100644 index 0000000000..76e9d4b9d9 --- /dev/null +++ b/tests/devices/i05/test_motors.py @@ -0,0 +1,77 @@ +import asyncio +from math import cos, sin + +import pytest +from ophyd_async.core import init_devices +from ophyd_async.testing import assert_reading, partial_reading + +from dodal.devices.i05.motors import THETA, I05Goniometer + + +@pytest.fixture +async def goniometer(): + with init_devices(mock=True): + goniometer = I05Goniometer("TEST:") + # await goniometer.polar.set(50) + return goniometer + + +async def test_goniometer_read(goniometer: I05Goniometer) -> None: + x = 10.0 + y = 5.0 + expected_long = y * cos(THETA) - x * sin(THETA) + expected_perp = y * sin(THETA) + x * cos(THETA) + + await asyncio.gather(goniometer.x.set(x), goniometer.y.set(y)) + + await assert_reading( + goniometer, + { + goniometer.x.name: partial_reading(x), + goniometer.y.name: partial_reading(y), + goniometer.z.name: partial_reading(0), + goniometer.polar.name: partial_reading(0), + goniometer.azimuth.name: partial_reading(0), + goniometer.tilt.name: partial_reading(0), + goniometer.perp.name: partial_reading(expected_perp), + goniometer.long.name: partial_reading(expected_long), + }, + ) + + +async def test_goniometer_set_long(goniometer: I05Goniometer) -> None: + x, y = 10, 5 + await asyncio.gather(goniometer.x.set(x), goniometer.y.set(y)) + + perp_before = goniometer._read_perp_calc(x, y) + + new_long = 20.0 + await goniometer.long.set(new_long) + + x_new = await goniometer.x.user_readback.get_value() + y_new = await goniometer.y.user_readback.get_value() + + long_after = goniometer._read_long_calc(x_new, y_new) + perp_after = goniometer._read_perp_calc(x_new, y_new) + + assert long_after == pytest.approx(new_long) + assert perp_after == pytest.approx(perp_before) + + +async def test_goniometer_set_perp(goniometer: I05Goniometer) -> None: + x, y = 10.0, 5.0 + await asyncio.gather(goniometer.x.set(x), goniometer.y.set(y)) + + long_before = goniometer._read_long_calc(x, y) + + new_perp = 15.0 + await goniometer.perp.set(new_perp) + + x_new_perp = await goniometer.x.user_readback.get_value() + y_new_perp = await goniometer.y.user_readback.get_value() + + long_after_perp = goniometer._read_long_calc(x_new_perp, y_new_perp) + perp_after_perp = goniometer._read_perp_calc(x_new_perp, y_new_perp) + + assert perp_after_perp == pytest.approx(new_perp, abs=1e-5) + assert long_after_perp == pytest.approx(long_before, abs=1e-5) From 3fdbcc9fa373a33bd66528c7644475a9dd9fda01 Mon Sep 17 00:00:00 2001 From: Oli Wenman Date: Wed, 28 Jan 2026 16:10:10 +0000 Subject: [PATCH 18/81] Remove comment --- tests/devices/i05/test_motors.py | 1 - 1 file changed, 1 deletion(-) diff --git a/tests/devices/i05/test_motors.py b/tests/devices/i05/test_motors.py index 76e9d4b9d9..ce7a24ea9a 100644 --- a/tests/devices/i05/test_motors.py +++ b/tests/devices/i05/test_motors.py @@ -12,7 +12,6 @@ async def goniometer(): with init_devices(mock=True): goniometer = I05Goniometer("TEST:") - # await goniometer.polar.set(50) return goniometer From 267d70b89171167314be45c419c29adffc72844c Mon Sep 17 00:00:00 2001 From: Oli Wenman Date: Wed, 28 Jan 2026 17:25:16 +0000 Subject: [PATCH 19/81] Added configurable rotation_angle_deg --- src/dodal/devices/i05/motors.py | 28 +++++++++++++++------------- src/dodal/devices/motors.py | 2 +- tests/devices/i05/test_motors.py | 21 ++++++++++++--------- 3 files changed, 28 insertions(+), 23 deletions(-) diff --git a/src/dodal/devices/i05/motors.py b/src/dodal/devices/i05/motors.py index bcc9091bb9..e4c3d313d2 100644 --- a/src/dodal/devices/i05/motors.py +++ b/src/dodal/devices/i05/motors.py @@ -13,9 +13,6 @@ XYZPolarAzimuthTiltStage, ) -ANGLE_DEG = 50 -THETA = radians(ANGLE_DEG) - class I05Goniometer(XYZPolarAzimuthTiltStage): """ @@ -33,8 +30,11 @@ def __init__( polar_infix: str = _POLAR, azimuth_infix: str = _AZIMUTH, tilt_infix: str = _TILT, + rotation_angle_deg: float = 50, name: str = "", ): + self.theta = radians(rotation_angle_deg) + super().__init__( prefix, name, @@ -52,41 +52,43 @@ def __init__( self._set_long_calc, x=self.x, y=self.y, + theta=self.theta, ) self.perp = derived_signal_rw( self._read_perp_calc, self._set_perp_calc, x=self.x, y=self.y, + theta=self.theta, ) - def _read_long_calc(self, x: float, y: float) -> float: - return y * cos(THETA) - x * sin(THETA) + def _read_long_calc(self, x: float, y: float, theta: float) -> float: + return y * cos(theta) - x * sin(theta) async def _set_long_calc(self, value: float) -> None: x_pos, y_pos = await asyncio.gather( self.x.user_readback.get_value(), self.y.user_readback.get_value() ) - perp = self._read_perp_calc(x_pos, y_pos) + perp = self._read_perp_calc(x_pos, y_pos, self.theta) long = value - new_x_pos = -1 * long * sin(THETA) + perp * cos(THETA) - new_y_pos = long * cos(THETA) + perp * sin(THETA) + new_x_pos = -1 * long * sin(self.theta) + perp * cos(self.theta) + new_y_pos = long * cos(self.theta) + perp * sin(self.theta) await asyncio.gather(self.x.set(new_x_pos), self.y.set(new_y_pos)) - def _read_perp_calc(self, x: float, y: float) -> float: - return y * sin(THETA) + x * cos(THETA) + def _read_perp_calc(self, x: float, y: float, theta: float) -> float: + return y * sin(theta) + x * cos(theta) async def _set_perp_calc(self, value: float) -> None: x_pos, y_pos = await asyncio.gather( self.x.user_readback.get_value(), self.y.user_readback.get_value() ) - long = self._read_long_calc(x_pos, y_pos) + long = self._read_long_calc(x_pos, y_pos, self.theta) perp = value - new_x_pos = -1 * long * sin(THETA) + perp * cos(THETA) - new_y_pos = long * cos(THETA) + perp * sin(THETA) + new_x_pos = -1 * long * sin(self.theta) + perp * cos(self.theta) + new_y_pos = long * cos(self.theta) + perp * sin(self.theta) await asyncio.gather(self.x.set(new_x_pos), self.y.set(new_y_pos)) diff --git a/src/dodal/devices/motors.py b/src/dodal/devices/motors.py index a30d1bcb8b..32b80885aa 100644 --- a/src/dodal/devices/motors.py +++ b/src/dodal/devices/motors.py @@ -297,7 +297,7 @@ def __init__( with self.add_children_as_readables(): self.kappa = Motor(prefix + kappa_infix) self.phi = Motor(prefix + phi_infix) - super().__init__(prefix, name, x_infix, y_infix, z_infix) + super().__init__(prefix, name, x_infix, y_infix, z_infix, omega_infix) self.vertical_in_lab_space = create_axis_perp_to_rotation( self.omega, self.y, self.z diff --git a/tests/devices/i05/test_motors.py b/tests/devices/i05/test_motors.py index ce7a24ea9a..9e76b2c830 100644 --- a/tests/devices/i05/test_motors.py +++ b/tests/devices/i05/test_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.i05.motors import THETA, I05Goniometer +from dodal.devices.i05.motors import I05Goniometer @pytest.fixture @@ -18,8 +18,9 @@ async def goniometer(): async def test_goniometer_read(goniometer: I05Goniometer) -> None: x = 10.0 y = 5.0 - expected_long = y * cos(THETA) - x * sin(THETA) - expected_perp = y * sin(THETA) + x * cos(THETA) + theta = goniometer.theta + expected_long = y * cos(theta) - x * sin(theta) + expected_perp = y * sin(theta) + x * cos(theta) await asyncio.gather(goniometer.x.set(x), goniometer.y.set(y)) @@ -40,9 +41,10 @@ async def test_goniometer_read(goniometer: I05Goniometer) -> None: async def test_goniometer_set_long(goniometer: I05Goniometer) -> None: x, y = 10, 5 + theta = goniometer.theta await asyncio.gather(goniometer.x.set(x), goniometer.y.set(y)) - perp_before = goniometer._read_perp_calc(x, y) + perp_before = goniometer._read_perp_calc(x, y, theta) new_long = 20.0 await goniometer.long.set(new_long) @@ -50,8 +52,8 @@ async def test_goniometer_set_long(goniometer: I05Goniometer) -> None: x_new = await goniometer.x.user_readback.get_value() y_new = await goniometer.y.user_readback.get_value() - long_after = goniometer._read_long_calc(x_new, y_new) - perp_after = goniometer._read_perp_calc(x_new, y_new) + long_after = goniometer._read_long_calc(x_new, y_new, theta) + perp_after = goniometer._read_perp_calc(x_new, y_new, theta) assert long_after == pytest.approx(new_long) assert perp_after == pytest.approx(perp_before) @@ -59,9 +61,10 @@ async def test_goniometer_set_long(goniometer: I05Goniometer) -> None: async def test_goniometer_set_perp(goniometer: I05Goniometer) -> None: x, y = 10.0, 5.0 + theta = goniometer.theta await asyncio.gather(goniometer.x.set(x), goniometer.y.set(y)) - long_before = goniometer._read_long_calc(x, y) + long_before = goniometer._read_long_calc(x, y, theta) new_perp = 15.0 await goniometer.perp.set(new_perp) @@ -69,8 +72,8 @@ async def test_goniometer_set_perp(goniometer: I05Goniometer) -> None: x_new_perp = await goniometer.x.user_readback.get_value() y_new_perp = await goniometer.y.user_readback.get_value() - long_after_perp = goniometer._read_long_calc(x_new_perp, y_new_perp) - perp_after_perp = goniometer._read_perp_calc(x_new_perp, y_new_perp) + long_after_perp = goniometer._read_long_calc(x_new_perp, y_new_perp, theta) + perp_after_perp = goniometer._read_perp_calc(x_new_perp, y_new_perp, theta) assert perp_after_perp == pytest.approx(new_perp, abs=1e-5) assert long_after_perp == pytest.approx(long_before, abs=1e-5) From 0715af92f4bcfe9e8b522b92e07254e1eab23641 Mon Sep 17 00:00:00 2001 From: Oli Wenman Date: Thu, 29 Jan 2026 10:16:17 +0000 Subject: [PATCH 20/81] Restructuted using rotation matrix --- src/dodal/devices/i05/motors.py | 32 +++++++++++++++++++++++--------- 1 file changed, 23 insertions(+), 9 deletions(-) diff --git a/src/dodal/devices/i05/motors.py b/src/dodal/devices/i05/motors.py index e4c3d313d2..8569afaf57 100644 --- a/src/dodal/devices/i05/motors.py +++ b/src/dodal/devices/i05/motors.py @@ -1,6 +1,7 @@ import asyncio -from math import cos, radians, sin +from math import radians +import numpy as np from ophyd_async.core import derived_signal_rw from dodal.devices.motors import ( @@ -62,33 +63,46 @@ def __init__( theta=self.theta, ) + def _rotation_matrix(self, theta) -> np.ndarray: + c, s = np.cos(theta), np.sin(theta) + return np.array([[c, s], [-s, c]]) + + def _inverse_rotation_matrix(self, theta) -> np.ndarray: + return self._rotation_matrix(theta).T + def _read_long_calc(self, x: float, y: float, theta: float) -> float: - return y * cos(theta) - x * sin(theta) + vec = np.array([x, y]) + perp, long = self._rotation_matrix(theta) @ vec + return long async def _set_long_calc(self, value: float) -> None: x_pos, y_pos = await asyncio.gather( - self.x.user_readback.get_value(), self.y.user_readback.get_value() + self.x.user_readback.get_value(), + self.y.user_readback.get_value(), ) perp = self._read_perp_calc(x_pos, y_pos, self.theta) long = value - new_x_pos = -1 * long * sin(self.theta) + perp * cos(self.theta) - new_y_pos = long * cos(self.theta) + perp * sin(self.theta) + vec_rot = np.array([perp, long]) + new_x_pos, new_y_pos = self._inverse_rotation_matrix(self.theta) @ vec_rot await asyncio.gather(self.x.set(new_x_pos), self.y.set(new_y_pos)) def _read_perp_calc(self, x: float, y: float, theta: float) -> float: - return y * sin(theta) + x * cos(theta) + vec = np.array([x, y]) + perp, long = self._rotation_matrix(theta) @ vec + return perp async def _set_perp_calc(self, value: float) -> None: x_pos, y_pos = await asyncio.gather( - self.x.user_readback.get_value(), self.y.user_readback.get_value() + self.x.user_readback.get_value(), + self.y.user_readback.get_value(), ) long = self._read_long_calc(x_pos, y_pos, self.theta) perp = value - new_x_pos = -1 * long * sin(self.theta) + perp * cos(self.theta) - new_y_pos = long * cos(self.theta) + perp * sin(self.theta) + vec_rot = np.array([perp, long]) + new_x_pos, new_y_pos = self._inverse_rotation_matrix(self.theta) @ vec_rot await asyncio.gather(self.x.set(new_x_pos), self.y.set(new_y_pos)) From 46b7eb30aa0bd709658468520257a6a1aa1d364a Mon Sep 17 00:00:00 2001 From: Oli Wenman Date: Thu, 29 Jan 2026 10:41:54 +0000 Subject: [PATCH 21/81] Used a Vector2D dataclass --- src/dodal/devices/i05/motors.py | 66 +++++++++++++++++++++------------ 1 file changed, 42 insertions(+), 24 deletions(-) diff --git a/src/dodal/devices/i05/motors.py b/src/dodal/devices/i05/motors.py index 8569afaf57..652f0ff8d0 100644 --- a/src/dodal/devices/i05/motors.py +++ b/src/dodal/devices/i05/motors.py @@ -1,4 +1,5 @@ import asyncio +from dataclasses import dataclass from math import radians import numpy as np @@ -15,6 +16,37 @@ ) +@dataclass +class Vector2D: + x: float + y: float + + def to_array(self) -> np.ndarray: + return np.array([self.x, self.y]) + + @classmethod + def from_array(cls, arr: np.ndarray) -> "Vector2D": + return cls(arr[0], arr[1]) + + def _rotation_matrix(self, theta) -> np.ndarray: + c, s = np.cos(theta), np.sin(theta) + return np.array([[c, s], [-s, c]]) + + def _inverse_rotation_matrix(self, theta) -> np.ndarray: + return self._rotation_matrix(theta).T + + def rotate(self, theta: float) -> "Vector2D": + rotated = self._rotation_matrix(theta) @ self.to_array() + return Vector2D.from_array(rotated) + + def inverse_rotate(self, theta: float) -> "Vector2D": + rotated = self._inverse_rotation_matrix(theta) @ self.to_array() + return Vector2D.from_array(rotated) + + def __repr__(self): + return f"Vector2D(x={self.x}, y={self.y})" + + class I05Goniometer(XYZPolarAzimuthTiltStage): """ Six-axis stage with a standard xyz stage and three axis of rotation: polar, azimuth @@ -63,17 +95,9 @@ def __init__( theta=self.theta, ) - def _rotation_matrix(self, theta) -> np.ndarray: - c, s = np.cos(theta), np.sin(theta) - return np.array([[c, s], [-s, c]]) - - def _inverse_rotation_matrix(self, theta) -> np.ndarray: - return self._rotation_matrix(theta).T - def _read_long_calc(self, x: float, y: float, theta: float) -> float: - vec = np.array([x, y]) - perp, long = self._rotation_matrix(theta) @ vec - return long + vec = Vector2D(x, y) + return vec.rotate(theta).y async def _set_long_calc(self, value: float) -> None: x_pos, y_pos = await asyncio.gather( @@ -81,28 +105,22 @@ async def _set_long_calc(self, value: float) -> None: self.y.user_readback.get_value(), ) perp = self._read_perp_calc(x_pos, y_pos, self.theta) - long = value + new_vec = Vector2D(perp, value) + new_pos = new_vec.inverse_rotate(self.theta) - vec_rot = np.array([perp, long]) - new_x_pos, new_y_pos = self._inverse_rotation_matrix(self.theta) @ vec_rot - - await asyncio.gather(self.x.set(new_x_pos), self.y.set(new_y_pos)) + await asyncio.gather(self.x.set(new_pos.x), self.y.set(new_pos.y)) def _read_perp_calc(self, x: float, y: float, theta: float) -> float: - vec = np.array([x, y]) - perp, long = self._rotation_matrix(theta) @ vec - return perp + vec = Vector2D(x, y) + return vec.rotate(theta).x async def _set_perp_calc(self, value: float) -> None: x_pos, y_pos = await asyncio.gather( self.x.user_readback.get_value(), self.y.user_readback.get_value(), ) - long = self._read_long_calc(x_pos, y_pos, self.theta) - perp = value - - vec_rot = np.array([perp, long]) - new_x_pos, new_y_pos = self._inverse_rotation_matrix(self.theta) @ vec_rot + new_vec = Vector2D(value, long) + new_pos = new_vec.inverse_rotate(self.theta) - await asyncio.gather(self.x.set(new_x_pos), self.y.set(new_y_pos)) + await asyncio.gather(self.x.set(new_pos.x), self.y.set(new_pos.y)) From 31327ad3d65922ae436971ab264fe53778341e2d Mon Sep 17 00:00:00 2001 From: Oli Wenman Date: Thu, 29 Jan 2026 11:20:25 +0000 Subject: [PATCH 22/81] Made the rotation matrix configurable --- src/dodal/devices/i05/motors.py | 94 ++++++++++++++++++++++++-------- tests/devices/i05/test_motors.py | 15 +++-- 2 files changed, 77 insertions(+), 32 deletions(-) diff --git a/src/dodal/devices/i05/motors.py b/src/dodal/devices/i05/motors.py index 652f0ff8d0..66959df50b 100644 --- a/src/dodal/devices/i05/motors.py +++ b/src/dodal/devices/i05/motors.py @@ -1,8 +1,11 @@ import asyncio +from collections.abc import Callable from dataclasses import dataclass from math import radians import numpy as np +from numpy import cos as c +from numpy import sin as s from ophyd_async.core import derived_signal_rw from dodal.devices.motors import ( @@ -18,40 +21,83 @@ @dataclass class Vector2D: + """ + Helper class that can perform rotations on x and y. It is configured with a matrix + of callables so this can be used for any kind of rotation. + + from numpy import cos as c + from numpy import sin as s + + def neg_s(x: float) -> float: + return -s(x) + + ROTATION_MATRIX = [[c, s], [neg_s, c]] + x, y = 1, 5 + + vec = Vector2D(x, y, ROTATION_MATRIX) + new_vec = vec.rotate_deg(45) + new_x = new_vec.x + new_y = new_vec.y + """ + x: float y: float + callable_rotation_matrix: list[list[Callable[[float], float]]] def to_array(self) -> np.ndarray: return np.array([self.x, self.y]) @classmethod - def from_array(cls, arr: np.ndarray) -> "Vector2D": - return cls(arr[0], arr[1]) + def from_array( + cls, + arr: np.ndarray, + callable_rotation_matrix: list[list[Callable[[float], float]]], + ) -> "Vector2D": + return cls( + x=arr[0], y=arr[1], callable_rotation_matrix=callable_rotation_matrix + ) def _rotation_matrix(self, theta) -> np.ndarray: - c, s = np.cos(theta), np.sin(theta) - return np.array([[c, s], [-s, c]]) + return np.array( + [ + [fn(theta) for fn in row_functions] + for row_functions in self.callable_rotation_matrix + ] + ) def _inverse_rotation_matrix(self, theta) -> np.ndarray: return self._rotation_matrix(theta).T + def rotate_deg(self, angle_deg: float) -> "Vector2D": + return self.rotate(radians(angle_deg)) + def rotate(self, theta: float) -> "Vector2D": rotated = self._rotation_matrix(theta) @ self.to_array() - return Vector2D.from_array(rotated) + return Vector2D.from_array(rotated, self.callable_rotation_matrix) + + def inverse_rotate_deg(self, angle_deg: float) -> "Vector2D": + return self.inverse_rotate(radians(angle_deg)) def inverse_rotate(self, theta: float) -> "Vector2D": rotated = self._inverse_rotation_matrix(theta) @ self.to_array() - return Vector2D.from_array(rotated) + return Vector2D.from_array(rotated, self.callable_rotation_matrix) def __repr__(self): return f"Vector2D(x={self.x}, y={self.y})" +def neg_s(x: float) -> float: + return -s(x) + + +ROTATION_MATRIX = rotation_matrix = [[c, s], [neg_s, c]] + + class I05Goniometer(XYZPolarAzimuthTiltStage): """ Six-axis stage with a standard xyz stage and three axis of rotation: polar, azimuth - and tilt. This implementation extends to add perp and long coordinate transformation - derived signals. + and tilt. This implementation extends to add perp and long rotations as derived + signals at a configured angle (default 50 degrees.) """ def __init__( @@ -63,10 +109,10 @@ def __init__( polar_infix: str = _POLAR, azimuth_infix: str = _AZIMUTH, tilt_infix: str = _TILT, - rotation_angle_deg: float = 50, + rotation_angle_deg: float = 50.0, name: str = "", ): - self.theta = radians(rotation_angle_deg) + self.rotation_angle_deg = rotation_angle_deg super().__init__( prefix, @@ -85,42 +131,42 @@ def __init__( self._set_long_calc, x=self.x, y=self.y, - theta=self.theta, + angle_deg=self.rotation_angle_deg, ) self.perp = derived_signal_rw( self._read_perp_calc, self._set_perp_calc, x=self.x, y=self.y, - theta=self.theta, + angle_deg=self.rotation_angle_deg, ) - def _read_long_calc(self, x: float, y: float, theta: float) -> float: - vec = Vector2D(x, y) - return vec.rotate(theta).y + def _read_long_calc(self, x: float, y: float, angle_deg: float) -> float: + vec = Vector2D(x, y, ROTATION_MATRIX) + return vec.rotate_deg(angle_deg).y async def _set_long_calc(self, value: float) -> None: x_pos, y_pos = await asyncio.gather( self.x.user_readback.get_value(), self.y.user_readback.get_value(), ) - perp = self._read_perp_calc(x_pos, y_pos, self.theta) - new_vec = Vector2D(perp, value) - new_pos = new_vec.inverse_rotate(self.theta) + perp = self._read_perp_calc(x_pos, y_pos, self.rotation_angle_deg) + vec = Vector2D(perp, value, ROTATION_MATRIX) + new_pos = vec.inverse_rotate_deg(self.rotation_angle_deg) await asyncio.gather(self.x.set(new_pos.x), self.y.set(new_pos.y)) - def _read_perp_calc(self, x: float, y: float, theta: float) -> float: - vec = Vector2D(x, y) - return vec.rotate(theta).x + def _read_perp_calc(self, x: float, y: float, angle_deg: float) -> float: + vec = Vector2D(x, y, ROTATION_MATRIX) + return vec.rotate_deg(angle_deg).x async def _set_perp_calc(self, value: float) -> None: x_pos, y_pos = await asyncio.gather( self.x.user_readback.get_value(), self.y.user_readback.get_value(), ) - long = self._read_long_calc(x_pos, y_pos, self.theta) - new_vec = Vector2D(value, long) - new_pos = new_vec.inverse_rotate(self.theta) + long = self._read_long_calc(x_pos, y_pos, self.rotation_angle_deg) + vec = Vector2D(value, long, ROTATION_MATRIX) + new_pos = vec.inverse_rotate_deg(self.rotation_angle_deg) await asyncio.gather(self.x.set(new_pos.x), self.y.set(new_pos.y)) diff --git a/tests/devices/i05/test_motors.py b/tests/devices/i05/test_motors.py index 9e76b2c830..0fc97a8e98 100644 --- a/tests/devices/i05/test_motors.py +++ b/tests/devices/i05/test_motors.py @@ -1,5 +1,5 @@ import asyncio -from math import cos, sin +from math import cos, radians, sin import pytest from ophyd_async.core import init_devices @@ -16,11 +16,10 @@ async def goniometer(): async def test_goniometer_read(goniometer: I05Goniometer) -> None: - x = 10.0 - y = 5.0 - theta = goniometer.theta - expected_long = y * cos(theta) - x * sin(theta) - expected_perp = y * sin(theta) + x * cos(theta) + x, y = 10, 5 + theta = radians(goniometer.rotation_angle_deg) + expected_perp = x * cos(theta) + y * sin(theta) + expected_long = -x * sin(theta) + y * cos(theta) await asyncio.gather(goniometer.x.set(x), goniometer.y.set(y)) @@ -41,7 +40,7 @@ async def test_goniometer_read(goniometer: I05Goniometer) -> None: async def test_goniometer_set_long(goniometer: I05Goniometer) -> None: x, y = 10, 5 - theta = goniometer.theta + theta = radians(goniometer.rotation_angle_deg) await asyncio.gather(goniometer.x.set(x), goniometer.y.set(y)) perp_before = goniometer._read_perp_calc(x, y, theta) @@ -61,7 +60,7 @@ async def test_goniometer_set_long(goniometer: I05Goniometer) -> None: async def test_goniometer_set_perp(goniometer: I05Goniometer) -> None: x, y = 10.0, 5.0 - theta = goniometer.theta + theta = radians(goniometer.rotation_angle_deg) await asyncio.gather(goniometer.x.set(x), goniometer.y.set(y)) long_before = goniometer._read_long_calc(x, y, theta) From 1a105e1af03cf78cd06cca2a9a26f04d11443ab3 Mon Sep 17 00:00:00 2001 From: Oli Wenman Date: Thu, 29 Jan 2026 11:33:33 +0000 Subject: [PATCH 23/81] Fixed tests --- tests/devices/i05/test_motors.py | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/tests/devices/i05/test_motors.py b/tests/devices/i05/test_motors.py index 0fc97a8e98..5b50e368ab 100644 --- a/tests/devices/i05/test_motors.py +++ b/tests/devices/i05/test_motors.py @@ -40,10 +40,10 @@ async def test_goniometer_read(goniometer: I05Goniometer) -> None: async def test_goniometer_set_long(goniometer: I05Goniometer) -> None: x, y = 10, 5 - theta = radians(goniometer.rotation_angle_deg) + angle_deg = goniometer.rotation_angle_deg await asyncio.gather(goniometer.x.set(x), goniometer.y.set(y)) - perp_before = goniometer._read_perp_calc(x, y, theta) + perp_before = goniometer._read_perp_calc(x, y, angle_deg) new_long = 20.0 await goniometer.long.set(new_long) @@ -51,8 +51,8 @@ async def test_goniometer_set_long(goniometer: I05Goniometer) -> None: x_new = await goniometer.x.user_readback.get_value() y_new = await goniometer.y.user_readback.get_value() - long_after = goniometer._read_long_calc(x_new, y_new, theta) - perp_after = goniometer._read_perp_calc(x_new, y_new, theta) + long_after = goniometer._read_long_calc(x_new, y_new, angle_deg) + perp_after = goniometer._read_perp_calc(x_new, y_new, angle_deg) assert long_after == pytest.approx(new_long) assert perp_after == pytest.approx(perp_before) @@ -60,10 +60,10 @@ async def test_goniometer_set_long(goniometer: I05Goniometer) -> None: async def test_goniometer_set_perp(goniometer: I05Goniometer) -> None: x, y = 10.0, 5.0 - theta = radians(goniometer.rotation_angle_deg) + angle_deg = goniometer.rotation_angle_deg await asyncio.gather(goniometer.x.set(x), goniometer.y.set(y)) - long_before = goniometer._read_long_calc(x, y, theta) + long_before = goniometer._read_long_calc(x, y, angle_deg) new_perp = 15.0 await goniometer.perp.set(new_perp) @@ -71,8 +71,8 @@ async def test_goniometer_set_perp(goniometer: I05Goniometer) -> None: x_new_perp = await goniometer.x.user_readback.get_value() y_new_perp = await goniometer.y.user_readback.get_value() - long_after_perp = goniometer._read_long_calc(x_new_perp, y_new_perp, theta) - perp_after_perp = goniometer._read_perp_calc(x_new_perp, y_new_perp, theta) + long_after_perp = goniometer._read_long_calc(x_new_perp, y_new_perp, angle_deg) + perp_after_perp = goniometer._read_perp_calc(x_new_perp, y_new_perp, angle_deg) assert perp_after_perp == pytest.approx(new_perp, abs=1e-5) assert long_after_perp == pytest.approx(long_before, abs=1e-5) From f914865555ac351651bd48b8a4fef43510a84dd7 Mon Sep 17 00:00:00 2001 From: Oli Wenman Date: Thu, 29 Jan 2026 11:33:53 +0000 Subject: [PATCH 24/81] Moved Vector2D to i05_shared (for now) --- src/dodal/devices/i05/motors.py | 74 +----------------------- src/dodal/devices/i05_shared/__init__.py | 0 src/dodal/devices/i05_shared/math.py | 72 +++++++++++++++++++++++ 3 files changed, 74 insertions(+), 72 deletions(-) create mode 100644 src/dodal/devices/i05_shared/__init__.py create mode 100644 src/dodal/devices/i05_shared/math.py diff --git a/src/dodal/devices/i05/motors.py b/src/dodal/devices/i05/motors.py index 66959df50b..9e00e97899 100644 --- a/src/dodal/devices/i05/motors.py +++ b/src/dodal/devices/i05/motors.py @@ -1,13 +1,10 @@ import asyncio -from collections.abc import Callable -from dataclasses import dataclass -from math import radians -import numpy as np from numpy import cos as c from numpy import sin as s from ophyd_async.core import derived_signal_rw +from dodal.devices.i05_shared.math import Vector2D from dodal.devices.motors import ( _AZIMUTH, _POLAR, @@ -19,78 +16,11 @@ ) -@dataclass -class Vector2D: - """ - Helper class that can perform rotations on x and y. It is configured with a matrix - of callables so this can be used for any kind of rotation. - - from numpy import cos as c - from numpy import sin as s - - def neg_s(x: float) -> float: - return -s(x) - - ROTATION_MATRIX = [[c, s], [neg_s, c]] - x, y = 1, 5 - - vec = Vector2D(x, y, ROTATION_MATRIX) - new_vec = vec.rotate_deg(45) - new_x = new_vec.x - new_y = new_vec.y - """ - - x: float - y: float - callable_rotation_matrix: list[list[Callable[[float], float]]] - - def to_array(self) -> np.ndarray: - return np.array([self.x, self.y]) - - @classmethod - def from_array( - cls, - arr: np.ndarray, - callable_rotation_matrix: list[list[Callable[[float], float]]], - ) -> "Vector2D": - return cls( - x=arr[0], y=arr[1], callable_rotation_matrix=callable_rotation_matrix - ) - - def _rotation_matrix(self, theta) -> np.ndarray: - return np.array( - [ - [fn(theta) for fn in row_functions] - for row_functions in self.callable_rotation_matrix - ] - ) - - def _inverse_rotation_matrix(self, theta) -> np.ndarray: - return self._rotation_matrix(theta).T - - def rotate_deg(self, angle_deg: float) -> "Vector2D": - return self.rotate(radians(angle_deg)) - - def rotate(self, theta: float) -> "Vector2D": - rotated = self._rotation_matrix(theta) @ self.to_array() - return Vector2D.from_array(rotated, self.callable_rotation_matrix) - - def inverse_rotate_deg(self, angle_deg: float) -> "Vector2D": - return self.inverse_rotate(radians(angle_deg)) - - def inverse_rotate(self, theta: float) -> "Vector2D": - rotated = self._inverse_rotation_matrix(theta) @ self.to_array() - return Vector2D.from_array(rotated, self.callable_rotation_matrix) - - def __repr__(self): - return f"Vector2D(x={self.x}, y={self.y})" - - def neg_s(x: float) -> float: return -s(x) -ROTATION_MATRIX = rotation_matrix = [[c, s], [neg_s, c]] +ROTATION_MATRIX = [[c, s], [neg_s, c]] class I05Goniometer(XYZPolarAzimuthTiltStage): diff --git a/src/dodal/devices/i05_shared/__init__.py b/src/dodal/devices/i05_shared/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/src/dodal/devices/i05_shared/math.py b/src/dodal/devices/i05_shared/math.py new file mode 100644 index 0000000000..a582f2216e --- /dev/null +++ b/src/dodal/devices/i05_shared/math.py @@ -0,0 +1,72 @@ +from collections.abc import Callable +from dataclasses import dataclass +from math import radians + +import numpy as np + + +@dataclass +class Vector2D: + """ + Helper class that can perform rotations on x and y. It is configured with a matrix + of callables so this can be used for any kind of rotation. + + from numpy import cos as c + from numpy import sin as s + + def neg_s(x: float) -> float: + return -s(x) + + ROTATION_MATRIX = [[c, s], [neg_s, c]] + x, y = 1, 5 + + vec = Vector2D(x, y, ROTATION_MATRIX) + new_vec = vec.rotate_deg(45) + new_x = new_vec.x + new_y = new_vec.y + """ + + x: float + y: float + callable_rotation_matrix: list[list[Callable[[float], float]]] + + def to_array(self) -> np.ndarray: + return np.array([self.x, self.y]) + + @classmethod + def from_array( + cls, + arr: np.ndarray, + callable_rotation_matrix: list[list[Callable[[float], float]]], + ) -> "Vector2D": + return cls( + x=arr[0], y=arr[1], callable_rotation_matrix=callable_rotation_matrix + ) + + def _rotation_matrix(self, theta) -> np.ndarray: + return np.array( + [ + [fn(theta) for fn in row_functions] + for row_functions in self.callable_rotation_matrix + ] + ) + + def _inverse_rotation_matrix(self, theta) -> np.ndarray: + return self._rotation_matrix(theta).T + + def rotate_deg(self, angle_deg: float) -> "Vector2D": + return self.rotate(radians(angle_deg)) + + def rotate(self, theta: float) -> "Vector2D": + rotated = self._rotation_matrix(theta) @ self.to_array() + return Vector2D.from_array(rotated, self.callable_rotation_matrix) + + def inverse_rotate_deg(self, angle_deg: float) -> "Vector2D": + return self.inverse_rotate(radians(angle_deg)) + + def inverse_rotate(self, theta: float) -> "Vector2D": + rotated = self._inverse_rotation_matrix(theta) @ self.to_array() + return Vector2D.from_array(rotated, self.callable_rotation_matrix) + + def __repr__(self): + return f"Vector2D(x={self.x}, y={self.y})" From 2bb24a9a3a2f8a814232b797808d7d70147b4d90 Mon Sep 17 00:00:00 2001 From: Oli Wenman Date: Thu, 29 Jan 2026 12:11:05 +0000 Subject: [PATCH 25/81] Rename files to make less confusing when editing --- src/dodal/devices/i05/__init__.py | 3 +- .../devices/i05/{motors.py => i05_motors.py} | 32 +++++++++---------- .../i05_1/{motors.py => i05_1_motors.py} | 0 .../{test_motors.py => test_i05_motors.py} | 2 +- .../{test_motors.py => test_i05_1_motors.py} | 0 5 files changed, 19 insertions(+), 18 deletions(-) rename src/dodal/devices/i05/{motors.py => i05_motors.py} (86%) rename src/dodal/devices/i05_1/{motors.py => i05_1_motors.py} (100%) rename tests/devices/i05/{test_motors.py => test_i05_motors.py} (98%) rename tests/devices/i05_1/{test_motors.py => test_i05_1_motors.py} (100%) diff --git a/src/dodal/devices/i05/__init__.py b/src/dodal/devices/i05/__init__.py index 608b87cc86..2aa947e836 100644 --- a/src/dodal/devices/i05/__init__.py +++ b/src/dodal/devices/i05/__init__.py @@ -1,3 +1,4 @@ from dodal.devices.i05.enums import Grating +from dodal.devices.i05.i05_motors import I05Goniometer -__all__ = ["Grating"] +__all__ = ["Grating", "I05Goniometer"] diff --git a/src/dodal/devices/i05/motors.py b/src/dodal/devices/i05/i05_motors.py similarity index 86% rename from src/dodal/devices/i05/motors.py rename to src/dodal/devices/i05/i05_motors.py index 9e00e97899..0346e9a322 100644 --- a/src/dodal/devices/i05/motors.py +++ b/src/dodal/devices/i05/i05_motors.py @@ -71,32 +71,32 @@ def __init__( angle_deg=self.rotation_angle_deg, ) - def _read_long_calc(self, x: float, y: float, angle_deg: float) -> float: + def _read_perp_calc(self, x: float, y: float, angle_deg: float) -> float: vec = Vector2D(x, y, ROTATION_MATRIX) - return vec.rotate_deg(angle_deg).y + return vec.rotate_deg(angle_deg).x - async def _set_long_calc(self, value: float) -> None: - x_pos, y_pos = await asyncio.gather( - self.x.user_readback.get_value(), + async def _set_perp_calc(self, value: float) -> None: + z_pos, y_pos = await asyncio.gather( + self.z.user_readback.get_value(), self.y.user_readback.get_value(), ) - perp = self._read_perp_calc(x_pos, y_pos, self.rotation_angle_deg) - vec = Vector2D(perp, value, ROTATION_MATRIX) + long = self._read_long_calc(z_pos, y_pos, self.rotation_angle_deg) + vec = Vector2D(value, long, ROTATION_MATRIX) new_pos = vec.inverse_rotate_deg(self.rotation_angle_deg) - await asyncio.gather(self.x.set(new_pos.x), self.y.set(new_pos.y)) + await asyncio.gather(self.z.set(new_pos.x), self.y.set(new_pos.y)) - def _read_perp_calc(self, x: float, y: float, angle_deg: float) -> float: + def _read_long_calc(self, x: float, y: float, angle_deg: float) -> float: vec = Vector2D(x, y, ROTATION_MATRIX) - return vec.rotate_deg(angle_deg).x + return vec.rotate_deg(angle_deg).y - async def _set_perp_calc(self, value: float) -> None: - x_pos, y_pos = await asyncio.gather( - self.x.user_readback.get_value(), + async def _set_long_calc(self, value: float) -> None: + z_pos, y_pos = await asyncio.gather( + self.z.user_readback.get_value(), self.y.user_readback.get_value(), ) - long = self._read_long_calc(x_pos, y_pos, self.rotation_angle_deg) - vec = Vector2D(value, long, ROTATION_MATRIX) + perp = self._read_perp_calc(z_pos, y_pos, self.rotation_angle_deg) + vec = Vector2D(perp, value, ROTATION_MATRIX) new_pos = vec.inverse_rotate_deg(self.rotation_angle_deg) - await asyncio.gather(self.x.set(new_pos.x), self.y.set(new_pos.y)) + await asyncio.gather(self.z.set(new_pos.x), self.y.set(new_pos.y)) diff --git a/src/dodal/devices/i05_1/motors.py b/src/dodal/devices/i05_1/i05_1_motors.py similarity index 100% rename from src/dodal/devices/i05_1/motors.py rename to src/dodal/devices/i05_1/i05_1_motors.py diff --git a/tests/devices/i05/test_motors.py b/tests/devices/i05/test_i05_motors.py similarity index 98% rename from tests/devices/i05/test_motors.py rename to tests/devices/i05/test_i05_motors.py index 5b50e368ab..b4bad9ade8 100644 --- a/tests/devices/i05/test_motors.py +++ b/tests/devices/i05/test_i05_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.i05.motors import I05Goniometer +from dodal.devices.i05 import I05Goniometer @pytest.fixture diff --git a/tests/devices/i05_1/test_motors.py b/tests/devices/i05_1/test_i05_1_motors.py similarity index 100% rename from tests/devices/i05_1/test_motors.py rename to tests/devices/i05_1/test_i05_1_motors.py From cde0f4cef155f972932ab89503a465f2e7abf290 Mon Sep 17 00:00:00 2001 From: Oli Wenman Date: Thu, 29 Jan 2026 12:12:07 +0000 Subject: [PATCH 26/81] Fix import --- src/dodal/beamlines/i05_1.py | 2 +- src/dodal/devices/i05_1/__init__.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/dodal/beamlines/i05_1.py b/src/dodal/beamlines/i05_1.py index fba4f0e38e..135a63be7d 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.i05_1.motors import XYZPolarAzimuthDefocusStage +from dodal.devices.i05_1 import XYZPolarAzimuthDefocusStage from dodal.log import set_beamline as set_log_beamline from dodal.utils import BeamlinePrefix, get_beamline_name diff --git a/src/dodal/devices/i05_1/__init__.py b/src/dodal/devices/i05_1/__init__.py index 3c3f4509f6..5cb81dab9d 100644 --- a/src/dodal/devices/i05_1/__init__.py +++ b/src/dodal/devices/i05_1/__init__.py @@ -1,3 +1,3 @@ -from .motors import XYZPolarAzimuthDefocusStage +from .i05_1_motors import XYZPolarAzimuthDefocusStage __all__ = ["XYZPolarAzimuthDefocusStage"] From a33e813324a38f688e3901f4490163811616f9ad Mon Sep 17 00:00:00 2001 From: Oli Wenman Date: Thu, 29 Jan 2026 12:12:33 +0000 Subject: [PATCH 27/81] Fix import --- src/dodal/beamlines/i05.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/dodal/beamlines/i05.py b/src/dodal/beamlines/i05.py index 947afe2064..4cdc589053 100644 --- a/src/dodal/beamlines/i05.py +++ b/src/dodal/beamlines/i05.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.i05.motors import I05Goniometer +from dodal.devices.i05 import I05Goniometer from dodal.devices.temperture_controller import Lakeshore336 from dodal.log import set_beamline as set_log_beamline from dodal.utils import BeamlinePrefix, get_beamline_name From 41b0bad36ca31a13d0b8381950208419c356abb3 Mon Sep 17 00:00:00 2001 From: Oli Wenman Date: Thu, 29 Jan 2026 12:50:26 +0000 Subject: [PATCH 28/81] Removed class and instead use functions --- src/dodal/devices/i05/i05_motors.py | 41 +++++----- src/dodal/devices/i05_shared/math.py | 110 +++++++++++---------------- 2 files changed, 68 insertions(+), 83 deletions(-) diff --git a/src/dodal/devices/i05/i05_motors.py b/src/dodal/devices/i05/i05_motors.py index 0346e9a322..d5f5a7e1d6 100644 --- a/src/dodal/devices/i05/i05_motors.py +++ b/src/dodal/devices/i05/i05_motors.py @@ -1,10 +1,11 @@ import asyncio +from math import radians from numpy import cos as c from numpy import sin as s from ophyd_async.core import derived_signal_rw -from dodal.devices.i05_shared.math import Vector2D +from dodal.devices.i05_shared.math import inverse_rotate, rotate from dodal.devices.motors import ( _AZIMUTH, _POLAR, @@ -72,31 +73,33 @@ def __init__( ) def _read_perp_calc(self, x: float, y: float, angle_deg: float) -> float: - vec = Vector2D(x, y, ROTATION_MATRIX) - return vec.rotate_deg(angle_deg).x + new_x, new_y = rotate(radians(angle_deg), x, y, ROTATION_MATRIX) + return new_x async def _set_perp_calc(self, value: float) -> None: - z_pos, y_pos = await asyncio.gather( - self.z.user_readback.get_value(), + x_pos, y_pos = await asyncio.gather( + self.x.user_readback.get_value(), self.y.user_readback.get_value(), ) - long = self._read_long_calc(z_pos, y_pos, self.rotation_angle_deg) - vec = Vector2D(value, long, ROTATION_MATRIX) - new_pos = vec.inverse_rotate_deg(self.rotation_angle_deg) - - await asyncio.gather(self.z.set(new_pos.x), self.y.set(new_pos.y)) + perp = value + long = self._read_long_calc(x_pos, y_pos, self.rotation_angle_deg) + new_x, new_y = inverse_rotate( + radians(self.rotation_angle_deg), perp, long, ROTATION_MATRIX + ) + await asyncio.gather(self.x.set(new_x), self.y.set(new_y)) def _read_long_calc(self, x: float, y: float, angle_deg: float) -> float: - vec = Vector2D(x, y, ROTATION_MATRIX) - return vec.rotate_deg(angle_deg).y + new_x, new_y = rotate(radians(angle_deg), x, y, ROTATION_MATRIX) + return new_y async def _set_long_calc(self, value: float) -> None: - z_pos, y_pos = await asyncio.gather( - self.z.user_readback.get_value(), + x_pos, y_pos = await asyncio.gather( + self.x.user_readback.get_value(), self.y.user_readback.get_value(), ) - perp = self._read_perp_calc(z_pos, y_pos, self.rotation_angle_deg) - vec = Vector2D(perp, value, ROTATION_MATRIX) - new_pos = vec.inverse_rotate_deg(self.rotation_angle_deg) - - await asyncio.gather(self.z.set(new_pos.x), self.y.set(new_pos.y)) + perp = self._read_perp_calc(x_pos, y_pos, self.rotation_angle_deg) + long = value + new_x, new_y = inverse_rotate( + radians(self.rotation_angle_deg), perp, long, ROTATION_MATRIX + ) + await asyncio.gather(self.x.set(new_x), self.y.set(new_y)) diff --git a/src/dodal/devices/i05_shared/math.py b/src/dodal/devices/i05_shared/math.py index a582f2216e..994e6bc12c 100644 --- a/src/dodal/devices/i05_shared/math.py +++ b/src/dodal/devices/i05_shared/math.py @@ -1,72 +1,54 @@ -from collections.abc import Callable -from dataclasses import dataclass -from math import radians - -import numpy as np - - -@dataclass -class Vector2D: - """ - Helper class that can perform rotations on x and y. It is configured with a matrix - of callables so this can be used for any kind of rotation. - - from numpy import cos as c - from numpy import sin as s - - def neg_s(x: float) -> float: - return -s(x) +""" +Helper functions that can perform rotations on x and y. A matrix of callables is used so +that any rotation can be used. - ROTATION_MATRIX = [[c, s], [neg_s, c]] - x, y = 1, 5 +from numpy import cos as c +from numpy import sin as s - vec = Vector2D(x, y, ROTATION_MATRIX) - new_vec = vec.rotate_deg(45) - new_x = new_vec.x - new_y = new_vec.y - """ +def neg_s(x: float) -> float: + return -s(x) - x: float - y: float - callable_rotation_matrix: list[list[Callable[[float], float]]] +ROTATION_MATRIX = [[c, s], [neg_s, c]] +x, y = 1, 5 - def to_array(self) -> np.ndarray: - return np.array([self.x, self.y]) +new_x, new_y = rotate_deg(45, x, y, ROTATION_MATRIX) +""" - @classmethod - def from_array( - cls, - arr: np.ndarray, - callable_rotation_matrix: list[list[Callable[[float], float]]], - ) -> "Vector2D": - return cls( - x=arr[0], y=arr[1], callable_rotation_matrix=callable_rotation_matrix - ) - - def _rotation_matrix(self, theta) -> np.ndarray: - return np.array( - [ - [fn(theta) for fn in row_functions] - for row_functions in self.callable_rotation_matrix - ] - ) - - def _inverse_rotation_matrix(self, theta) -> np.ndarray: - return self._rotation_matrix(theta).T - - def rotate_deg(self, angle_deg: float) -> "Vector2D": - return self.rotate(radians(angle_deg)) - - def rotate(self, theta: float) -> "Vector2D": - rotated = self._rotation_matrix(theta) @ self.to_array() - return Vector2D.from_array(rotated, self.callable_rotation_matrix) +from collections.abc import Callable - def inverse_rotate_deg(self, angle_deg: float) -> "Vector2D": - return self.inverse_rotate(radians(angle_deg)) +import numpy as np - def inverse_rotate(self, theta: float) -> "Vector2D": - rotated = self._inverse_rotation_matrix(theta) @ self.to_array() - return Vector2D.from_array(rotated, self.callable_rotation_matrix) - def __repr__(self): - return f"Vector2D(x={self.x}, y={self.y})" +def _get_rotation_matrix( + theta: float, callable_rotation_matrix: list[list[Callable[[float], float]]] +) -> np.ndarray: + return np.array( + [ + [fn(theta) for fn in row_functions] + for row_functions in callable_rotation_matrix + ] + ) + + +def rotate( + theta: float, + x: float, + y: float, + callable_rotation_matrix: list[list[Callable[[float], float]]], +) -> tuple[float, float]: + rotation_matrix = _get_rotation_matrix(theta, callable_rotation_matrix) + positions = np.array([x, y]) + rotation = rotation_matrix @ positions + return rotation[0], rotation[1] + + +def inverse_rotate( + theta: float, + x: float, + y: float, + callable_rotation_matrix: list[list[Callable[[float], float]]], +) -> tuple[float, float]: + rotation_matrix = _get_rotation_matrix(theta, callable_rotation_matrix) + positions = np.array([x, y]) + rotation = rotation_matrix.T @ positions + return rotation[0], rotation[1] From a6034326dbd0b4a79a49eab304e7599f508c0e63 Mon Sep 17 00:00:00 2001 From: Oli Wenman Date: Thu, 29 Jan 2026 13:55:33 +0000 Subject: [PATCH 29/81] Further simplifed, single function --- src/dodal/devices/i05/i05_motors.py | 15 +++++++++------ src/dodal/devices/i05_shared/math.py | 24 ++++++++++-------------- 2 files changed, 19 insertions(+), 20 deletions(-) diff --git a/src/dodal/devices/i05/i05_motors.py b/src/dodal/devices/i05/i05_motors.py index d5f5a7e1d6..fabdd70597 100644 --- a/src/dodal/devices/i05/i05_motors.py +++ b/src/dodal/devices/i05/i05_motors.py @@ -5,7 +5,7 @@ from numpy import sin as s from ophyd_async.core import derived_signal_rw -from dodal.devices.i05_shared.math import inverse_rotate, rotate +from dodal.devices.i05_shared.math import rotate from dodal.devices.motors import ( _AZIMUTH, _POLAR, @@ -21,7 +21,10 @@ def neg_s(x: float) -> float: return -s(x) -ROTATION_MATRIX = [[c, s], [neg_s, c]] +ROTATION_MATRIX = ( + (c, s), + (neg_s, c), +) class I05Goniometer(XYZPolarAzimuthTiltStage): @@ -83,8 +86,8 @@ async def _set_perp_calc(self, value: float) -> None: ) perp = value long = self._read_long_calc(x_pos, y_pos, self.rotation_angle_deg) - new_x, new_y = inverse_rotate( - radians(self.rotation_angle_deg), perp, long, ROTATION_MATRIX + new_x, new_y = rotate( + radians(self.rotation_angle_deg), perp, long, ROTATION_MATRIX, inverse=True ) await asyncio.gather(self.x.set(new_x), self.y.set(new_y)) @@ -99,7 +102,7 @@ async def _set_long_calc(self, value: float) -> None: ) perp = self._read_perp_calc(x_pos, y_pos, self.rotation_angle_deg) long = value - new_x, new_y = inverse_rotate( - radians(self.rotation_angle_deg), perp, long, ROTATION_MATRIX + new_x, new_y = rotate( + radians(self.rotation_angle_deg), perp, long, ROTATION_MATRIX, inverse=True ) await asyncio.gather(self.x.set(new_x), self.y.set(new_y)) diff --git a/src/dodal/devices/i05_shared/math.py b/src/dodal/devices/i05_shared/math.py index 994e6bc12c..c538252b3b 100644 --- a/src/dodal/devices/i05_shared/math.py +++ b/src/dodal/devices/i05_shared/math.py @@ -18,9 +18,14 @@ def neg_s(x: float) -> float: import numpy as np +CallableRotationMatrixType = tuple[ + tuple[Callable[[float], float], Callable[[float], float]], + tuple[Callable[[float], float], Callable[[float], float]], +] + def _get_rotation_matrix( - theta: float, callable_rotation_matrix: list[list[Callable[[float], float]]] + theta: float, callable_rotation_matrix: CallableRotationMatrixType ) -> np.ndarray: return np.array( [ @@ -34,21 +39,12 @@ def rotate( theta: float, x: float, y: float, - callable_rotation_matrix: list[list[Callable[[float], float]]], + callable_rotation_matrix: CallableRotationMatrixType, + inverse: bool = False, ) -> tuple[float, float]: rotation_matrix = _get_rotation_matrix(theta, callable_rotation_matrix) + if inverse: + rotation_matrix = rotation_matrix.T positions = np.array([x, y]) rotation = rotation_matrix @ positions return rotation[0], rotation[1] - - -def inverse_rotate( - theta: float, - x: float, - y: float, - callable_rotation_matrix: list[list[Callable[[float], float]]], -) -> tuple[float, float]: - rotation_matrix = _get_rotation_matrix(theta, callable_rotation_matrix) - positions = np.array([x, y]) - rotation = rotation_matrix.T @ positions - return rotation[0], rotation[1] From d848d244b31fbd924af2ed5f3226983ff0d78fea Mon Sep 17 00:00:00 2001 From: Oli Wenman Date: Thu, 29 Jan 2026 13:57:42 +0000 Subject: [PATCH 30/81] Improved doc string --- src/dodal/devices/i05_shared/math.py | 32 ++++++++++++++-------------- 1 file changed, 16 insertions(+), 16 deletions(-) diff --git a/src/dodal/devices/i05_shared/math.py b/src/dodal/devices/i05_shared/math.py index c538252b3b..86e88222b2 100644 --- a/src/dodal/devices/i05_shared/math.py +++ b/src/dodal/devices/i05_shared/math.py @@ -1,19 +1,3 @@ -""" -Helper functions that can perform rotations on x and y. A matrix of callables is used so -that any rotation can be used. - -from numpy import cos as c -from numpy import sin as s - -def neg_s(x: float) -> float: - return -s(x) - -ROTATION_MATRIX = [[c, s], [neg_s, c]] -x, y = 1, 5 - -new_x, new_y = rotate_deg(45, x, y, ROTATION_MATRIX) -""" - from collections.abc import Callable import numpy as np @@ -42,6 +26,22 @@ def rotate( callable_rotation_matrix: CallableRotationMatrixType, inverse: bool = False, ) -> tuple[float, float]: + """ + Helper functions that can perform rotations on x and y. A matrix of callables is + parsed so any rotation can be used. An example is shown below: + + from numpy import cos as c + from numpy import sin as s + from numpy import radians + + def neg_s(x: float) -> float: + return -s(x) + + ROTATION_MATRIX = [[c, s], [neg_s, c]] + x, y = 1, 5 + + new_x, new_y = rotate(radians(45), x, y, ROTATION_MATRIX) + """ rotation_matrix = _get_rotation_matrix(theta, callable_rotation_matrix) if inverse: rotation_matrix = rotation_matrix.T From a1a1599eba33005710ba4047ed8110de99959b2e Mon Sep 17 00:00:00 2001 From: Oli Wenman Date: Thu, 29 Jan 2026 13:58:54 +0000 Subject: [PATCH 31/81] Fix doc string import --- src/dodal/devices/i05_shared/math.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/dodal/devices/i05_shared/math.py b/src/dodal/devices/i05_shared/math.py index 86e88222b2..df45344b9c 100644 --- a/src/dodal/devices/i05_shared/math.py +++ b/src/dodal/devices/i05_shared/math.py @@ -32,7 +32,7 @@ def rotate( from numpy import cos as c from numpy import sin as s - from numpy import radians + from math import radians def neg_s(x: float) -> float: return -s(x) From a59f53589a0163c5efcefcd869d7deb2299bc985 Mon Sep 17 00:00:00 2001 From: Oli Wenman Date: Thu, 29 Jan 2026 15:15:33 +0000 Subject: [PATCH 32/81] First attempt at creaint helper function to build signals --- src/dodal/devices/i05/i05_motors.py | 85 +++++++++++++++++++++++++---- 1 file changed, 73 insertions(+), 12 deletions(-) diff --git a/src/dodal/devices/i05/i05_motors.py b/src/dodal/devices/i05/i05_motors.py index fabdd70597..11ac24b5bc 100644 --- a/src/dodal/devices/i05/i05_motors.py +++ b/src/dodal/devices/i05/i05_motors.py @@ -3,9 +3,10 @@ from numpy import cos as c from numpy import sin as s -from ophyd_async.core import derived_signal_rw +from ophyd_async.core import SignalR, SignalRW, derived_signal_rw +from ophyd_async.epics.motor import Motor -from dodal.devices.i05_shared.math import rotate +from dodal.devices.i05_shared.math import CallableRotationMatrixType, rotate from dodal.devices.motors import ( _AZIMUTH, _POLAR, @@ -60,19 +61,33 @@ def __init__( ) with self.add_children_as_readables(): - self.long = derived_signal_rw( - self._read_long_calc, - self._set_long_calc, - x=self.x, - y=self.y, + # self.perp = derived_signal_rw( + # self._read_perp_calc, + # self._set_perp_calc, + # x=self.x, + # y=self.y, + # angle_deg=self.rotation_angle_deg, + # ) + # self.long = derived_signal_rw( + # self._read_long_calc, + # self._set_long_calc, + # x=self.x, + # y=self.y, + # angle_deg=self.rotation_angle_deg, + # ) + self.perp = create_rw_rotation_axis_signal( + i=self.x, + j=self.y, angle_deg=self.rotation_angle_deg, + rotation_matrix=ROTATION_MATRIX, + i_axis=True, ) - self.perp = derived_signal_rw( - self._read_perp_calc, - self._set_perp_calc, - x=self.x, - y=self.y, + self.long = create_rw_rotation_axis_signal( + i=self.x, + j=self.y, angle_deg=self.rotation_angle_deg, + rotation_matrix=ROTATION_MATRIX, + i_axis=False, ) def _read_perp_calc(self, x: float, y: float, angle_deg: float) -> float: @@ -106,3 +121,49 @@ async def _set_long_calc(self, value: float) -> None: radians(self.rotation_angle_deg), perp, long, ROTATION_MATRIX, inverse=True ) await asyncio.gather(self.x.set(new_x), self.y.set(new_y)) + + +def create_rw_rotation_axis_signal( + i: Motor, + j: Motor, + angle_deg: SignalR[float] | float, + rotation_matrix: CallableRotationMatrixType, + i_axis: bool, +) -> SignalRW[float]: + 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 _read_rotate_calc( + i: float, j: float, angle_deg: float, return_i: bool + ) -> float: + new_i, new_j = rotate(radians(angle_deg), i, j, rotation_matrix) + return new_i if return_i else new_j + + async def _set_inverse_rotate_calc(value: float) -> None: + i_pos, j_pos, angle_deg_pos = await asyncio.gather( + i.user_readback.get_value(), + j.user_readback.get_value(), + _get_angle_deg(angle_deg), + ) + if i_axis: + i_rotate = value + j_rotate = _read_rotate_calc(i_pos, j_pos, angle_deg_pos, return_i=False) + else: + i_rotate = _read_rotate_calc(i_pos, j_pos, angle_deg_pos, return_i=True) + j_rotate = value + + new_i, new_j = rotate( + radians(angle_deg_pos), i_rotate, j_rotate, rotation_matrix, inverse=True + ) + await asyncio.gather(i.set(new_i), j.set(new_j)) + + return derived_signal_rw( + _read_rotate_calc, + _set_inverse_rotate_calc, + i=i, + j=j, + angle_deg=angle_deg, + return_i=i_axis, + ) From b31c7938439a1a573ae6006666bafebe84942614 Mon Sep 17 00:00:00 2001 From: Oli Wenman Date: Fri, 30 Jan 2026 09:25:08 +0000 Subject: [PATCH 33/81] Optmise test --- tests/devices/i05/test_i05_motors.py | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/tests/devices/i05/test_i05_motors.py b/tests/devices/i05/test_i05_motors.py index b4bad9ade8..4e9e296ea3 100644 --- a/tests/devices/i05/test_i05_motors.py +++ b/tests/devices/i05/test_i05_motors.py @@ -48,14 +48,15 @@ async def test_goniometer_set_long(goniometer: I05Goniometer) -> None: new_long = 20.0 await goniometer.long.set(new_long) - x_new = await goniometer.x.user_readback.get_value() - y_new = await goniometer.y.user_readback.get_value() + x_new, y_new = await asyncio.gather( + goniometer.x.user_readback.get_value(), goniometer.y.user_readback.get_value() + ) - long_after = goniometer._read_long_calc(x_new, y_new, angle_deg) perp_after = goniometer._read_perp_calc(x_new, y_new, angle_deg) + long_after = goniometer._read_long_calc(x_new, y_new, angle_deg) - assert long_after == pytest.approx(new_long) assert perp_after == pytest.approx(perp_before) + assert long_after == pytest.approx(new_long) async def test_goniometer_set_perp(goniometer: I05Goniometer) -> None: @@ -68,8 +69,9 @@ async def test_goniometer_set_perp(goniometer: I05Goniometer) -> None: new_perp = 15.0 await goniometer.perp.set(new_perp) - x_new_perp = await goniometer.x.user_readback.get_value() - y_new_perp = await goniometer.y.user_readback.get_value() + x_new_perp, y_new_perp = await asyncio.gather( + goniometer.x.user_readback.get_value(), goniometer.y.user_readback.get_value() + ) long_after_perp = goniometer._read_long_calc(x_new_perp, y_new_perp, angle_deg) perp_after_perp = goniometer._read_perp_calc(x_new_perp, y_new_perp, angle_deg) From 28970738e31157930b8534da1e072b1e215bcdaa Mon Sep 17 00:00:00 2001 From: Oli Wenman Date: Fri, 30 Jan 2026 17:48:23 +0000 Subject: [PATCH 34/81] Create helper function to create i and j virtaul axis. Apply to i05 and i05_1 motors --- src/dodal/devices/i05/i05_motors.py | 204 +++++++++++------- src/dodal/devices/i05_1/i05_1_motors.py | 26 ++- src/dodal/devices/i05_shared/math.py | 66 +++--- .../devices/i05_shared/rotation_signals.py | 0 tests/devices/i05_1/test_i05_1_motors.py | 33 +++ 5 files changed, 207 insertions(+), 122 deletions(-) create mode 100644 src/dodal/devices/i05_shared/rotation_signals.py diff --git a/src/dodal/devices/i05/i05_motors.py b/src/dodal/devices/i05/i05_motors.py index 11ac24b5bc..a1e26fe028 100644 --- a/src/dodal/devices/i05/i05_motors.py +++ b/src/dodal/devices/i05/i05_motors.py @@ -1,12 +1,15 @@ import asyncio from math import radians -from numpy import cos as c -from numpy import sin as s -from ophyd_async.core import SignalR, SignalRW, derived_signal_rw +from ophyd_async.core import ( + SignalR, + SignalRW, + derived_signal_rw, +) +from ophyd_async.core._protocol import AsyncMovable from ophyd_async.epics.motor import Motor -from dodal.devices.i05_shared.math import CallableRotationMatrixType, rotate +from dodal.devices.i05_shared.math import rotate_clockwise, rotate_counter_clockwise from dodal.devices.motors import ( _AZIMUTH, _POLAR, @@ -18,16 +21,6 @@ ) -def neg_s(x: float) -> float: - return -s(x) - - -ROTATION_MATRIX = ( - (c, s), - (neg_s, c), -) - - class I05Goniometer(XYZPolarAzimuthTiltStage): """ Six-axis stage with a standard xyz stage and three axis of rotation: polar, azimuth @@ -61,37 +54,12 @@ def __init__( ) with self.add_children_as_readables(): - # self.perp = derived_signal_rw( - # self._read_perp_calc, - # self._set_perp_calc, - # x=self.x, - # y=self.y, - # angle_deg=self.rotation_angle_deg, - # ) - # self.long = derived_signal_rw( - # self._read_long_calc, - # self._set_long_calc, - # x=self.x, - # y=self.y, - # angle_deg=self.rotation_angle_deg, - # ) - self.perp = create_rw_rotation_axis_signal( - i=self.x, - j=self.y, - angle_deg=self.rotation_angle_deg, - rotation_matrix=ROTATION_MATRIX, - i_axis=True, - ) - self.long = create_rw_rotation_axis_signal( - i=self.x, - j=self.y, - angle_deg=self.rotation_angle_deg, - rotation_matrix=ROTATION_MATRIX, - i_axis=False, + self.perp, self.long = create_rotational_ij_component_signals_with_motors( + self.x, self.y, self.rotation_angle_deg ) def _read_perp_calc(self, x: float, y: float, angle_deg: float) -> float: - new_x, new_y = rotate(radians(angle_deg), x, y, ROTATION_MATRIX) + new_x, new_y = rotate_clockwise(radians(angle_deg), x, y) return new_x async def _set_perp_calc(self, value: float) -> None: @@ -101,13 +69,13 @@ async def _set_perp_calc(self, value: float) -> None: ) perp = value long = self._read_long_calc(x_pos, y_pos, self.rotation_angle_deg) - new_x, new_y = rotate( - radians(self.rotation_angle_deg), perp, long, ROTATION_MATRIX, inverse=True + new_x, new_y = rotate_counter_clockwise( + radians(self.rotation_angle_deg), perp, long ) await asyncio.gather(self.x.set(new_x), self.y.set(new_y)) def _read_long_calc(self, x: float, y: float, angle_deg: float) -> float: - new_x, new_y = rotate(radians(angle_deg), x, y, ROTATION_MATRIX) + new_x, new_y = rotate_clockwise(radians(angle_deg), x, y) return new_y async def _set_long_calc(self, value: float) -> None: @@ -117,53 +85,123 @@ async def _set_long_calc(self, value: float) -> None: ) perp = self._read_perp_calc(x_pos, y_pos, self.rotation_angle_deg) long = value - new_x, new_y = rotate( - radians(self.rotation_angle_deg), perp, long, ROTATION_MATRIX, inverse=True + new_x, new_y = rotate_counter_clockwise( + radians(self.rotation_angle_deg), perp, long ) await asyncio.gather(self.x.set(new_x), self.y.set(new_y)) -def create_rw_rotation_axis_signal( - i: Motor, - j: Motor, - angle_deg: SignalR[float] | float, - rotation_matrix: CallableRotationMatrixType, - i_axis: bool, -) -> SignalRW[float]: - 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 _read_rotate_calc( - i: float, j: float, angle_deg: float, return_i: bool - ) -> float: - new_i, new_j = rotate(radians(angle_deg), i, j, rotation_matrix) - return new_i if return_i else new_j - - async def _set_inverse_rotate_calc(value: float) -> None: +# NOTE: This already exists in ophyd-async but is not exposed publically in current +# release. It has been made public now https://github.com/bluesky/ophyd-async/pull/1172 +# so the below can be removed once we move to latest ophyd-async release. +# @runtime_checkable +# class AsyncMovable(Protocol[T_co]): +# @abstractmethod +# def set(self, value: T_co) -> AsyncStatus: +# """Return a ``Status`` that is marked done when the device is done moving.""" + + +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: AsyncMovable[float], + j_write: AsyncMovable[float], + angle_deg: float | SignalR[float], +) -> tuple[SignalRW[float], SignalRW[float]]: + """ + Create virtual "rotated" i and j component signals from two real motors. + + These virtual signals represent the i/j components after rotation by a given + angle. When writing to these virtual signals, the real motor positions are + adjusted using the inverse rotation so that the rotated component moves to the + requested value. + + Args: + i_read (SignalR): SignalR representing the i motor readback. + j_read (SignalR): representing the j motor readback. + i_write (AsyncReadable): object for setting the i position. + j_write (AsyncReadbale): object for setting the j position. + angle_deg (float | SignalR): Rotation angle in degrees. + + Returns: + tuple[SignalRW[float], SignalRW[float]] Two virtual read/write signals + corresponding to the rotated i and j components. + """ + + def _read_i_rotation_component_calc(i: float, j: float, angle_deg: float) -> float: + new_i, new_j = rotate_clockwise(radians(angle_deg), i, j) + return new_i + + async def _set_i_rotation_component_calc(value: float) -> None: + """Move virtual i-axis to desired position while keeping j-axis constant in the + rotated frame.""" + i_pos, j_pos, angle_deg_pos = await asyncio.gather( + i_read.get_value(), + j_read.get_value(), + _get_angle_deg(angle_deg), + ) + # Rotated coordinates + i_rotation_target = value + j_rotation_current = _read_j_rotation_component_calc( + i_pos, j_pos, angle_deg_pos + ) + # Convert back to motor frame by doing inverse rotation to determine actual motor positions + new_i_pos, new_j_pos = rotate_counter_clockwise( + radians(angle_deg_pos), i_rotation_target, j_rotation_current + ) + await asyncio.gather(i_write.set(new_i_pos), j_write.set(new_j_pos)) + + def _read_j_rotation_component_calc(i: float, j: float, angle_deg: float) -> float: + new_i, new_j = rotate_clockwise(radians(angle_deg), i, j) + return new_j + + async def _set_j_rotation_component_calc(value: float) -> None: + """Move virtual j-axis to desired position while keeping j-axis constant in the + rotated frame.""" i_pos, j_pos, angle_deg_pos = await asyncio.gather( - i.user_readback.get_value(), - j.user_readback.get_value(), + i_read.get_value(), + j_read.get_value(), _get_angle_deg(angle_deg), ) - if i_axis: - i_rotate = value - j_rotate = _read_rotate_calc(i_pos, j_pos, angle_deg_pos, return_i=False) - else: - i_rotate = _read_rotate_calc(i_pos, j_pos, angle_deg_pos, return_i=True) - j_rotate = value - - new_i, new_j = rotate( - radians(angle_deg_pos), i_rotate, j_rotate, rotation_matrix, inverse=True + # Rotated coordinates + i_rotation_target = _read_i_rotation_component_calc(i_pos, j_pos, angle_deg_pos) + j_rotation_current = value + # Convert back to motor frame by doing inverse rotation to determine actual motor positions + new_i_pos, new_j_pos = rotate_counter_clockwise( + radians(angle_deg_pos), i_rotation_target, j_rotation_current ) - await asyncio.gather(i.set(new_i), j.set(new_j)) + await asyncio.gather(i_write.set(new_i_pos), j_write.set(new_j_pos)) return derived_signal_rw( - _read_rotate_calc, - _set_inverse_rotate_calc, - i=i, - j=j, + _read_i_rotation_component_calc, + _set_i_rotation_component_calc, + i=i_read, + j=j_read, + angle_deg=angle_deg, + ), derived_signal_rw( + _read_j_rotation_component_calc, + _set_j_rotation_component_calc, + i=i_read, + j=j_read, + angle_deg=angle_deg, + ) + + +def create_rotational_ij_component_signals_with_motors( + i: Motor, + j: Motor, + angle_deg: float | SignalR[float], +) -> tuple[SignalRW[float], SignalRW[float]]: + return create_rotational_ij_component_signals( + i_read=i.user_readback, + i_write=i, # type: ignore + j_read=j.user_readback, + j_write=j, # type: ignore angle_deg=angle_deg, - return_i=i_axis, ) diff --git a/src/dodal/devices/i05_1/i05_1_motors.py b/src/dodal/devices/i05_1/i05_1_motors.py index a594d24eb5..77ec38c86a 100644 --- a/src/dodal/devices/i05_1/i05_1_motors.py +++ b/src/dodal/devices/i05_1/i05_1_motors.py @@ -1,5 +1,9 @@ from ophyd_async.epics.motor import Motor +from dodal.devices.i05.i05_motors import ( + create_rotational_ij_component_signals, + create_rotational_ij_component_signals_with_motors, +) from dodal.devices.motors import XYZPolarAzimuthStage @@ -20,8 +24,6 @@ def __init__( defocus_infix="SDMF", name="", ): - with self.add_children_as_readables(): - self.defocus = Motor(prefix + defocus_infix) super().__init__( prefix, name, @@ -31,3 +33,23 @@ def __init__( polar_infix=polar_infix, azimuth_infix=azimuth_infix, ) + + with self.add_children_as_readables(): + self.defocus = Motor(prefix + defocus_infix) + self.hor, self.vert = create_rotational_ij_component_signals_with_motors( + i=self.x, + j=self.y, + angle_deg=self.azimuth.user_readback, + ) + self.perp, _ = create_rotational_ij_component_signals( + i_read=self.hor, + i_write=self.hor, + j_read=self.z.user_readback, + j_write=self.z, # type: ignore + angle_deg=self.azimuth.user_readback, + ) + self.long, _ = create_rotational_ij_component_signals_with_motors( + i=self.x, + j=self.y, + angle_deg=self.polar.user_readback, + ) diff --git a/src/dodal/devices/i05_shared/math.py b/src/dodal/devices/i05_shared/math.py index df45344b9c..b963368c93 100644 --- a/src/dodal/devices/i05_shared/math.py +++ b/src/dodal/devices/i05_shared/math.py @@ -1,50 +1,42 @@ -from collections.abc import Callable - import numpy as np -CallableRotationMatrixType = tuple[ - tuple[Callable[[float], float], Callable[[float], float]], - tuple[Callable[[float], float], Callable[[float], float]], -] +""" +| Rotation | Formula for X_rot | Formula for Y_rot | +| -------- | ----------------- | ----------------- | +| CCW | x cosθ - y sinθ | x sinθ + y cosθ | +| CW | x cosθ + y sinθ | -x sinθ + y cosθ | +""" + + +def do_rotation(x: float, y: float, rotation_matrix: np.ndarray) -> tuple[float, float]: + positions = np.array([x, y]) + rotation = rotation_matrix @ positions + return rotation[0], rotation[1] -def _get_rotation_matrix( - theta: float, callable_rotation_matrix: CallableRotationMatrixType -) -> np.ndarray: - return np.array( +def rotate_clockwise( + theta: float, + x: float, + y: float, +) -> tuple[float, float]: + rotation_matrix = np.array( [ - [fn(theta) for fn in row_functions] - for row_functions in callable_rotation_matrix + [np.cos(theta), np.sin(theta)], + [-np.sin(theta), np.cos(theta)], ] ) + return do_rotation(x, y, rotation_matrix) -def rotate( +def rotate_counter_clockwise( theta: float, x: float, y: float, - callable_rotation_matrix: CallableRotationMatrixType, - inverse: bool = False, ) -> tuple[float, float]: - """ - Helper functions that can perform rotations on x and y. A matrix of callables is - parsed so any rotation can be used. An example is shown below: - - from numpy import cos as c - from numpy import sin as s - from math import radians - - def neg_s(x: float) -> float: - return -s(x) - - ROTATION_MATRIX = [[c, s], [neg_s, c]] - x, y = 1, 5 - - new_x, new_y = rotate(radians(45), x, y, ROTATION_MATRIX) - """ - rotation_matrix = _get_rotation_matrix(theta, callable_rotation_matrix) - if inverse: - rotation_matrix = rotation_matrix.T - positions = np.array([x, y]) - rotation = rotation_matrix @ positions - return rotation[0], rotation[1] + rotation_matrix = np.array( + [ + [np.cos(theta), -np.sin(theta)], + [np.sin(theta), np.cos(theta)], + ] + ) + return do_rotation(x, y, rotation_matrix) diff --git a/src/dodal/devices/i05_shared/rotation_signals.py b/src/dodal/devices/i05_shared/rotation_signals.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/tests/devices/i05_1/test_i05_1_motors.py b/tests/devices/i05_1/test_i05_1_motors.py index ad728fde16..62e4994291 100644 --- a/tests/devices/i05_1/test_i05_1_motors.py +++ b/tests/devices/i05_1/test_i05_1_motors.py @@ -1,4 +1,8 @@ +import asyncio +from math import radians + import pytest +from numpy import cos, sin from ophyd_async.core import init_devices, set_mock_value from ophyd_async.testing import assert_reading, partial_reading @@ -12,6 +16,35 @@ def xyzpad_stage() -> XYZPolarAzimuthDefocusStage: return xyzpad_stage +async def test_goniometer_read(xyzpad_stage: XYZPolarAzimuthDefocusStage) -> None: + x, y = 10, 5 + angle_deg = 45 + theta = radians(angle_deg) + + expected_horz = x * cos(theta) + y * sin(theta) + expected_vert = x * -sin(theta) + y * cos(theta) + + await asyncio.gather( + xyzpad_stage.x.set(x), + xyzpad_stage.y.set(y), + xyzpad_stage.azimuth.set(angle_deg), + ) + + await assert_reading( + xyzpad_stage, + { + xyzpad_stage.x.name: partial_reading(x), + xyzpad_stage.y.name: partial_reading(y), + xyzpad_stage.z.name: partial_reading(0), + xyzpad_stage.polar.name: partial_reading(0), + xyzpad_stage.azimuth.name: partial_reading(angle_deg), + xyzpad_stage.defocus.name: partial_reading(0), + xyzpad_stage.hor.name: partial_reading(expected_horz), + xyzpad_stage.vert.name: partial_reading(expected_vert), + }, + ) + + @pytest.mark.parametrize( "x, y, z, polar, azimuth, defocus", [ From a28493b296d63192a8a33d369623b2ec37306fe2 Mon Sep 17 00:00:00 2001 From: Oli Wenman Date: Mon, 2 Feb 2026 10:42:21 +0000 Subject: [PATCH 35/81] perp and long motors now correctly done, added read test --- src/dodal/devices/i05/i05_motors.py | 16 +++--- src/dodal/devices/i05_1/i05_1_motors.py | 17 +++--- src/dodal/devices/i05_shared/math.py | 4 +- tests/devices/i05/test_i05_motors.py | 2 +- tests/devices/i05_1/test_i05_1_motors.py | 71 +++++++----------------- 5 files changed, 40 insertions(+), 70 deletions(-) diff --git a/src/dodal/devices/i05/i05_motors.py b/src/dodal/devices/i05/i05_motors.py index a1e26fe028..c93c484acd 100644 --- a/src/dodal/devices/i05/i05_motors.py +++ b/src/dodal/devices/i05/i05_motors.py @@ -113,6 +113,7 @@ def create_rotational_ij_component_signals( i_write: AsyncMovable[float], j_write: AsyncMovable[float], angle_deg: float | SignalR[float], + clockwise_frame: bool = True, ) -> tuple[SignalRW[float], SignalRW[float]]: """ Create virtual "rotated" i and j component signals from two real motors. @@ -134,8 +135,11 @@ def create_rotational_ij_component_signals( corresponding to the rotated i and j components. """ + rotate = rotate_clockwise if clockwise_frame else rotate_counter_clockwise + reverse_rotate = rotate_counter_clockwise if clockwise_frame else rotate_clockwise + def _read_i_rotation_component_calc(i: float, j: float, angle_deg: float) -> float: - new_i, new_j = rotate_clockwise(radians(angle_deg), i, j) + new_i, new_j = rotate(radians(angle_deg), i, j) return new_i async def _set_i_rotation_component_calc(value: float) -> None: @@ -152,13 +156,13 @@ async def _set_i_rotation_component_calc(value: float) -> None: i_pos, j_pos, angle_deg_pos ) # Convert back to motor frame by doing inverse rotation to determine actual motor positions - new_i_pos, new_j_pos = rotate_counter_clockwise( + new_i_pos, new_j_pos = reverse_rotate( radians(angle_deg_pos), i_rotation_target, j_rotation_current ) await asyncio.gather(i_write.set(new_i_pos), j_write.set(new_j_pos)) def _read_j_rotation_component_calc(i: float, j: float, angle_deg: float) -> float: - new_i, new_j = rotate_clockwise(radians(angle_deg), i, j) + new_i, new_j = rotate(radians(angle_deg), i, j) return new_j async def _set_j_rotation_component_calc(value: float) -> None: @@ -173,7 +177,7 @@ async def _set_j_rotation_component_calc(value: float) -> None: i_rotation_target = _read_i_rotation_component_calc(i_pos, j_pos, angle_deg_pos) j_rotation_current = value # Convert back to motor frame by doing inverse rotation to determine actual motor positions - new_i_pos, new_j_pos = rotate_counter_clockwise( + new_i_pos, new_j_pos = reverse_rotate( radians(angle_deg_pos), i_rotation_target, j_rotation_current ) await asyncio.gather(i_write.set(new_i_pos), j_write.set(new_j_pos)) @@ -194,9 +198,7 @@ async def _set_j_rotation_component_calc(value: float) -> None: def create_rotational_ij_component_signals_with_motors( - i: Motor, - j: Motor, - angle_deg: float | SignalR[float], + i: Motor, j: Motor, angle_deg: float | SignalR[float], clockwise_frame: bool = True ) -> tuple[SignalRW[float], SignalRW[float]]: return create_rotational_ij_component_signals( i_read=i.user_readback, diff --git a/src/dodal/devices/i05_1/i05_1_motors.py b/src/dodal/devices/i05_1/i05_1_motors.py index 77ec38c86a..ec0608a283 100644 --- a/src/dodal/devices/i05_1/i05_1_motors.py +++ b/src/dodal/devices/i05_1/i05_1_motors.py @@ -40,16 +40,13 @@ def __init__( i=self.x, j=self.y, angle_deg=self.azimuth.user_readback, + clockwise_frame=True, ) - self.perp, _ = create_rotational_ij_component_signals( - i_read=self.hor, - i_write=self.hor, - j_read=self.z.user_readback, - j_write=self.z, # type: ignore - angle_deg=self.azimuth.user_readback, - ) - self.long, _ = create_rotational_ij_component_signals_with_motors( - i=self.x, - j=self.y, + self.long, self.perp = create_rotational_ij_component_signals( + i_read=self.z.user_readback, + i_write=self.z, # type: ignore + j_read=self.hor, + j_write=self.hor, angle_deg=self.polar.user_readback, + clockwise_frame=False, ) diff --git a/src/dodal/devices/i05_shared/math.py b/src/dodal/devices/i05_shared/math.py index b963368c93..075527dc58 100644 --- a/src/dodal/devices/i05_shared/math.py +++ b/src/dodal/devices/i05_shared/math.py @@ -3,15 +3,15 @@ """ | Rotation | Formula for X_rot | Formula for Y_rot | | -------- | ----------------- | ----------------- | -| CCW | x cosθ - y sinθ | x sinθ + y cosθ | | CW | x cosθ + y sinθ | -x sinθ + y cosθ | +| CCW | x cosθ - y sinθ | x sinθ + y cosθ | """ def do_rotation(x: float, y: float, rotation_matrix: np.ndarray) -> tuple[float, float]: positions = np.array([x, y]) rotation = rotation_matrix @ positions - return rotation[0], rotation[1] + return float(rotation[0]), float(rotation[1]) def rotate_clockwise( diff --git a/tests/devices/i05/test_i05_motors.py b/tests/devices/i05/test_i05_motors.py index 4e9e296ea3..fc51364180 100644 --- a/tests/devices/i05/test_i05_motors.py +++ b/tests/devices/i05/test_i05_motors.py @@ -19,7 +19,7 @@ async def test_goniometer_read(goniometer: I05Goniometer) -> None: x, y = 10, 5 theta = radians(goniometer.rotation_angle_deg) expected_perp = x * cos(theta) + y * sin(theta) - expected_long = -x * sin(theta) + y * cos(theta) + expected_long = x * -sin(theta) + y * cos(theta) await asyncio.gather(goniometer.x.set(x), goniometer.y.set(y)) diff --git a/tests/devices/i05_1/test_i05_1_motors.py b/tests/devices/i05_1/test_i05_1_motors.py index 62e4994291..6fd64b5779 100644 --- a/tests/devices/i05_1/test_i05_1_motors.py +++ b/tests/devices/i05_1/test_i05_1_motors.py @@ -1,9 +1,8 @@ import asyncio -from math import radians +from math import cos, radians, sin import pytest -from numpy import cos, sin -from ophyd_async.core import init_devices, set_mock_value +from ophyd_async.core import init_devices from ophyd_async.testing import assert_reading, partial_reading from dodal.devices.i05_1 import XYZPolarAzimuthDefocusStage @@ -16,18 +15,25 @@ def xyzpad_stage() -> XYZPolarAzimuthDefocusStage: return xyzpad_stage -async def test_goniometer_read(xyzpad_stage: XYZPolarAzimuthDefocusStage) -> None: +async def test_xyzpad_stage_read(xyzpad_stage: XYZPolarAzimuthDefocusStage) -> None: x, y = 10, 5 - angle_deg = 45 - theta = radians(angle_deg) + azimuth_angle_deg = 45 + azimuth_theta = radians(azimuth_angle_deg) + expected_hor = x * cos(azimuth_theta) + y * sin(azimuth_theta) + expected_vert = x * -sin(azimuth_theta) + y * cos(azimuth_theta) - expected_horz = x * cos(theta) + y * sin(theta) - expected_vert = x * -sin(theta) + y * cos(theta) + z = 15 + polar_angle_deg = 35 + polar_theta = radians(polar_angle_deg) + expected_long = z * cos(polar_theta) + expected_hor * -sin(polar_theta) + expected_perp = z * sin(polar_theta) + expected_hor * cos(polar_theta) await asyncio.gather( xyzpad_stage.x.set(x), xyzpad_stage.y.set(y), - xyzpad_stage.azimuth.set(angle_deg), + xyzpad_stage.z.set(z), + xyzpad_stage.azimuth.set(azimuth_angle_deg), + xyzpad_stage.polar.set(polar_angle_deg), ) await assert_reading( @@ -35,48 +41,13 @@ async def test_goniometer_read(xyzpad_stage: XYZPolarAzimuthDefocusStage) -> Non { xyzpad_stage.x.name: partial_reading(x), xyzpad_stage.y.name: partial_reading(y), - xyzpad_stage.z.name: partial_reading(0), - xyzpad_stage.polar.name: partial_reading(0), - xyzpad_stage.azimuth.name: partial_reading(angle_deg), + xyzpad_stage.z.name: partial_reading(z), + xyzpad_stage.polar.name: partial_reading(polar_angle_deg), + xyzpad_stage.azimuth.name: partial_reading(azimuth_angle_deg), xyzpad_stage.defocus.name: partial_reading(0), - xyzpad_stage.hor.name: partial_reading(expected_horz), + xyzpad_stage.hor.name: partial_reading(expected_hor), xyzpad_stage.vert.name: partial_reading(expected_vert), - }, - ) - - -@pytest.mark.parametrize( - "x, y, z, polar, azimuth, defocus", - [ - (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_xyzpad_position_table( - xyzpad_stage: XYZPolarAzimuthDefocusStage, - x: float, - y: float, - z: float, - polar: float, - azimuth: float, - defocus: float, -) -> None: - set_mock_value(xyzpad_stage.x.user_readback, x) - set_mock_value(xyzpad_stage.y.user_readback, y) - set_mock_value(xyzpad_stage.z.user_readback, z) - set_mock_value(xyzpad_stage.polar.user_readback, polar) - set_mock_value(xyzpad_stage.azimuth.user_readback, azimuth) - set_mock_value(xyzpad_stage.defocus.user_readback, defocus) - - await assert_reading( - xyzpad_stage, - { - "xyzpad_stage-x": partial_reading(x), - "xyzpad_stage-y": partial_reading(y), - "xyzpad_stage-z": partial_reading(z), - "xyzpad_stage-polar": partial_reading(polar), - "xyzpad_stage-azimuth": partial_reading(azimuth), - "xyzpad_stage-defocus": partial_reading(defocus), + xyzpad_stage.long.name: partial_reading(expected_long), + xyzpad_stage.perp.name: partial_reading(expected_perp), }, ) From db997ba5c443a93920bd7819ce2871f50061e46b Mon Sep 17 00:00:00 2001 From: Oli Wenman Date: Mon, 2 Feb 2026 17:45:28 +0000 Subject: [PATCH 36/81] Added helper util function for testing rotation signals which can be applied to all that use the rotation signal factory --- src/dodal/devices/i05/i05_motors.py | 163 +----------------- src/dodal/devices/i05_1/i05_1_motors.py | 2 +- .../devices/i05_shared/rotation_signals.py | 120 +++++++++++++ tests/devices/i05/test_i05_motors.py | 73 ++++---- tests/devices/i05_1/test_i05_1_motors.py | 78 ++++++++- tests/devices/i05_shared/__init__.py | 0 .../i05_shared/rotation_signal_test_util.py | 82 +++++++++ 7 files changed, 313 insertions(+), 205 deletions(-) create mode 100644 tests/devices/i05_shared/__init__.py create mode 100644 tests/devices/i05_shared/rotation_signal_test_util.py diff --git a/src/dodal/devices/i05/i05_motors.py b/src/dodal/devices/i05/i05_motors.py index c93c484acd..36b452da88 100644 --- a/src/dodal/devices/i05/i05_motors.py +++ b/src/dodal/devices/i05/i05_motors.py @@ -1,15 +1,6 @@ -import asyncio -from math import radians - -from ophyd_async.core import ( - SignalR, - SignalRW, - derived_signal_rw, +from dodal.devices.i05_shared.rotation_signals import ( + create_rotational_ij_component_signals_with_motors, ) -from ophyd_async.core._protocol import AsyncMovable -from ophyd_async.epics.motor import Motor - -from dodal.devices.i05_shared.math import rotate_clockwise, rotate_counter_clockwise from dodal.devices.motors import ( _AZIMUTH, _POLAR, @@ -57,153 +48,3 @@ def __init__( self.perp, self.long = create_rotational_ij_component_signals_with_motors( self.x, self.y, self.rotation_angle_deg ) - - def _read_perp_calc(self, x: float, y: float, angle_deg: float) -> float: - new_x, new_y = rotate_clockwise(radians(angle_deg), x, y) - return new_x - - async def _set_perp_calc(self, value: float) -> None: - x_pos, y_pos = await asyncio.gather( - self.x.user_readback.get_value(), - self.y.user_readback.get_value(), - ) - perp = value - long = self._read_long_calc(x_pos, y_pos, self.rotation_angle_deg) - new_x, new_y = rotate_counter_clockwise( - radians(self.rotation_angle_deg), perp, long - ) - await asyncio.gather(self.x.set(new_x), self.y.set(new_y)) - - def _read_long_calc(self, x: float, y: float, angle_deg: float) -> float: - new_x, new_y = rotate_clockwise(radians(angle_deg), x, y) - return new_y - - async def _set_long_calc(self, value: float) -> None: - x_pos, y_pos = await asyncio.gather( - self.x.user_readback.get_value(), - self.y.user_readback.get_value(), - ) - perp = self._read_perp_calc(x_pos, y_pos, self.rotation_angle_deg) - long = value - new_x, new_y = rotate_counter_clockwise( - radians(self.rotation_angle_deg), perp, long - ) - await asyncio.gather(self.x.set(new_x), self.y.set(new_y)) - - -# NOTE: This already exists in ophyd-async but is not exposed publically in current -# release. It has been made public now https://github.com/bluesky/ophyd-async/pull/1172 -# so the below can be removed once we move to latest ophyd-async release. -# @runtime_checkable -# class AsyncMovable(Protocol[T_co]): -# @abstractmethod -# def set(self, value: T_co) -> AsyncStatus: -# """Return a ``Status`` that is marked done when the device is done moving.""" - - -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: AsyncMovable[float], - j_write: AsyncMovable[float], - angle_deg: float | SignalR[float], - clockwise_frame: bool = True, -) -> tuple[SignalRW[float], SignalRW[float]]: - """ - Create virtual "rotated" i and j component signals from two real motors. - - These virtual signals represent the i/j components after rotation by a given - angle. When writing to these virtual signals, the real motor positions are - adjusted using the inverse rotation so that the rotated component moves to the - requested value. - - Args: - i_read (SignalR): SignalR representing the i motor readback. - j_read (SignalR): representing the j motor readback. - i_write (AsyncReadable): object for setting the i position. - j_write (AsyncReadbale): object for setting the j position. - angle_deg (float | SignalR): Rotation angle in degrees. - - 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 - reverse_rotate = rotate_counter_clockwise if clockwise_frame else rotate_clockwise - - def _read_i_rotation_component_calc(i: float, j: float, angle_deg: float) -> float: - new_i, new_j = rotate(radians(angle_deg), i, j) - return new_i - - async def _set_i_rotation_component_calc(value: float) -> None: - """Move virtual i-axis to desired position while keeping j-axis constant in the - rotated frame.""" - i_pos, j_pos, angle_deg_pos = await asyncio.gather( - i_read.get_value(), - j_read.get_value(), - _get_angle_deg(angle_deg), - ) - # Rotated coordinates - i_rotation_target = value - j_rotation_current = _read_j_rotation_component_calc( - i_pos, j_pos, angle_deg_pos - ) - # Convert back to motor frame by doing inverse rotation to determine actual motor positions - new_i_pos, new_j_pos = reverse_rotate( - radians(angle_deg_pos), i_rotation_target, j_rotation_current - ) - await asyncio.gather(i_write.set(new_i_pos), j_write.set(new_j_pos)) - - def _read_j_rotation_component_calc(i: float, j: float, angle_deg: float) -> float: - new_i, new_j = rotate(radians(angle_deg), i, j) - return new_j - - async def _set_j_rotation_component_calc(value: float) -> None: - """Move virtual j-axis to desired position while keeping j-axis constant in the - rotated frame.""" - i_pos, j_pos, angle_deg_pos = await asyncio.gather( - i_read.get_value(), - j_read.get_value(), - _get_angle_deg(angle_deg), - ) - # Rotated coordinates - i_rotation_target = _read_i_rotation_component_calc(i_pos, j_pos, angle_deg_pos) - j_rotation_current = value - # Convert back to motor frame by doing inverse rotation to determine actual motor positions - new_i_pos, new_j_pos = reverse_rotate( - radians(angle_deg_pos), i_rotation_target, j_rotation_current - ) - await asyncio.gather(i_write.set(new_i_pos), j_write.set(new_j_pos)) - - return derived_signal_rw( - _read_i_rotation_component_calc, - _set_i_rotation_component_calc, - i=i_read, - j=j_read, - angle_deg=angle_deg, - ), derived_signal_rw( - _read_j_rotation_component_calc, - _set_j_rotation_component_calc, - i=i_read, - j=j_read, - angle_deg=angle_deg, - ) - - -def create_rotational_ij_component_signals_with_motors( - i: Motor, j: Motor, angle_deg: float | SignalR[float], clockwise_frame: bool = True -) -> tuple[SignalRW[float], SignalRW[float]]: - return create_rotational_ij_component_signals( - i_read=i.user_readback, - i_write=i, # type: ignore - j_read=j.user_readback, - j_write=j, # type: ignore - angle_deg=angle_deg, - ) diff --git a/src/dodal/devices/i05_1/i05_1_motors.py b/src/dodal/devices/i05_1/i05_1_motors.py index ec0608a283..b861e8d52d 100644 --- a/src/dodal/devices/i05_1/i05_1_motors.py +++ b/src/dodal/devices/i05_1/i05_1_motors.py @@ -1,6 +1,6 @@ from ophyd_async.epics.motor import Motor -from dodal.devices.i05.i05_motors import ( +from dodal.devices.i05_shared.rotation_signals import ( create_rotational_ij_component_signals, create_rotational_ij_component_signals_with_motors, ) diff --git a/src/dodal/devices/i05_shared/rotation_signals.py b/src/dodal/devices/i05_shared/rotation_signals.py index e69de29bb2..fb1d20e0f5 100644 --- a/src/dodal/devices/i05_shared/rotation_signals.py +++ b/src/dodal/devices/i05_shared/rotation_signals.py @@ -0,0 +1,120 @@ +import asyncio +from math import radians + +from ophyd_async.core import ( + SignalR, + SignalRW, + derived_signal_rw, +) +from ophyd_async.core._protocol import AsyncMovable +from ophyd_async.epics.motor import Motor + +from dodal.devices.i05_shared.math import rotate_clockwise, rotate_counter_clockwise + + +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: AsyncMovable[float], + j_write: AsyncMovable[float], + angle_deg: float | SignalR[float], + clockwise_frame: bool = True, +) -> tuple[SignalRW[float], SignalRW[float]]: + """ + Create virtual "rotated" i and j component signals from two real motors. + + These virtual signals represent the i/j components after rotation by a given + angle. When writing to these virtual signals, the real motor positions are + adjusted using the inverse rotation so that the rotated component moves to the + requested value. + + Args: + i_read (SignalR): SignalR representing the i motor readback. + j_read (SignalR): representing the j motor readback. + i_write (AsyncReadable): object for setting the i position. + j_write (AsyncReadbale): object for setting the j position. + angle_deg (float | SignalR): Rotation angle in degrees. + + 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 + reverse_rotate = rotate_counter_clockwise if clockwise_frame else rotate_clockwise + + def _read_i_rotation_component_calc(i: float, j: float, angle_deg: float) -> float: + new_i, new_j = rotate(radians(angle_deg), i, j) + return new_i + + async def _set_i_rotation_component_calc(value: float) -> None: + """Move virtual i-axis to desired position while keeping j-axis constant in the + rotated frame.""" + i_pos, j_pos, angle_deg_pos = await asyncio.gather( + i_read.get_value(), + j_read.get_value(), + _get_angle_deg(angle_deg), + ) + # Rotated coordinates + i_rotation_target = value + j_rotation_current = _read_j_rotation_component_calc( + i_pos, j_pos, angle_deg_pos + ) + # Convert back to motor frame by doing inverse rotation to determine actual motor positions + new_i_pos, new_j_pos = reverse_rotate( + radians(angle_deg_pos), i_rotation_target, j_rotation_current + ) + await asyncio.gather(i_write.set(new_i_pos), j_write.set(new_j_pos)) + + def _read_j_rotation_component_calc(i: float, j: float, angle_deg: float) -> float: + new_i, new_j = rotate(radians(angle_deg), i, j) + return new_j + + async def _set_j_rotation_component_calc(value: float) -> None: + """Move virtual j-axis to desired position while keeping j-axis constant in the + rotated frame.""" + i_pos, j_pos, angle_deg_pos = await asyncio.gather( + i_read.get_value(), + j_read.get_value(), + _get_angle_deg(angle_deg), + ) + # Rotated coordinates + i_rotation_target = _read_i_rotation_component_calc(i_pos, j_pos, angle_deg_pos) + j_rotation_current = value + # Convert back to motor frame by doing inverse rotation to determine actual motor positions + new_i_pos, new_j_pos = reverse_rotate( + radians(angle_deg_pos), i_rotation_target, j_rotation_current + ) + await asyncio.gather(i_write.set(new_i_pos), j_write.set(new_j_pos)) + + return derived_signal_rw( + _read_i_rotation_component_calc, + _set_i_rotation_component_calc, + i=i_read, + j=j_read, + angle_deg=angle_deg, + ), derived_signal_rw( + _read_j_rotation_component_calc, + _set_j_rotation_component_calc, + i=i_read, + j=j_read, + angle_deg=angle_deg, + ) + + +def create_rotational_ij_component_signals_with_motors( + i: Motor, j: Motor, angle_deg: float | SignalR[float], clockwise_frame: bool = True +) -> tuple[SignalRW[float], SignalRW[float]]: + return create_rotational_ij_component_signals( + i_read=i.user_readback, + i_write=i, # type: ignore + j_read=j.user_readback, + j_write=j, # type: ignore + angle_deg=angle_deg, + ) diff --git a/tests/devices/i05/test_i05_motors.py b/tests/devices/i05/test_i05_motors.py index fc51364180..b1900c283f 100644 --- a/tests/devices/i05/test_i05_motors.py +++ b/tests/devices/i05/test_i05_motors.py @@ -6,6 +6,18 @@ from ophyd_async.testing import assert_reading, partial_reading from dodal.devices.i05 import I05Goniometer +from tests.devices.i05_shared.rotation_signal_test_util import ( + AxesTestConfig, + assert_set_axis_preserves_other, +) + + +def expected_perp_read(x: float, y: float, theta: float) -> float: + return x * cos(theta) + y * sin(theta) + + +def expected_long_read(x: float, y: float, theta: float) -> float: + return x * -sin(theta) + y * cos(theta) @pytest.fixture @@ -18,8 +30,8 @@ async def goniometer(): async def test_goniometer_read(goniometer: I05Goniometer) -> None: x, y = 10, 5 theta = radians(goniometer.rotation_angle_deg) - expected_perp = x * cos(theta) + y * sin(theta) - expected_long = x * -sin(theta) + y * cos(theta) + expected_perp = expected_perp_read(x, y, theta) + expected_long = expected_long_read(x, y, theta) await asyncio.gather(goniometer.x.set(x), goniometer.y.set(y)) @@ -38,43 +50,26 @@ async def test_goniometer_read(goniometer: I05Goniometer) -> None: ) -async def test_goniometer_set_long(goniometer: I05Goniometer) -> None: - x, y = 10, 5 - angle_deg = goniometer.rotation_angle_deg - await asyncio.gather(goniometer.x.set(x), goniometer.y.set(y)) - - perp_before = goniometer._read_perp_calc(x, y, angle_deg) - - new_long = 20.0 - await goniometer.long.set(new_long) - - x_new, y_new = await asyncio.gather( - goniometer.x.user_readback.get_value(), goniometer.y.user_readback.get_value() +async def test_goniometer_perp_and_long_set( + goniometer: I05Goniometer, +) -> None: + axes_test_config = AxesTestConfig( + i_read=goniometer.x.user_readback, + j_read=goniometer.y.user_readback, + i_write=goniometer.x, # type: ignore + j_write=goniometer.y, # type: ignore + angle_deg=goniometer.rotation_angle_deg, + expected_i_read_func=expected_perp_read, + expected_j_read_func=expected_long_read, + i_rotation_axis=goniometer.perp, + j_rotation_axis=goniometer.long, ) - perp_after = goniometer._read_perp_calc(x_new, y_new, angle_deg) - long_after = goniometer._read_long_calc(x_new, y_new, angle_deg) - - assert perp_after == pytest.approx(perp_before) - assert long_after == pytest.approx(new_long) - - -async def test_goniometer_set_perp(goniometer: I05Goniometer) -> None: - x, y = 10.0, 5.0 - angle_deg = goniometer.rotation_angle_deg - await asyncio.gather(goniometer.x.set(x), goniometer.y.set(y)) - - long_before = goniometer._read_long_calc(x, y, angle_deg) - - new_perp = 15.0 - await goniometer.perp.set(new_perp) - - x_new_perp, y_new_perp = await asyncio.gather( - goniometer.x.user_readback.get_value(), goniometer.y.user_readback.get_value() + await assert_set_axis_preserves_other( + i_val=10, + j_val=5, + angle_deg_val=goniometer.rotation_angle_deg, + new_i_axis_value=20, + new_j_axis_value=20, + axes_config=axes_test_config, ) - - long_after_perp = goniometer._read_long_calc(x_new_perp, y_new_perp, angle_deg) - perp_after_perp = goniometer._read_perp_calc(x_new_perp, y_new_perp, angle_deg) - - assert perp_after_perp == pytest.approx(new_perp, abs=1e-5) - assert long_after_perp == pytest.approx(long_before, abs=1e-5) diff --git a/tests/devices/i05_1/test_i05_1_motors.py b/tests/devices/i05_1/test_i05_1_motors.py index 6fd64b5779..fff5b0ffb8 100644 --- a/tests/devices/i05_1/test_i05_1_motors.py +++ b/tests/devices/i05_1/test_i05_1_motors.py @@ -6,6 +6,10 @@ from ophyd_async.testing import assert_reading, partial_reading from dodal.devices.i05_1 import XYZPolarAzimuthDefocusStage +from tests.devices.i05_shared.rotation_signal_test_util import ( + AxesTestConfig, + assert_set_axis_preserves_other, +) @pytest.fixture @@ -15,18 +19,34 @@ def xyzpad_stage() -> XYZPolarAzimuthDefocusStage: return xyzpad_stage +def expected_hor_read(x: float, y: float, azimuth_theta: float) -> float: + return x * cos(azimuth_theta) + y * sin(azimuth_theta) + + +def expected_vert_read(x: float, y: float, azimuth_theta: float) -> float: + return x * -sin(azimuth_theta) + y * cos(azimuth_theta) + + +def expected_long_read(z: float, hor: float, polar_theta: float) -> float: + return z * cos(polar_theta) + hor * -sin(polar_theta) + + +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(xyzpad_stage: XYZPolarAzimuthDefocusStage) -> None: x, y = 10, 5 azimuth_angle_deg = 45 azimuth_theta = radians(azimuth_angle_deg) - expected_hor = x * cos(azimuth_theta) + y * sin(azimuth_theta) - expected_vert = x * -sin(azimuth_theta) + y * cos(azimuth_theta) + expected_hor = expected_hor_read(x, y, azimuth_theta) + expected_vert = expected_vert_read(x, y, azimuth_theta) z = 15 polar_angle_deg = 35 polar_theta = radians(polar_angle_deg) - expected_long = z * cos(polar_theta) + expected_hor * -sin(polar_theta) - expected_perp = z * sin(polar_theta) + expected_hor * cos(polar_theta) + expected_long = expected_long_read(z, expected_hor, polar_theta) + expected_perp = expected_perp_read(z, expected_hor, polar_theta) await asyncio.gather( xyzpad_stage.x.set(x), @@ -51,3 +71,53 @@ async def test_xyzpad_stage_read(xyzpad_stage: XYZPolarAzimuthDefocusStage) -> N xyzpad_stage.perp.name: partial_reading(expected_perp), }, ) + + +async def test_xyzpad_hor_and_vert_set( + xyzpad_stage: XYZPolarAzimuthDefocusStage, +) -> None: + axes_test_config = AxesTestConfig( + i_read=xyzpad_stage.x.user_readback, + j_read=xyzpad_stage.y.user_readback, + i_write=xyzpad_stage.x, # type: ignore + j_write=xyzpad_stage.y, # type: ignore + angle_deg=xyzpad_stage.azimuth, # type: ignore + expected_i_read_func=expected_hor_read, + expected_j_read_func=expected_vert_read, + i_rotation_axis=xyzpad_stage.hor, + j_rotation_axis=xyzpad_stage.vert, + ) + + await assert_set_axis_preserves_other( + i_val=10, + j_val=5, + angle_deg_val=45, + new_i_axis_value=20, + new_j_axis_value=20, + axes_config=axes_test_config, + ) + + +async def test_xyzpad_long_and_perp_set( + xyzpad_stage: XYZPolarAzimuthDefocusStage, +) -> None: + axes_test_config = AxesTestConfig( + i_read=xyzpad_stage.z.user_readback, + j_read=xyzpad_stage.hor, + i_write=xyzpad_stage.z, # type: ignore + j_write=xyzpad_stage.hor, # type: ignore + angle_deg=xyzpad_stage.polar, # type: ignore + expected_i_read_func=expected_long_read, + expected_j_read_func=expected_perp_read, + i_rotation_axis=xyzpad_stage.long, + j_rotation_axis=xyzpad_stage.perp, + ) + + await assert_set_axis_preserves_other( + i_val=10, + j_val=5, + angle_deg_val=45, + new_i_axis_value=20, + new_j_axis_value=20, + axes_config=axes_test_config, + ) diff --git a/tests/devices/i05_shared/__init__.py b/tests/devices/i05_shared/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/tests/devices/i05_shared/rotation_signal_test_util.py b/tests/devices/i05_shared/rotation_signal_test_util.py new file mode 100644 index 0000000000..acb3abb53e --- /dev/null +++ b/tests/devices/i05_shared/rotation_signal_test_util.py @@ -0,0 +1,82 @@ +import asyncio +from collections.abc import Callable +from dataclasses import dataclass +from math import radians + +import pytest +from ophyd_async.core import SignalR, SignalRW +from ophyd_async.core._protocol import AsyncMovable + + +@dataclass +class AxesTestConfig: + i_read: SignalR[float] + j_read: SignalR[float] + i_write: AsyncMovable[float] + j_write: AsyncMovable[float] + angle_deg: float | AsyncMovable[float] + expected_i_read_func: Callable[[float, float, float], float] + expected_j_read_func: Callable[[float, float, float], float] + i_rotation_axis: SignalRW[float] + j_rotation_axis: SignalRW[float] + + +async def assert_set_axis_preserves_other( + *, + i_val: float, + j_val: float, + angle_deg_val: float, + new_i_axis_value: float, + new_j_axis_value: float, + axes_config: AxesTestConfig, +) -> None: + await asyncio.gather(axes_config.i_write.set(i_val), axes_config.j_write.set(j_val)) + angle_deg = axes_config.angle_deg + if isinstance(angle_deg, AsyncMovable): + await angle_deg.set(angle_deg_val) + + theta = radians(angle_deg_val) + + # Capture invariant component before move + expected_i = new_i_axis_value + expected_j = axes_config.expected_j_read_func(i_val, j_val, theta) + + # Move axis to new position + await axes_config.i_rotation_axis.set(expected_i) + + # Read back motors + i_new, j_new = await asyncio.gather( + axes_config.i_read.get_value(), axes_config.j_read.get_value() + ) + + # Compute rotated components after move + i_val_after = axes_config.expected_i_read_func(i_new, j_new, theta) + j_val_after = axes_config.expected_j_read_func(i_new, j_new, theta) + + assert i_val_after == pytest.approx(expected_i) + assert j_val_after == pytest.approx(expected_j) + + await asyncio.gather(axes_config.i_write.set(i_val), axes_config.j_write.set(j_val)) + angle_deg = axes_config.angle_deg + if isinstance(angle_deg, AsyncMovable): + await angle_deg.set(angle_deg_val) + + theta = radians(angle_deg_val) + + expected_j = new_j_axis_value + expected_i = axes_config.expected_i_read_func(i_val, j_val, theta) + + # Move axis to new position + await axes_config.j_rotation_axis.set(expected_j) + + # Read back motors + i_new, j_new = await asyncio.gather( + axes_config.i_read.get_value(), axes_config.j_read.get_value() + ) + + # Compute rotated components after move + i_val_after = axes_config.expected_i_read_func(i_new, j_new, theta) + j_val_after = axes_config.expected_j_read_func(i_new, j_new, theta) + + assert i_val_after == pytest.approx(expected_i) + assert j_val_after == pytest.approx(expected_j) From fbc91d405c377776df6b81c77aa298a8d9681877 Mon Sep 17 00:00:00 2001 From: Oli Wenman Date: Tue, 3 Feb 2026 11:06:59 +0000 Subject: [PATCH 37/81] Move maths to common, update common motors to use it --- src/dodal/common/maths.py | 42 +++++++++++++++++++ src/dodal/devices/i05_shared/math.py | 42 ------------------- .../devices/i05_shared/rotation_signals.py | 2 +- src/dodal/devices/motors.py | 11 ++--- 4 files changed, 49 insertions(+), 48 deletions(-) delete mode 100644 src/dodal/devices/i05_shared/math.py diff --git a/src/dodal/common/maths.py b/src/dodal/common/maths.py index 44e581d7ca..f0d6d690f6 100644 --- a/src/dodal/common/maths.py +++ b/src/dodal/common/maths.py @@ -128,3 +128,45 @@ def contains(self, x: float, y: float) -> bool: self.get_min_x() <= x <= self.get_max_x() and self.get_min_y() <= y <= self.get_max_y() ) + + +""" +| Rotation | Formula for X_rot | Formula for Y_rot | +| -------- | ----------------- | ----------------- | +| CW | x cosθ + y sinθ | -x sinθ + y cosθ | +| CCW | x cosθ - y sinθ | x sinθ + y cosθ | +""" + + +def do_rotation(x: float, y: float, rotation_matrix: np.ndarray) -> tuple[float, float]: + positions = np.array([x, y]) + rotation = rotation_matrix @ positions + return float(rotation[0]), float(rotation[1]) + + +def rotate_clockwise( + theta: float, + x: float, + y: float, +) -> tuple[float, float]: + rotation_matrix = np.array( + [ + [np.cos(theta), np.sin(theta)], + [-np.sin(theta), np.cos(theta)], + ] + ) + return do_rotation(x, y, rotation_matrix) + + +def rotate_counter_clockwise( + theta: float, + x: float, + y: float, +) -> tuple[float, float]: + rotation_matrix = np.array( + [ + [np.cos(theta), -np.sin(theta)], + [np.sin(theta), np.cos(theta)], + ] + ) + return do_rotation(x, y, rotation_matrix) diff --git a/src/dodal/devices/i05_shared/math.py b/src/dodal/devices/i05_shared/math.py deleted file mode 100644 index 075527dc58..0000000000 --- a/src/dodal/devices/i05_shared/math.py +++ /dev/null @@ -1,42 +0,0 @@ -import numpy as np - -""" -| Rotation | Formula for X_rot | Formula for Y_rot | -| -------- | ----------------- | ----------------- | -| CW | x cosθ + y sinθ | -x sinθ + y cosθ | -| CCW | x cosθ - y sinθ | x sinθ + y cosθ | -""" - - -def do_rotation(x: float, y: float, rotation_matrix: np.ndarray) -> tuple[float, float]: - positions = np.array([x, y]) - rotation = rotation_matrix @ positions - return float(rotation[0]), float(rotation[1]) - - -def rotate_clockwise( - theta: float, - x: float, - y: float, -) -> tuple[float, float]: - rotation_matrix = np.array( - [ - [np.cos(theta), np.sin(theta)], - [-np.sin(theta), np.cos(theta)], - ] - ) - return do_rotation(x, y, rotation_matrix) - - -def rotate_counter_clockwise( - theta: float, - x: float, - y: float, -) -> tuple[float, float]: - rotation_matrix = np.array( - [ - [np.cos(theta), -np.sin(theta)], - [np.sin(theta), np.cos(theta)], - ] - ) - return do_rotation(x, y, rotation_matrix) diff --git a/src/dodal/devices/i05_shared/rotation_signals.py b/src/dodal/devices/i05_shared/rotation_signals.py index fb1d20e0f5..03c71faa5e 100644 --- a/src/dodal/devices/i05_shared/rotation_signals.py +++ b/src/dodal/devices/i05_shared/rotation_signals.py @@ -9,7 +9,7 @@ from ophyd_async.core._protocol import AsyncMovable from ophyd_async.epics.motor import Motor -from dodal.devices.i05_shared.math import rotate_clockwise, rotate_counter_clockwise +from dodal.common.maths import rotate_clockwise, rotate_counter_clockwise async def _get_angle_deg(angle_deg: SignalR[float] | float) -> float: diff --git a/src/dodal/devices/motors.py b/src/dodal/devices/motors.py index 32b80885aa..df175dc79c 100644 --- a/src/dodal/devices/motors.py +++ b/src/dodal/devices/motors.py @@ -5,6 +5,8 @@ from ophyd_async.core import SignalRW, StandardReadable, derived_signal_rw from ophyd_async.epics.motor import Motor +from dodal.common.maths import rotate_clockwise, rotate_counter_clockwise + _X, _Y, _Z = "X", "Y", "Z" _OMEGA = "OMEGA" @@ -365,14 +367,13 @@ def create_axis_perp_to_rotation( """ def _get(j_val: float, i_val: float, rot_deg_value: float) -> float: - i_component = i_val * math.cos(math.radians(rot_deg_value)) - j_component = j_val * math.sin(math.radians(rot_deg_value)) - return i_component + j_component + x, y = rotate_clockwise(math.radians(rot_deg_value), i_val, j_val) + return x async def _set(vertical_value: float) -> None: rot_deg_value = await motor_theta.user_readback.get_value() - i_component = vertical_value * math.cos(math.radians(rot_deg_value)) - j_component = vertical_value * math.sin(math.radians(rot_deg_value)) + theta = math.radians(rot_deg_value) + i_component, j_component = rotate_counter_clockwise(theta, vertical_value, 0.0) await asyncio.gather( motor_i.set(i_component), motor_j.set(j_component), From 206015259e6b36811a317dd1fd755402a91aa244 Mon Sep 17 00:00:00 2001 From: Oli Wenman Date: Tue, 3 Feb 2026 11:38:06 +0000 Subject: [PATCH 38/81] Fix lint --- src/dodal/devices/i05/i05_motors.py | 12 ++++++++---- src/dodal/devices/i05_1/i05_1_motors.py | 9 +++++---- src/dodal/devices/i05_shared/rotation_signals.py | 13 ------------- 3 files changed, 13 insertions(+), 21 deletions(-) diff --git a/src/dodal/devices/i05/i05_motors.py b/src/dodal/devices/i05/i05_motors.py index 36b452da88..cc24bf7c3b 100644 --- a/src/dodal/devices/i05/i05_motors.py +++ b/src/dodal/devices/i05/i05_motors.py @@ -1,5 +1,5 @@ from dodal.devices.i05_shared.rotation_signals import ( - create_rotational_ij_component_signals_with_motors, + create_rotational_ij_component_signals, ) from dodal.devices.motors import ( _AZIMUTH, @@ -16,7 +16,7 @@ class I05Goniometer(XYZPolarAzimuthTiltStage): """ Six-axis stage with a standard xyz stage and three axis of rotation: polar, azimuth and tilt. This implementation extends to add perp and long rotations as derived - signals at a configured angle (default 50 degrees.) + signals at a configured angle (default 50 degrees). """ def __init__( @@ -45,6 +45,10 @@ def __init__( ) with self.add_children_as_readables(): - self.perp, self.long = create_rotational_ij_component_signals_with_motors( - self.x, self.y, self.rotation_angle_deg + self.perp, self.long = create_rotational_ij_component_signals( + i_read=self.x.user_readback, + i_write=self.x, # type: ignore + j_read=self.y.user_readback, + j_write=self.y, # type: ignore + angle_deg=self.rotation_angle_deg, ) diff --git a/src/dodal/devices/i05_1/i05_1_motors.py b/src/dodal/devices/i05_1/i05_1_motors.py index b861e8d52d..c49d476599 100644 --- a/src/dodal/devices/i05_1/i05_1_motors.py +++ b/src/dodal/devices/i05_1/i05_1_motors.py @@ -2,7 +2,6 @@ from dodal.devices.i05_shared.rotation_signals import ( create_rotational_ij_component_signals, - create_rotational_ij_component_signals_with_motors, ) from dodal.devices.motors import XYZPolarAzimuthStage @@ -36,9 +35,11 @@ def __init__( with self.add_children_as_readables(): self.defocus = Motor(prefix + defocus_infix) - self.hor, self.vert = create_rotational_ij_component_signals_with_motors( - i=self.x, - j=self.y, + self.hor, self.vert = create_rotational_ij_component_signals( + i_read=self.x.user_readback, + j_read=self.y.user_readback, + i_write=self.x, # type: ignore + j_write=self.y, # type: ignore angle_deg=self.azimuth.user_readback, clockwise_frame=True, ) diff --git a/src/dodal/devices/i05_shared/rotation_signals.py b/src/dodal/devices/i05_shared/rotation_signals.py index 03c71faa5e..36908d4e38 100644 --- a/src/dodal/devices/i05_shared/rotation_signals.py +++ b/src/dodal/devices/i05_shared/rotation_signals.py @@ -7,7 +7,6 @@ derived_signal_rw, ) from ophyd_async.core._protocol import AsyncMovable -from ophyd_async.epics.motor import Motor from dodal.common.maths import rotate_clockwise, rotate_counter_clockwise @@ -106,15 +105,3 @@ async def _set_j_rotation_component_calc(value: float) -> None: j=j_read, angle_deg=angle_deg, ) - - -def create_rotational_ij_component_signals_with_motors( - i: Motor, j: Motor, angle_deg: float | SignalR[float], clockwise_frame: bool = True -) -> tuple[SignalRW[float], SignalRW[float]]: - return create_rotational_ij_component_signals( - i_read=i.user_readback, - i_write=i, # type: ignore - j_read=j.user_readback, - j_write=j, # type: ignore - angle_deg=angle_deg, - ) From b2c29d7e9c6b679fd628e87a6b82f9ad923bb6ae Mon Sep 17 00:00:00 2001 From: Oli Wenman Date: Tue, 3 Feb 2026 12:03:20 +0000 Subject: [PATCH 39/81] Remove use of type: ignore by utilising bluesky maybe_await and Movable --- src/dodal/devices/i05/i05_motors.py | 4 +-- src/dodal/devices/i05_1/i05_1_motors.py | 6 ++--- .../devices/i05_shared/rotation_signals.py | 15 +++++++---- tests/devices/i05_1/test_i05_1_motors.py | 12 ++++----- .../i05_shared/rotation_signal_test_util.py | 27 ++++++++++++------- 5 files changed, 38 insertions(+), 26 deletions(-) diff --git a/src/dodal/devices/i05/i05_motors.py b/src/dodal/devices/i05/i05_motors.py index cc24bf7c3b..a48a99d0f8 100644 --- a/src/dodal/devices/i05/i05_motors.py +++ b/src/dodal/devices/i05/i05_motors.py @@ -47,8 +47,8 @@ def __init__( with self.add_children_as_readables(): self.perp, self.long = create_rotational_ij_component_signals( i_read=self.x.user_readback, - i_write=self.x, # type: ignore + i_write=self.x, j_read=self.y.user_readback, - j_write=self.y, # type: ignore + j_write=self.y, angle_deg=self.rotation_angle_deg, ) diff --git a/src/dodal/devices/i05_1/i05_1_motors.py b/src/dodal/devices/i05_1/i05_1_motors.py index c49d476599..cb36b5b0bc 100644 --- a/src/dodal/devices/i05_1/i05_1_motors.py +++ b/src/dodal/devices/i05_1/i05_1_motors.py @@ -38,14 +38,14 @@ def __init__( self.hor, self.vert = create_rotational_ij_component_signals( i_read=self.x.user_readback, j_read=self.y.user_readback, - i_write=self.x, # type: ignore - j_write=self.y, # type: ignore + i_write=self.x, + j_write=self.y, angle_deg=self.azimuth.user_readback, clockwise_frame=True, ) self.long, self.perp = create_rotational_ij_component_signals( i_read=self.z.user_readback, - i_write=self.z, # type: ignore + i_write=self.z, j_read=self.hor, j_write=self.hor, angle_deg=self.polar.user_readback, diff --git a/src/dodal/devices/i05_shared/rotation_signals.py b/src/dodal/devices/i05_shared/rotation_signals.py index 36908d4e38..4f0463089c 100644 --- a/src/dodal/devices/i05_shared/rotation_signals.py +++ b/src/dodal/devices/i05_shared/rotation_signals.py @@ -1,12 +1,13 @@ import asyncio from math import radians +from bluesky.protocols import Movable +from bluesky.utils import maybe_await from ophyd_async.core import ( SignalR, SignalRW, derived_signal_rw, ) -from ophyd_async.core._protocol import AsyncMovable from dodal.common.maths import rotate_clockwise, rotate_counter_clockwise @@ -20,8 +21,8 @@ async def _get_angle_deg(angle_deg: SignalR[float] | float) -> float: def create_rotational_ij_component_signals( i_read: SignalR[float], j_read: SignalR[float], - i_write: AsyncMovable[float], - j_write: AsyncMovable[float], + i_write: Movable[float], + j_write: Movable[float], angle_deg: float | SignalR[float], clockwise_frame: bool = True, ) -> tuple[SignalRW[float], SignalRW[float]]: @@ -69,7 +70,9 @@ async def _set_i_rotation_component_calc(value: float) -> None: new_i_pos, new_j_pos = reverse_rotate( radians(angle_deg_pos), i_rotation_target, j_rotation_current ) - await asyncio.gather(i_write.set(new_i_pos), j_write.set(new_j_pos)) + await asyncio.gather( + maybe_await(i_write.set(new_i_pos)), maybe_await(j_write.set(new_j_pos)) + ) def _read_j_rotation_component_calc(i: float, j: float, angle_deg: float) -> float: new_i, new_j = rotate(radians(angle_deg), i, j) @@ -90,7 +93,9 @@ async def _set_j_rotation_component_calc(value: float) -> None: new_i_pos, new_j_pos = reverse_rotate( radians(angle_deg_pos), i_rotation_target, j_rotation_current ) - await asyncio.gather(i_write.set(new_i_pos), j_write.set(new_j_pos)) + await asyncio.gather( + maybe_await(i_write.set(new_i_pos)), maybe_await(j_write.set(new_j_pos)) + ) return derived_signal_rw( _read_i_rotation_component_calc, diff --git a/tests/devices/i05_1/test_i05_1_motors.py b/tests/devices/i05_1/test_i05_1_motors.py index fff5b0ffb8..416c517bb5 100644 --- a/tests/devices/i05_1/test_i05_1_motors.py +++ b/tests/devices/i05_1/test_i05_1_motors.py @@ -79,9 +79,9 @@ async def test_xyzpad_hor_and_vert_set( axes_test_config = AxesTestConfig( i_read=xyzpad_stage.x.user_readback, j_read=xyzpad_stage.y.user_readback, - i_write=xyzpad_stage.x, # type: ignore - j_write=xyzpad_stage.y, # type: ignore - angle_deg=xyzpad_stage.azimuth, # type: ignore + i_write=xyzpad_stage.x, + j_write=xyzpad_stage.y, + angle_deg=xyzpad_stage.azimuth, expected_i_read_func=expected_hor_read, expected_j_read_func=expected_vert_read, i_rotation_axis=xyzpad_stage.hor, @@ -104,9 +104,9 @@ async def test_xyzpad_long_and_perp_set( axes_test_config = AxesTestConfig( i_read=xyzpad_stage.z.user_readback, j_read=xyzpad_stage.hor, - i_write=xyzpad_stage.z, # type: ignore - j_write=xyzpad_stage.hor, # type: ignore - angle_deg=xyzpad_stage.polar, # type: ignore + i_write=xyzpad_stage.z, + j_write=xyzpad_stage.hor, + angle_deg=xyzpad_stage.polar, expected_i_read_func=expected_long_read, expected_j_read_func=expected_perp_read, i_rotation_axis=xyzpad_stage.long, diff --git a/tests/devices/i05_shared/rotation_signal_test_util.py b/tests/devices/i05_shared/rotation_signal_test_util.py index acb3abb53e..b5a1e1719f 100644 --- a/tests/devices/i05_shared/rotation_signal_test_util.py +++ b/tests/devices/i05_shared/rotation_signal_test_util.py @@ -4,17 +4,18 @@ from math import radians import pytest +from bluesky.protocols import Movable +from bluesky.utils import maybe_await from ophyd_async.core import SignalR, SignalRW -from ophyd_async.core._protocol import AsyncMovable @dataclass class AxesTestConfig: i_read: SignalR[float] j_read: SignalR[float] - i_write: AsyncMovable[float] - j_write: AsyncMovable[float] - angle_deg: float | AsyncMovable[float] + i_write: Movable[float] + j_write: Movable[float] + angle_deg: float | Movable[float] expected_i_read_func: Callable[[float, float, float], float] expected_j_read_func: Callable[[float, float, float], float] i_rotation_axis: SignalRW[float] @@ -30,10 +31,13 @@ async def assert_set_axis_preserves_other( new_j_axis_value: float, axes_config: AxesTestConfig, ) -> None: - await asyncio.gather(axes_config.i_write.set(i_val), axes_config.j_write.set(j_val)) + await asyncio.gather( + maybe_await(axes_config.i_write.set(i_val)), + maybe_await(axes_config.j_write.set(j_val)), + ) angle_deg = axes_config.angle_deg - if isinstance(angle_deg, AsyncMovable): - await angle_deg.set(angle_deg_val) + if isinstance(angle_deg, Movable): + await maybe_await(angle_deg.set(angle_deg_val)) theta = radians(angle_deg_val) @@ -56,10 +60,13 @@ async def assert_set_axis_preserves_other( assert i_val_after == pytest.approx(expected_i) assert j_val_after == pytest.approx(expected_j) - await asyncio.gather(axes_config.i_write.set(i_val), axes_config.j_write.set(j_val)) + await asyncio.gather( + maybe_await(axes_config.i_write.set(i_val)), + maybe_await(axes_config.j_write.set(j_val)), + ) angle_deg = axes_config.angle_deg - if isinstance(angle_deg, AsyncMovable): - await angle_deg.set(angle_deg_val) + if isinstance(angle_deg, Movable): + await maybe_await(angle_deg.set(angle_deg_val)) theta = radians(angle_deg_val) From 26ff07a78e1ea3b534b7ea4e0807a7c7feddc9a0 Mon Sep 17 00:00:00 2001 From: Oli Wenman Date: Tue, 3 Feb 2026 12:13:53 +0000 Subject: [PATCH 40/81] Correct doc string and update variable names --- src/dodal/devices/i05_shared/rotation_signals.py | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/src/dodal/devices/i05_shared/rotation_signals.py b/src/dodal/devices/i05_shared/rotation_signals.py index 4f0463089c..24f7c449c7 100644 --- a/src/dodal/devices/i05_shared/rotation_signals.py +++ b/src/dodal/devices/i05_shared/rotation_signals.py @@ -37,9 +37,11 @@ def create_rotational_ij_component_signals( Args: i_read (SignalR): SignalR representing the i motor readback. j_read (SignalR): representing the j motor readback. - i_write (AsyncReadable): object for setting the i position. - j_write (AsyncReadbale): object for setting the j position. + i_write (Movable): object for setting the i position. + j_write (Movable): object for setting the j position. angle_deg (float | SignalR): Rotation angle in degrees. + clockwise_rotation(boolean): If True, the rotated frame is defined using a + clockwise rotation; otherwise, a counter-clockwise rotation is used. Returns: tuple[SignalRW[float], SignalRW[float]] Two virtual read/write signals @@ -87,11 +89,13 @@ async def _set_j_rotation_component_calc(value: float) -> None: _get_angle_deg(angle_deg), ) # Rotated coordinates - i_rotation_target = _read_i_rotation_component_calc(i_pos, j_pos, angle_deg_pos) - j_rotation_current = value + i_rotation_current = _read_i_rotation_component_calc( + i_pos, j_pos, angle_deg_pos + ) + j_rotation_target = value # Convert back to motor frame by doing inverse rotation to determine actual motor positions new_i_pos, new_j_pos = reverse_rotate( - radians(angle_deg_pos), i_rotation_target, j_rotation_current + radians(angle_deg_pos), i_rotation_current, j_rotation_target ) await asyncio.gather( maybe_await(i_write.set(new_i_pos)), maybe_await(j_write.set(new_j_pos)) From 8398fb450d3badbd7f0687179fe19222581e9ecb Mon Sep 17 00:00:00 2001 From: Oli Wenman Date: Tue, 3 Feb 2026 12:49:29 +0000 Subject: [PATCH 41/81] Add more detailed doc strings and fix lint --- src/dodal/devices/i05/i05_motors.py | 23 ++++++++++++--- src/dodal/devices/i05_1/i05_1_motors.py | 28 +++++++++++++++++-- .../devices/i05_shared/rotation_signals.py | 25 ++++++++++------- 3 files changed, 59 insertions(+), 17 deletions(-) diff --git a/src/dodal/devices/i05/i05_motors.py b/src/dodal/devices/i05/i05_motors.py index a48a99d0f8..97c86295b3 100644 --- a/src/dodal/devices/i05/i05_motors.py +++ b/src/dodal/devices/i05/i05_motors.py @@ -13,10 +13,25 @@ class I05Goniometer(XYZPolarAzimuthTiltStage): - """ - Six-axis stage with a standard xyz stage and three axis of rotation: polar, azimuth - and tilt. This implementation extends to add perp and long rotations as derived - signals at a configured angle (default 50 degrees). + """Six-axis stage with a standard xyz stage and three axis of rotation: polar, + azimuth, and tilt. + + In addition, it defines two virtual translational axes, `perp` and `long`, which + form a rotated Cartesian frame within the x-y plane. + + - `long`: Translation along the longitudinal direction of the rotated in-plane + coordinate frame defined by ``rotation_angle_deg``. + + - `perp`: Translation perpendicular to `long` within the x-y plane. + + The `perp` and `long` axes are derived from the underlying x and y motors using a + fixed rotation angle (default 50 degrees). From the user's point of view, these + behave as ordinary orthogonal Cartesian translation axes aligned with physically + meaningful directions on the sample, while internally coordinating motion of the x + and y motors. + + Unlike sample-frame axes that rotate with a live rotation motor, these axes are + defined at a constant orientation set by `rotation_angle_deg`. """ def __init__( diff --git a/src/dodal/devices/i05_1/i05_1_motors.py b/src/dodal/devices/i05_1/i05_1_motors.py index cb36b5b0bc..e7e52226b3 100644 --- a/src/dodal/devices/i05_1/i05_1_motors.py +++ b/src/dodal/devices/i05_1/i05_1_motors.py @@ -7,9 +7,31 @@ class XYZPolarAzimuthDefocusStage(XYZPolarAzimuthStage): - """ - Six-axis stage with a standard xyz stage and three axis of rotation: polar, azimuth - and defocus. + """Six-axis stage with a standard xyz stage and three axis of rotation: polar, + azimuth, and defocus. + + This device exposes four virtual translational axes that are defined in frames + of reference attached to the sample: + + - `hor` and `vert`: + Horizontal and vertical translation axes in the sample frame. + These axes are derived from the lab-frame x and y motors and rotate + with the azimuth angle, so that motion along `hor` and `vert` + remains aligned with the sample regardless of its azimuthal rotation. + + - `long` and `perp`: + Longitudinal and perpendicular translation axes in the tilted sample + frame. These axes are derived from the lab-frame z motor and the + sample-frame `hor` axis, and rotate with the polar angle. + Motion along `long` follows the sample's longitudinal direction, + while `perp` moves perpendicular to it within the polar rotation plane. + + All four 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 azimuth or polar rotations. """ def __init__( diff --git a/src/dodal/devices/i05_shared/rotation_signals.py b/src/dodal/devices/i05_shared/rotation_signals.py index 24f7c449c7..2fe04a73b9 100644 --- a/src/dodal/devices/i05_shared/rotation_signals.py +++ b/src/dodal/devices/i05_shared/rotation_signals.py @@ -26,13 +26,17 @@ def create_rotational_ij_component_signals( angle_deg: float | SignalR[float], clockwise_frame: bool = True, ) -> tuple[SignalRW[float], SignalRW[float]]: - """ - Create virtual "rotated" i and j component signals from two real motors. + """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. - These virtual signals represent the i/j components after rotation by a given - angle. When writing to these virtual signals, the real motor positions are - adjusted using the inverse rotation so that the rotated component moves to the - requested value. + 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): SignalR representing the i motor readback. @@ -40,14 +44,13 @@ def create_rotational_ij_component_signals( i_write (Movable): object for setting the i position. j_write (Movable): object for setting the j position. angle_deg (float | SignalR): Rotation angle in degrees. - clockwise_rotation(boolean): If True, the rotated frame is defined using a + clockwise_frame (boolean): If True, the rotated frame is defined using a clockwise rotation; otherwise, a counter-clockwise rotation is used. 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 reverse_rotate = rotate_counter_clockwise if clockwise_frame else rotate_clockwise @@ -57,7 +60,8 @@ def _read_i_rotation_component_calc(i: float, j: float, angle_deg: float) -> flo async def _set_i_rotation_component_calc(value: float) -> None: """Move virtual i-axis to desired position while keeping j-axis constant in the - rotated frame.""" + rotated frame. + """ i_pos, j_pos, angle_deg_pos = await asyncio.gather( i_read.get_value(), j_read.get_value(), @@ -82,7 +86,8 @@ def _read_j_rotation_component_calc(i: float, j: float, angle_deg: float) -> flo async def _set_j_rotation_component_calc(value: float) -> None: """Move virtual j-axis to desired position while keeping j-axis constant in the - rotated frame.""" + rotated frame. + """ i_pos, j_pos, angle_deg_pos = await asyncio.gather( i_read.get_value(), j_read.get_value(), From c9ec869f97e8db256062c0c6560eb21e37e2082a Mon Sep 17 00:00:00 2001 From: Oli Wenman Date: Tue, 3 Feb 2026 12:53:30 +0000 Subject: [PATCH 42/81] Reduced duplication of code --- .../devices/i05_shared/rotation_signals.py | 80 ++++++------------- 1 file changed, 24 insertions(+), 56 deletions(-) diff --git a/src/dodal/devices/i05_shared/rotation_signals.py b/src/dodal/devices/i05_shared/rotation_signals.py index 2fe04a73b9..a3fc44cdd2 100644 --- a/src/dodal/devices/i05_shared/rotation_signals.py +++ b/src/dodal/devices/i05_shared/rotation_signals.py @@ -52,70 +52,38 @@ def create_rotational_ij_component_signals( corresponding to the rotated i and j components. """ rotate = rotate_clockwise if clockwise_frame else rotate_counter_clockwise - reverse_rotate = rotate_counter_clockwise if clockwise_frame else rotate_clockwise + inverse_rotate = rotate_counter_clockwise if clockwise_frame else rotate_clockwise - def _read_i_rotation_component_calc(i: float, j: float, angle_deg: float) -> float: - new_i, new_j = rotate(radians(angle_deg), i, j) - return new_i - - async def _set_i_rotation_component_calc(value: float) -> None: - """Move virtual i-axis to desired position while keeping j-axis constant in the - rotated frame. - """ - i_pos, j_pos, angle_deg_pos = await asyncio.gather( + 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), ) - # Rotated coordinates - i_rotation_target = value - j_rotation_current = _read_j_rotation_component_calc( - i_pos, j_pos, angle_deg_pos - ) - # Convert back to motor frame by doing inverse rotation to determine actual motor positions - new_i_pos, new_j_pos = reverse_rotate( - radians(angle_deg_pos), i_rotation_target, j_rotation_current - ) + 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(new_i_pos)), maybe_await(j_write.set(new_j_pos)) + maybe_await(i_write.set(i_new)), + maybe_await(j_write.set(j_new)), ) - def _read_j_rotation_component_calc(i: float, j: float, angle_deg: float) -> float: - new_i, new_j = rotate(radians(angle_deg), i, j) - return new_j + def _read_i(i: float, j: float, ang: float) -> float: + return rotate(radians(ang), i, j)[0] - async def _set_j_rotation_component_calc(value: float) -> None: - """Move virtual j-axis to desired position while keeping j-axis constant in the - rotated frame. - """ - i_pos, j_pos, angle_deg_pos = await asyncio.gather( - i_read.get_value(), - j_read.get_value(), - _get_angle_deg(angle_deg), - ) - # Rotated coordinates - i_rotation_current = _read_i_rotation_component_calc( - i_pos, j_pos, angle_deg_pos - ) - j_rotation_target = value - # Convert back to motor frame by doing inverse rotation to determine actual motor positions - new_i_pos, new_j_pos = reverse_rotate( - radians(angle_deg_pos), i_rotation_current, j_rotation_target - ) - await asyncio.gather( - maybe_await(i_write.set(new_i_pos)), maybe_await(j_write.set(new_j_pos)) - ) + 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_rotation_component_calc, - _set_i_rotation_component_calc, - i=i_read, - j=j_read, - angle_deg=angle_deg, - ), derived_signal_rw( - _read_j_rotation_component_calc, - _set_j_rotation_component_calc, - i=i_read, - j=j_read, - angle_deg=angle_deg, + 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), ) From 0689c2320cabd80d00059ecc8b0275694f28c3c7 Mon Sep 17 00:00:00 2001 From: Oli Wenman Date: Tue, 3 Feb 2026 14:15:08 +0000 Subject: [PATCH 43/81] Add tests for maths rotation functions --- tests/common/test_maths.py | 80 +++++++++++++++++++++++++++++++++++++- 1 file changed, 79 insertions(+), 1 deletion(-) diff --git a/tests/common/test_maths.py b/tests/common/test_maths.py index 562658a14a..2ca0c76d53 100644 --- a/tests/common/test_maths.py +++ b/tests/common/test_maths.py @@ -1,7 +1,15 @@ +from math import pi, sqrt + +import numpy as np import pytest from dodal.common import in_micros, step_to_num -from dodal.common.maths import Rectangle2D +from dodal.common.maths import ( + Rectangle2D, + do_rotation, + rotate_clockwise, + rotate_counter_clockwise, +) @pytest.mark.parametrize( @@ -108,3 +116,73 @@ def test_rectangle_min_max( assert rect.get_max_y() == max_y assert rect.get_min_x() == min_x assert rect.get_min_y() == min_y + + +@pytest.mark.parametrize( + "theta,x,y,expected", + [ + (0.0, 1.0, 2.0, (1.0, 2.0)), + (pi / 2, 1.0, 0.0, (0.0, -1.0)), # 90° clockwise + (pi, 1.0, 2.0, (-1.0, -2.0)), + (2 * pi, 3.0, -4.0, (3.0, -4.0)), + ], +) +def test_rotate_clockwise_known_angles(theta, x, y, expected): + x_new, y_new = rotate_clockwise(theta, x, y) + assert x_new == pytest.approx(expected[0]) + assert y_new == pytest.approx(expected[1]) + + +@pytest.mark.parametrize( + "theta,x,y,expected", + [ + (0.0, 1.0, 2.0, (1.0, 2.0)), + (pi / 2, 1.0, 0.0, (0.0, 1.0)), # 90° CCW + (pi, 1.0, 2.0, (-1.0, -2.0)), + ], +) +def test_rotate_counter_clockwise_known_angles(theta, x, y, expected): + x_new, y_new = rotate_counter_clockwise(theta, x, y) + assert x_new == pytest.approx(expected[0]) + assert y_new == pytest.approx(expected[1]) + + +def test_clockwise_and_counter_clockwise_are_inverses(): + x, y = 2.5, -1.3 + theta = 0.73 + + x_rot, y_rot = rotate_clockwise(theta, x, y) + x_back, y_back = rotate_counter_clockwise(theta, x_rot, y_rot) + + assert x_back == pytest.approx(x) + assert y_back == pytest.approx(y) + + +def test_rotation_preserves_length(): + x, y = 3.0, 4.0 + theta = 1.234 + + x_rot, y_rot = rotate_clockwise(theta, x, y) + + original_length = sqrt(x**2 + y**2) + rotated_length = sqrt(x_rot**2 + y_rot**2) + + assert rotated_length == pytest.approx(original_length) + + +def test_do_rotation_matches_manual_matrix_multiply(): + x, y = 1.2, -0.4 + theta = 0.5 + + rotation_matrix = np.array( + [ + [np.cos(theta), -np.sin(theta)], + [np.sin(theta), np.cos(theta)], + ] + ) + + x_new, y_new = do_rotation(x, y, rotation_matrix) + + expected = rotation_matrix @ np.array([x, y]) + assert x_new == pytest.approx(expected[0]) + assert y_new == pytest.approx(expected[1]) From ec7b7b52427f467317920bf926224974fb8ec8a8 Mon Sep 17 00:00:00 2001 From: Oli Wenman Date: Tue, 3 Feb 2026 14:16:22 +0000 Subject: [PATCH 44/81] Simplify assert rotated function, add doc string, improve type checking --- tests/devices/i05/test_i05_motors.py | 21 ++- tests/devices/i05_1/test_i05_1_motors.py | 19 +-- .../i05_shared/rotation_signal_test_util.py | 150 ++++++++++++------ 3 files changed, 117 insertions(+), 73 deletions(-) diff --git a/tests/devices/i05/test_i05_motors.py b/tests/devices/i05/test_i05_motors.py index b1900c283f..0d3bcd0b43 100644 --- a/tests/devices/i05/test_i05_motors.py +++ b/tests/devices/i05/test_i05_motors.py @@ -7,8 +7,8 @@ from dodal.devices.i05 import I05Goniometer from tests.devices.i05_shared.rotation_signal_test_util import ( - AxesTestConfig, - assert_set_axis_preserves_other, + RotatedCartesianFrameTestConfig, + assert_rotated_axes_are_orthogonal, ) @@ -21,7 +21,7 @@ def expected_long_read(x: float, y: float, theta: float) -> float: @pytest.fixture -async def goniometer(): +async def goniometer() -> I05Goniometer: with init_devices(mock=True): goniometer = I05Goniometer("TEST:") return goniometer @@ -50,26 +50,23 @@ async def test_goniometer_read(goniometer: I05Goniometer) -> None: ) -async def test_goniometer_perp_and_long_set( - goniometer: I05Goniometer, -) -> None: - axes_test_config = AxesTestConfig( +async def test_goniometer_perp_and_long_set(goniometer: I05Goniometer) -> None: + frame_config = RotatedCartesianFrameTestConfig( i_read=goniometer.x.user_readback, j_read=goniometer.y.user_readback, - i_write=goniometer.x, # type: ignore - j_write=goniometer.y, # type: ignore + i_write=goniometer.x, + j_write=goniometer.y, angle_deg=goniometer.rotation_angle_deg, expected_i_read_func=expected_perp_read, expected_j_read_func=expected_long_read, i_rotation_axis=goniometer.perp, j_rotation_axis=goniometer.long, ) - - await assert_set_axis_preserves_other( + await assert_rotated_axes_are_orthogonal( i_val=10, j_val=5, angle_deg_val=goniometer.rotation_angle_deg, new_i_axis_value=20, new_j_axis_value=20, - axes_config=axes_test_config, + frame_config=frame_config, ) diff --git a/tests/devices/i05_1/test_i05_1_motors.py b/tests/devices/i05_1/test_i05_1_motors.py index 416c517bb5..8f3f960eeb 100644 --- a/tests/devices/i05_1/test_i05_1_motors.py +++ b/tests/devices/i05_1/test_i05_1_motors.py @@ -7,8 +7,8 @@ from dodal.devices.i05_1 import XYZPolarAzimuthDefocusStage from tests.devices.i05_shared.rotation_signal_test_util import ( - AxesTestConfig, - assert_set_axis_preserves_other, + RotatedCartesianFrameTestConfig, + assert_rotated_axes_are_orthogonal, ) @@ -55,7 +55,6 @@ async def test_xyzpad_stage_read(xyzpad_stage: XYZPolarAzimuthDefocusStage) -> N xyzpad_stage.azimuth.set(azimuth_angle_deg), xyzpad_stage.polar.set(polar_angle_deg), ) - await assert_reading( xyzpad_stage, { @@ -76,7 +75,7 @@ async def test_xyzpad_stage_read(xyzpad_stage: XYZPolarAzimuthDefocusStage) -> N async def test_xyzpad_hor_and_vert_set( xyzpad_stage: XYZPolarAzimuthDefocusStage, ) -> None: - axes_test_config = AxesTestConfig( + frame_config = RotatedCartesianFrameTestConfig( i_read=xyzpad_stage.x.user_readback, j_read=xyzpad_stage.y.user_readback, i_write=xyzpad_stage.x, @@ -87,21 +86,20 @@ async def test_xyzpad_hor_and_vert_set( i_rotation_axis=xyzpad_stage.hor, j_rotation_axis=xyzpad_stage.vert, ) - - await assert_set_axis_preserves_other( + await assert_rotated_axes_are_orthogonal( i_val=10, j_val=5, angle_deg_val=45, new_i_axis_value=20, new_j_axis_value=20, - axes_config=axes_test_config, + frame_config=frame_config, ) async def test_xyzpad_long_and_perp_set( xyzpad_stage: XYZPolarAzimuthDefocusStage, ) -> None: - axes_test_config = AxesTestConfig( + frame_config = RotatedCartesianFrameTestConfig( i_read=xyzpad_stage.z.user_readback, j_read=xyzpad_stage.hor, i_write=xyzpad_stage.z, @@ -112,12 +110,11 @@ async def test_xyzpad_long_and_perp_set( i_rotation_axis=xyzpad_stage.long, j_rotation_axis=xyzpad_stage.perp, ) - - await assert_set_axis_preserves_other( + await assert_rotated_axes_are_orthogonal( i_val=10, j_val=5, angle_deg_val=45, new_i_axis_value=20, new_j_axis_value=20, - axes_config=axes_test_config, + frame_config=frame_config, ) diff --git a/tests/devices/i05_shared/rotation_signal_test_util.py b/tests/devices/i05_shared/rotation_signal_test_util.py index b5a1e1719f..175ee48ed3 100644 --- a/tests/devices/i05_shared/rotation_signal_test_util.py +++ b/tests/devices/i05_shared/rotation_signal_test_util.py @@ -10,7 +10,7 @@ @dataclass -class AxesTestConfig: +class RotatedCartesianFrameTestConfig: i_read: SignalR[float] j_read: SignalR[float] i_write: Movable[float] @@ -22,68 +22,118 @@ class AxesTestConfig: j_rotation_axis: SignalRW[float] -async def assert_set_axis_preserves_other( - *, +async def _set_initial_state( i_val: float, j_val: float, angle_deg_val: float, - new_i_axis_value: float, - new_j_axis_value: float, - axes_config: AxesTestConfig, + frame_config: RotatedCartesianFrameTestConfig, ) -> None: await asyncio.gather( - maybe_await(axes_config.i_write.set(i_val)), - maybe_await(axes_config.j_write.set(j_val)), + maybe_await(frame_config.i_write.set(i_val)), + maybe_await(frame_config.j_write.set(j_val)), ) - angle_deg = axes_config.angle_deg - if isinstance(angle_deg, Movable): - await maybe_await(angle_deg.set(angle_deg_val)) - - theta = radians(angle_deg_val) + if isinstance(frame_config.angle_deg, Movable): + await maybe_await(frame_config.angle_deg.set(angle_deg_val)) - # Capture invariant component before move - expected_i = new_i_axis_value - expected_j = axes_config.expected_j_read_func(i_val, j_val, theta) - # Move axis to new position - await axes_config.i_rotation_axis.set(expected_i) - - # Read back motors +async def _read_rotated_components( + frame_config: RotatedCartesianFrameTestConfig, theta: float +) -> tuple[float, float]: i_new, j_new = await asyncio.gather( - axes_config.i_read.get_value(), axes_config.j_read.get_value() + frame_config.i_read.get_value(), + frame_config.j_read.get_value(), ) - - # Compute rotated components after move - i_val_after = axes_config.expected_i_read_func(i_new, j_new, theta) - j_val_after = axes_config.expected_j_read_func(i_new, j_new, theta) - - assert i_val_after == pytest.approx(expected_i) - assert j_val_after == pytest.approx(expected_j) - - await asyncio.gather( - maybe_await(axes_config.i_write.set(i_val)), - maybe_await(axes_config.j_write.set(j_val)), + return ( + frame_config.expected_i_read_func(i_new, j_new, theta), + frame_config.expected_j_read_func(i_new, j_new, theta), ) - angle_deg = axes_config.angle_deg - if isinstance(angle_deg, Movable): - await maybe_await(angle_deg.set(angle_deg_val)) - - theta = radians(angle_deg_val) - - expected_j = new_j_axis_value - expected_i = axes_config.expected_i_read_func(i_val, j_val, theta) - # Move axis to new position - await axes_config.j_rotation_axis.set(expected_j) - # Read back motors - i_new, j_new = await asyncio.gather( - axes_config.i_read.get_value(), axes_config.j_read.get_value() +async def _assert_setting_axis_preserves_other( + set_axis: SignalRW[float], + set_value: float, + expected_i: float, + expected_j: float, + frame_config: RotatedCartesianFrameTestConfig, + theta: float, +) -> None: + await set_axis.set(set_value) + i_after, j_after = await _read_rotated_components( + frame_config=frame_config, theta=theta ) + assert i_after == pytest.approx(expected_i) + assert j_after == pytest.approx(expected_j) - # Compute rotated components after move - i_val_after = axes_config.expected_i_read_func(i_new, j_new, theta) - j_val_after = axes_config.expected_j_read_func(i_new, j_new, theta) - assert i_val_after == pytest.approx(expected_i) - assert j_val_after == pytest.approx(expected_j) +async def assert_rotated_axes_are_orthogonal( + i_val: float, + j_val: float, + angle_deg_val: float, + new_i_axis_value: float, + new_j_axis_value: float, + frame_config: RotatedCartesianFrameTestConfig, +) -> None: + """Assert that the virtual rotated axes behave as independent Cartesian + coordinates. + + This helper verifies that setting one virtual axis in a rotated frame + updates only that axis while preserving the orthogonal axis in the same + rotated frame. + + Specifically, the test: + - Initializes the underlying i/j motors and rotation angle. + - Sets the virtual i' axis and asserts that: + * i' moves to the requested value + * j' remains unchanged + - Resets the system. + - Sets the virtual j' axis and asserts that: + * j' moves to the requested value + * i' remains unchanged + + This confirms that the virtual axes form a proper orthogonal Cartesian + coordinate frame, and that inverse rotations are applied correctly when + writing to the underlying motors. + + Args: + i_val: Initial value for the underlying i-axis. + j_val: Initial value for the underlying j-axis. + angle_deg_val: Rotation angle (in degrees) defining the rotated frame. + new_i_axis_value: Target value for the virtual i' axis. + new_j_axis_value: Target value for the virtual j' axis. + frame_config: Configuration describing how virtual rotated axes are derived from + the underlying motors, including expected readout functions. + """ + # Test setting i′ preserves j′ + await _set_initial_state( + i_val=i_val, + j_val=j_val, + angle_deg_val=angle_deg_val, + frame_config=frame_config, + ) + theta = radians(angle_deg_val) + expected_j = frame_config.expected_j_read_func(i_val, j_val, theta) + + await _assert_setting_axis_preserves_other( + set_axis=frame_config.i_rotation_axis, + set_value=new_i_axis_value, + expected_i=new_i_axis_value, + expected_j=expected_j, + frame_config=frame_config, + theta=theta, + ) + # Test setting j′ preserves i′ + await _set_initial_state( + i_val=i_val, + j_val=j_val, + angle_deg_val=angle_deg_val, + frame_config=frame_config, + ) + expected_i = frame_config.expected_i_read_func(i_val, j_val, theta) + await _assert_setting_axis_preserves_other( + set_axis=frame_config.j_rotation_axis, + set_value=new_j_axis_value, + expected_i=expected_i, + expected_j=new_j_axis_value, + frame_config=frame_config, + theta=theta, + ) From d7db203fe978f842b0c37fcfaa9cdc5181ceb4b9 Mon Sep 17 00:00:00 2001 From: Oli Wenman Date: Tue, 3 Feb 2026 14:21:58 +0000 Subject: [PATCH 45/81] Improve type checking --- tests/common/test_maths.py | 21 +++++++++++---------- 1 file changed, 11 insertions(+), 10 deletions(-) diff --git a/tests/common/test_maths.py b/tests/common/test_maths.py index 2ca0c76d53..c4de2c34e5 100644 --- a/tests/common/test_maths.py +++ b/tests/common/test_maths.py @@ -122,12 +122,14 @@ def test_rectangle_min_max( "theta,x,y,expected", [ (0.0, 1.0, 2.0, (1.0, 2.0)), - (pi / 2, 1.0, 0.0, (0.0, -1.0)), # 90° clockwise + (pi / 2, 1.0, 0.0, (0.0, -1.0)), # 90 degress clockwise (pi, 1.0, 2.0, (-1.0, -2.0)), (2 * pi, 3.0, -4.0, (3.0, -4.0)), ], ) -def test_rotate_clockwise_known_angles(theta, x, y, expected): +def test_rotate_clockwise_known_angles( + theta: float, x: float, y: float, expected: tuple[float, float] +) -> None: x_new, y_new = rotate_clockwise(theta, x, y) assert x_new == pytest.approx(expected[0]) assert y_new == pytest.approx(expected[1]) @@ -137,17 +139,19 @@ def test_rotate_clockwise_known_angles(theta, x, y, expected): "theta,x,y,expected", [ (0.0, 1.0, 2.0, (1.0, 2.0)), - (pi / 2, 1.0, 0.0, (0.0, 1.0)), # 90° CCW + (pi / 2, 1.0, 0.0, (0.0, 1.0)), # 90 degrees counter clockwise (pi, 1.0, 2.0, (-1.0, -2.0)), ], ) -def test_rotate_counter_clockwise_known_angles(theta, x, y, expected): +def test_rotate_counter_clockwise_known_angles( + theta: float, x: float, y: float, expected: tuple[float, float] +) -> None: x_new, y_new = rotate_counter_clockwise(theta, x, y) assert x_new == pytest.approx(expected[0]) assert y_new == pytest.approx(expected[1]) -def test_clockwise_and_counter_clockwise_are_inverses(): +def test_clockwise_and_counter_clockwise_are_inverses() -> None: x, y = 2.5, -1.3 theta = 0.73 @@ -163,26 +167,23 @@ def test_rotation_preserves_length(): theta = 1.234 x_rot, y_rot = rotate_clockwise(theta, x, y) - original_length = sqrt(x**2 + y**2) rotated_length = sqrt(x_rot**2 + y_rot**2) assert rotated_length == pytest.approx(original_length) -def test_do_rotation_matches_manual_matrix_multiply(): +def test_do_rotation_matches_manual_matrix_multiply() -> None: x, y = 1.2, -0.4 theta = 0.5 - rotation_matrix = np.array( [ [np.cos(theta), -np.sin(theta)], [np.sin(theta), np.cos(theta)], ] ) - x_new, y_new = do_rotation(x, y, rotation_matrix) - expected = rotation_matrix @ np.array([x, y]) + assert x_new == pytest.approx(expected[0]) assert y_new == pytest.approx(expected[1]) From ec853decc85e78787936881b7c00ce2e7ff7df86 Mon Sep 17 00:00:00 2001 From: Oli Wenman Date: Tue, 3 Feb 2026 15:16:44 +0000 Subject: [PATCH 46/81] Correct defocus infix --- src/dodal/devices/i05_1/i05_1_motors.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/dodal/devices/i05_1/i05_1_motors.py b/src/dodal/devices/i05_1/i05_1_motors.py index e7e52226b3..a1a940b63c 100644 --- a/src/dodal/devices/i05_1/i05_1_motors.py +++ b/src/dodal/devices/i05_1/i05_1_motors.py @@ -42,7 +42,7 @@ def __init__( z_infix="SMZ", polar_infix="POL", azimuth_infix="AZM", - defocus_infix="SDMF", + defocus_infix="SMDF", name="", ): super().__init__( From 118dc6c66a620496c401f4e172d77a786737a569 Mon Sep 17 00:00:00 2001 From: Oli Wenman Date: Tue, 3 Feb 2026 17:25:56 +0000 Subject: [PATCH 47/81] Refactor existing classes to add i21 --- src/dodal/beamlines/i09.py | 6 ++--- src/dodal/beamlines/i09_1.py | 6 ++--- src/dodal/devices/motors.py | 50 +++++++++++++++++++++++++----------- 3 files changed, 41 insertions(+), 21 deletions(-) diff --git a/src/dodal/beamlines/i09.py b/src/dodal/beamlines/i09.py index e450afa655..1138580284 100644 --- a/src/dodal/beamlines/i09.py +++ b/src/dodal/beamlines/i09.py @@ -9,7 +9,7 @@ from dodal.devices.electron_analyser.vgscienta import VGScientaDetector from dodal.devices.fast_shutter import DualFastShutter, GenericFastShutter from dodal.devices.i09 import LensMode, PassEnergy, PsuMode -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 eeb2664bac..e75d066b7c 100644 --- a/src/dodal/beamlines/i09_1.py +++ b/src/dodal/beamlines/i09_1.py @@ -5,7 +5,7 @@ from dodal.devices.electron_analyser.base import EnergySource from dodal.devices.electron_analyser.specs import SpecsDetector from dodal.devices.i09_1 import LensMode, PsuMode -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/devices/motors.py b/src/dodal/devices/motors.py index 086b62f6b4..c5f33b1377 100644 --- a/src/dodal/devices/motors.py +++ b/src/dodal/devices/motors.py @@ -104,8 +104,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, @@ -114,16 +114,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__( @@ -133,17 +133,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__( @@ -153,14 +173,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 ) From 8f180b91eb66ae4e7d24cfffbadee34ddf640b0a Mon Sep 17 00:00:00 2001 From: Oli Wenman Date: Tue, 3 Feb 2026 17:43:19 +0000 Subject: [PATCH 48/81] Update i05 device location for main branch --- src/dodal/beamlines/i05.py | 2 +- src/dodal/beamlines/i05_1.py | 2 +- src/dodal/devices/beamlines/i05/__init__.py | 5 +++-- src/dodal/devices/{ => beamlines}/i05_1/__init__.py | 0 src/dodal/devices/{ => beamlines}/i05_1/i05_1_motors.py | 4 ++-- src/dodal/devices/{ => beamlines}/i05_shared/__init__.py | 0 .../devices/{ => beamlines}/i05_shared/rotation_signals.py | 0 tests/devices/{ => beamlines}/i05/__init__.py | 0 tests/devices/{ => beamlines}/i05/test_i05_motors.py | 0 tests/devices/{ => beamlines}/i05_1/__init__.py | 0 tests/devices/{ => beamlines}/i05_1/test_i05_1_motors.py | 2 +- tests/devices/{ => beamlines}/i05_shared/__init__.py | 0 .../{ => beamlines}/i05_shared/rotation_signal_test_util.py | 0 13 files changed, 8 insertions(+), 7 deletions(-) rename src/dodal/devices/{ => beamlines}/i05_1/__init__.py (100%) rename src/dodal/devices/{ => beamlines}/i05_1/i05_1_motors.py (100%) rename src/dodal/devices/{ => beamlines}/i05_shared/__init__.py (100%) rename src/dodal/devices/{ => beamlines}/i05_shared/rotation_signals.py (100%) rename tests/devices/{ => beamlines}/i05/__init__.py (100%) rename tests/devices/{ => beamlines}/i05/test_i05_motors.py (100%) rename tests/devices/{ => beamlines}/i05_1/__init__.py (100%) rename tests/devices/{ => beamlines}/i05_1/test_i05_1_motors.py (100%) rename tests/devices/{ => beamlines}/i05_shared/__init__.py (100%) rename tests/devices/{ => beamlines}/i05_shared/rotation_signal_test_util.py (100%) diff --git a/src/dodal/beamlines/i05.py b/src/dodal/beamlines/i05.py index 4cdc589053..2ca7d00d06 100644 --- a/src/dodal/beamlines/i05.py +++ b/src/dodal/beamlines/i05.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.i05 import I05Goniometer +from dodal.devices.beamlines.i05 import I05Goniometer from dodal.devices.temperture_controller import Lakeshore336 from dodal.log import set_beamline as set_log_beamline from dodal.utils import BeamlinePrefix, get_beamline_name diff --git a/src/dodal/beamlines/i05_1.py b/src/dodal/beamlines/i05_1.py index 135a63be7d..93842aaa5f 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.i05_1 import XYZPolarAzimuthDefocusStage +from dodal.devices.beamlines.i05_1 import XYZPolarAzimuthDefocusStage from dodal.log import set_beamline as set_log_beamline from dodal.utils import BeamlinePrefix, get_beamline_name diff --git a/src/dodal/devices/beamlines/i05/__init__.py b/src/dodal/devices/beamlines/i05/__init__.py index 5bedbdc16e..b11147cc6d 100644 --- a/src/dodal/devices/beamlines/i05/__init__.py +++ b/src/dodal/devices/beamlines/i05/__init__.py @@ -1,3 +1,4 @@ -from dodal.devices.beamlines.i05.enums import Grating +from .enums import Grating +from .i05_motors import I05Goniometer -__all__ = ["Grating"] +__all__ = ["Grating", "I05Goniometer"] diff --git a/src/dodal/devices/i05_1/__init__.py b/src/dodal/devices/beamlines/i05_1/__init__.py similarity index 100% rename from src/dodal/devices/i05_1/__init__.py rename to src/dodal/devices/beamlines/i05_1/__init__.py diff --git a/src/dodal/devices/i05_1/i05_1_motors.py b/src/dodal/devices/beamlines/i05_1/i05_1_motors.py similarity index 100% rename from src/dodal/devices/i05_1/i05_1_motors.py rename to src/dodal/devices/beamlines/i05_1/i05_1_motors.py index a1a940b63c..aebdaafaf9 100644 --- a/src/dodal/devices/i05_1/i05_1_motors.py +++ b/src/dodal/devices/beamlines/i05_1/i05_1_motors.py @@ -1,8 +1,8 @@ -from ophyd_async.epics.motor import Motor - from dodal.devices.i05_shared.rotation_signals import ( create_rotational_ij_component_signals, ) +from ophyd_async.epics.motor import Motor + from dodal.devices.motors import XYZPolarAzimuthStage diff --git a/src/dodal/devices/i05_shared/__init__.py b/src/dodal/devices/beamlines/i05_shared/__init__.py similarity index 100% rename from src/dodal/devices/i05_shared/__init__.py rename to src/dodal/devices/beamlines/i05_shared/__init__.py diff --git a/src/dodal/devices/i05_shared/rotation_signals.py b/src/dodal/devices/beamlines/i05_shared/rotation_signals.py similarity index 100% rename from src/dodal/devices/i05_shared/rotation_signals.py rename to src/dodal/devices/beamlines/i05_shared/rotation_signals.py diff --git a/tests/devices/i05/__init__.py b/tests/devices/beamlines/i05/__init__.py similarity index 100% rename from tests/devices/i05/__init__.py rename to tests/devices/beamlines/i05/__init__.py diff --git a/tests/devices/i05/test_i05_motors.py b/tests/devices/beamlines/i05/test_i05_motors.py similarity index 100% rename from tests/devices/i05/test_i05_motors.py rename to tests/devices/beamlines/i05/test_i05_motors.py diff --git a/tests/devices/i05_1/__init__.py b/tests/devices/beamlines/i05_1/__init__.py similarity index 100% rename from tests/devices/i05_1/__init__.py rename to tests/devices/beamlines/i05_1/__init__.py diff --git a/tests/devices/i05_1/test_i05_1_motors.py b/tests/devices/beamlines/i05_1/test_i05_1_motors.py similarity index 100% rename from tests/devices/i05_1/test_i05_1_motors.py rename to tests/devices/beamlines/i05_1/test_i05_1_motors.py index 8f3f960eeb..eaeed05a1b 100644 --- a/tests/devices/i05_1/test_i05_1_motors.py +++ b/tests/devices/beamlines/i05_1/test_i05_1_motors.py @@ -2,10 +2,10 @@ from math import cos, radians, sin import pytest +from dodal.devices.i05_1 import XYZPolarAzimuthDefocusStage from ophyd_async.core import init_devices from ophyd_async.testing import assert_reading, partial_reading -from dodal.devices.i05_1 import XYZPolarAzimuthDefocusStage from tests.devices.i05_shared.rotation_signal_test_util import ( RotatedCartesianFrameTestConfig, assert_rotated_axes_are_orthogonal, diff --git a/tests/devices/i05_shared/__init__.py b/tests/devices/beamlines/i05_shared/__init__.py similarity index 100% rename from tests/devices/i05_shared/__init__.py rename to tests/devices/beamlines/i05_shared/__init__.py diff --git a/tests/devices/i05_shared/rotation_signal_test_util.py b/tests/devices/beamlines/i05_shared/rotation_signal_test_util.py similarity index 100% rename from tests/devices/i05_shared/rotation_signal_test_util.py rename to tests/devices/beamlines/i05_shared/rotation_signal_test_util.py From 87898878755e1ccac1c0eec1b10e1f6a9794fa39 Mon Sep 17 00:00:00 2001 From: Oli Wenman Date: Tue, 3 Feb 2026 17:44:10 +0000 Subject: [PATCH 49/81] Fix test imports --- tests/devices/beamlines/i05/test_i05_motors.py | 4 ++-- tests/devices/beamlines/i05_1/test_i05_1_motors.py | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/tests/devices/beamlines/i05/test_i05_motors.py b/tests/devices/beamlines/i05/test_i05_motors.py index 0d3bcd0b43..3dead059d4 100644 --- a/tests/devices/beamlines/i05/test_i05_motors.py +++ b/tests/devices/beamlines/i05/test_i05_motors.py @@ -5,8 +5,8 @@ from ophyd_async.core import init_devices from ophyd_async.testing import assert_reading, partial_reading -from dodal.devices.i05 import I05Goniometer -from tests.devices.i05_shared.rotation_signal_test_util import ( +from dodal.devices.beamlines.i05 import I05Goniometer +from tests.devices.beamlines.i05_shared.rotation_signal_test_util import ( RotatedCartesianFrameTestConfig, assert_rotated_axes_are_orthogonal, ) 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 eaeed05a1b..b82363abb4 100644 --- a/tests/devices/beamlines/i05_1/test_i05_1_motors.py +++ b/tests/devices/beamlines/i05_1/test_i05_1_motors.py @@ -2,11 +2,11 @@ from math import cos, radians, sin import pytest -from dodal.devices.i05_1 import XYZPolarAzimuthDefocusStage from ophyd_async.core import init_devices from ophyd_async.testing import assert_reading, partial_reading -from tests.devices.i05_shared.rotation_signal_test_util import ( +from dodal.devices.beamlines.i05_1 import XYZPolarAzimuthDefocusStage +from tests.devices.beamlines.i05_shared.rotation_signal_test_util import ( RotatedCartesianFrameTestConfig, assert_rotated_axes_are_orthogonal, ) From e13e2a23bf9b3ff0d0d06203c3b2f523622c7d96 Mon Sep 17 00:00:00 2001 From: Oli Wenman Date: Wed, 4 Feb 2026 09:41:50 +0000 Subject: [PATCH 50/81] Fix imports --- src/dodal/devices/beamlines/i05/i05_motors.py | 2 +- src/dodal/devices/beamlines/i05_1/i05_1_motors.py | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/src/dodal/devices/beamlines/i05/i05_motors.py b/src/dodal/devices/beamlines/i05/i05_motors.py index 97c86295b3..9c68b6511e 100644 --- a/src/dodal/devices/beamlines/i05/i05_motors.py +++ b/src/dodal/devices/beamlines/i05/i05_motors.py @@ -1,4 +1,4 @@ -from dodal.devices.i05_shared.rotation_signals import ( +from dodal.devices.beamlines.i05_shared.rotation_signals import ( create_rotational_ij_component_signals, ) from dodal.devices.motors import ( 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 aebdaafaf9..38dc7d22f9 100644 --- a/src/dodal/devices/beamlines/i05_1/i05_1_motors.py +++ b/src/dodal/devices/beamlines/i05_1/i05_1_motors.py @@ -1,8 +1,8 @@ -from dodal.devices.i05_shared.rotation_signals import ( - create_rotational_ij_component_signals, -) from ophyd_async.epics.motor import Motor +from dodal.devices.beamlines.i05_shared.rotation_signals import ( + create_rotational_ij_component_signals, +) from dodal.devices.motors import XYZPolarAzimuthStage From 5271d19e86d8737fc4ce343808f43a5b706070a4 Mon Sep 17 00:00:00 2001 From: Oli Wenman Date: Wed, 4 Feb 2026 17:05:11 +0000 Subject: [PATCH 51/81] Add I21 sample stages --- src/dodal/beamlines/i21.py | 14 + src/dodal/devices/beamlines/i05/i05_motors.py | 12 +- .../devices/beamlines/i05_1/i05_1_motors.py | 10 +- .../devices/beamlines/i05_shared/__init__.py | 0 .../beamlines/i05_shared/rotation_signals.py | 89 ------ src/dodal/devices/beamlines/i21/i21_motors.py | 255 ++++++++++++++++++ src/dodal/devices/motors.py | 89 +++++- tests/devices/test_motors.py | 110 ++++---- 8 files changed, 421 insertions(+), 158 deletions(-) delete mode 100644 src/dodal/devices/beamlines/i05_shared/__init__.py delete mode 100644 src/dodal/devices/beamlines/i05_shared/rotation_signals.py create mode 100644 src/dodal/devices/beamlines/i21/i21_motors.py diff --git a/src/dodal/beamlines/i21.py b/src/dodal/beamlines/i21.py index 70e6fd10f8..77779e8768 100644 --- a/src/dodal/beamlines/i21.py +++ b/src/dodal/beamlines/i21.py @@ -5,6 +5,10 @@ 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.i21_motors import ( + I21XYZAzimuthTiltPolarStage, + 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() -> I21XYZAzimuthTiltPolarStage: + return I21XYZAzimuthTiltPolarStage(prefix=f"{PREFIX.beamline_prefix}-EA-SMPL-01:") + + +@devices.factory() +def uvw(smp: I21XYZAzimuthTiltPolarStage) -> 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 9c68b6511e..4550037cb5 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,11 +5,12 @@ _X, _Y, _Z, - XYZPolarAzimuthTiltStage, + XYZAzimuthTiltPolarStage, + create_rotational_ij_component_signals, ) -class I05Goniometer(XYZPolarAzimuthTiltStage): +class I05Goniometer(XYZAzimuthTiltPolarStage): """Six-axis stage with a standard xyz stage and three axis of rotation: polar, azimuth, and tilt. @@ -40,9 +38,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 = "", ): @@ -54,9 +52,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/i05_1_motors.py b/src/dodal/devices/beamlines/i05_1/i05_1_motors.py index 38dc7d22f9..d30572af3d 100644 --- a/src/dodal/devices/beamlines/i05_1/i05_1_motors.py +++ b/src/dodal/devices/beamlines/i05_1/i05_1_motors.py @@ -1,12 +1,12 @@ 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): +class XYZPolarAzimuthDefocusStage(XYZAzimuthPolarStage): """Six-axis stage with a standard xyz stage and three axis of rotation: polar, azimuth, and defocus. @@ -40,8 +40,8 @@ def __init__( x_infix="SMX", y_infix="SMY", z_infix="SMZ", - polar_infix="POL", azimuth_infix="AZM", + polar_infix="POL", defocus_infix="SMDF", name="", ): @@ -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/i05_shared/__init__.py b/src/dodal/devices/beamlines/i05_shared/__init__.py deleted file mode 100644 index e69de29bb2..0000000000 diff --git a/src/dodal/devices/beamlines/i05_shared/rotation_signals.py b/src/dodal/devices/beamlines/i05_shared/rotation_signals.py deleted file mode 100644 index a3fc44cdd2..0000000000 --- a/src/dodal/devices/beamlines/i05_shared/rotation_signals.py +++ /dev/null @@ -1,89 +0,0 @@ -import asyncio -from math import radians - -from bluesky.protocols import Movable -from bluesky.utils import maybe_await -from ophyd_async.core import ( - SignalR, - SignalRW, - derived_signal_rw, -) - -from dodal.common.maths import rotate_clockwise, rotate_counter_clockwise - - -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): SignalR representing the i motor readback. - j_read (SignalR): representing the j motor readback. - i_write (Movable): object for setting the i position. - j_write (Movable): object for setting the j position. - angle_deg (float | SignalR): Rotation angle in degrees. - clockwise_frame (boolean): If True, the rotated frame is defined using a - clockwise rotation; otherwise, a counter-clockwise rotation is used. - - 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/src/dodal/devices/beamlines/i21/i21_motors.py b/src/dodal/devices/beamlines/i21/i21_motors.py new file mode 100644 index 0000000000..afb0f76539 --- /dev/null +++ b/src/dodal/devices/beamlines/i21/i21_motors.py @@ -0,0 +1,255 @@ +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 ( + _AZIMUTH, + _TILT, + _X, + _Y, + _Z, + XYZAzimuthTiltPolarStage, + XYZAzimuthTiltStage, + create_rotational_ij_component_signals, +) + + +class I21XYZAzimuthTiltPolarStage(XYZAzimuthTiltPolarStage): + """Note: I21 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). Have used standard class in hopes + We can try topersuade i21 to use the standard variable names going forward to + align with other beamlines and not alias variables. + """ + + 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", + ): + super().__init__( + prefix, + name, + x_infix, + y_infix, + z_infix, + azimuth_infix, + tilt_infix, + polar_infix, + ) + + with self.add_children_as_readables(): + # Parallel and Perpendicular + 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, + ) + + +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)], + ] + ) + + +def toolpoint_to_xyz( + u: float, + v: float, + w: float, + tilt_deg: float, + azimuth_deg: float, + zero: tuple[float, float, float], +) -> tuple[float, float, float]: + rotation = toolpoint_rotation_matrix(tilt_deg, azimuth_deg) + tool = np.array([u, v, w]) + offset = np.array(zero) + xyz = offset + rotation @ tool + return tuple(xyz.astype(float)) + + +def xyz_to_toolpoint( + x: float, + y: float, + z: float, + tilt_deg: float, + azimuth_deg: float, + zero: tuple[float, float, float], +) -> tuple[float, float, float]: + rotation = toolpoint_rotation_matrix(tilt_deg, azimuth_deg) + xyz = np.array([x, y, z]) + offset = np.array(zero) + + tool = rotation.T @ (xyz - offset) + return tuple(tool.astype(float)) + + +AXES_ZERO = (5.667, 0.2408, 1.221) + + +@dataclass(frozen=True) +class MotorPositions: + x: float + y: float + z: float + tilt: float + azimuth: float + + +class ToolPointMotion(StandardReadable, Movable): + def __init__( + self, + smp: XYZAzimuthTiltStage, + zero: tuple[float, float, float] = AXES_ZERO, + name: str = "", + ): + self.smp = Reference(smp) + self._zero = zero + + with self.add_children_as_readables(): + self.u, self.v, self.w = self._create_uvws() + + super().__init__(name=name) + + async def _read_motor_positions(self) -> MotorPositions: + x, y, z, tilt, azimuth = await asyncio.gather( + self.smp().x.user_readback.get_value(), + self.smp().y.user_readback.get_value(), + self.smp().z.user_readback.get_value(), + self.smp().tilt.user_readback.get_value(), + self.smp().azimuth.user_readback.get_value(), + ) + return MotorPositions(x, y, z, tilt, azimuth) + + async def _check_motor_limits( + self, start: MotorPositions, end: MotorPositions + ) -> None: + await asyncio.gather( + self.smp().x.check_motor_limit(start.x, end.x), + self.smp().y.check_motor_limit(start.y, end.y), + self.smp().z.check_motor_limit(start.z, end.z), + self.smp().tilt.check_motor_limit(start.tilt, end.tilt), + self.smp().azimuth.check_motor_limit(start.azimuth, end.azimuth), + ) + + def _toolpoint_to_motor_positions( + self, u: float, v: float, w: float, tilt: float, azimuth: float + ) -> MotorPositions: + x, y, z = toolpoint_to_xyz(u, v, w, tilt, azimuth, self._zero) + return MotorPositions( + x=x, + y=y, + z=z, + tilt=tilt, + azimuth=azimuth, + ) + + async def _read_all(self) -> tuple[float, float, float, float, float]: + pos = await self._read_motor_positions() + u, v, w = xyz_to_toolpoint( + pos.x, + pos.y, + pos.z, + pos.tilt, + pos.azimuth, + self._zero, + ) + return u, v, w, pos.tilt, pos.azimuth + + async def _write_all( + self, u: float, v: float, w: float, tilt: float, azimuth: float + ) -> None: + start = await self._read_motor_positions() + end = self._toolpoint_to_motor_positions(u, v, w, tilt, azimuth) + + await self._check_motor_limits(start, end) + + await asyncio.gather( + self.smp().x.set(end.x), + self.smp().y.set(end.y), + self.smp().z.set(end.z), + self.smp().tilt.set(end.tilt), + self.smp().azimuth.set(end.azimuth), + ) + + def _create_uvws(self) -> tuple[SignalRW[float], SignalRW[float], SignalRW[float]]: + def read_u(x: float, y: float, z: float, tilt: float, azimuth: float) -> float: + return xyz_to_toolpoint(x, y, z, tilt, azimuth, self._zero)[0] + + async def set_u(value: float) -> None: + u, v, w, tilt, azimuth = await self._read_all() + await self._write_all(value, v, w, tilt, azimuth) + + def read_v(x: float, y: float, z: float, tilt: float, azimuth: float) -> float: + return xyz_to_toolpoint(x, y, z, tilt, azimuth, self._zero)[1] + + async def set_v(value: float) -> None: + u, v, w, tilt, azimuth = await self._read_all() + await self._write_all(u, value, w, tilt, azimuth) + + def read_w(x: float, y: float, z: float, tilt: float, azimuth: float) -> float: + return xyz_to_toolpoint(x, y, z, tilt, azimuth, self._zero)[2] + + async def set_w(value: float) -> None: + u, v, w, tilt, azimuth = await self._read_all() + await self._write_all(u, v, value, tilt, azimuth) + + u = derived_signal_rw( + read_u, + set_u, + x=self.smp().x, + y=self.smp().y, + z=self.smp().z, + tilt=self.smp().tilt, + azimuth=self.smp().azimuth, + ) + v = derived_signal_rw( + read_v, + set_v, + x=self.smp().x, + y=self.smp().y, + z=self.smp().z, + tilt=self.smp().tilt, + azimuth=self.smp().azimuth, + ) + w = derived_signal_rw( + read_w, + set_w, + x=self.smp().x, + y=self.smp().y, + z=self.smp().z, + tilt=self.smp().tilt, + azimuth=self.smp().azimuth, + ) + return u, v, w + + @AsyncStatus.wrap + async def set(self, value: MotorPositions): + await self._write_all(value.x, value.y, value.z, value.tilt, value.azimuth) diff --git a/src/dodal/devices/motors.py b/src/dodal/devices/motors.py index ad1f352f2f..3379485511 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 @@ -382,3 +390,80 @@ 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): SignalR representing the i motor readback. + j_read (SignalR): representing the j motor readback. + i_write (Movable): object for setting the i position. + j_write (Movable): object for setting the j position. + angle_deg (float | SignalR): Rotation angle in degrees. + clockwise_frame (boolean): If True, the rotated frame is defined using a + clockwise rotation; otherwise, a counter-clockwise rotation is used. + + 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/test_motors.py b/tests/devices/test_motors.py index daa8335af5..47aed2117e 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,38 +123,38 @@ 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), @@ -162,33 +162,33 @@ async def test_setting_xyzp_position_table( ], ) async def test_setting_xyzpa_position_table( - xyzpa_stage: XYZPolarAzimuthStage, + 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), @@ -196,30 +196,30 @@ async def test_setting_xyzpa_position_table( ], ) async def test_setting_xyzpat_position_table( - xyzpat_stage: XYZPolarAzimuthTiltStage, + 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), }, ) From 6d6b63e36bafcb5fe06ccab81aa6a0f46258f3fe Mon Sep 17 00:00:00 2001 From: Oli Wenman Date: Wed, 4 Feb 2026 17:08:49 +0000 Subject: [PATCH 52/81] Update smp to smp_ref --- src/dodal/devices/beamlines/i21/i21_motors.py | 62 +++++++++---------- 1 file changed, 31 insertions(+), 31 deletions(-) diff --git a/src/dodal/devices/beamlines/i21/i21_motors.py b/src/dodal/devices/beamlines/i21/i21_motors.py index afb0f76539..75c6b8b8d9 100644 --- a/src/dodal/devices/beamlines/i21/i21_motors.py +++ b/src/dodal/devices/beamlines/i21/i21_motors.py @@ -130,7 +130,7 @@ def __init__( zero: tuple[float, float, float] = AXES_ZERO, name: str = "", ): - self.smp = Reference(smp) + self.smp_ref = Reference(smp) self._zero = zero with self.add_children_as_readables(): @@ -140,11 +140,11 @@ def __init__( async def _read_motor_positions(self) -> MotorPositions: x, y, z, tilt, azimuth = await asyncio.gather( - self.smp().x.user_readback.get_value(), - self.smp().y.user_readback.get_value(), - self.smp().z.user_readback.get_value(), - self.smp().tilt.user_readback.get_value(), - self.smp().azimuth.user_readback.get_value(), + 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 MotorPositions(x, y, z, tilt, azimuth) @@ -152,11 +152,11 @@ async def _check_motor_limits( self, start: MotorPositions, end: MotorPositions ) -> None: await asyncio.gather( - self.smp().x.check_motor_limit(start.x, end.x), - self.smp().y.check_motor_limit(start.y, end.y), - self.smp().z.check_motor_limit(start.z, end.z), - self.smp().tilt.check_motor_limit(start.tilt, end.tilt), - self.smp().azimuth.check_motor_limit(start.azimuth, end.azimuth), + 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, end.tilt), + self.smp_ref().azimuth.check_motor_limit(start.azimuth, end.azimuth), ) def _toolpoint_to_motor_positions( @@ -192,11 +192,11 @@ async def _write_all( await self._check_motor_limits(start, end) await asyncio.gather( - self.smp().x.set(end.x), - self.smp().y.set(end.y), - self.smp().z.set(end.z), - self.smp().tilt.set(end.tilt), - self.smp().azimuth.set(end.azimuth), + self.smp_ref().x.set(end.x), + self.smp_ref().y.set(end.y), + self.smp_ref().z.set(end.z), + self.smp_ref().tilt.set(end.tilt), + self.smp_ref().azimuth.set(end.azimuth), ) def _create_uvws(self) -> tuple[SignalRW[float], SignalRW[float], SignalRW[float]]: @@ -224,29 +224,29 @@ async def set_w(value: float) -> None: u = derived_signal_rw( read_u, set_u, - x=self.smp().x, - y=self.smp().y, - z=self.smp().z, - tilt=self.smp().tilt, - azimuth=self.smp().azimuth, + x=self.smp_ref().x, + y=self.smp_ref().y, + z=self.smp_ref().z, + tilt=self.smp_ref().tilt, + azimuth=self.smp_ref().azimuth, ) v = derived_signal_rw( read_v, set_v, - x=self.smp().x, - y=self.smp().y, - z=self.smp().z, - tilt=self.smp().tilt, - azimuth=self.smp().azimuth, + x=self.smp_ref().x, + y=self.smp_ref().y, + z=self.smp_ref().z, + tilt=self.smp_ref().tilt, + azimuth=self.smp_ref().azimuth, ) w = derived_signal_rw( read_w, set_w, - x=self.smp().x, - y=self.smp().y, - z=self.smp().z, - tilt=self.smp().tilt, - azimuth=self.smp().azimuth, + x=self.smp_ref().x, + y=self.smp_ref().y, + z=self.smp_ref().z, + tilt=self.smp_ref().tilt, + azimuth=self.smp_ref().azimuth, ) return u, v, w From cb0fcc4589de3e68ed366125dc1df5faad62a280 Mon Sep 17 00:00:00 2001 From: Oli Wenman Date: Thu, 5 Feb 2026 10:53:20 +0000 Subject: [PATCH 53/81] Move ToolpointMoition to own file --- src/dodal/beamlines/i21.py | 14 +- src/dodal/devices/beamlines/i21/__init__.py | 8 +- src/dodal/devices/beamlines/i21/i21_motors.py | 249 +++--------------- .../devices/beamlines/i21/toolpoint_motion.py | 207 +++++++++++++++ 4 files changed, 253 insertions(+), 225 deletions(-) create mode 100644 src/dodal/devices/beamlines/i21/toolpoint_motion.py diff --git a/src/dodal/beamlines/i21.py b/src/dodal/beamlines/i21.py index 77779e8768..06aad8294c 100644 --- a/src/dodal/beamlines/i21.py +++ b/src/dodal/beamlines/i21.py @@ -4,10 +4,10 @@ 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.i21_motors import ( - I21XYZAzimuthTiltPolarStage, +from dodal.devices.beamlines.i21 import ( + Grating, ToolPointMotion, + XYZAzimuthTiltPolarParallelPerpendicularStage, ) from dodal.devices.insertion_device import ( Apple2, @@ -136,10 +136,12 @@ def sample_temperature_controller() -> Lakeshore336: @devices.factory() -def smp() -> I21XYZAzimuthTiltPolarStage: - return I21XYZAzimuthTiltPolarStage(prefix=f"{PREFIX.beamline_prefix}-EA-SMPL-01:") +def smp() -> XYZAzimuthTiltPolarParallelPerpendicularStage: + return XYZAzimuthTiltPolarParallelPerpendicularStage( + prefix=f"{PREFIX.beamline_prefix}-EA-SMPL-01:" + ) @devices.factory() -def uvw(smp: I21XYZAzimuthTiltPolarStage) -> ToolPointMotion: +def uvw(smp: XYZAzimuthTiltPolarParallelPerpendicularStage) -> ToolPointMotion: return ToolPointMotion(smp) diff --git a/src/dodal/devices/beamlines/i21/__init__.py b/src/dodal/devices/beamlines/i21/__init__.py index 08df37b271..e7ff388212 100644 --- a/src/dodal/devices/beamlines/i21/__init__.py +++ b/src/dodal/devices/beamlines/i21/__init__.py @@ -1,3 +1,9 @@ from .enums import Grating +from .i21_motors import XYZAzimuthTiltPolarParallelPerpendicularStage +from .toolpoint_motion import ToolPointMotion -__all__ = ["Grating"] +__all__ = [ + "Grating", + "XYZAzimuthTiltPolarParallelPerpendicularStage", + "ToolPointMotion", +] diff --git a/src/dodal/devices/beamlines/i21/i21_motors.py b/src/dodal/devices/beamlines/i21/i21_motors.py index 75c6b8b8d9..082a948f88 100644 --- a/src/dodal/devices/beamlines/i21/i21_motors.py +++ b/src/dodal/devices/beamlines/i21/i21_motors.py @@ -1,17 +1,3 @@ -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 ( _AZIMUTH, _TILT, @@ -19,18 +5,35 @@ _Y, _Z, XYZAzimuthTiltPolarStage, - XYZAzimuthTiltStage, create_rotational_ij_component_signals, ) -class I21XYZAzimuthTiltPolarStage(XYZAzimuthTiltPolarStage): - """Note: I21 uses the following pv to variable +class XYZAzimuthTiltPolarParallelPerpendicularStage(XYZAzimuthTiltPolarStage): + """Six-axis stage with a standard xyz stage and three axis of rotation: azimuth, + tilt and polar. 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). Have used standard class in hopes - We can try topersuade i21 to use the standard variable names going forward to - align with other beamlines and not alias variables. + - 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__( @@ -45,18 +48,16 @@ def __init__( polar_infix: str = "RZ", ): super().__init__( - prefix, - name, - x_infix, - y_infix, - z_infix, - azimuth_infix, - tilt_infix, - polar_infix, + 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(): - # Parallel and Perpendicular self.para, self.perp = create_rotational_ij_component_signals( i_read=self.x.user_readback, j_read=self.y.user_readback, @@ -65,191 +66,3 @@ def __init__( angle_deg=self.polar.user_readback, clockwise_frame=False, ) - - -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)], - ] - ) - - -def toolpoint_to_xyz( - u: float, - v: float, - w: float, - tilt_deg: float, - azimuth_deg: float, - zero: tuple[float, float, float], -) -> tuple[float, float, float]: - rotation = toolpoint_rotation_matrix(tilt_deg, azimuth_deg) - tool = np.array([u, v, w]) - offset = np.array(zero) - xyz = offset + rotation @ tool - return tuple(xyz.astype(float)) - - -def xyz_to_toolpoint( - x: float, - y: float, - z: float, - tilt_deg: float, - azimuth_deg: float, - zero: tuple[float, float, float], -) -> tuple[float, float, float]: - rotation = toolpoint_rotation_matrix(tilt_deg, azimuth_deg) - xyz = np.array([x, y, z]) - offset = np.array(zero) - - tool = rotation.T @ (xyz - offset) - return tuple(tool.astype(float)) - - -AXES_ZERO = (5.667, 0.2408, 1.221) - - -@dataclass(frozen=True) -class MotorPositions: - x: float - y: float - z: float - tilt: float - azimuth: float - - -class ToolPointMotion(StandardReadable, Movable): - def __init__( - self, - smp: XYZAzimuthTiltStage, - zero: tuple[float, float, float] = 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() - - super().__init__(name=name) - - async def _read_motor_positions(self) -> MotorPositions: - 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 MotorPositions(x, y, z, tilt, azimuth) - - async def _check_motor_limits( - self, start: MotorPositions, end: MotorPositions - ) -> 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, end.tilt), - self.smp_ref().azimuth.check_motor_limit(start.azimuth, end.azimuth), - ) - - def _toolpoint_to_motor_positions( - self, u: float, v: float, w: float, tilt: float, azimuth: float - ) -> MotorPositions: - x, y, z = toolpoint_to_xyz(u, v, w, tilt, azimuth, self._zero) - return MotorPositions( - x=x, - y=y, - z=z, - tilt=tilt, - azimuth=azimuth, - ) - - async def _read_all(self) -> tuple[float, float, float, float, float]: - pos = await self._read_motor_positions() - u, v, w = xyz_to_toolpoint( - pos.x, - pos.y, - pos.z, - pos.tilt, - pos.azimuth, - self._zero, - ) - return u, v, w, pos.tilt, pos.azimuth - - async def _write_all( - self, u: float, v: float, w: float, tilt: float, azimuth: float - ) -> None: - start = await self._read_motor_positions() - end = self._toolpoint_to_motor_positions(u, v, w, tilt, azimuth) - - await self._check_motor_limits(start, end) - - await asyncio.gather( - self.smp_ref().x.set(end.x), - self.smp_ref().y.set(end.y), - self.smp_ref().z.set(end.z), - self.smp_ref().tilt.set(end.tilt), - self.smp_ref().azimuth.set(end.azimuth), - ) - - def _create_uvws(self) -> tuple[SignalRW[float], SignalRW[float], SignalRW[float]]: - def read_u(x: float, y: float, z: float, tilt: float, azimuth: float) -> float: - return xyz_to_toolpoint(x, y, z, tilt, azimuth, self._zero)[0] - - async def set_u(value: float) -> None: - u, v, w, tilt, azimuth = await self._read_all() - await self._write_all(value, v, w, tilt, azimuth) - - def read_v(x: float, y: float, z: float, tilt: float, azimuth: float) -> float: - return xyz_to_toolpoint(x, y, z, tilt, azimuth, self._zero)[1] - - async def set_v(value: float) -> None: - u, v, w, tilt, azimuth = await self._read_all() - await self._write_all(u, value, w, tilt, azimuth) - - def read_w(x: float, y: float, z: float, tilt: float, azimuth: float) -> float: - return xyz_to_toolpoint(x, y, z, tilt, azimuth, self._zero)[2] - - async def set_w(value: float) -> None: - u, v, w, tilt, azimuth = await self._read_all() - await self._write_all(u, v, value, tilt, azimuth) - - u = derived_signal_rw( - read_u, - set_u, - x=self.smp_ref().x, - y=self.smp_ref().y, - z=self.smp_ref().z, - tilt=self.smp_ref().tilt, - azimuth=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=self.smp_ref().tilt, - azimuth=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=self.smp_ref().tilt, - azimuth=self.smp_ref().azimuth, - ) - return u, v, w - - @AsyncStatus.wrap - async def set(self, value: MotorPositions): - await self._write_all(value.x, value.y, value.z, value.tilt, value.azimuth) 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..74abdf5035 --- /dev/null +++ b/src/dodal/devices/beamlines/i21/toolpoint_motion.py @@ -0,0 +1,207 @@ +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)], + ] + ) + + +def toolpoint_to_xyz( + u: float, + v: float, + w: float, + tilt_deg: float, + azimuth_deg: float, + zero: tuple[float, float, float], +) -> tuple[float, float, float]: + rotation = toolpoint_rotation_matrix(tilt_deg, azimuth_deg) + tool = np.array([u, v, w]) + offset = np.array(zero) + xyz = offset + rotation @ tool + return tuple(xyz.astype(float)) + + +def xyz_to_toolpoint( + x: float, + y: float, + z: float, + tilt_deg: float, + azimuth_deg: float, + zero: tuple[float, float, float], +) -> tuple[float, float, float]: + rotation = toolpoint_rotation_matrix(tilt_deg, azimuth_deg) + xyz = np.array([x, y, z]) + offset = np.array(zero) + + tool = rotation.T @ (xyz - offset) + return tuple(tool.astype(float)) + + +@dataclass(frozen=True) +class MotorPositions: + x: float + y: float + z: float + tilt: float + azimuth: float + + +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() + + super().__init__(name=name) + + async def _read_motor_positions(self) -> MotorPositions: + 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 MotorPositions(x, y, z, tilt, azimuth) + + async def _check_motor_limits( + self, start: MotorPositions, end: MotorPositions + ) -> 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, end.tilt), + self.smp_ref().azimuth.check_motor_limit(start.azimuth, end.azimuth), + ) + + def _toolpoint_to_motor_positions( + self, u: float, v: float, w: float, tilt: float, azimuth: float + ) -> MotorPositions: + x, y, z = toolpoint_to_xyz(u, v, w, tilt, azimuth, self._zero) + return MotorPositions( + x=x, + y=y, + z=z, + tilt=tilt, + azimuth=azimuth, + ) + + async def _read_all(self) -> tuple[float, float, float, float, float]: + pos = await self._read_motor_positions() + u, v, w = xyz_to_toolpoint( + pos.x, + pos.y, + pos.z, + pos.tilt, + pos.azimuth, + self._zero, + ) + return u, v, w, pos.tilt, pos.azimuth + + async def _write_all( + self, u: float, v: float, w: float, tilt: float, azimuth: float + ) -> None: + start = await self._read_motor_positions() + end = self._toolpoint_to_motor_positions(u, v, w, tilt, azimuth) + + await self._check_motor_limits(start, end) + + await asyncio.gather( + self.smp_ref().x.set(end.x), + self.smp_ref().y.set(end.y), + self.smp_ref().z.set(end.z), + self.smp_ref().tilt.set(end.tilt), + self.smp_ref().azimuth.set(end.azimuth), + ) + + def _create_uvws(self) -> tuple[SignalRW[float], SignalRW[float], SignalRW[float]]: + def read_u(x: float, y: float, z: float, tilt: float, azimuth: float) -> float: + return xyz_to_toolpoint(x, y, z, tilt, azimuth, self._zero)[0] + + async def set_u(value: float) -> None: + u, v, w, tilt, azimuth = await self._read_all() + await self._write_all(value, v, w, tilt, azimuth) + + def read_v(x: float, y: float, z: float, tilt: float, azimuth: float) -> float: + return xyz_to_toolpoint(x, y, z, tilt, azimuth, self._zero)[1] + + async def set_v(value: float) -> None: + u, v, w, tilt, azimuth = await self._read_all() + await self._write_all(u, value, w, tilt, azimuth) + + def read_w(x: float, y: float, z: float, tilt: float, azimuth: float) -> float: + return xyz_to_toolpoint(x, y, z, tilt, azimuth, self._zero)[2] + + async def set_w(value: float) -> None: + u, v, w, tilt, azimuth = await self._read_all() + await self._write_all(u, v, value, tilt, azimuth) + + u = derived_signal_rw( + read_u, + set_u, + x=self.smp_ref().x, + y=self.smp_ref().y, + z=self.smp_ref().z, + tilt=self.smp_ref().tilt, + azimuth=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=self.smp_ref().tilt, + azimuth=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=self.smp_ref().tilt, + azimuth=self.smp_ref().azimuth, + ) + return u, v, w + + @AsyncStatus.wrap + async def set(self, value: MotorPositions): + await self._write_all(value.x, value.y, value.z, value.tilt, value.azimuth) From 73a955111fbb7fbbe002186a7da65f68408a6168 Mon Sep 17 00:00:00 2001 From: Oli Wenman Date: Thu, 5 Feb 2026 12:18:09 +0000 Subject: [PATCH 54/81] Add read and set tests --- .../devices/beamlines/i21/toolpoint_motion.py | 6 +- tests/devices/beamlines/i21/__init__.py | 0 .../beamlines/i21/test_toolpoint_motion.py | 149 ++++++++++++++++++ 3 files changed, 152 insertions(+), 3 deletions(-) create mode 100644 tests/devices/beamlines/i21/__init__.py create mode 100644 tests/devices/beamlines/i21/test_toolpoint_motion.py diff --git a/src/dodal/devices/beamlines/i21/toolpoint_motion.py b/src/dodal/devices/beamlines/i21/toolpoint_motion.py index 74abdf5035..cc6197ec29 100644 --- a/src/dodal/devices/beamlines/i21/toolpoint_motion.py +++ b/src/dodal/devices/beamlines/i21/toolpoint_motion.py @@ -153,21 +153,21 @@ async def _write_all( def _create_uvws(self) -> tuple[SignalRW[float], SignalRW[float], SignalRW[float]]: def read_u(x: float, y: float, z: float, tilt: float, azimuth: float) -> float: - return xyz_to_toolpoint(x, y, z, tilt, azimuth, self._zero)[0] + return float(xyz_to_toolpoint(x, y, z, tilt, azimuth, self._zero)[0]) async def set_u(value: float) -> None: u, v, w, tilt, azimuth = await self._read_all() await self._write_all(value, v, w, tilt, azimuth) def read_v(x: float, y: float, z: float, tilt: float, azimuth: float) -> float: - return xyz_to_toolpoint(x, y, z, tilt, azimuth, self._zero)[1] + return float(xyz_to_toolpoint(x, y, z, tilt, azimuth, self._zero)[1]) async def set_v(value: float) -> None: u, v, w, tilt, azimuth = await self._read_all() await self._write_all(u, value, w, tilt, azimuth) def read_w(x: float, y: float, z: float, tilt: float, azimuth: float) -> float: - return xyz_to_toolpoint(x, y, z, tilt, azimuth, self._zero)[2] + return float(xyz_to_toolpoint(x, y, z, tilt, azimuth, self._zero)[2]) async def set_w(value: float) -> None: u, v, w, tilt, azimuth = await self._read_all() 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_toolpoint_motion.py b/tests/devices/beamlines/i21/test_toolpoint_motion.py new file mode 100644 index 0000000000..169d767405 --- /dev/null +++ b/tests/devices/beamlines/i21/test_toolpoint_motion.py @@ -0,0 +1,149 @@ +import asyncio +from math import cos, radians, sin + +import pytest +from ophyd_async.core import init_devices +from ophyd_async.testing import assert_reading, partial_reading + +from dodal.devices.beamlines.i21 import ( + ToolPointMotion, + XYZAzimuthTiltPolarParallelPerpendicularStage, +) + + +@pytest.fixture +def smp() -> XYZAzimuthTiltPolarParallelPerpendicularStage: + with init_devices(mock=True): + smp = XYZAzimuthTiltPolarParallelPerpendicularStage("TEST:") + return smp + + +@pytest.fixture +def uvw(smp: XYZAzimuthTiltPolarParallelPerpendicularStage) -> ToolPointMotion: + with init_devices(mock=True): + uvw = ToolPointMotion(smp) + return uvw + + +async def test_uvw_read(uvw: ToolPointMotion) -> None: + x, y, z = 5, 10, 15 + dx, dy, dz = x - uvw._zero[0], y - uvw._zero[1], z - uvw._zero[2] + azimuth_deg, tilt_deg = 10, 30 + azimuth, tilt = radians(azimuth_deg), radians(tilt_deg) + + expected_u = dx * cos(tilt) - dz * sin(tilt) + 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) + ) + + 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), + ) + + await assert_reading( + uvw, + { + uvw.u.name: partial_reading(expected_u), + uvw.v.name: partial_reading(expected_v), + uvw.w.name: partial_reading(expected_w), + }, + ) + + +async def test_uvw_u_set(uvw: ToolPointMotion) -> None: + x, y, z = 5, 10, 15 + azimuth_deg, tilt_deg = 10, 30 + + 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), + ) + + u_value = 50 + read = await uvw._read_all() + expected_v = read[1] + expected_w = read[2] + + await uvw.u.set(50) + await assert_reading( + uvw, + { + uvw.u.name: partial_reading(u_value), + uvw.v.name: partial_reading(expected_v), + uvw.w.name: partial_reading(expected_w), + }, + ) + + +async def test_uvw_v_set(uvw: ToolPointMotion) -> None: + x, y, z = 5, 10, 15 + azimuth_deg, tilt_deg = 10, 30 + + 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), + ) + + read = await uvw._read_all() + expected_u = read[0] + v_value = 50 + expected_w = read[2] + + await uvw.v.set(50) + await assert_reading( + uvw, + { + uvw.u.name: partial_reading(expected_u), + uvw.v.name: partial_reading(v_value), + uvw.w.name: partial_reading(expected_w), + }, + ) + + +async def test_uvw_w_set(uvw: ToolPointMotion) -> None: + x, y, z = 5, 10, 15 + azimuth_deg, tilt_deg = 10, 30 + + 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), + ) + + read = await uvw._read_all() + expected_u = read[0] + expected_v = read[1] + w_value = 50 + + await uvw.w.set(50) + await assert_reading( + uvw, + { + uvw.u.name: partial_reading(expected_u), + uvw.v.name: partial_reading(expected_v), + uvw.w.name: partial_reading(w_value), + }, + ) From 43cf1530133fb48fe7ef7cfdf0607dc7b2f63395 Mon Sep 17 00:00:00 2001 From: Oli Wenman Date: Thu, 5 Feb 2026 14:02:00 +0000 Subject: [PATCH 55/81] Add test for uvw set --- src/dodal/devices/beamlines/i21/__init__.py | 3 +- .../devices/beamlines/i21/toolpoint_motion.py | 16 +++++---- .../beamlines/i21/test_toolpoint_motion.py | 34 ++++++++++++++++--- 3 files changed, 40 insertions(+), 13 deletions(-) diff --git a/src/dodal/devices/beamlines/i21/__init__.py b/src/dodal/devices/beamlines/i21/__init__.py index e7ff388212..c0d47c7a85 100644 --- a/src/dodal/devices/beamlines/i21/__init__.py +++ b/src/dodal/devices/beamlines/i21/__init__.py @@ -1,9 +1,10 @@ from .enums import Grating from .i21_motors import XYZAzimuthTiltPolarParallelPerpendicularStage -from .toolpoint_motion import ToolPointMotion +from .toolpoint_motion import ToolPointMotion, ToolPointMotorPositions __all__ = [ "Grating", "XYZAzimuthTiltPolarParallelPerpendicularStage", "ToolPointMotion", + "ToolPointMotorPositions", ] diff --git a/src/dodal/devices/beamlines/i21/toolpoint_motion.py b/src/dodal/devices/beamlines/i21/toolpoint_motion.py index cc6197ec29..7179a17b48 100644 --- a/src/dodal/devices/beamlines/i21/toolpoint_motion.py +++ b/src/dodal/devices/beamlines/i21/toolpoint_motion.py @@ -62,7 +62,7 @@ def xyz_to_toolpoint( @dataclass(frozen=True) -class MotorPositions: +class ToolPointMotorPositions: x: float y: float z: float @@ -88,9 +88,11 @@ def __init__( 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 _read_motor_positions(self) -> MotorPositions: + async def _read_motor_positions(self) -> ToolPointMotorPositions: x, y, z, tilt, azimuth = await asyncio.gather( self.smp_ref().x.user_readback.get_value(), self.smp_ref().y.user_readback.get_value(), @@ -98,10 +100,10 @@ async def _read_motor_positions(self) -> MotorPositions: self.smp_ref().tilt.user_readback.get_value(), self.smp_ref().azimuth.user_readback.get_value(), ) - return MotorPositions(x, y, z, tilt, azimuth) + return ToolPointMotorPositions(x, y, z, tilt, azimuth) async def _check_motor_limits( - self, start: MotorPositions, end: MotorPositions + self, start: ToolPointMotorPositions, end: ToolPointMotorPositions ) -> None: await asyncio.gather( self.smp_ref().x.check_motor_limit(start.x, end.x), @@ -113,9 +115,9 @@ async def _check_motor_limits( def _toolpoint_to_motor_positions( self, u: float, v: float, w: float, tilt: float, azimuth: float - ) -> MotorPositions: + ) -> ToolPointMotorPositions: x, y, z = toolpoint_to_xyz(u, v, w, tilt, azimuth, self._zero) - return MotorPositions( + return ToolPointMotorPositions( x=x, y=y, z=z, @@ -203,5 +205,5 @@ async def set_w(value: float) -> None: return u, v, w @AsyncStatus.wrap - async def set(self, value: MotorPositions): + async def set(self, value: ToolPointMotorPositions): await self._write_all(value.x, value.y, value.z, value.tilt, value.azimuth) diff --git a/tests/devices/beamlines/i21/test_toolpoint_motion.py b/tests/devices/beamlines/i21/test_toolpoint_motion.py index 169d767405..4a36c39453 100644 --- a/tests/devices/beamlines/i21/test_toolpoint_motion.py +++ b/tests/devices/beamlines/i21/test_toolpoint_motion.py @@ -7,6 +7,7 @@ from dodal.devices.beamlines.i21 import ( ToolPointMotion, + ToolPointMotorPositions, XYZAzimuthTiltPolarParallelPerpendicularStage, ) @@ -52,13 +53,15 @@ async def test_uvw_read(uvw: ToolPointMotion) -> None: smp.tilt.set(tilt_deg), ) + smp_read = await smp.read() await assert_reading( uvw, { uvw.u.name: partial_reading(expected_u), uvw.v.name: partial_reading(expected_v), uvw.w.name: partial_reading(expected_w), - }, + } + | smp_read, ) @@ -75,12 +78,12 @@ async def test_uvw_u_set(uvw: ToolPointMotion) -> None: smp.tilt.set(tilt_deg), ) - u_value = 50 read = await uvw._read_all() + u_value = 50 expected_v = read[1] expected_w = read[2] - await uvw.u.set(50) + await uvw.u.set(u_value) await assert_reading( uvw, { @@ -88,6 +91,7 @@ async def test_uvw_u_set(uvw: ToolPointMotion) -> None: uvw.v.name: partial_reading(expected_v), uvw.w.name: partial_reading(expected_w), }, + full_match=False, ) @@ -109,7 +113,7 @@ async def test_uvw_v_set(uvw: ToolPointMotion) -> None: v_value = 50 expected_w = read[2] - await uvw.v.set(50) + await uvw.v.set(v_value) await assert_reading( uvw, { @@ -117,6 +121,7 @@ async def test_uvw_v_set(uvw: ToolPointMotion) -> None: uvw.v.name: partial_reading(v_value), uvw.w.name: partial_reading(expected_w), }, + full_match=False, ) @@ -138,7 +143,7 @@ async def test_uvw_w_set(uvw: ToolPointMotion) -> None: expected_v = read[1] w_value = 50 - await uvw.w.set(50) + await uvw.w.set(w_value) await assert_reading( uvw, { @@ -146,4 +151,23 @@ async def test_uvw_w_set(uvw: ToolPointMotion) -> None: uvw.v.name: partial_reading(expected_v), uvw.w.name: partial_reading(w_value), }, + full_match=False, + ) + + +async def test_uvw_set(uvw: ToolPointMotion) -> None: + pos = ToolPointMotorPositions(x=10, y=20, z=30, tilt=40, azimuth=50) + await uvw.set(pos) + + await assert_reading( + uvw, + { + uvw.u.name: partial_reading(pos.x), + uvw.v.name: partial_reading(pos.y), + uvw.w.name: partial_reading(pos.z), + }, + full_match=False, ) + + assert await uvw.smp_ref().azimuth.user_readback.get_value() == pos.azimuth + assert await uvw.smp_ref().tilt.user_readback.get_value() == pos.tilt From 59cb498c8b7d51490d93c938d3645de7021934c9 Mon Sep 17 00:00:00 2001 From: Oli Wenman Date: Thu, 5 Feb 2026 15:28:15 +0000 Subject: [PATCH 56/81] Renamed angles to use deg to make units explicit --- .../devices/beamlines/i21/toolpoint_motion.py | 82 +++++++++++-------- .../beamlines/i21/test_toolpoint_motion.py | 61 +++++++++++++- 2 files changed, 108 insertions(+), 35 deletions(-) diff --git a/src/dodal/devices/beamlines/i21/toolpoint_motion.py b/src/dodal/devices/beamlines/i21/toolpoint_motion.py index 7179a17b48..3862cf616e 100644 --- a/src/dodal/devices/beamlines/i21/toolpoint_motion.py +++ b/src/dodal/devices/beamlines/i21/toolpoint_motion.py @@ -42,7 +42,8 @@ def toolpoint_to_xyz( tool = np.array([u, v, w]) offset = np.array(zero) xyz = offset + rotation @ tool - return tuple(xyz.astype(float)) + x, y, z = xyz + return float(x), float(y), float(z) def xyz_to_toolpoint( @@ -58,7 +59,8 @@ def xyz_to_toolpoint( offset = np.array(zero) tool = rotation.T @ (xyz - offset) - return tuple(tool.astype(float)) + u, v, w = tool + return float(u), float(v), float(w) @dataclass(frozen=True) @@ -66,8 +68,8 @@ class ToolPointMotorPositions: x: float y: float z: float - tilt: float - azimuth: float + tilt_deg: float + azimuth_deg: float class ToolPointMotion(StandardReadable, Movable): @@ -109,8 +111,10 @@ async def _check_motor_limits( 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, end.tilt), - self.smp_ref().azimuth.check_motor_limit(start.azimuth, end.azimuth), + 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 + ), ) def _toolpoint_to_motor_positions( @@ -121,8 +125,8 @@ def _toolpoint_to_motor_positions( x=x, y=y, z=z, - tilt=tilt, - azimuth=azimuth, + tilt_deg=tilt, + azimuth_deg=azimuth, ) async def _read_all(self) -> tuple[float, float, float, float, float]: @@ -131,11 +135,11 @@ async def _read_all(self) -> tuple[float, float, float, float, float]: pos.x, pos.y, pos.z, - pos.tilt, - pos.azimuth, + pos.tilt_deg, + pos.azimuth_deg, self._zero, ) - return u, v, w, pos.tilt, pos.azimuth + return u, v, w, pos.tilt_deg, pos.azimuth_deg async def _write_all( self, u: float, v: float, w: float, tilt: float, azimuth: float @@ -149,31 +153,43 @@ async def _write_all( self.smp_ref().x.set(end.x), self.smp_ref().y.set(end.y), self.smp_ref().z.set(end.z), - self.smp_ref().tilt.set(end.tilt), - self.smp_ref().azimuth.set(end.azimuth), + self.smp_ref().tilt.set(end.tilt_deg), + self.smp_ref().azimuth.set(end.azimuth_deg), ) def _create_uvws(self) -> tuple[SignalRW[float], SignalRW[float], SignalRW[float]]: - def read_u(x: float, y: float, z: float, tilt: float, azimuth: float) -> float: - return float(xyz_to_toolpoint(x, y, z, tilt, azimuth, self._zero)[0]) + def read_u( + x: float, y: float, z: float, tilt_deg: float, azimuth_deg: float + ) -> float: + return float( + xyz_to_toolpoint(x, y, z, tilt_deg, azimuth_deg, self._zero)[0] + ) async def set_u(value: float) -> None: - u, v, w, tilt, azimuth = await self._read_all() - await self._write_all(value, v, w, tilt, azimuth) + u, v, w, tilt_deg, azimuth_deg = await self._read_all() + await self._write_all(value, v, w, tilt_deg, azimuth_deg) - def read_v(x: float, y: float, z: float, tilt: float, azimuth: float) -> float: - return float(xyz_to_toolpoint(x, y, z, tilt, azimuth, self._zero)[1]) + def read_v( + x: float, y: float, z: float, tilt_deg: float, azimuth_deg: float + ) -> float: + return float( + xyz_to_toolpoint(x, y, z, tilt_deg, azimuth_deg, self._zero)[1] + ) async def set_v(value: float) -> None: - u, v, w, tilt, azimuth = await self._read_all() - await self._write_all(u, value, w, tilt, azimuth) + u, v, w, tilt_deg, azimuth_deg = await self._read_all() + await self._write_all(u, value, w, tilt_deg, azimuth_deg) - def read_w(x: float, y: float, z: float, tilt: float, azimuth: float) -> float: - return float(xyz_to_toolpoint(x, y, z, tilt, azimuth, self._zero)[2]) + def read_w( + x: float, y: float, z: float, tilt_deg: float, azimuth_deg: float + ) -> float: + return float( + xyz_to_toolpoint(x, y, z, tilt_deg, azimuth_deg, self._zero)[2] + ) async def set_w(value: float) -> None: - u, v, w, tilt, azimuth = await self._read_all() - await self._write_all(u, v, value, tilt, azimuth) + u, v, w, tilt_deg, azimuth_deg = await self._read_all() + await self._write_all(u, v, value, tilt_deg, azimuth_deg) u = derived_signal_rw( read_u, @@ -181,8 +197,8 @@ async def set_w(value: float) -> None: x=self.smp_ref().x, y=self.smp_ref().y, z=self.smp_ref().z, - tilt=self.smp_ref().tilt, - azimuth=self.smp_ref().azimuth, + tilt_deg=self.smp_ref().tilt, + azimuth_deg=self.smp_ref().azimuth, ) v = derived_signal_rw( read_v, @@ -190,8 +206,8 @@ async def set_w(value: float) -> None: x=self.smp_ref().x, y=self.smp_ref().y, z=self.smp_ref().z, - tilt=self.smp_ref().tilt, - azimuth=self.smp_ref().azimuth, + tilt_deg=self.smp_ref().tilt, + azimuth_deg=self.smp_ref().azimuth, ) w = derived_signal_rw( read_w, @@ -199,11 +215,13 @@ async def set_w(value: float) -> None: x=self.smp_ref().x, y=self.smp_ref().y, z=self.smp_ref().z, - tilt=self.smp_ref().tilt, - azimuth=self.smp_ref().azimuth, + tilt_deg=self.smp_ref().tilt, + azimuth_deg=self.smp_ref().azimuth, ) return u, v, w @AsyncStatus.wrap async def set(self, value: ToolPointMotorPositions): - await self._write_all(value.x, value.y, value.z, value.tilt, value.azimuth) + await self._write_all( + value.x, value.y, value.z, value.tilt_deg, value.azimuth_deg + ) diff --git a/tests/devices/beamlines/i21/test_toolpoint_motion.py b/tests/devices/beamlines/i21/test_toolpoint_motion.py index 4a36c39453..243feac81a 100644 --- a/tests/devices/beamlines/i21/test_toolpoint_motion.py +++ b/tests/devices/beamlines/i21/test_toolpoint_motion.py @@ -26,6 +26,42 @@ def uvw(smp: XYZAzimuthTiltPolarParallelPerpendicularStage) -> ToolPointMotion: return uvw +def expected_u_read( + pos: ToolPointMotorPositions, zero: tuple[float, float, float] +) -> float: + dx, dz = pos.x - zero[0], pos.z - zero[2] + expected_u = dx * cos(radians(pos.tilt_deg)) - dz * sin(radians(pos.tilt_deg)) + return expected_u + + +def expected_v_read( + pos: ToolPointMotorPositions, zero: tuple[float, float, float] +) -> float: + dx, dy, dz = pos.x - zero[0], pos.y - zero[1], pos.z - zero[2] + azimuth = radians(pos.azimuth_deg) + tilt = radians(pos.tilt_deg) + expected_v = ( + dx * sin(tilt) * sin(azimuth) + + dy * cos(azimuth) + + dz * cos(tilt) * sin(azimuth) + ) + return expected_v + + +def expected_w_read( + pos: ToolPointMotorPositions, zero: tuple[float, float, float] +) -> float: + dx, dy, dz = pos.x - zero[0], pos.y - zero[1], pos.z - zero[2] + azimuth = radians(pos.azimuth_deg) + tilt = radians(pos.tilt_deg) + expected_w = ( + dx * cos(azimuth) * sin(tilt) + - dy * sin(azimuth) + + dz * cos(tilt) * cos(azimuth) + ) + return expected_w + + async def test_uvw_read(uvw: ToolPointMotion) -> None: x, y, z = 5, 10, 15 dx, dy, dz = x - uvw._zero[0], y - uvw._zero[1], z - uvw._zero[2] @@ -68,6 +104,7 @@ async def test_uvw_read(uvw: ToolPointMotion) -> None: async def test_uvw_u_set(uvw: ToolPointMotion) -> None: x, y, z = 5, 10, 15 azimuth_deg, tilt_deg = 10, 30 + # azimuth, tilt = radians(azimuth_deg), radians(tilt_deg) smp = uvw.smp_ref() await asyncio.gather( @@ -83,7 +120,25 @@ async def test_uvw_u_set(uvw: ToolPointMotion) -> None: expected_v = read[1] expected_w = read[2] + # expected_x = ( + # x + # + u_value * cos(tilt) + # + expected_v * sin(tilt) * sin(azimuth) + # + expected_w * cos(azimuth) * sin(tilt) + # ) + # expected_y = y + expected_v * cos(azimuth) - expected_w * sin(azimuth) + # expected_z = ( + # z + # - u_value * sin(tilt) + # + expected_v * cos(tilt) * sin(azimuth) + # + expected_w * cos(tilt) * cos(azimuth) + # ) + await uvw.u.set(u_value) + # assert await smp.x.user_readback.get_value() == expected_x + # assert await smp.y.user_readback.get_value() == expected_y + # assert await smp.z.user_readback.get_value() == expected_z + await assert_reading( uvw, { @@ -156,7 +211,7 @@ async def test_uvw_w_set(uvw: ToolPointMotion) -> None: async def test_uvw_set(uvw: ToolPointMotion) -> None: - pos = ToolPointMotorPositions(x=10, y=20, z=30, tilt=40, azimuth=50) + pos = ToolPointMotorPositions(x=10, y=20, z=30, tilt_deg=40, azimuth_deg=50) await uvw.set(pos) await assert_reading( @@ -169,5 +224,5 @@ async def test_uvw_set(uvw: ToolPointMotion) -> None: full_match=False, ) - assert await uvw.smp_ref().azimuth.user_readback.get_value() == pos.azimuth - assert await uvw.smp_ref().tilt.user_readback.get_value() == pos.tilt + 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 From f505d8dc7f53f49593095d2ec8c0a841dbc7b86e Mon Sep 17 00:00:00 2001 From: Oli Wenman Date: Thu, 5 Feb 2026 15:43:47 +0000 Subject: [PATCH 57/81] Use expected_uvw_read fucntion for test --- .../beamlines/i21/test_toolpoint_motion.py | 44 +++++-------------- 1 file changed, 11 insertions(+), 33 deletions(-) diff --git a/tests/devices/beamlines/i21/test_toolpoint_motion.py b/tests/devices/beamlines/i21/test_toolpoint_motion.py index 243feac81a..1aaaa3cd7e 100644 --- a/tests/devices/beamlines/i21/test_toolpoint_motion.py +++ b/tests/devices/beamlines/i21/test_toolpoint_motion.py @@ -26,17 +26,9 @@ def uvw(smp: XYZAzimuthTiltPolarParallelPerpendicularStage) -> ToolPointMotion: return uvw -def expected_u_read( +def expected_uvw_read( pos: ToolPointMotorPositions, zero: tuple[float, float, float] -) -> float: - dx, dz = pos.x - zero[0], pos.z - zero[2] - expected_u = dx * cos(radians(pos.tilt_deg)) - dz * sin(radians(pos.tilt_deg)) - return expected_u - - -def expected_v_read( - pos: ToolPointMotorPositions, zero: tuple[float, float, float] -) -> float: +) -> tuple[float, float, float]: dx, dy, dz = pos.x - zero[0], pos.y - zero[1], pos.z - zero[2] azimuth = radians(pos.azimuth_deg) tilt = radians(pos.tilt_deg) @@ -45,40 +37,27 @@ def expected_v_read( + dy * cos(azimuth) + dz * cos(tilt) * sin(azimuth) ) - return expected_v - - -def expected_w_read( - pos: ToolPointMotorPositions, zero: tuple[float, float, float] -) -> float: - dx, dy, dz = pos.x - zero[0], pos.y - zero[1], pos.z - zero[2] - azimuth = radians(pos.azimuth_deg) - tilt = radians(pos.tilt_deg) + 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_w + return expected_v, expected_v, expected_w async def test_uvw_read(uvw: ToolPointMotion) -> None: x, y, z = 5, 10, 15 - dx, dy, dz = x - uvw._zero[0], y - uvw._zero[1], z - uvw._zero[2] azimuth_deg, tilt_deg = 10, 30 - azimuth, tilt = radians(azimuth_deg), radians(tilt_deg) - expected_u = dx * cos(tilt) - dz * sin(tilt) - 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) + pos = ToolPointMotorPositions( + x=x, y=y, z=z, azimuth_deg=azimuth_deg, tilt_deg=tilt_deg ) + expected_u, expected_v, expected_w = expected_uvw_read(pos, uvw._zero) smp = uvw.smp_ref() await asyncio.gather( @@ -119,7 +98,6 @@ async def test_uvw_u_set(uvw: ToolPointMotion) -> None: u_value = 50 expected_v = read[1] expected_w = read[2] - # expected_x = ( # x # + u_value * cos(tilt) From 3bce99be9c0bbf3bfb8697d7da6edcde3c45c7d9 Mon Sep 17 00:00:00 2001 From: Oli Wenman Date: Thu, 5 Feb 2026 17:32:54 +0000 Subject: [PATCH 58/81] Finished tests --- .../devices/beamlines/i21/toolpoint_motion.py | 4 +- .../beamlines/i21/test_toolpoint_motion.py | 228 ++++++++++-------- 2 files changed, 135 insertions(+), 97 deletions(-) diff --git a/src/dodal/devices/beamlines/i21/toolpoint_motion.py b/src/dodal/devices/beamlines/i21/toolpoint_motion.py index 3862cf616e..ca41604601 100644 --- a/src/dodal/devices/beamlines/i21/toolpoint_motion.py +++ b/src/dodal/devices/beamlines/i21/toolpoint_motion.py @@ -104,7 +104,7 @@ async def _read_motor_positions(self) -> ToolPointMotorPositions: ) return ToolPointMotorPositions(x, y, z, tilt, azimuth) - async def _check_motor_limits( + async def check_motor_limits( self, start: ToolPointMotorPositions, end: ToolPointMotorPositions ) -> None: await asyncio.gather( @@ -147,7 +147,7 @@ async def _write_all( start = await self._read_motor_positions() end = self._toolpoint_to_motor_positions(u, v, w, tilt, azimuth) - await self._check_motor_limits(start, end) + await self.check_motor_limits(start, end) await asyncio.gather( self.smp_ref().x.set(end.x), diff --git a/tests/devices/beamlines/i21/test_toolpoint_motion.py b/tests/devices/beamlines/i21/test_toolpoint_motion.py index 1aaaa3cd7e..1a55f74071 100644 --- a/tests/devices/beamlines/i21/test_toolpoint_motion.py +++ b/tests/devices/beamlines/i21/test_toolpoint_motion.py @@ -1,8 +1,11 @@ import asyncio from math import cos, radians, sin +from unittest.mock import AsyncMock +import numpy as np import pytest -from ophyd_async.core import init_devices +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 ( @@ -10,6 +13,11 @@ ToolPointMotorPositions, XYZAzimuthTiltPolarParallelPerpendicularStage, ) +from dodal.devices.beamlines.i21.toolpoint_motion import ( + DEFAULT_AXES_ZERO, + toolpoint_to_xyz, + xyz_to_toolpoint, +) @pytest.fixture @@ -30,8 +38,7 @@ def expected_uvw_read( pos: ToolPointMotorPositions, 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 = radians(pos.azimuth_deg) - tilt = radians(pos.tilt_deg) + azimuth, tilt = radians(pos.azimuth_deg), radians(pos.tilt_deg) expected_v = ( dx * sin(tilt) * sin(azimuth) + dy * cos(azimuth) @@ -50,14 +57,51 @@ def expected_uvw_read( return expected_v, expected_v, expected_w -async def test_uvw_read(uvw: ToolPointMotion) -> None: - x, y, z = 5, 10, 15 - azimuth_deg, tilt_deg = 10, 30 +def test_toolpoint_math_matches_legacy(): + zero = DEFAULT_AXES_ZERO + u, v, w = 1.2, -3.4, 5.6 + tilt, azimuth = 30.0, 10.0 + + x_new, y_new, z_new = toolpoint_to_xyz(u, v, w, tilt, azimuth, zero) + + chi = radians(tilt) + phi = radians(azimuth) + + x_old = zero[0] + u * cos(chi) + v * sin(chi) * sin(phi) + w * cos(phi) * sin(chi) + y_old = zero[1] + v * cos(phi) - w * sin(phi) + z_old = zero[2] - u * sin(chi) + v * cos(chi) * sin(phi) + w * cos(chi) * cos(phi) + + assert np.allclose([x_new, y_new, z_new], [x_old, y_old, z_old]) + + +def test_xyz_to_toolpoint_matches_legacy(): + zero = DEFAULT_AXES_ZERO + + x, y, z = 12.3, -4.5, 6.7 + tilt, azimuth = 30.0, 10.0 - pos = ToolPointMotorPositions( - x=x, y=y, z=z, azimuth_deg=azimuth_deg, tilt_deg=tilt_deg + u_new, v_new, w_new = xyz_to_toolpoint(x, y, z, tilt, azimuth, zero) + + chi = radians(tilt) + phi = radians(azimuth) + + dx = x - zero[0] + dy = y - zero[1] + dz = z - zero[2] + + u_old = dx * cos(chi) - dz * sin(chi) + v_old = dx * sin(chi) * sin(phi) + dy * cos(phi) + dz * cos(chi) * sin(phi) + w_old = dx * cos(phi) * sin(chi) - dy * sin(phi) + dz * cos(chi) * cos(phi) + + assert np.allclose( + [u_new, v_new, w_new], + [u_old, v_old, w_old], ) - expected_u, expected_v, expected_w = expected_uvw_read(pos, uvw._zero) + + +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( @@ -67,6 +111,9 @@ async def test_uvw_read(uvw: ToolPointMotion) -> None: smp.azimuth.set(azimuth_deg), smp.tilt.set(tilt_deg), ) + expected_u, expected_v, expected_w = xyz_to_toolpoint( + x=x, y=y, z=z, tilt_deg=tilt_deg, azimuth_deg=azimuth_deg, zero=uvw._zero + ) smp_read = await smp.read() await assert_reading( @@ -80,109 +127,59 @@ async def test_uvw_read(uvw: ToolPointMotion) -> None: ) -async def test_uvw_u_set(uvw: ToolPointMotion) -> None: - x, y, z = 5, 10, 15 - azimuth_deg, tilt_deg = 10, 30 - # azimuth, tilt = radians(azimuth_deg), radians(tilt_deg) - +@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.x.set(x), - smp.y.set(y), - smp.z.set(z), - smp.azimuth.set(azimuth_deg), - smp.tilt.set(tilt_deg), - ) - - read = await uvw._read_all() - u_value = 50 - expected_v = read[1] - expected_w = read[2] - # expected_x = ( - # x - # + u_value * cos(tilt) - # + expected_v * sin(tilt) * sin(azimuth) - # + expected_w * cos(azimuth) * sin(tilt) - # ) - # expected_y = y + expected_v * cos(azimuth) - expected_w * sin(azimuth) - # expected_z = ( - # z - # - u_value * sin(tilt) - # + expected_v * cos(tilt) * sin(azimuth) - # + expected_w * cos(tilt) * cos(azimuth) - # ) - - await uvw.u.set(u_value) - # assert await smp.x.user_readback.get_value() == expected_x - # assert await smp.y.user_readback.get_value() == expected_y - # assert await smp.z.user_readback.get_value() == expected_z - - await assert_reading( - uvw, - { - uvw.u.name: partial_reading(u_value), - uvw.v.name: partial_reading(expected_v), - uvw.w.name: partial_reading(expected_w), - }, - full_match=False, - ) - -async def test_uvw_v_set(uvw: ToolPointMotion) -> None: - x, y, z = 5, 10, 15 - azimuth_deg, tilt_deg = 10, 30 - - 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), ) - read = await uvw._read_all() - expected_u = read[0] - v_value = 50 - expected_w = read[2] + # Read initial toolpoint position + u0, v0, w0, tilt, azimuth = await uvw._read_all() - await uvw.v.set(v_value) - await assert_reading( - uvw, - { - uvw.u.name: partial_reading(expected_u), - uvw.v.name: partial_reading(v_value), - uvw.w.name: partial_reading(expected_w), - }, - full_match=False, - ) + # Set the selected axis + axis = getattr(uvw, axis_name) + await axis.set(new_value) + # Build expected toolpoint values + u = new_value if axis_name == "u" else u0 + v = new_value if axis_name == "v" else v0 + w = new_value if axis_name == "w" else w0 -async def test_uvw_w_set(uvw: ToolPointMotion) -> None: - x, y, z = 5, 10, 15 - azimuth_deg, tilt_deg = 10, 30 + expected_x, expected_y, expected_z = toolpoint_to_xyz( + u, v, w, tilt, azimuth, uvw._zero + ) - 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), + 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(), ) - read = await uvw._read_all() - expected_u = read[0] - expected_v = read[1] - w_value = 50 + assert np.isclose(actual_x, expected_x) + assert np.isclose(actual_y, expected_y) + assert np.isclose(actual_z, expected_z) - await uvw.w.set(w_value) await assert_reading( uvw, { - uvw.u.name: partial_reading(expected_u), - uvw.v.name: partial_reading(expected_v), - uvw.w.name: partial_reading(w_value), + uvw.u.name: partial_reading(u), + uvw.v.name: partial_reading(v), + uvw.w.name: partial_reading(w), }, full_match=False, ) @@ -204,3 +201,44 @@ async def test_uvw_set(uvw: ToolPointMotion) -> None: 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 + + +@pytest.mark.asyncio +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 = ToolPointMotorPositions( + x=1.0, y=2.0, z=3.0, tilt_deg=10.0, azimuth_deg=20.0 + ) + end = ToolPointMotorPositions(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 + ) + + +@pytest.mark.asyncio +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 = ToolPointMotorPositions(x=0.0, y=0.0, z=0.0, tilt_deg=0.0, azimuth_deg=0.0) + end = ToolPointMotorPositions(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) From 5d81fd313212e802c0bce00567b083564bd7ee2a Mon Sep 17 00:00:00 2001 From: Oli Wenman Date: Tue, 10 Feb 2026 09:44:51 +0000 Subject: [PATCH 59/81] Add smp_test --- tests/devices/beamlines/i21/test_i21_motors.py | 15 +++++++++++++++ 1 file changed, 15 insertions(+) create mode 100644 tests/devices/beamlines/i21/test_i21_motors.py 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..928d734bd7 --- /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 XYZAzimuthTiltPolarParallelPerpendicularStage + + +@pytest.fixture +def smp() -> XYZAzimuthTiltPolarParallelPerpendicularStage: + with init_devices(mock=True): + smp = XYZAzimuthTiltPolarParallelPerpendicularStage("TEST:") + return smp + + +def test_smp_read(smp: XYZAzimuthTiltPolarParallelPerpendicularStage) -> None: + pass From bf061114376bf73d28cfe711c5cb2c16222f0337 Mon Sep 17 00:00:00 2001 From: Oli Wenman Date: Tue, 10 Feb 2026 11:36:14 +0000 Subject: [PATCH 60/81] Add diffraction diode rotation, rename to I21SampleManipulatorStage --- src/dodal/beamlines/i21.py | 10 ++++------ src/dodal/devices/beamlines/i21/__init__.py | 4 ++-- src/dodal/devices/beamlines/i21/i21_motors.py | 15 +++++++++++---- tests/devices/beamlines/i21/test_i21_motors.py | 8 ++++---- .../beamlines/i21/test_toolpoint_motion.py | 8 ++++---- 5 files changed, 25 insertions(+), 20 deletions(-) diff --git a/src/dodal/beamlines/i21.py b/src/dodal/beamlines/i21.py index 06aad8294c..7e1c17358c 100644 --- a/src/dodal/beamlines/i21.py +++ b/src/dodal/beamlines/i21.py @@ -6,8 +6,8 @@ from dodal.device_manager import DeviceManager from dodal.devices.beamlines.i21 import ( Grating, + I21SampleManipulatorStage, ToolPointMotion, - XYZAzimuthTiltPolarParallelPerpendicularStage, ) from dodal.devices.insertion_device import ( Apple2, @@ -136,12 +136,10 @@ def sample_temperature_controller() -> Lakeshore336: @devices.factory() -def smp() -> XYZAzimuthTiltPolarParallelPerpendicularStage: - return XYZAzimuthTiltPolarParallelPerpendicularStage( - prefix=f"{PREFIX.beamline_prefix}-EA-SMPL-01:" - ) +def smp() -> I21SampleManipulatorStage: + return I21SampleManipulatorStage(prefix=f"{PREFIX.beamline_prefix}-EA-SMPL-01:") @devices.factory() -def uvw(smp: XYZAzimuthTiltPolarParallelPerpendicularStage) -> ToolPointMotion: +def uvw(smp: I21SampleManipulatorStage) -> ToolPointMotion: return ToolPointMotion(smp) diff --git a/src/dodal/devices/beamlines/i21/__init__.py b/src/dodal/devices/beamlines/i21/__init__.py index c0d47c7a85..ea55ac6c50 100644 --- a/src/dodal/devices/beamlines/i21/__init__.py +++ b/src/dodal/devices/beamlines/i21/__init__.py @@ -1,10 +1,10 @@ from .enums import Grating -from .i21_motors import XYZAzimuthTiltPolarParallelPerpendicularStage +from .i21_motors import I21SampleManipulatorStage from .toolpoint_motion import ToolPointMotion, ToolPointMotorPositions __all__ = [ "Grating", - "XYZAzimuthTiltPolarParallelPerpendicularStage", + "I21SampleManipulatorStage", "ToolPointMotion", "ToolPointMotorPositions", ] diff --git a/src/dodal/devices/beamlines/i21/i21_motors.py b/src/dodal/devices/beamlines/i21/i21_motors.py index 082a948f88..4e39477139 100644 --- a/src/dodal/devices/beamlines/i21/i21_motors.py +++ b/src/dodal/devices/beamlines/i21/i21_motors.py @@ -1,3 +1,5 @@ +from ophyd_async.epics.motor import Motor + from dodal.devices.motors import ( _AZIMUTH, _TILT, @@ -9,10 +11,11 @@ ) -class XYZAzimuthTiltPolarParallelPerpendicularStage(XYZAzimuthTiltPolarStage): - """Six-axis stage with a standard xyz stage and three axis of rotation: azimuth, - tilt and polar. It also exposes two virtual translational axes that are defined in - frames of reference attached to the sample. +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. @@ -46,6 +49,7 @@ def __init__( azimuth_infix: str = _AZIMUTH, tilt_infix: str = _TILT, polar_infix: str = "RZ", + difftth_infix: str = "DRING", ): super().__init__( prefix=prefix, @@ -58,6 +62,9 @@ def __init__( 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, diff --git a/tests/devices/beamlines/i21/test_i21_motors.py b/tests/devices/beamlines/i21/test_i21_motors.py index 928d734bd7..e87d00b6fb 100644 --- a/tests/devices/beamlines/i21/test_i21_motors.py +++ b/tests/devices/beamlines/i21/test_i21_motors.py @@ -1,15 +1,15 @@ import pytest from ophyd_async.core import init_devices -from dodal.devices.beamlines.i21 import XYZAzimuthTiltPolarParallelPerpendicularStage +from dodal.devices.beamlines.i21 import I21SampleManipulatorStage @pytest.fixture -def smp() -> XYZAzimuthTiltPolarParallelPerpendicularStage: +def smp() -> I21SampleManipulatorStage: with init_devices(mock=True): - smp = XYZAzimuthTiltPolarParallelPerpendicularStage("TEST:") + smp = I21SampleManipulatorStage("TEST:") return smp -def test_smp_read(smp: XYZAzimuthTiltPolarParallelPerpendicularStage) -> None: +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 index 1a55f74071..4cb436323a 100644 --- a/tests/devices/beamlines/i21/test_toolpoint_motion.py +++ b/tests/devices/beamlines/i21/test_toolpoint_motion.py @@ -9,9 +9,9 @@ from ophyd_async.testing import assert_reading, partial_reading from dodal.devices.beamlines.i21 import ( + I21SampleManipulatorStage, ToolPointMotion, ToolPointMotorPositions, - XYZAzimuthTiltPolarParallelPerpendicularStage, ) from dodal.devices.beamlines.i21.toolpoint_motion import ( DEFAULT_AXES_ZERO, @@ -21,14 +21,14 @@ @pytest.fixture -def smp() -> XYZAzimuthTiltPolarParallelPerpendicularStage: +def smp() -> I21SampleManipulatorStage: with init_devices(mock=True): - smp = XYZAzimuthTiltPolarParallelPerpendicularStage("TEST:") + smp = I21SampleManipulatorStage("TEST:") return smp @pytest.fixture -def uvw(smp: XYZAzimuthTiltPolarParallelPerpendicularStage) -> ToolPointMotion: +def uvw(smp: I21SampleManipulatorStage) -> ToolPointMotion: with init_devices(mock=True): uvw = ToolPointMotion(smp) return uvw From a99c745d3cc4947d92819d2385f73be370576c8c Mon Sep 17 00:00:00 2001 From: oliwenmandiamond <136330507+oliwenmandiamond@users.noreply.github.com> Date: Tue, 10 Feb 2026 13:27:44 +0000 Subject: [PATCH 61/81] Update src/dodal/devices/beamlines/i05/i05_motors.py Co-authored-by: Victor Rogalev --- src/dodal/devices/beamlines/i05/i05_motors.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/dodal/devices/beamlines/i05/i05_motors.py b/src/dodal/devices/beamlines/i05/i05_motors.py index 9c68b6511e..7ff3657f0f 100644 --- a/src/dodal/devices/beamlines/i05/i05_motors.py +++ b/src/dodal/devices/beamlines/i05/i05_motors.py @@ -13,7 +13,7 @@ class I05Goniometer(XYZPolarAzimuthTiltStage): - """Six-axis stage with a standard xyz stage and three axis of rotation: polar, + """Six-physical-axis stage with a standard xyz translational stage and three axis of rotation: polar, azimuth, and tilt. In addition, it defines two virtual translational axes, `perp` and `long`, which From d0b42148f5f735f64dedde8571eabb6058b08372 Mon Sep 17 00:00:00 2001 From: oliwenmandiamond <136330507+oliwenmandiamond@users.noreply.github.com> Date: Tue, 10 Feb 2026 13:28:04 +0000 Subject: [PATCH 62/81] Update src/dodal/devices/beamlines/i05/i05_motors.py Co-authored-by: Victor Rogalev --- src/dodal/devices/beamlines/i05/i05_motors.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/dodal/devices/beamlines/i05/i05_motors.py b/src/dodal/devices/beamlines/i05/i05_motors.py index 7ff3657f0f..7c379aa9c8 100644 --- a/src/dodal/devices/beamlines/i05/i05_motors.py +++ b/src/dodal/devices/beamlines/i05/i05_motors.py @@ -22,7 +22,7 @@ class I05Goniometer(XYZPolarAzimuthTiltStage): - `long`: Translation along the longitudinal direction of the rotated in-plane coordinate frame defined by ``rotation_angle_deg``. - - `perp`: Translation perpendicular to `long` within the x-y plane. + - `perp`: Translation along the rotated Y-axis. The `perp` and `long` axes are derived from the underlying x and y motors using a fixed rotation angle (default 50 degrees). From the user's point of view, these From 0ca0ae0291e0410f84fb793f5fb1d919ca11034d Mon Sep 17 00:00:00 2001 From: oliwenmandiamond <136330507+oliwenmandiamond@users.noreply.github.com> Date: Tue, 10 Feb 2026 13:28:24 +0000 Subject: [PATCH 63/81] Update src/dodal/devices/beamlines/i05/i05_motors.py Co-authored-by: Victor Rogalev --- src/dodal/devices/beamlines/i05/i05_motors.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/dodal/devices/beamlines/i05/i05_motors.py b/src/dodal/devices/beamlines/i05/i05_motors.py index 7c379aa9c8..5d64534e23 100644 --- a/src/dodal/devices/beamlines/i05/i05_motors.py +++ b/src/dodal/devices/beamlines/i05/i05_motors.py @@ -19,8 +19,7 @@ class I05Goniometer(XYZPolarAzimuthTiltStage): In addition, it defines two virtual translational axes, `perp` and `long`, which form a rotated Cartesian frame within the x-y plane. - - `long`: Translation along the longitudinal direction of the rotated in-plane - coordinate frame defined by ``rotation_angle_deg``. + - `long`: Translation along the rotated X-axis. - `perp`: Translation along the rotated Y-axis. From 5ac37adc5cf1d307355cf85af027992d3c2dbb3e Mon Sep 17 00:00:00 2001 From: oliwenmandiamond <136330507+oliwenmandiamond@users.noreply.github.com> Date: Tue, 10 Feb 2026 13:29:01 +0000 Subject: [PATCH 64/81] Update src/dodal/devices/beamlines/i05/i05_motors.py Co-authored-by: Victor Rogalev --- src/dodal/devices/beamlines/i05/i05_motors.py | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/src/dodal/devices/beamlines/i05/i05_motors.py b/src/dodal/devices/beamlines/i05/i05_motors.py index 5d64534e23..612b7767c2 100644 --- a/src/dodal/devices/beamlines/i05/i05_motors.py +++ b/src/dodal/devices/beamlines/i05/i05_motors.py @@ -23,11 +23,10 @@ class I05Goniometer(XYZPolarAzimuthTiltStage): - `perp`: Translation along the rotated Y-axis. - The `perp` and `long` axes are derived from the underlying x and y motors using a - fixed rotation angle (default 50 degrees). From the user's point of view, these - behave as ordinary orthogonal Cartesian translation axes aligned with physically - meaningful directions on the sample, while internally coordinating motion of the x - and y motors. + The `perp` and `long` axes are virtual axes derived from the underlying x and y motors using a + fixed rotation angle (default 50 degrees). Rotation angle corresponds to an angle between analyser axis and X-ray beam axis. From the user's point of view, these virtual axes + behave as ordinary orthogonal Cartesian translation axes aligned with the incoming X-ray beam (long) and perpendicular to it (perp), while internally coordinating motion of the x (perpendicular to analyser axis) + and y (along analyser axis) motors. Unlike sample-frame axes that rotate with a live rotation motor, these axes are defined at a constant orientation set by `rotation_angle_deg`. From bdfe3f90c0c2978fef1d2d23d46219a81928e0bd Mon Sep 17 00:00:00 2001 From: oliwenmandiamond <136330507+oliwenmandiamond@users.noreply.github.com> Date: Tue, 10 Feb 2026 13:29:22 +0000 Subject: [PATCH 65/81] Update src/dodal/devices/beamlines/i05/i05_motors.py Co-authored-by: Victor Rogalev --- src/dodal/devices/beamlines/i05/i05_motors.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/src/dodal/devices/beamlines/i05/i05_motors.py b/src/dodal/devices/beamlines/i05/i05_motors.py index 612b7767c2..70857b0979 100644 --- a/src/dodal/devices/beamlines/i05/i05_motors.py +++ b/src/dodal/devices/beamlines/i05/i05_motors.py @@ -28,8 +28,6 @@ class I05Goniometer(XYZPolarAzimuthTiltStage): behave as ordinary orthogonal Cartesian translation axes aligned with the incoming X-ray beam (long) and perpendicular to it (perp), while internally coordinating motion of the x (perpendicular to analyser axis) and y (along analyser axis) motors. - Unlike sample-frame axes that rotate with a live rotation motor, these axes are - defined at a constant orientation set by `rotation_angle_deg`. """ def __init__( From ce0d79ef75d8a0c192f8c1412f7a7d49b88a9075 Mon Sep 17 00:00:00 2001 From: oliwenmandiamond <136330507+oliwenmandiamond@users.noreply.github.com> Date: Tue, 10 Feb 2026 13:29:41 +0000 Subject: [PATCH 66/81] Update src/dodal/devices/beamlines/i05_shared/rotation_signals.py Co-authored-by: Victor Rogalev --- src/dodal/devices/beamlines/i05_shared/rotation_signals.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/dodal/devices/beamlines/i05_shared/rotation_signals.py b/src/dodal/devices/beamlines/i05_shared/rotation_signals.py index a3fc44cdd2..354b011afe 100644 --- a/src/dodal/devices/beamlines/i05_shared/rotation_signals.py +++ b/src/dodal/devices/beamlines/i05_shared/rotation_signals.py @@ -26,7 +26,7 @@ def create_rotational_ij_component_signals( angle_deg: float | SignalR[float], clockwise_frame: bool = True, ) -> tuple[SignalRW[float], SignalRW[float]]: - """Create virtual i/j signals representing a Cartesian coordinate frame + """Create virtual i/j axes 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 From a36556e0a7e0eaaa6c8b912d16a28eef57787844 Mon Sep 17 00:00:00 2001 From: oliwenmandiamond <136330507+oliwenmandiamond@users.noreply.github.com> Date: Tue, 10 Feb 2026 13:30:00 +0000 Subject: [PATCH 67/81] Update src/dodal/devices/beamlines/i05_1/i05_1_motors.py Co-authored-by: Victor Rogalev --- src/dodal/devices/beamlines/i05_1/i05_1_motors.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) 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 38dc7d22f9..baf565f16e 100644 --- a/src/dodal/devices/beamlines/i05_1/i05_1_motors.py +++ b/src/dodal/devices/beamlines/i05_1/i05_1_motors.py @@ -7,8 +7,8 @@ class XYZPolarAzimuthDefocusStage(XYZPolarAzimuthStage): - """Six-axis stage with a standard xyz stage and three axis of rotation: polar, - azimuth, and defocus. + """Six-physical-axis stage with a standard xyz stage, 2 axis of rotation: polar, + azimuth and one extra tranlastional axis defocus. This device exposes four virtual translational axes that are defined in frames of reference attached to the sample: From d8af671b3576c40101b992218537146b65a07ea5 Mon Sep 17 00:00:00 2001 From: oliwenmandiamond <136330507+oliwenmandiamond@users.noreply.github.com> Date: Tue, 10 Feb 2026 13:30:33 +0000 Subject: [PATCH 68/81] Update src/dodal/devices/beamlines/i05_1/i05_1_motors.py Co-authored-by: Victor Rogalev --- src/dodal/devices/beamlines/i05_1/i05_1_motors.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) 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 baf565f16e..42c50dd9c3 100644 --- a/src/dodal/devices/beamlines/i05_1/i05_1_motors.py +++ b/src/dodal/devices/beamlines/i05_1/i05_1_motors.py @@ -14,10 +14,10 @@ class XYZPolarAzimuthDefocusStage(XYZPolarAzimuthStage): of reference attached to the sample: - `hor` and `vert`: - Horizontal and vertical translation axes in the sample frame. - These axes are derived from the lab-frame x and y motors and rotate + Horizontal and vertical virtual translation axes of the rotated sample frame. + These axes are derived from X and Y axes rotated with the azimuth angle, so that motion along `hor` and `vert` - remains aligned with the sample regardless of its azimuthal rotation. + remains aligned with the gravity direction regardless of its azimuthal rotation. - `long` and `perp`: Longitudinal and perpendicular translation axes in the tilted sample From f80b264ff54c55af05f5eec3eaa80b556356205f Mon Sep 17 00:00:00 2001 From: oliwenmandiamond <136330507+oliwenmandiamond@users.noreply.github.com> Date: Tue, 10 Feb 2026 13:30:56 +0000 Subject: [PATCH 69/81] Update src/dodal/devices/beamlines/i05_1/i05_1_motors.py Co-authored-by: Victor Rogalev --- src/dodal/devices/beamlines/i05_1/i05_1_motors.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) 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 42c50dd9c3..c563e5c7f7 100644 --- a/src/dodal/devices/beamlines/i05_1/i05_1_motors.py +++ b/src/dodal/devices/beamlines/i05_1/i05_1_motors.py @@ -20,10 +20,10 @@ class XYZPolarAzimuthDefocusStage(XYZPolarAzimuthStage): remains aligned with the gravity direction regardless of its azimuthal rotation. - `long` and `perp`: - Longitudinal and perpendicular translation axes in the tilted sample - frame. These axes are derived from the lab-frame z motor and the - sample-frame `hor` axis, and rotate with the polar angle. - Motion along `long` follows the sample's longitudinal direction, + Longitudinal and perpendicular virtual translation axes in the rotated sample + frame. These axes are derived from the Z-axis and the + virtual `hor` axis, and depend on the polar angle. + Motion along `long` aligned with the analyser axis, while `perp` moves perpendicular to it within the polar rotation plane. All four virtual axes behave as ordinary orthogonal Cartesian translations From cd4cddf49ffe98c981d469672364f5c57c92bde8 Mon Sep 17 00:00:00 2001 From: Oli Wenman Date: Tue, 10 Feb 2026 13:35:57 +0000 Subject: [PATCH 70/81] Tidy up doc string after applying feedback commits --- src/dodal/devices/beamlines/i05/i05_motors.py | 24 +++++++++---------- 1 file changed, 12 insertions(+), 12 deletions(-) diff --git a/src/dodal/devices/beamlines/i05/i05_motors.py b/src/dodal/devices/beamlines/i05/i05_motors.py index 70857b0979..a207e38755 100644 --- a/src/dodal/devices/beamlines/i05/i05_motors.py +++ b/src/dodal/devices/beamlines/i05/i05_motors.py @@ -13,21 +13,21 @@ class I05Goniometer(XYZPolarAzimuthTiltStage): - """Six-physical-axis stage with a standard xyz translational stage and three axis of rotation: polar, - azimuth, and tilt. - - In addition, it defines two virtual translational axes, `perp` and `long`, which - form a rotated Cartesian frame within the x-y plane. + """Six-physical-axis stage with a standard xyz translational stage and three axis of + rotation: polar, azimuth, and tilt. + In addition, it defines two virtual translational axes derived signals, `perp` and + `long`, which form a rotated Cartesian frame within the x-y plane. - `long`: Translation along the rotated X-axis. + - `perp`: Translation along the rotated Y-axis. - - `perp`: Translation along the rotated Y-axis. - - The `perp` and `long` axes are virtual axes derived from the underlying x and y motors using a - fixed rotation angle (default 50 degrees). Rotation angle corresponds to an angle between analyser axis and X-ray beam axis. From the user's point of view, these virtual axes - behave as ordinary orthogonal Cartesian translation axes aligned with the incoming X-ray beam (long) and perpendicular to it (perp), while internally coordinating motion of the x (perpendicular to analyser axis) - and y (along analyser axis) motors. - + The `perp` and `long` axes are virtual axes derived from the underlying x and y + motors using a fixed rotation angle (default 50 degrees). Rotation angle corresponds + to an angle between analyser axis and X-ray beam axis. From the user's point of + view, these virtual axes behave as ordinary orthogonal Cartesian translation axes + aligned with the incoming X-ray beam (long) and perpendicular to it (perp), + while internally coordinating motion of the x (perpendicular to analyser axis) and y + (along analyser axis) motors. """ def __init__( From e6364b3e62fce9afe65bfe3dd052d0a5c27410ae Mon Sep 17 00:00:00 2001 From: Oli Wenman Date: Tue, 10 Feb 2026 13:48:04 +0000 Subject: [PATCH 71/81] Simplify rotation func --- src/dodal/common/maths.py | 20 +++----------------- 1 file changed, 3 insertions(+), 17 deletions(-) diff --git a/src/dodal/common/maths.py b/src/dodal/common/maths.py index 9b8ffa4a43..8ed2847c26 100644 --- a/src/dodal/common/maths.py +++ b/src/dodal/common/maths.py @@ -132,11 +132,7 @@ def do_rotation(x: float, y: float, rotation_matrix: np.ndarray) -> tuple[float, return float(rotation[0]), float(rotation[1]) -def rotate_clockwise( - theta: float, - x: float, - y: float, -) -> tuple[float, float]: +def rotate_clockwise(theta: float, x: float, y: float) -> tuple[float, float]: rotation_matrix = np.array( [ [np.cos(theta), np.sin(theta)], @@ -146,15 +142,5 @@ def rotate_clockwise( return do_rotation(x, y, rotation_matrix) -def rotate_counter_clockwise( - theta: float, - x: float, - y: float, -) -> tuple[float, float]: - rotation_matrix = np.array( - [ - [np.cos(theta), -np.sin(theta)], - [np.sin(theta), np.cos(theta)], - ] - ) - return do_rotation(x, y, rotation_matrix) +def rotate_counter_clockwise(theta: float, x: float, y: float) -> tuple[float, float]: + return rotate_clockwise(-theta, x, y) From 2adb1a6f66ebececd814098d7b9c222c04b3cc88 Mon Sep 17 00:00:00 2001 From: Oli Wenman Date: Wed, 11 Feb 2026 14:01:48 +0000 Subject: [PATCH 72/81] Fix imports --- src/dodal/beamlines/i05_1.py | 6 +- src/dodal/devices/beamlines/i05_1/__init__.py | 4 +- .../devices/beamlines/i05_1/i05_1_motors.py | 14 ++-- .../beamlines/i05_1/test_i05_1_motors.py | 74 +++++++++---------- 4 files changed, 49 insertions(+), 49 deletions(-) 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/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 62a4ba91eb..a2b628636f 100644 --- a/src/dodal/devices/beamlines/i05_1/i05_1_motors.py +++ b/src/dodal/devices/beamlines/i05_1/i05_1_motors.py @@ -37,13 +37,13 @@ class XYZAzimuthPolarDefocusStage(XYZAzimuthPolarStage): def __init__( self, prefix: str, - x_infix="SMX", - y_infix="SMY", - z_infix="SMZ", - azimuth_infix="AZM", - polar_infix="POL", - defocus_infix="SMDF", - name="", + x_infix: str = "SMX", + y_infix: str = "SMY", + z_infix: str = "SMZ", + azimuth_infix: str = "AZM", + polar_infix: str = "POL", + defocus_infix: str = "SMDF", + name: str = "", ): super().__init__( prefix, 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 b82363abb4..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,10 +13,10 @@ @pytest.fixture -def xyzpad_stage() -> XYZPolarAzimuthDefocusStage: +def sm() -> XYZAzimuthPolarDefocusStage: with init_devices(mock=True): - xyzpad_stage = XYZPolarAzimuthDefocusStage("TEST:") - return xyzpad_stage + sm = XYZAzimuthPolarDefocusStage("TEST:") + return sm def expected_hor_read(x: float, y: float, azimuth_theta: float) -> float: @@ -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(xyzpad_stage: 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) @@ -49,42 +49,42 @@ async def test_xyzpad_stage_read(xyzpad_stage: XYZPolarAzimuthDefocusStage) -> N expected_perp = expected_perp_read(z, expected_hor, polar_theta) await asyncio.gather( - xyzpad_stage.x.set(x), - xyzpad_stage.y.set(y), - xyzpad_stage.z.set(z), - xyzpad_stage.azimuth.set(azimuth_angle_deg), - xyzpad_stage.polar.set(polar_angle_deg), + sm.x.set(x), + sm.y.set(y), + sm.z.set(z), + sm.azimuth.set(azimuth_angle_deg), + sm.polar.set(polar_angle_deg), ) await assert_reading( - xyzpad_stage, + sm, { - xyzpad_stage.x.name: partial_reading(x), - xyzpad_stage.y.name: partial_reading(y), - xyzpad_stage.z.name: partial_reading(z), - xyzpad_stage.polar.name: partial_reading(polar_angle_deg), - xyzpad_stage.azimuth.name: partial_reading(azimuth_angle_deg), - xyzpad_stage.defocus.name: partial_reading(0), - xyzpad_stage.hor.name: partial_reading(expected_hor), - xyzpad_stage.vert.name: partial_reading(expected_vert), - xyzpad_stage.long.name: partial_reading(expected_long), - xyzpad_stage.perp.name: partial_reading(expected_perp), + sm.x.name: partial_reading(x), + sm.y.name: partial_reading(y), + sm.z.name: partial_reading(z), + sm.polar.name: partial_reading(polar_angle_deg), + sm.azimuth.name: partial_reading(azimuth_angle_deg), + sm.defocus.name: partial_reading(0), + sm.hor.name: partial_reading(expected_hor), + sm.vert.name: partial_reading(expected_vert), + sm.long.name: partial_reading(expected_long), + sm.perp.name: partial_reading(expected_perp), }, ) async def test_xyzpad_hor_and_vert_set( - xyzpad_stage: XYZPolarAzimuthDefocusStage, + sm: XYZAzimuthPolarDefocusStage, ) -> None: frame_config = RotatedCartesianFrameTestConfig( - i_read=xyzpad_stage.x.user_readback, - j_read=xyzpad_stage.y.user_readback, - i_write=xyzpad_stage.x, - j_write=xyzpad_stage.y, - angle_deg=xyzpad_stage.azimuth, + i_read=sm.x.user_readback, + j_read=sm.y.user_readback, + i_write=sm.x, + j_write=sm.y, + angle_deg=sm.azimuth, expected_i_read_func=expected_hor_read, expected_j_read_func=expected_vert_read, - i_rotation_axis=xyzpad_stage.hor, - j_rotation_axis=xyzpad_stage.vert, + i_rotation_axis=sm.hor, + j_rotation_axis=sm.vert, ) await assert_rotated_axes_are_orthogonal( i_val=10, @@ -97,18 +97,18 @@ async def test_xyzpad_hor_and_vert_set( async def test_xyzpad_long_and_perp_set( - xyzpad_stage: XYZPolarAzimuthDefocusStage, + sm: XYZAzimuthPolarDefocusStage, ) -> None: frame_config = RotatedCartesianFrameTestConfig( - i_read=xyzpad_stage.z.user_readback, - j_read=xyzpad_stage.hor, - i_write=xyzpad_stage.z, - j_write=xyzpad_stage.hor, - angle_deg=xyzpad_stage.polar, + i_read=sm.z.user_readback, + j_read=sm.hor, + i_write=sm.z, + j_write=sm.hor, + angle_deg=sm.polar, expected_i_read_func=expected_long_read, expected_j_read_func=expected_perp_read, - i_rotation_axis=xyzpad_stage.long, - j_rotation_axis=xyzpad_stage.perp, + i_rotation_axis=sm.long, + j_rotation_axis=sm.perp, ) await assert_rotated_axes_are_orthogonal( i_val=10, From cd95db71ca3e2df29fe71eb5d1dc0d11f7114181 Mon Sep 17 00:00:00 2001 From: Oli Wenman Date: Wed, 11 Feb 2026 14:03:21 +0000 Subject: [PATCH 73/81] Improve type checking for XYZPolarAzimuthDefocusStage --- src/dodal/devices/beamlines/i05_1/i05_1_motors.py | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) 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 c563e5c7f7..23c497e00f 100644 --- a/src/dodal/devices/beamlines/i05_1/i05_1_motors.py +++ b/src/dodal/devices/beamlines/i05_1/i05_1_motors.py @@ -37,13 +37,13 @@ class XYZPolarAzimuthDefocusStage(XYZPolarAzimuthStage): def __init__( self, prefix: str, - x_infix="SMX", - y_infix="SMY", - z_infix="SMZ", - polar_infix="POL", - azimuth_infix="AZM", - defocus_infix="SMDF", - name="", + x_infix: str = "SMX", + y_infix: str = "SMY", + z_infix: str = "SMZ", + polar_infix: str = "POL", + azimuth_infix: str = "AZM", + defocus_infix: str = "SMDF", + name: str = "", ): super().__init__( prefix, From 9867e9db6993a8634d9f58e1055f82ac39858fe6 Mon Sep 17 00:00:00 2001 From: Oli Wenman Date: Wed, 11 Feb 2026 14:05:35 +0000 Subject: [PATCH 74/81] Rename test device to what it is on i05-1 bl --- .../beamlines/i05_1/test_i05_1_motors.py | 72 +++++++++---------- 1 file changed, 36 insertions(+), 36 deletions(-) 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 b82363abb4..6978e2e9c5 100644 --- a/tests/devices/beamlines/i05_1/test_i05_1_motors.py +++ b/tests/devices/beamlines/i05_1/test_i05_1_motors.py @@ -13,10 +13,10 @@ @pytest.fixture -def xyzpad_stage() -> XYZPolarAzimuthDefocusStage: +def sm() -> XYZPolarAzimuthDefocusStage: with init_devices(mock=True): - xyzpad_stage = XYZPolarAzimuthDefocusStage("TEST:") - return xyzpad_stage + sm = XYZPolarAzimuthDefocusStage("TEST:") + return sm def expected_hor_read(x: float, y: float, azimuth_theta: float) -> float: @@ -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(xyzpad_stage: XYZPolarAzimuthDefocusStage) -> None: +async def test_xyzpad_stage_read(sm: XYZPolarAzimuthDefocusStage) -> None: x, y = 10, 5 azimuth_angle_deg = 45 azimuth_theta = radians(azimuth_angle_deg) @@ -49,42 +49,42 @@ async def test_xyzpad_stage_read(xyzpad_stage: XYZPolarAzimuthDefocusStage) -> N expected_perp = expected_perp_read(z, expected_hor, polar_theta) await asyncio.gather( - xyzpad_stage.x.set(x), - xyzpad_stage.y.set(y), - xyzpad_stage.z.set(z), - xyzpad_stage.azimuth.set(azimuth_angle_deg), - xyzpad_stage.polar.set(polar_angle_deg), + sm.x.set(x), + sm.y.set(y), + sm.z.set(z), + sm.azimuth.set(azimuth_angle_deg), + sm.polar.set(polar_angle_deg), ) await assert_reading( - xyzpad_stage, + sm, { - xyzpad_stage.x.name: partial_reading(x), - xyzpad_stage.y.name: partial_reading(y), - xyzpad_stage.z.name: partial_reading(z), - xyzpad_stage.polar.name: partial_reading(polar_angle_deg), - xyzpad_stage.azimuth.name: partial_reading(azimuth_angle_deg), - xyzpad_stage.defocus.name: partial_reading(0), - xyzpad_stage.hor.name: partial_reading(expected_hor), - xyzpad_stage.vert.name: partial_reading(expected_vert), - xyzpad_stage.long.name: partial_reading(expected_long), - xyzpad_stage.perp.name: partial_reading(expected_perp), + sm.x.name: partial_reading(x), + sm.y.name: partial_reading(y), + sm.z.name: partial_reading(z), + sm.polar.name: partial_reading(polar_angle_deg), + sm.azimuth.name: partial_reading(azimuth_angle_deg), + sm.defocus.name: partial_reading(0), + sm.hor.name: partial_reading(expected_hor), + sm.vert.name: partial_reading(expected_vert), + sm.long.name: partial_reading(expected_long), + sm.perp.name: partial_reading(expected_perp), }, ) async def test_xyzpad_hor_and_vert_set( - xyzpad_stage: XYZPolarAzimuthDefocusStage, + sm: XYZPolarAzimuthDefocusStage, ) -> None: frame_config = RotatedCartesianFrameTestConfig( - i_read=xyzpad_stage.x.user_readback, - j_read=xyzpad_stage.y.user_readback, - i_write=xyzpad_stage.x, - j_write=xyzpad_stage.y, - angle_deg=xyzpad_stage.azimuth, + i_read=sm.x.user_readback, + j_read=sm.y.user_readback, + i_write=sm.x, + j_write=sm.y, + angle_deg=sm.azimuth, expected_i_read_func=expected_hor_read, expected_j_read_func=expected_vert_read, - i_rotation_axis=xyzpad_stage.hor, - j_rotation_axis=xyzpad_stage.vert, + i_rotation_axis=sm.hor, + j_rotation_axis=sm.vert, ) await assert_rotated_axes_are_orthogonal( i_val=10, @@ -97,18 +97,18 @@ async def test_xyzpad_hor_and_vert_set( async def test_xyzpad_long_and_perp_set( - xyzpad_stage: XYZPolarAzimuthDefocusStage, + sm: XYZPolarAzimuthDefocusStage, ) -> None: frame_config = RotatedCartesianFrameTestConfig( - i_read=xyzpad_stage.z.user_readback, - j_read=xyzpad_stage.hor, - i_write=xyzpad_stage.z, - j_write=xyzpad_stage.hor, - angle_deg=xyzpad_stage.polar, + i_read=sm.z.user_readback, + j_read=sm.hor, + i_write=sm.z, + j_write=sm.hor, + angle_deg=sm.polar, expected_i_read_func=expected_long_read, expected_j_read_func=expected_perp_read, - i_rotation_axis=xyzpad_stage.long, - j_rotation_axis=xyzpad_stage.perp, + i_rotation_axis=sm.long, + j_rotation_axis=sm.perp, ) await assert_rotated_axes_are_orthogonal( i_val=10, From 8956bc2e4ca57ebb3bb2a8bdc19277d7f78cffbb Mon Sep 17 00:00:00 2001 From: Oli Wenman Date: Wed, 11 Feb 2026 15:10:18 +0000 Subject: [PATCH 75/81] Improve readability of code --- src/dodal/devices/beamlines/i21/__init__.py | 5 +- .../devices/beamlines/i21/toolpoint_motion.py | 114 +++++++++--------- .../beamlines/i21/test_toolpoint_motion.py | 39 +++--- 3 files changed, 79 insertions(+), 79 deletions(-) diff --git a/src/dodal/devices/beamlines/i21/__init__.py b/src/dodal/devices/beamlines/i21/__init__.py index ea55ac6c50..86cf42c720 100644 --- a/src/dodal/devices/beamlines/i21/__init__.py +++ b/src/dodal/devices/beamlines/i21/__init__.py @@ -1,10 +1,11 @@ from .enums import Grating from .i21_motors import I21SampleManipulatorStage -from .toolpoint_motion import ToolPointMotion, ToolPointMotorPositions +from .toolpoint_motion import ToolPointMotion, UVWMotorPositions, XYZMotorPositions __all__ = [ "Grating", "I21SampleManipulatorStage", "ToolPointMotion", - "ToolPointMotorPositions", + "UVWMotorPositions", + "XYZMotorPositions", ] diff --git a/src/dodal/devices/beamlines/i21/toolpoint_motion.py b/src/dodal/devices/beamlines/i21/toolpoint_motion.py index ca41604601..b4c76e0bbe 100644 --- a/src/dodal/devices/beamlines/i21/toolpoint_motion.py +++ b/src/dodal/devices/beamlines/i21/toolpoint_motion.py @@ -30,7 +30,7 @@ def toolpoint_rotation_matrix(tilt_deg: float, azimuth_deg: float) -> np.ndarray ) -def toolpoint_to_xyz( +def uvw_to_xyz( u: float, v: float, w: float, @@ -46,7 +46,7 @@ def toolpoint_to_xyz( return float(x), float(y), float(z) -def xyz_to_toolpoint( +def xyz_to_uvw( x: float, y: float, z: float, @@ -63,13 +63,24 @@ def xyz_to_toolpoint( return float(u), float(v), float(w) -@dataclass(frozen=True) -class ToolPointMotorPositions: +@dataclass +class AnglePositions: + tilt_deg: float + azimuth_deg: float + + +@dataclass +class UVWMotorPositions(AnglePositions): + u: float + v: float + w: float + + +@dataclass +class XYZMotorPositions(AnglePositions): x: float y: float z: float - tilt_deg: float - azimuth_deg: float class ToolPointMotion(StandardReadable, Movable): @@ -94,18 +105,8 @@ def __init__( super().__init__(name=name) - async def _read_motor_positions(self) -> ToolPointMotorPositions: - 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 ToolPointMotorPositions(x, y, z, tilt, azimuth) - async def check_motor_limits( - self, start: ToolPointMotorPositions, end: ToolPointMotorPositions + self, start: XYZMotorPositions, end: XYZMotorPositions ) -> None: await asyncio.gather( self.smp_ref().x.check_motor_limit(start.x, end.x), @@ -117,38 +118,41 @@ async def check_motor_limits( ), ) - def _toolpoint_to_motor_positions( - self, u: float, v: float, w: float, tilt: float, azimuth: float - ) -> ToolPointMotorPositions: - x, y, z = toolpoint_to_xyz(u, v, w, tilt, azimuth, self._zero) - return ToolPointMotorPositions( + async def _get_real_motor_positions( + self, + ) -> tuple[float, float, float, float, float]: + return 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(), + ) + + async def _read_all_uvw(self) -> tuple[float, float, float, float, float]: + x, y, z, tilt, azimuth = await self._get_real_motor_positions() + u, v, w = xyz_to_uvw( x=x, y=y, z=z, tilt_deg=tilt, azimuth_deg=azimuth, + zero=self._zero, ) + return u, v, w, tilt, azimuth - async def _read_all(self) -> tuple[float, float, float, float, float]: - pos = await self._read_motor_positions() - u, v, w = xyz_to_toolpoint( - pos.x, - pos.y, - pos.z, - pos.tilt_deg, - pos.azimuth_deg, - self._zero, - ) - return u, v, w, pos.tilt_deg, pos.azimuth_deg - - async def _write_all( + async def _write_all_uvw( self, u: float, v: float, w: float, tilt: float, azimuth: float ) -> None: - start = await self._read_motor_positions() - end = self._toolpoint_to_motor_positions(u, v, w, tilt, azimuth) + # Current positions + x0, y0, z0, tilt0, azimuth0 = await self._get_real_motor_positions() + start = XYZMotorPositions(x0, y0, z0, tilt0, azimuth0) - await self.check_motor_limits(start, end) + # Target positions + x, y, z = uvw_to_xyz(u, v, w, tilt, azimuth, self._zero) + end = XYZMotorPositions(x, y, z, tilt, azimuth) + await self.check_motor_limits(start, end) await asyncio.gather( self.smp_ref().x.set(end.x), self.smp_ref().y.set(end.y), @@ -161,35 +165,29 @@ 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: - return float( - xyz_to_toolpoint(x, y, z, tilt_deg, azimuth_deg, self._zero)[0] - ) + return float(xyz_to_uvw(x, y, z, tilt_deg, azimuth_deg, self._zero)[0]) async def set_u(value: float) -> None: - u, v, w, tilt_deg, azimuth_deg = await self._read_all() - await self._write_all(value, v, w, tilt_deg, azimuth_deg) + u, v, w, tilt_deg, azimuth_deg = await self._read_all_uvw() + await self._write_all_uvw(value, v, w, tilt_deg, azimuth_deg) def read_v( x: float, y: float, z: float, tilt_deg: float, azimuth_deg: float ) -> float: - return float( - xyz_to_toolpoint(x, y, z, tilt_deg, azimuth_deg, self._zero)[1] - ) + return float(xyz_to_uvw(x, y, z, tilt_deg, azimuth_deg, self._zero)[1]) async def set_v(value: float) -> None: - u, v, w, tilt_deg, azimuth_deg = await self._read_all() - await self._write_all(u, value, w, tilt_deg, azimuth_deg) + u, v, w, tilt_deg, azimuth_deg = await self._read_all_uvw() + await self._write_all_uvw(u, value, w, tilt_deg, azimuth_deg) def read_w( x: float, y: float, z: float, tilt_deg: float, azimuth_deg: float ) -> float: - return float( - xyz_to_toolpoint(x, y, z, tilt_deg, azimuth_deg, self._zero)[2] - ) + return float(xyz_to_uvw(x, y, z, tilt_deg, azimuth_deg, self._zero)[2]) async def set_w(value: float) -> None: - u, v, w, tilt_deg, azimuth_deg = await self._read_all() - await self._write_all(u, v, value, tilt_deg, azimuth_deg) + u, v, w, tilt_deg, azimuth_deg = await self._read_all_uvw() + await self._write_all_uvw(u, v, value, tilt_deg, azimuth_deg) u = derived_signal_rw( read_u, @@ -221,7 +219,11 @@ async def set_w(value: float) -> None: return u, v, w @AsyncStatus.wrap - async def set(self, value: ToolPointMotorPositions): - await self._write_all( - value.x, value.y, value.z, value.tilt_deg, value.azimuth_deg + async def set(self, value: UVWMotorPositions): + await self._write_all_uvw( + u=value.u, + v=value.v, + w=value.w, + tilt=value.tilt_deg, + azimuth=value.azimuth_deg, ) diff --git a/tests/devices/beamlines/i21/test_toolpoint_motion.py b/tests/devices/beamlines/i21/test_toolpoint_motion.py index 4cb436323a..8ce03c5ec6 100644 --- a/tests/devices/beamlines/i21/test_toolpoint_motion.py +++ b/tests/devices/beamlines/i21/test_toolpoint_motion.py @@ -11,12 +11,13 @@ from dodal.devices.beamlines.i21 import ( I21SampleManipulatorStage, ToolPointMotion, - ToolPointMotorPositions, + UVWMotorPositions, + XYZMotorPositions, ) from dodal.devices.beamlines.i21.toolpoint_motion import ( DEFAULT_AXES_ZERO, - toolpoint_to_xyz, - xyz_to_toolpoint, + uvw_to_xyz, + xyz_to_uvw, ) @@ -35,7 +36,7 @@ def uvw(smp: I21SampleManipulatorStage) -> ToolPointMotion: def expected_uvw_read( - pos: ToolPointMotorPositions, zero: tuple[float, float, float] + 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) @@ -62,7 +63,7 @@ def test_toolpoint_math_matches_legacy(): u, v, w = 1.2, -3.4, 5.6 tilt, azimuth = 30.0, 10.0 - x_new, y_new, z_new = toolpoint_to_xyz(u, v, w, tilt, azimuth, zero) + x_new, y_new, z_new = uvw_to_xyz(u, v, w, tilt, azimuth, zero) chi = radians(tilt) phi = radians(azimuth) @@ -80,7 +81,7 @@ def test_xyz_to_toolpoint_matches_legacy(): x, y, z = 12.3, -4.5, 6.7 tilt, azimuth = 30.0, 10.0 - u_new, v_new, w_new = xyz_to_toolpoint(x, y, z, tilt, azimuth, zero) + u_new, v_new, w_new = xyz_to_uvw(x, y, z, tilt, azimuth, zero) chi = radians(tilt) phi = radians(azimuth) @@ -111,7 +112,7 @@ async def test_uvw_read(uvw: ToolPointMotion) -> None: smp.azimuth.set(azimuth_deg), smp.tilt.set(tilt_deg), ) - expected_u, expected_v, expected_w = xyz_to_toolpoint( + expected_u, expected_v, expected_w = xyz_to_uvw( x=x, y=y, z=z, tilt_deg=tilt_deg, azimuth_deg=azimuth_deg, zero=uvw._zero ) @@ -149,7 +150,7 @@ async def test_uvw_axis_set( ) # Read initial toolpoint position - u0, v0, w0, tilt, azimuth = await uvw._read_all() + u0, v0, w0, tilt, azimuth = await uvw._read_all_uvw() # Set the selected axis axis = getattr(uvw, axis_name) @@ -160,9 +161,7 @@ async def test_uvw_axis_set( v = new_value if axis_name == "v" else v0 w = new_value if axis_name == "w" else w0 - expected_x, expected_y, expected_z = toolpoint_to_xyz( - u, v, w, tilt, azimuth, uvw._zero - ) + expected_x, expected_y, expected_z = uvw_to_xyz(u, v, w, tilt, azimuth, uvw._zero) actual_x, actual_y, actual_z = await asyncio.gather( smp.x.user_readback.get_value(), @@ -186,15 +185,15 @@ async def test_uvw_axis_set( async def test_uvw_set(uvw: ToolPointMotion) -> None: - pos = ToolPointMotorPositions(x=10, y=20, z=30, tilt_deg=40, azimuth_deg=50) + 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.x), - uvw.v.name: partial_reading(pos.y), - uvw.w.name: partial_reading(pos.z), + uvw.u.name: partial_reading(pos.u), + uvw.v.name: partial_reading(pos.v), + uvw.w.name: partial_reading(pos.w), }, full_match=False, ) @@ -214,10 +213,8 @@ async def test_uvw_check_motor_limits_calls_all_motors( smp.tilt.check_motor_limit = AsyncMock() smp.azimuth.check_motor_limit = AsyncMock() - start = ToolPointMotorPositions( - x=1.0, y=2.0, z=3.0, tilt_deg=10.0, azimuth_deg=20.0 - ) - end = ToolPointMotorPositions(x=4.0, y=5.0, z=6.0, tilt_deg=30.0, azimuth_deg=40.0) + 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) @@ -237,8 +234,8 @@ async def test_check_motor_limits_raises_on_failure( set_mock_value(uvw.smp_ref().z.high_limit_travel, 500) set_mock_value(uvw.smp_ref().z.dial_high_limit_travel, 500) - start = ToolPointMotorPositions(x=0.0, y=0.0, z=0.0, tilt_deg=0.0, azimuth_deg=0.0) - end = ToolPointMotorPositions(x=1.0, y=1.0, z=600, tilt_deg=5.0, azimuth_deg=5.0) + 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) From 2831c9e613896f5b6b8b6405e6b7f2943b47e9de Mon Sep 17 00:00:00 2001 From: Oli Wenman Date: Wed, 11 Feb 2026 15:12:55 +0000 Subject: [PATCH 76/81] Removed duplicate import --- src/dodal/beamlines/i09.py | 1 - 1 file changed, 1 deletion(-) diff --git a/src/dodal/beamlines/i09.py b/src/dodal/beamlines/i09.py index 9b79554ce2..5cb3ebacf9 100644 --- a/src/dodal/beamlines/i09.py +++ b/src/dodal/beamlines/i09.py @@ -5,7 +5,6 @@ from dodal.common.beamlines.beamline_utils import set_beamline as set_utils_beamline from dodal.device_manager import DeviceManager from dodal.devices.beamlines.i09 import LensMode, PassEnergy, PsuMode -from dodal.devices.beamlines.i09.enums 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 019f1cbbd6ae28b21254da15e003bc92931c17f8 Mon Sep 17 00:00:00 2001 From: Oli Wenman Date: Wed, 11 Feb 2026 16:01:58 +0000 Subject: [PATCH 77/81] Made logic more explicit and reused dataclasses. Fixed tests --- .../devices/beamlines/i21/toolpoint_motion.py | 151 ++++++++---------- .../beamlines/i21/test_toolpoint_motion.py | 52 +++--- 2 files changed, 97 insertions(+), 106 deletions(-) diff --git a/src/dodal/devices/beamlines/i21/toolpoint_motion.py b/src/dodal/devices/beamlines/i21/toolpoint_motion.py index b4c76e0bbe..47015ae932 100644 --- a/src/dodal/devices/beamlines/i21/toolpoint_motion.py +++ b/src/dodal/devices/beamlines/i21/toolpoint_motion.py @@ -30,59 +30,55 @@ def toolpoint_rotation_matrix(tilt_deg: float, azimuth_deg: float) -> np.ndarray ) -def uvw_to_xyz( - u: float, - v: float, - w: float, - tilt_deg: float, - azimuth_deg: float, - zero: tuple[float, float, float], -) -> tuple[float, float, float]: - rotation = toolpoint_rotation_matrix(tilt_deg, azimuth_deg) - tool = np.array([u, v, w]) - offset = np.array(zero) - xyz = offset + rotation @ tool - x, y, z = xyz - return float(x), float(y), float(z) - - -def xyz_to_uvw( - x: float, - y: float, - z: float, - tilt_deg: float, - azimuth_deg: float, - zero: tuple[float, float, float], -) -> tuple[float, float, float]: - rotation = toolpoint_rotation_matrix(tilt_deg, azimuth_deg) - xyz = np.array([x, y, z]) - offset = np.array(zero) - - tool = rotation.T @ (xyz - offset) - u, v, w = tool - return float(u), float(v), float(w) - - -@dataclass +@dataclass(kw_only=True) class AnglePositions: tilt_deg: float azimuth_deg: float -@dataclass +@dataclass(kw_only=True) class UVWMotorPositions(AnglePositions): u: float v: float w: float -@dataclass +@dataclass(kw_only=True) class XYZMotorPositions(AnglePositions): 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 @@ -120,74 +116,73 @@ async def check_motor_limits( async def _get_real_motor_positions( self, - ) -> tuple[float, float, float, float, float]: - return await asyncio.gather( + ) -> 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) -> tuple[float, float, float, float, float]: - x, y, z, tilt, azimuth = await self._get_real_motor_positions() - u, v, w = xyz_to_uvw( - x=x, - y=y, - z=z, - tilt_deg=tilt, - azimuth_deg=azimuth, - zero=self._zero, - ) - return u, v, w, tilt, azimuth - - async def _write_all_uvw( - self, u: float, v: float, w: float, tilt: float, azimuth: float - ) -> None: - # Current positions - x0, y0, z0, tilt0, azimuth0 = await self._get_real_motor_positions() - start = XYZMotorPositions(x0, y0, z0, tilt0, azimuth0) + async def _read_all_uvw(self) -> UVWMotorPositions: + xyz_pos = await self._get_real_motor_positions() + uvw_pos = xyz_to_uvw(xyz_pos, zero=self._zero) + return uvw_pos - # Target positions - x, y, z = uvw_to_xyz(u, v, w, tilt, azimuth, self._zero) - end = XYZMotorPositions(x, y, z, tilt, azimuth) + async def _write_all_uvw(self, uvw_pos: UVWMotorPositions) -> None: + xyz_start = await self._get_real_motor_positions() + xyz_end = uvw_to_xyz(uvw_pos, self._zero) - await self.check_motor_limits(start, end) + await self.check_motor_limits(xyz_start, xyz_end) await asyncio.gather( - self.smp_ref().x.set(end.x), - self.smp_ref().y.set(end.y), - self.smp_ref().z.set(end.z), - self.smp_ref().tilt.set(end.tilt_deg), - self.smp_ref().azimuth.set(end.azimuth_deg), + 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: - return float(xyz_to_uvw(x, y, z, tilt_deg, azimuth_deg, self._zero)[0]) + 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: - u, v, w, tilt_deg, azimuth_deg = await self._read_all_uvw() - await self._write_all_uvw(value, v, w, tilt_deg, azimuth_deg) + 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: - return float(xyz_to_uvw(x, y, z, tilt_deg, azimuth_deg, self._zero)[1]) + 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: - u, v, w, tilt_deg, azimuth_deg = await self._read_all_uvw() - await self._write_all_uvw(u, value, w, tilt_deg, azimuth_deg) + 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: - return float(xyz_to_uvw(x, y, z, tilt_deg, azimuth_deg, self._zero)[2]) + 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: - u, v, w, tilt_deg, azimuth_deg = await self._read_all_uvw() - await self._write_all_uvw(u, v, value, tilt_deg, azimuth_deg) + uvw_pos = await self._read_all_uvw() + uvw_pos.w = value + await self._write_all_uvw(uvw_pos) u = derived_signal_rw( read_u, @@ -220,10 +215,4 @@ async def set_w(value: float) -> None: @AsyncStatus.wrap async def set(self, value: UVWMotorPositions): - await self._write_all_uvw( - u=value.u, - v=value.v, - w=value.w, - tilt=value.tilt_deg, - azimuth=value.azimuth_deg, - ) + await self._write_all_uvw(value) diff --git a/tests/devices/beamlines/i21/test_toolpoint_motion.py b/tests/devices/beamlines/i21/test_toolpoint_motion.py index 8ce03c5ec6..0df9ed28b1 100644 --- a/tests/devices/beamlines/i21/test_toolpoint_motion.py +++ b/tests/devices/beamlines/i21/test_toolpoint_motion.py @@ -1,4 +1,5 @@ import asyncio +from dataclasses import replace from math import cos, radians, sin from unittest.mock import AsyncMock @@ -58,12 +59,14 @@ def expected_uvw_read( return expected_v, expected_v, expected_w -def test_toolpoint_math_matches_legacy(): +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 - x_new, y_new, z_new = uvw_to_xyz(u, v, w, tilt, azimuth, zero) + 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) @@ -72,16 +75,19 @@ def test_toolpoint_math_matches_legacy(): y_old = zero[1] + v * cos(phi) - w * sin(phi) z_old = zero[2] - u * sin(chi) + v * cos(chi) * sin(phi) + w * cos(chi) * cos(phi) - assert np.allclose([x_new, y_new, z_new], [x_old, y_old, z_old]) + assert np.allclose( + [expected_xyz.x, expected_xyz.y, expected_xyz.z], [x_old, y_old, z_old] + ) -def test_xyz_to_toolpoint_matches_legacy(): +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) - u_new, v_new, w_new = xyz_to_uvw(x, y, z, tilt, azimuth, zero) + expected_uvw = xyz_to_uvw(xyz, zero) chi = radians(tilt) phi = radians(azimuth) @@ -95,8 +101,7 @@ def test_xyz_to_toolpoint_matches_legacy(): w_old = dx * cos(phi) * sin(chi) - dy * sin(phi) + dz * cos(chi) * cos(phi) assert np.allclose( - [u_new, v_new, w_new], - [u_old, v_old, w_old], + [expected_uvw.u, expected_uvw.v, expected_uvw.w], [u_old, v_old, w_old] ) @@ -112,17 +117,18 @@ async def test_uvw_read(uvw: ToolPointMotion) -> None: smp.azimuth.set(azimuth_deg), smp.tilt.set(tilt_deg), ) - expected_u, expected_v, expected_w = xyz_to_uvw( - x=x, y=y, z=z, tilt_deg=tilt_deg, azimuth_deg=azimuth_deg, zero=uvw._zero + 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_u), - uvw.v.name: partial_reading(expected_v), - uvw.w.name: partial_reading(expected_w), + 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, ) @@ -150,18 +156,16 @@ async def test_uvw_axis_set( ) # Read initial toolpoint position - u0, v0, w0, tilt, azimuth = await uvw._read_all_uvw() + 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 - u = new_value if axis_name == "u" else u0 - v = new_value if axis_name == "v" else v0 - w = new_value if axis_name == "w" else w0 + uvw_pos = replace(uvw_pos0, **{axis_name: new_value}) - expected_x, expected_y, expected_z = uvw_to_xyz(u, v, w, tilt, azimuth, uvw._zero) + expected_xyz = uvw_to_xyz(uvw_pos, uvw._zero) actual_x, actual_y, actual_z = await asyncio.gather( smp.x.user_readback.get_value(), @@ -169,16 +173,16 @@ async def test_uvw_axis_set( smp.z.user_readback.get_value(), ) - assert np.isclose(actual_x, expected_x) - assert np.isclose(actual_y, expected_y) - assert np.isclose(actual_z, expected_z) + 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(u), - uvw.v.name: partial_reading(v), - uvw.w.name: partial_reading(w), + 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, ) @@ -202,7 +206,6 @@ async def test_uvw_set(uvw: ToolPointMotion) -> None: assert await uvw.smp_ref().tilt.user_readback.get_value() == pos.tilt_deg -@pytest.mark.asyncio async def test_uvw_check_motor_limits_calls_all_motors( uvw: ToolPointMotion, ) -> None: @@ -227,7 +230,6 @@ async def test_uvw_check_motor_limits_calls_all_motors( ) -@pytest.mark.asyncio async def test_check_motor_limits_raises_on_failure( uvw: ToolPointMotion, ) -> None: From 9673b0a856a90b94eae39625bd46b893eeb7919b Mon Sep 17 00:00:00 2001 From: Oli Wenman Date: Wed, 11 Feb 2026 16:04:50 +0000 Subject: [PATCH 78/81] Rename variable names to make clearer --- .../beamlines/i21/test_toolpoint_motion.py | 21 +++++++++++-------- 1 file changed, 12 insertions(+), 9 deletions(-) diff --git a/tests/devices/beamlines/i21/test_toolpoint_motion.py b/tests/devices/beamlines/i21/test_toolpoint_motion.py index 0df9ed28b1..3dbeaf985f 100644 --- a/tests/devices/beamlines/i21/test_toolpoint_motion.py +++ b/tests/devices/beamlines/i21/test_toolpoint_motion.py @@ -71,12 +71,15 @@ def test_uvw_to_xyz_math_matches_legacy(): chi = radians(tilt) phi = radians(azimuth) - x_old = zero[0] + u * cos(chi) + v * sin(chi) * sin(phi) + w * cos(phi) * sin(chi) - y_old = zero[1] + v * cos(phi) - w * sin(phi) - z_old = zero[2] - u * sin(chi) + v * cos(chi) * sin(phi) + w * cos(chi) * cos(phi) - + 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( - [expected_xyz.x, expected_xyz.y, expected_xyz.z], [x_old, y_old, z_old] + [x_legacy, y_legacy, z_legacy], [expected_xyz.x, expected_xyz.y, expected_xyz.z] ) @@ -96,12 +99,12 @@ def test_xyz_to_uvw_math_matches_legacy(): dy = y - zero[1] dz = z - zero[2] - u_old = dx * cos(chi) - dz * sin(chi) - v_old = dx * sin(chi) * sin(phi) + dy * cos(phi) + dz * cos(chi) * sin(phi) - w_old = dx * cos(phi) * sin(chi) - dy * sin(phi) + dz * cos(chi) * cos(phi) + 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( - [expected_uvw.u, expected_uvw.v, expected_uvw.w], [u_old, v_old, w_old] + [u_legacy, v_legacy, w_legacy], [expected_uvw.u, expected_uvw.v, expected_uvw.w] ) From d5b059256091d0e5ffdfc3c4d813f96766a8eb92 Mon Sep 17 00:00:00 2001 From: Oli Wenman Date: Wed, 11 Feb 2026 16:19:01 +0000 Subject: [PATCH 79/81] Add more type checking docs for create_rotational_ij_component_signals --- src/dodal/devices/motors.py | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/src/dodal/devices/motors.py b/src/dodal/devices/motors.py index 3379485511..10c3d36387 100644 --- a/src/dodal/devices/motors.py +++ b/src/dodal/devices/motors.py @@ -419,13 +419,14 @@ def create_rotational_ij_component_signals( rotation so that the requested motion is achieved in the rotated frame. Args: - i_read (SignalR): SignalR representing the i motor readback. - j_read (SignalR): representing the j motor readback. - i_write (Movable): object for setting the i position. - j_write (Movable): object for setting the j position. - angle_deg (float | SignalR): Rotation angle in degrees. - clockwise_frame (boolean): If True, the rotated frame is defined using a - clockwise rotation; otherwise, a counter-clockwise rotation is used. + 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 From 261efbdb982e6654d191e5daae0cff9e694319c0 Mon Sep 17 00:00:00 2001 From: Oli Wenman Date: Thu, 12 Feb 2026 14:16:53 +0000 Subject: [PATCH 80/81] Update test names --- src/dodal/devices/beamlines/i21/toolpoint_motion.py | 6 +++--- tests/devices/test_motors.py | 6 +++--- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/src/dodal/devices/beamlines/i21/toolpoint_motion.py b/src/dodal/devices/beamlines/i21/toolpoint_motion.py index 47015ae932..4376129f61 100644 --- a/src/dodal/devices/beamlines/i21/toolpoint_motion.py +++ b/src/dodal/devices/beamlines/i21/toolpoint_motion.py @@ -31,20 +31,20 @@ def toolpoint_rotation_matrix(tilt_deg: float, azimuth_deg: float) -> np.ndarray @dataclass(kw_only=True) -class AnglePositions: +class AngleMotorPositions: tilt_deg: float azimuth_deg: float @dataclass(kw_only=True) -class UVWMotorPositions(AnglePositions): +class UVWMotorPositions(AngleMotorPositions): u: float v: float w: float @dataclass(kw_only=True) -class XYZMotorPositions(AnglePositions): +class XYZMotorPositions(AngleMotorPositions): x: float y: float z: float diff --git a/tests/devices/test_motors.py b/tests/devices/test_motors.py index 47aed2117e..ee8c66262b 100644 --- a/tests/devices/test_motors.py +++ b/tests/devices/test_motors.py @@ -161,7 +161,7 @@ async def test_setting_xyza_position_table( (1.23, 2.40, 3.51, 24.0, 1.0), ], ) -async def test_setting_xyzpa_position_table( +async def test_setting_xyzat_position_table( xyzat_stage: XYZAzimuthTiltStage, x: float, y: float, @@ -195,7 +195,7 @@ async def test_setting_xyzpa_position_table( (1.23, 2.40, 3.51, 24.0, 1.0, 2.0), ], ) -async def test_setting_xyzpat_position_table( +async def test_setting_xyzatp_position_table( xyzatp_stage: XYZAzimuthTiltPolarStage, x: float, y: float, @@ -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, From 1d0cb8cc885e4e5b235f8fb17bde5808b59c4164 Mon Sep 17 00:00:00 2001 From: Oli Wenman Date: Fri, 13 Feb 2026 16:49:18 +0000 Subject: [PATCH 81/81] Rename function --- src/dodal/devices/beamlines/i21/toolpoint_motion.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/dodal/devices/beamlines/i21/toolpoint_motion.py b/src/dodal/devices/beamlines/i21/toolpoint_motion.py index 4376129f61..79c462ba86 100644 --- a/src/dodal/devices/beamlines/i21/toolpoint_motion.py +++ b/src/dodal/devices/beamlines/i21/toolpoint_motion.py @@ -114,7 +114,7 @@ async def check_motor_limits( ), ) - async def _get_real_motor_positions( + async def _get_xyz_motor_positions( self, ) -> XYZMotorPositions: x, y, z, tilt, azimuth = await asyncio.gather( @@ -127,12 +127,12 @@ async def _get_real_motor_positions( 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_real_motor_positions() + 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_real_motor_positions() + 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)