From 18277c8847f8d1c9a9d5d2e5cc1ee1fbb43a2c97 Mon Sep 17 00:00:00 2001 From: Callum Forrester Date: Fri, 5 Apr 2024 13:51:51 +0100 Subject: [PATCH] Add Beam Current Monitors for I22 and P38 (#402) * Pin to ophyd-async 0.3 alpha release * Write Tetramm Device Based on I22 Experiment * The device is an ophyd-async standard detector, also include unit tests. * Define current monitor configs for I22 and * Additional tetramm tests for full coverage * Improve exception when wrong trigger types used --- pyproject.toml | 8 +- src/dodal/beamlines/i22.py | 37 +++ src/dodal/beamlines/p38.py | 23 +- src/dodal/devices/tetramm.py | 240 +++++++++++++++ .../unit_tests/test_device_instantiation.py | 2 +- tests/devices/unit_tests/conftest.py | 14 + tests/devices/unit_tests/test_tetramm.py | 279 ++++++++++++++++++ 7 files changed, 595 insertions(+), 8 deletions(-) create mode 100644 src/dodal/beamlines/i22.py create mode 100644 src/dodal/devices/tetramm.py create mode 100644 tests/devices/unit_tests/test_tetramm.py diff --git a/pyproject.toml b/pyproject.toml index 1deadf9edb..8b839727f6 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -14,7 +14,7 @@ classifiers = [ description = "Ophyd devices and other utils that could be used across DLS beamlines" dependencies = [ "ophyd", - "ophyd-async@git+https://github.com/bluesky/ophyd-async", + "ophyd-async>=0.3a1", "bluesky", "pyepics", "dataclasses-json", @@ -23,9 +23,9 @@ dependencies = [ "requests", "graypy", "pydantic", - "opencv-python-headless", # For pin-tip detection. - "aioca", # Required for CA support with ophyd-async. - "p4p", # Required for PVA support with ophyd-async. + "opencv-python-headless", # For pin-tip detection. + "aioca", # Required for CA support with ophyd-async. + "p4p", # Required for PVA support with ophyd-async. "numpy", ] diff --git a/src/dodal/beamlines/i22.py b/src/dodal/beamlines/i22.py new file mode 100644 index 0000000000..ed669446e4 --- /dev/null +++ b/src/dodal/beamlines/i22.py @@ -0,0 +1,37 @@ +from dodal.beamlines.beamline_utils import device_instantiation, get_directory_provider +from dodal.beamlines.beamline_utils import set_beamline as set_utils_beamline +from dodal.devices.tetramm import TetrammDetector +from dodal.log import set_beamline as set_log_beamline +from dodal.utils import get_beamline_name + +BL = get_beamline_name("i22") +set_log_beamline(BL) +set_utils_beamline(BL) + + +def i0( + wait_for_connection: bool = True, + fake_with_ophyd_sim: bool = False, +) -> TetrammDetector: + return device_instantiation( + TetrammDetector, + "i0", + "-EA-XBPM-02", + wait_for_connection, + fake_with_ophyd_sim, + directory_provider=get_directory_provider(), + ) + + +def it( + wait_for_connection: bool = True, + fake_with_ophyd_sim: bool = False, +) -> TetrammDetector: + return device_instantiation( + TetrammDetector, + "it", + "-EA-TTRM-02", + wait_for_connection, + fake_with_ophyd_sim, + directory_provider=get_directory_provider(), + ) diff --git a/src/dodal/beamlines/p38.py b/src/dodal/beamlines/p38.py index 1c0a30923a..c23fb23a1c 100644 --- a/src/dodal/beamlines/p38.py +++ b/src/dodal/beamlines/p38.py @@ -1,6 +1,7 @@ -from dodal.beamlines.beamline_utils import device_instantiation +from dodal.beamlines.beamline_utils import device_instantiation, get_directory_provider from dodal.beamlines.beamline_utils import set_beamline as set_utils_beamline from dodal.devices.areadetector import AdAravisDetector +from dodal.devices.tetramm import TetrammDetector from dodal.log import set_beamline as set_log_beamline from dodal.utils import get_beamline_name @@ -10,7 +11,8 @@ def d11( - wait_for_connection: bool = True, fake_with_ophyd_sim: bool = False + wait_for_connection: bool = True, + fake_with_ophyd_sim: bool = False, ) -> AdAravisDetector: return device_instantiation( AdAravisDetector, @@ -22,7 +24,8 @@ def d11( def d12( - wait_for_connection: bool = True, fake_with_ophyd_sim: bool = False + wait_for_connection: bool = True, + fake_with_ophyd_sim: bool = False, ) -> AdAravisDetector: return device_instantiation( AdAravisDetector, @@ -31,3 +34,17 @@ def d12( wait_for_connection, fake_with_ophyd_sim, ) + + +def i0( + wait_for_connection: bool = True, + fake_with_ophyd_sim: bool = False, +) -> TetrammDetector: + return device_instantiation( + TetrammDetector, + "i0", + "-EA-XBPM-01", + wait_for_connection, + fake_with_ophyd_sim, + directory_provider=get_directory_provider(), + ) diff --git a/src/dodal/devices/tetramm.py b/src/dodal/devices/tetramm.py new file mode 100644 index 0000000000..aa8830c40b --- /dev/null +++ b/src/dodal/devices/tetramm.py @@ -0,0 +1,240 @@ +import asyncio +from enum import Enum +from typing import Sequence + +from bluesky.protocols import Hints +from ophyd_async.core import ( + AsyncStatus, + DetectorControl, + DetectorTrigger, + Device, + DirectoryProvider, + ShapeProvider, + StandardDetector, + set_and_wait_for_value, +) +from ophyd_async.epics.areadetector.utils import ad_r, ad_rw, stop_busy_record +from ophyd_async.epics.areadetector.writers import HDFWriter, NDFileHDF +from ophyd_async.epics.signal import epics_signal_r, epics_signal_rw + + +class TetrammRange(str, Enum): + uA = "+- 120 uA" + nA = "+- 120 nA" + + +class TetrammTrigger(str, Enum): + FreeRun = "Free run" + ExtTrigger = "Ext. trig." + ExtBulb = "Ext. bulb" + ExtGate = "Ext. gate" + + +class TetrammChannels(str, Enum): + One = "1" + Two = "2" + Four = "4" + + +class TetrammResolution(str, Enum): + SixteenBits = "16 bits" + TwentyFourBits = "24 bits" + + +class TetrammGeometry(str, Enum): + Diamond = "Diamond" + Square = "Square" + + +class TetrammDriver(Device): + def __init__( + self, + prefix: str, + name: str = "", + ): + self._prefix = prefix + self.range = ad_rw(TetrammRange, prefix + "Range") + self.sample_time = ad_r(float, prefix + "SampleTime") + + self.values_per_reading = ad_rw(int, prefix + "ValuesPerRead") + self.averaging_time = ad_rw(float, prefix + "AveragingTime") + self.to_average = ad_r(int, prefix + "NumAverage") + self.averaged = ad_r(int, prefix + "NumAveraged") + + self.acquire = ad_rw(bool, prefix + "Acquire") + + # this PV is special, for some reason it doesn't have a _RBV suffix... + self.overflows = epics_signal_r(int, prefix + "RingOverflows") + + self.num_channels = ad_rw(TetrammChannels, prefix + "NumChannels") + self.resolution = ad_rw(TetrammResolution, prefix + "Resolution") + self.trigger_mode = ad_rw(TetrammTrigger, prefix + "TriggerMode") + self.bias = ad_rw(bool, prefix + "BiasState") + self.bias_volts = ad_rw(float, prefix + "BiasVoltage") + self.geometry = ad_rw(TetrammGeometry, prefix + "Geometry") + self.nd_attributes_file = epics_signal_rw(str, prefix + "NDAttributesFile") + + super().__init__(name=name) + + +class TetrammController(DetectorControl): + """Controller for a TetrAMM current monitor + + Attributes: + base_sample_rate (int): Fixed in hardware + + Args: + drv (TetrammDriver): A configured driver for the device + maximum_readings_per_frame (int): Maximum number of readings per frame: actual readings may be lower if higher frame rate is required + minimum_values_per_reading (int): Lower bound on the values that will be averaged to create a single reading + readings_per_frame (int): Actual number of readings per frame. + + """ + + base_sample_rate: int = 100_000 + + def __init__( + self, + drv: TetrammDriver, + minimum_values_per_reading: int = 5, + maximum_readings_per_frame: int = 1_000, + readings_per_frame: int = 1_000, + ): + # TODO: Are any of these also fixed by hardware constraints? + self._drv = drv + self.maximum_readings_per_frame = maximum_readings_per_frame + self.minimum_values_per_reading = minimum_values_per_reading + self.readings_per_frame = readings_per_frame + + def get_deadtime(self, exposure: float) -> float: + # 2 internal clock cycles. Best effort approximation + return 2 / self.base_sample_rate + + async def arm( + self, + num: int, + trigger: DetectorTrigger, + exposure: float, + ) -> AsyncStatus: + self._validate_trigger(trigger) + + # trigger mode must be set first and on its own! + await self._drv.trigger_mode.set(TetrammTrigger.ExtTrigger) + + await asyncio.gather( + self._drv.averaging_time.set(exposure), self.set_frame_time(exposure) + ) + + status = await set_and_wait_for_value(self._drv.acquire, 1) + + return status + + def _validate_trigger(self, trigger: DetectorTrigger) -> None: + supported_trigger_types = { + DetectorTrigger.edge_trigger, + DetectorTrigger.constant_gate, + } + + if trigger not in supported_trigger_types: + raise ValueError( + f"{self.__class__.__name__} only supports the following trigger " + f"types: {supported_trigger_types} but was asked to " + f"use {trigger}" + ) + + async def disarm(self): + await stop_busy_record(self._drv.acquire, 0, timeout=1) + + async def set_frame_time(self, frame_time: float): + """Tries to set the exposure time of a single frame. + + As during the exposure time, the device must collect an integer number + of readings, in the case where the frame_time is not a multiple of the base + sample rate, it will be lowered to the prior multiple ot ensure triggers + are not missed. + + Args: + frame_time (float): The time for a single frame in seconds + + Raises: + ValueError: If frame_time is too low to collect the required number + of readings per frame. + """ + + values_per_reading: int = int( + frame_time * self.base_sample_rate / self.readings_per_frame + ) + + if values_per_reading < self.minimum_values_per_reading: + raise ValueError( + f"frame_time {frame_time} is too low to collect at least " + f"{self.minimum_values_per_reading} values per reading, at " + f"{self.readings_per_frame} readings per frame." + ) + await self._drv.values_per_reading.set(values_per_reading) + + @property + def max_frame_rate(self) -> float: + """Max frame rate in Hz for the current configuration""" + return 1 / self.minimum_frame_time + + @max_frame_rate.setter + def max_frame_rate(self, mfr: float): + self.minimum_frame_time = 1 / mfr + + @property + def minimum_frame_time(self) -> float: + """Smallest amount of time needed to take a frame""" + time_per_reading = self.minimum_values_per_reading / self.base_sample_rate + return self.readings_per_frame * time_per_reading + + @minimum_frame_time.setter + def minimum_frame_time(self, frame: float): + time_per_reading = self.minimum_values_per_reading / self.base_sample_rate + self.readings_per_frame = int( + min(self.maximum_readings_per_frame, frame / time_per_reading) + ) + + +class TetrammShapeProvider(ShapeProvider): + max_channels = 11 + + def __init__(self, controller: TetrammController) -> None: + self.controller = controller + + async def __call__(self) -> Sequence[int]: + return [self.max_channels, self.controller.readings_per_frame] + + +# TODO: Support MeanValue signals https://github.com/DiamondLightSource/dodal/issues/337 +class TetrammDetector(StandardDetector): + def __init__( + self, + prefix: str, + directory_provider: DirectoryProvider, + name: str, + **scalar_sigs: str, + ) -> None: + self.drv = TetrammDriver(prefix + "DRV:") + self.hdf = NDFileHDF(prefix + "HDF5:") + controller = TetrammController(self.drv) + super().__init__( + controller, + HDFWriter( + self.hdf, + directory_provider, + lambda: self.name, + TetrammShapeProvider(controller), + **scalar_sigs, + ), + [ + self.drv.values_per_reading, + self.drv.averaging_time, + self.drv.sample_time, + ], + name, + ) + + @property + def hints(self) -> Hints: + return {"fields": [self.name]} diff --git a/tests/beamlines/unit_tests/test_device_instantiation.py b/tests/beamlines/unit_tests/test_device_instantiation.py index 65fb5fde26..6d35f187e6 100644 --- a/tests/beamlines/unit_tests/test_device_instantiation.py +++ b/tests/beamlines/unit_tests/test_device_instantiation.py @@ -5,7 +5,7 @@ from dodal.beamlines import beamline_utils from dodal.utils import BLUESKY_PROTOCOLS, make_all_devices -ALL_BEAMLINES = {"i03", "i04", "i04_1", "i23", "i24", "p38", "p45"} +ALL_BEAMLINES = {"i03", "i04", "i04_1", "i23", "i24", "p38", "i22", "p45"} def follows_bluesky_protocols(obj: Any) -> bool: diff --git a/tests/devices/unit_tests/conftest.py b/tests/devices/unit_tests/conftest.py index 6dcbe07a0b..44bac62f8d 100644 --- a/tests/devices/unit_tests/conftest.py +++ b/tests/devices/unit_tests/conftest.py @@ -1,8 +1,11 @@ from functools import partial +from pathlib import Path from unittest.mock import MagicMock, patch +import pytest from ophyd.epics_motor import EpicsMotor from ophyd.status import Status +from ophyd_async.core import DirectoryInfo, DirectoryProvider, StaticDirectoryProvider from dodal.devices.util.motor_utils import ExtendedEpicsMotor @@ -21,3 +24,14 @@ def patch_motor(motor: EpicsMotor | ExtendedEpicsMotor, initial_position=0): if isinstance(motor, ExtendedEpicsMotor): motor.motor_resolution.sim_put(0.001) # type: ignore return patch.object(motor, "set", MagicMock(side_effect=partial(mock_set, motor))) + + +DIRECTORY_INFO_FOR_TESTING: DirectoryInfo = DirectoryInfo( + root=Path("/does/not/exist"), + resource_dir=Path("/on/this/filesystem"), +) + + +@pytest.fixture +def static_directory_provider(tmp_path: Path) -> DirectoryProvider: + return StaticDirectoryProvider(tmp_path) diff --git a/tests/devices/unit_tests/test_tetramm.py b/tests/devices/unit_tests/test_tetramm.py new file mode 100644 index 0000000000..ae790812bf --- /dev/null +++ b/tests/devices/unit_tests/test_tetramm.py @@ -0,0 +1,279 @@ +import pytest +from bluesky.run_engine import RunEngine +from ophyd_async.core import ( + DetectorTrigger, + DeviceCollector, + DirectoryProvider, + set_sim_value, +) +from ophyd_async.core.detector import TriggerInfo +from ophyd_async.epics.areadetector import FileWriteMode + +from dodal.devices.tetramm import ( + TetrammController, + TetrammDetector, + TetrammDriver, + TetrammTrigger, +) + +TEST_TETRAMM_NAME = "foobar" + + +@pytest.fixture +async def tetramm_driver(RE: RunEngine) -> TetrammDriver: + async with DeviceCollector(sim=True): + driver = TetrammDriver("DRIVER:") + + return driver + + +@pytest.fixture +async def tetramm_controller( + RE: RunEngine, tetramm_driver: TetrammDriver +) -> TetrammController: + async with DeviceCollector(sim=True): + controller = TetrammController( + tetramm_driver, + maximum_readings_per_frame=2_000, + ) + + return controller + + +@pytest.fixture +async def tetramm(static_directory_provider: DirectoryProvider) -> TetrammDetector: + async with DeviceCollector(sim=True): + tetramm = TetrammDetector( + "MY-TETRAMM:", + static_directory_provider, + name=TEST_TETRAMM_NAME, + ) + + return tetramm + + +async def test_max_frame_rate_is_calculated_correctly( + tetramm_controller: TetrammController, +): + tetramm_controller.minimum_frame_time = 2.0 + + assert tetramm_controller.minimum_frame_time == 0.1 + assert tetramm_controller.max_frame_rate == 10.0 + + # Ensure that the minimum frame time is correctly calculated given a maximum + # frame rate. + # max_frame_rate**-1 = minimum_frame_times + tetramm_controller.max_frame_rate = 20.0 + assert tetramm_controller.minimum_frame_time == pytest.approx(1 / 20) + + +def test_min_frame_time_is_calculated_correctly( + tetramm_controller: TetrammController, +): + tetramm_controller = tetramm_controller + # Using coprimes to ensure the solution has a unique relation to the values. + tetramm_controller.base_sample_rate = 100_000 + tetramm_controller.readings_per_frame = 999 + tetramm_controller.maximum_readings_per_frame = 1_001 + tetramm_controller.minimum_values_per_reading = 17 + + # min_frame_time (s/f) = max_readings_per_frame * values_per_reading / sample_rate (v/s) + minimum_frame_time = ( + tetramm_controller.readings_per_frame + * tetramm_controller.minimum_values_per_reading + / float(tetramm_controller.base_sample_rate) + ) + + assert tetramm_controller.minimum_frame_time == pytest.approx(minimum_frame_time) + + # From rearranging the above + # readings_per_frame = frame_time * sample_rate / values_per_reading + + readings_per_time = ( + tetramm_controller.base_sample_rate + / tetramm_controller.minimum_values_per_reading + ) + + # 100_000 / 17 ~ 5800; 5800 * 0.01 = 58; 58 << tetramm_controller.maximum_readings_per_frame + tetramm_controller.minimum_frame_time = 0.01 + assert tetramm_controller.readings_per_frame == int(readings_per_time * 0.01) + + # 100_000 / 17 ~ 5800; 5800 * 0.2 = 1160; 1160 > tetramm_controller.maximum_readings_per_frame + tetramm_controller.minimum_frame_time = 0.2 + assert ( + tetramm_controller.readings_per_frame + == tetramm_controller.maximum_readings_per_frame + ) + + # 100_000 / 17 ~ 5800; 5800 * 0.2 = 1160; 1160 < 1200 + tetramm_controller.maximum_readings_per_frame = 1200 + tetramm_controller.minimum_frame_time = 0.1 + assert tetramm_controller.readings_per_frame == int(readings_per_time * 0.1) + + +VALID_TEST_EXPOSURE_TIME = 1 / 19 + + +async def test_set_frame_time_updates_values_per_reading( + tetramm_controller: TetrammController, + tetramm_driver: TetrammDriver, +): + await tetramm_controller.set_frame_time(VALID_TEST_EXPOSURE_TIME) + values_per_reading = await tetramm_driver.values_per_reading.get_value() + assert values_per_reading == 5 + + +async def test_set_invalid_frame_time_for_number_of_values_per_reading( + tetramm_controller: TetrammController, +): + """ + frame_time >= readings_per_frame * values_per_reading / sample_rate + With the default values: + base_sample_rate = 100_000 + minimum_values_per_reading = 5 + readings_per_frame = 1_000 + frame_time >= 1_000 * 5 / 100_000 = 1/20 + """ + + with pytest.raises( + ValueError, + match="frame_time 0.02 is too low to collect at least 5 values per reading, at 1000 readings per frame.", + ): + await (await tetramm_controller.arm(-1, DetectorTrigger.edge_trigger, 1 / 50)) + + +@pytest.mark.parametrize( + "trigger_type", + [ + DetectorTrigger.internal, + DetectorTrigger.variable_gate, + ], +) +async def test_arm_raises_value_error_for_invalid_trigger_type( + tetramm_controller: TetrammController, + trigger_type: DetectorTrigger, +): + accepted_types = { + DetectorTrigger.edge_trigger, + DetectorTrigger.constant_gate, + } + with pytest.raises( + ValueError, + match="TetrammController only supports the following trigger " + f"types: {accepted_types} but was asked to " + f"use {trigger_type}", + ): + await tetramm_controller.arm( + -1, + trigger_type, + VALID_TEST_EXPOSURE_TIME, + ) + + +@pytest.mark.parametrize( + "trigger_type", + [ + DetectorTrigger.edge_trigger, + DetectorTrigger.constant_gate, + ], +) +async def test_arm_sets_signals_correctly_given_valid_inputs( + tetramm_controller: TetrammController, + tetramm_driver: TetrammDriver, + trigger_type: DetectorTrigger, +): + arm_status = await tetramm_controller.arm( + -1, trigger_type, VALID_TEST_EXPOSURE_TIME + ) + await arm_status + + await assert_armed(tetramm_driver) + + +async def test_disarm_disarms_driver( + tetramm_controller: TetrammController, + tetramm_driver: TetrammDriver, +): + assert (await tetramm_driver.acquire.get_value()) == 0 + arm_status = await tetramm_controller.arm( + -1, DetectorTrigger.edge_trigger, VALID_TEST_EXPOSURE_TIME + ) + await arm_status + assert (await tetramm_driver.acquire.get_value()) == 1 + await tetramm_controller.disarm() + assert (await tetramm_driver.acquire.get_value()) == 0 + + +async def test_hints_self_by_default(tetramm: TetrammDetector): + assert tetramm.hints == {"fields": [TEST_TETRAMM_NAME]} + + +async def test_prepare_with_too_low_a_deadtime_raises_error( + tetramm: TetrammDetector, +): + with pytest.raises( + AssertionError, + match=r"Detector .* needs at least 2e-05s deadtime, but trigger logic " + "provides only 1e-05s", + ): + await tetramm.prepare( + TriggerInfo( + 5, + DetectorTrigger.edge_trigger, + 1.0 / 100_000.0, + VALID_TEST_EXPOSURE_TIME, + ) + ) + + +async def test_prepare_arms_tetramm( + tetramm: TetrammDetector, +): + await tetramm.prepare( + TriggerInfo( + 5, + DetectorTrigger.edge_trigger, + 0.1, + VALID_TEST_EXPOSURE_TIME, + ) + ) + await assert_armed(tetramm.drv) + + +async def test_stage_sets_up_writer( + tetramm: TetrammDetector, +): + set_sim_value(tetramm.hdf.file_path_exists, 1) + await tetramm.stage() + + assert (await tetramm.hdf.num_capture.get_value()) == 0 + assert (await tetramm.hdf.num_extra_dims.get_value()) == 0 + assert await tetramm.hdf.lazy_open.get_value() + assert await tetramm.hdf.swmr_mode.get_value() + assert (await tetramm.hdf.file_template.get_value()) == "%s/%s.h5" + assert (await tetramm.hdf.file_write_mode.get_value()) == FileWriteMode.stream + + +async def test_stage_sets_up_accurate_describe_output( + tetramm: TetrammDetector, +): + assert tetramm.describe() == {} + + set_sim_value(tetramm.hdf.file_path_exists, 1) + await tetramm.stage() + + assert tetramm.describe() == { + TEST_TETRAMM_NAME: { + "source": "sim://MY-TETRAMM:HDF5:FullFileName_RBV", + "shape": (11, 1000), + "dtype": "array", + "external": "STREAM:", + } + } + + +async def assert_armed(driver: TetrammDriver) -> None: + assert (await driver.trigger_mode.get_value()) is TetrammTrigger.ExtTrigger + assert (await driver.averaging_time.get_value()) == VALID_TEST_EXPOSURE_TIME + assert (await driver.values_per_reading.get_value()) == 5 + assert (await driver.acquire.get_value()) == 1