From 637c86aa9f7169ba4c3c4eeaf6af3a82673f459e Mon Sep 17 00:00:00 2001 From: eir17846 Date: Fri, 14 Nov 2025 16:50:20 +0000 Subject: [PATCH 01/10] add classes --- src/dodal/devices/i09_1_shared/energy,py | 106 +++++++++++++++++++++++ 1 file changed, 106 insertions(+) create mode 100644 src/dodal/devices/i09_1_shared/energy,py diff --git a/src/dodal/devices/i09_1_shared/energy,py b/src/dodal/devices/i09_1_shared/energy,py new file mode 100644 index 0000000000..f7b1ca8b01 --- /dev/null +++ b/src/dodal/devices/i09_1_shared/energy,py @@ -0,0 +1,106 @@ +from asyncio import gather +from collections.abc import Callable + +from bluesky.protocols import Locatable, Location, Movable +from numpy import ndarray +from ophyd_async.core import ( + AsyncStatus, + Reference, + StandardReadable, + StandardReadableFormat, + derived_signal_rw, +) + +from dodal.devices.common_dcm import DoubleCrystalMonochromatorBase +from dodal.devices.i09_1_shared.hard_undulator_functions import ( + MAX_ENERGY_COLUMN, + MIN_ENERGY_COLUMN, +) +from dodal.devices.undulator import UndulatorInMm, UndulatorOrder + + +class HardInsertionDeviceEnergy(StandardReadable, Movable[float]): + """ + Compound device to link hard x-ray undulator gap and order to photon energy. + Setting the energy adjusts the undulator gap accordingly. + """ + + def __init__( + self, + undulator_order: UndulatorOrder, + undulator: UndulatorInMm, + lut: dict[int, ndarray], + gap_to_energy_func: Callable[..., float], + energy_to_gap_func: Callable[..., float], + name: str = "" + ) -> None: + self._lut = lut + self.gap_to_energy_func = gap_to_energy_func + self.energy_to_gap_func = energy_to_gap_func + with self.add_children_as_readables(StandardReadableFormat.HINTED_SIGNAL): + self._undulator_order = Reference(undulator_order) + self._undulator = Reference(undulator) + self.energy = derived_signal_rw( + raw_to_derived=self._read_energy, + set_derived=self._set_energy, + current_gap = self._undulator().gap_motor.user_readback, + current_order = self._undulator_order().value, + derived_units="eV", + ) + super().__init__(name=name) + + def _read_energy(self, current_gap: float, current_order: int) -> float: + return self.gap_to_energy_func( + gap = current_gap, + look_up_table = self._lut, + order = current_order, + ) + + async def _set_energy(self, energy: float): + current_order = await self._undulator_order().value.get_value() + min_energy, max_energy = self._lut[current_order][MIN_ENERGY_COLUMN:MAX_ENERGY_COLUMN + 1] + if not (min_energy <= energy <= max_energy): + raise ValueError( + f"Requested energy {energy} keV is out of range for harmonic {current_order}: " + f"[{min_energy}, {max_energy}] keV" + ) + + target_gap = self.energy_to_gap_func( + photon_energy_kev=energy, + look_up_table = self._lut, + order=current_order + ) + await self._undulator().set(target_gap) + + @AsyncStatus.wrap + async def set(self, value: float) -> None: + await self.energy.set(value) + + +class HardEnergy(StandardReadable, Locatable[float]): + """ + Energy compound device that provides combined change of both DCM energy and undulator gap accordingly. + """ + def __init__( + self, + dcm: DoubleCrystalMonochromatorBase, + undulator_energy: HardInsertionDeviceEnergy, + name: str = "" + ) -> None: + with self.add_children_as_readables(): + self._dcm = Reference(dcm) + self._undulator_energy = Reference(undulator_energy) + super().__init__(name=name) + + @AsyncStatus.wrap + async def set(self, value: float): + await gather( + self._dcm().energy_in_keV.set(value), + self._undulator_energy().set(value) + ) + + async def locate(self) -> Location[float]: + return Location( + setpoint = await self._dcm().energy_in_keV.user_setpoint.get_value(), + readback = await self._dcm().energy_in_keV.user_readback.get_value() + ) From 7e662d22fa4f18528d7cc80ec44918fa6ef20a04 Mon Sep 17 00:00:00 2001 From: eir17846 Date: Fri, 14 Nov 2025 17:17:31 +0000 Subject: [PATCH 02/10] add beamline objects --- src/dodal/beamlines/i09_1.py | 33 ++++++++++ src/dodal/devices/i09_1_shared/__init__.py | 8 ++- .../i09_1_shared/{energy,py => energy.py} | 60 +++++++++---------- 3 files changed, 70 insertions(+), 31 deletions(-) rename src/dodal/devices/i09_1_shared/{energy,py => energy.py} (66%) diff --git a/src/dodal/beamlines/i09_1.py b/src/dodal/beamlines/i09_1.py index 24120649d2..03fc5e666a 100644 --- a/src/dodal/beamlines/i09_1.py +++ b/src/dodal/beamlines/i09_1.py @@ -10,6 +10,11 @@ from dodal.devices.electron_analyser import EnergySource from dodal.devices.electron_analyser.specs import SpecsDetector from dodal.devices.i09_1 import LensMode, PsuMode +from dodal.devices.i09_1_shared.energy import HardEnergy, HardInsertionDeviceEnergy +from dodal.devices.i09_1_shared.hard_undulator_functions import ( + calculate_gap_i09_hu, + get_hu_lut_as_dict, +) from dodal.devices.synchrotron import Synchrotron from dodal.devices.undulator import UndulatorInMm, UndulatorOrder from dodal.log import set_beamline as set_log_beamline @@ -58,3 +63,31 @@ def undulator() -> UndulatorInMm: @device_factory() def harmonics() -> UndulatorOrder: return UndulatorOrder(name="harmonics") + + +HU_LUT_PATH = "/dls_sw/i09/software/gda/workspace_git/gda-diamond.git/configurations/i09-1-shared/lookupTables/IIDCalibrationTable.txt" + + +async def lut_dictionary() -> dict: + return await get_hu_lut_as_dict(HU_LUT_PATH) + + +@device_factory() +def hu_id_energy() -> HardInsertionDeviceEnergy: + return HardInsertionDeviceEnergy( + undulator_order=harmonics(), + undulator=undulator(), + lut=lut_dictionary(), + gap_to_energy_func=lambda x: x, # Placeholder, not used in this context + energy_to_gap_func=calculate_gap_i09_hu, + name="hu_id_energy", + ) + + +@device_factory() +def hu_energy() -> HardEnergy: + return HardEnergy( + dcm=dcm(), + undulator_energy=hu_id_energy(), + name="hu_energy", + ) diff --git a/src/dodal/devices/i09_1_shared/__init__.py b/src/dodal/devices/i09_1_shared/__init__.py index 4c5ebf6e4a..4e33fb2787 100644 --- a/src/dodal/devices/i09_1_shared/__init__.py +++ b/src/dodal/devices/i09_1_shared/__init__.py @@ -1,3 +1,9 @@ +from .energy import HardEnergy, HardInsertionDeviceEnergy from .hard_undulator_functions import calculate_gap_i09_hu, get_hu_lut_as_dict -__all__ = ["calculate_gap_i09_hu", "get_hu_lut_as_dict"] +__all__ = [ + "calculate_gap_i09_hu", + "get_hu_lut_as_dict", + "HardInsertionDeviceEnergy", + "HardEnergy", +] diff --git a/src/dodal/devices/i09_1_shared/energy,py b/src/dodal/devices/i09_1_shared/energy.py similarity index 66% rename from src/dodal/devices/i09_1_shared/energy,py rename to src/dodal/devices/i09_1_shared/energy.py index f7b1ca8b01..989e92f541 100644 --- a/src/dodal/devices/i09_1_shared/energy,py +++ b/src/dodal/devices/i09_1_shared/energy.py @@ -1,5 +1,5 @@ from asyncio import gather -from collections.abc import Callable +from collections.abc import Awaitable, Callable from bluesky.protocols import Locatable, Location, Movable from numpy import ndarray @@ -26,14 +26,14 @@ class HardInsertionDeviceEnergy(StandardReadable, Movable[float]): """ def __init__( - self, - undulator_order: UndulatorOrder, - undulator: UndulatorInMm, - lut: dict[int, ndarray], - gap_to_energy_func: Callable[..., float], - energy_to_gap_func: Callable[..., float], - name: str = "" - ) -> None: + self, + undulator_order: UndulatorOrder, + undulator: UndulatorInMm, + lut: dict[int, ndarray] | Awaitable[dict[int, ndarray]], + gap_to_energy_func: Callable[..., float], + energy_to_gap_func: Callable[..., float], + name: str = "", + ) -> None: self._lut = lut self.gap_to_energy_func = gap_to_energy_func self.energy_to_gap_func = energy_to_gap_func @@ -43,22 +43,24 @@ def __init__( self.energy = derived_signal_rw( raw_to_derived=self._read_energy, set_derived=self._set_energy, - current_gap = self._undulator().gap_motor.user_readback, - current_order = self._undulator_order().value, + current_gap=self._undulator().gap_motor.user_readback, + current_order=self._undulator_order().value, derived_units="eV", ) super().__init__(name=name) def _read_energy(self, current_gap: float, current_order: int) -> float: return self.gap_to_energy_func( - gap = current_gap, - look_up_table = self._lut, - order = current_order, - ) + gap=current_gap, + look_up_table=self._lut, + order=current_order, + ) async def _set_energy(self, energy: float): current_order = await self._undulator_order().value.get_value() - min_energy, max_energy = self._lut[current_order][MIN_ENERGY_COLUMN:MAX_ENERGY_COLUMN + 1] + min_energy, max_energy = self._lut[current_order][ + MIN_ENERGY_COLUMN : MAX_ENERGY_COLUMN + 1 + ] if not (min_energy <= energy <= max_energy): raise ValueError( f"Requested energy {energy} keV is out of range for harmonic {current_order}: " @@ -66,9 +68,7 @@ async def _set_energy(self, energy: float): ) target_gap = self.energy_to_gap_func( - photon_energy_kev=energy, - look_up_table = self._lut, - order=current_order + photon_energy_kev=energy, look_up_table=self._lut, order=current_order ) await self._undulator().set(target_gap) @@ -81,12 +81,13 @@ class HardEnergy(StandardReadable, Locatable[float]): """ Energy compound device that provides combined change of both DCM energy and undulator gap accordingly. """ + def __init__( - self, - dcm: DoubleCrystalMonochromatorBase, - undulator_energy: HardInsertionDeviceEnergy, - name: str = "" - ) -> None: + self, + dcm: DoubleCrystalMonochromatorBase, + undulator_energy: HardInsertionDeviceEnergy, + name: str = "", + ) -> None: with self.add_children_as_readables(): self._dcm = Reference(dcm) self._undulator_energy = Reference(undulator_energy) @@ -95,12 +96,11 @@ def __init__( @AsyncStatus.wrap async def set(self, value: float): await gather( - self._dcm().energy_in_keV.set(value), - self._undulator_energy().set(value) - ) + self._dcm().energy_in_keV.set(value), self._undulator_energy().set(value) + ) async def locate(self) -> Location[float]: return Location( - setpoint = await self._dcm().energy_in_keV.user_setpoint.get_value(), - readback = await self._dcm().energy_in_keV.user_readback.get_value() - ) + setpoint=await self._dcm().energy_in_keV.user_setpoint.get_value(), + readback=await self._dcm().energy_in_keV.user_readback.get_value(), + ) From 5417c8fde7df4042faa719fdd44c5e3ce8b58a06 Mon Sep 17 00:00:00 2001 From: eir17846 Date: Fri, 14 Nov 2025 17:29:23 +0000 Subject: [PATCH 03/10] remove lut object parameter for now --- src/dodal/beamlines/i09_1.py | 2 +- src/dodal/devices/i09_1_shared/energy.py | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/dodal/beamlines/i09_1.py b/src/dodal/beamlines/i09_1.py index 03fc5e666a..8ec0101bb0 100644 --- a/src/dodal/beamlines/i09_1.py +++ b/src/dodal/beamlines/i09_1.py @@ -77,7 +77,7 @@ def hu_id_energy() -> HardInsertionDeviceEnergy: return HardInsertionDeviceEnergy( undulator_order=harmonics(), undulator=undulator(), - lut=lut_dictionary(), + lut={}, # Placeholder, will be set later_ gap_to_energy_func=lambda x: x, # Placeholder, not used in this context energy_to_gap_func=calculate_gap_i09_hu, name="hu_id_energy", diff --git a/src/dodal/devices/i09_1_shared/energy.py b/src/dodal/devices/i09_1_shared/energy.py index 989e92f541..beede52908 100644 --- a/src/dodal/devices/i09_1_shared/energy.py +++ b/src/dodal/devices/i09_1_shared/energy.py @@ -1,5 +1,5 @@ from asyncio import gather -from collections.abc import Awaitable, Callable +from collections.abc import Callable from bluesky.protocols import Locatable, Location, Movable from numpy import ndarray @@ -29,7 +29,7 @@ def __init__( self, undulator_order: UndulatorOrder, undulator: UndulatorInMm, - lut: dict[int, ndarray] | Awaitable[dict[int, ndarray]], + lut: dict[int, ndarray], gap_to_energy_func: Callable[..., float], energy_to_gap_func: Callable[..., float], name: str = "", From 74e7f4bea3d3d81dc115bcb3e53052c0462ceca8 Mon Sep 17 00:00:00 2001 From: eir17846 Date: Mon, 17 Nov 2025 17:46:06 +0000 Subject: [PATCH 04/10] add energy_readback and tests --- src/dodal/devices/i09_1_shared/energy.py | 9 +- .../devices/i09_1_shared/test_hard_energy.py | 109 ++++++++++++++++++ 2 files changed, 116 insertions(+), 2 deletions(-) create mode 100644 tests/devices/i09_1_shared/test_hard_energy.py diff --git a/src/dodal/devices/i09_1_shared/energy.py b/src/dodal/devices/i09_1_shared/energy.py index beede52908..14b17dd88b 100644 --- a/src/dodal/devices/i09_1_shared/energy.py +++ b/src/dodal/devices/i09_1_shared/energy.py @@ -9,6 +9,7 @@ StandardReadable, StandardReadableFormat, derived_signal_rw, + soft_signal_rw, ) from dodal.devices.common_dcm import DoubleCrystalMonochromatorBase @@ -37,9 +38,12 @@ def __init__( self._lut = lut self.gap_to_energy_func = gap_to_energy_func self.energy_to_gap_func = energy_to_gap_func + self._undulator_order = Reference(undulator_order) + self._undulator = Reference(undulator) + + self.add_readables([undulator_order, undulator.current_gap]) with self.add_children_as_readables(StandardReadableFormat.HINTED_SIGNAL): - self._undulator_order = Reference(undulator_order) - self._undulator = Reference(undulator) + self.energy_demand = soft_signal_rw(float) self.energy = derived_signal_rw( raw_to_derived=self._read_energy, set_derived=self._set_energy, @@ -74,6 +78,7 @@ async def _set_energy(self, energy: float): @AsyncStatus.wrap async def set(self, value: float) -> None: + self.energy_demand.set(value) await self.energy.set(value) diff --git a/tests/devices/i09_1_shared/test_hard_energy.py b/tests/devices/i09_1_shared/test_hard_energy.py new file mode 100644 index 0000000000..5688db1ddf --- /dev/null +++ b/tests/devices/i09_1_shared/test_hard_energy.py @@ -0,0 +1,109 @@ +import pytest +from bluesky.plan_stubs import mv +from bluesky.run_engine import RunEngine +from ophyd_async.core import init_devices +from ophyd_async.testing import ( + assert_reading, + partial_reading, + set_mock_value, +) + +from dodal.devices.i09_1_shared import ( + HardInsertionDeviceEnergy, + calculate_gap_i09_hu, + get_hu_lut_as_dict, +) +from dodal.devices.undulator import UndulatorInMm, UndulatorOrder +from dodal.testing.setup import patch_all_motors +from tests.devices.i09_1_shared.test_data import TEST_HARD_UNDULATOR_LUT + + +@pytest.fixture +async def lut_dictionary() -> dict: + return await get_hu_lut_as_dict(TEST_HARD_UNDULATOR_LUT) + + +@pytest.fixture +def undulator_order() -> UndulatorOrder: + with init_devices(mock=True): + order = UndulatorOrder(name="undulator_order") + set_mock_value(order.value, 3) + return order + + +@pytest.fixture +async def undulator_in_mm() -> UndulatorInMm: + async with init_devices(mock=True): + undulator_mm = UndulatorInMm("UND-02") + patch_all_motors(undulator_mm) + return undulator_mm + + +@pytest.fixture +async def hu_id_energy( + undulator_order: UndulatorOrder, + undulator_in_mm: UndulatorInMm, + lut_dictionary: dict, +) -> HardInsertionDeviceEnergy: + hu = HardInsertionDeviceEnergy( + undulator_order=undulator_order, + undulator=undulator_in_mm, + lut=lut_dictionary, # Placeholder, will be set later_ + gap_to_energy_func=lambda gap, + look_up_table, + order: gap, # Placeholder, not used in this context + energy_to_gap_func=calculate_gap_i09_hu, + name="hu_id_energy", + ) + # patch_all_motors(hu) + return hu + + +async def test_reading_includes_read_fields(hu_id_energy: HardInsertionDeviceEnergy): + await assert_reading( + hu_id_energy, + { + "hu_id_energy-energy": partial_reading(0.0), + "hu_id_energy-energy_demand": partial_reading(0.0), + "undulator_mm-current_gap": partial_reading(0.0), + "undulator_order-value": partial_reading(3.0), + }, + ) + + +async def test_energy_demand_initialised_correctly( + hu_id_energy: HardInsertionDeviceEnergy, + run_engine: RunEngine, +): + value = 3.456 + run_engine(mv(hu_id_energy, value)) + assert await hu_id_energy.energy_demand.get_value() == pytest.approx( + value, abs=0.001 + ) + + +@pytest.mark.parametrize( + "energy_value, order_value, expected_gap", + [ + (2.13, 1, 12.814), + (2.78, 3, 6.053), + (6.24, 5, 7.956), + ], +) +async def test_set_energy_updates_gap( + run_engine: RunEngine, + hu_id_energy: HardInsertionDeviceEnergy, + energy_value: float, + order_value: int, + expected_gap: float, +): + await hu_id_energy._undulator_order().value.set(order_value) + assert await hu_id_energy._undulator_order().value.get_value() == order_value + run_engine(mv(hu_id_energy, energy_value)) + assert await hu_id_energy.energy_demand.get_value() == pytest.approx( + energy_value, abs=0.001 + ) + assert ( + await hu_id_energy._undulator().gap_motor.user_readback.get_value() + == pytest.approx(expected_gap, abs=0.001) + ) From 8a4853d9be0bdc2dd41082921cfa175fa0f0f938 Mon Sep 17 00:00:00 2001 From: eir17846 Date: Tue, 18 Nov 2025 12:28:29 +0000 Subject: [PATCH 05/10] fix readables in hardEnergy class --- tests/devices/i09_1_shared/test_hard_energy.py | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/tests/devices/i09_1_shared/test_hard_energy.py b/tests/devices/i09_1_shared/test_hard_energy.py index 5688db1ddf..11d7b39782 100644 --- a/tests/devices/i09_1_shared/test_hard_energy.py +++ b/tests/devices/i09_1_shared/test_hard_energy.py @@ -1,3 +1,5 @@ +import re + import pytest from bluesky.plan_stubs import mv from bluesky.run_engine import RunEngine @@ -71,6 +73,17 @@ async def test_reading_includes_read_fields(hu_id_energy: HardInsertionDeviceEne ) +async def test_set_energy_fails( + hu_id_energy: HardInsertionDeviceEnergy, +): + value = 100.5 + with pytest.raises( + ValueError, + match=re.escape(f"Requested energy {value} keV is out of range for harmonic 3"), + ): + await hu_id_energy.set(value) + + async def test_energy_demand_initialised_correctly( hu_id_energy: HardInsertionDeviceEnergy, run_engine: RunEngine, From ac54523fd3b0b1d59469da4b54c332467bb3967c Mon Sep 17 00:00:00 2001 From: eir17846 Date: Tue, 18 Nov 2025 14:21:07 +0000 Subject: [PATCH 06/10] add hard energy tests --- src/dodal/devices/i09_1_shared/energy.py | 6 +- .../i09_1_shared/hard_undulator_functions.py | 2 +- .../devices/i09_1_shared/test_hard_energy.py | 116 ++++++++++++++++-- 3 files changed, 113 insertions(+), 11 deletions(-) diff --git a/src/dodal/devices/i09_1_shared/energy.py b/src/dodal/devices/i09_1_shared/energy.py index 14b17dd88b..8ec3daeac6 100644 --- a/src/dodal/devices/i09_1_shared/energy.py +++ b/src/dodal/devices/i09_1_shared/energy.py @@ -93,9 +93,9 @@ def __init__( undulator_energy: HardInsertionDeviceEnergy, name: str = "", ) -> None: - with self.add_children_as_readables(): - self._dcm = Reference(dcm) - self._undulator_energy = Reference(undulator_energy) + self._dcm = Reference(dcm) + self._undulator_energy = Reference(undulator_energy) + self.add_readables([undulator_energy, dcm.energy_in_keV]) super().__init__(name=name) @AsyncStatus.wrap diff --git a/src/dodal/devices/i09_1_shared/hard_undulator_functions.py b/src/dodal/devices/i09_1_shared/hard_undulator_functions.py index ccd0f68378..f19492e1e9 100644 --- a/src/dodal/devices/i09_1_shared/hard_undulator_functions.py +++ b/src/dodal/devices/i09_1_shared/hard_undulator_functions.py @@ -26,7 +26,7 @@ async def get_hu_lut_as_dict(lut_path: str) -> dict: ) for i in range(_lookup_table.shape[0]): lut_dict[_lookup_table[i][0]] = _lookup_table[i] - LOGGER.debug(f"Loaded lookup table:\n {lut_dict}") + # LOGGER.debug(f"Loaded lookup table:\n {lut_dict}") return lut_dict diff --git a/tests/devices/i09_1_shared/test_hard_energy.py b/tests/devices/i09_1_shared/test_hard_energy.py index 11d7b39782..663fa15d03 100644 --- a/tests/devices/i09_1_shared/test_hard_energy.py +++ b/tests/devices/i09_1_shared/test_hard_energy.py @@ -10,7 +10,13 @@ set_mock_value, ) +from dodal.devices.common_dcm import ( + DoubleCrystalMonochromatorWithDSpacing, + PitchAndRollCrystal, + StationaryCrystal, +) from dodal.devices.i09_1_shared import ( + HardEnergy, HardInsertionDeviceEnergy, calculate_gap_i09_hu, get_hu_lut_as_dict, @@ -33,6 +39,16 @@ def undulator_order() -> UndulatorOrder: return order +@pytest.fixture +async def dcm() -> DoubleCrystalMonochromatorWithDSpacing: + async with init_devices(mock=True): + dcm = DoubleCrystalMonochromatorWithDSpacing( + "TEST-MO-DCM-01:", PitchAndRollCrystal, StationaryCrystal + ) + patch_all_motors(dcm) + return dcm + + @pytest.fixture async def undulator_in_mm() -> UndulatorInMm: async with init_devices(mock=True): @@ -61,7 +77,24 @@ async def hu_id_energy( return hu -async def test_reading_includes_read_fields(hu_id_energy: HardInsertionDeviceEnergy): +@pytest.fixture +async def hu_energy( + hu_id_energy: HardInsertionDeviceEnergy, + dcm: DoubleCrystalMonochromatorWithDSpacing, +) -> HardEnergy: + async with init_devices(mock=True): + hu_energy_device = HardEnergy( + dcm=dcm, + undulator_energy=hu_id_energy, + name="hu_energy", + ) + patch_all_motors(hu_energy_device) + return hu_energy_device + + +async def test_hu_id_energy_reading_includes_read_fields( + hu_id_energy: HardInsertionDeviceEnergy, +): await assert_reading( hu_id_energy, { @@ -73,7 +106,7 @@ async def test_reading_includes_read_fields(hu_id_energy: HardInsertionDeviceEne ) -async def test_set_energy_fails( +async def test_hu_id_energy_set_energy_fails( hu_id_energy: HardInsertionDeviceEnergy, ): value = 100.5 @@ -84,7 +117,7 @@ async def test_set_energy_fails( await hu_id_energy.set(value) -async def test_energy_demand_initialised_correctly( +async def test_hu_id_energy_energy_demand_initialised_correctly( hu_id_energy: HardInsertionDeviceEnergy, run_engine: RunEngine, ): @@ -98,12 +131,12 @@ async def test_energy_demand_initialised_correctly( @pytest.mark.parametrize( "energy_value, order_value, expected_gap", [ - (2.13, 1, 12.814), - (2.78, 3, 6.053), - (6.24, 5, 7.956), + (2.130, 1, 12.814), + (2.780, 3, 6.053), + (6.240, 5, 7.956), ], ) -async def test_set_energy_updates_gap( +async def test_hu_id_energy_set_energy_updates_gap( run_engine: RunEngine, hu_id_energy: HardInsertionDeviceEnergy, energy_value: float, @@ -120,3 +153,72 @@ async def test_set_energy_updates_gap( await hu_id_energy._undulator().gap_motor.user_readback.get_value() == pytest.approx(expected_gap, abs=0.001) ) + + +async def test_hu_energy_read_include_read_fields( + hu_energy: HardEnergy, +): + await assert_reading( + hu_energy, + { + "dcm-energy_in_keV": partial_reading(0.0), + "hu_id_energy-energy": partial_reading(0.0), + "hu_id_energy-energy_demand": partial_reading(0.0), + "undulator_mm-current_gap": partial_reading(0.0), + "undulator_order-value": partial_reading(3.0), + }, + ) + + +async def test_hu_energy_set_both_dcm_and_id_energy( + run_engine: RunEngine, + hu_energy: HardEnergy, +): + energy_value = 3.1415 + run_engine(mv(hu_energy, energy_value)) + assert ( + await hu_energy._undulator_energy().energy_demand.get_value() + == pytest.approx(energy_value, abs=0.00001) + ) + assert ( + await hu_energy._dcm().energy_in_keV.user_readback.get_value() + == pytest.approx(energy_value, abs=0.00001) + ) + + +@pytest.mark.parametrize( + "energy_value, order_value, expected_gap", + [ + (2.781, 3, 6.055), + (6.241, 5, 7.957), + ], +) +async def test_hu_energy_set_moves_gap( + run_engine: RunEngine, + hu_energy: HardEnergy, + energy_value: float, + order_value: int, + expected_gap: float, +): + await hu_energy._undulator_energy()._undulator_order().value.set(order_value) + assert ( + await hu_energy._undulator_energy()._undulator_order().value.get_value() + == pytest.approx(order_value, abs=0.00001) + ) + run_engine(mv(hu_energy, energy_value)) + assert ( + await hu_energy._undulator_energy() + ._undulator() + .gap_motor.user_readback.get_value() + == pytest.approx(expected_gap, abs=0.001) + ) + + +async def test_hu_energy_locate( + run_engine: RunEngine, + hu_energy: HardEnergy, +): + energy_value = 3.1415 + run_engine(mv(hu_energy, energy_value)) + located_position = await hu_energy.locate() + assert located_position == {"readback": energy_value, "setpoint": energy_value} From b7ba3a5b35be7a4219ad0e2c00cb9db62d002ddb Mon Sep 17 00:00:00 2001 From: eir17846 Date: Tue, 18 Nov 2025 15:12:36 +0000 Subject: [PATCH 07/10] remove lut from i09_1 --- src/dodal/beamlines/i09_1.py | 10 +--------- 1 file changed, 1 insertion(+), 9 deletions(-) diff --git a/src/dodal/beamlines/i09_1.py b/src/dodal/beamlines/i09_1.py index 8ec0101bb0..9a8d0f516c 100644 --- a/src/dodal/beamlines/i09_1.py +++ b/src/dodal/beamlines/i09_1.py @@ -13,7 +13,6 @@ from dodal.devices.i09_1_shared.energy import HardEnergy, HardInsertionDeviceEnergy from dodal.devices.i09_1_shared.hard_undulator_functions import ( calculate_gap_i09_hu, - get_hu_lut_as_dict, ) from dodal.devices.synchrotron import Synchrotron from dodal.devices.undulator import UndulatorInMm, UndulatorOrder @@ -65,20 +64,13 @@ def harmonics() -> UndulatorOrder: return UndulatorOrder(name="harmonics") -HU_LUT_PATH = "/dls_sw/i09/software/gda/workspace_git/gda-diamond.git/configurations/i09-1-shared/lookupTables/IIDCalibrationTable.txt" - - -async def lut_dictionary() -> dict: - return await get_hu_lut_as_dict(HU_LUT_PATH) - - @device_factory() def hu_id_energy() -> HardInsertionDeviceEnergy: return HardInsertionDeviceEnergy( undulator_order=harmonics(), undulator=undulator(), lut={}, # Placeholder, will be set later_ - gap_to_energy_func=lambda x: x, # Placeholder, not used in this context + gap_to_energy_func=lambda x: x, # Placeholder, need https://github.com/DiamondLightSource/dodal/pull/1712 merged first energy_to_gap_func=calculate_gap_i09_hu, name="hu_id_energy", ) From c94576536e0f7c17021502cfcee27e3574c5b529 Mon Sep 17 00:00:00 2001 From: eir17846 Date: Tue, 18 Nov 2025 17:17:30 +0000 Subject: [PATCH 08/10] rename module --- src/dodal/beamlines/i09_1.py | 2 +- src/dodal/devices/i09_1_shared/__init__.py | 2 +- src/dodal/devices/i09_1_shared/{energy.py => hard_energy.py} | 0 3 files changed, 2 insertions(+), 2 deletions(-) rename src/dodal/devices/i09_1_shared/{energy.py => hard_energy.py} (100%) diff --git a/src/dodal/beamlines/i09_1.py b/src/dodal/beamlines/i09_1.py index 9a8d0f516c..e43ee57f1f 100644 --- a/src/dodal/beamlines/i09_1.py +++ b/src/dodal/beamlines/i09_1.py @@ -10,7 +10,7 @@ from dodal.devices.electron_analyser import EnergySource from dodal.devices.electron_analyser.specs import SpecsDetector from dodal.devices.i09_1 import LensMode, PsuMode -from dodal.devices.i09_1_shared.energy import HardEnergy, HardInsertionDeviceEnergy +from dodal.devices.i09_1_shared.hard_energy import HardEnergy, HardInsertionDeviceEnergy from dodal.devices.i09_1_shared.hard_undulator_functions import ( calculate_gap_i09_hu, ) diff --git a/src/dodal/devices/i09_1_shared/__init__.py b/src/dodal/devices/i09_1_shared/__init__.py index 4e33fb2787..9eecfd5f3a 100644 --- a/src/dodal/devices/i09_1_shared/__init__.py +++ b/src/dodal/devices/i09_1_shared/__init__.py @@ -1,4 +1,4 @@ -from .energy import HardEnergy, HardInsertionDeviceEnergy +from .hard_energy import HardEnergy, HardInsertionDeviceEnergy from .hard_undulator_functions import calculate_gap_i09_hu, get_hu_lut_as_dict __all__ = [ diff --git a/src/dodal/devices/i09_1_shared/energy.py b/src/dodal/devices/i09_1_shared/hard_energy.py similarity index 100% rename from src/dodal/devices/i09_1_shared/energy.py rename to src/dodal/devices/i09_1_shared/hard_energy.py From 43a05f47c0e166c2f0959bb3f3dd21950df3d124 Mon Sep 17 00:00:00 2001 From: eir17846 Date: Wed, 19 Nov 2025 17:36:52 +0000 Subject: [PATCH 09/10] fix typo --- src/dodal/devices/i09_1_shared/__init__.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/dodal/devices/i09_1_shared/__init__.py b/src/dodal/devices/i09_1_shared/__init__.py index 0edc6ad0f8..174e219ecb 100644 --- a/src/dodal/devices/i09_1_shared/__init__.py +++ b/src/dodal/devices/i09_1_shared/__init__.py @@ -8,8 +8,7 @@ __all__ = [ "calculate_gap_i09_hu", "get_hu_lut_as_dict", - "calculate_energy_i09_hu" + "calculate_energy_i09_hu", "HardInsertionDeviceEnergy", "HardEnergy", ] - From c81366ac78bf60c3e7fd874e8eaa26e42bdbf857 Mon Sep 17 00:00:00 2001 From: eir17846 Date: Wed, 19 Nov 2025 17:49:42 +0000 Subject: [PATCH 10/10] and reverse function for energy from gap calculation and update tests --- src/dodal/beamlines/i09_1.py | 3 +- .../devices/i09_1_shared/test_hard_energy.py | 29 +++++++++++++------ 2 files changed, 22 insertions(+), 10 deletions(-) diff --git a/src/dodal/beamlines/i09_1.py b/src/dodal/beamlines/i09_1.py index e43ee57f1f..52fc02dfeb 100644 --- a/src/dodal/beamlines/i09_1.py +++ b/src/dodal/beamlines/i09_1.py @@ -12,6 +12,7 @@ from dodal.devices.i09_1 import LensMode, PsuMode from dodal.devices.i09_1_shared.hard_energy import HardEnergy, HardInsertionDeviceEnergy from dodal.devices.i09_1_shared.hard_undulator_functions import ( + calculate_energy_i09_hu, calculate_gap_i09_hu, ) from dodal.devices.synchrotron import Synchrotron @@ -70,7 +71,7 @@ def hu_id_energy() -> HardInsertionDeviceEnergy: undulator_order=harmonics(), undulator=undulator(), lut={}, # Placeholder, will be set later_ - gap_to_energy_func=lambda x: x, # Placeholder, need https://github.com/DiamondLightSource/dodal/pull/1712 merged first + gap_to_energy_func=calculate_energy_i09_hu, energy_to_gap_func=calculate_gap_i09_hu, name="hu_id_energy", ) diff --git a/tests/devices/i09_1_shared/test_hard_energy.py b/tests/devices/i09_1_shared/test_hard_energy.py index 663fa15d03..b0155f8a92 100644 --- a/tests/devices/i09_1_shared/test_hard_energy.py +++ b/tests/devices/i09_1_shared/test_hard_energy.py @@ -18,6 +18,7 @@ from dodal.devices.i09_1_shared import ( HardEnergy, HardInsertionDeviceEnergy, + calculate_energy_i09_hu, calculate_gap_i09_hu, get_hu_lut_as_dict, ) @@ -66,10 +67,8 @@ async def hu_id_energy( hu = HardInsertionDeviceEnergy( undulator_order=undulator_order, undulator=undulator_in_mm, - lut=lut_dictionary, # Placeholder, will be set later_ - gap_to_energy_func=lambda gap, - look_up_table, - order: gap, # Placeholder, not used in this context + lut=lut_dictionary, + gap_to_energy_func=calculate_energy_i09_hu, energy_to_gap_func=calculate_gap_i09_hu, name="hu_id_energy", ) @@ -93,13 +92,18 @@ async def hu_energy( async def test_hu_id_energy_reading_includes_read_fields( + run_engine: RunEngine, hu_id_energy: HardInsertionDeviceEnergy, ): + # need to set correct order to avoid errors in reading + await hu_id_energy._undulator_order().value.set(3) + energy_value = 3.1416 + run_engine(mv(hu_id_energy, energy_value)) await assert_reading( hu_id_energy, { - "hu_id_energy-energy": partial_reading(0.0), - "hu_id_energy-energy_demand": partial_reading(0.0), + "hu_id_energy-energy": partial_reading(energy_value), + "hu_id_energy-energy_demand": partial_reading(energy_value), "undulator_mm-current_gap": partial_reading(0.0), "undulator_order-value": partial_reading(3.0), }, @@ -149,6 +153,9 @@ async def test_hu_id_energy_set_energy_updates_gap( assert await hu_id_energy.energy_demand.get_value() == pytest.approx( energy_value, abs=0.001 ) + assert await hu_id_energy.energy.get_value() == pytest.approx( + energy_value, abs=0.001 + ) assert ( await hu_id_energy._undulator().gap_motor.user_readback.get_value() == pytest.approx(expected_gap, abs=0.001) @@ -157,13 +164,17 @@ async def test_hu_id_energy_set_energy_updates_gap( async def test_hu_energy_read_include_read_fields( hu_energy: HardEnergy, + run_engine: RunEngine, ): + await hu_energy._undulator_energy()._undulator_order().value.set(3) + energy_value = 3.1416 + run_engine(mv(hu_energy, energy_value)) await assert_reading( hu_energy, { - "dcm-energy_in_keV": partial_reading(0.0), - "hu_id_energy-energy": partial_reading(0.0), - "hu_id_energy-energy_demand": partial_reading(0.0), + "dcm-energy_in_keV": partial_reading(energy_value), + "hu_id_energy-energy": partial_reading(energy_value), + "hu_id_energy-energy_demand": partial_reading(energy_value), "undulator_mm-current_gap": partial_reading(0.0), "undulator_order-value": partial_reading(3.0), },