From a392fd3be377ebcd2c7177093ec94d27771a72be Mon Sep 17 00:00:00 2001 From: Dan Henriksen Date: Wed, 10 Jun 2026 17:29:55 -0400 Subject: [PATCH 01/13] wip. moved eiger2 classes from cdi-profile-collection. tests very broken --- src/cditools/eiger_async.py | 414 +++++++++++++++++++++++++++++++++++- tests/test_eiger_async.py | 23 +- 2 files changed, 417 insertions(+), 20 deletions(-) diff --git a/src/cditools/eiger_async.py b/src/cditools/eiger_async.py index f8b38b0..21ecf39 100644 --- a/src/cditools/eiger_async.py +++ b/src/cditools/eiger_async.py @@ -3,14 +3,15 @@ """ from __future__ import annotations - -# import asyncio -# from collections.abc import AsyncGenerator, AsyncIterator, Iterator, Sequence +import asyncio +import functools +import os +from collections.abc import AsyncGenerator, AsyncIterator, Sequence +from urllib.parse import urlunparse +from pathlib import Path from logging import getLogger -# from pathlib import Path from typing import Annotated as A # from typing import Any, cast -# from urllib.parse import urlunparse import numpy as np # type: ignore[import-not-found] # from bluesky.protocols import StreamAsset @@ -38,16 +39,292 @@ #ADBaseController, #ADBaseDatasetDescriber, ADBaseIO, - #ADImageMode, #ADWriter, - # AreaDetector, NDFileIO, - # NDPluginBaseIO, + ADImageMode, + AreaDetector, + NDPluginBaseIO, + trigger_info_from_num_images, ) from ophyd_async.epics.signal import PvSuffix +from typing import Annotated as A +from ophyd_async.core import ( + AsyncStatus, + DetectorArmLogic, + DetectorDataLogic, + DetectorTriggerLogic, + PathInfo, + PathProvider, + SignalDatatypeT, + StreamResourceDataProvider, + StreamResourceInfo, + StrictEnum, + TriggerInfo, + observe_value, + set_and_wait_for_other_value, +) +from ophyd_async.core._data_providers import ( + StreamableDataProvider, +) +from ophyd_async.core._signal import ( + SignalR, + SignalRW, +) +from ophyd_async.core._status import WatchableAsyncStatus +from ophyd_async.core._utils import ( + DEFAULT_TIMEOUT, + WatcherUpdate, + error_if_none, +) +from ophyd_async.epics.core import stop_busy_record + logger = getLogger(__name__) +class EigerController(DetectorTriggerLogic): + """Controller for Eiger detector, handling trigger modes and acquisition setup.""" + + def __init__( + self, + driver: EigerDriverIO, + ) -> None: + self.driver = driver + + 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_internal(self, num: int, livetime: float, deadtime: float): + """Prepare the detector for acquisition.""" + + if livetime > 0: + await self.driver.acquire_time.set(livetime) + + await self.driver.trigger_mode.set(EigerTriggerMode.INTERNAL_SERIES) + + if num == 0: + image_mode = ADImageMode.CONTINUOUS + else: + image_mode = ADImageMode.MULTIPLE + + # await self.driver.num_triggers.set(num) + + await asyncio.gather( + self.driver.image_mode.set(image_mode), + ) + + async def prepare_edge(self, num: int, livetime: float): + """Prepare the detector to take external edge triggered exposures. + + :param num: the number of exposures to take + :param livetime: how long the exposure should be, 0 means what is currently set + """ + + await self.driver.acquire_time.set(livetime) + await self.driver.num_triggers.set(num) + if num == 0: + image_mode = ADImageMode.CONTINUOUS + else: + image_mode = ADImageMode.MULTIPLE + + await self.driver.trigger_mode.set(EigerTriggerMode.EXTERNAL_SERIES) + await asyncio.gather( + self.driver.image_mode.set(image_mode), + ) + + async def default_trigger_info(self): + return await trigger_info_from_num_images(self.driver) + + +class EigerDataLogic(DetectorDataLogic): + """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, + ): + self.fileio = fileio + self._path_provider = path_provider + # self._dataset_describer = dataset_describer + # self._plugins = plugins or {} + + self._file_info: PathInfo | None = None + self._datasets: list[StreamResourceDataProvider] = [] + self._master_file_path_cache: list[Path] = [] + + async def prepare_unbounded(self, datakey_name: str) -> StreamableDataProvider: + """Provider can work for an unbounded number of collections.""" + # Get file path info from path provider + # TODO: should probably just pass datakey_name + self._file_info = self._path_provider("eiger2-1") + self._master_file_path_cache.clear() + + # 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), + self.fileio.manual_trigger.set(True), + # TODO sort out how to get this from the plan + self.fileio.num_triggers.set(5000), + ) + + await set_and_wait_for_other_value( + set_signal=self.fileio.acquire, + set_value=True, + match_signal=self.fileio.armed, + match_value=True, + wait_for_set_completion=False, + timeout=DEFAULT_TIMEOUT, + ) + + 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 + # TODO: allow multiple files + 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, + ) + driver = self.fileio + + shape = await asyncio.gather( + *[sig.get_value() for sig in [driver.array_size_y, driver.array_size_x]] + ) + datatype = "uint32" + # Remove entries in shape that are zero + shape = [x for x in shape if x > 0] + + mfp = await self._master_file_path + # TODO sort out how to get from parent + name = "eiger" + exposures_per_event = await self.fileio.num_images.get_value() + + # TODO sort out how to tell tiled about the additional data files. + return StreamResourceDataProvider( + uri=urlunparse(("file", "localhost", str(mfp), "", "", None)), + resources=[ + StreamResourceInfo( + data_key=f"{name}_image", + shape=(exposures_per_event, *shape), + # TODO sort out how to set this and mirror here + chunk_shape=(1, *shape), + dtype_numpy=np.dtype(datatype.lower()).str, + parameters={ + "dataset": f"entry/data/data_{1:06d}", + }, + # TODO put in better value + source="EIGER2_FILE_WRITER", + ) + ], + mimetype="application/x-hdf5", + collections_written_signal=self.fileio.array_counter, + ) + + @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 observe_indices_written( + self, timeout: float + ) -> AsyncGenerator[int, None]: + async for num_captured in observe_value(self.fileio.array_counter, timeout): + yield num_captured + + async def get_indices_written(self) -> int: + return await self.fileio.array_counter.get_value() + + async def stop(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(): + # ... + + self._file_info = None + await self.fileio.fw_enable.set(False) + + +# TODO sort out if ths is the right name of things +class EigerArmLogic(DetectorArmLogic): + def __init__( + self, driver: Eiger2DriverIO, driver_armed_signal: SignalR[bool] | None = None + ): + self.driver = driver + if driver_armed_signal is not None: + self.driver_armed_signal = driver_armed_signal + else: + self.driver_armed_signal = driver.acquire + self.acquire_status: AsyncStatus | None = None + self._rolling_image_counter = 0 + + async def arm(self): + self._rolling_image_counter = await self.driver.num_images_counter.get_value() + ret = await self.driver.trigger.set(1) + return ret + + async def wait_for_idle(self): + + target_num_images, frame_acquire_period = await asyncio.gather(self.driver.num_images.get_value(), + self.driver.acquire_period.get_value()) + frame_timeout = frame_acquire_period + DEFAULT_TIMEOUT + done_timeout = frame_timeout * target_num_images + target_num_images += self._rolling_image_counter + async for images_complete in observe_value(self.driver.num_images_counter, timeout=frame_timeout, done_timeout=done_timeout): + if images_complete == target_num_images: + break + + async def disarm(self): + self._rolling_image_counter = 0 + await stop_busy_record(self.driver.acquire) + + await asyncio.gather( + self.driver.manual_trigger.set(False), + self.driver.num_triggers.set(1), + ) + # class EigerDocumentComposer: # def __init__( @@ -172,6 +449,8 @@ class EigerStreamVersion(StrictEnum): See https://areadetector.github.io/areaDetector/ADEiger/eiger.html#stream-interface """ + # TODO - Stream or Stream1? + # STREAM1 = "Stream" STREAM1 = "Stream1" STREAM2 = "Stream2" @@ -309,8 +588,12 @@ class Eiger2DriverIO(EigerDriverIO): hv_state: A[SignalR[str], PvSuffix("HVState_RBV")] # Acquisition Setup + # TODO - ThresholdEnergy or Threshold? + # threshold: A[SignalRW[float], PvSuffix.rbv("ThresholdEnergy")] threshold: A[SignalRW[float], PvSuffix.rbv("Threshold")] threshold1_enable: A[SignalRW[bool], PvSuffix.rbv("Threshold1Enable")] + # TODO - Threshold2Energy or Threshold? + # threshold2: A[SignalRW[float], PvSuffix.rbv("Threshold2Energy")] threshold2: A[SignalRW[float], PvSuffix.rbv("Threshold2")] threshold2_enable: A[SignalRW[bool], PvSuffix.rbv("Threshold2Enable")] threshold_diff_enable: A[SignalRW[bool], PvSuffix.rbv("ThresholdDiffEnable")] @@ -325,7 +608,10 @@ class Eiger2DriverIO(EigerDriverIO): # Stream Interface stream_version: A[SignalRW[EigerStreamVersion], PvSuffix.rbv("StreamVersion")] + # TODO - which one? + # stream_hdr_appendix: None stream_hdr_appendix: A[SignalRW[str], PvSuffix.rbv("StreamHdrAppendix")] + # stream_img_appendix: None stream_img_appendix: A[SignalRW[str], PvSuffix.rbv("StreamImgAppendix")] # FileWriter Interface @@ -666,3 +952,115 @@ class Eiger2DriverIO(EigerDriverIO): # name=name, # config_sigs=config_sigs, # ) + + +class EigerDetector(AreaDetector[Eiger2DriverIO]): + """Eiger detector implementation using the AreaDetector pattern.""" + + def __init__( + self, + prefix: str, + path_provider: PathProvider, + driver_suffix: str = "cam1:", + name: str = "", + config_sigs: Sequence[SignalR[SignalDatatypeT]] = (), + plugins: dict[str, NDPluginBaseIO] | None = None, + ): + driver = Eiger2DriverIO(prefix + driver_suffix) + controller = EigerController(driver) + # if issubclass(writer_cls, EigerDataLogic): + # 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_logic = EigerDataLogic(fileio=driver, path_provider=path_provider) + arm_logic = EigerArmLogic(driver) + super().__init__( + prefix=prefix, + driver=driver, + trigger_logic=controller, + writer_type=None, + name=name, + config_sigs=config_sigs, + plugins=plugins, + arm_logic=arm_logic, + ) + # self.writer = None + self.add_detector_logics(writer_logic) + + # TODO remove this as it should be identical to upstream. + @WatchableAsyncStatus.wrap + async def trigger(self) -> AsyncIterator[WatcherUpdate[int]]: + """Trigger a single exposure. + + If [`prepare()`](#StandardDetector.prepare) has not been called since + the last [`stage()`](#StandardDetector.stage), an implicit prepare is + performed. When [](#OPHYD_ASYNC_PRESERVE_DETECTOR_STATE) is `YES` + [](#DetectorTriggerLogic.default_trigger_info) is called to read current + hardware state; otherwise a bare [`TriggerInfo()`](#TriggerInfo) is + used. + """ + if self._prepare_ctx is None: + # Opt-in: set OPHYD_ASYNC_PRESERVE_DETECTOR_STATE=YES to have + # trigger() read back current hardware state (e.g. num_images) via + # default_trigger_info() instead of always falling back to TriggerInfo(). + # See ADR 0013 for rationale. + # TODO: flip default to YES and remove this guard in a future PR once + # downstream code has had time to implement default_trigger_info(). + preserve_state = ( + os.environ.get("OPHYD_ASYNC_PRESERVE_DETECTOR_STATE", "NO").upper() + == "YES" + ) + if preserve_state and self._trigger_logic is not None: + + def _logic_supported(base_class, method) -> bool: + # If the function that is bound in a subclass is the same as the function + # attached to the superclass, then the subclass has not overridden it, so + # this method is not supported by the subclass. + return method.__func__ is not getattr(base_class, method.__name__) + + _trigger_logic_supported = functools.partial( + _logic_supported, DetectorTriggerLogic + ) + if not _trigger_logic_supported( + self._trigger_logic.default_trigger_info + ): + raise RuntimeError( + f"OPHYD_ASYNC_PRESERVE_DETECTOR_STATE=YES is set but " + f"'{self.name}' has no default_trigger_info() - implement " + "default_trigger_info() on your DetectorTriggerLogic subclass " + "or unset the environment variable." + ) + trigger_info = await self._trigger_logic.default_trigger_info() + else: + trigger_info = TriggerInfo() + await self.prepare(trigger_info) + else: + # Check the one that was provided is suitable for triggering + trigger_info = self._prepare_ctx.trigger_info + if trigger_info.number_of_events != 1: + msg = ( + "trigger() is not supported for multiple events, the detector was " + f"prepared with number_of_events={trigger_info.number_of_events}." + ) + raise ValueError(msg) + # Ensure the data provider is still usable + await self._update_prepare_context(trigger_info) + ctx = error_if_none(self._prepare_ctx, "Prepare should have been run") + # Arm the detector and wait for it to finish. + if self._arm_logic: + await self._arm_logic.arm() + + async for update in self._wait_for_index( + data_providers=ctx.streamable_data_providers, + trigger_info=ctx.trigger_info, + initial_collections_written=ctx.collections_written, + collections_requested=1, + wait_for_idle=True, + ): + yield update diff --git a/tests/test_eiger_async.py b/tests/test_eiger_async.py index da60bd8..83c05e2 100644 --- a/tests/test_eiger_async.py +++ b/tests/test_eiger_async.py @@ -24,7 +24,7 @@ TriggerInfo, init_devices, ) -from ophyd_async.epics.adcore import ADBaseDatasetDescriber, ADBaseDataType, ADImageMode +from ophyd_async.epics.adcore import ADBaseDataType, ADImageMode from ophyd_async.testing import ( callback_on_mock_put, set_mock_value, @@ -33,11 +33,11 @@ from cditools.eiger_async import ( EigerController, + EigerDataLogic, EigerDataSource, EigerDetector, EigerDriverIO, EigerTriggerMode, - EigerWriter, ) EIGER_DATA_PATH = Path("/tmp/pytest/eiger_data/") @@ -139,13 +139,12 @@ def mock_path_provider() -> PathProvider: def eiger_writer( mock_eiger_driver: EigerDriverIO, mock_path_provider: PathProvider, -) -> Generator[EigerWriter, None, None]: +) -> Generator[EigerDataLogic, None, None]: """Create an EigerWriter instance for testing.""" if not EIGER_DATA_PATH.exists(): EIGER_DATA_PATH.mkdir(parents=True) assert EIGER_DATA_PATH.exists() - dataset_describer = ADBaseDatasetDescriber(mock_eiger_driver) - yield EigerWriter(mock_eiger_driver, mock_path_provider, dataset_describer) + yield EigerWriter(mock_eiger_driver, mock_path_provider) if EIGER_DATA_PATH.exists(): shutil.rmtree(EIGER_DATA_PATH) @@ -157,11 +156,11 @@ def eiger_controller(mock_eiger_driver: EigerDriverIO) -> EigerController: @pytest.mark.asyncio async def test_eiger_writer_initialization( - eiger_writer: EigerWriter, + eiger_writer: EigerDataLogic, mock_eiger_driver: EigerDriverIO, mock_path_provider: PathProvider, ): - """Test that EigerWriter initializes correctly.""" + """Test that EigerDataLogic initializes correctly.""" assert eiger_writer.fileio is mock_eiger_driver assert eiger_writer._path_provider is mock_path_provider # type: ignore[reportPrivateUsage] assert eiger_writer._dataset_describer is not None # type: ignore[reportPrivateUsage] @@ -170,7 +169,7 @@ async def test_eiger_writer_initialization( @pytest.mark.asyncio async def test_eiger_writer_open( - eiger_writer: EigerWriter, + eiger_writer: EigerDataLogic, mock_eiger_driver: EigerDriverIO, ) -> None: """Test the open method configures the detector correctly.""" @@ -234,7 +233,7 @@ async def test_eiger_writer_open( @pytest.mark.asyncio async def test_eiger_writer_get_indices_written( - eiger_writer: EigerWriter, + eiger_writer: EigerDataLogic, mock_eiger_driver: EigerDriverIO, ): """Test getting the number of indices written.""" @@ -286,7 +285,7 @@ async def test_eiger_writer_get_indices_written( @pytest.mark.asyncio async def test_eiger_writer_observe_indices_written( - eiger_writer: EigerWriter, + eiger_writer: EigerDataLogic, mock_eiger_driver: EigerDriverIO, ) -> None: """Test observing indices as they are written.""" @@ -360,7 +359,7 @@ async def _complete(): @pytest.mark.asyncio async def test_eiger_writer_collect_stream_docs( - eiger_writer: EigerWriter, + eiger_writer: EigerDataLogic, mock_eiger_driver: EigerDriverIO, ) -> None: """Test collecting stream documents.""" @@ -417,7 +416,7 @@ async def collect_docs( @pytest.mark.asyncio async def test_eiger_writer_close( - eiger_writer: EigerWriter, + eiger_writer: EigerDataLogic, mock_eiger_driver: EigerDriverIO, ) -> None: """Test closing the writer.""" From 8ce5e54d06bfd4376f8ae55c88613ad683ad44f1 Mon Sep 17 00:00:00 2001 From: Dan Henriksen Date: Thu, 11 Jun 2026 17:45:02 -0400 Subject: [PATCH 02/13] got a couple tests to pass --- src/cditools/eiger_async.py | 18 ++++-- tests/test_eiger_async.py | 119 ++++++++++++++++++++++++++++-------- 2 files changed, 105 insertions(+), 32 deletions(-) diff --git a/src/cditools/eiger_async.py b/src/cditools/eiger_async.py index 21ecf39..dcb6ea2 100644 --- a/src/cditools/eiger_async.py +++ b/src/cditools/eiger_async.py @@ -81,6 +81,7 @@ logger = getLogger(__name__) + class EigerController(DetectorTriggerLogic): """Controller for Eiger detector, handling trigger modes and acquisition setup.""" @@ -102,7 +103,12 @@ def get_deadtime(self, exposure: float | None) -> float: return default_deadtime async def prepare_internal(self, num: int, livetime: float, deadtime: float): - """Prepare the detector for acquisition.""" + """Prepare the detector for acquisition. + + On Internal Series, num sets the number of images to take per trigger: + https://areadetector.github.io/areaDetector/ADEiger/eiger.html#implementation-of-standard-driver-parameters + """ + # TODO - should we do something with deadtime? if livetime > 0: await self.driver.acquire_time.set(livetime) @@ -114,7 +120,7 @@ async def prepare_internal(self, num: int, livetime: float, deadtime: float): else: image_mode = ADImageMode.MULTIPLE - # await self.driver.num_triggers.set(num) + await self.driver.num_images.set(num) await asyncio.gather( self.driver.image_mode.set(image_mode), @@ -128,7 +134,9 @@ async def prepare_edge(self, num: int, livetime: float): """ await self.driver.acquire_time.set(livetime) - await self.driver.num_triggers.set(num) + # TODO is setting num_triggers right? or should it be num_images? + # await self.driver.num_triggers.set(num) + await self.driver.num_images.set(num) if num == 0: image_mode = ADImageMode.CONTINUOUS else: @@ -293,6 +301,7 @@ def __init__( self, driver: Eiger2DriverIO, driver_armed_signal: SignalR[bool] | None = None ): self.driver = driver + # TODO - remove? driver_armed_signal doesn't seem to be a thing anywhere else if driver_armed_signal is not None: self.driver_armed_signal = driver_armed_signal else: @@ -306,7 +315,6 @@ async def arm(self): return ret async def wait_for_idle(self): - target_num_images, frame_acquire_period = await asyncio.gather(self.driver.num_images.get_value(), self.driver.acquire_period.get_value()) frame_timeout = frame_acquire_period + DEFAULT_TIMEOUT @@ -990,7 +998,7 @@ def __init__( plugins=plugins, arm_logic=arm_logic, ) - # self.writer = None + self.data_logic = writer_logic self.add_detector_logics(writer_logic) # TODO remove this as it should be identical to upstream. diff --git a/tests/test_eiger_async.py b/tests/test_eiger_async.py index 83c05e2..022d5af 100644 --- a/tests/test_eiger_async.py +++ b/tests/test_eiger_async.py @@ -6,7 +6,7 @@ import asyncio import shutil -from collections.abc import Generator +from collections.abc import AsyncGenerator, Generator from pathlib import Path import bluesky.plans as bp @@ -101,10 +101,19 @@ def mock_eiger_detector(RE: RunEngine) -> Generator[EigerDetector, None, None]: ) with init_devices(mock=True): detector = EigerDetector("MOCK:EIGER:", path_provider, name="test_eiger") - set_mock_value(detector.fileio.file_path_exists, True) + + set_mock_value(detector.driver.file_path_exists, True) set_mock_value(detector.driver.array_size_x, 2048) set_mock_value(detector.driver.array_size_y, 2048) set_mock_value(detector.driver.data_type, "UInt16") + set_mock_value(detector.driver.acquire, False) + set_mock_value(detector.data_logic.fileio.armed, False) + + # Sync acquire with armed when acquire is set + async def sync_fileio_armed(value: bool): + set_mock_value(detector.data_logic.fileio.armed, value) + + callback_on_mock_put(detector.driver.acquire, sync_fileio_armed) yield detector @@ -136,15 +145,25 @@ def mock_path_provider() -> PathProvider: @pytest.fixture -def eiger_writer( +async def eiger_writer( mock_eiger_driver: EigerDriverIO, mock_path_provider: PathProvider, -) -> Generator[EigerDataLogic, None, None]: +) -> AsyncGenerator[EigerDataLogic, None]: """Create an EigerWriter instance for testing.""" if not EIGER_DATA_PATH.exists(): EIGER_DATA_PATH.mkdir(parents=True) assert EIGER_DATA_PATH.exists() - yield EigerWriter(mock_eiger_driver, mock_path_provider) + + with init_devices(mock=True): + datalogic = EigerDataLogic(mock_eiger_driver, mock_path_provider) + + async def sync_fileio_armed(value: bool): + print('here'*10) + set_mock_value(datalogic.fileio.armed, value) + + callback_on_mock_put(datalogic.fileio.acquire, sync_fileio_armed) + # yield EigerDataLogic(mock_eiger_driver, mock_path_provider) + yield datalogic if EIGER_DATA_PATH.exists(): shutil.rmtree(EIGER_DATA_PATH) @@ -432,35 +451,65 @@ async def test_eiger_writer_close( await eiger_writer.close() assert eiger_writer._file_info is None # type: ignore[reportPrivateUsage] - @pytest.mark.asyncio -async def test_eiger_controller_prepare(eiger_controller: EigerController) -> None: +async def test_eiger_prepare(mock_eiger_detector: EigerDetector) -> None: trigger_info = TriggerInfo( - number_of_events=1, + trigger=DetectorTrigger.INTERNAL, livetime=0.01, deadtime=0.001, - trigger=DetectorTrigger.INTERNAL, + exposures_per_collection=1, + collections_per_event=1, + number_of_events=1, exposure_timeout=1.0, - exposures_per_event=1, ) - await eiger_controller.prepare(trigger_info) - assert await eiger_controller.driver.acquire_time.get_value() == 0.01 + await mock_eiger_detector.prepare(trigger_info) + assert await mock_eiger_detector.driver.acquire_time.get_value() == 0.01 assert ( - await eiger_controller.driver.trigger_mode.get_value() + await mock_eiger_detector.driver.trigger_mode.get_value() == EigerTriggerMode.INTERNAL_SERIES ) - assert await eiger_controller.driver.num_images.get_value() == 1 - assert await eiger_controller.driver.image_mode.get_value() == ADImageMode.MULTIPLE + assert await mock_eiger_detector.driver.num_images.get_value() == 1 + assert await mock_eiger_detector.driver.image_mode.get_value() == ADImageMode.MULTIPLE + # Implement tests for these other trigger_infos trigger_info = TriggerInfo( - number_of_events=10, + trigger=DetectorTrigger.EXTERNAL_EDGE, livetime=0.0, deadtime=0.0, - trigger=DetectorTrigger.EDGE_TRIGGER, + exposures_per_collection=5, + collections_per_event=1, + number_of_events=10, exposure_timeout=10.0, - exposures_per_event=5, ) - await eiger_controller.prepare(trigger_info) + +@pytest.mark.asyncio +async def test_eiger_data_logic_prepare_unbounded(eiger_writer: EigerDataLogic) -> None: + trigger_info = TriggerInfo( + trigger=DetectorTrigger.INTERNAL, + livetime=0.01, + deadtime=0.001, + exposures_per_collection=1, + collections_per_event=1, + number_of_events=1, + exposure_timeout=1.0, + ) + stream_resource = await eiger_writer.prepare_unbounded("test_eiger") + print(stream_resource) + +@pytest.mark.asyncio +async def test_eiger_controller_prepare_internal(eiger_controller: EigerController) -> None: + await eiger_controller.prepare_internal(num=1, livetime=0.01, deadtime=0.001) + assert await eiger_controller.driver.acquire_time.get_value() == 0.01 + assert ( + await eiger_controller.driver.trigger_mode.get_value() + == EigerTriggerMode.INTERNAL_SERIES + ) + assert await eiger_controller.driver.num_images.get_value() == 1 + assert await eiger_controller.driver.image_mode.get_value() == ADImageMode.MULTIPLE + +@pytest.mark.asyncio +async def test_eiger_controller_prepare_edge(eiger_controller: EigerController) -> None: + await eiger_controller.prepare_edge(num=5, livetime=0.0) assert await eiger_controller.driver.acquire_time.get_value() == 0.0 assert ( await eiger_controller.driver.trigger_mode.get_value() @@ -469,6 +518,10 @@ async def test_eiger_controller_prepare(eiger_controller: EigerController) -> No assert await eiger_controller.driver.num_images.get_value() == 5 assert await eiger_controller.driver.image_mode.get_value() == ADImageMode.MULTIPLE + +@pytest.mark.skip("Does this test reflect any kind of desired behavior?") +@pytest.mark.asyncio +async def test_eiger_controller_prepare_edge2(eiger_controller: EigerController) -> None: trigger_info = TriggerInfo( number_of_events=0, livetime=None, @@ -477,7 +530,7 @@ async def test_eiger_controller_prepare(eiger_controller: EigerController) -> No exposure_timeout=10.0, exposures_per_event=1, ) - await eiger_controller.prepare(trigger_info) + await eiger_controller.prepare_edge(num=0, livetime=None) assert await eiger_controller.driver.acquire_time.get_value() == 0.0 assert ( await eiger_controller.driver.trigger_mode.get_value() @@ -493,16 +546,27 @@ async def test_eiger_controller_prepare(eiger_controller: EigerController) -> No async def test_eiger_detector(mock_eiger_detector: EigerDetector) -> None: set_mock_value(mock_eiger_detector.driver.num_images, 1) set_mock_value(mock_eiger_detector.driver.acquire_period, 0.001) - set_mock_value(mock_eiger_detector.fileio.array_counter, 0) + set_mock_value(mock_eiger_detector.data_logic.fileio.array_counter, 0) async def _simulate_one_trigger(value: bool, wait: bool) -> None: await asyncio.sleep(await mock_eiger_detector.driver.acquire_period.get_value()) - array_counter = await mock_eiger_detector.fileio.array_counter.get_value() - set_mock_value(mock_eiger_detector.fileio.array_counter, array_counter + 1) + array_counter = await mock_eiger_detector.data_logic.fileio.array_counter.get_value() + set_mock_value(mock_eiger_detector.data_logic.fileio.array_counter, array_counter + 1) callback_on_mock_put(mock_eiger_detector.driver.acquire, _simulate_one_trigger) # Standalone methods + await mock_eiger_detector.prepare( + TriggerInfo( + trigger=DetectorTrigger.INTERNAL, + livetime=0.01, + deadtime=0.001, + exposures_per_collection=1, + collections_per_event=1, + number_of_events=1, + exposure_timeout=10.0, + ) + ) await mock_eiger_detector.describe() # Case 1 - Step Scan: stage, trigger, read, trigger, read, unstage @@ -517,16 +581,17 @@ async def _simulate_one_trigger(value: bool, wait: bool) -> None: await mock_eiger_detector.read() await mock_eiger_detector.unstage() - set_mock_value(mock_eiger_detector.fileio.array_counter, 0) + set_mock_value(mock_eiger_detector.data_logic.fileio.array_counter, 0) # Case 2 - Fly Scan: prepare, kickoff, complete await mock_eiger_detector.prepare( TriggerInfo( - number_of_events=1, + trigger=DetectorTrigger.INTERNAL, livetime=0.01, deadtime=0.001, - trigger=DetectorTrigger.INTERNAL, + exposures_per_collection=1, + collections_per_event=1, + number_of_events=1, exposure_timeout=10.0, - exposures_per_event=1, ) ) await mock_eiger_detector.kickoff() From b634deca42133696edaafb3e81b47895cbfa5fc0 Mon Sep 17 00:00:00 2001 From: Dan Henriksen Date: Thu, 11 Jun 2026 19:22:55 -0400 Subject: [PATCH 03/13] updated some tests, deleted unused code --- src/cditools/eiger_async.py | 941 ++++++++++-------------------------- tests/test_eiger_async.py | 19 +- 2 files changed, 266 insertions(+), 694 deletions(-) diff --git a/src/cditools/eiger_async.py b/src/cditools/eiger_async.py index dcb6ea2..e1989cd 100644 --- a/src/cditools/eiger_async.py +++ b/src/cditools/eiger_async.py @@ -11,44 +11,22 @@ from pathlib import Path from logging import getLogger from typing import Annotated as A -# from typing import Any, cast - -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 ophyd_async.core import ( - # DetectorTrigger, - # PathInfo, - # PathProvider, - # SignalDatatypeT, - SignalR, - SignalRW, - StrictEnum, - SubsetEnum, - # TriggerInfo, - # observe_value, -) +import numpy as np + from ophyd_async.epics.adcore import ( - #ADBaseController, - #ADBaseDatasetDescriber, ADBaseIO, - #ADWriter, NDFileIO, ADImageMode, AreaDetector, NDPluginBaseIO, trigger_info_from_num_images, ) -from ophyd_async.epics.signal import PvSuffix -from typing import Annotated as A +from ophyd_async.epics.core import PvSuffix, stop_busy_record from ophyd_async.core import ( + SignalR, + SignalRW, + StrictEnum, + SubsetEnum, AsyncStatus, DetectorArmLogic, DetectorDataLogic, @@ -58,333 +36,21 @@ SignalDatatypeT, StreamResourceDataProvider, StreamResourceInfo, - StrictEnum, TriggerInfo, observe_value, set_and_wait_for_other_value, ) -from ophyd_async.core._data_providers import ( - StreamableDataProvider, -) -from ophyd_async.core._signal import ( - SignalR, - SignalRW, -) +from ophyd_async.core._data_providers import StreamableDataProvider from ophyd_async.core._status import WatchableAsyncStatus from ophyd_async.core._utils import ( DEFAULT_TIMEOUT, WatcherUpdate, error_if_none, ) -from ophyd_async.epics.core import stop_busy_record - logger = getLogger(__name__) -class EigerController(DetectorTriggerLogic): - """Controller for Eiger detector, handling trigger modes and acquisition setup.""" - - def __init__( - self, - driver: EigerDriverIO, - ) -> None: - self.driver = driver - - 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_internal(self, num: int, livetime: float, deadtime: float): - """Prepare the detector for acquisition. - - On Internal Series, num sets the number of images to take per trigger: - https://areadetector.github.io/areaDetector/ADEiger/eiger.html#implementation-of-standard-driver-parameters - """ - # TODO - should we do something with deadtime? - - if livetime > 0: - await self.driver.acquire_time.set(livetime) - - await self.driver.trigger_mode.set(EigerTriggerMode.INTERNAL_SERIES) - - if num == 0: - image_mode = ADImageMode.CONTINUOUS - else: - image_mode = ADImageMode.MULTIPLE - - await self.driver.num_images.set(num) - - await asyncio.gather( - self.driver.image_mode.set(image_mode), - ) - - async def prepare_edge(self, num: int, livetime: float): - """Prepare the detector to take external edge triggered exposures. - - :param num: the number of exposures to take - :param livetime: how long the exposure should be, 0 means what is currently set - """ - - await self.driver.acquire_time.set(livetime) - # TODO is setting num_triggers right? or should it be num_images? - # await self.driver.num_triggers.set(num) - await self.driver.num_images.set(num) - if num == 0: - image_mode = ADImageMode.CONTINUOUS - else: - image_mode = ADImageMode.MULTIPLE - - await self.driver.trigger_mode.set(EigerTriggerMode.EXTERNAL_SERIES) - await asyncio.gather( - self.driver.image_mode.set(image_mode), - ) - - async def default_trigger_info(self): - return await trigger_info_from_num_images(self.driver) - - -class EigerDataLogic(DetectorDataLogic): - """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, - ): - self.fileio = fileio - self._path_provider = path_provider - # self._dataset_describer = dataset_describer - # self._plugins = plugins or {} - - self._file_info: PathInfo | None = None - self._datasets: list[StreamResourceDataProvider] = [] - self._master_file_path_cache: list[Path] = [] - - async def prepare_unbounded(self, datakey_name: str) -> StreamableDataProvider: - """Provider can work for an unbounded number of collections.""" - # Get file path info from path provider - # TODO: should probably just pass datakey_name - self._file_info = self._path_provider("eiger2-1") - self._master_file_path_cache.clear() - - # 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), - self.fileio.manual_trigger.set(True), - # TODO sort out how to get this from the plan - self.fileio.num_triggers.set(5000), - ) - - await set_and_wait_for_other_value( - set_signal=self.fileio.acquire, - set_value=True, - match_signal=self.fileio.armed, - match_value=True, - wait_for_set_completion=False, - timeout=DEFAULT_TIMEOUT, - ) - - 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 - # TODO: allow multiple files - 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, - ) - driver = self.fileio - - shape = await asyncio.gather( - *[sig.get_value() for sig in [driver.array_size_y, driver.array_size_x]] - ) - datatype = "uint32" - # Remove entries in shape that are zero - shape = [x for x in shape if x > 0] - - mfp = await self._master_file_path - # TODO sort out how to get from parent - name = "eiger" - exposures_per_event = await self.fileio.num_images.get_value() - - # TODO sort out how to tell tiled about the additional data files. - return StreamResourceDataProvider( - uri=urlunparse(("file", "localhost", str(mfp), "", "", None)), - resources=[ - StreamResourceInfo( - data_key=f"{name}_image", - shape=(exposures_per_event, *shape), - # TODO sort out how to set this and mirror here - chunk_shape=(1, *shape), - dtype_numpy=np.dtype(datatype.lower()).str, - parameters={ - "dataset": f"entry/data/data_{1:06d}", - }, - # TODO put in better value - source="EIGER2_FILE_WRITER", - ) - ], - mimetype="application/x-hdf5", - collections_written_signal=self.fileio.array_counter, - ) - - @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 observe_indices_written( - self, timeout: float - ) -> AsyncGenerator[int, None]: - async for num_captured in observe_value(self.fileio.array_counter, timeout): - yield num_captured - - async def get_indices_written(self) -> int: - return await self.fileio.array_counter.get_value() - - async def stop(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(): - # ... - - self._file_info = None - await self.fileio.fw_enable.set(False) - - -# TODO sort out if ths is the right name of things -class EigerArmLogic(DetectorArmLogic): - def __init__( - self, driver: Eiger2DriverIO, driver_armed_signal: SignalR[bool] | None = None - ): - self.driver = driver - # TODO - remove? driver_armed_signal doesn't seem to be a thing anywhere else - if driver_armed_signal is not None: - self.driver_armed_signal = driver_armed_signal - else: - self.driver_armed_signal = driver.acquire - self.acquire_status: AsyncStatus | None = None - self._rolling_image_counter = 0 - - async def arm(self): - self._rolling_image_counter = await self.driver.num_images_counter.get_value() - ret = await self.driver.trigger.set(1) - return ret - - async def wait_for_idle(self): - target_num_images, frame_acquire_period = await asyncio.gather(self.driver.num_images.get_value(), - self.driver.acquire_period.get_value()) - frame_timeout = frame_acquire_period + DEFAULT_TIMEOUT - done_timeout = frame_timeout * target_num_images - target_num_images += self._rolling_image_counter - async for images_complete in observe_value(self.driver.num_images_counter, timeout=frame_timeout, done_timeout=done_timeout): - if images_complete == target_num_images: - break - - async def disarm(self): - self._rolling_image_counter = 0 - await stop_busy_record(self.driver.acquire) - - await asyncio.gather( - self.driver.manual_trigger.set(False), - self.driver.num_triggers.set(1), - ) - - -# 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 class EigerTriggerMode(SubsetEnum): """Trigger modes for the Eiger detector. @@ -626,340 +292,248 @@ 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 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 EigerController(DetectorTriggerLogic): + """Controller for Eiger detector, handling trigger modes and acquisition setup.""" + + def __init__(self, driver: EigerDriverIO) -> None: + self.driver = driver + + 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_internal(self, num: int, livetime: float, deadtime: float): + """Prepare the detector for acquisition. + + On Internal Series, num sets the number of images to take per trigger: + https://areadetector.github.io/areaDetector/ADEiger/eiger.html#implementation-of-standard-driver-parameters + """ + # TODO - should we do something with deadtime? + # TODO - put other awaits into the gather + + if livetime > 0: + await self.driver.acquire_time.set(livetime) + + await self.driver.trigger_mode.set(EigerTriggerMode.INTERNAL_SERIES) + + if num == 0: + image_mode = ADImageMode.CONTINUOUS + else: + image_mode = ADImageMode.MULTIPLE + + await asyncio.gather( + self.driver.num_triggers.set(num), + self.driver.image_mode.set(image_mode), + ) + + # TODO should num_triggers or num_images be set? + # TODO - put other awaits into the gather + async def prepare_edge(self, num: int, livetime: float): + """Prepare the detector to take external edge triggered exposures. + + :param num: the number of exposures to take + :param livetime: how long the exposure should be, 0 means what is currently set + """ + + await self.driver.acquire_time.set(livetime) + await self.driver.num_triggers.set(num) + if num == 0: + image_mode = ADImageMode.CONTINUOUS + else: + image_mode = ADImageMode.MULTIPLE + + await self.driver.trigger_mode.set(EigerTriggerMode.EXTERNAL_SERIES) + await asyncio.gather( + self.driver.image_mode.set(image_mode), + ) + + async def default_trigger_info(self): + return await trigger_info_from_num_images(self.driver) + + +class EigerDataLogic(DetectorDataLogic): + """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, + ): + self.fileio = fileio + self._path_provider = path_provider + + self._file_info: PathInfo | None = None + self._datasets: list[StreamResourceDataProvider] = [] + self._master_file_path_cache: list[Path] = [] + + async def prepare_unbounded(self, datakey_name: str) -> StreamableDataProvider: + """Provider can work for an unbounded number of collections.""" + # Get file path info from path provider + # TODO: should probably just pass datakey_name + self._file_info = self._path_provider("eiger2-1") + self._master_file_path_cache.clear() + + # 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.num_capture.set(0), + # Use array_counter to track the total number of images written + self.fileio.array_counter.set(0), + self.fileio.manual_trigger.set(True), + # TODO sort out how to get this from the plan + self.fileio.num_triggers.set(5000), + ) + + await set_and_wait_for_other_value( + set_signal=self.fileio.acquire, + set_value=True, + match_signal=self.fileio.armed, + match_value=True, + wait_for_set_completion=False, + timeout=DEFAULT_TIMEOUT, + ) + + 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 + # TODO: allow multiple files + 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, + ) + driver = self.fileio + + shape = await asyncio.gather( + *[sig.get_value() for sig in [driver.array_size_y, driver.array_size_x]] + ) + datatype = "uint32" + # Remove entries in shape that are zero + shape = [x for x in shape if x > 0] + + mfp = await self._master_file_path + # TODO sort out how to get from parent + name = "eiger" + exposures_per_event = await self.fileio.num_images.get_value() + + # TODO sort out how to tell tiled about the additional data files. + return StreamResourceDataProvider( + uri=urlunparse(("file", "localhost", str(mfp), "", "", None)), + resources=[ + StreamResourceInfo( + data_key=f"{name}_image", + shape=(exposures_per_event, *shape), + # TODO sort out how to set this and mirror here + chunk_shape=(1, *shape), + dtype_numpy=np.dtype(datatype.lower()).str, + parameters={ + "dataset": f"entry/data/data_{1:06d}", + }, + # TODO put in better value + source="EIGER2_FILE_WRITER", + ) + ], + mimetype="application/x-hdf5", + collections_written_signal=self.fileio.array_counter, + ) + + @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 observe_indices_written( + self, timeout: float + ) -> AsyncGenerator[int, None]: + async for num_captured in observe_value(self.fileio.array_counter, timeout): + yield num_captured + + async def get_indices_written(self) -> int: + return await self.fileio.array_counter.get_value() + + async def stop(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(): + # ... + + self._file_info = None + await self.fileio.fw_enable.set(False) + + +# TODO sort out if ths is the right name of things +class EigerArmLogic(DetectorArmLogic): + def __init__( + self, driver: Eiger2DriverIO, driver_armed_signal: SignalR[bool] | None = None + ): + self.driver = driver + # TODO - remove? driver_armed_signal doesn't seem to be a thing anywhere else + if driver_armed_signal is not None: + self.driver_armed_signal = driver_armed_signal + else: + self.driver_armed_signal = driver.acquire + self.acquire_status: AsyncStatus | None = None + self._rolling_image_counter = 0 + + async def arm(self): + self._rolling_image_counter = await self.driver.num_images_counter.get_value() + ret = await self.driver.trigger.set(1) + return ret + + async def wait_for_idle(self): + target_num_images, frame_acquire_period = await asyncio.gather(self.driver.num_images.get_value(), + self.driver.acquire_period.get_value()) + frame_timeout = frame_acquire_period + DEFAULT_TIMEOUT + done_timeout = frame_timeout * target_num_images + target_num_images += self._rolling_image_counter + async for images_complete in observe_value(self.driver.num_images_counter, timeout=frame_timeout, done_timeout=done_timeout): + if images_complete == target_num_images: + break + + async def disarm(self): + self._rolling_image_counter = 0 + await stop_busy_record(self.driver.acquire) + + await asyncio.gather( + self.driver.manual_trigger.set(False), + self.driver.num_triggers.set(1), + ) class EigerDetector(AreaDetector[Eiger2DriverIO]): @@ -976,17 +550,6 @@ def __init__( ): driver = Eiger2DriverIO(prefix + driver_suffix) controller = EigerController(driver) - # if issubclass(writer_cls, EigerDataLogic): - # 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_logic = EigerDataLogic(fileio=driver, path_provider=path_provider) arm_logic = EigerArmLogic(driver) super().__init__( prefix=prefix, @@ -998,8 +561,8 @@ def __init__( plugins=plugins, arm_logic=arm_logic, ) - self.data_logic = writer_logic - self.add_detector_logics(writer_logic) + self.data_logic = EigerDataLogic(fileio=driver, path_provider=path_provider) + self.add_detector_logics(self.data_logic) # TODO remove this as it should be identical to upstream. @WatchableAsyncStatus.wrap diff --git a/tests/test_eiger_async.py b/tests/test_eiger_async.py index 022d5af..a65cbe6 100644 --- a/tests/test_eiger_async.py +++ b/tests/test_eiger_async.py @@ -158,7 +158,6 @@ async def eiger_writer( datalogic = EigerDataLogic(mock_eiger_driver, mock_path_provider) async def sync_fileio_armed(value: bool): - print('here'*10) set_mock_value(datalogic.fileio.armed, value) callback_on_mock_put(datalogic.fileio.acquire, sync_fileio_armed) @@ -504,7 +503,7 @@ async def test_eiger_controller_prepare_internal(eiger_controller: EigerControll await eiger_controller.driver.trigger_mode.get_value() == EigerTriggerMode.INTERNAL_SERIES ) - assert await eiger_controller.driver.num_images.get_value() == 1 + assert await eiger_controller.driver.num_triggers.get_value() == 1 assert await eiger_controller.driver.image_mode.get_value() == ADImageMode.MULTIPLE @pytest.mark.asyncio @@ -515,7 +514,7 @@ async def test_eiger_controller_prepare_edge(eiger_controller: EigerController) await eiger_controller.driver.trigger_mode.get_value() == EigerTriggerMode.EXTERNAL_SERIES ) - assert await eiger_controller.driver.num_images.get_value() == 5 + assert await eiger_controller.driver.num_triggers.get_value() == 5 assert await eiger_controller.driver.image_mode.get_value() == ADImageMode.MULTIPLE @@ -547,13 +546,16 @@ async def test_eiger_detector(mock_eiger_detector: EigerDetector) -> None: set_mock_value(mock_eiger_detector.driver.num_images, 1) set_mock_value(mock_eiger_detector.driver.acquire_period, 0.001) set_mock_value(mock_eiger_detector.data_logic.fileio.array_counter, 0) + set_mock_value(mock_eiger_detector.driver.num_images_counter, 0) - async def _simulate_one_trigger(value: bool, wait: bool) -> None: + async def _simulate_one_trigger(value: bool) -> None: await asyncio.sleep(await mock_eiger_detector.driver.acquire_period.get_value()) array_counter = await mock_eiger_detector.data_logic.fileio.array_counter.get_value() set_mock_value(mock_eiger_detector.data_logic.fileio.array_counter, array_counter + 1) + num_images_counter = await mock_eiger_detector.driver.num_images_counter.get_value() + set_mock_value(mock_eiger_detector.driver.num_images_counter, num_images_counter + 1) - callback_on_mock_put(mock_eiger_detector.driver.acquire, _simulate_one_trigger) + callback_on_mock_put(mock_eiger_detector.driver.trigger, _simulate_one_trigger) # Standalone methods await mock_eiger_detector.prepare( @@ -572,16 +574,23 @@ async def _simulate_one_trigger(value: bool, wait: bool) -> None: # Case 1 - Step Scan: stage, trigger, read, trigger, read, unstage await mock_eiger_detector.stage() await mock_eiger_detector.trigger() + print(2) assert ( await mock_eiger_detector.driver.data_source.get_value() == EigerDataSource.FILE_WRITER ) + print(3) await mock_eiger_detector.read() + print(4) await mock_eiger_detector.trigger() + print(5) await mock_eiger_detector.read() + print(6) await mock_eiger_detector.unstage() + print(7) set_mock_value(mock_eiger_detector.data_logic.fileio.array_counter, 0) + set_mock_value(mock_eiger_detector.driver.num_images_counter, 0) # Case 2 - Fly Scan: prepare, kickoff, complete await mock_eiger_detector.prepare( TriggerInfo( From dca1d8cc02ddcb9f97d617bcc747bbc7883be19e Mon Sep 17 00:00:00 2001 From: Dan Henriksen Date: Mon, 15 Jun 2026 14:40:16 -0400 Subject: [PATCH 04/13] pinned version of ophyd-async to 0.17a2 --- pixi.lock | 144 +++++++++++++++++++++++++++---------------------- pyproject.toml | 2 +- 2 files changed, 80 insertions(+), 66 deletions(-) diff --git a/pixi.lock b/pixi.lock index 7253ea4..d63ff80 100644 --- a/pixi.lock +++ b/pixi.lock @@ -5,6 +5,8 @@ environments: - url: https://conda.anaconda.org/conda-forge/ indexes: - https://pypi.org/simple + options: + pypi-prerelease-mode: if-necessary-or-explicit packages: linux-64: - conda: https://conda.anaconda.org/conda-forge/linux-64/_libgcc_mutex-0.1-conda_forge.tar.bz2 @@ -29,12 +31,15 @@ environments: - conda: https://conda.anaconda.org/conda-forge/linux-64/readline-8.2-h8c095d6_2.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/tk-8.6.13-noxft_hd72426e_102.conda - conda: https://conda.anaconda.org/conda-forge/noarch/tzdata-2025b-h78e105d_0.conda + - pypi: https://files.pythonhosted.org/packages/51/89/da0f32b679b8769dd472eb2927308d328bf75c296629fd9f95135afacd0a/aioca-2.1-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/78/b6/6307fbef88d9b5ee7421e68d78a9f162e0da4900bc5f5793f6d3d0e34fb8/annotated_types-0.7.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/77/06/bb80f5f86020c4551da315d78b3ab75e8228f89f0162f2c3a819e407941a/attrs-25.3.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/c1/41/497f55b454aa64e2cb3f27990e5cb76c64457d54365b07d6c125bdac7b38/bluesky-1.14.4-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/85/32/10bb5764d90a8eee674e9dc6f4db6a0ab47c8c4d0d83c27f7c39ac415a4d/click-8.2.1-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/e3/51/9b208e85196941db2f0654ad0357ca6388ab3ed67efdbfc799f35d1f83aa/colorlog-6.9.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/f3/4f/f94ac1b84d2169cf2ebf64353ce98fd743f85d30678059c514d9b3d6644c/compress_pickle-2.1.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/e7/05/c19819d5e3d95294a6f5947fb9b9629efb316b96de511b418c53d245aae6/cycler-0.12.1-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/a2/af/6bb0e1de4fb7218fa4ba95fe91ba1fd4a5661dd28a461eff6a0f609c996c/epicscorelibs-7.0.7.99.1.2-cp313-cp313-manylinux2014_x86_64.whl - pypi: https://files.pythonhosted.org/packages/fb/32/e31e3363bf48ad2ba80b644b01ad9676ce154f1b755950de81eb4ed5b6bd/event_model-1.23.1-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/27/cd/c883e1a7c447479d6e13985565080e3fea88ab5a107c21684c813dba1875/flexcache-0.3-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/fe/5e/3be305568fe5f34448807976dc82fc151d76c3e0e03958f34770286278c1/flexparser-0.4-py3-none-any.whl @@ -51,7 +56,7 @@ environments: - pypi: https://files.pythonhosted.org/packages/19/49/4df9123aafa7b539317bf6d342cb6d227e49f7a35b99c287a6109b13dd93/numpy-2.2.6-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl - pypi: https://files.pythonhosted.org/packages/bb/ee/6b08dde0a022c463b88f55ae81149584b125a42183407dc1045c486cc870/opentelemetry_api-1.36.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/fa/b3/1e63820f1a4df7854e33f0c3c5fa6be758c7fb7ea007ae9c8b9b35f48690/ophyd-1.10.7-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/c8/cb/75a17d45ae070fa4af0b6bf79fb820b9d9137bf09f485a6728f71e4a062e/ophyd_async-0.12.3-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/7f/a3/b4605ee05a6a3480ad57cf0ac15e381f99189b022362f0c08044c86227d7/ophyd_async-0.19.1-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/20/12/38679034af332785aac8774540895e234f4d07f7545804097de4b666afd8/packaging-25.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/76/cc/c528311d798e22ec884b816e8aa2989e0f1f28cdc8e5969e2be5f10bce85/pint-0.25-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/40/4b/2028861e724d3bd36227adfa20d3fd24c3fc6d52032f4a93c133be5d17ce/platformdirs-4.4.0-py3-none-any.whl @@ -63,13 +68,17 @@ environments: - pypi: https://files.pythonhosted.org/packages/03/36/0a14aebbaa26fe7fab4780c76f2239e76cc95a0090bdb25e31d95c492fcd/rpds_py-0.27.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl - pypi: https://files.pythonhosted.org/packages/d1/e5/f2a0621f1781b76a38194acae72f01e37b1941470407345b6e8653ad7640/ruamel.yaml-0.18.15-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/1f/8f/ecfbe2123ade605c49ef769788f79c38ddb1c8fa81e01f4dbf5cf1a44b16/ruamel.yaml.clib-0.2.12-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl + - pypi: https://files.pythonhosted.org/packages/68/b9/f44b139096467996b4d85589d9880fd55f0c80a204d298a1b73d0adc2654/scanspec-1.0.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/a6/24/4d91e05817e92e3a61c8a21e08fd0f390f5301f1c448b137c57c4bc6e543/semver-3.0.4-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/a3/dc/17031897dae0efacfea57dfd3a82fdd2a2aeb58e0ff71b77b87e44edc772/setuptools-80.9.0-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/b9/36/c101788fad13e8ea65c5b3d3dee8ff996500800cd554ae6ff72143690247/setuptools_dso-2.12.2-py2.py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/14/ba/d03f7ee711391af1d5f4dd7c44f8abdd06bce247028af2441ba8f6ff329b/stamina-25.1.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/e5/30/643397144bfbfec6f6ef821f36f33e57d35946c44a2352d3c9f0ae847619/tenacity-9.1.2-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/03/98/eb27cc78ad3af8e302c9d8ff4977f5026676e130d28dd7578132a457170c/toolz-1.0.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/d0/30/dc54f88dd4a2b5dc8a0279bdd7270e735851848b762aeb1c1184ed1f6b14/tqdm-4.67.1-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/17/69/cd203477f944c353c31bade965f880aa1061fd6bf05ded0726ca845b6ff7/typing_inspection-0.4.1-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/72/89/0265b2b79424ed05b8d1e9c8fca71e1b150478e5b0c19aa50b0ae397326e/velocity_profile-1.0.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/2e/54/647ade08bf0db230bfea292f893923872fd20be6ac6f53b2b936ba839d75/zipp-3.23.0-py3-none-any.whl - pypi: ./ dev: @@ -77,6 +86,8 @@ environments: - url: https://conda.anaconda.org/conda-forge/ indexes: - https://pypi.org/simple + options: + pypi-prerelease-mode: if-necessary-or-explicit packages: linux-64: - conda: https://conda.anaconda.org/conda-forge/linux-64/_libgcc_mutex-0.1-conda_forge.tar.bz2 @@ -104,7 +115,7 @@ environments: - pypi: https://files.pythonhosted.org/packages/a2/22/44738b41bb5ca30f94b5f4c00c71c20be86d7eb4ddc389d4cf3c7b8b69ef/adbc_driver_manager-1.7.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl - pypi: https://files.pythonhosted.org/packages/58/15/86561628738161017273d9a689e9405e4ea9a9d41a70fd2460dbc5d646ae/adbc_driver_postgresql-1.7.0-py3-none-manylinux2014_x86_64.manylinux_2_17_x86_64.whl - pypi: https://files.pythonhosted.org/packages/93/1f/618d88542ca66baf6bc25a3e5ecbd698eff31b12b2ab2a590bae8d9d8c83/adbc_driver_sqlite-1.7.0-py3-none-manylinux2014_x86_64.manylinux_2_17_x86_64.whl - - pypi: https://files.pythonhosted.org/packages/5b/08/185c3b29b0698328b202e6c965c23187e2e29ead78cb468aab0a09ee97fc/aioca-1.8.1-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/51/89/da0f32b679b8769dd472eb2927308d328bf75c296629fd9f95135afacd0a/aioca-2.1-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/a5/45/30bb92d442636f570cb5651bc661f52b610e2eec3f891a5dc3a4c3667db0/aiofiles-24.1.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/f5/10/6c25ed6de94c49f88a91fa5018cb4c0f3625f31d5be9f771ebe5cc7cd506/aiosqlite-0.21.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/39/4a/4c61d4c84cfd9befb6fa08a702535b27b21fff08c946bc2f6139decbf7f7/alembic-1.16.5-py3-none-any.whl @@ -205,7 +216,7 @@ environments: - pypi: https://files.pythonhosted.org/packages/c0/da/977ded879c29cbd04de313843e76868e6e13408a94ed6b987245dc7c8506/openpyxl-3.1.5-py2.py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/bb/ee/6b08dde0a022c463b88f55ae81149584b125a42183407dc1045c486cc870/opentelemetry_api-1.36.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/fa/b3/1e63820f1a4df7854e33f0c3c5fa6be758c7fb7ea007ae9c8b9b35f48690/ophyd-1.10.7-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/c8/cb/75a17d45ae070fa4af0b6bf79fb820b9d9137bf09f485a6728f71e4a062e/ophyd_async-0.12.3-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/7f/a3/b4605ee05a6a3480ad57cf0ac15e381f99189b022362f0c08044c86227d7/ophyd_async-0.19.1-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/d0/b4/f98355eff0bd1a38454209bbc73372ce351ba29933cb3e2eba16c04b9448/orjson-3.11.3-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl - pypi: https://files.pythonhosted.org/packages/20/12/38679034af332785aac8774540895e234f4d07f7545804097de4b666afd8/packaging-25.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/8f/52/0634adaace9be2d8cac9ef78f05c47f3a675882e068438b9d7ec7ef0c13f/pandas-2.3.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl @@ -253,6 +264,7 @@ environments: - pypi: https://files.pythonhosted.org/packages/d1/e5/f2a0621f1781b76a38194acae72f01e37b1941470407345b6e8653ad7640/ruamel.yaml-0.18.15-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/1f/8f/ecfbe2123ade605c49ef769788f79c38ddb1c8fa81e01f4dbf5cf1a44b16/ruamel.yaml.clib-0.2.12-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl - pypi: https://files.pythonhosted.org/packages/80/22/3c6cef96627f89b344c933781ed38329bfb87737aa438f15da95907cbfd5/ruff-0.12.11-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl + - pypi: https://files.pythonhosted.org/packages/68/b9/f44b139096467996b4d85589d9880fd55f0c80a204d298a1b73d0adc2654/scanspec-1.0.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/54/24/b4293291fa1dd830f353d2cb163295742fa87f179fcc8a20a306a81978b7/SecretStorage-3.3.3-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/a6/24/4d91e05817e92e3a61c8a21e08fd0f390f5301f1c448b137c57c4bc6e543/semver-3.0.4-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/a3/dc/17031897dae0efacfea57dfd3a82fdd2a2aeb58e0ff71b77b87e44edc772/setuptools-80.9.0-py3-none-any.whl @@ -279,6 +291,7 @@ environments: - pypi: https://files.pythonhosted.org/packages/a7/c2/fe1e52489ae3122415c51f387e221dd0773709bad6c6cdaa599e8a2c5185/urllib3-2.5.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/d2/e2/dc81b1bd1dcfe91735810265e9d26bc8ec5da45b4c0f6237e286819194c3/uvicorn-0.35.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/d2/19/f5b78616566ea68edd42aacaf645adbf71fbd83fc52281fba555dc27e3f1/uvloop-0.21.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl + - pypi: https://files.pythonhosted.org/packages/72/89/0265b2b79424ed05b8d1e9c8fca71e1b150478e5b0c19aa50b0ae397326e/velocity_profile-1.0.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/76/06/04c8e804f813cf972e3262f3f8584c232de64f0cde9f703b46cf53a45090/virtualenv-20.34.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/25/0d/4d564798a49bf5482a4fa9416dea6b6c0733a3b5700cb8a5a503c4b15853/watchfiles-1.1.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl - pypi: https://files.pythonhosted.org/packages/fd/84/fd2ba7aafacbad3c4201d395674fc6348826569da3c0937e75505ead3528/wcwidth-0.2.13-py2.py3-none-any.whl @@ -293,6 +306,8 @@ environments: - url: https://conda.anaconda.org/conda-forge/ indexes: - https://pypi.org/simple + options: + pypi-prerelease-mode: if-necessary-or-explicit packages: linux-64: - conda: https://conda.anaconda.org/conda-forge/linux-64/_libgcc_mutex-0.1-conda_forge.tar.bz2 @@ -318,6 +333,7 @@ environments: - conda: https://conda.anaconda.org/conda-forge/linux-64/tk-8.6.13-noxft_hd72426e_102.conda - conda: https://conda.anaconda.org/conda-forge/noarch/tzdata-2025b-h78e105d_0.conda - pypi: https://files.pythonhosted.org/packages/8d/3f/95338030883d8c8b91223b4e21744b04d11b161a3ef117295d8241f50ab4/accessible_pygments-0.0.5-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/51/89/da0f32b679b8769dd472eb2927308d328bf75c296629fd9f95135afacd0a/aioca-2.1-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/7e/b3/6b4067be973ae96ba0d615946e314c5ae35f9f993eca561b356540bb0c2b/alabaster-1.0.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/78/b6/6307fbef88d9b5ee7421e68d78a9f162e0da4900bc5f5793f6d3d0e34fb8/annotated_types-0.7.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/77/06/bb80f5f86020c4551da315d78b3ab75e8228f89f0162f2c3a819e407941a/attrs-25.3.0-py3-none-any.whl @@ -326,10 +342,12 @@ environments: - pypi: https://files.pythonhosted.org/packages/c1/41/497f55b454aa64e2cb3f27990e5cb76c64457d54365b07d6c125bdac7b38/bluesky-1.14.4-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/e5/48/1549795ba7742c948d2ad169c1c8cdbae65bc450d6cd753d124b17c8cd32/certifi-2025.8.3-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/7e/95/42aa2156235cbc8fa61208aded06ef46111c4d3f0de233107b3f38631803/charset_normalizer-3.4.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl + - pypi: https://files.pythonhosted.org/packages/85/32/10bb5764d90a8eee674e9dc6f4db6a0ab47c8c4d0d83c27f7c39ac415a4d/click-8.2.1-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/e3/51/9b208e85196941db2f0654ad0357ca6388ab3ed67efdbfc799f35d1f83aa/colorlog-6.9.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/f3/4f/f94ac1b84d2169cf2ebf64353ce98fd743f85d30678059c514d9b3d6644c/compress_pickle-2.1.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/e7/05/c19819d5e3d95294a6f5947fb9b9629efb316b96de511b418c53d245aae6/cycler-0.12.1-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/8f/d7/9322c609343d929e75e7e5e6255e614fcc67572cfd083959cdef3b7aad79/docutils-0.21.2-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/a2/af/6bb0e1de4fb7218fa4ba95fe91ba1fd4a5661dd28a461eff6a0f609c996c/epicscorelibs-7.0.7.99.1.2-cp313-cp313-manylinux2014_x86_64.whl - pypi: https://files.pythonhosted.org/packages/fb/32/e31e3363bf48ad2ba80b644b01ad9676ce154f1b755950de81eb4ed5b6bd/event_model-1.23.1-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/27/cd/c883e1a7c447479d6e13985565080e3fea88ab5a107c21684c813dba1875/flexcache-0.3-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/fe/5e/3be305568fe5f34448807976dc82fc151d76c3e0e03958f34770286278c1/flexparser-0.4-py3-none-any.whl @@ -355,7 +373,7 @@ environments: - pypi: https://files.pythonhosted.org/packages/19/49/4df9123aafa7b539317bf6d342cb6d227e49f7a35b99c287a6109b13dd93/numpy-2.2.6-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl - pypi: https://files.pythonhosted.org/packages/bb/ee/6b08dde0a022c463b88f55ae81149584b125a42183407dc1045c486cc870/opentelemetry_api-1.36.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/fa/b3/1e63820f1a4df7854e33f0c3c5fa6be758c7fb7ea007ae9c8b9b35f48690/ophyd-1.10.7-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/c8/cb/75a17d45ae070fa4af0b6bf79fb820b9d9137bf09f485a6728f71e4a062e/ophyd_async-0.12.3-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/7f/a3/b4605ee05a6a3480ad57cf0ac15e381f99189b022362f0c08044c86227d7/ophyd_async-0.19.1-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/20/12/38679034af332785aac8774540895e234f4d07f7545804097de4b666afd8/packaging-25.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/76/cc/c528311d798e22ec884b816e8aa2989e0f1f28cdc8e5969e2be5f10bce85/pint-0.25-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/40/4b/2028861e724d3bd36227adfa20d3fd24c3fc6d52032f4a93c133be5d17ce/platformdirs-4.4.0-py3-none-any.whl @@ -370,7 +388,10 @@ environments: - pypi: https://files.pythonhosted.org/packages/03/36/0a14aebbaa26fe7fab4780c76f2239e76cc95a0090bdb25e31d95c492fcd/rpds_py-0.27.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl - pypi: https://files.pythonhosted.org/packages/d1/e5/f2a0621f1781b76a38194acae72f01e37b1941470407345b6e8653ad7640/ruamel.yaml-0.18.15-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/1f/8f/ecfbe2123ade605c49ef769788f79c38ddb1c8fa81e01f4dbf5cf1a44b16/ruamel.yaml.clib-0.2.12-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl + - pypi: https://files.pythonhosted.org/packages/68/b9/f44b139096467996b4d85589d9880fd55f0c80a204d298a1b73d0adc2654/scanspec-1.0.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/a6/24/4d91e05817e92e3a61c8a21e08fd0f390f5301f1c448b137c57c4bc6e543/semver-3.0.4-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/a3/dc/17031897dae0efacfea57dfd3a82fdd2a2aeb58e0ff71b77b87e44edc772/setuptools-80.9.0-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/b9/36/c101788fad13e8ea65c5b3d3dee8ff996500800cd554ae6ff72143690247/setuptools_dso-2.12.2-py2.py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/c8/78/3565d011c61f5a43488987ee32b6f3f656e7f107ac2782dd57bdd7d91d9a/snowballstemmer-3.0.1-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/14/a0/bb38d3b76b8cae341dad93a2dd83ab7462e6dbcdd84d43f54ee60a8dc167/soupsieve-2.8-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/31/53/136e9eca6e0b9dc0e1962e2c908fbea2e5ac000c2a2fbd9a35797958c48b/sphinx-8.2.3-py3-none-any.whl @@ -390,6 +411,7 @@ environments: - pypi: https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/17/69/cd203477f944c353c31bade965f880aa1061fd6bf05ded0726ca845b6ff7/typing_inspection-0.4.1-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/a7/c2/fe1e52489ae3122415c51f387e221dd0773709bad6c6cdaa599e8a2c5185/urllib3-2.5.0-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/72/89/0265b2b79424ed05b8d1e9c8fca71e1b150478e5b0c19aa50b0ae397326e/velocity_profile-1.0.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/2e/54/647ade08bf0db230bfea292f893923872fd20be6ac6f53b2b936ba839d75/zipp-3.23.0-py3-none-any.whl - pypi: ./ test: @@ -397,6 +419,8 @@ environments: - url: https://conda.anaconda.org/conda-forge/ indexes: - https://pypi.org/simple + options: + pypi-prerelease-mode: if-necessary-or-explicit packages: linux-64: - conda: https://conda.anaconda.org/conda-forge/linux-64/_libgcc_mutex-0.1-conda_forge.tar.bz2 @@ -421,6 +445,7 @@ environments: - conda: https://conda.anaconda.org/conda-forge/linux-64/readline-8.2-h8c095d6_2.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/tk-8.6.13-noxft_hd72426e_102.conda - conda: https://conda.anaconda.org/conda-forge/noarch/tzdata-2025b-h78e105d_0.conda + - pypi: https://files.pythonhosted.org/packages/51/89/da0f32b679b8769dd472eb2927308d328bf75c296629fd9f95135afacd0a/aioca-2.1-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/a5/45/30bb92d442636f570cb5651bc661f52b610e2eec3f891a5dc3a4c3667db0/aiofiles-24.1.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/f5/10/6c25ed6de94c49f88a91fa5018cb4c0f3625f31d5be9f771ebe5cc7cd506/aiosqlite-0.21.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/39/4a/4c61d4c84cfd9befb6fa08a702535b27b21fff08c946bc2f6139decbf7f7/alembic-1.16.5-py3-none-any.whl @@ -447,6 +472,7 @@ environments: - pypi: https://files.pythonhosted.org/packages/11/79/479e2194c9096b92aecdf33634ae948d2be306c6011673e98ee1917f32c2/dpkt-1.9.8-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/cb/a3/460c57f094a4a165c84a1341c373b0a4f5ec6ac244b998d5021aade89b77/ecdsa-0.19.1-py2.py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/35/a8/365059bbcd4572cbc41de17fd5b682be5868b218c3c5479071865cab9078/entrypoints-0.4-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/a2/af/6bb0e1de4fb7218fa4ba95fe91ba1fd4a5661dd28a461eff6a0f609c996c/epicscorelibs-7.0.7.99.1.2-cp313-cp313-manylinux2014_x86_64.whl - pypi: https://files.pythonhosted.org/packages/fb/32/e31e3363bf48ad2ba80b644b01ad9676ce154f1b755950de81eb4ed5b6bd/event_model-1.23.1-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/e5/47/d63c60f59a59467fda0f93f46335c9d18526d7071f025cb5b89d5353ea42/fastapi-0.116.1-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/27/cd/c883e1a7c447479d6e13985565080e3fea88ab5a107c21684c813dba1875/flexcache-0.3-py3-none-any.whl @@ -484,7 +510,7 @@ environments: - pypi: https://files.pythonhosted.org/packages/19/49/4df9123aafa7b539317bf6d342cb6d227e49f7a35b99c287a6109b13dd93/numpy-2.2.6-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl - pypi: https://files.pythonhosted.org/packages/bb/ee/6b08dde0a022c463b88f55ae81149584b125a42183407dc1045c486cc870/opentelemetry_api-1.36.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/fa/b3/1e63820f1a4df7854e33f0c3c5fa6be758c7fb7ea007ae9c8b9b35f48690/ophyd-1.10.7-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/c8/cb/75a17d45ae070fa4af0b6bf79fb820b9d9137bf09f485a6728f71e4a062e/ophyd_async-0.12.3-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/7f/a3/b4605ee05a6a3480ad57cf0ac15e381f99189b022362f0c08044c86227d7/ophyd_async-0.19.1-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/d0/b4/f98355eff0bd1a38454209bbc73372ce351ba29933cb3e2eba16c04b9448/orjson-3.11.3-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl - pypi: https://files.pythonhosted.org/packages/20/12/38679034af332785aac8774540895e234f4d07f7545804097de4b666afd8/packaging-25.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/71/e7/40fb618334dcdf7c5a316c0e7343c5cd82d3d866edc100d98e29bc945ecd/partd-1.4.2-py3-none-any.whl @@ -514,7 +540,10 @@ environments: - pypi: https://files.pythonhosted.org/packages/64/8d/0133e4eb4beed9e425d9a98ed6e081a55d195481b7632472be1af08d2f6b/rsa-4.9.1-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/d1/e5/f2a0621f1781b76a38194acae72f01e37b1941470407345b6e8653ad7640/ruamel.yaml-0.18.15-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/1f/8f/ecfbe2123ade605c49ef769788f79c38ddb1c8fa81e01f4dbf5cf1a44b16/ruamel.yaml.clib-0.2.12-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl + - pypi: https://files.pythonhosted.org/packages/68/b9/f44b139096467996b4d85589d9880fd55f0c80a204d298a1b73d0adc2654/scanspec-1.0.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/a6/24/4d91e05817e92e3a61c8a21e08fd0f390f5301f1c448b137c57c4bc6e543/semver-3.0.4-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/a3/dc/17031897dae0efacfea57dfd3a82fdd2a2aeb58e0ff71b77b87e44edc772/setuptools-80.9.0-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/b9/36/c101788fad13e8ea65c5b3d3dee8ff996500800cd554ae6ff72143690247/setuptools_dso-2.12.2-py2.py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/e0/f9/0595336914c5619e5f28a1fb793285925a8cd4b432c9da0a987836c7f822/shellingham-1.5.4-py2.py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/e9/44/75a9c9421471a6c4805dbf2356f7c181a29c1879239abab1ea2cc8f38b40/sniffio-1.3.1-py3-none-any.whl @@ -530,6 +559,7 @@ environments: - pypi: https://files.pythonhosted.org/packages/17/69/cd203477f944c353c31bade965f880aa1061fd6bf05ded0726ca845b6ff7/typing_inspection-0.4.1-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/d2/e2/dc81b1bd1dcfe91735810265e9d26bc8ec5da45b4c0f6237e286819194c3/uvicorn-0.35.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/d2/19/f5b78616566ea68edd42aacaf645adbf71fbd83fc52281fba555dc27e3f1/uvloop-0.21.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl + - pypi: https://files.pythonhosted.org/packages/72/89/0265b2b79424ed05b8d1e9c8fca71e1b150478e5b0c19aa50b0ae397326e/velocity_profile-1.0.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/25/0d/4d564798a49bf5482a4fa9416dea6b6c0733a3b5700cb8a5a503c4b15853/watchfiles-1.1.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl - pypi: https://files.pythonhosted.org/packages/ff/b2/83a6ddf56cdcbad4e3d841fcc55d6ba7d19aeb89c50f24dd7e859ec0805f/websockets-15.0.1-cp313-cp313-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl - pypi: https://files.pythonhosted.org/packages/e0/a3/d3d4fd394a10b1256f9dccb2fe0ddd125fc575d7c437b1c70df050f14176/zarr-3.1.2-py3-none-any.whl @@ -613,29 +643,13 @@ packages: - pyarrow>=14.0.1 ; extra == 'test' - pytest ; extra == 'test' requires_python: '>=3.9' -- pypi: https://files.pythonhosted.org/packages/5b/08/185c3b29b0698328b202e6c965c23187e2e29ead78cb468aab0a09ee97fc/aioca-1.8.1-py3-none-any.whl +- pypi: https://files.pythonhosted.org/packages/51/89/da0f32b679b8769dd472eb2927308d328bf75c296629fd9f95135afacd0a/aioca-2.1-py3-none-any.whl name: aioca - version: 1.8.1 - sha256: b856b68c4722387bc88917c10f71f87b7e74d95a28581187b6577682a6c68909 + version: '2.1' + sha256: df0f062b81a4846d3e5705f0bfaf7bee9f876009302cf3b06ada93d6f5153b6f requires_dist: - numpy - epicscorelibs>=7.0.3.99.4.0 - - black ; extra == 'dev' - - click ; extra == 'dev' - - mypy ; extra == 'dev' - - myst-parser ; extra == 'dev' - - pipdeptree ; extra == 'dev' - - pre-commit ; extra == 'dev' - - pydata-sphinx-theme>=0.12 ; extra == 'dev' - - pytest ; extra == 'dev' - - pytest-asyncio ; extra == 'dev' - - pytest-cov ; extra == 'dev' - - ruff ; extra == 'dev' - - sphinx-autobuild ; extra == 'dev' - - sphinx-copybutton ; extra == 'dev' - - sphinx-design ; extra == 'dev' - - tox-direct ; extra == 'dev' - - types-mock ; extra == 'dev' requires_python: '>=3.8' - pypi: https://files.pythonhosted.org/packages/a5/45/30bb92d442636f570cb5651bc661f52b610e2eec3f891a5dc3a4c3667db0/aiofiles-24.1.0-py3-none-any.whl name: aiofiles @@ -1031,11 +1045,11 @@ packages: requires_python: '>=3.8' - pypi: ./ name: cditools - version: 0.1.dev82+g9cfe709dd.d20250902 - sha256: 8d150403b1dbd5cc6f2f621421facbd9a8aef505ba22ab908fd0346bc6f2b324 + version: 0.1.1.dev75+gb634deca4.d20260615 + sha256: 329fa3eedf2260d6e30b16a594d6308e9e9a7e3d3922a23b0b7524c2d0034046 requires_dist: - ophyd - - ophyd-async>=0.10.0a4 + - ophyd-async[ca]>=0.17a2 - h5py - entrypoints ; extra == 'test' - pytest>=6 ; extra == 'test' @@ -1065,7 +1079,6 @@ packages: - sphinx-autodoc-typehints ; extra == 'docs' - furo>=2023.8.17 ; extra == 'docs' requires_python: '>=3.9' - editable: true - pypi: https://files.pythonhosted.org/packages/e5/48/1549795ba7742c948d2ad169c1c8cdbae65bc450d6cd753d124b17c8cd32/certifi-2025.8.3-py3-none-any.whl name: certifi version: 2025.8.3 @@ -2388,10 +2401,10 @@ packages: - sphinx-design ; extra == 'dev' - tox-direct ; extra == 'dev' requires_python: '>=3.8' -- pypi: https://files.pythonhosted.org/packages/c8/cb/75a17d45ae070fa4af0b6bf79fb820b9d9137bf09f485a6728f71e4a062e/ophyd_async-0.12.3-py3-none-any.whl +- pypi: https://files.pythonhosted.org/packages/7f/a3/b4605ee05a6a3480ad57cf0ac15e381f99189b022362f0c08044c86227d7/ophyd_async-0.19.1-py3-none-any.whl name: ophyd-async - version: 0.12.3 - sha256: 3d0e98f9ab0169776dad24c072ef19ece6d53faa2933e55d3c139c525761b802 + version: 0.19.1 + sha256: bb4cc6751aee46e53c588b493ce0a8905c94fbf4b5092af2f774019846af7c74 requires_dist: - numpy - bluesky>=1.13.1rc2 @@ -2401,46 +2414,16 @@ packages: - pydantic>=2.0 - pydantic-numpy - stamina>=23.1.0 + - scanspec>=0.8 + - velocity-profile - h5py ; extra == 'sim' - aioca>=2.0a4 ; extra == 'ca' - p4p>=4.2.0 ; extra == 'pva' - - pytango==10.0.0 ; extra == 'tango' + - pytango>=10.1.3 ; extra == 'tango' - ipython ; extra == 'demo' - matplotlib ; extra == 'demo' - pyqt6 ; extra == 'demo' - - ophyd-async[sim] ; extra == 'dev' - - ophyd-async[ca] ; extra == 'dev' - - ophyd-async[pva] ; extra == 'dev' - - ophyd-async[tango] ; extra == 'dev' - - ophyd-async[demo] ; extra == 'dev' - - inflection ; extra == 'dev' - - import-linter ; extra == 'dev' - - myst-parser ; extra == 'dev' - - numpydoc ; extra == 'dev' - - ophyd>=1.10.7 ; extra == 'dev' - - pickleshare ; extra == 'dev' - - pipdeptree ; extra == 'dev' - - pre-commit ; extra == 'dev' - - pydata-sphinx-theme>=0.12 ; extra == 'dev' - - pyepics>=3.4.2 ; extra == 'dev' - - pyright ; extra == 'dev' - - pytest ; extra == 'dev' - - pytest-asyncio ; extra == 'dev' - - pytest-cov ; extra == 'dev' - - pytest-faulthandler ; extra == 'dev' - - pytest-forked ; extra == 'dev' - - pytest-rerunfailures ; extra == 'dev' - - pytest-timeout ; extra == 'dev' - - ruff ; extra == 'dev' - - sphinx-autobuild ; extra == 'dev' - - sphinx-autodoc2 ; extra == 'dev' - - sphinxcontrib-mermaid ; extra == 'dev' - - sphinx-copybutton ; extra == 'dev' - - sphinx-design ; extra == 'dev' - - tox-direct ; extra == 'dev' - - types-mock ; extra == 'dev' - - types-pyyaml ; extra == 'dev' - requires_python: '>=3.10' + requires_python: '>=3.11' - pypi: https://files.pythonhosted.org/packages/d0/b4/f98355eff0bd1a38454209bbc73372ce351ba29933cb3e2eba16c04b9448/orjson-3.11.3-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl name: orjson version: 3.11.3 @@ -3065,6 +3048,19 @@ packages: version: 0.12.11 sha256: 4fc58f9266d62c6eccc75261a665f26b4ef64840887fc6cbc552ce5b29f96cc8 requires_python: '>=3.7' +- pypi: https://files.pythonhosted.org/packages/68/b9/f44b139096467996b4d85589d9880fd55f0c80a204d298a1b73d0adc2654/scanspec-1.0.0-py3-none-any.whl + name: scanspec + version: 1.0.0 + sha256: 4ad6dccd6ac19dbd8af888c80c5db53c70d6b8aee9d26c0ca174cebd3c396730 + requires_dist: + - numpy + - click>=8.1 + - pydantic>=2.0 + - scipy ; extra == 'plotting' + - matplotlib ; extra == 'plotting' + - fastapi>=0.100.0 ; extra == 'service' + - uvicorn ; extra == 'service' + requires_python: '>=3.11' - pypi: https://files.pythonhosted.org/packages/54/24/b4293291fa1dd830f353d2cb163295742fa87f179fcc8a20a306a81978b7/SecretStorage-3.3.3-py3-none-any.whl name: secretstorage version: 3.3.3 @@ -3840,6 +3836,24 @@ packages: - pyopenssl~=23.0.0 ; extra == 'test' - mypy>=0.800 ; extra == 'test' requires_python: '>=3.8.0' +- pypi: https://files.pythonhosted.org/packages/72/89/0265b2b79424ed05b8d1e9c8fca71e1b150478e5b0c19aa50b0ae397326e/velocity_profile-1.0.0-py3-none-any.whl + name: velocity-profile + version: 1.0.0 + sha256: b9082aedb2863748e1e6e56e7a794cd5742addd571f6ba2e13f4f5b8a09422d9 + requires_dist: + - numpy + - copier ; extra == 'dev' + - mypy ; extra == 'dev' + - pipdeptree ; extra == 'dev' + - pre-commit ; extra == 'dev' + - pytest ; extra == 'dev' + - pytest-cov ; extra == 'dev' + - ruff ; extra == 'dev' + - tox-direct ; extra == 'dev' + - types-mock ; extra == 'dev' + - scanspec ; extra == 'dev' + - pydantic<2.0 ; extra == 'dev' + requires_python: '>=3.7' - pypi: https://files.pythonhosted.org/packages/76/06/04c8e804f813cf972e3262f3f8584c232de64f0cde9f703b46cf53a45090/virtualenv-20.34.0-py3-none-any.whl name: virtualenv version: 20.34.0 diff --git a/pyproject.toml b/pyproject.toml index 4a03250..3567ec0 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -31,7 +31,7 @@ classifiers = [ dynamic = ["version"] dependencies = [ "ophyd", - "ophyd-async >=0.10.0a4", + "ophyd-async[ca] >=0.17a2", "h5py", ] From bd96ebd3cfe0e6f2e71619b9cf1d8ae792abe02b Mon Sep 17 00:00:00 2001 From: Dan Henriksen Date: Wed, 17 Jun 2026 14:26:16 -0400 Subject: [PATCH 05/13] fix bug in EigerStreamVersion enum --- src/cditools/eiger_async.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/src/cditools/eiger_async.py b/src/cditools/eiger_async.py index e1989cd..34223c7 100644 --- a/src/cditools/eiger_async.py +++ b/src/cditools/eiger_async.py @@ -123,9 +123,7 @@ class EigerStreamVersion(StrictEnum): See https://areadetector.github.io/areaDetector/ADEiger/eiger.html#stream-interface """ - # TODO - Stream or Stream1? - # STREAM1 = "Stream" - STREAM1 = "Stream1" + STREAM1 = "Stream" STREAM2 = "Stream2" From 50dc20b64d2748b81ef262a898ff9a1621ce81d6 Mon Sep 17 00:00:00 2001 From: Dan Henriksen Date: Wed, 17 Jun 2026 14:42:07 -0400 Subject: [PATCH 06/13] pin ophyd-async version; revert pvs since they did not connect --- pixi.lock | 20 ++++++++++---------- pyproject.toml | 2 +- src/cditools/eiger_async.py | 15 ++++----------- 3 files changed, 15 insertions(+), 22 deletions(-) diff --git a/pixi.lock b/pixi.lock index d63ff80..fabfaa9 100644 --- a/pixi.lock +++ b/pixi.lock @@ -56,7 +56,7 @@ environments: - pypi: https://files.pythonhosted.org/packages/19/49/4df9123aafa7b539317bf6d342cb6d227e49f7a35b99c287a6109b13dd93/numpy-2.2.6-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl - pypi: https://files.pythonhosted.org/packages/bb/ee/6b08dde0a022c463b88f55ae81149584b125a42183407dc1045c486cc870/opentelemetry_api-1.36.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/fa/b3/1e63820f1a4df7854e33f0c3c5fa6be758c7fb7ea007ae9c8b9b35f48690/ophyd-1.10.7-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/7f/a3/b4605ee05a6a3480ad57cf0ac15e381f99189b022362f0c08044c86227d7/ophyd_async-0.19.1-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/34/6a/68cf0ccf958ddae11d6da56905cd71c5c8101a581abb67f8c7574b95823f/ophyd_async-0.17a2-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/20/12/38679034af332785aac8774540895e234f4d07f7545804097de4b666afd8/packaging-25.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/76/cc/c528311d798e22ec884b816e8aa2989e0f1f28cdc8e5969e2be5f10bce85/pint-0.25-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/40/4b/2028861e724d3bd36227adfa20d3fd24c3fc6d52032f4a93c133be5d17ce/platformdirs-4.4.0-py3-none-any.whl @@ -216,7 +216,7 @@ environments: - pypi: https://files.pythonhosted.org/packages/c0/da/977ded879c29cbd04de313843e76868e6e13408a94ed6b987245dc7c8506/openpyxl-3.1.5-py2.py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/bb/ee/6b08dde0a022c463b88f55ae81149584b125a42183407dc1045c486cc870/opentelemetry_api-1.36.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/fa/b3/1e63820f1a4df7854e33f0c3c5fa6be758c7fb7ea007ae9c8b9b35f48690/ophyd-1.10.7-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/7f/a3/b4605ee05a6a3480ad57cf0ac15e381f99189b022362f0c08044c86227d7/ophyd_async-0.19.1-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/34/6a/68cf0ccf958ddae11d6da56905cd71c5c8101a581abb67f8c7574b95823f/ophyd_async-0.17a2-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/d0/b4/f98355eff0bd1a38454209bbc73372ce351ba29933cb3e2eba16c04b9448/orjson-3.11.3-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl - pypi: https://files.pythonhosted.org/packages/20/12/38679034af332785aac8774540895e234f4d07f7545804097de4b666afd8/packaging-25.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/8f/52/0634adaace9be2d8cac9ef78f05c47f3a675882e068438b9d7ec7ef0c13f/pandas-2.3.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl @@ -373,7 +373,7 @@ environments: - pypi: https://files.pythonhosted.org/packages/19/49/4df9123aafa7b539317bf6d342cb6d227e49f7a35b99c287a6109b13dd93/numpy-2.2.6-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl - pypi: https://files.pythonhosted.org/packages/bb/ee/6b08dde0a022c463b88f55ae81149584b125a42183407dc1045c486cc870/opentelemetry_api-1.36.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/fa/b3/1e63820f1a4df7854e33f0c3c5fa6be758c7fb7ea007ae9c8b9b35f48690/ophyd-1.10.7-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/7f/a3/b4605ee05a6a3480ad57cf0ac15e381f99189b022362f0c08044c86227d7/ophyd_async-0.19.1-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/34/6a/68cf0ccf958ddae11d6da56905cd71c5c8101a581abb67f8c7574b95823f/ophyd_async-0.17a2-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/20/12/38679034af332785aac8774540895e234f4d07f7545804097de4b666afd8/packaging-25.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/76/cc/c528311d798e22ec884b816e8aa2989e0f1f28cdc8e5969e2be5f10bce85/pint-0.25-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/40/4b/2028861e724d3bd36227adfa20d3fd24c3fc6d52032f4a93c133be5d17ce/platformdirs-4.4.0-py3-none-any.whl @@ -510,7 +510,7 @@ environments: - pypi: https://files.pythonhosted.org/packages/19/49/4df9123aafa7b539317bf6d342cb6d227e49f7a35b99c287a6109b13dd93/numpy-2.2.6-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl - pypi: https://files.pythonhosted.org/packages/bb/ee/6b08dde0a022c463b88f55ae81149584b125a42183407dc1045c486cc870/opentelemetry_api-1.36.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/fa/b3/1e63820f1a4df7854e33f0c3c5fa6be758c7fb7ea007ae9c8b9b35f48690/ophyd-1.10.7-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/7f/a3/b4605ee05a6a3480ad57cf0ac15e381f99189b022362f0c08044c86227d7/ophyd_async-0.19.1-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/34/6a/68cf0ccf958ddae11d6da56905cd71c5c8101a581abb67f8c7574b95823f/ophyd_async-0.17a2-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/d0/b4/f98355eff0bd1a38454209bbc73372ce351ba29933cb3e2eba16c04b9448/orjson-3.11.3-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl - pypi: https://files.pythonhosted.org/packages/20/12/38679034af332785aac8774540895e234f4d07f7545804097de4b666afd8/packaging-25.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/71/e7/40fb618334dcdf7c5a316c0e7343c5cd82d3d866edc100d98e29bc945ecd/partd-1.4.2-py3-none-any.whl @@ -1045,11 +1045,11 @@ packages: requires_python: '>=3.8' - pypi: ./ name: cditools - version: 0.1.1.dev75+gb634deca4.d20260615 - sha256: 329fa3eedf2260d6e30b16a594d6308e9e9a7e3d3922a23b0b7524c2d0034046 + version: 0.1.1.dev77+gbd96ebd3c.d20260617 + sha256: 5581ab46c321a090c4331aa21510018a9ba1f1ec373bb6bc0d01b7df4f6c52a1 requires_dist: - ophyd - - ophyd-async[ca]>=0.17a2 + - ophyd-async[ca]==0.17a2 - h5py - entrypoints ; extra == 'test' - pytest>=6 ; extra == 'test' @@ -2401,10 +2401,10 @@ packages: - sphinx-design ; extra == 'dev' - tox-direct ; extra == 'dev' requires_python: '>=3.8' -- pypi: https://files.pythonhosted.org/packages/7f/a3/b4605ee05a6a3480ad57cf0ac15e381f99189b022362f0c08044c86227d7/ophyd_async-0.19.1-py3-none-any.whl +- pypi: https://files.pythonhosted.org/packages/34/6a/68cf0ccf958ddae11d6da56905cd71c5c8101a581abb67f8c7574b95823f/ophyd_async-0.17a2-py3-none-any.whl name: ophyd-async - version: 0.19.1 - sha256: bb4cc6751aee46e53c588b493ce0a8905c94fbf4b5092af2f774019846af7c74 + version: 0.17a2 + sha256: 9e7e05d8fbd34ddc9fb907cbcab1b9fbd54fa9aa2d4a26615302d3841200ffeb requires_dist: - numpy - bluesky>=1.13.1rc2 diff --git a/pyproject.toml b/pyproject.toml index 3567ec0..1d015ed 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -31,7 +31,7 @@ classifiers = [ dynamic = ["version"] dependencies = [ "ophyd", - "ophyd-async[ca] >=0.17a2", + "ophyd-async[ca] ==0.17a2", "h5py", ] diff --git a/src/cditools/eiger_async.py b/src/cditools/eiger_async.py index 34223c7..e8fbd28 100644 --- a/src/cditools/eiger_async.py +++ b/src/cditools/eiger_async.py @@ -260,13 +260,9 @@ class Eiger2DriverIO(EigerDriverIO): hv_state: A[SignalR[str], PvSuffix("HVState_RBV")] # Acquisition Setup - # TODO - ThresholdEnergy or Threshold? - # threshold: A[SignalRW[float], PvSuffix.rbv("ThresholdEnergy")] - threshold: A[SignalRW[float], PvSuffix.rbv("Threshold")] + threshold: A[SignalRW[float], PvSuffix.rbv("ThresholdEnergy")] threshold1_enable: A[SignalRW[bool], PvSuffix.rbv("Threshold1Enable")] - # TODO - Threshold2Energy or Threshold? - # threshold2: A[SignalRW[float], PvSuffix.rbv("Threshold2Energy")] - threshold2: A[SignalRW[float], PvSuffix.rbv("Threshold2")] + threshold2: A[SignalRW[float], PvSuffix.rbv("Threshold2Energy")] threshold2_enable: A[SignalRW[bool], PvSuffix.rbv("Threshold2Enable")] threshold_diff_enable: A[SignalRW[bool], PvSuffix.rbv("ThresholdDiffEnable")] counting_mode: A[SignalRW[str], PvSuffix.rbv("CountingMode")] @@ -280,11 +276,8 @@ class Eiger2DriverIO(EigerDriverIO): # Stream Interface stream_version: A[SignalRW[EigerStreamVersion], PvSuffix.rbv("StreamVersion")] - # TODO - which one? - # stream_hdr_appendix: None - stream_hdr_appendix: A[SignalRW[str], PvSuffix.rbv("StreamHdrAppendix")] - # stream_img_appendix: None - stream_img_appendix: A[SignalRW[str], PvSuffix.rbv("StreamImgAppendix")] + stream_hdr_appendix: None + stream_img_appendix: None # FileWriter Interface fw_hdf5_format: A[SignalRW[EigerHDF5Format], PvSuffix.rbv("FWHDF5Format")] From 7b7f427711bd7b64635c107d496f589deab13ed3 Mon Sep 17 00:00:00 2001 From: CDI Operator Date: Wed, 17 Jun 2026 15:46:20 -0400 Subject: [PATCH 07/13] removed debug print statements --- tests/test_eiger_async.py | 6 ------ 1 file changed, 6 deletions(-) diff --git a/tests/test_eiger_async.py b/tests/test_eiger_async.py index a65cbe6..b529814 100644 --- a/tests/test_eiger_async.py +++ b/tests/test_eiger_async.py @@ -574,21 +574,15 @@ async def _simulate_one_trigger(value: bool) -> None: # Case 1 - Step Scan: stage, trigger, read, trigger, read, unstage await mock_eiger_detector.stage() await mock_eiger_detector.trigger() - print(2) assert ( await mock_eiger_detector.driver.data_source.get_value() == EigerDataSource.FILE_WRITER ) - print(3) await mock_eiger_detector.read() - print(4) await mock_eiger_detector.trigger() - print(5) await mock_eiger_detector.read() - print(6) await mock_eiger_detector.unstage() - print(7) set_mock_value(mock_eiger_detector.data_logic.fileio.array_counter, 0) set_mock_value(mock_eiger_detector.driver.num_images_counter, 0) # Case 2 - Fly Scan: prepare, kickoff, complete From 43dac1226924386869b97ee338a6d6332b8c1e5e Mon Sep 17 00:00:00 2001 From: Dan Henriksen Date: Thu, 11 Jun 2026 19:22:55 -0400 Subject: [PATCH 08/13] updated some tests, deleted unused code --- tests/test_eiger_async.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/tests/test_eiger_async.py b/tests/test_eiger_async.py index b529814..a65cbe6 100644 --- a/tests/test_eiger_async.py +++ b/tests/test_eiger_async.py @@ -574,15 +574,21 @@ async def _simulate_one_trigger(value: bool) -> None: # Case 1 - Step Scan: stage, trigger, read, trigger, read, unstage await mock_eiger_detector.stage() await mock_eiger_detector.trigger() + print(2) assert ( await mock_eiger_detector.driver.data_source.get_value() == EigerDataSource.FILE_WRITER ) + print(3) await mock_eiger_detector.read() + print(4) await mock_eiger_detector.trigger() + print(5) await mock_eiger_detector.read() + print(6) await mock_eiger_detector.unstage() + print(7) set_mock_value(mock_eiger_detector.data_logic.fileio.array_counter, 0) set_mock_value(mock_eiger_detector.driver.num_images_counter, 0) # Case 2 - Fly Scan: prepare, kickoff, complete From 010d0a2593a8c40de225df0b06a731c6cb013440 Mon Sep 17 00:00:00 2001 From: Dan Henriksen Date: Mon, 15 Jun 2026 14:32:04 -0400 Subject: [PATCH 09/13] updated some tests; left comments and todos --- src/cditools/eiger_async.py | 14 +- tests/test_eiger_async.py | 247 +++++++++++++++++------------------- 2 files changed, 125 insertions(+), 136 deletions(-) diff --git a/src/cditools/eiger_async.py b/src/cditools/eiger_async.py index e8fbd28..180a387 100644 --- a/src/cditools/eiger_async.py +++ b/src/cditools/eiger_async.py @@ -302,13 +302,10 @@ def get_deadtime(self, exposure: float | None) -> float: async def prepare_internal(self, num: int, livetime: float, deadtime: float): """Prepare the detector for acquisition. - - On Internal Series, num sets the number of images to take per trigger: https://areadetector.github.io/areaDetector/ADEiger/eiger.html#implementation-of-standard-driver-parameters """ # TODO - should we do something with deadtime? # TODO - put other awaits into the gather - if livetime > 0: await self.driver.acquire_time.set(livetime) @@ -319,8 +316,11 @@ async def prepare_internal(self, num: int, livetime: float, deadtime: float): else: image_mode = ADImageMode.MULTIPLE + # TODO - should we set num_images here? + # num_triggers gets overwritten in .prepare_unbounded(), which gets called further + # alone in .prepare() + # await self.driver.num_triggers.set(num), await asyncio.gather( - self.driver.num_triggers.set(num), self.driver.image_mode.set(image_mode), ) @@ -368,7 +368,7 @@ def __init__( self._datasets: list[StreamResourceDataProvider] = [] self._master_file_path_cache: list[Path] = [] - async def prepare_unbounded(self, datakey_name: str) -> StreamableDataProvider: + async def prepare_unbounded(self, datakey_name: str) -> StreamResourceDataProvider: """Provider can work for an unbounded number of collections.""" # Get file path info from path provider # TODO: should probably just pass datakey_name @@ -391,6 +391,7 @@ async def prepare_unbounded(self, datakey_name: str) -> StreamableDataProvider: self.fileio.manual_trigger.set(True), # TODO sort out how to get this from the plan self.fileio.num_triggers.set(5000), + self.fileio.data_source.set(EigerDataSource.FILE_WRITER) ) await set_and_wait_for_other_value( @@ -429,6 +430,7 @@ async def prepare_unbounded(self, datakey_name: str) -> StreamableDataProvider: mfp = await self._master_file_path # TODO sort out how to get from parent + # TODO - should this be the datakey_name that gets passed in? name = "eiger" exposures_per_event = await self.fileio.num_images.get_value() @@ -445,7 +447,7 @@ async def prepare_unbounded(self, datakey_name: str) -> StreamableDataProvider: parameters={ "dataset": f"entry/data/data_{1:06d}", }, - # TODO put in better value + # TODO put in better value; should it match EigerDataSource.FILE_WRITER? source="EIGER2_FILE_WRITER", ) ], diff --git a/tests/test_eiger_async.py b/tests/test_eiger_async.py index a65cbe6..b1c2a3f 100644 --- a/tests/test_eiger_async.py +++ b/tests/test_eiger_async.py @@ -13,6 +13,7 @@ import h5py import numpy as np import pytest +import pytest_asyncio from bluesky.callbacks.tiled_writer import TiledWriter from bluesky.run_engine import RunEngine from event_model import StreamDatum, StreamResource @@ -144,7 +145,7 @@ def mock_path_provider() -> PathProvider: ) -@pytest.fixture +@pytest_asyncio.fixture async def eiger_writer( mock_eiger_driver: EigerDriverIO, mock_path_provider: PathProvider, @@ -181,12 +182,11 @@ async def test_eiger_writer_initialization( """Test that EigerDataLogic initializes correctly.""" assert eiger_writer.fileio is mock_eiger_driver assert eiger_writer._path_provider is mock_path_provider # type: ignore[reportPrivateUsage] - assert eiger_writer._dataset_describer is not None # type: ignore[reportPrivateUsage] assert eiger_writer._file_info is None # type: ignore[reportPrivateUsage] @pytest.mark.asyncio -async def test_eiger_writer_open( +async def test_eiger_data_logic_prepare_unbounded( eiger_writer: EigerDataLogic, mock_eiger_driver: EigerDriverIO, ) -> None: @@ -200,53 +200,23 @@ async def test_eiger_writer_open( set_mock_value(mock_eiger_driver.sequence_id, 0) set_mock_value(mock_eiger_driver.num_images, 1) - description = await eiger_writer.open(name="test_eiger", exposures_per_event=1) + streamDataProv = await eiger_writer.prepare_unbounded(datakey_name="test_eiger") assert await mock_eiger_driver.fw_enable.get_value() is True assert await mock_eiger_driver.save_files.get_value() is True - assert description.keys() == { - # TODO: Add these when empty shape datasets are supported by tiled - # "test_eiger_y_pixel_size", - # "test_eiger_x_pixel_size", - # "test_eiger_detector_distance", - # "test_eiger_incident_wavelength", - # "test_eiger_frame_time", - # "test_eiger_beam_center_x", - # "test_eiger_beam_center_y", - # "test_eiger_count_time", - # "test_eiger_pixel_mask", - "test_eiger_image", - } - assert description["test_eiger_image"]["source"] == "ADEiger FileWriter" + # TODO data_key should probably match datakey_name actually + assert streamDataProv.resources[0].data_key == "eiger_image" + assert streamDataProv.resources[0].source == "EIGER2_FILE_WRITER" # Case 2: 4 images per file, 11 images, 2 triggers # Expect 6 files, the first 5 will have 4 images, the last will have 2 set_mock_value(mock_eiger_driver.sequence_id, 1) set_mock_value(mock_eiger_driver.num_images, 11) - description = await eiger_writer.open( - name="test_eiger", - exposures_per_event=await mock_eiger_driver.num_images.get_value(), - ) - assert description.keys() == { - # TODO: Add these when empty shape datasets are supported by tiled - # "test_eiger_y_pixel_size", - # "test_eiger_x_pixel_size", - # "test_eiger_detector_distance", - # "test_eiger_incident_wavelength", - # "test_eiger_frame_time", - # "test_eiger_beam_center_x", - # "test_eiger_beam_center_y", - # "test_eiger_count_time", - # "test_eiger_pixel_mask", - "test_eiger_image", - } - data_key = description["test_eiger_image"] - assert tuple(data_key["shape"]) == (11, array_size_x, array_size_y) - assert data_key["dtype"] == "array" - assert "dtype_numpy" in data_key - assert data_key["dtype_numpy"] == np.dtype(np.uint32).str - assert "external" in data_key - assert data_key["external"] == "STREAM:" - assert data_key["source"] == "ADEiger FileWriter" + streamDataProv = await eiger_writer.prepare_unbounded(datakey_name="test_eiger") + streamResourceProv = streamDataProv.resources[0] + assert streamResourceProv.data_key == "eiger_image" + assert streamResourceProv.shape == (11, array_size_x, array_size_y) + assert streamResourceProv.dtype_numpy == np.dtype(np.uint32).str + assert streamResourceProv.source == "EIGER2_FILE_WRITER" @pytest.mark.asyncio @@ -260,9 +230,8 @@ async def test_eiger_writer_get_indices_written( # Case 1: 1 image, 1 trigger set_mock_value(mock_eiger_driver.num_images, 1) set_mock_value(mock_eiger_driver.array_counter, 0) - await eiger_writer.open( - name="test_eiger", - exposures_per_event=await mock_eiger_driver.num_images.get_value(), + await eiger_writer.prepare_unbounded( + datakey_name="test_eiger" ) assert await eiger_writer.get_indices_written() == 0 set_mock_value(mock_eiger_driver.array_counter, 1) @@ -271,9 +240,8 @@ async def test_eiger_writer_get_indices_written( # Case 2: 1 image, 5 triggers set_mock_value(mock_eiger_driver.num_images, 1) set_mock_value(mock_eiger_driver.array_counter, 0) - await eiger_writer.open( - name="test_eiger", - exposures_per_event=await mock_eiger_driver.num_images.get_value(), + await eiger_writer.prepare_unbounded( + datakey_name="test_eiger" ) assert await eiger_writer.get_indices_written() == 0 set_mock_value(mock_eiger_driver.array_counter, 1) @@ -283,12 +251,19 @@ async def test_eiger_writer_get_indices_written( set_mock_value(mock_eiger_driver.array_counter, 5) assert await eiger_writer.get_indices_written() == 5 + +# TODO - should we add this? +@pytest.mark.skip("Driver does not currently allow setting num_images per trigger") +@pytest.mark.asyncio +async def test_eiger_writer_get_indices_written_multi_images( + eiger_writer: EigerDataLogic, + mock_eiger_driver: EigerDriverIO, +): # Case 3: 5 images, 2 triggers set_mock_value(mock_eiger_driver.num_images, 5) set_mock_value(mock_eiger_driver.array_counter, 0) - await eiger_writer.open( - name="test_eiger", - exposures_per_event=await mock_eiger_driver.num_images.get_value(), + await eiger_writer.prepare_unbounded( + datakey_name="test_eiger" ) assert await eiger_writer.get_indices_written() == 0 set_mock_value(mock_eiger_driver.array_counter, 4) @@ -346,7 +321,7 @@ async def _complete(): set_mock_value(mock_eiger_driver.num_triggers, 1) num_images = await mock_eiger_driver.num_images.get_value() num_triggers = await mock_eiger_driver.num_triggers.get_value() - await eiger_writer.open(name="test_eiger", exposures_per_event=num_images) + await eiger_writer.prepare_unbounded(datakey_name="test_eiger") observed = await _simulate_writing_indices( num_images=num_images, num_triggers=num_triggers ) @@ -357,18 +332,26 @@ async def _complete(): set_mock_value(mock_eiger_driver.num_triggers, 5) num_images = await mock_eiger_driver.num_images.get_value() num_triggers = await mock_eiger_driver.num_triggers.get_value() - await eiger_writer.open(name="test_eiger", exposures_per_event=num_images) + await eiger_writer.prepare_unbounded(datakey_name="test_eiger") observed = await _simulate_writing_indices( num_images=num_images, num_triggers=num_triggers ) assert observed == [0, 1, 2, 3, 4, 5] + +# TODO - should we add this? +@pytest.mark.skip("Driver does not currently allow setting num_images per trigger") +@pytest.mark.asyncio +async def test_eiger_writer_observe_indices_written_multi_image( + eiger_writer: EigerDataLogic, + mock_eiger_driver: EigerDriverIO, +) -> None: # Case 3: 5 images, 2 triggers set_mock_value(mock_eiger_driver.num_images, 5) set_mock_value(mock_eiger_driver.num_triggers, 2) num_images = await mock_eiger_driver.num_images.get_value() num_triggers = await mock_eiger_driver.num_triggers.get_value() - await eiger_writer.open(name="test_eiger", exposures_per_event=num_images) + await eiger_writer.prepare_unbounded(datakey_name="test_eiger") observed = await _simulate_writing_indices( num_images=num_images, num_triggers=num_triggers ) @@ -382,58 +365,67 @@ async def test_eiger_writer_collect_stream_docs( ) -> None: """Test collecting stream documents.""" - async def collect_docs( - num_triggers: int, - ) -> tuple[list[StreamResource], list[StreamDatum]]: - resource_docs = [] - data_docs = [] - for i in range(1, num_triggers + 1): - sequence_id = await mock_eiger_driver.sequence_id.get_value() - set_mock_value(mock_eiger_driver.sequence_id, sequence_id + 1) - async for doc_type, doc in eiger_writer.collect_stream_docs( - name="", indices_written=i - ): - if doc_type == "stream_resource": - resource_docs.append(doc) - elif doc_type == "stream_datum": - data_docs.append(doc) - return resource_docs, data_docs + # async def collect_docs( + # num_triggers: int, + # ) -> tuple[list[StreamResource], list[StreamDatum]]: + # for i in range(1, num_triggers + 1): + # sequence_id = await mock_eiger_driver.sequence_id.get_value() + # set_mock_value(mock_eiger_driver.sequence_id, sequence_id + 1) + # async for doc_type, doc in eiger_writer.collect_stream_docs( + # name="", indices_written=i + # ): + # if doc_type == "stream_resource": + # resource_docs.append(doc) + # elif doc_type == "stream_datum": + # data_docs.append(doc) + # return resource_docs, data_docs set_mock_value(mock_eiger_driver.sequence_id, 0) set_mock_value(mock_eiger_driver.num_images, 1) - await eiger_writer.open(name="test_eiger", exposures_per_event=1) - resource_docs, data_docs = await collect_docs(num_triggers=1) - assert len(resource_docs) == 1 - assert len(data_docs) == 1 - assert ( - resource_docs[0]["uri"] - == f"file://localhost{EIGER_DATA_PATH}/test_eiger_1_master.h5" - ) - await eiger_writer.close() + provider = await eiger_writer.prepare_unbounded(datakey_name="test_eiger") + # simulate first trigger + set_mock_value(mock_eiger_driver.sequence_id, 1) + set_mock_value(mock_eiger_driver.array_counter, 1) + + resource_docs = [] + data_docs = [] + async for doc_type, doc in provider.make_stream_docs(1, 1): + if doc_type == "stream_resource": + resource_docs.append(doc) + elif doc_type == "stream_datum": + data_docs.append(doc) - await eiger_writer.open(name="test_eiger", exposures_per_event=1) - resource_docs, data_docs = await collect_docs(num_triggers=3) - assert len(resource_docs) == 3 - assert len(data_docs) == 3 - # There are 10 different datasets inside a single master file - # 3 triggers, so 30 total resources/datasets + assert len(resource_docs) == 1 + assert len(data_docs) == 1 assert ( resource_docs[0]["uri"] - == f"file://localhost{EIGER_DATA_PATH}/test_eiger_2_master.h5" - ) - assert ( - resource_docs[1]["uri"] - == f"file://localhost{EIGER_DATA_PATH}/test_eiger_3_master.h5" - ) - assert ( - resource_docs[2]["uri"] - == f"file://localhost{EIGER_DATA_PATH}/test_eiger_4_master.h5" - ) + == f"file://localhost{EIGER_DATA_PATH}/test_eiger_0_master.h5" + ) + + await eiger_writer.stop() + + provider2 = await eiger_writer.prepare_unbounded(datakey_name="test_eiger") + resource_docs = [] + data_docs = [] + for i in range(1, 4): + set_mock_value(mock_eiger_driver.sequence_id, i + 1) + set_mock_value(mock_eiger_driver.array_counter, i) + async for doc_type, doc in provider2.make_stream_docs(i, i): + print(doc_type, doc) + if doc_type == "stream_resource": + resource_docs.append(doc) + elif doc_type == "stream_datum": + print('appending') + data_docs.append(doc) + print(await provider2.collections_written_signal.get_value()) + assert len(resource_docs) == 1 + assert len(data_docs) == 1 + assert await provider2.collections_written_signal.get_value() == 3 @pytest.mark.asyncio -async def test_eiger_writer_close( +async def test_eiger_writer_stop( eiger_writer: EigerDataLogic, mock_eiger_driver: EigerDriverIO, ) -> None: @@ -442,12 +434,12 @@ async def test_eiger_writer_close( # Verify the writing was enabled set_mock_value(mock_eiger_driver.sequence_id, 1) set_mock_value(mock_eiger_driver.num_images, 1) - await eiger_writer.open(name="test_eiger", exposures_per_event=1) + await eiger_writer.prepare_unbounded(datakey_name="test_eiger") assert await mock_eiger_driver.fw_enable.get_value() is True assert await mock_eiger_driver.save_files.get_value() is True # Verify the writing was disabled - await eiger_writer.close() + await eiger_writer.stop() assert eiger_writer._file_info is None # type: ignore[reportPrivateUsage] @pytest.mark.asyncio @@ -467,8 +459,10 @@ async def test_eiger_prepare(mock_eiger_detector: EigerDetector) -> None: await mock_eiger_detector.driver.trigger_mode.get_value() == EigerTriggerMode.INTERNAL_SERIES ) - assert await mock_eiger_detector.driver.num_images.get_value() == 1 + # num_triggers in this context is the number of triggers + assert await mock_eiger_detector.driver.num_triggers.get_value() == 5000 assert await mock_eiger_detector.driver.image_mode.get_value() == ADImageMode.MULTIPLE + assert await mock_eiger_detector.events_to_kickoff.get_value() == 1 # Implement tests for these other trigger_infos trigger_info = TriggerInfo( @@ -481,20 +475,7 @@ async def test_eiger_prepare(mock_eiger_detector: EigerDetector) -> None: exposure_timeout=10.0, ) -@pytest.mark.asyncio -async def test_eiger_data_logic_prepare_unbounded(eiger_writer: EigerDataLogic) -> None: - trigger_info = TriggerInfo( - trigger=DetectorTrigger.INTERNAL, - livetime=0.01, - deadtime=0.001, - exposures_per_collection=1, - collections_per_event=1, - number_of_events=1, - exposure_timeout=1.0, - ) - stream_resource = await eiger_writer.prepare_unbounded("test_eiger") - print(stream_resource) - +@pytest.mark.skip("What should `num` do in `prepare_internal`?") @pytest.mark.asyncio async def test_eiger_controller_prepare_internal(eiger_controller: EigerController) -> None: await eiger_controller.prepare_internal(num=1, livetime=0.01, deadtime=0.001) @@ -574,21 +555,19 @@ async def _simulate_one_trigger(value: bool) -> None: # Case 1 - Step Scan: stage, trigger, read, trigger, read, unstage await mock_eiger_detector.stage() await mock_eiger_detector.trigger() - print(2) + assert ( + await mock_eiger_detector.data_logic.fileio.data_source.get_value() + == EigerDataSource.FILE_WRITER + ) assert ( await mock_eiger_detector.driver.data_source.get_value() == EigerDataSource.FILE_WRITER ) - print(3) await mock_eiger_detector.read() - print(4) await mock_eiger_detector.trigger() - print(5) await mock_eiger_detector.read() - print(6) await mock_eiger_detector.unstage() - print(7) set_mock_value(mock_eiger_detector.data_logic.fileio.array_counter, 0) set_mock_value(mock_eiger_detector.driver.num_images_counter, 0) # Case 2 - Fly Scan: prepare, kickoff, complete @@ -612,31 +591,39 @@ async def test_eiger_detector_with_RE( RE: RunEngine, tiled_client: Container, mock_eiger_detector: EigerDetector ) -> None: RE.subscribe(print) - set_mock_value(mock_eiger_detector.fileio.array_counter, 0) + set_mock_value(mock_eiger_detector.data_logic.fileio.array_counter, 0) - async def _write_file(value: bool, wait: bool) -> None: + async def _write_file(value: bool) -> None: if value: - num_images = await mock_eiger_detector.driver.num_images.get_value() - sequence_id = await mock_eiger_detector.fileio.sequence_id.get_value() + 1 - set_mock_value(mock_eiger_detector.fileio.sequence_id, sequence_id) + sequence_id = await mock_eiger_detector.data_logic.fileio.sequence_id.get_value() + 1 + set_mock_value(mock_eiger_detector.data_logic.fileio.sequence_id, sequence_id) await asyncio.sleep( await mock_eiger_detector.driver.acquire_period.get_value() ) + + num_images = await mock_eiger_detector.driver.num_images.get_value() write_eiger_hdf5_file( num_images=num_images, sequence_id=sequence_id, name="test_eiger", ) - array_counter = await mock_eiger_detector.fileio.array_counter.get_value() + + num_images_counter = await mock_eiger_detector.driver.num_images_counter.get_value() + set_mock_value(mock_eiger_detector.driver.num_images_counter, num_images_counter + num_images) + + array_counter = await mock_eiger_detector.data_logic.fileio.array_counter.get_value() + print(array_counter) + print(num_images) set_mock_value( - mock_eiger_detector.fileio.array_counter, array_counter + num_images + mock_eiger_detector.data_logic.fileio.array_counter, array_counter + num_images ) + set_mock_value(mock_eiger_detector.data_logic.fileio.armed, value) tiled_writer = TiledWriter(tiled_client) RE.subscribe(tiled_writer) - callback_on_mock_put(mock_eiger_detector.driver.acquire, _write_file) + callback_on_mock_put(mock_eiger_detector.driver.trigger, _write_file) - set_mock_value(mock_eiger_detector.fileio.sequence_id, 0) + set_mock_value(mock_eiger_detector.data_logic.fileio.sequence_id, 0) set_mock_value(mock_eiger_detector.driver.num_images, 1) set_mock_value(mock_eiger_detector.driver.acquire_period, 0.001) uid = RE(bp.count([mock_eiger_detector])) @@ -744,7 +731,7 @@ async def _write_file(value: bool, wait: bool) -> None: # == np.uint32 # ) - set_mock_value(mock_eiger_detector.fileio.sequence_id, 2) + set_mock_value(mock_eiger_detector.data_logic.fileio.sequence_id, 2) set_mock_value(mock_eiger_detector.driver.num_images, 1) set_mock_value(mock_eiger_detector.driver.acquire_period, 0.001) uid = RE(bp.count([mock_eiger_detector], num=10)) From ef65cac5b01cbed8adc19bbceaaa920cfff9ae33 Mon Sep 17 00:00:00 2001 From: Dan Henriksen Date: Wed, 17 Jun 2026 16:17:42 -0400 Subject: [PATCH 10/13] removed debug prints and commented out code --- tests/test_eiger_async.py | 21 --------------------- 1 file changed, 21 deletions(-) diff --git a/tests/test_eiger_async.py b/tests/test_eiger_async.py index b1c2a3f..ed53fce 100644 --- a/tests/test_eiger_async.py +++ b/tests/test_eiger_async.py @@ -16,7 +16,6 @@ import pytest_asyncio from bluesky.callbacks.tiled_writer import TiledWriter from bluesky.run_engine import RunEngine -from event_model import StreamDatum, StreamResource from ophyd_async.core import ( DetectorTrigger, PathProvider, @@ -365,21 +364,6 @@ async def test_eiger_writer_collect_stream_docs( ) -> None: """Test collecting stream documents.""" - # async def collect_docs( - # num_triggers: int, - # ) -> tuple[list[StreamResource], list[StreamDatum]]: - # for i in range(1, num_triggers + 1): - # sequence_id = await mock_eiger_driver.sequence_id.get_value() - # set_mock_value(mock_eiger_driver.sequence_id, sequence_id + 1) - # async for doc_type, doc in eiger_writer.collect_stream_docs( - # name="", indices_written=i - # ): - # if doc_type == "stream_resource": - # resource_docs.append(doc) - # elif doc_type == "stream_datum": - # data_docs.append(doc) - # return resource_docs, data_docs - set_mock_value(mock_eiger_driver.sequence_id, 0) set_mock_value(mock_eiger_driver.num_images, 1) @@ -412,13 +396,10 @@ async def test_eiger_writer_collect_stream_docs( set_mock_value(mock_eiger_driver.sequence_id, i + 1) set_mock_value(mock_eiger_driver.array_counter, i) async for doc_type, doc in provider2.make_stream_docs(i, i): - print(doc_type, doc) if doc_type == "stream_resource": resource_docs.append(doc) elif doc_type == "stream_datum": - print('appending') data_docs.append(doc) - print(await provider2.collections_written_signal.get_value()) assert len(resource_docs) == 1 assert len(data_docs) == 1 assert await provider2.collections_written_signal.get_value() == 3 @@ -612,8 +593,6 @@ async def _write_file(value: bool) -> None: set_mock_value(mock_eiger_detector.driver.num_images_counter, num_images_counter + num_images) array_counter = await mock_eiger_detector.data_logic.fileio.array_counter.get_value() - print(array_counter) - print(num_images) set_mock_value( mock_eiger_detector.data_logic.fileio.array_counter, array_counter + num_images ) From b98e27922b882a2766e0c5f94889aa9888a26992 Mon Sep 17 00:00:00 2001 From: Dan Henriksen Date: Wed, 17 Jun 2026 19:39:57 -0400 Subject: [PATCH 11/13] fixes arraycount_rbv bug; when data source is file_writer, the eiger2 buffers data until disarm() --- 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 180a387..29d3c1d 100644 --- a/src/cditools/eiger_async.py +++ b/src/cditools/eiger_async.py @@ -391,7 +391,7 @@ async def prepare_unbounded(self, datakey_name: str) -> StreamResourceDataProvid self.fileio.manual_trigger.set(True), # TODO sort out how to get this from the plan self.fileio.num_triggers.set(5000), - self.fileio.data_source.set(EigerDataSource.FILE_WRITER) + self.fileio.data_source.set(EigerDataSource.STREAM) ) await set_and_wait_for_other_value( From c4bff6b6fd9ec9a633667359b0bb261c43af52ef Mon Sep 17 00:00:00 2001 From: Dan Henriksen Date: Thu, 18 Jun 2026 14:51:35 -0400 Subject: [PATCH 12/13] updated tests and data source to be stream --- src/cditools/eiger_async.py | 2 +- tests/test_eiger_async.py | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/cditools/eiger_async.py b/src/cditools/eiger_async.py index 29d3c1d..8c70d7c 100644 --- a/src/cditools/eiger_async.py +++ b/src/cditools/eiger_async.py @@ -448,7 +448,7 @@ async def prepare_unbounded(self, datakey_name: str) -> StreamResourceDataProvid "dataset": f"entry/data/data_{1:06d}", }, # TODO put in better value; should it match EigerDataSource.FILE_WRITER? - source="EIGER2_FILE_WRITER", + source=EigerDataSource.STREAM, ) ], mimetype="application/x-hdf5", diff --git a/tests/test_eiger_async.py b/tests/test_eiger_async.py index ed53fce..9c5bab1 100644 --- a/tests/test_eiger_async.py +++ b/tests/test_eiger_async.py @@ -204,7 +204,7 @@ async def test_eiger_data_logic_prepare_unbounded( assert await mock_eiger_driver.save_files.get_value() is True # TODO data_key should probably match datakey_name actually assert streamDataProv.resources[0].data_key == "eiger_image" - assert streamDataProv.resources[0].source == "EIGER2_FILE_WRITER" + assert streamDataProv.resources[0].source == "STREAM" # Case 2: 4 images per file, 11 images, 2 triggers # Expect 6 files, the first 5 will have 4 images, the last will have 2 @@ -215,7 +215,7 @@ async def test_eiger_data_logic_prepare_unbounded( assert streamResourceProv.data_key == "eiger_image" assert streamResourceProv.shape == (11, array_size_x, array_size_y) assert streamResourceProv.dtype_numpy == np.dtype(np.uint32).str - assert streamResourceProv.source == "EIGER2_FILE_WRITER" + assert streamResourceProv.source == "STREAM" @pytest.mark.asyncio From b65c0b264f76527fa3445ab5e4f3c3f001e3058e Mon Sep 17 00:00:00 2001 From: Dan Henriksen Date: Thu, 18 Jun 2026 15:28:05 -0400 Subject: [PATCH 13/13] removed unused imports --- src/cditools/eiger_async.py | 1 - src/cditools/screens.py | 1 - 2 files changed, 2 deletions(-) diff --git a/src/cditools/eiger_async.py b/src/cditools/eiger_async.py index 8c70d7c..2de72d8 100644 --- a/src/cditools/eiger_async.py +++ b/src/cditools/eiger_async.py @@ -40,7 +40,6 @@ observe_value, set_and_wait_for_other_value, ) -from ophyd_async.core._data_providers import StreamableDataProvider from ophyd_async.core._status import WatchableAsyncStatus from ophyd_async.core._utils import ( DEFAULT_TIMEOUT, diff --git a/src/cditools/screens.py b/src/cditools/screens.py index 7fcd69b..d2e22c8 100644 --- a/src/cditools/screens.py +++ b/src/cditools/screens.py @@ -7,7 +7,6 @@ Device, EpicsMotor, EpicsSignal, - ImagePlugin, ProsilicaDetector, ProsilicaDetectorCam, ROIPlugin,