From 4e573a9e62de1b31bce32b8f863310555dd16a5d Mon Sep 17 00:00:00 2001 From: Dan Henriksen Date: Thu, 18 Jun 2026 16:09:17 -0400 Subject: [PATCH 01/15] updated build-and-inspect-python-package with sha so build should work now --- .github/workflows/cd.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/cd.yml b/.github/workflows/cd.yml index e494637e..0f5331db 100644 --- a/.github/workflows/cd.yml +++ b/.github/workflows/cd.yml @@ -30,7 +30,7 @@ jobs: fetch-depth: 0 persist-credentials: false - - uses: hynek/build-and-inspect-python-package@c52c3a4710070b50470d903818a7b25115dcd076 # v2.13.0 + - uses: hynek/build-and-inspect-python-package@d44ca7d91762de7a7d5436ddae667c6da6d1c3df# v2.18.0 publish: needs: [dist] From b014ba5b0af0ad3dd1b3bcaa827457a45d7d7c1f Mon Sep 17 00:00:00 2001 From: Dan Henriksen Date: Thu, 18 Jun 2026 16:10:24 -0400 Subject: [PATCH 02/15] fixed space --- .github/workflows/cd.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/cd.yml b/.github/workflows/cd.yml index 0f5331db..8e4bd9a9 100644 --- a/.github/workflows/cd.yml +++ b/.github/workflows/cd.yml @@ -30,7 +30,7 @@ jobs: fetch-depth: 0 persist-credentials: false - - uses: hynek/build-and-inspect-python-package@d44ca7d91762de7a7d5436ddae667c6da6d1c3df# v2.18.0 + - uses: hynek/build-and-inspect-python-package@d44ca7d91762de7a7d5436ddae667c6da6d1c3df # v2.13.0 publish: needs: [dist] From 7d8683e3f95d95092429a0c39bd34558522b3c75 Mon Sep 17 00:00:00 2001 From: Dan Henriksen Date: Thu, 18 Jun 2026 16:13:01 -0400 Subject: [PATCH 03/15] STY: fixed style issues --- src/cditools/eiger_async.py | 14 ++++++++++---- src/cditools/merlin.py | 4 ++-- src/cditools/merlin_async.py | 3 ++- 3 files changed, 14 insertions(+), 7 deletions(-) diff --git a/src/cditools/eiger_async.py b/src/cditools/eiger_async.py index 2de72d88..afef3f86 100644 --- a/src/cditools/eiger_async.py +++ b/src/cditools/eiger_async.py @@ -10,6 +10,7 @@ from urllib.parse import urlunparse from pathlib import Path from logging import getLogger + from typing import Annotated as A import numpy as np @@ -390,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.STREAM) + self.fileio.data_source.set(EigerDataSource.STREAM), ) await set_and_wait_for_other_value( @@ -509,12 +510,17 @@ 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()) + 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): + 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 diff --git a/src/cditools/merlin.py b/src/cditools/merlin.py index 8bfae083..843dac4b 100644 --- a/src/cditools/merlin.py +++ b/src/cditools/merlin.py @@ -129,7 +129,7 @@ class CDIMerlinDetector(CDIModalTrigger, MerlinDetector): "HDF1:", read_attrs=[], configuration_attrs=[], - write_path_template = '', + write_path_template="", root="/nsls2/data/cdi/proposals/", ) @@ -207,4 +207,4 @@ def stage(self): return super().stage() def unstage(self): - return super().unstage() \ No newline at end of file + return super().unstage() diff --git a/src/cditools/merlin_async.py b/src/cditools/merlin_async.py index 7d0a2ef7..11721849 100644 --- a/src/cditools/merlin_async.py +++ b/src/cditools/merlin_async.py @@ -46,6 +46,7 @@ class MerlinTriggerMode(StrictEnum): TRIGGER_BOTH_RISING = "Trigger both rising" SOFTWARE = "Software" + class MerlinTriggerModeRBV(StrictEnum): """Trigger modes for the Merlin detector""" @@ -128,4 +129,4 @@ def __init__( plugins=plugins, config_sigs=config_sigs, name=name, - ) \ No newline at end of file + ) From 5e78e3e32e7d341d042b6e6aa7e38e2a5f149472 Mon Sep 17 00:00:00 2001 From: Thomas A Caswell Date: Thu, 25 Jun 2026 14:44:30 -0400 Subject: [PATCH 04/15] MNT: remove irrelevant check We do not have a requirements.txt in this repo --- .pre-commit-config.yaml | 1 - 1 file changed, 1 deletion(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 66389482..99ce14b2 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -24,7 +24,6 @@ repos: - id: mixed-line-ending - id: name-tests-test args: ["--pytest-test-first"] - - id: requirements-txt-fixer - id: trailing-whitespace - repo: https://github.com/rbubley/mirrors-prettier From 8ba3341d283010a8a753f504e328a925c87f18d9 Mon Sep 17 00:00:00 2001 From: Thomas A Caswell Date: Thu, 25 Jun 2026 14:44:58 -0400 Subject: [PATCH 05/15] STY: Adjust ruff's style views --- pyproject.toml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/pyproject.toml b/pyproject.toml index 1d015edd..3b820977 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -150,6 +150,9 @@ extend-select = [ ignore = [ "PLR09", # Too many <...> "PLR2004", # Magic value used in comparison + "EM102", # It is fine to use and fstring literal in an error message + "RET504", # do not worry about assiging locals before returning + "SIM108", # ternary is not always clearer ] isort.required-imports = ["from __future__ import annotations"] # Uncomment if using a _compat.typing backport From 5cc3e141485b7994fd2cf7e5eaf6ac5d120ac726 Mon Sep 17 00:00:00 2001 From: Thomas A Caswell Date: Thu, 25 Jun 2026 14:45:56 -0400 Subject: [PATCH 06/15] TST: comment out bodies of skipped tests These are never run and not complete, but are tripping the linters. --- tests/test_eiger_async.py | 66 +++++++++++++++++++++------------------ 1 file changed, 36 insertions(+), 30 deletions(-) diff --git a/tests/test_eiger_async.py b/tests/test_eiger_async.py index 9c5bab1d..2772215f 100644 --- a/tests/test_eiger_async.py +++ b/tests/test_eiger_async.py @@ -344,17 +344,19 @@ async def _complete(): 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.prepare_unbounded(datakey_name="test_eiger") - observed = await _simulate_writing_indices( - num_images=num_images, num_triggers=num_triggers - ) - assert observed == [0, 0, 0, 0, 0, 1, 1, 1, 1, 1, 2] +) -> 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.prepare_unbounded(datakey_name="test_eiger") +# observed = await _simulate_writing_indices( +# num_images=num_images, num_triggers=num_triggers +# ) +# assert observed == [0, 0, 0, 0, 0, 1, 1, 1, 1, 1, 2] @pytest.mark.asyncio @@ -482,25 +484,29 @@ async def test_eiger_controller_prepare_edge(eiger_controller: EigerController) @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, - deadtime=0.0, - trigger=DetectorTrigger.EDGE_TRIGGER, - exposure_timeout=10.0, - exposures_per_event=1, - ) - 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() - == EigerTriggerMode.EXTERNAL_SERIES - ) - assert await eiger_controller.driver.num_images.get_value() == 1 - assert ( - await eiger_controller.driver.image_mode.get_value() == ADImageMode.CONTINUOUS - ) +async def test_eiger_controller_prepare_edge2( + eiger_controller: EigerController, +) -> None: ... + + +# trigger_info = TriggerInfo( +# number_of_events=0, +# livetime=None, +# deadtime=0.0, +# trigger=DetectorTrigger.EDGE_TRIGGER, +# exposure_timeout=10.0, +# exposures_per_event=1, +# ) +# 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() +# == EigerTriggerMode.EXTERNAL_SERIES +# ) +# assert await eiger_controller.driver.num_images.get_value() == 1 +# assert ( +# await eiger_controller.driver.image_mode.get_value() == ADImageMode.CONTINUOUS +# ) @pytest.mark.asyncio From 7bf29e9e6fc70eb4dbd42d5b345768d7bda29cb0 Mon Sep 17 00:00:00 2001 From: Thomas A Caswell Date: Thu, 25 Jun 2026 14:46:30 -0400 Subject: [PATCH 07/15] STY: run ruff over codebase --- src/cditools/eiger_async.py | 39 +++++++++++------------ tests/test_eiger_async.py | 63 ++++++++++++++++++++++++------------- 2 files changed, 61 insertions(+), 41 deletions(-) diff --git a/src/cditools/eiger_async.py b/src/cditools/eiger_async.py index afef3f86..3276cb18 100644 --- a/src/cditools/eiger_async.py +++ b/src/cditools/eiger_async.py @@ -3,31 +3,18 @@ """ from __future__ import annotations + 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 -import numpy as np +from urllib.parse import urlunparse -from ophyd_async.epics.adcore import ( - ADBaseIO, - NDFileIO, - ADImageMode, - AreaDetector, - NDPluginBaseIO, - trigger_info_from_num_images, -) -from ophyd_async.epics.core import PvSuffix, stop_busy_record +import numpy as np from ophyd_async.core import ( - SignalR, - SignalRW, - StrictEnum, - SubsetEnum, AsyncStatus, DetectorArmLogic, DetectorDataLogic, @@ -35,8 +22,12 @@ PathInfo, PathProvider, SignalDatatypeT, + SignalR, + SignalRW, StreamResourceDataProvider, StreamResourceInfo, + StrictEnum, + SubsetEnum, TriggerInfo, observe_value, set_and_wait_for_other_value, @@ -47,6 +38,15 @@ WatcherUpdate, error_if_none, ) +from ophyd_async.epics.adcore import ( + ADBaseIO, + ADImageMode, + AreaDetector, + NDFileIO, + NDPluginBaseIO, + trigger_info_from_num_images, +) +from ophyd_async.epics.core import PvSuffix, stop_busy_record logger = getLogger(__name__) @@ -300,7 +300,7 @@ def get_deadtime(self, exposure: float | None) -> float: ) return default_deadtime - async def prepare_internal(self, num: int, livetime: float, deadtime: float): + async def prepare_internal(self, num: int, livetime: float, deadtime: float): # noqa: ARG002 """Prepare the detector for acquisition. https://areadetector.github.io/areaDetector/ADEiger/eiger.html#implementation-of-standard-driver-parameters """ @@ -371,8 +371,7 @@ def __init__( 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 - self._file_info = self._path_provider("eiger2-1") + self._file_info = self._path_provider(datakey_name) self._master_file_path_cache.clear() # Set the name pattern with $id replacement similar to original diff --git a/tests/test_eiger_async.py b/tests/test_eiger_async.py index 2772215f..18476350 100644 --- a/tests/test_eiger_async.py +++ b/tests/test_eiger_async.py @@ -229,9 +229,7 @@ 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.prepare_unbounded( - datakey_name="test_eiger" - ) + 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) assert await eiger_writer.get_indices_written() == 1 @@ -239,9 +237,7 @@ 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.prepare_unbounded( - datakey_name="test_eiger" - ) + 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) assert await eiger_writer.get_indices_written() == 1 @@ -261,9 +257,7 @@ async def test_eiger_writer_get_indices_written_multi_images( # 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.prepare_unbounded( - datakey_name="test_eiger" - ) + 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) assert await eiger_writer.get_indices_written() == 0 @@ -425,6 +419,7 @@ async def test_eiger_writer_stop( await eiger_writer.stop() assert eiger_writer._file_info is None # type: ignore[reportPrivateUsage] + @pytest.mark.asyncio async def test_eiger_prepare(mock_eiger_detector: EigerDetector) -> None: trigger_info = TriggerInfo( @@ -444,7 +439,9 @@ async def test_eiger_prepare(mock_eiger_detector: EigerDetector) -> None: ) # 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.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 @@ -458,9 +455,12 @@ async def test_eiger_prepare(mock_eiger_detector: EigerDetector) -> None: exposure_timeout=10.0, ) + @pytest.mark.skip("What should `num` do in `prepare_internal`?") @pytest.mark.asyncio -async def test_eiger_controller_prepare_internal(eiger_controller: EigerController) -> None: +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 ( @@ -470,6 +470,7 @@ async def test_eiger_controller_prepare_internal(eiger_controller: EigerControll assert await eiger_controller.driver.num_triggers.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) @@ -518,10 +519,18 @@ async def test_eiger_detector(mock_eiger_detector: EigerDetector) -> 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) + 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.trigger, _simulate_one_trigger) @@ -582,8 +591,12 @@ async def test_eiger_detector_with_RE( async def _write_file(value: bool) -> None: if value: - 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) + 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() ) @@ -595,12 +608,20 @@ async def _write_file(value: bool) -> None: name="test_eiger", ) - 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) + 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() + 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 + 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) From d1c57c17de98c29300cf443f0d7cebe6a2fa51fd Mon Sep 17 00:00:00 2001 From: Thomas A Caswell Date: Thu, 25 Jun 2026 17:20:04 -0400 Subject: [PATCH 08/15] FIX: eiger tests and some internal TODOs --- src/cditools/eiger_async.py | 11 ++++------- tests/test_eiger_async.py | 32 ++++++++++++++++++-------------- 2 files changed, 22 insertions(+), 21 deletions(-) diff --git a/src/cditools/eiger_async.py b/src/cditools/eiger_async.py index 3276cb18..94edb21d 100644 --- a/src/cditools/eiger_async.py +++ b/src/cditools/eiger_async.py @@ -355,6 +355,7 @@ class EigerDataLogic(DetectorDataLogic): 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 + datakey_suffix: str = "_image" def __init__( self, @@ -371,7 +372,7 @@ def __init__( 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 - self._file_info = self._path_provider(datakey_name) + self._file_info = self._path_provider(self.fileio.parent.name) self._master_file_path_cache.clear() # Set the name pattern with $id replacement similar to original @@ -428,9 +429,6 @@ async def prepare_unbounded(self, datakey_name: str) -> StreamResourceDataProvid shape = [x for x in shape if x > 0] 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() # TODO sort out how to tell tiled about the additional data files. @@ -438,7 +436,7 @@ async def prepare_unbounded(self, datakey_name: str) -> StreamResourceDataProvid uri=urlunparse(("file", "localhost", str(mfp), "", "", None)), resources=[ StreamResourceInfo( - data_key=f"{name}_image", + data_key=datakey_name, shape=(exposures_per_event, *shape), # TODO sort out how to set this and mirror here chunk_shape=(1, *shape), @@ -446,8 +444,7 @@ async def prepare_unbounded(self, datakey_name: str) -> StreamResourceDataProvid parameters={ "dataset": f"entry/data/data_{1:06d}", }, - # TODO put in better value; should it match EigerDataSource.FILE_WRITER? - source=EigerDataSource.STREAM, + source='eiger', ) ], mimetype="application/x-hdf5", diff --git a/tests/test_eiger_async.py b/tests/test_eiger_async.py index 18476350..d7fa1068 100644 --- a/tests/test_eiger_async.py +++ b/tests/test_eiger_async.py @@ -199,23 +199,23 @@ async def test_eiger_data_logic_prepare_unbounded( set_mock_value(mock_eiger_driver.sequence_id, 0) set_mock_value(mock_eiger_driver.num_images, 1) - streamDataProv = await eiger_writer.prepare_unbounded(datakey_name="test_eiger") + streamDataProv = await eiger_writer.prepare_unbounded(datakey_name="test_eiger_image") assert await mock_eiger_driver.fw_enable.get_value() is True 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 == "STREAM" + assert streamDataProv.resources[0].data_key == "test_eiger_image" + assert streamDataProv.resources[0].source == "eiger" # 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) - streamDataProv = await eiger_writer.prepare_unbounded(datakey_name="test_eiger") + streamDataProv = await eiger_writer.prepare_unbounded(datakey_name="test_eiger_image") streamResourceProv = streamDataProv.resources[0] - assert streamResourceProv.data_key == "eiger_image" + assert streamResourceProv.data_key == "test_eiger_image" assert streamResourceProv.shape == (11, array_size_x, array_size_y) assert streamResourceProv.dtype_numpy == np.dtype(np.uint32).str - assert streamResourceProv.source == "STREAM" + assert streamResourceProv.source == "eiger" @pytest.mark.asyncio @@ -553,11 +553,11 @@ async def _simulate_one_trigger(value: bool) -> None: await mock_eiger_detector.trigger() assert ( await mock_eiger_detector.data_logic.fileio.data_source.get_value() - == EigerDataSource.FILE_WRITER + == EigerDataSource.STREAM ) assert ( await mock_eiger_detector.driver.data_source.get_value() - == EigerDataSource.FILE_WRITER + == EigerDataSource.STREAM ) await mock_eiger_detector.read() await mock_eiger_detector.trigger() @@ -592,7 +592,7 @@ async def test_eiger_detector_with_RE( async def _write_file(value: bool) -> None: if value: sequence_id = ( - await mock_eiger_detector.data_logic.fileio.sequence_id.get_value() + 1 + await mock_eiger_detector.data_logic.fileio.sequence_id.get_value() ) set_mock_value( mock_eiger_detector.data_logic.fileio.sequence_id, sequence_id @@ -603,7 +603,7 @@ async def _write_file(value: bool) -> None: num_images = await mock_eiger_detector.driver.num_images.get_value() write_eiger_hdf5_file( - num_images=num_images, + num_images=num_images * NUM_STEPS, sequence_id=sequence_id, name="test_eiger", ) @@ -632,12 +632,14 @@ async def _write_file(value: bool) -> None: 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) + NUM_STEPS = 1 uid = RE(bp.count([mock_eiger_detector])) assert uid is not None assert ( tiled_client.values().last()["primary"]["test_eiger_image"].read() is not None ) assert tiled_client.values().last()["primary"]["test_eiger_image"].shape == ( + 1, 1, 2048, 2048, @@ -740,19 +742,21 @@ async def _write_file(value: bool) -> None: 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)) + NUM_STEPS = 10 + uid = RE(bp.count([mock_eiger_detector], num=NUM_STEPS)) assert uid is not None - assert ( - tiled_client.values().last()["primary"]["test_eiger_image"].read() is not None - ) assert tiled_client.values().last()["primary"]["test_eiger_image"].shape == ( 10, + 1, 2048, 2048, ) assert ( tiled_client.values().last()["primary"]["test_eiger_image"].dtype == np.uint32 ) + assert ( + tiled_client.values().last()["primary"]["test_eiger_image"].read() is not None + ) # TODO: Add these when empty shape datasets are supported by tiled # assert tiled_client.values().last()["primary"]["test_eiger_x_pixel_size"].read() is not None # assert tiled_client.values().last()["primary"][ From 1e79c1201eda4a14df433d4ff02518367348a601 Mon Sep 17 00:00:00 2001 From: Thomas A Caswell Date: Thu, 25 Jun 2026 17:39:05 -0400 Subject: [PATCH 09/15] BLD: update ophyd-async pin --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 3b820977..d6bf9ccd 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.19", "h5py", ] From b3a9380830903243c2db8c46e1da966ee4b565b0 Mon Sep 17 00:00:00 2001 From: Thomas A Caswell Date: Thu, 25 Jun 2026 17:58:04 -0400 Subject: [PATCH 10/15] API: account from ophyd-async API changes I advocated for these! --- src/cditools/eiger_async.py | 19 +++++++++---------- 1 file changed, 9 insertions(+), 10 deletions(-) diff --git a/src/cditools/eiger_async.py b/src/cditools/eiger_async.py index 94edb21d..46504c62 100644 --- a/src/cditools/eiger_async.py +++ b/src/cditools/eiger_async.py @@ -16,7 +16,7 @@ import numpy as np from ophyd_async.core import ( AsyncStatus, - DetectorArmLogic, + DetectorAcquireLogic, DetectorDataLogic, DetectorTriggerLogic, PathInfo, @@ -372,7 +372,7 @@ def __init__( 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 - self._file_info = self._path_provider(self.fileio.parent.name) + self._file_info = self._path_provider(datakey_name) self._master_file_path_cache.clear() # Set the name pattern with $id replacement similar to original @@ -487,7 +487,7 @@ async def stop(self) -> None: # TODO sort out if ths is the right name of things -class EigerArmLogic(DetectorArmLogic): +class EigerAcquireLogic(DetectorAcquireLogic): def __init__( self, driver: Eiger2DriverIO, driver_armed_signal: SignalR[bool] | None = None ): @@ -500,7 +500,7 @@ def __init__( self.acquire_status: AsyncStatus | None = None self._rolling_image_counter = 0 - async def arm(self): + async def start_acquiring(self): self._rolling_image_counter = await self.driver.num_images_counter.get_value() ret = await self.driver.trigger.set(1) return ret @@ -520,7 +520,7 @@ async def wait_for_idle(self): if images_complete == target_num_images: break - async def disarm(self): + async def ensure_stopped(self): self._rolling_image_counter = 0 await stop_busy_record(self.driver.acquire) @@ -544,16 +544,15 @@ def __init__( ): driver = Eiger2DriverIO(prefix + driver_suffix) controller = EigerController(driver) - arm_logic = EigerArmLogic(driver) + acquire_logic = EigerAcquireLogic(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, + acquire_logic=acquire_logic, ) self.data_logic = EigerDataLogic(fileio=driver, path_provider=path_provider) self.add_detector_logics(self.data_logic) @@ -618,8 +617,8 @@ def _logic_supported(base_class, method) -> bool: 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() + if self._acquire_logic: + await self._acquire_logic.start_acquiring() async for update in self._wait_for_index( data_providers=ctx.streamable_data_providers, From 11cf1cf5c6c45a756a91103ab017945d2203f9af Mon Sep 17 00:00:00 2001 From: Thomas A Caswell Date: Thu, 25 Jun 2026 18:31:26 -0400 Subject: [PATCH 11/15] FIX: correct string passed to path provider --- src/cditools/eiger_async.py | 14 +++----------- tests/test_eiger_async.py | 9 +++++++++ 2 files changed, 12 insertions(+), 11 deletions(-) diff --git a/src/cditools/eiger_async.py b/src/cditools/eiger_async.py index 46504c62..d2ced086 100644 --- a/src/cditools/eiger_async.py +++ b/src/cditools/eiger_async.py @@ -372,7 +372,7 @@ def __init__( 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 - self._file_info = self._path_provider(datakey_name) + self._file_info = self._path_provider(self.fileio.parent.name) self._master_file_path_cache.clear() # Set the name pattern with $id replacement similar to original @@ -570,16 +570,8 @@ async def trigger(self) -> AsyncIterator[WatcherUpdate[int]]: 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" - ) + # We always want to preserve state on the eiger + preserve_state = True if preserve_state and self._trigger_logic is not None: def _logic_supported(base_class, method) -> bool: diff --git a/tests/test_eiger_async.py b/tests/test_eiger_async.py index d7fa1068..d304856a 100644 --- a/tests/test_eiger_async.py +++ b/tests/test_eiger_async.py @@ -8,6 +8,7 @@ import shutil from collections.abc import AsyncGenerator, Generator from pathlib import Path +from dataclasses import dataclass import bluesky.plans as bp import h5py @@ -127,6 +128,14 @@ def mock_eiger_driver(RE: RunEngine) -> EigerDriverIO: with init_devices(mock=True): driver = EigerDriverIO("MOCK:EIGER:cam1:") + + + @dataclass + class Parent: + name : str + + driver.parent = Parent('eiger2-1') + # Set up some default mock values set_mock_value(driver.file_path_exists, True) set_mock_value(driver.array_size_x, 2048) From 920954eccf6ec92dec800ff0547da3f5fea1a2e5 Mon Sep 17 00:00:00 2001 From: Thomas A Caswell Date: Thu, 25 Jun 2026 18:48:54 -0400 Subject: [PATCH 12/15] FIX: the number of exposures per trigger handled higher in stack --- src/cditools/eiger_async.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/cditools/eiger_async.py b/src/cditools/eiger_async.py index d2ced086..a6aac005 100644 --- a/src/cditools/eiger_async.py +++ b/src/cditools/eiger_async.py @@ -429,7 +429,6 @@ async def prepare_unbounded(self, datakey_name: str) -> StreamResourceDataProvid shape = [x for x in shape if x > 0] mfp = await self._master_file_path - exposures_per_event = await self.fileio.num_images.get_value() # TODO sort out how to tell tiled about the additional data files. return StreamResourceDataProvider( @@ -437,7 +436,7 @@ async def prepare_unbounded(self, datakey_name: str) -> StreamResourceDataProvid resources=[ StreamResourceInfo( data_key=datakey_name, - shape=(exposures_per_event, *shape), + shape=shape, # TODO sort out how to set this and mirror here chunk_shape=(1, *shape), dtype_numpy=np.dtype(datatype.lower()).str, From 51c53574138e8c84ac92505939ab33aa8b15bfa7 Mon Sep 17 00:00:00 2001 From: Thomas A Caswell Date: Thu, 25 Jun 2026 19:40:13 -0400 Subject: [PATCH 13/15] STY: run ruff --- src/cditools/eiger_async.py | 3 +-- tests/test_eiger_async.py | 16 +++++++++------- 2 files changed, 10 insertions(+), 9 deletions(-) diff --git a/src/cditools/eiger_async.py b/src/cditools/eiger_async.py index a6aac005..8b405a73 100644 --- a/src/cditools/eiger_async.py +++ b/src/cditools/eiger_async.py @@ -6,7 +6,6 @@ import asyncio import functools -import os from collections.abc import AsyncGenerator, AsyncIterator, Sequence from logging import getLogger from pathlib import Path @@ -443,7 +442,7 @@ async def prepare_unbounded(self, datakey_name: str) -> StreamResourceDataProvid parameters={ "dataset": f"entry/data/data_{1:06d}", }, - source='eiger', + source="eiger", ) ], mimetype="application/x-hdf5", diff --git a/tests/test_eiger_async.py b/tests/test_eiger_async.py index d304856a..a16b8441 100644 --- a/tests/test_eiger_async.py +++ b/tests/test_eiger_async.py @@ -7,8 +7,8 @@ import asyncio import shutil from collections.abc import AsyncGenerator, Generator -from pathlib import Path from dataclasses import dataclass +from pathlib import Path import bluesky.plans as bp import h5py @@ -128,13 +128,11 @@ def mock_eiger_driver(RE: RunEngine) -> EigerDriverIO: with init_devices(mock=True): driver = EigerDriverIO("MOCK:EIGER:cam1:") - - @dataclass class Parent: - name : str + name: str - driver.parent = Parent('eiger2-1') + driver.parent = Parent("eiger2-1") # Set up some default mock values set_mock_value(driver.file_path_exists, True) @@ -208,7 +206,9 @@ async def test_eiger_data_logic_prepare_unbounded( set_mock_value(mock_eiger_driver.sequence_id, 0) set_mock_value(mock_eiger_driver.num_images, 1) - streamDataProv = await eiger_writer.prepare_unbounded(datakey_name="test_eiger_image") + streamDataProv = await eiger_writer.prepare_unbounded( + datakey_name="test_eiger_image" + ) assert await mock_eiger_driver.fw_enable.get_value() is True assert await mock_eiger_driver.save_files.get_value() is True # TODO data_key should probably match datakey_name actually @@ -219,7 +219,9 @@ async def test_eiger_data_logic_prepare_unbounded( # 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) - streamDataProv = await eiger_writer.prepare_unbounded(datakey_name="test_eiger_image") + streamDataProv = await eiger_writer.prepare_unbounded( + datakey_name="test_eiger_image" + ) streamResourceProv = streamDataProv.resources[0] assert streamResourceProv.data_key == "test_eiger_image" assert streamResourceProv.shape == (11, array_size_x, array_size_y) From f6db2928ba7dd91a30aa69bd8bb560e9d45a349c Mon Sep 17 00:00:00 2001 From: Thomas A Caswell Date: Thu, 25 Jun 2026 19:42:28 -0400 Subject: [PATCH 14/15] CI: bump Python versions --- .github/workflows/ci.yml | 2 +- pyproject.toml | 2 +- src/cditools/simulated/black_hole.py | 3 ++- 3 files changed, 4 insertions(+), 3 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b3693357..7b3a9a17 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -47,7 +47,7 @@ jobs: strategy: fail-fast: false matrix: - python-version: ["3.10", "3.11", "3.12", "3.13"] + python-version: ["3.11", "3.12", "3.13", "3.14"] runs-on: [ubuntu-latest] steps: diff --git a/pyproject.toml b/pyproject.toml index d6bf9ccd..f1e84609 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -10,7 +10,7 @@ authors = [ ] description = "CDI Tools Package" readme = "README.md" -requires-python = ">=3.9" +requires-python = ">=3.11" classifiers = [ "Development Status :: 1 - Planning", "Intended Audience :: Science/Research", diff --git a/src/cditools/simulated/black_hole.py b/src/cditools/simulated/black_hole.py index 8d3b7a03..9b64ad7c 100644 --- a/src/cditools/simulated/black_hole.py +++ b/src/cditools/simulated/black_hole.py @@ -2,7 +2,8 @@ import re from collections import defaultdict -from typing import Any, Callable +from collections.abc import Callable +from typing import Any from caproto import ( # type: ignore[import-not-found] ChannelChar, From 644fda7a3289801098b2da4b5d99be5fcde066bb Mon Sep 17 00:00:00 2001 From: Thomas A Caswell Date: Thu, 25 Jun 2026 19:46:44 -0400 Subject: [PATCH 15/15] TST: fix tests for updated shapes --- tests/test_eiger_async.py | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/tests/test_eiger_async.py b/tests/test_eiger_async.py index a16b8441..bfa8fa40 100644 --- a/tests/test_eiger_async.py +++ b/tests/test_eiger_async.py @@ -224,7 +224,7 @@ async def test_eiger_data_logic_prepare_unbounded( ) streamResourceProv = streamDataProv.resources[0] assert streamResourceProv.data_key == "test_eiger_image" - assert streamResourceProv.shape == (11, array_size_x, array_size_y) + assert streamResourceProv.shape == (array_size_x, array_size_y) assert streamResourceProv.dtype_numpy == np.dtype(np.uint32).str assert streamResourceProv.source == "eiger" @@ -649,8 +649,9 @@ async def _write_file(value: bool) -> None: assert ( tiled_client.values().last()["primary"]["test_eiger_image"].read() is not None ) + # this should be (1, 1, 2048, 2046) but I think there is a tiled/tiledwriter bug assert tiled_client.values().last()["primary"]["test_eiger_image"].shape == ( - 1, + # 1, 1, 2048, 2048, @@ -758,7 +759,8 @@ async def _write_file(value: bool) -> None: assert uid is not None assert tiled_client.values().last()["primary"]["test_eiger_image"].shape == ( 10, - 1, + # I think this is a tiled bug + # 1, 2048, 2048, )