diff --git a/src/dodal/beamlines/i03.py b/src/dodal/beamlines/i03.py index 8ce4027245..e9e62536ad 100644 --- a/src/dodal/beamlines/i03.py +++ b/src/dodal/beamlines/i03.py @@ -30,7 +30,7 @@ from dodal.devices.undulator_dcm import UndulatorDCM from dodal.devices.webcam import Webcam from dodal.devices.xbpm_feedback import XBPMFeedback -from dodal.devices.xspress3_mini.xspress3_mini import Xspress3Mini +from dodal.devices.xspress3.xspress3 import Xspress3 from dodal.devices.zebra import Zebra from dodal.devices.zebra_controlled_shutter import ZebraShutter from dodal.devices.zocalo import ZocaloResults @@ -363,12 +363,12 @@ def zebra(wait_for_connection: bool = True, fake_with_ophyd_sim: bool = False) - def xspress3mini( wait_for_connection: bool = True, fake_with_ophyd_sim: bool = False -) -> Xspress3Mini: +) -> Xspress3: """Get the i03 Xspress3Mini device, instantiate it if it hasn't already been. If this is called when already instantiated in i03, it will return the existing object. """ return device_instantiation( - Xspress3Mini, + Xspress3, "xspress3mini", "-EA-XSP3-01:", wait_for_connection, diff --git a/src/dodal/beamlines/i20_1.py b/src/dodal/beamlines/i20_1.py index 58a22b8175..cee4116272 100644 --- a/src/dodal/beamlines/i20_1.py +++ b/src/dodal/beamlines/i20_1.py @@ -1,6 +1,7 @@ from dodal.common.beamlines.beamline_utils import device_instantiation from dodal.common.beamlines.beamline_utils import set_beamline as set_utils_beamline from dodal.devices.turbo_slit import TurboSlit +from dodal.devices.xspress3.xspress3 import Xspress3 from dodal.log import set_beamline as set_log_beamline from dodal.utils import get_beamline_name @@ -23,3 +24,20 @@ def turbo_slit( wait=wait_for_connection, fake=fake_with_ophyd_sim, ) + + +def xspress3( + wait_for_connection: bool = True, fake_with_ophyd_sim: bool = False +) -> Xspress3: + """ + 16 channels Xspress3 detector + """ + + return device_instantiation( + Xspress3, + prefix="-EA-DET-03:", + name="Xspress3", + num_channels=16, + wait=wait_for_connection, + fake=fake_with_ophyd_sim, + ) diff --git a/src/dodal/devices/xspress3/xspress3.py b/src/dodal/devices/xspress3/xspress3.py new file mode 100644 index 0000000000..f8ae574f0a --- /dev/null +++ b/src/dodal/devices/xspress3/xspress3.py @@ -0,0 +1,150 @@ +from enum import Enum + +from bluesky.protocols import Stageable +from numpy import float64 +from numpy.typing import NDArray +from ophyd_async.core import ( + AsyncStatus, + Device, + DeviceVector, + wait_for_value, +) +from ophyd_async.epics.signal.signal import ( + epics_signal_r, + epics_signal_rw, + epics_signal_rw_rbv, +) + +from dodal.devices.xspress3.xspress3_channel import ( + AcquireState, + Xspress3Channel, + Xspress3ROIChannel, +) +from dodal.log import LOGGER + + +class TriggerMode(str, Enum): + SOFTWARE = "Software" + HARDWARE = "Hardware" + BURST = "Burst" + TTL_Veto_Only = "TTL Veto Only" + IDC = "IDC" + SOTWARE_START_STOP = "Software Start/Stop" + TTL_BOTH = "TTL Both" + LVDS_VETO_ONLY = "LVDS Veto Only" + LVDS_both = "LVDS Both" + + +class UpdateRBV(str, Enum): + DISABLED = "Disabled" + ENABLED = "Enabled" + + +class AcquireRBVState(str, Enum): + DONE = "Done" + ACQUIRE = "Acquiring" + + +class DetectorState(str, Enum): + IDLE = "Idle" + ACQUIRE = "Acquire" + READOUT = "Readout" + CORRECT = "Correct" + Saving = "Saving" + ABORTING = "Aborting" + ERROR = "Error" + WAITING = "Waiting" + INTILTIALIZING = "Initializing" + DISCONNECTED = "Disconnected" + ABORTED = "Aborted" + + +class Xspress3(Device, Stageable): + """Xpress/XpressMini is a region of interest (ROI) picker that sums the detector + output into a scaler with user-defined regions. It is often used as a signal + discriminator to provide better energy resolution and signal to noise in X-ray detection experiments. + This currently only provide staging functionality. + + Parameters + ---------- + prefix: + Beamline part of PV + name: + Name of the device + num_channels: + Number of channel xspress3 has, default is 1 for mini. + timeout: + How long to wait for before timing out for staging/arming of detector default is 1 sec + """ + + def __init__( + self, prefix: str, name: str = "", num_channels: int = 1, timeout: float = 1 + ) -> None: + self.channels = DeviceVector( + {i: Xspress3Channel(f"{prefix}C{i}_") for i in range(1, num_channels + 1)} + ) + """MCA on/off switch readback""" + self.get_roi_calc_status = DeviceVector( + { + i: epics_signal_rw(float, f"{prefix}MCA{i}:Enable_RBV") + for i in range(1, num_channels + 1) + } + ) + """start and size of the multi-channel analyzer (MCA) array""" + self.roi_mca = DeviceVector( + { + i: Xspress3ROIChannel(f"{prefix}ROISUM{i}:") + for i in range(1, num_channels + 1) + } + ) + + """signal for the corrected MCA spectrum (1d array)""" + self.dt_corrected_latest_mca = DeviceVector( + { + i: epics_signal_r(NDArray[float64], f"{prefix}ARR{i}:ArrayData") + for i in range(1, num_channels + 1) + } + ) + + """Shared controls for triggering detection""" + self.timeout = timeout + self.acquire_time = epics_signal_rw(float, prefix + "AcquireTime") + self.max_num_channels = epics_signal_r(int, prefix + "MAX_NUM_CHANNELS_RBV") + # acquire and acquire readback has a different enum + self.acquire = epics_signal_rw(AcquireState, prefix + "Acquire") + self.acquire_rbv = epics_signal_r(AcquireRBVState, prefix + "Acquire_RBV") + self.trigger_mode = epics_signal_rw_rbv(TriggerMode, prefix + "TriggerMode") + + self.detector_state = epics_signal_r( + DetectorState, prefix + "DetectorState_RBV" + ) + + self.set_num_images = epics_signal_rw(int, prefix + "NumImages") + self.detector_busy_states = [ + DetectorState.ACQUIRE, + DetectorState.CORRECT, + DetectorState.ABORTING, + ] + super().__init__(name=name) + + @AsyncStatus.wrap + async def stage(self) -> None: + LOGGER.info("Arming Xspress3 detector...") + await self.trigger_mode.set(TriggerMode.BURST) + await wait_for_value( + self.detector_state, + lambda v: v in self.detector_busy_states, + timeout=self.timeout, + ) + await self.acquire.set(AcquireState.ACQUIRE) + await wait_for_value( + self.acquire_rbv, AcquireRBVState.ACQUIRE, timeout=self.timeout + ) + + @AsyncStatus.wrap + async def unstage(self) -> None: + await self.acquire.set(AcquireState.DONE) + LOGGER.info("unstaging Xspress3 detector...") + await wait_for_value( + self.acquire_rbv, AcquireRBVState.DONE, timeout=self.timeout + ) diff --git a/src/dodal/devices/xspress3/xspress3_channel.py b/src/dodal/devices/xspress3/xspress3_channel.py new file mode 100644 index 0000000000..6ac1043658 --- /dev/null +++ b/src/dodal/devices/xspress3/xspress3_channel.py @@ -0,0 +1,42 @@ +from enum import Enum + +from ophyd_async.core import Device +from ophyd_async.epics.signal import epics_signal_r, epics_signal_rw + + +class AcquireState(str, Enum): + DONE = "Done" + ACQUIRE = "Acquire" + + +class Xspress3Channel(Device): + """ + Xspress3 Channel contains the truncated detector data and its collection conditions + including the definition of ROI(region of interest). + """ + + def __init__(self, prefix: str, name: str = "") -> None: + self.update_arrays = epics_signal_rw(AcquireState, prefix + "SCAS:TS:TSAcquire") + + self.roi_high_limit = epics_signal_rw(int, prefix + "SCA5_HLM") + self.roi_low_limit = epics_signal_rw(int, prefix + "SCA5_LLM") + self.time = epics_signal_r(int, prefix + "SCA0:Value_RBV") + self.reset_ticks = epics_signal_r(int, prefix + "SCA1:Value_RBV") + self.reset_count = epics_signal_r(int, prefix + "SCA2:Value_RBV") + self.all_event = epics_signal_r(int, prefix + "SCA3:Value_RBV") + self.all_good = epics_signal_r(int, prefix + "SCA4:Value_RBV") + self.pileup = epics_signal_r(int, prefix + "SCA7:Value_RBV") + self.total_time = epics_signal_r(int, prefix + "SCA8:Value_RBV") + self.mca_roi1_LLM = epics_signal_r(int, prefix + "SCA8:Value_RBV") + super().__init__(name=name) + + +class Xspress3ROIChannel(Device): + """ + This is the Xspress3 multi-channel analyzer range + """ + + def __init__(self, prefix: str, name: str = "") -> None: + self.roi_start_x = epics_signal_rw(int, prefix + "MinX") + self.roi_size_x = epics_signal_rw(int, prefix + "SizeX") + super().__init__(name=name) diff --git a/src/dodal/devices/xspress3_mini/xspress3_mini.py b/src/dodal/devices/xspress3_mini/xspress3_mini.py deleted file mode 100644 index 1f56c451d0..0000000000 --- a/src/dodal/devices/xspress3_mini/xspress3_mini.py +++ /dev/null @@ -1,103 +0,0 @@ -from enum import Enum - -from ophyd import ( - Component, - Device, - EpicsSignal, - EpicsSignalRO, - EpicsSignalWithRBV, - Signal, -) -from ophyd.status import Status - -from dodal.devices.status import await_value, await_value_in_list -from dodal.devices.xspress3_mini.xspress3_mini_channel import Xspress3MiniChannel -from dodal.log import LOGGER - - -class AttenuationOptimisationFailedException(Exception): - pass - - -class TriggerMode(Enum): - SOFTWARE = "Software" - HARDWARE = "Hardware" - BURST = "Burst" - TTL_Veto_Only = "TTL_Veto_Only" - IDC = "IDC" - SOTWARE_START_STOP = "Software_Start/Stop" - TTL_BOTH = "TTL_Both" - LVDS_VETO_ONLY = "LVDS_Veto_Only" - LVDS_both = "LVDS_Both" - - -class UpdateRBV(Enum): - DISABLED = "Disabled" - ENABLED = "Enabled" - - -class EraseState(Enum): - DONE = 0 - ERASE = 1 - - -class AcquireState(Enum): - DONE = 0 - ACQUIRE = 1 - - -class DetectorState(Enum): - ACQUIRE = "Acquire" - CORRECT = "Correct" - READOUT = "Readout" - ABORTING = "Aborting" - - IDLE = "Idle" - SAVING = "Saving" - ERROR = "Error" - INTILTIALIZING = "Initializing" - DISCONNECTED = "Disconnected" - ABORTED = "Aborted" - - -class Xspress3Mini(Device): - class ArmingSignal(Signal): - def set(self, value, *, timeout=None, settle_time=None, **kwargs): - return self.parent.arm() - - ARM_STATUS_WAIT = 1 - - do_arm = Component(ArmingSignal) - - # Assume only one channel for now - channel_1 = Component(Xspress3MiniChannel, "C1_") - - erase = Component(EpicsSignal, "ERASE", string=True) - get_max_num_channels = Component(EpicsSignalRO, "MAX_NUM_CHANNELS_RBV") - acquire = Component(EpicsSignalWithRBV, "Acquire") - get_roi_calc_mini = Component(EpicsSignal, "MCA1:Enable_RBV") - trigger_mode_mini = Component(EpicsSignalWithRBV, "TriggerMode") - roi_start_x = Component(EpicsSignal, "ROISUM1:MinX") - roi_size_x = Component(EpicsSignal, "ROISUM1:SizeX") - acquire_time = Component(EpicsSignal, "AcquireTime") - detector_state = Component(EpicsSignalRO, "DetectorState_RBV", string=True) - dt_corrected_latest_mca = Component(EpicsSignalRO, "ARR1:ArrayData") - set_num_images = Component(EpicsSignal, "NumImages") - - detector_busy_states = [ - DetectorState.ACQUIRE.value, - DetectorState.CORRECT.value, - DetectorState.ABORTING.value, - ] - - def stage(self): - self.arm().wait(timeout=10) - - def arm(self) -> Status: - LOGGER.info("Arming Xspress3Mini detector...") - self.trigger_mode_mini.put(TriggerMode.BURST.value) - arm_status = await_value_in_list(self.detector_state, self.detector_busy_states) - self.erase.put(EraseState.ERASE.value) - arm_status &= self.acquire.set(AcquireState.ACQUIRE.value) - arm_status.wait(self.ARM_STATUS_WAIT) - return await_value(self.acquire, 0) diff --git a/src/dodal/devices/xspress3_mini/xspress3_mini_channel.py b/src/dodal/devices/xspress3_mini/xspress3_mini_channel.py deleted file mode 100644 index bc00bf170d..0000000000 --- a/src/dodal/devices/xspress3_mini/xspress3_mini_channel.py +++ /dev/null @@ -1,24 +0,0 @@ -from enum import Enum - -from ophyd import Component, Device, EpicsSignal, EpicsSignalRO - - -class TimeSeriesValues(Enum): - START_VALUE = "Acquire" - STOP_VALUE = "Done" - UPDATE_VALUE = "" - - -class Xspress3MiniChannel(Device): - update_arrays = Component(EpicsSignal, "SCAS:TS:TSAcquire") - - roi_high_limit = Component(EpicsSignal, "SCA5_HLM") - roi_llm = Component(EpicsSignal, "SCA5_LLM") - - time = Component(EpicsSignalRO, "SCA0:Value_RBV") - reset_ticks = Component(EpicsSignalRO, "SCA1:Value_RBV") - reset_count = Component(EpicsSignalRO, "SCA2:Value_RBV") - all_event = Component(EpicsSignalRO, "SCA3:Value_RBV") - all_good = Component(EpicsSignalRO, "SCA4:Value_RBV") - pileup = Component(EpicsSignalRO, "SCA7:Value_RBV") - total_time = Component(EpicsSignalRO, "SCA8:Value_RBV") diff --git a/tests/devices/unit_tests/test_xspress3.py b/tests/devices/unit_tests/test_xspress3.py new file mode 100644 index 0000000000..ee61147142 --- /dev/null +++ b/tests/devices/unit_tests/test_xspress3.py @@ -0,0 +1,87 @@ +from unittest.mock import ANY, Mock + +import bluesky.plan_stubs as bps +import pytest +from bluesky.run_engine import RunEngine +from ophyd_async.core import ( + DeviceCollector, + callback_on_mock_put, + get_mock_put, + set_mock_value, +) + +from dodal.devices.xspress3.xspress3 import ( + AcquireRBVState, + DetectorState, + TriggerMode, + Xspress3, +) + + +@pytest.fixture +async def mock_xspress3mini(prefix: str = "BLXX-EA-DET-007:") -> Xspress3: + async with DeviceCollector(mock=True): + mock_xspress3mini = Xspress3(prefix, "Xspress3Mini", 2) + assert mock_xspress3mini.channels[1].name == "Xspress3Mini-channels-1" + assert mock_xspress3mini.channels[2].name == "Xspress3Mini-channels-2" + assert ( + mock_xspress3mini.get_roi_calc_status[1].name + == "Xspress3Mini-get_roi_calc_status-1" + ) + assert ( + mock_xspress3mini.get_roi_calc_status[2].name + == "Xspress3Mini-get_roi_calc_status-2" + ) + assert mock_xspress3mini.roi_mca[1].name == "Xspress3Mini-roi_mca-1" + assert mock_xspress3mini.roi_mca[2].name == "Xspress3Mini-roi_mca-2" + mock_xspress3mini.timeout = 1.0 + return mock_xspress3mini + + +def test_stage_in_RE_success_in_busy_state(mock_xspress3mini: Xspress3, RE: RunEngine): + # set xspress to busy + set_mock_value(mock_xspress3mini.acquire_rbv, AcquireRBVState.DONE) + set_mock_value(mock_xspress3mini.detector_state, DetectorState.ACQUIRE) + # make rbv change from DONE->ACQUIRE->DONE + rbv_mocks = Mock() + rbv_mocks.get.side_effect = [AcquireRBVState.ACQUIRE, AcquireRBVState.DONE] + callback_on_mock_put( + mock_xspress3mini.acquire, + lambda *_, **__: set_mock_value(mock_xspress3mini.acquire_rbv, rbv_mocks.get()), + ) + + RE(bps.stage(mock_xspress3mini, wait=True)) + + get_mock_put(mock_xspress3mini.trigger_mode).assert_called_once_with( + TriggerMode.BURST, wait=ANY, timeout=ANY + ) + assert 2 == get_mock_put(mock_xspress3mini.acquire).call_count + + +async def test_stage_fail_on_detector_not_busy_state( + mock_xspress3mini: Xspress3, RE: RunEngine +): + set_mock_value(mock_xspress3mini.detector_state, DetectorState.IDLE) + mock_xspress3mini.timeout = 0.1 + with pytest.raises(TimeoutError): + await mock_xspress3mini.stage() + with pytest.raises(Exception): + RE(bps.stage(mock_xspress3mini, wait=True)) + assert 2 == get_mock_put(mock_xspress3mini.trigger_mode).call_count + # unstage is call even when staging failed + assert 1 == get_mock_put(mock_xspress3mini.acquire).call_count + + +async def test_stage_fail_to_acquire_timeout( + mock_xspress3mini: Xspress3, RE: RunEngine +): + set_mock_value(mock_xspress3mini.detector_state, DetectorState.ACQUIRE) + set_mock_value(mock_xspress3mini.acquire_rbv, AcquireRBVState.DONE) + mock_xspress3mini.timeout = 0.1 + with pytest.raises(TimeoutError): + await mock_xspress3mini.stage() + with pytest.raises(Exception): + RE(bps.stage(mock_xspress3mini, wait=True)) + + assert 2 == get_mock_put(mock_xspress3mini.trigger_mode).call_count + assert 3 == get_mock_put(mock_xspress3mini.acquire).call_count diff --git a/tests/devices/unit_tests/test_xspress3mini.py b/tests/devices/unit_tests/test_xspress3mini.py deleted file mode 100644 index 521dfe355b..0000000000 --- a/tests/devices/unit_tests/test_xspress3mini.py +++ /dev/null @@ -1,48 +0,0 @@ -from unittest.mock import MagicMock, patch - -import pytest -from bluesky import plan_stubs as bps -from bluesky.run_engine import RunEngine -from ophyd.sim import make_fake_device -from ophyd.status import Status - -from dodal.devices.xspress3_mini.xspress3_mini import DetectorState, Xspress3Mini - - -def get_bad_status() -> Status: - status = Status("get_bad_status") - status.set_exception(Exception) - return status - - -@pytest.fixture -def fake_xspress3mini(): - FakeXspress3Mini = make_fake_device(Xspress3Mini) - fake_xspress3mini: Xspress3Mini = FakeXspress3Mini(name="xspress3mini") - return fake_xspress3mini - - -@pytest.fixture -def status_finished() -> MagicMock: - return MagicMock() - - -def test_arm_success_on_busy_state(fake_xspress3mini, status_finished: MagicMock): - fake_xspress3mini.detector_state.sim_put(DetectorState.ACQUIRE.value) # type: ignore - status = fake_xspress3mini.arm() - status.add_callback(status_finished) - status_finished.assert_not_called() - fake_xspress3mini.acquire.sim_put(0) # type: ignore - status.wait(timeout=1) - - -@patch("dodal.devices.xspress3_mini.xspress3_mini.await_value") -def test_stage_in_busy_state(mock_await_value, fake_xspress3mini, RE: RunEngine): - fake_xspress3mini.detector_state.sim_put(DetectorState.ACQUIRE.value) # type: ignore - fake_xspress3mini.acquire.sim_put(0) # type: ignore - RE(bps.stage(fake_xspress3mini)) - - -def test_stage_fails_in_failed_acquire_state(fake_xspress3mini, RE: RunEngine): - with pytest.raises(Exception): - RE(bps.stage(fake_xspress3mini))