diff --git a/pyproject.toml b/pyproject.toml index 8bb14eff26..3c3f9804c7 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -15,7 +15,8 @@ description = "Ophyd devices and other utils that could be used across DLS beaml dependencies = [ "click", "ophyd", - "ophyd-async >= 0.8.0a5", + "ophyd-async>=0.8.0a5", + "ophyd-async[sim]", "bluesky", "pyepics", "dataclasses-json", diff --git a/src/dodal/beamlines/i18.py b/src/dodal/beamlines/i18.py new file mode 100644 index 0000000000..d48b935248 --- /dev/null +++ b/src/dodal/beamlines/i18.py @@ -0,0 +1,286 @@ +from pathlib import Path + +from ophyd_async.fastcs.panda import HDFPanda + +from dodal.common.beamlines.beamline_utils import ( + device_instantiation, + get_path_provider, + set_path_provider, +) +from dodal.common.beamlines.beamline_utils import set_beamline as set_utils_beamline +from dodal.common.beamlines.device_helpers import numbered_slits +from dodal.common.visit import ( + LocalDirectoryServiceClient, + StaticVisitPathProvider, +) +from dodal.devices.i18.diode import Diode +from dodal.devices.i18.KBMirror import KBMirror +from dodal.devices.i18.sim_detector import SimDetector +from dodal.devices.i18.table import Table +from dodal.devices.i22.dcm import CrystalMetadata, DoubleCrystalMonochromator +from dodal.devices.slits import Slits +from dodal.devices.synchrotron import Synchrotron +from dodal.devices.tetramm import TetrammDetector +from dodal.devices.undulator import Undulator +from dodal.devices.xspress3.xspress3 import Xspress3 +from dodal.log import set_beamline as set_log_beamline +from dodal.utils import BeamlinePrefix, get_beamline_name, skip_device + +BL = get_beamline_name("i18") +set_log_beamline(BL) +set_utils_beamline(BL) + + +# Currently we must hard-code the visit, determining the visit at runtime requires +# infrastructure that is still WIP. +# Communication with GDA is also WIP so for now we determine an arbitrary scan number +# locally and write the commissioning directory. The scan number is not guaranteed to +# be unique and the data is at risk - this configuration is for testing only. +set_path_provider( + StaticVisitPathProvider( + BL, + Path("/dls/i18/data/2024/cm37264-2/bluesky"), + client=LocalDirectoryServiceClient(), + ) +) + + +def synchrotron( + wait_for_connection: bool = True, fake_with_ophyd_sim: bool = False +) -> Synchrotron: + return device_instantiation( + Synchrotron, + "synchrotron", + "", + wait_for_connection, + fake_with_ophyd_sim, + ) + + +# not ready yet +@skip_device() +def undulator( + wait_for_connection: bool = True, + fake_with_ophyd_sim: bool = False, +) -> Undulator: + return device_instantiation( + Undulator, + "undulator", + f"{BeamlinePrefix(BL).insertion_prefix}-MO-SERVC-01:", + wait_for_connection, + fake_with_ophyd_sim, + bl_prefix=False, + poles=80, + length=2.0, + ) + + +@skip_device() +def slits_1( + wait_for_connection: bool = True, + fake_with_ophyd_sim: bool = False, +) -> Slits: + return numbered_slits( + 1, + wait_for_connection, + fake_with_ophyd_sim, + ) + + +# Must document what PandAs are physically connected to +# See: https://github.com/bluesky/ophyd-async/issues/284 +@skip_device() +def panda1( + wait_for_connection: bool = True, + fake_with_ophyd_sim: bool = False, +) -> HDFPanda: + return device_instantiation( + HDFPanda, + "panda1", + "-MO-PANDA-01:", + wait_for_connection, + fake_with_ophyd_sim, + path_provider=get_path_provider(), + ) + + +def xspress3( + wait_for_connection: bool = True, fake_with_ophyd_sim: bool = False +) -> Xspress3: + return device_instantiation( + Xspress3, + prefix="-EA-XSP-02:", + name="xspress3", + num_channels=16, + wait=wait_for_connection, + fake=fake_with_ophyd_sim, + ) + + +# odin detectors are not yet supported. +# There is a controls project in the works, +# not ready anytime soon +@skip_device() +def xspress3_odin( + wait_for_connection: bool = True, fake_with_ophyd_sim: bool = False +) -> Xspress3: + return device_instantiation( + Xspress3, + prefix="-EA-XSP-03:", + name="xspress3_odin", + num_channels=4, + wait=wait_for_connection, + fake=fake_with_ophyd_sim, + ) + + +def dcm( + wait_for_connection: bool = True, + fake_with_ophyd_sim: bool = False, +) -> DoubleCrystalMonochromator: + crystal_1_metadata = CrystalMetadata( + usage="Bragg", + type="silicon", + reflection=(1, 1, 1), + d_spacing=(3.13475, "nm"), + ) + + crystal_2_metadata = CrystalMetadata( + usage="Bragg", + type="silicon", + reflection=(1, 1, 1), + d_spacing=(3.13475, "nm"), + ) + + return device_instantiation( + DoubleCrystalMonochromator, + "dcm", + f"{BeamlinePrefix(BL).beamline_prefix}-MO-DCM-01:", + wait_for_connection, + fake_with_ophyd_sim, + bl_prefix=False, + temperature_prefix=f"{BeamlinePrefix(BL).beamline_prefix}-DI-DCM-01:", + crystal_1_metadata=crystal_1_metadata, + crystal_2_metadata=crystal_2_metadata, + ) + + +@skip_device() +def i0( + wait_for_connection: bool = True, + fake_with_ophyd_sim: bool = False, +) -> TetrammDetector: + return device_instantiation( + TetrammDetector, + "i0", + "-DI-XBPM-02:", + wait_for_connection, + fake_with_ophyd_sim, + type="Cividec Diamond XBPM", + path_provider=get_path_provider(), + ) + + +@skip_device() +def it( + wait_for_connection: bool = True, + fake_with_ophyd_sim: bool = False, +) -> TetrammDetector: + return device_instantiation( + TetrammDetector, + "it", + "-DI-XBPM-01:", + wait_for_connection, + fake_with_ophyd_sim, + type="Tetramm", + path_provider=get_path_provider(), + ) + + +@skip_device +def vfm( + wait_for_connection: bool = True, + fake_with_ophyd_sim: bool = False, +) -> KBMirror: + return device_instantiation( + KBMirror, + "vfm", + "-OP-VFM-01:", + wait_for_connection, + fake_with_ophyd_sim, + ) + + +def hfm( + wait_for_connection: bool = True, + fake_with_ophyd_sim: bool = False, +) -> KBMirror: + return device_instantiation( + KBMirror, + "hfm", + "-OP-HFM-01:", + wait_for_connection, + fake_with_ophyd_sim, + ) + + +def d7diode( + wait_for_connection: bool = True, fake_with_ophyd_sim: bool = False +) -> Diode: + return device_instantiation( + Diode, + "-DI-PHDGN-07:", + wait_for_connection, + fake_with_ophyd_sim, + ) + + +def main_table( + wait_for_connection: bool = True, fake_with_ophyd_sim: bool = False +) -> Table: + return device_instantiation( + Table, + "table", + "-MO-TABLE-01:", + wait_for_connection, + fake_with_ophyd_sim, + ) + + +def thor_labs_table( + wait_for_connection: bool = True, fake_with_ophyd_sim: bool = False +) -> Table: + return device_instantiation( + Table, + "table", + "-MO-TABLE-02:", + wait_for_connection, + fake_with_ophyd_sim, + ) + + +# SIMULATED DEVICES + + +# this is a mock table, not sure how does it relate to the real Table, maybe just a fake option in instantiation is needed +@skip_device() +def raster_stage( + wait_for_connection: bool = True, fake_with_ophyd_sim: bool = False +) -> Table: + return device_instantiation( + Table, + "raster_stage", + "-MO-SIM-01:", + wait_for_connection, + fake_with_ophyd_sim, + ) + + +def sim_detector(wait_for_connection: bool = True, fake_with_ophyd_sim: bool = False): + return device_instantiation( + SimDetector, + "sim_detector", + "-MO-SIM-01:", + wait_for_connection, + fake_with_ophyd_sim, + ) diff --git a/src/dodal/beamlines/i22.py b/src/dodal/beamlines/i22.py index fb7ef57135..8529c3e96c 100644 --- a/src/dodal/beamlines/i22.py +++ b/src/dodal/beamlines/i22.py @@ -144,8 +144,8 @@ def undulator() -> Undulator: return Undulator( prefix=f"{PREFIX.insertion_prefix}-MO-SERVC-01:", id_gap_lookup_table_path="/dls_sw/i22/software/daq_configuration/lookup/BeamLine_Undulator_toGap.txt", - poles=80, - length=2.0, + poles=None, # todo need to double check for the metadata to be valid + length=None, # todo need to double check for the metadata to be valid ) diff --git a/src/dodal/devices/i18/KBMirror.py b/src/dodal/devices/i18/KBMirror.py new file mode 100644 index 0000000000..baf69f67a0 --- /dev/null +++ b/src/dodal/devices/i18/KBMirror.py @@ -0,0 +1,31 @@ +from bluesky.protocols import Movable +from ophyd_async.core import AsyncStatus, StandardReadable +from ophyd_async.epics.core import epics_signal_rw +from pydantic import BaseModel + + +class XYPosition(BaseModel): + x: float + y: float + + +class KBMirror(StandardReadable, Movable): + def __init__( + self, + prefix: str, + name: str = "", + ): + self._prefix = prefix + with self.add_children_as_readables(): + self.x = epics_signal_rw(float, prefix + "X") + self.y = epics_signal_rw(float, prefix + "Y") + self.bend1 = epics_signal_rw(float, prefix + "BEND1") + self.bend2 = epics_signal_rw(float, prefix + "BEND2") + self.curve = epics_signal_rw(float, prefix + "CURVE") + self.ellip = epics_signal_rw(float, prefix + "ELLIP") + super().__init__(name=name) + + @AsyncStatus.wrap + async def set(self, value: XYPosition): + self.x.set(value.x) + self.y.set(value.y) diff --git a/src/dodal/devices/i18/diode.py b/src/dodal/devices/i18/diode.py new file mode 100644 index 0000000000..f0c9526adc --- /dev/null +++ b/src/dodal/devices/i18/diode.py @@ -0,0 +1,17 @@ +from ophyd_async.core import ( + StandardReadable, +) +from ophyd_async.epics.core import epics_signal_r + + +class Diode(StandardReadable): + def __init__( + self, + prefix: str, + name: str = "", + ): + self._prefix = prefix + with self.add_children_as_readables(): + self.signal = epics_signal_r(float, prefix + "B:DIODE:I") + + super().__init__(name=name) diff --git a/src/dodal/devices/i18/table.py b/src/dodal/devices/i18/table.py new file mode 100644 index 0000000000..1ebb40fa34 --- /dev/null +++ b/src/dodal/devices/i18/table.py @@ -0,0 +1,28 @@ +from ophyd_async.core import ( + AsyncStatus, + StandardReadable, +) +from ophyd_async.epics.motor import Motor +from pydantic import BaseModel + + +class Four_D_Position(BaseModel): + x: float + y: float + z: float + theta: float + + +class Table(StandardReadable): + def __init__(self, prefix: str = "", name: str = "") -> None: + with self.add_children_as_readables(): + self.x = Motor(prefix + "X") + self.y = Motor(prefix + "Y") + self.z = Motor(prefix + "Z") + self.theta = Motor(prefix + "THETA") + super().__init__(name=name) + + @AsyncStatus.wrap + async def set(self, value: Four_D_Position): + self.x.set(value.x) + self.y.set(value.y) diff --git a/tests/devices/i18/test_kb_mirror.py b/tests/devices/i18/test_kb_mirror.py new file mode 100644 index 0000000000..1eff890564 --- /dev/null +++ b/tests/devices/i18/test_kb_mirror.py @@ -0,0 +1,65 @@ +from unittest.mock import ANY + +import pytest +from ophyd_async.core import DeviceCollector, set_mock_value + +from dodal.devices.i18.KBMirror import KBMirror, XYPosition + + +@pytest.fixture +async def kbmirror() -> KBMirror: + """Fixture to set up a mock KBMirror device using DeviceCollector.""" + async with DeviceCollector(mock=True): + kbmirror = KBMirror(prefix="MIRROR:") + return kbmirror + + +async def test_setting_xy_position_kbmirror(kbmirror: KBMirror): + """ + Test setting x and y positions on the KBMirror using the ophyd_async mock tools. + """ + # Mock the initial values of the x and y signals + set_mock_value(kbmirror.x, 0.0) + set_mock_value(kbmirror.y, 0.0) + + # Create a position object + position = XYPosition(x=1.23, y=4.56) + + # Call set to update the position + await kbmirror.set(position) + + reading = await kbmirror.read() + expected_reading = { + "kbmirror-y": { + "value": 4.56, + "timestamp": ANY, + "alarm_severity": 0, + }, + "kbmirror-bend1": { + "value": 0.0, + "timestamp": ANY, + "alarm_severity": 0, + }, + "kbmirror-ellip": { + "value": 0.0, + "timestamp": ANY, + "alarm_severity": 0, + }, + "kbmirror-x": { + "value": 1.23, + "timestamp": ANY, + "alarm_severity": 0, + }, + "kbmirror-bend2": { + "value": 0.0, + "timestamp": ANY, + "alarm_severity": 0, + }, + "kbmirror-curve": { + "value": 0.0, + "timestamp": ANY, + "alarm_severity": 0, + }, + } + + assert reading == expected_reading diff --git a/tests/devices/i18/test_sim_detector.py b/tests/devices/i18/test_sim_detector.py new file mode 100644 index 0000000000..684f80f903 --- /dev/null +++ b/tests/devices/i18/test_sim_detector.py @@ -0,0 +1,35 @@ +from unittest.mock import Mock + +from dodal.devices.i18.sim_detector import SimDetector + + +def test_simdetector_read(): + """ + Test SimDetector.read() returns a pattern generator value in the correct structure. + """ + motor_mock = Mock() + pattern_generator_mock = Mock() + pattern_generator_mock.return_value = {"value": pattern_generator_mock} + + detector = SimDetector(name="detector1", motor=motor_mock, motor_field="position") + detector.pattern_generator = ( + pattern_generator_mock # Replace actual generator with mock for testing + ) + + result = detector.read() + + # Check the structure and value + assert result == {"detector1": {"value": pattern_generator_mock}} + + +def test_simdetector_describe(): + """ + Test SimDetector.describe() returns correct metadata. + """ + motor_mock = Mock() + detector = SimDetector(name="detector1", motor=motor_mock, motor_field="position") + + result = detector.describe() + + # Check the structure and value + assert result == {"detector1": {"source": "synthetic", "dtype": "number"}} diff --git a/tests/devices/i18/test_table.py b/tests/devices/i18/test_table.py new file mode 100644 index 0000000000..900339894a --- /dev/null +++ b/tests/devices/i18/test_table.py @@ -0,0 +1,50 @@ +from unittest.mock import ANY + +import pytest +from ophyd_async.core import DeviceCollector, set_mock_value + +from dodal.devices.i18.table import Four_D_Position, Table + + +@pytest.fixture +async def table() -> Table: + """Fixture to set up a mock Table device using DeviceCollector.""" + async with DeviceCollector(mock=True): + table = Table(prefix="MIRROR:") + return table + + +async def test_setting_xy_position_table(table: Table): + """ + Test setting x and y positions on the Table using the ophyd_async mock tools. + """ + set_mock_value(table.x.user_readback, 1.23) + set_mock_value(table.y.user_readback, 4.56) + + # Create a position object + position = Four_D_Position(x=1.23, y=4.56, z=0.0, theta=0.0) + + # Call set to update the position + await table.set(position) + + reading = await table.read() + expected_reading = { + "table-y": { + "value": 4.56, + "timestamp": ANY, + "alarm_severity": 0, + }, + "table-x": { + "value": 1.23, + "timestamp": ANY, + "alarm_severity": 0, + }, + "table-theta": { + "alarm_severity": 0, + "timestamp": ANY, + "value": 0.0, + }, + "table-z": {"alarm_severity": 0, "timestamp": ANY, "value": 0.0}, + } + + assert reading == expected_reading