diff --git a/src/dodal/beamlines/p38.py b/src/dodal/beamlines/p38.py index 11da31b0da..33e0798810 100644 --- a/src/dodal/beamlines/p38.py +++ b/src/dodal/beamlines/p38.py @@ -15,6 +15,8 @@ from dodal.devices.i22.dcm import CrystalMetadata, DoubleCrystalMonochromator from dodal.devices.i22.fswitch import FSwitch from dodal.devices.linkam3 import Linkam3 +from dodal.devices.pressure_jump_cell import PressureJumpCell +from dodal.devices.areadetector import PressureJumpCellDetector from dodal.devices.slits import Slits from dodal.devices.tetramm import TetrammDetector from dodal.devices.undulator import Undulator @@ -314,7 +316,36 @@ def linkam( return device_instantiation( Linkam3, "linkam", - "-EA-LINKM-02:", + f"{BeamlinePrefix(BL).insertion_prefix}-EA-LINKM-02:", wait_for_connection, fake_with_ophyd_sim, ) + + +def high_pressure_xray_cell( + wait_for_connection: bool = True, fake_with_ophyd_sim: bool = False +) -> PressureJumpCell: + return device_instantiation( + PressureJumpCell, + "high_pressure_xray_cell", + f"{BeamlinePrefix(BL).insertion_prefix}-EA", + wait_for_connection, + fake_with_ophyd_sim, + cell_prefix="-HPXC-01:", + adc_prefix="-ADC", + ) + +def high_pressure_xray_cell_adc( + wait_for_connection: bool = True, fake_with_ophyd_sim: bool = False +) -> PressureJumpCellDetector: + return device_instantiation( + PressureJumpCellDetector, + "high_pressure_xray_cell_adc", + "-EA-HPXC-01:", + wait_for_connection, + fake_with_ophyd_sim, + adc_suffix="TRIG:", + drv_suffix="DET:", + hdf_suffix="FILE:", + path_provider=get_path_provider(), + ) diff --git a/src/dodal/devices/areadetector/__init__.py b/src/dodal/devices/areadetector/__init__.py index 78171d5ef5..e2ba794ab8 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 .pressurejumpcell import PressureJumpCellDetector __all__ = [ "AdSimDetector", "SynchronisedAdDriverBase", "Hdf5Writer", "AdAravisDetector", + "PressureJumpCellDetector", ] diff --git a/src/dodal/devices/areadetector/pressurejumpcell.py b/src/dodal/devices/areadetector/pressurejumpcell.py new file mode 100644 index 0000000000..ae7c3650ac --- /dev/null +++ b/src/dodal/devices/areadetector/pressurejumpcell.py @@ -0,0 +1,48 @@ +from typing import get_args + +from bluesky.protocols import HasHints, Hints + +from ophyd_async.core import PathProvider, StandardDetector +from ophyd_async.epics import adcore + +from .pressurejumpcell_controller import PressureJumpCellController +from .pressurejumpcell_io import PressureJumpCellDriverIO, PressureJumpCellAdcIO + + +class PressureJumpCellDetector(StandardDetector, HasHints): + """ + Ophyd-async implementation of a Pressure Jump Cell ADC Detector for fast pressure jumps. + The detector may be configured for an external trigger on the TTL Trig input. + """ + + _controller: PressureJumpCellController + _writer: adcore.ADHDFWriter + + def __init__( + self, + prefix: str, + path_provider: PathProvider, + drv_suffix="cam1:", + adc_suffix="TRIG", + hdf_suffix="HDF1:", + name="", + ): + self.drv = PressureJumpCellDriverIO(prefix + drv_suffix) + self.adc = PressureJumpCellAdcIO(prefix + adc_suffix) + self.hdf = adcore.NDFileHDFIO(prefix + hdf_suffix) + + super().__init__( + PressureJumpCellController(self.drv, self.adc), + adcore.ADHDFWriter( + self.hdf, + path_provider, + lambda: self.name, + adcore.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/pressurejumpcell_controller.py b/src/dodal/devices/areadetector/pressurejumpcell_controller.py new file mode 100644 index 0000000000..986f0d361c --- /dev/null +++ b/src/dodal/devices/areadetector/pressurejumpcell_controller.py @@ -0,0 +1,82 @@ +import asyncio +from typing import Optional, Tuple + +from ophyd_async.core import ( + AsyncStatus, + DetectorControl, + DetectorTrigger, + set_and_wait_for_value, +) +from ophyd_async.epics import adcore + +from .pressurejumpcell_io import ( + PressureJumpCellDriverIO, + PressureJumpCellAdcIO, + PressureJumpCellTriggerMode, + PressureJumpCellAdcTriggerMode, +) + + +#TODO Find out what the deadtime should be and if it can be retrieved from the device +_HIGHEST_POSSIBLE_DEADTIME = 1e-3 + + +class PressureJumpCellController(DetectorControl): + + def __init__(self, driver: PressureJumpCellDriverIO, adc: PressureJumpCellAdcIO) -> None: + self._drv = driver + self._adc = adc + + def get_deadtime(self, exposure: float | None) -> float: + return _HIGHEST_POSSIBLE_DEADTIME + + async def arm( + self, + num: int = 0, + trigger: DetectorTrigger = DetectorTrigger.internal, + exposure: Optional[float] = None, + ) -> AsyncStatus: + if num == 0: + image_mode = adcore.ImageMode.continuous + else: + image_mode = adcore.ImageMode.multiple + if exposure is not None: + await self._drv.acquire_time.set(exposure) + + trigger_mode, adc_trigger_mode = self._get_trigger_info(trigger) + + # trigger mode must be set first and on it's own! + await self._drv.trigger_mode.set(trigger_mode) + await self._adc.adc_trigger_mode.set(adc_trigger_mode) + + await asyncio.gather( + self._drv.num_images.set(num), + self._drv.image_mode.set(image_mode), + ) + + status = await set_and_wait_for_value(self._drv.acquire, True) + return status + + def _get_trigger_info( + self, trigger: DetectorTrigger + ) -> Tuple[PressureJumpCellTriggerMode, PressureJumpCellAdcTriggerMode]: + supported_trigger_types = ( + DetectorTrigger.edge_trigger, + DetectorTrigger.internal, + ) + 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}" + ) + if trigger == DetectorTrigger.internal: + return (PressureJumpCellTriggerMode.internal, PressureJumpCellAdcTriggerMode.continuous) + else: + return (PressureJumpCellTriggerMode.external, PressureJumpCellAdcTriggerMode.single) + + async def disarm(self): + await asyncio.gather( + adcore.stop_busy_record(self._drv.acquire, False, timeout=1), + adcore.stop_busy_record(self._adc.acquire, False, timeout=1) + ) diff --git a/src/dodal/devices/areadetector/pressurejumpcell_io.py b/src/dodal/devices/areadetector/pressurejumpcell_io.py new file mode 100644 index 0000000000..d0104916b6 --- /dev/null +++ b/src/dodal/devices/areadetector/pressurejumpcell_io.py @@ -0,0 +1,29 @@ +from enum import Enum + +from ophyd_async.core import SubsetEnum +from ophyd_async.epics import adcore +from ophyd_async.epics.signal import epics_signal_rw_rbv + + +class PressureJumpCellTriggerMode(str, Enum): + internal = "Internal" + external = "Exernal" + +class PressureJumpCellAdcTriggerMode(str, Enum): + single = "Single" + multiple = "Multiple" + continuous = "Continuous" + +class PressureJumpCellDriverIO(adcore.ADBaseIO): + def __init__(self, prefix: str, name: str = "") -> None: + self.trigger_mode = epics_signal_rw_rbv( + PressureJumpCellTriggerMode, prefix + "TriggerMode" + ) + super().__init__(prefix, name=name) + +class PressureJumpCellAdcIO(adcore.ADBaseIO): + def __init__(self, prefix: str, name: str = "") -> None: + self.adc_trigger_mode = epics_signal_rw_rbv( + PressureJumpCellAdcTriggerMode, prefix + "TriggerMode" + ) + super().__init__(prefix, name=name) diff --git a/src/dodal/devices/pressure_jump_cell.py b/src/dodal/devices/pressure_jump_cell.py new file mode 100644 index 0000000000..2b8686e9fc --- /dev/null +++ b/src/dodal/devices/pressure_jump_cell.py @@ -0,0 +1,273 @@ +import asyncio +from dataclasses import dataclass +from enum import Enum + +from bluesky.protocols import HasName, Movable +from ophyd_async.core import ( + AsyncStatus, + ConfigSignal, + DeviceVector, + SignalR, + SignalRW, + StandardReadable, +) +from ophyd_async.epics.signal import epics_signal_r, epics_signal_rw + + +class PumpState(str, Enum): + MANUAL = "Manual" + AUTO_PRESSURE = "Auto Pressure" + AUTO_POSITION = "Auto Position" + + +class BusyState(str, Enum): + IDLE = "Idle" + BUSY = "Busy" + + +class TimerState(str, Enum): + TIMEOUT = "TIMEOUT" + COUNTDOWN = "COUNTDOWN" + + +class StopState(str, Enum): + CONTINUE = "CONTINUE" + STOP = "STOP" + + +class FastValveControlRequest(str, Enum): + OPEN = "Open" + CLOSE = "Close" + RESET = "Reset" + ARM = "Arm" + DISARM = "Disarm" + + +class ValveControlRequest(str, Enum): + OPEN = "Open" + CLOSE = "Close" + RESET = "Reset" + + +class PumpMotorControlRequest(str, Enum): + ENABLE = "Enable" + DISABLE = "Disable" + RESET = "Reset" + FORWARD = "Forward" + REVERSE = "Reverse" + + +class PumpMotorDirectionState(str, Enum): + EMPTY = "" + FORWARD = "Forward" + REVERSE = "Reverse" + + +class ValveState(str, Enum): + FAULT = "Fault" + OPEN = "Open" + OPENING = "Opening" + CLOSED = "Closed" + CLOSING = "Closing" + + +class FastValveState(str, Enum): + FAULT = "Fault" + OPEN = "Open" + OPEN_ARMED = "Open Armed" + CLOSED = "Closed" + CLOSED_ARMED = "Closed Armed" + NONE = "Unused" + + +class LimitSwitchState(str, Enum): + OFF = "Off" + ON = "On" + + +@dataclass +class AllValvesControlState: + valve_1: ValveControlRequest | None = None + valve_3: ValveControlRequest | None = None + valve_5: FastValveControlRequest | None = None + valve_6: FastValveControlRequest | None = None + + +class AllValvesControl(StandardReadable, Movable): + """ + valves 2, 4, 7, 8 are not controlled by the IOC, + as they are under manual control. + fast_valves: tuple[int, ...] = (5, 6) + slow_valves: tuple[int, ...] = (1, 3) + """ + + def __init__( + self, + prefix: str, + name: str = "", + fast_valves: tuple[int, ...] = (5, 6), + slow_valves: tuple[int, ...] = (1, 3), + ) -> None: + self.fast_valves = fast_valves + self.slow_valves = slow_valves + with self.add_children_as_readables(): + self.valve_states: DeviceVector[SignalR[ValveState]] = DeviceVector( + { + i: epics_signal_r(ValveState, f"{prefix}V{i}:STA") + for i in self.slow_valves + } + ) + self.fast_valve_states: DeviceVector[SignalR[FastValveState]] = ( + DeviceVector( + { + i: epics_signal_r(FastValveState, f"{prefix}V{i}:STA") + for i in self.fast_valves + } + ) + ) + + self.fast_valve_control: DeviceVector[SignalRW[FastValveControlRequest]] = ( + DeviceVector( + { + i: epics_signal_rw(FastValveControlRequest, f"{prefix}V{i}:CON") + for i in self.fast_valves + } + ) + ) + + self.valve_control: DeviceVector[SignalRW[ValveControlRequest]] = DeviceVector( + { + i: epics_signal_rw(ValveControlRequest, f"{prefix}V{i}:CON") + for i in self.slow_valves + } + ) + + super().__init__(name) + + async def set_valve( + self, valve: int, value: ValveControlRequest | FastValveControlRequest + ): + if valve in self.slow_valves and isinstance(value, ValveControlRequest): + await self.valve_control[valve].set(value) + elif valve in self.fast_valves and isinstance(value, FastValveControlRequest): + await self.fast_valve_control[valve].set(value) + + @AsyncStatus.wrap + async def set(self, value: AllValvesControlState): + await asyncio.gather( + *( + self.set_valve(int(i[-1]), value) + for i, value in value.__dict__.items() + if value is not None + ) + ) + + +class Pump(StandardReadable): + def __init__(self, prefix: str, name: str = "") -> None: + with self.add_children_as_readables(): + self.pump_position = epics_signal_r(float, prefix + "POS") + self.pump_motor_direction = epics_signal_r( + PumpMotorDirectionState, prefix + "MTRDIR" + ) + self.pump_speed = epics_signal_rw( + float, write_pv=prefix + "MSPEED", read_pv=prefix + "MSPEED_RBV" + ) + + with self.add_children_as_readables(ConfigSignal): + self.pump_mode = epics_signal_rw(PumpState, prefix + "SP:AUTO") + + super().__init__(name) + + +class PressureTransducer(StandardReadable): + """ + Pressure transducer for a high pressure X-ray cell. + This is the chamber and there are three of them. + 1 is the start, 3 is where the sample is. + NOTE: the distinction between the adc prefix and the cell prefix is kept here. + + """ + + def __init__( + self, + prefix: str, + cell_prefix: str, + number: int, + name: str = "", + full_different_prefix_adc: str = "", + ) -> None: + final_prefix = f"{prefix}{cell_prefix}" + with self.add_children_as_readables(): + self.omron_pressure = epics_signal_r( + float, f"{final_prefix}PP{number}:PRES" + ) + self.omron_voltage = epics_signal_r(float, f"{final_prefix}PP{number}:RAW") + self.beckhoff_pressure = epics_signal_r( + float, f"{final_prefix}STATP{number}:MeanValue_RBV" + ) + self.slow_beckhoff_voltage_readout = epics_signal_r( + float, f"{full_different_prefix_adc}CH1" + ) + + super().__init__(name) + + +class PressureJumpCellController(HasName): + def __init__(self, prefix: str, name: str = "") -> None: + self.stop = epics_signal_rw(StopState, f"{prefix}STOP") + + self.target_pressure = epics_signal_rw(float, f"{prefix}TARGET") + self.timeout = epics_signal_rw(float, f"{prefix}TIMER.HIGH") + self.go = epics_signal_rw(bool, f"{prefix}GO") + + ## Jump logic ## + self.start_pressure = epics_signal_rw(float, f"{prefix}JUMPF") + self.target_pressure = epics_signal_rw(float, f"{prefix}JUMPT") + self.jump_ready = epics_signal_rw(bool, f"{prefix}SETJUMP") + + self._name = name + super().__init__() + + @property + def name(self): + return self._name + + +class PressureJumpCell(StandardReadable): + """ + High pressure X-ray cell, used to apply pressure or pressure jumps to a sample. + prefix: str + The prefix of beamline - SPECIAL - unusual that the cell prefix is computed separately + """ + + def __init__( + self, + prefix: str, + cell_prefix: str = "-HPXC-01:", + adc_prefix: str = "-ADC", + name: str = "", + ): + self.all_valves_control = AllValvesControl(f"{prefix}{cell_prefix}", name) + self.pump = Pump(f"{prefix}{cell_prefix}", name) + + self.controller = PressureJumpCellController( + f"{prefix}{cell_prefix}CTRL:", name + ) + + with self.add_children_as_readables(): + self.pressure_transducers: DeviceVector[PressureTransducer] = DeviceVector( + { + i: PressureTransducer( + prefix=prefix, + number=i, + cell_prefix=cell_prefix, + full_different_prefix_adc=f"{prefix}{adc_prefix}-0{i}:", + ) + for i in [1, 2, 3] + } + ) + + self.cell_temperature = epics_signal_r(float, f"{prefix}{cell_prefix}TEMP") + + super().__init__(name) diff --git a/tests/devices/unit_tests/test_pressure_jump_cell.py b/tests/devices/unit_tests/test_pressure_jump_cell.py new file mode 100644 index 0000000000..21c7b1df72 --- /dev/null +++ b/tests/devices/unit_tests/test_pressure_jump_cell.py @@ -0,0 +1,205 @@ +from unittest.mock import ANY + +import pytest +from ophyd_async.core import DeviceCollector, assert_reading, set_mock_value + +from dodal.devices.pressure_jump_cell import ( + FastValveState, + PressureJumpCell, + PumpMotorDirectionState, + ValveState, +) + + +@pytest.fixture +async def cell() -> PressureJumpCell: + async with DeviceCollector(mock=True): + pjump = PressureJumpCell("DEMO-PJUMPCELL-01:") + + return pjump + + +async def test_reading_pjumpcell_includes_read_fields_valves( + cell: PressureJumpCell, +): + set_mock_value(cell.all_valves_control.valve_states[1], ValveState.CLOSED) + set_mock_value(cell.all_valves_control.valve_states[3], ValveState.OPEN) + set_mock_value( + cell.all_valves_control.fast_valve_states[5], + FastValveState.CLOSED_ARMED, + ) + set_mock_value( + cell.all_valves_control.fast_valve_states[6], FastValveState.OPEN_ARMED + ) + + await assert_reading( + cell.all_valves_control, + { + "pjump-all_valves_control-valve_states-1": { + "value": ValveState.CLOSED, + "timestamp": ANY, + "alarm_severity": 0, + }, + "pjump-all_valves_control-valve_states-3": { + "value": ValveState.OPEN, + "timestamp": ANY, + "alarm_severity": 0, + }, + "pjump-all_valves_control-fast_valve_states-5": { + "value": FastValveState.CLOSED_ARMED, + "timestamp": ANY, + "alarm_severity": 0, + }, + "pjump-all_valves_control-fast_valve_states-6": { + "value": FastValveState.OPEN_ARMED, + "timestamp": ANY, + "alarm_severity": 0, + }, + }, + ) + + +async def test_reading_pjumpcell_includes_read_fields_pump( + cell: PressureJumpCell, +): + set_mock_value(cell.pump.pump_position, 100) + # set_mock_value(cell.pump.pump_forward_limit, LimitSwitchState.OFF) + # set_mock_value(cell.pump.pump_backward_limit, LimitSwitchState.ON) + set_mock_value( + cell.pump.pump_motor_direction, + PumpMotorDirectionState.FORWARD, + ) + set_mock_value(cell.pump.pump_speed, 100) + + await assert_reading( + cell.pump, + { + "pjump-pump-pump_position": { + "value": 100, + "timestamp": ANY, + "alarm_severity": 0, + }, + "pjump-pump-pump_motor_direction": { + "value": PumpMotorDirectionState.FORWARD, + "timestamp": ANY, + "alarm_severity": 0, + }, + "pjump-pump-pump_speed": { + "value": 100, + "timestamp": ANY, + "alarm_severity": 0, + }, + }, + ) + + +async def test_reading_pjumpcell_includes_read_fields_transducers( + cell: PressureJumpCell, +): + set_mock_value(cell.pressure_transducers[1].omron_pressure, 1001) + set_mock_value(cell.pressure_transducers[1].omron_voltage, 2.51) + set_mock_value(cell.pressure_transducers[1].beckhoff_pressure, 1001.1) + set_mock_value(cell.pressure_transducers[1].slow_beckhoff_voltage_readout, 2.51) + + set_mock_value(cell.pressure_transducers[2].omron_pressure, 1002) + set_mock_value(cell.pressure_transducers[2].omron_voltage, 2.52) + set_mock_value(cell.pressure_transducers[2].beckhoff_pressure, 1002.2) + set_mock_value(cell.pressure_transducers[2].slow_beckhoff_voltage_readout, 2.52) + + set_mock_value(cell.pressure_transducers[3].omron_pressure, 1003) + set_mock_value(cell.pressure_transducers[3].omron_voltage, 2.53) + set_mock_value(cell.pressure_transducers[3].beckhoff_pressure, 1003.3) + set_mock_value(cell.pressure_transducers[3].slow_beckhoff_voltage_readout, 2.53) + + await assert_reading( + cell.pressure_transducers[1], + { + "pjump-pressure_transducers-1-omron_pressure": { + "value": 1001, + "timestamp": ANY, + "alarm_severity": 0, + }, + "pjump-pressure_transducers-1-omron_voltage": { + "value": 2.51, + "timestamp": ANY, + "alarm_severity": 0, + }, + "pjump-pressure_transducers-1-beckhoff_pressure": { + "value": 1001.1, + "timestamp": ANY, + "alarm_severity": 0, + }, + "pjump-pressure_transducers-1-beckhoff_voltage": { + "value": 2.51, + "timestamp": ANY, + "alarm_severity": 0, + }, + }, + ) + await assert_reading( + cell.pressure_transducers[2], + { + "pjump-pressure_transducers-2-omron_pressure": { + "value": 1002, + "timestamp": ANY, + "alarm_severity": 0, + }, + "pjump-pressure_transducers-2-omron_voltage": { + "value": 2.52, + "timestamp": ANY, + "alarm_severity": 0, + }, + "pjump-pressure_transducers-2-beckhoff_pressure": { + "value": 1002.2, + "timestamp": ANY, + "alarm_severity": 0, + }, + "pjump-pressure_transducers-2-beckhoff_voltage": { + "value": 2.52, + "timestamp": ANY, + "alarm_severity": 0, + }, + }, + ) + await assert_reading( + cell.pressure_transducers[3], + { + "pjump-pressure_transducers-3-omron_pressure": { + "value": 1003, + "timestamp": ANY, + "alarm_severity": 0, + }, + "pjump-pressure_transducers-3-omron_voltage": { + "value": 2.53, + "timestamp": ANY, + "alarm_severity": 0, + }, + "pjump-pressure_transducers-3-beckhoff_pressure": { + "value": 1003.3, + "timestamp": ANY, + "alarm_severity": 0, + }, + "pjump-pressure_transducers-3-beckhoff_voltage": { + "value": 2.53, + "timestamp": ANY, + "alarm_severity": 0, + }, + }, + ) + + +async def test_reading_pjumpcell_includes_read_fields( + cell: PressureJumpCell, +): + set_mock_value(cell.cell_temperature, 12.3) + + await assert_reading( + cell.cell_temperature, + { + "pjump-cell_temperature": { + "value": 12.3, + "timestamp": ANY, + "alarm_severity": 0, + }, + }, + )