From 35a82c2a90b0e173376f8ae1918f266d73f0e955 Mon Sep 17 00:00:00 2001 From: jennmald Date: Fri, 20 Mar 2026 10:43:27 -0400 Subject: [PATCH 1/7] merline ophyd class --- src/cditools/merlin.py | 26 ++++++++++++++++++++++---- 1 file changed, 22 insertions(+), 4 deletions(-) diff --git a/src/cditools/merlin.py b/src/cditools/merlin.py index fd12a03..afeedd9 100644 --- a/src/cditools/merlin.py +++ b/src/cditools/merlin.py @@ -129,10 +129,10 @@ class CDIMerlinDetector(CDIModalTrigger, MerlinDetector): "HDF1:", read_attrs=[], configuration_attrs=[], - write_path_template="/nsls2/data/tst/legacy/mock-proposals/2025-2/pass-56789/assets/merlin/%Y/%m/%d", - root="/nsls2/data/tst/legacy/mock-proposals/2025-2/pass-56789/assets/merlin", + root="/nsls2/data/cdi/proposals/", ) + _asset_path = "merlinES-1" proc1 = Cpt(ProcessPlugin, "Proc1:") stats1 = Cpt(StatsPlugin, "Stats1:") stats2 = Cpt(StatsPlugin, "Stats2:") @@ -169,9 +169,17 @@ def __init__( **kwargs, ) + def _update_paths(self): + self.write_path_template = self.root_path_str + "%Y/%m/%d/" + self.read_path_template = self.root_path_str + "%Y/%m/%d/" + self.reg_root = self.root_path_str + + @property + def root_path_str(self): + return f"{self.root_str}/{self._md['cycle']}/{self._md['data_session']}/assets/{self._asset_path}" + def mode_internal(self) -> None: super().mode_internal() - count_time = self.count_time.get() if isinstance(count_time, float): self.stage_sigs[self.cam.acquire_time] = count_time @@ -179,7 +187,6 @@ def mode_internal(self) -> None: def mode_external(self) -> None: super().mode_external() - # NOTE: these values specify a debounce time for external triggering so # they should be set to < 0.5 the expected exposure time, or at # minimum the lowest possible dead time = 1.64ms @@ -189,3 +196,14 @@ def mode_external(self) -> None: self.stage_sigs[self.cam.acquire_period] = expected_exposure + min_dead_time self.cam.stage_sigs[self.cam.trigger_mode] = "Trigger Enable" + + def stage(self): + self._update_paths() + _TIMEOUT = 2 + self.cam.array_counter.set(0, timeout=_TIMEOUT).wait() + self.stage_sigs[self.cam.trigger_mode] = 0 + + return super().stage() + + def unstage(self): + return super().unstage() \ No newline at end of file From 11a65599834cf07f83a66b20a75ece6c3c0c9d86 Mon Sep 17 00:00:00 2001 From: jennmald Date: Fri, 20 Mar 2026 11:15:20 -0400 Subject: [PATCH 2/7] write_path_template --- src/cditools/merlin.py | 1 + 1 file changed, 1 insertion(+) diff --git a/src/cditools/merlin.py b/src/cditools/merlin.py index afeedd9..8bfae08 100644 --- a/src/cditools/merlin.py +++ b/src/cditools/merlin.py @@ -129,6 +129,7 @@ class CDIMerlinDetector(CDIModalTrigger, MerlinDetector): "HDF1:", read_attrs=[], configuration_attrs=[], + write_path_template = '', root="/nsls2/data/cdi/proposals/", ) From af2c26fb990c859f03edfdbd1dcad2f6529c3645 Mon Sep 17 00:00:00 2001 From: jennmald Date: Fri, 20 Mar 2026 11:33:38 -0400 Subject: [PATCH 3/7] fix root path --- src/cditools/merlin.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/cditools/merlin.py b/src/cditools/merlin.py index 8bfae08..512f651 100644 --- a/src/cditools/merlin.py +++ b/src/cditools/merlin.py @@ -171,9 +171,9 @@ def __init__( ) def _update_paths(self): - self.write_path_template = self.root_path_str + "%Y/%m/%d/" - self.read_path_template = self.root_path_str + "%Y/%m/%d/" - self.reg_root = self.root_path_str + self.write_path_template = self.root_path_str() + "%Y/%m/%d/" + self.read_path_template = self.root_path_str() + "%Y/%m/%d/" + self.reg_root = self.root_path_str() @property def root_path_str(self): From fe6878ba6a4923ba21f30c8e576fd7b219c3d1a3 Mon Sep 17 00:00:00 2001 From: jennmald Date: Fri, 20 Mar 2026 11:51:45 -0400 Subject: [PATCH 4/7] fix root path str --- src/cditools/merlin.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/cditools/merlin.py b/src/cditools/merlin.py index 512f651..8bfae08 100644 --- a/src/cditools/merlin.py +++ b/src/cditools/merlin.py @@ -171,9 +171,9 @@ def __init__( ) def _update_paths(self): - self.write_path_template = self.root_path_str() + "%Y/%m/%d/" - self.read_path_template = self.root_path_str() + "%Y/%m/%d/" - self.reg_root = self.root_path_str() + self.write_path_template = self.root_path_str + "%Y/%m/%d/" + self.read_path_template = self.root_path_str + "%Y/%m/%d/" + self.reg_root = self.root_path_str @property def root_path_str(self): From 7ebb8bd08542b53374c8f7faa9830c546de87a2f Mon Sep 17 00:00:00 2001 From: jennmald Date: Mon, 23 Mar 2026 11:26:19 -0400 Subject: [PATCH 5/7] merlin async --- src/cditools/merlin_async.py | 122 +++++++++++++++++++++++++++++++++++ 1 file changed, 122 insertions(+) create mode 100644 src/cditools/merlin_async.py diff --git a/src/cditools/merlin_async.py b/src/cditools/merlin_async.py new file mode 100644 index 0000000..73a8af3 --- /dev/null +++ b/src/cditools/merlin_async.py @@ -0,0 +1,122 @@ +""" +Ophyd Async implementation for the Merlin Detector +""" + +from __future__ import annotations + +from collections.abc import Sequence +from dataclasses import dataclass +from typing import Annotated as A + +from ophyd_async.core import ( + DetectorTriggerLogic, + PathProvider, + SignalDict, + SignalR, + SignalRW, + StrictEnum, +) +from ophyd_async.epics.adcore import ( + ADArmLogic, + ADBaseIO, + ADWriterType, + AreaDetector, + NDPluginBaseIO, + prepare_exposures, + trigger_info_from_num_images, +) +from ophyd_async.epics.core import PvSuffix + +__all__ = [ + "MerlinDetector", + "MerlinDriverIO", + "MerlinTriggerLogic", + "MerlinTriggerMode", +] + +_MIN_DEAD_TIME = 0.002 + + +class MerlinTriggerMode(StrictEnum): + """Trigger modes for the Merlin detector""" + + INTERNAL = "Internal" + TRIGGER_ENABLE = "Trigger Enable" + TRIGGER_START_RISING = "Trigger start rising" + TRIGGER_START_FALLING = "Trigger start falling" + TRIGGER_BOTH_RISING = "Trigger both rising" + SOFTWARE = "Software" + + +class MerlinDriverIO(ADBaseIO): + """Driver for merlin model:DU897_BV as deployed on p99. + + This mirrors the interface provided by ADMerlin/db/merlin.template. + https://github.com/areaDetector/ADMerlin/blob/master/merlinApp/Db/merlin.template + """ + + trigger_mode: A[SignalRW[MerlinTriggerMode], PvSuffix.rbv("TriggerMode")] + + +# The deadtime of an Merlin controller varies depending on the exact model of camera. +# Ideally we would maximize performance by dynamically retrieving the deadtime at +# runtime. See https://github.com/bluesky/ophyd-async/issues/308 +@dataclass +class MerlinTriggerLogic(DetectorTriggerLogic): + """Trigger logic for MerlinDriverIO.""" + + driver: MerlinDriverIO + + def get_deadtime(self, config_values: SignalDict) -> float: # noqa: ARG002 + return _MIN_DEAD_TIME + + async def prepare_internal(self, num: int, livetime: float, deadtime: float): + await self.driver.trigger_mode.set(MerlinTriggerMode.INTERNAL) + await prepare_exposures(self.driver, num, livetime, deadtime) + + async def prepare_edge(self, num: int, livetime: float): + # Is this the right trigger mode? + await self.driver.trigger_mode.set(MerlinTriggerMode.TRIGGER_START_RISING) + await prepare_exposures(self.driver, num, livetime) + + async def default_trigger_info(self): + return await trigger_info_from_num_images(self.driver) + + +class MerlinDetector(AreaDetector[MerlinDriverIO]): + """Create an ADMerlin AreaDetector instance. + + :param prefix: EPICS PV prefix for the detector + :param path_provider: Provider for file paths during acquisition + :param driver_suffix: Suffix for the driver PV, defaults to "cam1:" + :param writer_type: Type of file writer (HDF or TIFF) + :param writer_suffix: Suffix for the writer PV + :param plugins: Additional areaDetector plugins to include + :param config_sigs: Additional signals to include in configuration + :param name: Name for the detector device + """ + + def __init__( + self, + prefix: str, + path_provider: PathProvider | None = None, + driver_suffix="cam1:", + writer_type: ADWriterType | None = ADWriterType.HDF, + writer_suffix: str | None = None, + plugins: dict[str, NDPluginBaseIO] | None = None, + config_sigs: Sequence[SignalR] = (), + name: str = "", + ) -> None: + driver = MerlinDriverIO(prefix + driver_suffix) + super().__init__( + prefix=prefix, + driver=driver, + arm_logic=ADArmLogic(driver), + trigger_logic=MerlinTriggerLogic(driver), + path_provider=path_provider, + writer_type=writer_type, + writer_suffix=writer_suffix, + plugins=plugins, + config_sigs=config_sigs, + name=name, + ) From e3586f6ae3d94c2653bc348454974fc1be929231 Mon Sep 17 00:00:00 2001 From: jennmald Date: Mon, 23 Mar 2026 11:40:31 -0400 Subject: [PATCH 6/7] merlin trigger modes --- src/cditools/merlin_async.py | 1 - 1 file changed, 1 deletion(-) diff --git a/src/cditools/merlin_async.py b/src/cditools/merlin_async.py index 73a8af3..9514ed3 100644 --- a/src/cditools/merlin_async.py +++ b/src/cditools/merlin_async.py @@ -31,7 +31,6 @@ "MerlinDetector", "MerlinDriverIO", "MerlinTriggerLogic", - "MerlinTriggerMode", ] _MIN_DEAD_TIME = 0.002 From 5ed2750418bbdc69bb9f97c96cd7cb337a276d27 Mon Sep 17 00:00:00 2001 From: Jennefer Maldonado Date: Thu, 26 Mar 2026 17:16:09 -0400 Subject: [PATCH 7/7] modified ophyd async class for merlin --- src/cditools/merlin_async.py | 21 ++++++++++++++++++++- 1 file changed, 20 insertions(+), 1 deletion(-) diff --git a/src/cditools/merlin_async.py b/src/cditools/merlin_async.py index 9514ed3..1b2abcb 100644 --- a/src/cditools/merlin_async.py +++ b/src/cditools/merlin_async.py @@ -15,6 +15,8 @@ SignalR, SignalRW, StrictEnum, + SubsetEnum, + soft_signal_rw ) from ophyd_async.epics.adcore import ( ADArmLogic, @@ -22,6 +24,7 @@ ADWriterType, AreaDetector, NDPluginBaseIO, + ADBaseDataType, prepare_exposures, trigger_info_from_num_images, ) @@ -36,7 +39,7 @@ _MIN_DEAD_TIME = 0.002 -class MerlinTriggerMode(StrictEnum): +class MerlinTriggerMode(SubsetEnum): """Trigger modes for the Merlin detector""" INTERNAL = "Internal" @@ -46,6 +49,15 @@ class MerlinTriggerMode(StrictEnum): TRIGGER_BOTH_RISING = "Trigger both rising" SOFTWARE = "Software" +# class MerlinTriggerModeRBV(SubsetEnum): +# """Trigger modes for the Merlin detector""" + +# INTERNAL = "Internal" +# TRIGGER_ENABLE = "Trigger Enable" +# TRIGGER_START_RISING = "Trigger start rising" +# TRIGGER_START_FALLING = "Trigger start falling" +# TRIGGER_BOTH_RISING = "Trigger both rising " +# SOFTWARE = "Software" class MerlinDriverIO(ADBaseIO): """Driver for merlin model:DU897_BV as deployed on p99. @@ -56,6 +68,12 @@ class MerlinDriverIO(ADBaseIO): trigger_mode: A[SignalRW[MerlinTriggerMode], PvSuffix.rbv("TriggerMode")] + # Since ADMerlin doesn't set the data type readback correctly, but is always uint16, + # just turn it into a static soft signal + def __init__(self, prefix: str, name: str = ""): + super().__init__(prefix, name=name) + self.data_type = soft_signal_rw(ADBaseDataType, ADBaseDataType.UINT16, name="data_type") + # The deadtime of an Merlin controller varies depending on the exact model of camera. # Ideally we would maximize performance by dynamically retrieving the deadtime at @@ -119,3 +137,4 @@ def __init__( config_sigs=config_sigs, name=name, ) +