From 859b56939a11c1aa20cffde9a95ce4901e48ae12 Mon Sep 17 00:00:00 2001 From: eir17846 Date: Fri, 13 Feb 2026 13:56:38 +0000 Subject: [PATCH 1/5] add apple knot on i05-shared --- src/dodal/beamlines/i05_shared.py | 48 +++++++++- src/dodal/devices/beamlines/i05/__init__.py | 4 - .../devices/beamlines/i05_shared/__init__.py | 13 +++ .../beamlines/i05_shared/apple_knot.py | 92 +++++++++++++++++++ .../{i05 => i05_shared}/compound_motors.py | 0 .../beamlines/{i05 => i05_shared}/enums.py | 0 6 files changed, 152 insertions(+), 5 deletions(-) create mode 100644 src/dodal/devices/beamlines/i05_shared/__init__.py create mode 100644 src/dodal/devices/beamlines/i05_shared/apple_knot.py rename src/dodal/devices/beamlines/{i05 => i05_shared}/compound_motors.py (100%) rename src/dodal/devices/beamlines/{i05 => i05_shared}/enums.py (100%) diff --git a/src/dodal/beamlines/i05_shared.py b/src/dodal/beamlines/i05_shared.py index 807c114048..a7cf6af57d 100644 --- a/src/dodal/beamlines/i05_shared.py +++ b/src/dodal/beamlines/i05_shared.py @@ -1,10 +1,23 @@ from dodal.device_manager import DeviceManager -from dodal.devices.beamlines.i05.enums import Grating +from dodal.devices.beamlines.i05_shared import ( + Grating, + energy_to_gap_converter, + energy_to_phase_converter, +) +from dodal.devices.beamlines.i05_shared.apple_knot import ( + APPLE_KNOT_EXCLUSION_ZONES, +) from dodal.devices.insertion_device import ( Apple2, UndulatorGap, UndulatorLockedPhaseAxes, ) +from dodal.devices.insertion_device.apple_knot_controller import ( + AppleKnotController, + AppleKnotPathFinder, +) +from dodal.devices.insertion_device.energy import BeamEnergy, InsertionDeviceEnergy +from dodal.devices.insertion_device.polarisation import InsertionDevicePolarisation from dodal.devices.pgm import PlaneGratingMonochromator from dodal.devices.synchrotron import Synchrotron from dodal.utils import BeamlinePrefix, get_beamline_name @@ -49,3 +62,36 @@ def id( ) -> Apple2[UndulatorLockedPhaseAxes]: """i05 insertion device.""" return Apple2[UndulatorLockedPhaseAxes](id_gap=id_gap, id_phase=id_phase) + + +@devices.factory() +def id_controller( + id: Apple2[UndulatorLockedPhaseAxes], +) -> AppleKnotController[UndulatorLockedPhaseAxes]: + return AppleKnotController[UndulatorLockedPhaseAxes]( + apple=id, + gap_energy_motor_converter=energy_to_gap_converter, + phase_energy_motor_converter=energy_to_phase_converter, + path_finder=AppleKnotPathFinder(APPLE_KNOT_EXCLUSION_ZONES), + ) + + +@devices.factory() +def id_energy( + id_controller: AppleKnotController[UndulatorLockedPhaseAxes], +) -> InsertionDeviceEnergy: + return InsertionDeviceEnergy(id_controller=id_controller) + + +@devices.factory() +def id_polarisation( + id_controller: AppleKnotController[UndulatorLockedPhaseAxes], +) -> InsertionDevicePolarisation: + return InsertionDevicePolarisation(id_controller=id_controller) + + +@devices.factory() +def energy( + id_energy: InsertionDeviceEnergy, pgm: PlaneGratingMonochromator +) -> BeamEnergy: + return BeamEnergy(id_energy=id_energy, mono=pgm.energy) diff --git a/src/dodal/devices/beamlines/i05/__init__.py b/src/dodal/devices/beamlines/i05/__init__.py index 78969620a3..e69de29bb2 100644 --- a/src/dodal/devices/beamlines/i05/__init__.py +++ b/src/dodal/devices/beamlines/i05/__init__.py @@ -1,4 +0,0 @@ -from dodal.devices.beamlines.i05.compound_motors import PolynomCompoundMotors -from dodal.devices.beamlines.i05.enums import Grating - -__all__ = ["Grating", "PolynomCompoundMotors"] diff --git a/src/dodal/devices/beamlines/i05_shared/__init__.py b/src/dodal/devices/beamlines/i05_shared/__init__.py new file mode 100644 index 0000000000..e40ebe631b --- /dev/null +++ b/src/dodal/devices/beamlines/i05_shared/__init__.py @@ -0,0 +1,13 @@ +from dodal.devices.beamlines.i05_shared.apple_knot import ( + energy_to_gap_converter, + energy_to_phase_converter, +) +from dodal.devices.beamlines.i05_shared.compound_motors import PolynomCompoundMotors +from dodal.devices.beamlines.i05_shared.enums import Grating + +__all__ = [ + "Grating", + "PolynomCompoundMotors", + "energy_to_gap_converter", + "energy_to_phase_converter", +] diff --git a/src/dodal/devices/beamlines/i05_shared/apple_knot.py b/src/dodal/devices/beamlines/i05_shared/apple_knot.py new file mode 100644 index 0000000000..9e01bb4657 --- /dev/null +++ b/src/dodal/devices/beamlines/i05_shared/apple_knot.py @@ -0,0 +1,92 @@ +import numpy.polynomial as poly + +from dodal.common.maths import Rectangle2D +from dodal.devices.insertion_device.enum import Pol + +# Define exclusion zones in phase-gap space +APPLE_KNOT_EXCLUSION_ZONES = ( + Rectangle2D(-65.5, 0.0, 65.5, 25.5), # mechanical limit + Rectangle2D(-10.5, 0.0, 10.5, 37.5), # power load limit +) + +# Polynomials for energy to gap conversion +LH_GAP_POLYNOMIAL = poly.Polynomial( + [ + 12.464, + 1.8417, + -0.030139, + 0.00023511, + 1.0158e-6, + -3.9229e-8, + 3.6772e-10, + -1.7356e-12, + 4.2103e-15, + -4.1724e-18, + ] +) +LV_GAP_POLYNOMIAL = poly.Polynomial( + [ + 8.7456, + 1.1344, + -0.024317, + 0.00041143, + -5.0759e-6, + 4.496e-8, + -2.7464e-10, + 1.081e-12, + -2.4377e-15, + 2.3749e-18, + ] +) +C_GAP_POLYNOMIAL = poly.Polynomial( + [ + 9.1763, + 1.4886, + -0.035968, + 0.00064576, + -7.951e-6, + 6.6281e-8, + -3.6547e-10, + 1.2699e-12, + -2.5078e-15, + 2.1392e-18, + ] +) + +# Polynomial for energy to phase conversion +C_PHASE_POLYNOMIAL = poly.Polynomial( + [ + 34.431, + 0.79535, + -0.022218, + 0.00040781, + -4.921e-6, + 3.9683e-8, + -2.1267e-10, + 7.2752e-13, + -1.4341e-15, + 1.2345e-18, + ] +) + + +def energy_to_gap_converter(energy: float, pol: Pol) -> float: + if pol == Pol.LH: + return float(LH_GAP_POLYNOMIAL(energy)) + if pol == Pol.LV: + return float(LV_GAP_POLYNOMIAL(energy)) + if pol == Pol.PC or pol == Pol.NC: + return float(C_GAP_POLYNOMIAL(energy)) + return 0.0 + + +def energy_to_phase_converter(energy: float, pol: Pol) -> float: + if pol == Pol.LH: + return 0.0 + if pol == Pol.LV: + return 70.0 + if pol == Pol.PC: + return float(C_PHASE_POLYNOMIAL(energy)) + if pol == Pol.NC: + return -float(C_PHASE_POLYNOMIAL(energy)) + return 0.0 diff --git a/src/dodal/devices/beamlines/i05/compound_motors.py b/src/dodal/devices/beamlines/i05_shared/compound_motors.py similarity index 100% rename from src/dodal/devices/beamlines/i05/compound_motors.py rename to src/dodal/devices/beamlines/i05_shared/compound_motors.py diff --git a/src/dodal/devices/beamlines/i05/enums.py b/src/dodal/devices/beamlines/i05_shared/enums.py similarity index 100% rename from src/dodal/devices/beamlines/i05/enums.py rename to src/dodal/devices/beamlines/i05_shared/enums.py From 4cfa0f35896d18c9a4977f906e495afaca61fc03 Mon Sep 17 00:00:00 2001 From: eir17846 Date: Fri, 13 Feb 2026 14:13:09 +0000 Subject: [PATCH 2/5] fix merge and tests --- src/dodal/devices/beamlines/i05/__init__.py | 3 +++ tests/devices/beamlines/i05/test_compound_motor.py | 2 +- 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/src/dodal/devices/beamlines/i05/__init__.py b/src/dodal/devices/beamlines/i05/__init__.py index e69de29bb2..3675d760ef 100644 --- a/src/dodal/devices/beamlines/i05/__init__.py +++ b/src/dodal/devices/beamlines/i05/__init__.py @@ -0,0 +1,3 @@ +from .i05_motors import I05Goniometer + +__all__ = ["I05Goniometer"] diff --git a/tests/devices/beamlines/i05/test_compound_motor.py b/tests/devices/beamlines/i05/test_compound_motor.py index f7a84480a2..d06fcd420c 100644 --- a/tests/devices/beamlines/i05/test_compound_motor.py +++ b/tests/devices/beamlines/i05/test_compound_motor.py @@ -6,7 +6,7 @@ from ophyd_async.epics.motor import Motor from ophyd_async.testing import assert_configuration, assert_reading, partial_reading -from dodal.devices.beamlines.i05 import PolynomCompoundMotors +from dodal.devices.beamlines.i05_shared import PolynomCompoundMotors @pytest.fixture From 44ccd2efd66083b511991a4e4752666a2f430be6 Mon Sep 17 00:00:00 2001 From: eir17846 Date: Fri, 13 Feb 2026 14:26:58 +0000 Subject: [PATCH 3/5] reorganise tests - use real apple knot functions and polynomials --- .../testing/fixtures/devices/apple_knot.py | 9 ++++ .../test_compound_motor.py | 0 .../test_apple_knot_path_finder.py | 17 ++----- .../test_apple_knot_undulator.py | 51 +++---------------- 4 files changed, 21 insertions(+), 56 deletions(-) create mode 100644 src/dodal/testing/fixtures/devices/apple_knot.py rename tests/devices/beamlines/{i05 => i05_shared}/test_compound_motor.py (100%) diff --git a/src/dodal/testing/fixtures/devices/apple_knot.py b/src/dodal/testing/fixtures/devices/apple_knot.py new file mode 100644 index 0000000000..1db15d778e --- /dev/null +++ b/src/dodal/testing/fixtures/devices/apple_knot.py @@ -0,0 +1,9 @@ +import pytest + +from dodal.devices.beamlines.i05_shared.apple_knot import APPLE_KNOT_EXCLUSION_ZONES +from dodal.devices.insertion_device.apple_knot_controller import AppleKnotPathFinder + + +@pytest.fixture +def apple_knot_i05_path_finder() -> AppleKnotPathFinder: + return AppleKnotPathFinder(APPLE_KNOT_EXCLUSION_ZONES) diff --git a/tests/devices/beamlines/i05/test_compound_motor.py b/tests/devices/beamlines/i05_shared/test_compound_motor.py similarity index 100% rename from tests/devices/beamlines/i05/test_compound_motor.py rename to tests/devices/beamlines/i05_shared/test_compound_motor.py diff --git a/tests/devices/insertion_device/test_apple_knot_path_finder.py b/tests/devices/insertion_device/test_apple_knot_path_finder.py index 572333aa5f..07ebb35c70 100644 --- a/tests/devices/insertion_device/test_apple_knot_path_finder.py +++ b/tests/devices/insertion_device/test_apple_knot_path_finder.py @@ -1,6 +1,5 @@ import pytest -from dodal.common.maths import Rectangle2D from dodal.devices.insertion_device import ( AppleKnotPathFinder, ) @@ -10,13 +9,10 @@ ) # add mock_config_client, mock_id_gap, mock_phase and mock_jaw_phase_axes to pytest. -pytest_plugins = ["dodal.testing.fixtures.devices.apple2"] - -# Define exclusion zones in phase-gap space -TEST_APPLE_KNOT_EXCLUSION_ZONES = ( - Rectangle2D(-65.5, 0.0, 65.5, 25.5), # mechanical limit - Rectangle2D(-10.5, 0.0, 10.5, 37.5), # power load limit -) +pytest_plugins = [ + "dodal.testing.fixtures.devices.apple2", + "dodal.testing.fixtures.devices.apple_knot", +] def get_pair_apple2_val( @@ -42,11 +38,6 @@ def get_pair_apple2_val( return start_val, target_val -@pytest.fixture -def apple_knot_i05_path_finder() -> AppleKnotPathFinder: - return AppleKnotPathFinder(TEST_APPLE_KNOT_EXCLUSION_ZONES) - - @pytest.mark.parametrize( "start_phase, start_gap, target_phase, target_gap", [ diff --git a/tests/devices/insertion_device/test_apple_knot_undulator.py b/tests/devices/insertion_device/test_apple_knot_undulator.py index c011ab1df9..a6c2d14ce5 100644 --- a/tests/devices/insertion_device/test_apple_knot_undulator.py +++ b/tests/devices/insertion_device/test_apple_knot_undulator.py @@ -1,10 +1,12 @@ -import numpy.polynomial as poly import pytest from ophyd_async.core import ( set_mock_value, ) -from dodal.common.maths import Rectangle2D +from dodal.devices.beamlines.i05_shared import ( + energy_to_gap_converter, + energy_to_phase_converter, +) from dodal.devices.insertion_device import ( AppleKnotController, AppleKnotPathFinder, @@ -16,47 +18,10 @@ ) # add mock_config_client, mock_id_gap, mock_phase and mock_jaw_phase_axes to pytest. -pytest_plugins = ["dodal.testing.fixtures.devices.apple2"] - -# Define exclusion zones in phase-gap space -TEST_APPLE_KNOT_EXCLUSION_ZONES = ( - Rectangle2D(-65.5, 0.0, 65.5, 25.5), # mechanical limit - Rectangle2D(-10.5, 0.0, 10.5, 37.5), # power load limit -) -# Test polynomials for energy to gap/phase conversion -TEST_LH_GAP_POLYNOMIAL = poly.Polynomial([12.46, 0.832, -0.002]) -TEST_LV_GAP_POLYNOMIAL = poly.Polynomial([8.7456, 0.412, -0.0024317]) -TEST_C_GAP_POLYNOMIAL = poly.Polynomial([9.1763, 0.312, -0.000305968]) -TEST_C_PHASE_POLYNOMIAL = poly.Polynomial( - [4.4, 0.79, -0.022, 0.00041, -4.921e-6, 3.9683e-8] -) - - -def energy_to_gap_converter(energy: float, pol: Pol) -> float: - if pol == Pol.LH: - return float(TEST_LH_GAP_POLYNOMIAL(energy)) - if pol == Pol.LV: - return float(TEST_LV_GAP_POLYNOMIAL(energy)) - if pol == Pol.PC or pol == Pol.NC: - return float(TEST_C_GAP_POLYNOMIAL(energy)) - return 0.0 - - -def energy_to_phase_converter(energy: float, pol: Pol) -> float: - if pol == Pol.LH: - return 0.0 - if pol == Pol.LV: - return 70.0 - if pol == Pol.PC: - return float(TEST_C_PHASE_POLYNOMIAL(energy)) - if pol == Pol.NC: - return -float(TEST_C_PHASE_POLYNOMIAL(energy)) - return 0.0 - - -@pytest.fixture -def apple_knot_i05_path_finder() -> AppleKnotPathFinder: - return AppleKnotPathFinder(TEST_APPLE_KNOT_EXCLUSION_ZONES) +pytest_plugins = [ + "dodal.testing.fixtures.devices.apple2", + "dodal.testing.fixtures.devices.apple_knot", +] @pytest.fixture From 987a828e43fd45e728f08e1a89d7f6342a112cdc Mon Sep 17 00:00:00 2001 From: eir17846 Date: Fri, 13 Feb 2026 14:51:36 +0000 Subject: [PATCH 4/5] add test to improve coverage --- .../test_apple_knot_undulator.py | 37 +++++++++++++++++++ 1 file changed, 37 insertions(+) diff --git a/tests/devices/insertion_device/test_apple_knot_undulator.py b/tests/devices/insertion_device/test_apple_knot_undulator.py index a6c2d14ce5..6feb92add4 100644 --- a/tests/devices/insertion_device/test_apple_knot_undulator.py +++ b/tests/devices/insertion_device/test_apple_knot_undulator.py @@ -104,6 +104,43 @@ async def test_id_set_pol( assert await mock_apple_knot_i05_controller.polarisation.get_value() == target_pol +@pytest.mark.parametrize( + "initial_pol, initial_energy, target_pol", + [ + (Pol.LV, 40.0, Pol.LA), + (Pol.LV, 40.0, Pol.LH3), + (Pol.LV, 80.0, Pol.LV3), + (Pol.LV, 80.0, Pol.NONE), + ], +) +async def test_id_set_pol_fails( + mock_apple_knot_i05_controller: AppleKnotController[UndulatorLockedPhaseAxes], + mock_locked_apple2: Apple2[UndulatorLockedPhaseAxes], + initial_pol: Pol, + initial_energy: float, + target_pol: Pol, +): + mock_apple_knot_i05_controller._energy_set(initial_energy) + set_mock_value( + mock_locked_apple2.gap().user_readback, + energy_to_gap_converter(initial_energy, initial_pol), + ) + set_mock_value( + mock_locked_apple2.phase().top_outer.user_readback, + energy_to_phase_converter(initial_energy, initial_pol), + ) + set_mock_value( + mock_locked_apple2.phase().btm_inner.user_readback, + energy_to_phase_converter(initial_energy, initial_pol), + ) + assert await mock_apple_knot_i05_controller.polarisation.get_value() == initial_pol + with pytest.raises( + RuntimeError, + match="No valid path found for move avoiding exclusion zones.", + ): + await mock_apple_knot_i05_controller.polarisation.set(target_pol) + + @pytest.mark.parametrize( "target_energy, initial_gap, initial_phase_top_outer", [ From 112ea33b48da308cca3211b60eccbc1af2e32797 Mon Sep 17 00:00:00 2001 From: eir17846 Date: Thu, 19 Feb 2026 10:51:07 +0000 Subject: [PATCH 5/5] implement comments suggestions --- src/dodal/beamlines/i05_shared.py | 4 +--- src/dodal/devices/beamlines/i05_shared/__init__.py | 4 +++- .../{apple_knot.py => apple_knot_constants.py} | 10 +++++----- src/dodal/testing/fixtures/devices/apple_knot.py | 4 +++- 4 files changed, 12 insertions(+), 10 deletions(-) rename src/dodal/devices/beamlines/i05_shared/{apple_knot.py => apple_knot_constants.py} (91%) diff --git a/src/dodal/beamlines/i05_shared.py b/src/dodal/beamlines/i05_shared.py index a7cf6af57d..05ca5c2dd8 100644 --- a/src/dodal/beamlines/i05_shared.py +++ b/src/dodal/beamlines/i05_shared.py @@ -1,12 +1,10 @@ from dodal.device_manager import DeviceManager from dodal.devices.beamlines.i05_shared import ( + APPLE_KNOT_EXCLUSION_ZONES, Grating, energy_to_gap_converter, energy_to_phase_converter, ) -from dodal.devices.beamlines.i05_shared.apple_knot import ( - APPLE_KNOT_EXCLUSION_ZONES, -) from dodal.devices.insertion_device import ( Apple2, UndulatorGap, diff --git a/src/dodal/devices/beamlines/i05_shared/__init__.py b/src/dodal/devices/beamlines/i05_shared/__init__.py index e40ebe631b..75cf35881f 100644 --- a/src/dodal/devices/beamlines/i05_shared/__init__.py +++ b/src/dodal/devices/beamlines/i05_shared/__init__.py @@ -1,4 +1,5 @@ -from dodal.devices.beamlines.i05_shared.apple_knot import ( +from dodal.devices.beamlines.i05_shared.apple_knot_constants import ( + APPLE_KNOT_EXCLUSION_ZONES, energy_to_gap_converter, energy_to_phase_converter, ) @@ -10,4 +11,5 @@ "PolynomCompoundMotors", "energy_to_gap_converter", "energy_to_phase_converter", + "APPLE_KNOT_EXCLUSION_ZONES", ] diff --git a/src/dodal/devices/beamlines/i05_shared/apple_knot.py b/src/dodal/devices/beamlines/i05_shared/apple_knot_constants.py similarity index 91% rename from src/dodal/devices/beamlines/i05_shared/apple_knot.py rename to src/dodal/devices/beamlines/i05_shared/apple_knot_constants.py index 9e01bb4657..72720c09a1 100644 --- a/src/dodal/devices/beamlines/i05_shared/apple_knot.py +++ b/src/dodal/devices/beamlines/i05_shared/apple_knot_constants.py @@ -1,4 +1,4 @@ -import numpy.polynomial as poly +from numpy.polynomial import Polynomial from dodal.common.maths import Rectangle2D from dodal.devices.insertion_device.enum import Pol @@ -10,7 +10,7 @@ ) # Polynomials for energy to gap conversion -LH_GAP_POLYNOMIAL = poly.Polynomial( +LH_GAP_POLYNOMIAL = Polynomial( [ 12.464, 1.8417, @@ -24,7 +24,7 @@ -4.1724e-18, ] ) -LV_GAP_POLYNOMIAL = poly.Polynomial( +LV_GAP_POLYNOMIAL = Polynomial( [ 8.7456, 1.1344, @@ -38,7 +38,7 @@ 2.3749e-18, ] ) -C_GAP_POLYNOMIAL = poly.Polynomial( +C_GAP_POLYNOMIAL = Polynomial( [ 9.1763, 1.4886, @@ -54,7 +54,7 @@ ) # Polynomial for energy to phase conversion -C_PHASE_POLYNOMIAL = poly.Polynomial( +C_PHASE_POLYNOMIAL = Polynomial( [ 34.431, 0.79535, diff --git a/src/dodal/testing/fixtures/devices/apple_knot.py b/src/dodal/testing/fixtures/devices/apple_knot.py index 1db15d778e..2b4e45dcc4 100644 --- a/src/dodal/testing/fixtures/devices/apple_knot.py +++ b/src/dodal/testing/fixtures/devices/apple_knot.py @@ -1,6 +1,8 @@ import pytest -from dodal.devices.beamlines.i05_shared.apple_knot import APPLE_KNOT_EXCLUSION_ZONES +from dodal.devices.beamlines.i05_shared import ( + APPLE_KNOT_EXCLUSION_ZONES, +) from dodal.devices.insertion_device.apple_knot_controller import AppleKnotPathFinder