diff --git a/src/dodal/beamlines/i03.py b/src/dodal/beamlines/i03.py index 1c52a64710..55757d3851 100644 --- a/src/dodal/beamlines/i03.py +++ b/src/dodal/beamlines/i03.py @@ -6,7 +6,7 @@ from dodal.devices.aperturescatterguard import AperturePositions, ApertureScatterguard from dodal.devices.attenuator import Attenuator from dodal.devices.backlight import Backlight -from dodal.devices.DCM import DCM +from dodal.devices.dcm import DCM from dodal.devices.detector import DetectorParams from dodal.devices.detector.detector_motion import DetectorMotion from dodal.devices.eiger import EigerDetector @@ -50,10 +50,9 @@ def dcm(wait_for_connection: bool = True, fake_with_ophyd_sim: bool = False) -> return device_instantiation( DCM, "dcm", - "", + "-MO-DCM-01:", wait_for_connection, fake_with_ophyd_sim, - daq_configuration_path=DAQ_CONFIGURATION_PATH, ) @@ -321,6 +320,8 @@ def undulator_dcm( fake=fake_with_ophyd_sim, undulator=undulator(wait_for_connection, fake_with_ophyd_sim), dcm=dcm(wait_for_connection, fake_with_ophyd_sim), + daq_configuration_path=DAQ_CONFIGURATION_PATH, + id_gap_lookup_table_path="/dls_sw/i03/software/daq_configuration/lookup/BeamLine_Undulator_toGap.txt", ) diff --git a/src/dodal/beamlines/i04.py b/src/dodal/beamlines/i04.py index 8b8e6b1edf..dafa8f655b 100644 --- a/src/dodal/beamlines/i04.py +++ b/src/dodal/beamlines/i04.py @@ -4,7 +4,7 @@ from dodal.devices.attenuator import Attenuator from dodal.devices.backlight import Backlight from dodal.devices.beamstop import BeamStop -from dodal.devices.DCM import DCM +from dodal.devices.dcm import DCM from dodal.devices.detector import DetectorParams from dodal.devices.detector.detector_motion import DetectorMotion from dodal.devices.eiger import EigerDetector @@ -188,10 +188,9 @@ def dcm(wait_for_connection: bool = True, fake_with_ophyd_sim: bool = False) -> return device_instantiation( DCM, "dcm", - "", + "-MO-DCM-01:", wait_for_connection, fake_with_ophyd_sim, - daq_configuration_path=DAQ_CONFIGURATION_PATH, ) diff --git a/src/dodal/devices/DCM.py b/src/dodal/devices/DCM.py deleted file mode 100644 index 41b26cbab4..0000000000 --- a/src/dodal/devices/DCM.py +++ /dev/null @@ -1,46 +0,0 @@ -from ophyd import Component as Cpt -from ophyd import Device, EpicsMotor, EpicsSignalRO, Kind - -from dodal.beamlines.beamline_parameters import get_beamline_parameters - - -class DCM(Device): - def __init__(self, *args, daq_configuration_path: str, **kwargs): - super().__init__(*args, **kwargs) - self.dcm_pitch_converter_lookup_table_path = ( - daq_configuration_path + "/lookup/BeamLineEnergy_DCM_Pitch_converter.txt" - ) - self.dcm_roll_converter_lookup_table_path = ( - daq_configuration_path + "/lookup/BeamLineEnergy_DCM_Roll_converter.txt" - ) - # I03 configures the DCM Perp as a side effect of applying this fixed value to the DCM Offset after an energy change - # Nb this parameter is misleadingly named to confuse you - self.fixed_offset_mm = get_beamline_parameters( - daq_configuration_path + "/domain/beamlineParameters" - )["DCM_Perp_Offset_FIXED"] - - """ - A double crystal monochromator (DCM), used to select the energy of the beam. - - perp describes the gap between the 2 DCM crystals which has to change as you alter - the angle to select the requested energy. - - offset ensures that the beam exits the DCM at the same point, regardless of energy. - """ - - bragg_in_degrees = Cpt(EpicsMotor, "-MO-DCM-01:BRAGG") - roll_in_mrad = Cpt(EpicsMotor, "-MO-DCM-01:ROLL") - offset_in_mm = Cpt(EpicsMotor, "-MO-DCM-01:OFFSET") - perp_in_mm = Cpt(EpicsMotor, "-MO-DCM-01:PERP") - energy_in_kev = Cpt(EpicsMotor, "-MO-DCM-01:ENERGY", kind=Kind.hinted) - pitch_in_mrad = Cpt(EpicsMotor, "-MO-DCM-01:PITCH") - wavelength = Cpt(EpicsMotor, "-MO-DCM-01:WAVELENGTH") - - # temperatures - xtal1_temp = Cpt(EpicsSignalRO, "-MO-DCM-01:TEMP1") - xtal2_temp = Cpt(EpicsSignalRO, "-MO-DCM-01:TEMP2") - xtal1_heater_temp = Cpt(EpicsSignalRO, "-MO-DCM-01:TEMP3") - xtal2_heater_temp = Cpt(EpicsSignalRO, "-MO-DCM-01:TEMP4") - backplate_temp = Cpt(EpicsSignalRO, "-MO-DCM-01:TEMP5") - perp_temp = Cpt(EpicsSignalRO, "-MO-DCM-01:TEMP6") - perp_sub_assembly_temp = Cpt(EpicsSignalRO, "-MO-DCM-01:TEMP7") diff --git a/src/dodal/devices/dcm.py b/src/dodal/devices/dcm.py new file mode 100644 index 0000000000..9b6c3bed71 --- /dev/null +++ b/src/dodal/devices/dcm.py @@ -0,0 +1,39 @@ +from ophyd_async.core import StandardReadable +from ophyd_async.epics.motion import Motor +from ophyd_async.epics.signal import epics_signal_r + + +class DCM(StandardReadable): + """ + A double crystal monochromator (DCM), used to select the energy of the beam. + + perp describes the gap between the 2 DCM crystals which has to change as you alter + the angle to select the requested energy. + + offset ensures that the beam exits the DCM at the same point, regardless of energy. + """ + + def __init__( + self, + prefix: str, + name: str = "", + ) -> None: + with self.add_children_as_readables(): + self.bragg_in_degrees = Motor(prefix + "BRAGG") + self.roll_in_mrad = Motor(prefix + "ROLL") + self.offset_in_mm = Motor(prefix + "OFFSET") + self.perp_in_mm = Motor(prefix + "PERP") + self.energy_in_kev = Motor(prefix + "ENERGY") + self.pitch_in_mrad = Motor(prefix + "PITCH") + self.wavelength = Motor(prefix + "WAVELENGTH") + + # temperatures + self.xtal1_temp = epics_signal_r(float, prefix + "TEMP1") + self.xtal2_temp = epics_signal_r(float, prefix + "TEMP2") + self.xtal1_heater_temp = epics_signal_r(float, prefix + "TEMP3") + self.xtal2_heater_temp = epics_signal_r(float, prefix + "TEMP4") + self.backplate_temp = epics_signal_r(float, prefix + "TEMP5") + self.perp_temp = epics_signal_r(float, prefix + "TEMP6") + self.perp_sub_assembly_temp = epics_signal_r(float, prefix + "TEMP7") + + super().__init__(name) diff --git a/src/dodal/devices/undulator.py b/src/dodal/devices/undulator.py index 974d6a4492..75edec779c 100644 --- a/src/dodal/devices/undulator.py +++ b/src/dodal/devices/undulator.py @@ -1,28 +1,34 @@ from enum import Enum -from ophyd import Component, Device, EpicsMotor, EpicsSignalRO +from ophyd_async.core import StandardReadable +from ophyd_async.epics.motion import Motor +from ophyd_async.epics.signal import epics_signal_r # The acceptable difference, in mm, between the undulator gap and the DCM # energy, when the latter is converted to mm using lookup tables UNDULATOR_DISCREPANCY_THRESHOLD_MM = 2e-3 -class UndulatorGapAccess(Enum): +class UndulatorGapAccess(str, Enum): ENABLED = "ENABLED" DISABLED = "DISABLED" -class Undulator(Device): - gap_motor = Component(EpicsMotor, "BLGAPMTR") - current_gap = Component(EpicsSignalRO, "CURRGAPD") - gap_access = Component(EpicsSignalRO, "IDBLENA") - gap_discrepancy_tolerance_mm: float = UNDULATOR_DISCREPANCY_THRESHOLD_MM +class Undulator(StandardReadable): + """ + An Undulator-type insertion device, used to control photon emission at a given + beam energy. + """ def __init__( self, - lookup_table_path="/dls_sw/i03/software/daq_configuration/lookup/BeamLine_Undulator_toGap.txt", - *args, - **kwargs, - ): - super().__init__(*args, **kwargs) - self.lookup_table_path = lookup_table_path + prefix: str, + name: str = "", + ) -> None: + with self.add_children_as_readables(): + self.gap_motor = Motor(prefix + "BLGAPMTR") + self.current_gap = epics_signal_r(float, prefix + "CURRGAPD") + self.gap_access = epics_signal_r(UndulatorGapAccess, prefix + "IDBLENA") + self.gap_discrepancy_tolerance_mm: float = UNDULATOR_DISCREPANCY_THRESHOLD_MM + + super().__init__(name) diff --git a/src/dodal/devices/undulator_dcm.py b/src/dodal/devices/undulator_dcm.py index 6093acf705..6c568abe50 100644 --- a/src/dodal/devices/undulator_dcm.py +++ b/src/dodal/devices/undulator_dcm.py @@ -1,14 +1,19 @@ +import asyncio + import numpy as np -from numpy import argmin, loadtxt, ndarray -from ophyd import Component, Device, Signal -from ophyd.status import Status +from bluesky.protocols import Movable +from numpy import argmin, ndarray +from ophyd_async.core import AsyncStatus, StandardReadable -from dodal.devices.DCM import DCM -from dodal.devices.undulator import Undulator, UndulatorGapAccess +from dodal.beamlines.beamline_parameters import get_beamline_parameters from dodal.log import LOGGER -ENERGY_TIMEOUT_S = 30 -STATUS_TIMEOUT_S = 10 +from .dcm import DCM +from .undulator import Undulator, UndulatorGapAccess +from .util.lookup_tables import energy_distance_table + +ENERGY_TIMEOUT_S: float = 30.0 +STATUS_TIMEOUT_S: float = 10.0 # Enable to allow testing when the beamline is down, do not change in production! TEST_MODE = False @@ -18,10 +23,6 @@ class AccessError(Exception): pass -def _get_energy_distance_table(lookup_table_path: str) -> ndarray: - return loadtxt(lookup_table_path, comments=["#", "Units"]) - - def _get_closest_gap_for_energy( dcm_energy_ev: float, energy_to_distance_table: ndarray ) -> float: @@ -30,58 +31,105 @@ def _get_closest_gap_for_energy( return table[1][idx] -class UndulatorDCM(Device): +class UndulatorDCM(StandardReadable, Movable): """ - Composite device to handle changing beamline energies + Composite device to handle changing beamline energies, wraps the Undulator and the + DCM. The DCM has a motor which controls the beam energy, when it moves, the + Undulator gap may also have to change to enable emission at the new energy. + The relationship between the two motor motor positions is provided via a lookup + table. + + Calling unulator_dcm.set(energy) will move the DCM motor, perform a table lookup + and move the Undulator gap motor if needed. So the set method can be thought of as + a comprehensive way to set beam energy. """ - class EnergySignal(Signal): - parent: "UndulatorDCM" - - def set(self, value, *, timeout=None, settle_time=None, **kwargs) -> Status: - energy_kev = value - access_level = self.parent.undulator.gap_access.get(as_string=True) - if access_level == UndulatorGapAccess.DISABLED.value and not TEST_MODE: - raise AccessError( - "Undulator gap access is disabled. Contact Control Room" - ) - - # Get 2d np.array converting energies to undulator gap distance, from lookup table - energy_to_distance_table = _get_energy_distance_table( - self.parent.undulator.lookup_table_path - ) - LOGGER.info(f"Setting DCM energy to {energy_kev:.2f} kev") + def __init__( + self, + undulator: Undulator, + dcm: DCM, + id_gap_lookup_table_path: str, + daq_configuration_path: str, + prefix: str = "", + name: str = "", + ): + super().__init__(name) + + # Attributes are set after super call so they are not renamed to + # -undulator, etc. + self.undulator = undulator + self.dcm = dcm - status = self.parent.dcm.energy_in_kev.move( - energy_kev, timeout=ENERGY_TIMEOUT_S + # These attributes are just used by hyperion for lookup purposes + self.id_gap_lookup_table_path = id_gap_lookup_table_path + self.dcm_pitch_converter_lookup_table_path = ( + daq_configuration_path + "/lookup/BeamLineEnergy_DCM_Pitch_converter.txt" + ) + self.dcm_roll_converter_lookup_table_path = ( + daq_configuration_path + "/lookup/BeamLineEnergy_DCM_Roll_converter.txt" + ) + # I03 configures the DCM Perp as a side effect of applying this fixed value to the DCM Offset after an energy change + # Nb this parameter is misleadingly named to confuse you + self.dcm_fixed_offset_mm = get_beamline_parameters( + daq_configuration_path + "/domain/beamlineParameters" + )["DCM_Perp_Offset_FIXED"] + + def set(self, value: float) -> AsyncStatus: + async def _set(): + await asyncio.gather( + self._set_dcm_energy(value), + self._set_undulator_gap_if_required(value), ) - # Use the lookup table to get the undulator gap associated with this dcm energy - gap_to_match_dcm_energy = _get_closest_gap_for_energy( - energy_kev * 1000, energy_to_distance_table + return AsyncStatus(_set()) + + async def _set_dcm_energy(self, energy_kev: float) -> None: + access_level = await self.undulator.gap_access.get_value() + if access_level is UndulatorGapAccess.DISABLED and not TEST_MODE: + raise AccessError("Undulator gap access is disabled. Contact Control Room") + + await self.dcm.energy_in_kev.set( + energy_kev, + timeout=ENERGY_TIMEOUT_S, + ) + + async def _set_undulator_gap_if_required(self, energy_kev: float) -> None: + LOGGER.info(f"Setting DCM energy to {energy_kev:.2f} kev") + gap_to_match_dcm_energy = await self._gap_to_match_dcm_energy(energy_kev) + + # Check if undulator gap is close enough to the value from the DCM + current_gap = await self.undulator.current_gap.get_value() + if ( + abs(gap_to_match_dcm_energy - current_gap) + > self.undulator.gap_discrepancy_tolerance_mm + ): + LOGGER.info( + f"Undulator gap mismatch. {abs(gap_to_match_dcm_energy-current_gap):.3f}mm is outside tolerance.\ + Moving gap to nominal value, {gap_to_match_dcm_energy:.3f}mm" ) - - # Check if undulator gap is close enough to the value from the DCM - current_gap = self.parent.undulator.current_gap.get() - - if ( - abs(gap_to_match_dcm_energy - current_gap) - > self.parent.undulator.gap_discrepancy_tolerance_mm - ): - LOGGER.info( - f"Undulator gap mismatch. {abs(gap_to_match_dcm_energy-current_gap):.3f}mm is outside tolerance.\ - Moving gap to nominal value, {gap_to_match_dcm_energy:.3f}mm" + if not TEST_MODE: + # Only move if the gap is sufficiently different to the value from the + # DCM lookup table AND we're not in TEST_MODE + await self.undulator.gap_motor.set( + gap_to_match_dcm_energy, + timeout=STATUS_TIMEOUT_S, ) - if not TEST_MODE: - status &= self.parent.undulator.gap_motor.move( - gap_to_match_dcm_energy, timeout=STATUS_TIMEOUT_S - ) - - return status - - energy_kev = Component(EnergySignal) + else: + LOGGER.debug("In test mode, not moving ID gap") + else: + LOGGER.debug( + "Gap is already in the correct place for the new energy value " + f"{energy_kev}, no need to ask it to move" + ) - def __init__(self, undulator: Undulator, dcm: DCM, *args, **kwargs): - super().__init__(*args, **kwargs) - self.undulator = undulator - self.dcm = dcm + async def _gap_to_match_dcm_energy(self, energy_kev: float) -> float: + # Get 2d np.array converting energies to undulator gap distance, from lookup table + energy_to_distance_table = await energy_distance_table( + self.id_gap_lookup_table_path + ) + + # Use the lookup table to get the undulator gap associated with this dcm energy + return _get_closest_gap_for_energy( + energy_kev * 1000, + energy_to_distance_table, + ) diff --git a/src/dodal/devices/util/adjuster_plans.py b/src/dodal/devices/util/adjuster_plans.py index 9c7cc69826..3c907fddac 100644 --- a/src/dodal/devices/util/adjuster_plans.py +++ b/src/dodal/devices/util/adjuster_plans.py @@ -8,12 +8,13 @@ from bluesky import plan_stubs as bps from bluesky.run_engine import Msg from ophyd.epics_motor import EpicsMotor +from ophyd_async.epics.motion import Motor from dodal.log import LOGGER def lookup_table_adjuster( - lookup_table: Callable[[float], float], output_device: EpicsMotor, input + lookup_table: Callable[[float], float], output_device: EpicsMotor | Motor, input ): """Returns a callable that adjusts a value according to a lookup table""" diff --git a/src/dodal/devices/util/lookup_tables.py b/src/dodal/devices/util/lookup_tables.py index 0e1b74475d..c7da7409c0 100644 --- a/src/dodal/devices/util/lookup_tables.py +++ b/src/dodal/devices/util/lookup_tables.py @@ -4,14 +4,35 @@ """ from collections.abc import Sequence +from io import StringIO from typing import Callable +import aiofiles import numpy as np from numpy import interp, loadtxt from dodal.log import LOGGER +async def energy_distance_table(lookup_table_path: str) -> np.ndarray: + """ + Returns a numpy formatted lookup table for required positions of an ID gap to + provide emission at a given beam energy. + + Args: + lookup_table_path: Path to lookup table + + Returns: + ndarray: Lookup table + """ + + # Slight cheat to make the file IO async, numpy doesn't do any real IO now, just + # decodes the text + async with aiofiles.open(lookup_table_path, "r") as stream: + raw_table = await stream.read() + return loadtxt(StringIO(raw_table), comments=["#", "Units"]) + + def linear_interpolation_lut(filename: str) -> Callable[[float], float]: """Returns a callable that converts values by linear interpolation of lookup table values""" LOGGER.info(f"Using lookup table {filename}") diff --git a/tests/beamlines/unit_tests/test_device_instantiation.py b/tests/beamlines/unit_tests/test_device_instantiation.py index 05d3458544..d7b3155d52 100644 --- a/tests/beamlines/unit_tests/test_device_instantiation.py +++ b/tests/beamlines/unit_tests/test_device_instantiation.py @@ -22,7 +22,10 @@ def test_device_creation(RE, module_and_devices_for_beamline): """ module, devices = module_and_devices_for_beamline for device_name, device in devices.items(): - assert device_name in beamline_utils.ACTIVE_DEVICES + assert device_name in beamline_utils.ACTIVE_DEVICES, ( + f"No device named {device_name} was created, devices " + f"are {beamline_utils.ACTIVE_DEVICES.keys()}" + ) assert follows_bluesky_protocols(device) assert len(beamline_utils.ACTIVE_DEVICES) == len(devices) diff --git a/tests/devices/system_tests/test_undulator_system.py b/tests/devices/system_tests/test_undulator_system.py index 7fcc1434a7..10cd1cd750 100644 --- a/tests/devices/system_tests/test_undulator_system.py +++ b/tests/devices/system_tests/test_undulator_system.py @@ -1,16 +1,12 @@ import pytest +from ophyd_async.core import DeviceCollector from dodal.devices.undulator import Undulator SIM_INSERTION_PREFIX = "SR03S" -@pytest.fixture -def undulator(): - undulator = Undulator(f"{SIM_INSERTION_PREFIX}-MO-SERVC-01:", name="undulator") - return undulator - - @pytest.mark.s03 -def test_undulator_connects(undulator): - undulator.wait_for_connection() +def test_undulator_connects(): + with DeviceCollector(): + undulator = Undulator(f"{SIM_INSERTION_PREFIX}-MO-SERVC-01:") # noqa: F841 diff --git a/tests/devices/unit_tests/test_dcm.py b/tests/devices/unit_tests/test_dcm.py new file mode 100644 index 0000000000..c5111b0e40 --- /dev/null +++ b/tests/devices/unit_tests/test_dcm.py @@ -0,0 +1,33 @@ +import pytest +from ophyd_async.core import ( + DeviceCollector, +) + +from dodal.devices.dcm import DCM + + +@pytest.fixture +async def dcm() -> DCM: + async with DeviceCollector(sim=True): + dcm = DCM("DCM-01", name="dcm") + return dcm + + +@pytest.mark.parametrize( + "key", + [ + "dcm-backplate_temp", + "dcm-bragg_in_degrees", + "dcm-energy_in_kev", + "dcm-offset_in_mm", + ], +) +async def test_read_and_describe_includes( + dcm: DCM, + key: str, +): + description = await dcm.describe() + reading = await dcm.read() + + assert key in description + assert key in reading diff --git a/tests/devices/unit_tests/test_undulator.py b/tests/devices/unit_tests/test_undulator.py new file mode 100644 index 0000000000..0ff4645792 --- /dev/null +++ b/tests/devices/unit_tests/test_undulator.py @@ -0,0 +1,32 @@ +import pytest +from ophyd_async.core import ( + DeviceCollector, +) + +from dodal.devices.undulator import Undulator + + +@pytest.fixture +async def undulator() -> Undulator: + async with DeviceCollector(sim=True): + undulator = Undulator("UND-01", name="undulator") + return undulator + + +@pytest.mark.parametrize( + "key", + [ + "undulator-gap_motor", + "undulator-current_gap", + "undulator-gap_access", + ], +) +async def test_read_and_describe_includes( + undulator: Undulator, + key: str, +): + description = await undulator.describe() + reading = await undulator.read() + + assert key in description + assert key in reading diff --git a/tests/devices/unit_tests/test_undulator_dcm.py b/tests/devices/unit_tests/test_undulator_dcm.py index 69679936d4..14550d0e46 100644 --- a/tests/devices/unit_tests/test_undulator_dcm.py +++ b/tests/devices/unit_tests/test_undulator_dcm.py @@ -1,50 +1,66 @@ +import asyncio from unittest.mock import MagicMock, patch import numpy as np import pytest -from ophyd.sim import make_fake_device -from ophyd.status import Status +from ophyd_async.core import ( + AsyncStatus, + DeviceCollector, + set_sim_value, +) -from dodal.devices.DCM import DCM +from dodal.devices.dcm import DCM from dodal.devices.undulator import Undulator, UndulatorGapAccess from dodal.devices.undulator_dcm import ( AccessError, UndulatorDCM, _get_closest_gap_for_energy, - _get_energy_distance_table, ) from ...conftest import MOCK_DAQ_CONFIG_PATH +ID_GAP_LOOKUP_TABLE_PATH: str = ( + "./tests/devices/unit_tests/test_beamline_undulator_to_gap_lookup_table.txt" +) + @pytest.fixture -def fake_undulator_dcm() -> UndulatorDCM: - undulator: Undulator = make_fake_device(Undulator)( - name="undulator", - lookup_table_path="./tests/devices/unit_tests/test_beamline_undulator_to_gap_lookup_table.txt", - ) - dcm: DCM = make_fake_device(DCM)( - name="dcm", daq_configuration_path=MOCK_DAQ_CONFIG_PATH +async def fake_undulator_dcm() -> UndulatorDCM: + async with DeviceCollector(sim=True): + undulator = Undulator("UND-01", name="undulator") + dcm = DCM("DCM-01", name="dcm") + undulator_dcm = UndulatorDCM( + undulator, + dcm, + id_gap_lookup_table_path=ID_GAP_LOOKUP_TABLE_PATH, + daq_configuration_path=MOCK_DAQ_CONFIG_PATH, + name="undulator_dcm", + ) + return undulator_dcm + + +def test_lookup_table_paths_passed(fake_undulator_dcm: UndulatorDCM): + assert fake_undulator_dcm.id_gap_lookup_table_path == ID_GAP_LOOKUP_TABLE_PATH + assert ( + fake_undulator_dcm.dcm_pitch_converter_lookup_table_path + == MOCK_DAQ_CONFIG_PATH + "/lookup/BeamLineEnergy_DCM_Pitch_converter.txt" ) - undulator_dcm: UndulatorDCM = make_fake_device(UndulatorDCM)( - undulator, dcm, name="undulator_dcm" + assert ( + fake_undulator_dcm.dcm_roll_converter_lookup_table_path + == MOCK_DAQ_CONFIG_PATH + "/lookup/BeamLineEnergy_DCM_Roll_converter.txt" ) - return undulator_dcm -def test_when_gap_access_is_disabled_set_energy_then_error_is_raised( +async def test_fixed_offset_decoded(fake_undulator_dcm: UndulatorDCM): + assert fake_undulator_dcm.dcm_fixed_offset_mm == 25.6 + + +async def test_when_gap_access_is_disabled_set_energy_then_error_is_raised( fake_undulator_dcm: UndulatorDCM, ): - fake_undulator_dcm.undulator.gap_access.sim_put(UndulatorGapAccess.DISABLED.value) # type: ignore + set_sim_value(fake_undulator_dcm.undulator.gap_access, UndulatorGapAccess.DISABLED) with pytest.raises(AccessError): - fake_undulator_dcm.energy_kev.set(5) - - -def test_energy_to_distance_table_correct_format(fake_undulator_dcm: UndulatorDCM): - table = _get_energy_distance_table(fake_undulator_dcm.undulator.lookup_table_path) - assert table[0][0] == 5700 - assert table[49][1] == 6.264 - assert table.shape == (50, 2) + await fake_undulator_dcm.set(5) @pytest.mark.parametrize( @@ -58,82 +74,93 @@ def test_correct_closest_distance_to_energy_from_table(dcm_energy, expected_outp ) -@patch("dodal.devices.undulator_dcm.loadtxt") +@patch("dodal.devices.util.lookup_tables.loadtxt") @patch("dodal.devices.undulator_dcm.LOGGER") -def test_if_gap_is_wrong_then_logger_info_is_called_and_gap_is_set_correctly( +async def test_if_gap_is_wrong_then_logger_info_is_called_and_gap_is_set_correctly( mock_logger: MagicMock, mock_load: MagicMock, fake_undulator_dcm: UndulatorDCM ): - fake_undulator_dcm.undulator.current_gap.sim_put(5.3) # type: ignore - fake_undulator_dcm.undulator.gap_motor.move = MagicMock() - fake_undulator_dcm.dcm.energy_in_kev.move = MagicMock() + set_sim_value(fake_undulator_dcm.undulator.current_gap, 5.3) + set_sim_value(fake_undulator_dcm.dcm.energy_in_kev.user_readback, 5.7) + mock_load.return_value = np.array([[5700, 5.4606], [7000, 6.045], [9700, 6.404]]) - fake_undulator_dcm.dcm.energy_in_kev.user_readback.sim_put(5.7) # type: ignore - fake_undulator_dcm.energy_kev.set(6.9) + await fake_undulator_dcm.set(6.9) - fake_undulator_dcm.dcm.energy_in_kev.move.assert_called_once_with(6.9, timeout=30) - fake_undulator_dcm.undulator.gap_motor.move.assert_called_once_with( - 6.045, timeout=10 - ) + assert (await fake_undulator_dcm.dcm.energy_in_kev.user_setpoint.get_value()) == 6.9 + assert ( + await fake_undulator_dcm.undulator.gap_motor.user_setpoint.get_value() + ) == 6.045 mock_logger.info.assert_called() -@patch("dodal.devices.undulator_dcm.loadtxt") +@patch("dodal.devices.util.lookup_tables.loadtxt") @patch("dodal.devices.undulator_dcm.LOGGER") @patch("dodal.devices.undulator_dcm.TEST_MODE", True) -def test_when_gap_access_is_not_checked_if_test_mode_enabled( +async def test_when_gap_access_is_not_checked_if_test_mode_enabled( mock_logger: MagicMock, mock_load: MagicMock, fake_undulator_dcm: UndulatorDCM ): - fake_undulator_dcm.undulator.gap_access.sim_put(UndulatorGapAccess.DISABLED.value) # type: ignore - fake_undulator_dcm.undulator.current_gap.sim_put(5.3) # type: ignore - fake_undulator_dcm.undulator.gap_motor.move = MagicMock() - fake_undulator_dcm.dcm.energy_in_kev.move = MagicMock() + set_sim_value(fake_undulator_dcm.undulator.gap_access, UndulatorGapAccess.DISABLED) + set_sim_value(fake_undulator_dcm.undulator.current_gap, 5.3) + set_sim_value(fake_undulator_dcm.dcm.energy_in_kev.user_readback, 5.7) + + set_sim_value(fake_undulator_dcm.undulator.gap_motor.user_setpoint, 0.0) + set_sim_value(fake_undulator_dcm.undulator.gap_motor.user_readback, 0.0) + mock_load.return_value = np.array([[5700, 5.4606], [7000, 6.045], [9700, 6.404]]) - fake_undulator_dcm.dcm.energy_in_kev.user_readback.sim_put(5.7) # type: ignore - fake_undulator_dcm.energy_kev.set(6.9) + await fake_undulator_dcm.set(6.9) + + assert (await fake_undulator_dcm.dcm.energy_in_kev.user_setpoint.get_value()) == 6.9 + # Verify undulator has not been asked to move + assert ( + await fake_undulator_dcm.undulator.gap_motor.user_setpoint.get_value() + ) == 0.0 - fake_undulator_dcm.dcm.energy_in_kev.move.assert_called_once_with(6.9, timeout=30) - fake_undulator_dcm.undulator.gap_motor.move.assert_not_called() mock_logger.info.assert_called() + mock_logger.debug.assert_called_once() -@patch("dodal.devices.undulator_dcm.loadtxt") +@patch("dodal.devices.util.lookup_tables.loadtxt") @patch("dodal.devices.undulator_dcm.LOGGER") -def test_if_gap_is_already_correct_then_dont_move_gap( +async def test_if_gap_is_already_correct_then_dont_move_gap( mock_logger: MagicMock, mock_load: MagicMock, fake_undulator_dcm: UndulatorDCM ): - fake_undulator_dcm.undulator.gap_motor.move = MagicMock() - fake_undulator_dcm.dcm.energy_in_kev.move = MagicMock() + set_sim_value(fake_undulator_dcm.dcm.energy_in_kev.user_setpoint, 0.0) + set_sim_value(fake_undulator_dcm.dcm.energy_in_kev.user_readback, 0.0) + mock_load.return_value = np.array([[5700, 5.4606], [7000, 6.045], [9700, 6.404]]) - fake_undulator_dcm.undulator.current_gap.sim_put(5.4605) # type: ignore + set_sim_value(fake_undulator_dcm.undulator.current_gap, 5.4605) - fake_undulator_dcm.energy_kev.set(5.8).wait(timeout=0.01) + status = fake_undulator_dcm.set(5.8) + await asyncio.wait_for(status, timeout=0.01) - fake_undulator_dcm.undulator.gap_motor.move.assert_not_called() + # Verify undulator has not been asked to move + assert ( + await fake_undulator_dcm.undulator.gap_motor.user_setpoint.get_value() + ) == 0.0 mock_logger.info.assert_called_once() + mock_logger.debug.assert_called_once() -def test_energy_set_only_complete_when_all_statuses_are_finished( +async def test_energy_set_only_complete_when_all_statuses_are_finished( fake_undulator_dcm: UndulatorDCM, ): - dcm_energy_move_status = Status() - undulator_gap_move_status = Status() + set_sim_value(fake_undulator_dcm.undulator.current_gap, 5.0) + + release_dcm = asyncio.Event() + release_undulator = asyncio.Event() - fake_undulator_dcm.dcm.energy_in_kev.move = MagicMock( - return_value=dcm_energy_move_status + fake_undulator_dcm.dcm.energy_in_kev.set = MagicMock( + return_value=AsyncStatus(release_dcm.wait()) ) - fake_undulator_dcm.undulator.gap_motor.move = MagicMock( - return_value=undulator_gap_move_status + fake_undulator_dcm.undulator.gap_motor.set = MagicMock( + return_value=AsyncStatus(release_undulator.wait()) ) - _get_energy_distance_table = MagicMock() - _get_closest_gap_for_energy = MagicMock(return_value=10) - fake_undulator_dcm.undulator.current_gap.sim_put(5) # type: ignore - status: Status = fake_undulator_dcm.energy_kev.set(5.8) + status = fake_undulator_dcm.set(5.0) - assert not status.success - dcm_energy_move_status.set_finished() - assert not status.success - undulator_gap_move_status.set_finished() - status.wait(timeout=0.01) + assert not status.done + release_dcm.set() + assert not status.done + release_undulator.set() + await asyncio.wait_for(status, timeout=0.01) diff --git a/tests/devices/unit_tests/util/test_lookup_tables.py b/tests/devices/unit_tests/util/test_lookup_tables.py index 7ceaaca036..7424873fdb 100644 --- a/tests/devices/unit_tests/util/test_lookup_tables.py +++ b/tests/devices/unit_tests/util/test_lookup_tables.py @@ -2,10 +2,20 @@ from pytest import mark from dodal.devices.util.lookup_tables import ( + energy_distance_table, linear_interpolation_lut, ) +async def test_energy_to_distance_table_correct_format(): + table = await energy_distance_table( + "./tests/devices/unit_tests/test_beamline_undulator_to_gap_lookup_table.txt" + ) + assert table[0][0] == 5700 + assert table[49][1] == 6.264 + assert table.shape == (50, 2) + + @mark.parametrize("s, expected_t", [(2.0, 1.0), (3.0, 1.5), (5.0, 4.0), (5.25, 6.0)]) def test_linear_interpolation(s, expected_t): lut_converter = linear_interpolation_lut(