From 35a82c2a90b0e173376f8ae1918f266d73f0e955 Mon Sep 17 00:00:00 2001 From: jennmald Date: Fri, 20 Mar 2026 10:43:27 -0400 Subject: [PATCH 01/21] 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 02/21] 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 03/21] 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 04/21] 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 05/21] 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 06/21] 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 4c34caead3838695afa456c56459599cf29f0737 Mon Sep 17 00:00:00 2001 From: jennmald Date: Wed, 8 Apr 2026 12:57:10 -0400 Subject: [PATCH 07/21] remove old ophyd async HDFDatasetDescription --- src/cditools/eiger_async.py | 25 ++++++++++++------------- 1 file changed, 12 insertions(+), 13 deletions(-) diff --git a/src/cditools/eiger_async.py b/src/cditools/eiger_async.py index 5d5ec8e..68b83fb 100644 --- a/src/cditools/eiger_async.py +++ b/src/cditools/eiger_async.py @@ -24,7 +24,6 @@ ) from ophyd_async.core import ( DetectorTrigger, - HDFDatasetDescription, PathInfo, PathProvider, SignalDatatypeT, @@ -54,7 +53,7 @@ class EigerDocumentComposer: def __init__( self, full_file_name: Path, - datasets: list[HDFDatasetDescription], + datasets: list[Any], last_emitted_index: int = 0, hostname: str = "localhost", ) -> None: @@ -357,7 +356,7 @@ def __init__( ) self._file_info: PathInfo | None = None - self._datasets: list[HDFDatasetDescription] = [] + self._datasets: list[Any] = [] self._master_file_path_cache: list[Path] = [] async def open(self, name: str, exposures_per_event: int = 1) -> dict[str, DataKey]: @@ -485,16 +484,16 @@ async def open(self, name: str, exposures_per_event: int = 1) -> dict[str, DataK chunk_shape = (1,) else: chunk_shape = cast(tuple[int, ...], (1, *detector_shape)) - frame_datasets = [ - HDFDatasetDescription( - data_key=f"{name}_image", - dataset=f"entry/data/data_{1:06d}", - shape=(exposures_per_event, *detector_shape), - # Always write as uint32 - dtype_numpy=np.dtype(np.uint32).str, - chunk_shape=chunk_shape, - ) - ] + # frame_datasets = [ + # HDFDatasetDescription( + # data_key=f"{name}_image", + # dataset=f"entry/data/data_{1:06d}", + # shape=(exposures_per_event, *detector_shape), + # # Always write as uint32 + # dtype_numpy=np.dtype(np.uint32).str, + # chunk_shape=chunk_shape, + # ) + # ] # Cache descriptions for later use self._datasets = master_datasets + frame_datasets From 672a4d01ef9208bcb32628c59e41c8b4e2ef04a3 Mon Sep 17 00:00:00 2001 From: jennmald Date: Wed, 8 Apr 2026 13:02:45 -0400 Subject: [PATCH 08/21] more bad imports --- src/cditools/eiger_async.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/src/cditools/eiger_async.py b/src/cditools/eiger_async.py index 68b83fb..6583016 100644 --- a/src/cditools/eiger_async.py +++ b/src/cditools/eiger_async.py @@ -35,13 +35,13 @@ observe_value, ) from ophyd_async.epics.adcore import ( - ADBaseController, - ADBaseDatasetDescriber, - ADBaseIO, + #ADBaseController, + #ADBaseDatasetDescriber, + #ADBaseIO, ADImageMode, - ADWriter, + #ADWriter, AreaDetector, - NDFileIO, + #NDFileIO, NDPluginBaseIO, ) from ophyd_async.epics.signal import PvSuffix From df1e9cdaaf99e5f0685370e85248d150b21a8800 Mon Sep 17 00:00:00 2001 From: jennmald Date: Wed, 8 Apr 2026 13:06:04 -0400 Subject: [PATCH 09/21] add ADBaseIO --- src/cditools/eiger_async.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/cditools/eiger_async.py b/src/cditools/eiger_async.py index 6583016..4a02124 100644 --- a/src/cditools/eiger_async.py +++ b/src/cditools/eiger_async.py @@ -37,7 +37,7 @@ from ophyd_async.epics.adcore import ( #ADBaseController, #ADBaseDatasetDescriber, - #ADBaseIO, + ADBaseIO, ADImageMode, #ADWriter, AreaDetector, From 6a80bdca0da178e5325224fed9fdfd38911d06b2 Mon Sep 17 00:00:00 2001 From: jennmald Date: Wed, 8 Apr 2026 13:07:25 -0400 Subject: [PATCH 10/21] add NDFileIO --- src/cditools/eiger_async.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/cditools/eiger_async.py b/src/cditools/eiger_async.py index 4a02124..e271f6b 100644 --- a/src/cditools/eiger_async.py +++ b/src/cditools/eiger_async.py @@ -41,7 +41,7 @@ ADImageMode, #ADWriter, AreaDetector, - #NDFileIO, + NDFileIO, NDPluginBaseIO, ) from ophyd_async.epics.signal import PvSuffix From 876502ecb629cf00ac1525b381641b0bba41dfac Mon Sep 17 00:00:00 2001 From: jennmald Date: Wed, 8 Apr 2026 13:10:06 -0400 Subject: [PATCH 11/21] adwriter --- src/cditools/eiger_async.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/cditools/eiger_async.py b/src/cditools/eiger_async.py index e271f6b..5d30dc6 100644 --- a/src/cditools/eiger_async.py +++ b/src/cditools/eiger_async.py @@ -39,7 +39,7 @@ #ADBaseDatasetDescriber, ADBaseIO, ADImageMode, - #ADWriter, + ADWriter, AreaDetector, NDFileIO, NDPluginBaseIO, From 7a2dc85cc97298b7c56b230f869e92d997847fd5 Mon Sep 17 00:00:00 2001 From: jennmald Date: Wed, 8 Apr 2026 13:13:11 -0400 Subject: [PATCH 12/21] fix imports for adcore --- src/cditools/eiger_async.py | 708 ++++++++++++++++++------------------ 1 file changed, 354 insertions(+), 354 deletions(-) diff --git a/src/cditools/eiger_async.py b/src/cditools/eiger_async.py index 5d30dc6..360d72c 100644 --- a/src/cditools/eiger_async.py +++ b/src/cditools/eiger_async.py @@ -14,32 +14,32 @@ import numpy as np # type: ignore[import-not-found] from bluesky.protocols import StreamAsset -from event_model import ( # type: ignore[import-untyped] - ComposeStreamResource, - ComposeStreamResourceBundle, - DataKey, # type: ignore[import-untyped] - StreamDatum, - StreamRange, - StreamResource, -) +# from event_model import ( # type: ignore[import-untyped] +# ComposeStreamResource, +# ComposeStreamResourceBundle, +# DataKey, # type: ignore[import-untyped] +# StreamDatum, +# StreamRange, +# StreamResource, +# ) from ophyd_async.core import ( - DetectorTrigger, - PathInfo, + # DetectorTrigger, + # PathInfo, PathProvider, SignalDatatypeT, SignalR, SignalRW, StrictEnum, SubsetEnum, - TriggerInfo, - observe_value, + # TriggerInfo, + # observe_value, ) from ophyd_async.epics.adcore import ( #ADBaseController, #ADBaseDatasetDescriber, ADBaseIO, - ADImageMode, - ADWriter, + #ADImageMode, + #ADWriter, AreaDetector, NDFileIO, NDPluginBaseIO, @@ -49,55 +49,55 @@ logger = getLogger(__name__) -class EigerDocumentComposer: - def __init__( - self, - full_file_name: Path, - datasets: list[Any], - last_emitted_index: int = 0, - hostname: str = "localhost", - ) -> None: - self._last_emitted = last_emitted_index - self._hostname = hostname - uri = urlunparse( - ( - "file", - self._hostname, - str(full_file_name.absolute()), - "", - "", - None, - ) - ) - bundler_composer = ComposeStreamResource() - self._bundles: list[ComposeStreamResourceBundle] = [ - bundler_composer( - mimetype="application/x-hdf5", - uri=uri, - data_key=ds.data_key, - parameters={ - "dataset": ds.dataset, - "chunk_shape": ds.chunk_shape, - }, - uid=None, - validate=True, - ) - for ds in datasets - ] - - def stream_resources(self) -> Iterator[StreamResource]: - for bundle in self._bundles: - yield bundle.stream_resource_doc - - def stream_data(self, indices_written: int) -> Iterator[StreamDatum]: - if indices_written > self._last_emitted: - indices: StreamRange = { - "start": self._last_emitted, - "stop": indices_written, - } - self._last_emitted = indices_written - for bundle in self._bundles: - yield bundle.compose_stream_datum(indices) +# class EigerDocumentComposer: +# def __init__( +# self, +# full_file_name: Path, +# datasets: list[Any], +# last_emitted_index: int = 0, +# hostname: str = "localhost", +# ) -> None: +# self._last_emitted = last_emitted_index +# self._hostname = hostname +# uri = urlunparse( +# ( +# "file", +# self._hostname, +# str(full_file_name.absolute()), +# "", +# "", +# None, +# ) +# ) +# bundler_composer = ComposeStreamResource() +# self._bundles: list[ComposeStreamResourceBundle] = [ +# bundler_composer( +# mimetype="application/x-hdf5", +# uri=uri, +# data_key=ds.data_key, +# parameters={ +# "dataset": ds.dataset, +# "chunk_shape": ds.chunk_shape, +# }, +# uid=None, +# validate=True, +# ) +# for ds in datasets +# ] + +# def stream_resources(self) -> Iterator[StreamResource]: +# for bundle in self._bundles: +# yield bundle.stream_resource_doc + +# def stream_data(self, indices_written: int) -> Iterator[StreamDatum]: +# if indices_written > self._last_emitted: +# indices: StreamRange = { +# "start": self._last_emitted, +# "stop": indices_written, +# } +# self._last_emitted = indices_written +# for bundle in self._bundles: +# yield bundle.compose_stream_datum(indices) # TODO - add extra options in eiger2 and revert to StrictEnum @@ -332,297 +332,297 @@ class Eiger2DriverIO(EigerDriverIO): fw_hdf5_format: A[SignalRW[EigerHDF5Format], PvSuffix.rbv("FWHDF5Format")] -class EigerWriter(ADWriter[EigerDriverIO]): # type: ignore[reportInvalidTypeArguments] - """Eiger-specific file writer using the built-in FileWriter interface.""" - - default_suffix: str = "cam1:" - # Forced minimum number of images per file to force a single HDF5 file - _min_num_images_per_file: int = 1_000_000_000 - - def __init__( - self, - fileio: EigerDriverIO, - path_provider: PathProvider, - dataset_describer: ADBaseDatasetDescriber, - plugins: dict[str, NDPluginBaseIO] | None = None, - ): - super().__init__( - fileio, - path_provider, - dataset_describer, - file_extension=".h5", - mimetype="application/x-hdf5", - plugins=plugins, - ) - - self._file_info: PathInfo | None = None - self._datasets: list[Any] = [] - self._master_file_path_cache: list[Path] = [] - - async def open(self, name: str, exposures_per_event: int = 1) -> dict[str, DataKey]: - """Setup file writing for acquisition.""" - # Get file path info from path provider - self._file_info = self._path_provider() - self._master_file_path_cache.clear() - - # Cache for use later - self._exposures_per_event = exposures_per_event - - # Set the name pattern with $id replacement similar to original - name_pattern = f"{self._file_info.filename}_$id" - - # Configure the Eiger FileWriter - await asyncio.gather( - self.fileio.file_path.set(self._file_info.directory_path.as_posix()), - self.fileio.create_directory.set(self._file_info.create_dir_depth), - self.fileio.fw_name_pattern.set(name_pattern), - self.fileio.fw_enable.set(True), - self.fileio.save_files.set(True), - self.fileio.data_source.set(EigerDataSource.FILE_WRITER), - self.fileio.num_capture.set(0), - # Use array_counter to track the total number of images written - self.fileio.array_counter.set(0), - ) - - if not await self.fileio.file_path_exists.get_value(): - msg = f"File path {self._file_info.directory_path} does not exist" - raise FileNotFoundError(msg) - - if isinstance(self.fileio, Eiger2DriverIO): - await self.fileio.fw_hdf5_format.set(EigerHDF5Format.LEGACY) - - # Force the number of images per file to a large number to simplify the logic - num_images_per_file = await self.fileio.fw_nimgs_per_file.get_value() - if num_images_per_file < self._min_num_images_per_file: - await self.fileio.fw_nimgs_per_file.set(self._min_num_images_per_file) - logger.warning( - "Setting fw_nimgs_per_file to %d to force writing to a single HDF5 file", - self._min_num_images_per_file, - ) - - detector_shape = await self._dataset_describer.shape() - - # TODO: Add these when empty shape datasets are supported by tiled - # Add the master file datasets - master_datasets = [] - # master_datasets = [ - # HDFDatasetDescription2( - # data_key=f"{name}_y_pixel_size", - # dataset="entry/instrument/detector/y_pixel_size", - # shape=(), - # dtype_numpy=np.dtype(np.float32).str, - # chunk_shape=(), - # join_method="stack", - # ), - # HDFDatasetDescription2( - # data_key=f"{name}_x_pixel_size", - # dataset="entry/instrument/detector/x_pixel_size", - # shape=(), - # dtype_numpy=np.dtype(np.float32).str, - # chunk_shape=(), - # join_method="stack", - # ), - # HDFDatasetDescription2( - # data_key=f"{name}_detector_distance", - # dataset="entry/instrument/detector/detector_distance", - # shape=(), - # dtype_numpy=np.dtype(np.float32).str, - # chunk_shape=(), - # join_method="stack", - # ), - # HDFDatasetDescription2( - # data_key=f"{name}_incident_wavelength", - # dataset="entry/instrument/detector/incident_wavelength", - # shape=(), - # dtype_numpy=np.dtype(np.float32).str, - # chunk_shape=(), - # join_method="stack", - # ), - # HDFDatasetDescription2( - # data_key=f"{name}_frame_time", - # dataset="entry/instrument/detector/frame_time", - # shape=(), - # dtype_numpy=np.dtype(np.float32).str, - # chunk_shape=(), - # join_method="stack", - # ), - # HDFDatasetDescription2( - # data_key=f"{name}_beam_center_x", - # dataset="entry/instrument/detector/beam_center_x", - # shape=(), - # dtype_numpy=np.dtype(np.float32).str, - # chunk_shape=(), - # join_method="stack", - # ), - # HDFDatasetDescription2( - # data_key=f"{name}_beam_center_y", - # dataset="entry/instrument/detector/beam_center_y", - # shape=(), - # dtype_numpy=np.dtype(np.float32).str, - # chunk_shape=(), - # join_method="stack", - # ), - # HDFDatasetDescription2( - # data_key=f"{name}_count_time", - # dataset="entry/instrument/detector/count_time", - # shape=(), - # dtype_numpy=np.dtype(np.float32).str, - # chunk_shape=(), - # join_method="stack", - # ), - # HDFDatasetDescription2( - # data_key=f"{name}_pixel_mask", - # dataset="entry/instrument/detector/detectorSpecific/pixel_mask", - # shape=detector_shape, - # dtype_numpy=np.dtype(np.uint32).str, - # chunk_shape=detector_shape, - # join_method="stack", - # ), - # ] - - if any(s is None for s in detector_shape): - chunk_shape = (1,) - else: - chunk_shape = cast(tuple[int, ...], (1, *detector_shape)) - # frame_datasets = [ - # HDFDatasetDescription( - # data_key=f"{name}_image", - # dataset=f"entry/data/data_{1:06d}", - # shape=(exposures_per_event, *detector_shape), - # # Always write as uint32 - # dtype_numpy=np.dtype(np.uint32).str, - # chunk_shape=chunk_shape, - # ) - # ] - - # Cache descriptions for later use - self._datasets = master_datasets + frame_datasets - - return { - ds.data_key: DataKey( - source="ADEiger FileWriter", - shape=list(ds.shape), - dtype="array" - if exposures_per_event > 1 or len(ds.shape) > 1 - else "number", - dtype_numpy=ds.dtype_numpy, - external="STREAM:", - ) - for ds in self._datasets - } - - @property - async def _master_file_path(self) -> Path | None: - if self._file_info is None: - logger.warning( - "No master file path found for file info %s", - self._file_info, - ) - return None - sequence_id = await self.fileio.sequence_id.get_value() - return Path( - self._file_info.directory_path - / f"{self._file_info.filename}_{sequence_id}_master.h5" - ) - - async def collect_stream_docs( - self, name: str, indices_written: int - ) -> AsyncIterator[StreamAsset]: - """Generate stream documents for the written HDF5 files.""" - if indices_written: - master_file_path = await self._master_file_path - if master_file_path is None: - msg = f"Master file path is not set for {name}: {self._file_info}" - raise ValueError(msg) - - # Eiger generates a new master file for each trigger - # so we need to create a new composer with a new - # master file path - composer = EigerDocumentComposer( - master_file_path, - self._datasets, - last_emitted_index=indices_written - 1, - ) - - # For later validation - self._master_file_path_cache.append(master_file_path) - - for doc in composer.stream_resources(): - yield "stream_resource", doc - - for doc in composer.stream_data(indices_written): - yield "stream_datum", doc - - async def observe_indices_written( - self, timeout: float - ) -> AsyncGenerator[int, None]: - async for num_captured in observe_value(self.fileio.array_counter, timeout): - yield num_captured // self._exposures_per_event - - async def get_indices_written(self) -> int: - return await self.fileio.array_counter.get_value() // self._exposures_per_event - - async def close(self) -> None: - """Clean up file writing after acquisition and validate files exist.""" - - # Check that the master files were written - for master_file_path in self._master_file_path_cache: - if not master_file_path.exists(): - logger.warning("Master file was not written: %s", master_file_path) - - self._file_info = None - - -class EigerController(ADBaseController[EigerDriverIO]): - """Controller for Eiger detector, handling trigger modes and acquisition setup.""" - - def __init__( - self, driver: EigerDriverIO, *args: Any, **kwargs: dict[str, Any] - ) -> None: - super().__init__(driver, *args, **kwargs) - - def get_deadtime(self, exposure: float | None) -> float: - """Get detector deadtime for the given exposure.""" - default_deadtime = 0.000001 - if exposure is not None: - logger.warning( - "Ignoring exposure to calculate deadtime: %s, defaulting to %s", - exposure, - default_deadtime, - ) - return default_deadtime - - async def prepare(self, trigger_info: TriggerInfo) -> None: - """Prepare the detector for acquisition.""" - if (exposure := trigger_info.livetime) is not None: - await self.driver.acquire_time.set(exposure) - - # Configure trigger mode based on TriggerInfo - if trigger_info.trigger == DetectorTrigger.INTERNAL: - await self.driver.trigger_mode.set(EigerTriggerMode.INTERNAL_SERIES) - elif trigger_info.trigger == DetectorTrigger.EDGE_TRIGGER: - await self.driver.trigger_mode.set(EigerTriggerMode.EXTERNAL_SERIES) - else: - msg = f"Trigger mode {trigger_info.trigger} not supported" - raise NotImplementedError(msg) - - if trigger_info.total_number_of_exposures == 0: - image_mode = ADImageMode.CONTINUOUS - else: - image_mode = ADImageMode.MULTIPLE - - if isinstance(trigger_info.number_of_events, list): - logger.warning( - "Got a list for number of events, expected to be set up externally: %s", - trigger_info.number_of_events, - ) - else: - await self.driver.num_triggers.set(trigger_info.number_of_events) - - await asyncio.gather( - self.driver.num_images.set(trigger_info.exposures_per_event), - self.driver.image_mode.set(image_mode), - ) +# class EigerWriter(ADWriter[EigerDriverIO]): # type: ignore[reportInvalidTypeArguments] +# """Eiger-specific file writer using the built-in FileWriter interface.""" + +# default_suffix: str = "cam1:" +# # Forced minimum number of images per file to force a single HDF5 file +# _min_num_images_per_file: int = 1_000_000_000 + +# def __init__( +# self, +# fileio: EigerDriverIO, +# path_provider: PathProvider, +# dataset_describer: ADBaseDatasetDescriber, +# plugins: dict[str, NDPluginBaseIO] | None = None, +# ): +# super().__init__( +# fileio, +# path_provider, +# dataset_describer, +# file_extension=".h5", +# mimetype="application/x-hdf5", +# plugins=plugins, +# ) + +# self._file_info: PathInfo | None = None +# self._datasets: list[Any] = [] +# self._master_file_path_cache: list[Path] = [] + +# async def open(self, name: str, exposures_per_event: int = 1) -> dict[str, DataKey]: +# """Setup file writing for acquisition.""" +# # Get file path info from path provider +# self._file_info = self._path_provider() +# self._master_file_path_cache.clear() + +# # Cache for use later +# self._exposures_per_event = exposures_per_event + +# # Set the name pattern with $id replacement similar to original +# name_pattern = f"{self._file_info.filename}_$id" + +# # Configure the Eiger FileWriter +# await asyncio.gather( +# self.fileio.file_path.set(self._file_info.directory_path.as_posix()), +# self.fileio.create_directory.set(self._file_info.create_dir_depth), +# self.fileio.fw_name_pattern.set(name_pattern), +# self.fileio.fw_enable.set(True), +# self.fileio.save_files.set(True), +# self.fileio.data_source.set(EigerDataSource.FILE_WRITER), +# self.fileio.num_capture.set(0), +# # Use array_counter to track the total number of images written +# self.fileio.array_counter.set(0), +# ) + +# if not await self.fileio.file_path_exists.get_value(): +# msg = f"File path {self._file_info.directory_path} does not exist" +# raise FileNotFoundError(msg) + +# if isinstance(self.fileio, Eiger2DriverIO): +# await self.fileio.fw_hdf5_format.set(EigerHDF5Format.LEGACY) + +# # Force the number of images per file to a large number to simplify the logic +# num_images_per_file = await self.fileio.fw_nimgs_per_file.get_value() +# if num_images_per_file < self._min_num_images_per_file: +# await self.fileio.fw_nimgs_per_file.set(self._min_num_images_per_file) +# logger.warning( +# "Setting fw_nimgs_per_file to %d to force writing to a single HDF5 file", +# self._min_num_images_per_file, +# ) + +# detector_shape = await self._dataset_describer.shape() + +# # TODO: Add these when empty shape datasets are supported by tiled +# # Add the master file datasets +# master_datasets = [] +# # master_datasets = [ +# # HDFDatasetDescription2( +# # data_key=f"{name}_y_pixel_size", +# # dataset="entry/instrument/detector/y_pixel_size", +# # shape=(), +# # dtype_numpy=np.dtype(np.float32).str, +# # chunk_shape=(), +# # join_method="stack", +# # ), +# # HDFDatasetDescription2( +# # data_key=f"{name}_x_pixel_size", +# # dataset="entry/instrument/detector/x_pixel_size", +# # shape=(), +# # dtype_numpy=np.dtype(np.float32).str, +# # chunk_shape=(), +# # join_method="stack", +# # ), +# # HDFDatasetDescription2( +# # data_key=f"{name}_detector_distance", +# # dataset="entry/instrument/detector/detector_distance", +# # shape=(), +# # dtype_numpy=np.dtype(np.float32).str, +# # chunk_shape=(), +# # join_method="stack", +# # ), +# # HDFDatasetDescription2( +# # data_key=f"{name}_incident_wavelength", +# # dataset="entry/instrument/detector/incident_wavelength", +# # shape=(), +# # dtype_numpy=np.dtype(np.float32).str, +# # chunk_shape=(), +# # join_method="stack", +# # ), +# # HDFDatasetDescription2( +# # data_key=f"{name}_frame_time", +# # dataset="entry/instrument/detector/frame_time", +# # shape=(), +# # dtype_numpy=np.dtype(np.float32).str, +# # chunk_shape=(), +# # join_method="stack", +# # ), +# # HDFDatasetDescription2( +# # data_key=f"{name}_beam_center_x", +# # dataset="entry/instrument/detector/beam_center_x", +# # shape=(), +# # dtype_numpy=np.dtype(np.float32).str, +# # chunk_shape=(), +# # join_method="stack", +# # ), +# # HDFDatasetDescription2( +# # data_key=f"{name}_beam_center_y", +# # dataset="entry/instrument/detector/beam_center_y", +# # shape=(), +# # dtype_numpy=np.dtype(np.float32).str, +# # chunk_shape=(), +# # join_method="stack", +# # ), +# # HDFDatasetDescription2( +# # data_key=f"{name}_count_time", +# # dataset="entry/instrument/detector/count_time", +# # shape=(), +# # dtype_numpy=np.dtype(np.float32).str, +# # chunk_shape=(), +# # join_method="stack", +# # ), +# # HDFDatasetDescription2( +# # data_key=f"{name}_pixel_mask", +# # dataset="entry/instrument/detector/detectorSpecific/pixel_mask", +# # shape=detector_shape, +# # dtype_numpy=np.dtype(np.uint32).str, +# # chunk_shape=detector_shape, +# # join_method="stack", +# # ), +# # ] + +# if any(s is None for s in detector_shape): +# chunk_shape = (1,) +# else: +# chunk_shape = cast(tuple[int, ...], (1, *detector_shape)) +# # frame_datasets = [ +# # HDFDatasetDescription( +# # data_key=f"{name}_image", +# # dataset=f"entry/data/data_{1:06d}", +# # shape=(exposures_per_event, *detector_shape), +# # # Always write as uint32 +# # dtype_numpy=np.dtype(np.uint32).str, +# # chunk_shape=chunk_shape, +# # ) +# # ] + +# # Cache descriptions for later use +# self._datasets = master_datasets + frame_datasets + +# return { +# ds.data_key: DataKey( +# source="ADEiger FileWriter", +# shape=list(ds.shape), +# dtype="array" +# if exposures_per_event > 1 or len(ds.shape) > 1 +# else "number", +# dtype_numpy=ds.dtype_numpy, +# external="STREAM:", +# ) +# for ds in self._datasets +# } + +# @property +# async def _master_file_path(self) -> Path | None: +# if self._file_info is None: +# logger.warning( +# "No master file path found for file info %s", +# self._file_info, +# ) +# return None +# sequence_id = await self.fileio.sequence_id.get_value() +# return Path( +# self._file_info.directory_path +# / f"{self._file_info.filename}_{sequence_id}_master.h5" +# ) + +# async def collect_stream_docs( +# self, name: str, indices_written: int +# ) -> AsyncIterator[StreamAsset]: +# """Generate stream documents for the written HDF5 files.""" +# if indices_written: +# master_file_path = await self._master_file_path +# if master_file_path is None: +# msg = f"Master file path is not set for {name}: {self._file_info}" +# raise ValueError(msg) + +# # Eiger generates a new master file for each trigger +# # so we need to create a new composer with a new +# # master file path +# composer = EigerDocumentComposer( +# master_file_path, +# self._datasets, +# last_emitted_index=indices_written - 1, +# ) + +# # For later validation +# self._master_file_path_cache.append(master_file_path) + +# for doc in composer.stream_resources(): +# yield "stream_resource", doc + +# for doc in composer.stream_data(indices_written): +# yield "stream_datum", doc + +# async def observe_indices_written( +# self, timeout: float +# ) -> AsyncGenerator[int, None]: +# async for num_captured in observe_value(self.fileio.array_counter, timeout): +# yield num_captured // self._exposures_per_event + +# async def get_indices_written(self) -> int: +# return await self.fileio.array_counter.get_value() // self._exposures_per_event + +# async def close(self) -> None: +# """Clean up file writing after acquisition and validate files exist.""" + +# # Check that the master files were written +# for master_file_path in self._master_file_path_cache: +# if not master_file_path.exists(): +# logger.warning("Master file was not written: %s", master_file_path) + +# self._file_info = None + + +# class EigerController(ADBaseController[EigerDriverIO]): +# """Controller for Eiger detector, handling trigger modes and acquisition setup.""" + +# def __init__( +# self, driver: EigerDriverIO, *args: Any, **kwargs: dict[str, Any] +# ) -> None: +# super().__init__(driver, *args, **kwargs) + +# def get_deadtime(self, exposure: float | None) -> float: +# """Get detector deadtime for the given exposure.""" +# default_deadtime = 0.000001 +# if exposure is not None: +# logger.warning( +# "Ignoring exposure to calculate deadtime: %s, defaulting to %s", +# exposure, +# default_deadtime, +# ) +# return default_deadtime + +# async def prepare(self, trigger_info: TriggerInfo) -> None: +# """Prepare the detector for acquisition.""" +# if (exposure := trigger_info.livetime) is not None: +# await self.driver.acquire_time.set(exposure) + +# # Configure trigger mode based on TriggerInfo +# if trigger_info.trigger == DetectorTrigger.INTERNAL: +# await self.driver.trigger_mode.set(EigerTriggerMode.INTERNAL_SERIES) +# elif trigger_info.trigger == DetectorTrigger.EDGE_TRIGGER: +# await self.driver.trigger_mode.set(EigerTriggerMode.EXTERNAL_SERIES) +# else: +# msg = f"Trigger mode {trigger_info.trigger} not supported" +# raise NotImplementedError(msg) + +# if trigger_info.total_number_of_exposures == 0: +# image_mode = ADImageMode.CONTINUOUS +# else: +# image_mode = ADImageMode.MULTIPLE + +# if isinstance(trigger_info.number_of_events, list): +# logger.warning( +# "Got a list for number of events, expected to be set up externally: %s", +# trigger_info.number_of_events, +# ) +# else: +# await self.driver.num_triggers.set(trigger_info.number_of_events) + +# await asyncio.gather( +# self.driver.num_images.set(trigger_info.exposures_per_event), +# self.driver.image_mode.set(image_mode), +# ) class EigerDetector(AreaDetector[EigerController]): From 483285c7985a74033bba04efdb35694d526687db Mon Sep 17 00:00:00 2001 From: jennmald Date: Wed, 8 Apr 2026 13:15:30 -0400 Subject: [PATCH 13/21] one more time --- src/cditools/eiger_async.py | 102 ++++++++++++++++++------------------ 1 file changed, 51 insertions(+), 51 deletions(-) diff --git a/src/cditools/eiger_async.py b/src/cditools/eiger_async.py index 360d72c..f8b38b0 100644 --- a/src/cditools/eiger_async.py +++ b/src/cditools/eiger_async.py @@ -4,16 +4,16 @@ from __future__ import annotations -import asyncio -from collections.abc import AsyncGenerator, AsyncIterator, Iterator, Sequence +# import asyncio +# from collections.abc import AsyncGenerator, AsyncIterator, Iterator, Sequence from logging import getLogger -from pathlib import Path +# from pathlib import Path from typing import Annotated as A -from typing import Any, cast -from urllib.parse import urlunparse +# from typing import Any, cast +# from urllib.parse import urlunparse import numpy as np # type: ignore[import-not-found] -from bluesky.protocols import StreamAsset +# from bluesky.protocols import StreamAsset # from event_model import ( # type: ignore[import-untyped] # ComposeStreamResource, # ComposeStreamResourceBundle, @@ -25,8 +25,8 @@ from ophyd_async.core import ( # DetectorTrigger, # PathInfo, - PathProvider, - SignalDatatypeT, + # PathProvider, + # SignalDatatypeT, SignalR, SignalRW, StrictEnum, @@ -40,9 +40,9 @@ ADBaseIO, #ADImageMode, #ADWriter, - AreaDetector, + # AreaDetector, NDFileIO, - NDPluginBaseIO, + # NDPluginBaseIO, ) from ophyd_async.epics.signal import PvSuffix @@ -625,44 +625,44 @@ class Eiger2DriverIO(EigerDriverIO): # ) -class EigerDetector(AreaDetector[EigerController]): - """Eiger detector implementation using the AreaDetector pattern.""" - - def __init__( - self, - prefix: str, - path_provider: PathProvider, - driver_suffix: str = "cam1:", - writer_cls: type[ADWriter] = EigerWriter, # type: ignore[reportUnknownParameterType] - fileio_suffix: str | None = None, - name: str = "", - config_sigs: Sequence[SignalR[SignalDatatypeT]] = (), - plugins: dict[str, NDPluginBaseIO] | None = None, - ): - driver = EigerDriverIO(prefix + driver_suffix) - controller = EigerController(driver) - if issubclass(writer_cls, EigerWriter): - dataset_describer = ADBaseDatasetDescriber(driver) - # EigerWriter takes the driver as the fileio, since it relies on driver PVs - writer = writer_cls( - driver, - path_provider, - dataset_describer=dataset_describer, - plugins=plugins, - ) - else: - writer = writer_cls.with_io( - prefix, - path_provider, - dataset_source=driver, - fileio_suffix=fileio_suffix, - plugins=plugins, - ) - - super().__init__( - controller=controller, - writer=writer, - plugins=plugins, - name=name, - config_sigs=config_sigs, - ) +# class EigerDetector(AreaDetector[EigerController]): +# """Eiger detector implementation using the AreaDetector pattern.""" + +# def __init__( +# self, +# prefix: str, +# path_provider: PathProvider, +# driver_suffix: str = "cam1:", +# writer_cls: type[ADWriter] = EigerWriter, # type: ignore[reportUnknownParameterType] +# fileio_suffix: str | None = None, +# name: str = "", +# config_sigs: Sequence[SignalR[SignalDatatypeT]] = (), +# plugins: dict[str, NDPluginBaseIO] | None = None, +# ): +# driver = EigerDriverIO(prefix + driver_suffix) +# controller = EigerController(driver) +# if issubclass(writer_cls, EigerWriter): +# dataset_describer = ADBaseDatasetDescriber(driver) +# # EigerWriter takes the driver as the fileio, since it relies on driver PVs +# writer = writer_cls( +# driver, +# path_provider, +# dataset_describer=dataset_describer, +# plugins=plugins, +# ) +# else: +# writer = writer_cls.with_io( +# prefix, +# path_provider, +# dataset_source=driver, +# fileio_suffix=fileio_suffix, +# plugins=plugins, +# ) + +# super().__init__( +# controller=controller, +# writer=writer, +# plugins=plugins, +# name=name, +# config_sigs=config_sigs, +# ) From a817a2c2797319856be0683ecb68d4bfffd108ef Mon Sep 17 00:00:00 2001 From: jennmald Date: Wed, 8 Apr 2026 13:21:29 -0400 Subject: [PATCH 14/21] make data_type_signal --- src/cditools/eiger_async.py | 1 + 1 file changed, 1 insertion(+) diff --git a/src/cditools/eiger_async.py b/src/cditools/eiger_async.py index f8b38b0..e9fb0aa 100644 --- a/src/cditools/eiger_async.py +++ b/src/cditools/eiger_async.py @@ -322,6 +322,7 @@ class Eiger2DriverIO(EigerDriverIO): # Readout Setup signed_data: A[SignalRW[bool], PvSuffix.rbv("SignedData")] + data_type_setup: A[SignalRW[str], PvSuffix.rbv("DataTypeSetup")] # Stream Interface stream_version: A[SignalRW[EigerStreamVersion], PvSuffix.rbv("StreamVersion")] From 97ba1a7ae66513cb582c44c8748e43918dc83b41 Mon Sep 17 00:00:00 2001 From: jennmald Date: Wed, 8 Apr 2026 13:23:57 -0400 Subject: [PATCH 15/21] add data_type_signal to eigerio --- src/cditools/eiger_async.py | 1 + 1 file changed, 1 insertion(+) diff --git a/src/cditools/eiger_async.py b/src/cditools/eiger_async.py index e9fb0aa..8b82f1c 100644 --- a/src/cditools/eiger_async.py +++ b/src/cditools/eiger_async.py @@ -268,6 +268,7 @@ class EigerDriverIO(ADBaseIO, NDFileIO): beam_y: A[SignalRW[float], PvSuffix.rbv("BeamY")] det_dist: A[SignalRW[float], PvSuffix.rbv("DetDist")] wavelength: A[SignalRW[float], PvSuffix.rbv("Wavelength")] + data_type_setup: A[SignalRW[str], PvSuffix.rbv("DataTypeSetup")] # Detector Metadata chi_start: A[SignalRW[float], PvSuffix.rbv("ChiStart")] From 04776b95ff0c82120800d1c139441aae9b180feb Mon Sep 17 00:00:00 2001 From: jennmald Date: Wed, 8 Apr 2026 14:03:21 -0400 Subject: [PATCH 16/21] remove data type --- src/cditools/eiger_async.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/src/cditools/eiger_async.py b/src/cditools/eiger_async.py index 8b82f1c..f8b38b0 100644 --- a/src/cditools/eiger_async.py +++ b/src/cditools/eiger_async.py @@ -268,7 +268,6 @@ class EigerDriverIO(ADBaseIO, NDFileIO): beam_y: A[SignalRW[float], PvSuffix.rbv("BeamY")] det_dist: A[SignalRW[float], PvSuffix.rbv("DetDist")] wavelength: A[SignalRW[float], PvSuffix.rbv("Wavelength")] - data_type_setup: A[SignalRW[str], PvSuffix.rbv("DataTypeSetup")] # Detector Metadata chi_start: A[SignalRW[float], PvSuffix.rbv("ChiStart")] @@ -323,7 +322,6 @@ class Eiger2DriverIO(EigerDriverIO): # Readout Setup signed_data: A[SignalRW[bool], PvSuffix.rbv("SignedData")] - data_type_setup: A[SignalRW[str], PvSuffix.rbv("DataTypeSetup")] # Stream Interface stream_version: A[SignalRW[EigerStreamVersion], PvSuffix.rbv("StreamVersion")] From 25bde84c3206bb878ceaf240a5eddfe4c92e6fa1 Mon Sep 17 00:00:00 2001 From: jennmald Date: Wed, 3 Jun 2026 10:35:29 -0400 Subject: [PATCH 17/21] fix trigger logic --- src/cditools/merlin_async.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/cditools/merlin_async.py b/src/cditools/merlin_async.py index 9514ed3..c3d14f8 100644 --- a/src/cditools/merlin_async.py +++ b/src/cditools/merlin_async.py @@ -10,6 +10,7 @@ from ophyd_async.core import ( DetectorTriggerLogic, + DetectorArmLogic, PathProvider, SignalDict, SignalR, @@ -17,7 +18,6 @@ StrictEnum, ) from ophyd_async.epics.adcore import ( - ADArmLogic, ADBaseIO, ADWriterType, AreaDetector, @@ -110,7 +110,7 @@ def __init__( super().__init__( prefix=prefix, driver=driver, - arm_logic=ADArmLogic(driver), + arm_logic=DetectorArmLogic(driver), trigger_logic=MerlinTriggerLogic(driver), path_provider=path_provider, writer_type=writer_type, From 1a07e6d90c27945ddddb2b0a696b073088641817 Mon Sep 17 00:00:00 2001 From: jennmald Date: Wed, 3 Jun 2026 10:44:23 -0400 Subject: [PATCH 18/21] revert changes and pin ophyd-async version in profile instead --- src/cditools/merlin_async.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/cditools/merlin_async.py b/src/cditools/merlin_async.py index c3d14f8..9514ed3 100644 --- a/src/cditools/merlin_async.py +++ b/src/cditools/merlin_async.py @@ -10,7 +10,6 @@ from ophyd_async.core import ( DetectorTriggerLogic, - DetectorArmLogic, PathProvider, SignalDict, SignalR, @@ -18,6 +17,7 @@ StrictEnum, ) from ophyd_async.epics.adcore import ( + ADArmLogic, ADBaseIO, ADWriterType, AreaDetector, @@ -110,7 +110,7 @@ def __init__( super().__init__( prefix=prefix, driver=driver, - arm_logic=DetectorArmLogic(driver), + arm_logic=ADArmLogic(driver), trigger_logic=MerlinTriggerLogic(driver), path_provider=path_provider, writer_type=writer_type, From b19241e4b0fc074dcce01d9ab06849d4485b9b19 Mon Sep 17 00:00:00 2001 From: jennmald Date: Wed, 3 Jun 2026 10:57:08 -0400 Subject: [PATCH 19/21] use different merlin class from other branch --- src/cditools/merlin_async.py | 23 ++++++++++++++++------- 1 file changed, 16 insertions(+), 7 deletions(-) diff --git a/src/cditools/merlin_async.py b/src/cditools/merlin_async.py index 9514ed3..941eaff 100644 --- a/src/cditools/merlin_async.py +++ b/src/cditools/merlin_async.py @@ -10,14 +10,16 @@ from ophyd_async.core import ( DetectorTriggerLogic, + DetectorArmLogic, PathProvider, SignalDict, SignalR, SignalRW, - StrictEnum, + SubsetEnum, + soft_signal_rw, ) from ophyd_async.epics.adcore import ( - ADArmLogic, + ADBaseDataType, ADBaseIO, ADWriterType, AreaDetector, @@ -36,14 +38,14 @@ _MIN_DEAD_TIME = 0.002 -class MerlinTriggerMode(StrictEnum): +class MerlinTriggerMode(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" + TRIGGER_BOTH_RISING = "Trigger both rising " SOFTWARE = "Software" @@ -56,6 +58,14 @@ 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 @@ -74,7 +84,6 @@ async def prepare_internal(self, num: int, livetime: float, deadtime: float): 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) @@ -110,7 +119,7 @@ def __init__( super().__init__( prefix=prefix, driver=driver, - arm_logic=ADArmLogic(driver), + arm_logic=DetectorArmLogic(driver), trigger_logic=MerlinTriggerLogic(driver), path_provider=path_provider, writer_type=writer_type, @@ -118,4 +127,4 @@ def __init__( plugins=plugins, config_sigs=config_sigs, name=name, - ) + ) \ No newline at end of file From e00607e463a97c31f2409b92f37465460377100b Mon Sep 17 00:00:00 2001 From: jennmald Date: Wed, 3 Jun 2026 11:02:20 -0400 Subject: [PATCH 20/21] revert --- src/cditools/merlin_async.py | 21 ++++++--------------- 1 file changed, 6 insertions(+), 15 deletions(-) diff --git a/src/cditools/merlin_async.py b/src/cditools/merlin_async.py index 941eaff..e77b3a8 100644 --- a/src/cditools/merlin_async.py +++ b/src/cditools/merlin_async.py @@ -10,16 +10,14 @@ from ophyd_async.core import ( DetectorTriggerLogic, - DetectorArmLogic, PathProvider, SignalDict, SignalR, SignalRW, - SubsetEnum, - soft_signal_rw, + StrictEnum, ) from ophyd_async.epics.adcore import ( - ADBaseDataType, + ADArmLogic, ADBaseIO, ADWriterType, AreaDetector, @@ -38,14 +36,14 @@ _MIN_DEAD_TIME = 0.002 -class MerlinTriggerMode(SubsetEnum): +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 " + TRIGGER_BOTH_RISING = "Trigger both rising" SOFTWARE = "Software" @@ -58,14 +56,6 @@ 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 @@ -84,6 +74,7 @@ async def prepare_internal(self, num: int, livetime: float, deadtime: float): 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) @@ -119,7 +110,7 @@ def __init__( super().__init__( prefix=prefix, driver=driver, - arm_logic=DetectorArmLogic(driver), + arm_logic=ADArmLogic(driver), trigger_logic=MerlinTriggerLogic(driver), path_provider=path_provider, writer_type=writer_type, From cdde2421eceba93166aeb54b88f748c580482e4e Mon Sep 17 00:00:00 2001 From: jennmald Date: Wed, 3 Jun 2026 11:14:26 -0400 Subject: [PATCH 21/21] revert back to space fix --- src/cditools/merlin_async.py | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/src/cditools/merlin_async.py b/src/cditools/merlin_async.py index e77b3a8..7d0a2ef 100644 --- a/src/cditools/merlin_async.py +++ b/src/cditools/merlin_async.py @@ -46,6 +46,16 @@ class MerlinTriggerMode(StrictEnum): TRIGGER_BOTH_RISING = "Trigger both rising" SOFTWARE = "Software" +class MerlinTriggerModeRBV(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.