diff --git a/src/ophyd_async/epics/areadetector/__init__.py b/src/ophyd_async/epics/areadetector/__init__.py index cbad83e071..309827cb5e 100644 --- a/src/ophyd_async/epics/areadetector/__init__.py +++ b/src/ophyd_async/epics/areadetector/__init__.py @@ -1,4 +1,5 @@ from .aravis import AravisDetector +from .kinetix import KinetixDetector from .pilatus import PilatusDetector from .single_trigger_det import SingleTriggerDet from .utils import ( @@ -9,9 +10,12 @@ ad_r, ad_rw, ) +from .vimba import VimbaDetector __all__ = [ "AravisDetector", + "KinetixDetector", + "VimbaDetector", "SingleTriggerDet", "FileWriteMode", "ImageMode", diff --git a/src/ophyd_async/epics/areadetector/controllers/kinetix_controller.py b/src/ophyd_async/epics/areadetector/controllers/kinetix_controller.py new file mode 100644 index 0000000000..288b8a156c --- /dev/null +++ b/src/ophyd_async/epics/areadetector/controllers/kinetix_controller.py @@ -0,0 +1,49 @@ +import asyncio +from typing import Optional + +from ophyd_async.core import AsyncStatus, DetectorControl, DetectorTrigger +from ophyd_async.epics.areadetector.drivers.ad_base import ( + start_acquiring_driver_and_ensure_status, +) + +from ..drivers.kinetix_driver import KinetixDriver, KinetixTriggerMode +from ..utils import ImageMode, stop_busy_record + +KINETIX_TRIGGER_MODE_MAP = { + DetectorTrigger.internal: KinetixTriggerMode.internal, + DetectorTrigger.constant_gate: KinetixTriggerMode.gate, + DetectorTrigger.variable_gate: KinetixTriggerMode.gate, + DetectorTrigger.edge_trigger: KinetixTriggerMode.edge, +} + + +class KinetixController(DetectorControl): + def __init__( + self, + driver: KinetixDriver, + ) -> None: + self._drv = driver + + def get_deadtime(self, exposure: float) -> float: + return 0.001 + + async def arm( + self, + num: int, + trigger: DetectorTrigger = DetectorTrigger.internal, + exposure: Optional[float] = None, + ) -> AsyncStatus: + await asyncio.gather( + self._drv.trigger_mode.set(KINETIX_TRIGGER_MODE_MAP[trigger]), + self._drv.num_images.set(num), + self._drv.image_mode.set(ImageMode.multiple), + ) + if exposure is not None and trigger not in [ + DetectorTrigger.variable_gate, + DetectorTrigger.constant_gate, + ]: + await self._drv.acquire_time.set(exposure) + return await start_acquiring_driver_and_ensure_status(self._drv) + + async def disarm(self): + await stop_busy_record(self._drv.acquire, False, timeout=1) diff --git a/src/ophyd_async/epics/areadetector/controllers/vimba_controller.py b/src/ophyd_async/epics/areadetector/controllers/vimba_controller.py new file mode 100644 index 0000000000..82fe420281 --- /dev/null +++ b/src/ophyd_async/epics/areadetector/controllers/vimba_controller.py @@ -0,0 +1,66 @@ +import asyncio +from typing import Optional + +from ophyd_async.core import AsyncStatus, DetectorControl, DetectorTrigger +from ophyd_async.epics.areadetector.drivers.ad_base import ( + start_acquiring_driver_and_ensure_status, +) + +from ..drivers.vimba_driver import ( + VimbaDriver, + VimbaExposeOutMode, + VimbaOnOff, + VimbaTriggerSource, +) +from ..utils import ImageMode, stop_busy_record + +TRIGGER_MODE = { + DetectorTrigger.internal: VimbaOnOff.off, + DetectorTrigger.constant_gate: VimbaOnOff.on, + DetectorTrigger.variable_gate: VimbaOnOff.on, + DetectorTrigger.edge_trigger: VimbaOnOff.on, +} + +EXPOSE_OUT_MODE = { + DetectorTrigger.internal: VimbaExposeOutMode.timed, + DetectorTrigger.constant_gate: VimbaExposeOutMode.trigger_width, + DetectorTrigger.variable_gate: VimbaExposeOutMode.trigger_width, + DetectorTrigger.edge_trigger: VimbaExposeOutMode.timed, +} + + +class VimbaController(DetectorControl): + def __init__( + self, + driver: VimbaDriver, + ) -> None: + self._drv = driver + + def get_deadtime(self, exposure: float) -> float: + return 0.001 + + async def arm( + self, + num: int, + trigger: DetectorTrigger = DetectorTrigger.internal, + exposure: Optional[float] = None, + ) -> AsyncStatus: + await asyncio.gather( + self._drv.trigger_mode.set(TRIGGER_MODE[trigger]), + self._drv.expose_mode.set(EXPOSE_OUT_MODE[trigger]), + self._drv.num_images.set(num), + self._drv.image_mode.set(ImageMode.multiple), + ) + if exposure is not None and trigger not in [ + DetectorTrigger.variable_gate, + DetectorTrigger.constant_gate, + ]: + await self._drv.acquire_time.set(exposure) + if trigger != DetectorTrigger.internal: + self._drv.trig_source.set(VimbaTriggerSource.line1) + else: + self._drv.trig_source.set(VimbaTriggerSource.freerun) + return await start_acquiring_driver_and_ensure_status(self._drv) + + async def disarm(self): + await stop_busy_record(self._drv.acquire, False, timeout=1) diff --git a/src/ophyd_async/epics/areadetector/drivers/__init__.py b/src/ophyd_async/epics/areadetector/drivers/__init__.py index c24c42a1cf..451bd48b21 100644 --- a/src/ophyd_async/epics/areadetector/drivers/__init__.py +++ b/src/ophyd_async/epics/areadetector/drivers/__init__.py @@ -5,13 +5,17 @@ start_acquiring_driver_and_ensure_status, ) from .aravis_driver import AravisDriver +from .kinetix_driver import KinetixDriver from .pilatus_driver import PilatusDriver +from .vimba_driver import VimbaDriver __all__ = [ "ADBase", "ADBaseShapeProvider", "PilatusDriver", "AravisDriver", + "KinetixDriver", + "VimbaDriver", "start_acquiring_driver_and_ensure_status", "DetectorState", ] diff --git a/src/ophyd_async/epics/areadetector/drivers/kinetix_driver.py b/src/ophyd_async/epics/areadetector/drivers/kinetix_driver.py new file mode 100644 index 0000000000..ab0bd01af4 --- /dev/null +++ b/src/ophyd_async/epics/areadetector/drivers/kinetix_driver.py @@ -0,0 +1,24 @@ +from enum import Enum + +from ..utils import ad_rw +from .ad_base import ADBase + + +class KinetixTriggerMode(str, Enum): + internal = "Internal" + edge = "Rising Edge" + gate = "Exp. Gate" + + +class KinetixReadoutMode(str, Enum): + sensitivity = 1 + speed = 2 + dynamic_range = 3 + + +class KinetixDriver(ADBase): + def __init__(self, prefix: str, name: str = "") -> None: + # self.pixel_format = ad_rw(PixelFormat, prefix + "PixelFormat") + self.trigger_mode = ad_rw(KinetixTriggerMode, prefix + "TriggerMode") + self.mode = ad_rw(KinetixReadoutMode, prefix + "ReadoutPortIdx") + super().__init__(prefix, name) diff --git a/src/ophyd_async/epics/areadetector/drivers/vimba_driver.py b/src/ophyd_async/epics/areadetector/drivers/vimba_driver.py new file mode 100644 index 0000000000..4cec75dd2e --- /dev/null +++ b/src/ophyd_async/epics/areadetector/drivers/vimba_driver.py @@ -0,0 +1,58 @@ +from enum import Enum + +from ..utils import ad_rw +from .ad_base import ADBase + + +class VimbaPixelFormat(str, Enum): + internal = "Mono8" + ext_enable = "Mono12" + ext_trigger = "Ext. Trigger" + mult_trigger = "Mult. Trigger" + alignment = "Alignment" + + +class VimbaConvertFormat(str, Enum): + none = "None" + mono8 = "Mono8" + mono16 = "Mono16" + rgb8 = "RGB8" + rgb16 = "RGB16" + + +class VimbaTriggerSource(str, Enum): + freerun = "Freerun" + line1 = "Line1" + line2 = "Line2" + fixed_rate = "FixedRate" + software = "Software" + action0 = "Action0" + action1 = "Action1" + + +class VimbaOverlap(str, Enum): + off = "Off" + prev_frame = "PreviousFrame" + + +class VimbaOnOff(str, Enum): + on = "On" + off = "Off" + + +class VimbaExposeOutMode(str, Enum): + timed = "Timed" # Use ExposureTime PV + trigger_width = "TriggerWidth" # Expose for length of high signal + + +class VimbaDriver(ADBase): + def __init__(self, prefix: str, name: str = "") -> None: + # self.pixel_format = ad_rw(PixelFormat, prefix + "PixelFormat") + self.convert_format = ad_rw( + VimbaConvertFormat, prefix + "ConvertPixelFormat" + ) # Pixel format of data outputted to AD + self.trig_source = ad_rw(VimbaTriggerSource, prefix + "TriggerSource") + self.trigger_mode = ad_rw(VimbaOnOff, prefix + "TriggerMode") + self.overlap = ad_rw(VimbaOverlap, prefix + "TriggerOverlap") + self.expose_mode = ad_rw(VimbaExposeOutMode, prefix + "ExposureMode") + super().__init__(prefix, name) diff --git a/src/ophyd_async/epics/areadetector/kinetix.py b/src/ophyd_async/epics/areadetector/kinetix.py new file mode 100644 index 0000000000..36ec479a49 --- /dev/null +++ b/src/ophyd_async/epics/areadetector/kinetix.py @@ -0,0 +1,48 @@ +from bluesky.protocols import HasHints, Hints + +from ophyd_async.core import DirectoryProvider, StandardDetector +from ophyd_async.epics.areadetector.controllers.kinetix_controller import ( + KinetixController, +) +from ophyd_async.epics.areadetector.drivers import ADBaseShapeProvider +from ophyd_async.epics.areadetector.drivers.kinetix_driver import KinetixDriver +from ophyd_async.epics.areadetector.writers import HDFWriter, NDFileHDF + + +class KinetixDetector(StandardDetector, HasHints): + """ + Ophyd-async implementation of an ADKinetix Detector. + https://github.com/NSLS-II/ADKinetix + """ + + _controller: KinetixController + _writer: HDFWriter + + def __init__( + self, + name: str, + directory_provider: DirectoryProvider, + driver: KinetixDriver, + hdf: NDFileHDF, + **scalar_sigs: str, + ): + # Must be child of Detector to pick up connect() + self.drv = driver + self.hdf = hdf + + super().__init__( + KinetixController(self.drv), + HDFWriter( + self.hdf, + directory_provider, + lambda: self.name, + ADBaseShapeProvider(self.drv), + **scalar_sigs, + ), + config_sigs=(self.drv.acquire_time, self.drv.acquire), + name=name, + ) + + @property + def hints(self) -> Hints: + return self._writer.hints diff --git a/src/ophyd_async/epics/areadetector/vimba.py b/src/ophyd_async/epics/areadetector/vimba.py new file mode 100644 index 0000000000..5e764b5b20 --- /dev/null +++ b/src/ophyd_async/epics/areadetector/vimba.py @@ -0,0 +1,45 @@ +from bluesky.protocols import HasHints, Hints + +from ophyd_async.core import DirectoryProvider, StandardDetector +from ophyd_async.epics.areadetector.controllers.vimba_controller import VimbaController +from ophyd_async.epics.areadetector.drivers import ADBaseShapeProvider +from ophyd_async.epics.areadetector.drivers.vimba_driver import VimbaDriver +from ophyd_async.epics.areadetector.writers import HDFWriter, NDFileHDF + + +class VimbaDetector(StandardDetector, HasHints): + """ + Ophyd-async implementation of an ADVimba Detector. + """ + + _controller: VimbaController + _writer: HDFWriter + + def __init__( + self, + name: str, + directory_provider: DirectoryProvider, + driver: VimbaDriver, + hdf: NDFileHDF, + **scalar_sigs: str, + ): + # Must be child of Detector to pick up connect() + self.drv = driver + self.hdf = hdf + + super().__init__( + VimbaController(self.drv), + HDFWriter( + self.hdf, + directory_provider, + lambda: self.name, + ADBaseShapeProvider(self.drv), + **scalar_sigs, + ), + config_sigs=(self.drv.acquire_time, self.drv.acquire), + name=name, + ) + + @property + def hints(self) -> Hints: + return self._writer.hints diff --git a/tests/epics/areadetector/test_kinetix.py b/tests/epics/areadetector/test_kinetix.py new file mode 100644 index 0000000000..bb79a0b5f8 --- /dev/null +++ b/tests/epics/areadetector/test_kinetix.py @@ -0,0 +1,150 @@ +import pytest +from bluesky.run_engine import RunEngine + +from ophyd_async.core import ( + DetectorTrigger, + DeviceCollector, + DirectoryProvider, + set_sim_value, +) +from ophyd_async.epics.areadetector.drivers.kinetix_driver import KinetixDriver +from ophyd_async.epics.areadetector.kinetix import KinetixDetector +from ophyd_async.epics.areadetector.writers.nd_file_hdf import NDFileHDF + + +@pytest.fixture +async def adkinetix_driver(RE: RunEngine) -> KinetixDriver: + async with DeviceCollector(sim=True): + driver = KinetixDriver("DRV:") + + return driver + + +@pytest.fixture +async def hdf(RE: RunEngine) -> NDFileHDF: + async with DeviceCollector(sim=True): + hdf = NDFileHDF("HDF:") + + return hdf + + +@pytest.fixture +async def adkinetix( + RE: RunEngine, + static_directory_provider: DirectoryProvider, + adkinetix_driver: KinetixDriver, + hdf: NDFileHDF, +) -> KinetixDetector: + async with DeviceCollector(sim=True): + adkinetix = KinetixDetector( + "adkinetix", + static_directory_provider, + driver=adkinetix_driver, + hdf=hdf, + ) + + return adkinetix + + +async def test_get_deadtime( + adkinetix: KinetixDetector, +): + # Currently Kinetix driver doesn't support getting deadtime. + assert adkinetix._controller.get_deadtime(0) == 0.001 + + +async def test_trigger_modes(adkinetix: KinetixDetector): + set_sim_value(adkinetix.drv.trigger_mode, "Internal") + + async def setup_trigger_mode(trig_mode: DetectorTrigger): + await adkinetix.controller.arm(num=1, trigger=trig_mode) + # Prevent timeouts + set_sim_value(adkinetix.drv.acquire, True) + + # Default TriggerSource + assert (await adkinetix.drv.trigger_mode.get_value()) == "Internal" + + await setup_trigger_mode(DetectorTrigger.edge_trigger) + assert (await adkinetix.drv.trigger_mode.get_value()) == "Rising Edge" + + await setup_trigger_mode(DetectorTrigger.constant_gate) + assert (await adkinetix.drv.trigger_mode.get_value()) == "Exp. Gate" + + await setup_trigger_mode(DetectorTrigger.internal) + assert (await adkinetix.drv.trigger_mode.get_value()) == "Internal" + + await setup_trigger_mode(DetectorTrigger.variable_gate) + assert (await adkinetix.drv.trigger_mode.get_value()) == "Exp. Gate" + + +async def test_hints_from_hdf_writer(adkinetix: KinetixDetector): + assert adkinetix.hints == {"fields": ["adkinetix"]} + + +async def test_can_read(adkinetix: KinetixDetector): + # Standard detector can be used as Readable + assert (await adkinetix.read()) == {} + + +async def test_decribe_describes_writer_dataset(adkinetix: KinetixDetector): + set_sim_value(adkinetix._writer.hdf.file_path_exists, True) + set_sim_value(adkinetix._writer.hdf.capture, True) + + assert await adkinetix.describe() == {} + await adkinetix.stage() + assert await adkinetix.describe() == { + "adkinetix": { + "source": "soft://adkinetix-hdf-full_file_name", + "shape": (0, 0), + "dtype": "array", + "external": "STREAM:", + } + } + + +async def test_can_collect( + adkinetix: KinetixDetector, static_directory_provider: DirectoryProvider +): + directory_info = static_directory_provider() + full_file_name = directory_info.root / directory_info.resource_dir / "foo.h5" + set_sim_value(adkinetix.hdf.full_file_name, str(full_file_name)) + set_sim_value(adkinetix._writer.hdf.file_path_exists, True) + set_sim_value(adkinetix._writer.hdf.capture, True) + await adkinetix.stage() + docs = [(name, doc) async for name, doc in adkinetix.collect_asset_docs(1)] + assert len(docs) == 2 + assert docs[0][0] == "stream_resource" + stream_resource = docs[0][1] + sr_uid = stream_resource["uid"] + assert stream_resource["data_key"] == "adkinetix" + assert stream_resource["spec"] == "AD_HDF5_SWMR_SLICE" + assert stream_resource["root"] == str(directory_info.root) + assert stream_resource["resource_path"] == str( + directory_info.resource_dir / "foo.h5" + ) + assert stream_resource["path_semantics"] == "posix" + assert stream_resource["resource_kwargs"] == { + "path": "/entry/data/data", + "multiplier": 1, + "timestamps": "/entry/instrument/NDAttributes/NDArrayTimeStamp", + } + assert docs[1][0] == "stream_datum" + stream_datum = docs[1][1] + assert stream_datum["stream_resource"] == sr_uid + assert stream_datum["seq_nums"] == {"start": 0, "stop": 0} + assert stream_datum["indices"] == {"start": 0, "stop": 1} + + +async def test_can_decribe_collect(adkinetix: KinetixDetector): + set_sim_value(adkinetix._writer.hdf.file_path_exists, True) + set_sim_value(adkinetix._writer.hdf.capture, True) + assert (await adkinetix.describe_collect()) == {} + await adkinetix.stage() + assert (await adkinetix.describe_collect()) == { + "adkinetix": { + "source": "soft://adkinetix-hdf-full_file_name", + "shape": (0, 0), + "dtype": "array", + "external": "STREAM:", + } + } diff --git a/tests/epics/areadetector/test_vimba.py b/tests/epics/areadetector/test_vimba.py new file mode 100644 index 0000000000..002ddb1b7e --- /dev/null +++ b/tests/epics/areadetector/test_vimba.py @@ -0,0 +1,162 @@ +import pytest +from bluesky.run_engine import RunEngine + +from ophyd_async.core import ( + DetectorTrigger, + DeviceCollector, + DirectoryProvider, + set_sim_value, +) +from ophyd_async.epics.areadetector.drivers.vimba_driver import VimbaDriver +from ophyd_async.epics.areadetector.vimba import VimbaDetector +from ophyd_async.epics.areadetector.writers.nd_file_hdf import NDFileHDF + + +@pytest.fixture +async def advimba_driver(RE: RunEngine) -> VimbaDriver: + async with DeviceCollector(sim=True): + driver = VimbaDriver("DRV:") + + return driver + + +@pytest.fixture +async def hdf(RE: RunEngine) -> NDFileHDF: + async with DeviceCollector(sim=True): + hdf = NDFileHDF("HDF:") + + return hdf + + +@pytest.fixture +async def advimba( + RE: RunEngine, + static_directory_provider: DirectoryProvider, + advimba_driver: VimbaDriver, + hdf: NDFileHDF, +) -> VimbaDetector: + async with DeviceCollector(sim=True): + advimba = VimbaDetector( + "advimba", + static_directory_provider, + driver=advimba_driver, + hdf=hdf, + ) + + return advimba + + +async def test_get_deadtime( + advimba: VimbaDetector, +): + # Currently Vimba controller doesn't support getting deadtime. + assert advimba._controller.get_deadtime(0) == 0.001 + + +async def test_arming_trig_modes(advimba: VimbaDetector): + set_sim_value(advimba.drv.trig_source, "Freerun") + set_sim_value(advimba.drv.trigger_mode, "Off") + set_sim_value(advimba.drv.expose_mode, "Timed") + + async def setup_trigger_mode(trig_mode: DetectorTrigger): + await advimba.controller.arm(num=1, trigger=trig_mode) + # Prevent timeouts + set_sim_value(advimba.drv.acquire, True) + + # Default TriggerSource + assert (await advimba.drv.trig_source.get_value()) == "Freerun" + assert (await advimba.drv.trigger_mode.get_value()) == "Off" + assert (await advimba.drv.expose_mode.get_value()) == "Timed" + + await setup_trigger_mode(DetectorTrigger.edge_trigger) + assert (await advimba.drv.trig_source.get_value()) == "Line1" + assert (await advimba.drv.trigger_mode.get_value()) == "On" + assert (await advimba.drv.expose_mode.get_value()) == "Timed" + + await setup_trigger_mode(DetectorTrigger.constant_gate) + assert (await advimba.drv.trig_source.get_value()) == "Line1" + assert (await advimba.drv.trigger_mode.get_value()) == "On" + assert (await advimba.drv.expose_mode.get_value()) == "TriggerWidth" + + await setup_trigger_mode(DetectorTrigger.internal) + assert (await advimba.drv.trig_source.get_value()) == "Freerun" + assert (await advimba.drv.trigger_mode.get_value()) == "Off" + assert (await advimba.drv.expose_mode.get_value()) == "Timed" + + await setup_trigger_mode(DetectorTrigger.variable_gate) + assert (await advimba.drv.trig_source.get_value()) == "Line1" + assert (await advimba.drv.trigger_mode.get_value()) == "On" + assert (await advimba.drv.expose_mode.get_value()) == "TriggerWidth" + + +async def test_hints_from_hdf_writer(advimba: VimbaDetector): + assert advimba.hints == {"fields": ["advimba"]} + + +async def test_can_read(advimba: VimbaDetector): + # Standard detector can be used as Readable + assert (await advimba.read()) == {} + + +async def test_decribe_describes_writer_dataset(advimba: VimbaDetector): + set_sim_value(advimba._writer.hdf.file_path_exists, True) + set_sim_value(advimba._writer.hdf.capture, True) + + assert await advimba.describe() == {} + await advimba.stage() + assert await advimba.describe() == { + "advimba": { + "source": "soft://advimba-hdf-full_file_name", + "shape": (0, 0), + "dtype": "array", + "external": "STREAM:", + } + } + + +async def test_can_collect( + advimba: VimbaDetector, static_directory_provider: DirectoryProvider +): + directory_info = static_directory_provider() + full_file_name = directory_info.root / directory_info.resource_dir / "foo.h5" + set_sim_value(advimba.hdf.full_file_name, str(full_file_name)) + set_sim_value(advimba._writer.hdf.file_path_exists, True) + set_sim_value(advimba._writer.hdf.capture, True) + await advimba.stage() + docs = [(name, doc) async for name, doc in advimba.collect_asset_docs(1)] + assert len(docs) == 2 + assert docs[0][0] == "stream_resource" + stream_resource = docs[0][1] + sr_uid = stream_resource["uid"] + assert stream_resource["data_key"] == "advimba" + assert stream_resource["spec"] == "AD_HDF5_SWMR_SLICE" + assert stream_resource["root"] == str(directory_info.root) + assert stream_resource["resource_path"] == str( + directory_info.resource_dir / "foo.h5" + ) + assert stream_resource["path_semantics"] == "posix" + assert stream_resource["resource_kwargs"] == { + "path": "/entry/data/data", + "multiplier": 1, + "timestamps": "/entry/instrument/NDAttributes/NDArrayTimeStamp", + } + assert docs[1][0] == "stream_datum" + stream_datum = docs[1][1] + assert stream_datum["stream_resource"] == sr_uid + assert stream_datum["seq_nums"] == {"start": 0, "stop": 0} + assert stream_datum["indices"] == {"start": 0, "stop": 1} + + +async def test_can_decribe_collect(advimba: VimbaDetector): + set_sim_value(advimba._writer.hdf.file_path_exists, True) + set_sim_value(advimba._writer.hdf.capture, True) + assert (await advimba.describe_collect()) == {} + await advimba.stage() + assert (await advimba.describe_collect()) == { + "advimba": { + "source": "soft://advimba-hdf-full_file_name", + "shape": (0, 0), + "dtype": "array", + "external": "STREAM:", + } + }