diff --git a/src/dodal/beamlines/p99.py b/src/dodal/beamlines/p99.py index fd8b9dfbfb..19bc5e9b94 100644 --- a/src/dodal/beamlines/p99.py +++ b/src/dodal/beamlines/p99.py @@ -1,4 +1,13 @@ -from dodal.common.beamlines.beamline_utils import device_instantiation, set_beamline +from pathlib import Path + +from ophyd_async.core import AutoIncrementFilenameProvider, StaticPathProvider +from ophyd_async.epics.adcore import SingleTriggerDetector + +from dodal.common.beamlines.beamline_utils import ( + device_instantiation, + set_beamline, +) +from dodal.devices.areadetector import Andor2 from dodal.devices.motors import XYZPositioner from dodal.devices.p99.sample_stage import FilterMotor, SampleAngleStage from dodal.log import set_beamline as set_log_beamline @@ -12,7 +21,7 @@ def sample_angle_stage( wait_for_connection: bool = True, fake_with_ophyd_sim: bool = False ) -> SampleAngleStage: - """Sample stage for p99""" + """sample angle stage (old currently on the side)""" return device_instantiation( SampleAngleStage, @@ -26,8 +35,6 @@ def sample_angle_stage( def sample_stage_filer( wait_for_connection: bool = True, fake_with_ophyd_sim: bool = False ) -> FilterMotor: - """Sample stage for p99""" - return device_instantiation( FilterMotor, prefix="-MO-STAGE-02:MP:SELECT", @@ -41,7 +48,7 @@ def sample_xyz_stage( wait_for_connection: bool = True, fake_with_ophyd_sim: bool = False ) -> XYZPositioner: return device_instantiation( - FilterMotor, + XYZPositioner, prefix="-MO-STAGE-02:", name="sample_xyz_stage", wait=wait_for_connection, @@ -53,9 +60,49 @@ def sample_lab_xyz_stage( wait_for_connection: bool = True, fake_with_ophyd_sim: bool = False ) -> XYZPositioner: return device_instantiation( - FilterMotor, + XYZPositioner, prefix="-MO-STAGE-02:LAB:", name="sample_lab_xyz_stage", wait=wait_for_connection, fake=fake_with_ophyd_sim, ) + + +andor_data_path = StaticPathProvider( + filename_provider=AutoIncrementFilenameProvider(base_filename="andor2"), + directory_path=Path("/dls/p99/data/2024/cm37284-2/processing/writenData"), +) + + +def andor2_det( + wait_for_connection: bool = True, fake_with_ophyd_sim: bool = False +) -> Andor2: + return device_instantiation( + Andor2, + prefix="-EA-DET-03:", + name="andor2_det", + path_provider=andor_data_path, + wait=wait_for_connection, + fake=fake_with_ophyd_sim, + ) + + +def andor2_point( + wait_for_connection: bool = True, fake_with_ophyd_sim: bool = False +) -> SingleTriggerDetector: + """ + This is the same physical detector as andor2_det but it will not save + any image as we only care about the detector mean count. + This behave very much like a oversize/overpirce photo diode when use in this mode. + """ + return device_instantiation( + SingleTriggerDetector, + drv=andor2_det(wait_for_connection, fake_with_ophyd_sim).drv, + read_uncached=( + [andor2_det(wait_for_connection, fake_with_ophyd_sim).drv.stat_mean] + ), + prefix="", + name="andor2_point", + wait=wait_for_connection, + fake=fake_with_ophyd_sim, + ) diff --git a/src/dodal/devices/areadetector/__init__.py b/src/dodal/devices/areadetector/__init__.py index 78171d5ef5..e3df576501 100644 --- a/src/dodal/devices/areadetector/__init__.py +++ b/src/dodal/devices/areadetector/__init__.py @@ -1,10 +1,12 @@ from .adaravis import AdAravisDetector from .adsim import AdSimDetector from .adutils import Hdf5Writer, SynchronisedAdDriverBase +from .andor2 import Andor2 __all__ = [ "AdSimDetector", "SynchronisedAdDriverBase", "Hdf5Writer", "AdAravisDetector", + "Andor2", ] diff --git a/src/dodal/devices/areadetector/andor2.py b/src/dodal/devices/areadetector/andor2.py new file mode 100644 index 0000000000..178629cde9 --- /dev/null +++ b/src/dodal/devices/areadetector/andor2.py @@ -0,0 +1,49 @@ +from bluesky.protocols import Hints +from ophyd_async.core import PathProvider, StandardDetector +from ophyd_async.epics.adcore import ADBaseDatasetDescriber, ADHDFWriter, NDFileHDFIO + +from dodal.devices.areadetector.andor2_epics import Andor2Controller, Andor2DriverIO + + +class Andor2(StandardDetector): + """ + Andor 2 area detector device (CCD detector 56fps with full chip readout). + Andor model:DU897_BV. + """ + + _controller: Andor2Controller + _writer: ADHDFWriter + + def __init__( + self, + prefix: str, + path_provider: PathProvider, + name: str, + ): + """ + Parameters + ---------- + prefix: str + Epic Pv, + path_provider: PathProvider + Path provider for hdf writer + name: str + Name of the device + """ + self.drv = Andor2DriverIO(prefix + "CAM:") + self.hdf = NDFileHDFIO(prefix + "HDF5:") + super().__init__( + Andor2Controller(self.drv), + ADHDFWriter( + hdf=self.hdf, + path_provider=path_provider, + name_provider=lambda: self.name, + dataset_describer=ADBaseDatasetDescriber(self.drv), + ), + config_sigs=[self.drv.acquire_time], + name=name, + ) + + @property + def hints(self) -> Hints: + return self._writer.hints diff --git a/src/dodal/devices/areadetector/andor2_epics/__init__.py b/src/dodal/devices/areadetector/andor2_epics/__init__.py new file mode 100644 index 0000000000..5266370530 --- /dev/null +++ b/src/dodal/devices/areadetector/andor2_epics/__init__.py @@ -0,0 +1,4 @@ +from .andor2_controller import Andor2Controller +from .andor2_io import Andor2DriverIO, Andor2TriggerMode, ImageMode + +__all__ = ["Andor2Controller", "Andor2TriggerMode", "Andor2DriverIO", "ImageMode"] diff --git a/src/dodal/devices/areadetector/andor2_epics/andor2_controller.py b/src/dodal/devices/areadetector/andor2_epics/andor2_controller.py new file mode 100644 index 0000000000..ca904c303d --- /dev/null +++ b/src/dodal/devices/areadetector/andor2_epics/andor2_controller.py @@ -0,0 +1,79 @@ +import asyncio + +from ophyd_async.core import DetectorController, DetectorTrigger +from ophyd_async.core._detector import TriggerInfo +from ophyd_async.epics import adcore +from ophyd_async.epics.adcore import ( + DEFAULT_GOOD_STATES, + DetectorState, + stop_busy_record, +) + +from .andor2_io import ( + Andor2DriverIO, + Andor2TriggerMode, + ImageMode, +) + +MIN_DEAD_TIME = 0.1 +DEFAULT_MAX_NUM_IMAGE = 999_999 + + +class Andor2Controller(DetectorController): + _supported_trigger_types = { + DetectorTrigger.internal: Andor2TriggerMode.internal, + DetectorTrigger.edge_trigger: Andor2TriggerMode.ext_trigger, + } + + def __init__( + self, + driver: Andor2DriverIO, + good_states: set[DetectorState] | None = None, + ) -> None: + if good_states is None: + good_states = set(DEFAULT_GOOD_STATES) + self._drv = driver + self.good_states = good_states + + def get_deadtime(self, exposure: float | None) -> float: + if exposure is None: + return MIN_DEAD_TIME + return exposure + MIN_DEAD_TIME + + async def prepare(self, trigger_info: TriggerInfo): + if trigger_info.livetime is not None: + await adcore.set_exposure_time_and_acquire_period_if_supplied( + self, self._drv, trigger_info.livetime + ) + await asyncio.gather( + self._drv.trigger_mode.set(self._get_trigger_mode(trigger_info.trigger)), + self._drv.num_images.set( + DEFAULT_MAX_NUM_IMAGE + if trigger_info.total_number_of_triggers == 0 + else trigger_info.total_number_of_triggers + ), + self._drv.image_mode.set(ImageMode.multiple), + ) + + async def arm(self) -> None: + # Standard arm the detector and wait for the acquire PV to be True + self._arm_status = await adcore.start_acquiring_driver_and_ensure_status( + self._drv + ) + + async def wait_for_idle(self): + if self._arm_status: + await self._arm_status + + @classmethod + def _get_trigger_mode(cls, trigger: DetectorTrigger) -> Andor2TriggerMode: + if trigger not in cls._supported_trigger_types.keys(): + raise ValueError( + f"{cls.__name__} only supports the following trigger " + f"types: {cls._supported_trigger_types.keys()} but was asked to " + f"use {trigger}" + ) + return cls._supported_trigger_types[trigger] + + async def disarm(self): + await stop_busy_record(self._drv.acquire, False, timeout=1) diff --git a/src/dodal/devices/areadetector/andor2_epics/andor2_io.py b/src/dodal/devices/areadetector/andor2_epics/andor2_io.py new file mode 100644 index 0000000000..42e1236a34 --- /dev/null +++ b/src/dodal/devices/areadetector/andor2_epics/andor2_io.py @@ -0,0 +1,52 @@ +from enum import Enum + +from ophyd_async.epics.adcore._core_io import ADBaseIO +from ophyd_async.epics.signal import ( + epics_signal_r, + epics_signal_rw, + epics_signal_rw_rbv, +) + + +class Andor2TriggerMode(str, Enum): + internal = "Internal" + ext_trigger = "External" + ext_start = "External Start" + ext_exposure = "External Exposure" + ext_FVP = "External FVP" + soft = "Software" + + +class ImageMode(str, Enum): + single = "Single" + multiple = "Multiple" + continuous = "Continuous" + fast_kinetics = "Fast Kinetics" + + +class ADBaseDataType(str, Enum): + UInt16 = "UInt16" + UInt32 = "UInt32" + Float32 = "Float32" + Float64 = "Float64" + _ = "" + """ + #This is needed due to all empty enum from epics will reduce to a single + "" and throw a typeerror as the blank "" is unmatched. + """ + + +class Andor2DriverIO(ADBaseIO): + """ + Epics pv for andor model:DU897_BV as deployed on p99 + """ + + def __init__(self, prefix: str) -> None: + super().__init__(prefix) + self.trigger_mode = epics_signal_rw(Andor2TriggerMode, prefix + "TriggerMode") + self.data_type = epics_signal_r(ADBaseDataType, prefix + "DataType_RBV") + self.accumulate_period = epics_signal_r( + float, prefix + "AndorAccumulatePeriod_RBV" + ) + self.image_mode = epics_signal_rw_rbv(ImageMode, prefix + "ImageMode") + self.stat_mean = epics_signal_r(int, prefix[:-4] + "STAT:MeanValue_RBV") diff --git a/tests/devices/unit_tests/areadetector/test_andor2.py b/tests/devices/unit_tests/areadetector/test_andor2.py new file mode 100644 index 0000000000..76127bc3e9 --- /dev/null +++ b/tests/devices/unit_tests/areadetector/test_andor2.py @@ -0,0 +1,195 @@ +from collections import defaultdict +from pathlib import Path +from unittest.mock import Mock + +import pytest +from bluesky.plans import count +from bluesky.run_engine import RunEngine +from ophyd_async.core import ( + DetectorTrigger, + DeviceCollector, + FilenameProvider, + StaticFilenameProvider, + StaticPathProvider, + TriggerInfo, + assert_emitted, + callback_on_mock_put, + set_mock_value, +) +from ophyd_async.epics.adcore._core_io import DetectorState + +from dodal.devices.areadetector import Andor2 +from dodal.devices.areadetector.andor2_epics import ( + Andor2DriverIO, + Andor2TriggerMode, + ImageMode, +) +from dodal.devices.areadetector.andor2_epics.andor2_controller import ( + DEFAULT_MAX_NUM_IMAGE, + MIN_DEAD_TIME, + Andor2Controller, +) + + +@pytest.fixture +def static_filename_provider(): + return StaticFilenameProvider("ophyd_async_tests") + + +@pytest.fixture +def static_path_provider_factory(tmp_path: Path): + def create_static_dir_provider_given_fp(fp: FilenameProvider): + return StaticPathProvider(fp, tmp_path) + + return create_static_dir_provider_given_fp + + +@pytest.fixture +def static_path_provider( + static_path_provider_factory, + static_filename_provider: FilenameProvider, +): + return static_path_provider_factory(static_filename_provider) + + +@pytest.fixture +async def andor_controller() -> Andor2Controller: + async with DeviceCollector(mock=True): + drv = Andor2DriverIO("DRIVER:") + andor_controller = Andor2Controller(drv) + + return andor_controller + + +async def test_andor_controller_prepare_and_arm_with_TriggerInfo( + RE, andor_controller: Andor2Controller +): + await andor_controller.prepare( + trigger_info=TriggerInfo(number_of_triggers=1, livetime=0.002) + ) + await andor_controller.arm() + + driver = andor_controller._drv + assert await driver.num_images.get_value() == 1 + assert await driver.image_mode.get_value() == ImageMode.multiple + assert await driver.trigger_mode.get_value() == Andor2TriggerMode.internal + assert await driver.acquire.get_value() is True + assert await driver.acquire_time.get_value() == 0.002 + + +async def test_andor_controller_prepare_and_arm_with_no_livetime( + RE, andor_controller: Andor2Controller +): + # get driver and set the current acquire time. + default_count_time = 2141 + driver = andor_controller._drv + set_mock_value(driver.acquire_time, default_count_time) + await andor_controller.prepare(trigger_info=TriggerInfo(number_of_triggers=5)) + await andor_controller.arm() + + assert await driver.num_images.get_value() == 5 + assert await driver.image_mode.get_value() == ImageMode.multiple + assert await driver.trigger_mode.get_value() == Andor2TriggerMode.internal + assert await driver.acquire.get_value() is True + assert await driver.acquire_time.get_value() == default_count_time + + +async def test_andor_controller_prepare_and_arm_with_trigger_number_of_zero( + RE, andor_controller: Andor2Controller +): + # get driver and set the current acquire time. + default_count_time = 1231 + driver = andor_controller._drv + set_mock_value(driver.acquire_time, default_count_time) + await andor_controller.prepare(trigger_info=TriggerInfo(number_of_triggers=0)) + await andor_controller.arm() + + assert await driver.num_images.get_value() == DEFAULT_MAX_NUM_IMAGE + assert await driver.image_mode.get_value() == ImageMode.multiple + assert await driver.trigger_mode.get_value() == Andor2TriggerMode.internal + assert await driver.acquire.get_value() is True + assert await driver.acquire_time.get_value() == default_count_time + + +async def test_andor_controller_disarm(RE, andor_controller: Andor2Controller): + await andor_controller.disarm() + driver = andor_controller._drv + assert await driver.acquire.get_value() is False + + await andor_controller.disarm() + + +async def test_andor_incorrect_tigger_mode(RE, andor_controller: Andor2Controller): + with pytest.raises(ValueError): + andor_controller._get_trigger_mode(DetectorTrigger.variable_gate) + + assert await andor_controller._drv.acquire.get_value() is False + + +async def test_andor_controller_deadtime(RE, andor_controller: Andor2Controller): + assert andor_controller.get_deadtime(2) == 2 + MIN_DEAD_TIME + assert andor_controller.get_deadtime(None) == MIN_DEAD_TIME + + +@pytest.fixture +async def andor2(static_path_provider: StaticPathProvider) -> Andor2: + async with DeviceCollector(mock=True): + andor2 = Andor2("p99", static_path_provider, "andor2") + + set_mock_value(andor2._controller._drv.array_size_x, 10) + set_mock_value(andor2._controller._drv.array_size_y, 20) + set_mock_value(andor2.hdf.file_path_exists, True) + set_mock_value(andor2.hdf.num_captured, 0) + set_mock_value(andor2.hdf.file_path, str(static_path_provider._directory_path)) + set_mock_value( + andor2.hdf.full_file_name, + str(static_path_provider._directory_path) + "/test-andor2-hdf0", + ) + + rbv_mocks = Mock() + rbv_mocks.get.side_effect = range(0, 10000) + callback_on_mock_put( + andor2._writer.hdf.capture, + lambda *_, **__: set_mock_value(andor2._writer.hdf.capture, value=True), + ) + + callback_on_mock_put( + andor2.drv.acquire, + lambda *_, **__: set_mock_value( + andor2._writer.hdf.num_captured, rbv_mocks.get() + ), + ) + + return andor2 + + +async def test_andor2_RE( + RE: RunEngine, + andor2: Andor2, + static_path_provider: StaticPathProvider, +): + docs = defaultdict(list) + + def capture_emitted(name, doc): + docs[name].append(doc) + + RE.subscribe(capture_emitted) + set_mock_value(andor2.drv.detector_state, DetectorState.Idle) + RE(count([andor2], 10)) + assert ( + str(static_path_provider._directory_path) + == await andor2.hdf.file_path.get_value() + ) + assert ( + str(static_path_provider._directory_path) + "/test-andor2-hdf0" + == await andor2.hdf.full_file_name.get_value() + ) + assert_emitted( + docs, + start=1, + descriptor=1, + stream_resource=1, + stream_datum=10, + event=10, + stop=1, + )