Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/cd.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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.13.0

publish:
needs: [dist]
Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
1 change: 0 additions & 1 deletion .pre-commit-config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
7 changes: 5 additions & 2 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -31,7 +31,7 @@ classifiers = [
dynamic = ["version"]
dependencies = [
"ophyd",
"ophyd-async[ca] ==0.17a2",
"ophyd-async[ca] >=0.19",
"h5py",
]

Expand Down Expand Up @@ -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
Expand Down
93 changes: 42 additions & 51 deletions src/cditools/eiger_async.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,39 +3,30 @@
"""

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,
DetectorAcquireLogic,
DetectorDataLogic,
DetectorTriggerLogic,
PathInfo,
PathProvider,
SignalDatatypeT,
SignalR,
SignalRW,
StreamResourceDataProvider,
StreamResourceInfo,
StrictEnum,
SubsetEnum,
TriggerInfo,
observe_value,
set_and_wait_for_other_value,
Expand All @@ -46,6 +37,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__)

Expand Down Expand Up @@ -299,7 +299,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
"""
Expand Down Expand Up @@ -354,6 +354,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,
Expand All @@ -370,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(self.fileio.parent.name)
self._master_file_path_cache.clear()

# Set the name pattern with $id replacement similar to original
Expand All @@ -390,7 +390,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(
Expand Down Expand Up @@ -428,26 +428,21 @@ 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.
return StreamResourceDataProvider(
uri=urlunparse(("file", "localhost", str(mfp), "", "", None)),
resources=[
StreamResourceInfo(
data_key=f"{name}_image",
shape=(exposures_per_event, *shape),
data_key=datakey_name,
shape=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; should it match EigerDataSource.FILE_WRITER?
source=EigerDataSource.STREAM,
source="eiger",
)
],
mimetype="application/x-hdf5",
Expand Down Expand Up @@ -490,7 +485,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
):
Expand All @@ -503,22 +498,27 @@ 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

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

async def disarm(self):
async def ensure_stopped(self):
self._rolling_image_counter = 0
await stop_busy_record(self.driver.acquire)

Expand All @@ -542,16 +542,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)
Expand All @@ -569,16 +568,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:
Expand Down Expand Up @@ -616,8 +607,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,
Expand Down
4 changes: 2 additions & 2 deletions src/cditools/merlin.py
Original file line number Diff line number Diff line change
Expand Up @@ -129,7 +129,7 @@ class CDIMerlinDetector(CDIModalTrigger, MerlinDetector):
"HDF1:",
read_attrs=[],
configuration_attrs=[],
write_path_template = '',
write_path_template="",
root="/nsls2/data/cdi/proposals/",
)

Expand Down Expand Up @@ -207,4 +207,4 @@ def stage(self):
return super().stage()

def unstage(self):
return super().unstage()
return super().unstage()
3 changes: 2 additions & 1 deletion src/cditools/merlin_async.py
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ class MerlinTriggerMode(StrictEnum):
TRIGGER_BOTH_RISING = "Trigger both rising"
SOFTWARE = "Software"


class MerlinTriggerModeRBV(StrictEnum):
"""Trigger modes for the Merlin detector"""

Expand Down Expand Up @@ -128,4 +129,4 @@ def __init__(
plugins=plugins,
config_sigs=config_sigs,
name=name,
)
)
3 changes: 2 additions & 1 deletion src/cditools/simulated/black_hole.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Loading
Loading