Skip to content

Commit

Permalink
Merging main branch
Browse files Browse the repository at this point in the history
  • Loading branch information
burkeds committed Oct 8, 2024
2 parents b05024e + e291396 commit 592b6c1
Show file tree
Hide file tree
Showing 32 changed files with 323 additions and 321 deletions.
4 changes: 2 additions & 2 deletions docs/examples/foo_detector.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@

from ophyd_async.core import (
AsyncStatus,
DetectorControl,
DetectorController,
DetectorTrigger,
PathProvider,
StandardDetector,
Expand All @@ -19,7 +19,7 @@ def __init__(self, prefix: str, name: str = "") -> None:
super().__init__(prefix, name)


class FooController(DetectorControl):
class FooController(DetectorController):
def __init__(self, driver: FooDriver) -> None:
self._drv = driver

Expand Down
2 changes: 1 addition & 1 deletion docs/explanations/decisions/0007-subpackage-structure.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ There will be a flat public namespace under core, with contents reimported from
- `_signal.py` for `Signal`, `SignalBackend`, `observe_signal`, etc.
- `_mock.py` for `MockSignalBackend`, `get_mock_put`, etc.
- `_readable.py` for `StandardReadable`, `ConfigSignal`, `HintedSignal`, etc.
- `_detector.py` for `StandardDetector`, `DetectorWriter`, `DetectorControl`, `TriggerInfo`, etc.
- `_detector.py` for `StandardDetector`, `DetectorWriter`, `DetectorController`, `TriggerInfo`, etc.
- `_flyer.py` for `StandardFlyer`, `FlyerControl`, etc.

There are some renames that will be required, e.g. `HardwareTriggeredFlyable` -> `StandardFlyer`
Expand Down
10 changes: 5 additions & 5 deletions docs/how-to/make-a-standard-detector.rst
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ Make a StandardDetector
The `StandardDetector` is a simple compound device, with 2 standard components:

- `DetectorWriter` to handle data persistence, i/o and pass information about data to the RunEngine (usually an instance of :py:class:`ADHDFWriter`)
- `DetectorControl` with logic for arming and disarming the detector. This will be unique to the StandardDetector implementation.
- `DetectorController` with logic for arming and disarming the detector. This will be unique to the StandardDetector implementation.

Writing an AreaDetector StandardDetector
----------------------------------------
Expand All @@ -28,9 +28,9 @@ Enumeration fields should be named to prevent namespace collision, i.e. for a Si
:language: python
:pyobject: FooDriver

Define a :py:class:`FooController` with handling for converting the standard pattern of :py:meth:`ophyd_async.core.DetectorControl.arm` and :py:meth:`ophyd_async.core.DetectorControl.disarm` to required state of :py:class:`FooDriver` e.g. setting a compatible "FooTriggerSource" for a given `DetectorTrigger`, or raising an exception if incompatible with the `DetectorTrigger`.
Define a :py:class:`FooController` with handling for converting the standard pattern of :py:meth:`ophyd_async.core.DetectorController.arm` and :py:meth:`ophyd_async.core.DetectorController.disarm` to required state of :py:class:`FooDriver` e.g. setting a compatible "FooTriggerSource" for a given `DetectorTrigger`, or raising an exception if incompatible with the `DetectorTrigger`.

The :py:meth:`ophyd_async.core.DetectorControl.get_deadtime` method is used when constructing sequence tables for hardware controlled scanning. Details on how to calculate the deadtime may be only available from technical manuals or otherwise complex. **If it requires fetching from signals, it is recommended to cache the value during the StandardDetector `prepare` method.**
The :py:meth:`ophyd_async.core.DetectorController.get_deadtime` method is used when constructing sequence tables for hardware controlled scanning. Details on how to calculate the deadtime may be only available from technical manuals or otherwise complex. **If it requires fetching from signals, it is recommended to cache the value during the StandardDetector `prepare` method.**

.. literalinclude:: ../examples/foo_detector.py
:pyobject: FooController
Expand All @@ -47,8 +47,8 @@ If the :py:class:`FooDriver` signals that should be read as configuration, they
Writing a non-AreaDetector StandardDetector
-------------------------------------------

A non-AreaDetector `StandardDetector` should implement `DetectorControl` and `DetectorWriter` directly.
Here we construct a `DetectorControl` that co-ordinates signals on a PandA PositionCapture block - a child device "pcap" of the `StandardDetector` implementation, analogous to the :py:class:`FooDriver`.
A non-AreaDetector `StandardDetector` should implement `DetectorController` and `DetectorWriter` directly.
Here we construct a `DetectorController` that co-ordinates signals on a PandA PositionCapture block - a child device "pcap" of the `StandardDetector` implementation, analogous to the :py:class:`FooDriver`.

.. literalinclude:: ../../src/ophyd_async/fastcs/panda/_control.py
:pyobject: PandaPcapController
Expand Down
8 changes: 4 additions & 4 deletions src/ophyd_async/core/__init__.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
from ._detector import (
DetectorControl,
DetectorController,
DetectorTrigger,
DetectorWriter,
StandardDetector,
Expand All @@ -16,7 +16,7 @@
set_signal_values,
walk_rw_signals,
)
from ._flyer import StandardFlyer, TriggerLogic
from ._flyer import FlyerController, StandardFlyer
from ._hdf_dataset import HDFDataset, HDFFile
from ._log import config_ophyd_async_logging
from ._mock_signal_backend import MockSignalBackend
Expand Down Expand Up @@ -85,7 +85,7 @@
)

__all__ = [
"DetectorControl",
"DetectorController",
"DetectorTrigger",
"DetectorWriter",
"StandardDetector",
Expand All @@ -102,7 +102,7 @@
"set_signal_values",
"walk_rw_signals",
"StandardFlyer",
"TriggerLogic",
"FlyerController",
"HDFDataset",
"HDFFile",
"config_ophyd_async_logging",
Expand Down
111 changes: 72 additions & 39 deletions src/ophyd_async/core/_detector.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,9 @@
import asyncio
import time
from abc import ABC, abstractmethod
from collections.abc import AsyncGenerator, AsyncIterator, Callable, Sequence
from collections.abc import AsyncGenerator, AsyncIterator, Callable, Iterator, Sequence
from enum import Enum
from functools import cached_property
from typing import (
Generic,
)
Expand All @@ -20,7 +21,7 @@
WritesStreamAssets,
)
from event_model import DataKey
from pydantic import BaseModel, Field
from pydantic import BaseModel, Field, NonNegativeInt, computed_field

from ._device import Device
from ._protocol import AsyncConfigurable, AsyncReadable
Expand All @@ -45,8 +46,16 @@ class DetectorTrigger(str, Enum):
class TriggerInfo(BaseModel):
"""Minimal set of information required to setup triggering on a detector"""

#: Number of triggers that will be sent, 0 means infinite
number: int = Field(ge=0)
#: Number of triggers that will be sent, (0 means infinite) Can be:
# - A single integer or
# - A list of integers for multiple triggers
# Example for tomography: TriggerInfo(number=[2,3,100,3])
#: This would trigger:
#: - 2 times for dark field images
#: - 3 times for initial flat field images
#: - 100 times for projections
#: - 3 times for final flat field images
number_of_triggers: NonNegativeInt | list[NonNegativeInt]
#: Sort of triggers that will be sent
trigger: DetectorTrigger = Field(default=DetectorTrigger.internal)
#: What is the minimum deadtime between triggers
Expand All @@ -60,13 +69,18 @@ class TriggerInfo(BaseModel):
#: e.g. if num=10 and multiplier=5 then the detector will take 10 frames,
#: but publish 2 indices, and describe() will show a shape of (5, h, w)
multiplier: int = 1
#: The number of times the detector can go through a complete cycle of kickoff and
#: complete without needing to re-arm. This is important for detectors where the
#: process of arming is expensive in terms of time
iteration: int = 1

@computed_field
@cached_property
def total_number_of_triggers(self) -> int:
return (
sum(self.number_of_triggers)
if isinstance(self.number_of_triggers, list)
else self.number_of_triggers
)


class DetectorControl(ABC):
class DetectorController(ABC):
"""
Classes implementing this interface should hold the logic for
arming and disarming a detector
Expand Down Expand Up @@ -167,7 +181,7 @@ class StandardDetector(

def __init__(
self,
controller: DetectorControl,
controller: DetectorController,
writer: DetectorWriter,
config_sigs: Sequence[SignalR] = (),
name: str = "",
Expand All @@ -192,14 +206,18 @@ def __init__(
# For kickoff
self._watchers: list[Callable] = []
self._fly_status: WatchableAsyncStatus | None = None
self._fly_start: float
self._iterations_completed: int = 0
self._initial_frame: int
self._last_frame: int
self._fly_start: float | None = None
self._frames_to_complete: int = 0
# Represents the total number of frames that will have been completed at the
# end of the next `complete`.
self._completable_frames: int = 0
self._number_of_triggers_iter: Iterator[int] | None = None
self._initial_frame: int = 0

super().__init__(name)

@property
def controller(self) -> DetectorControl:
def controller(self) -> DetectorController:
return self._controller

@property
Expand Down Expand Up @@ -251,7 +269,7 @@ async def trigger(self) -> None:
if self._trigger_info is None:
await self.prepare(
TriggerInfo(
number=1,
number_of_triggers=1,
trigger=DetectorTrigger.internal,
deadtime=None,
livetime=None,
Expand Down Expand Up @@ -301,8 +319,12 @@ async def prepare(self, value: TriggerInfo) -> None:
f"but trigger logic provides only {value.deadtime}s"
)
self._trigger_info = value
self._number_of_triggers_iter = iter(
self._trigger_info.number_of_triggers
if isinstance(self._trigger_info.number_of_triggers, list)
else [self._trigger_info.number_of_triggers]
)
self._initial_frame = await self.writer.get_indices_written()
self._last_frame = self._initial_frame + self._trigger_info.number
self._describe, _ = await asyncio.gather(
self.writer.open(value.multiplier), self.controller.prepare(value)
)
Expand All @@ -312,39 +334,50 @@ async def prepare(self, value: TriggerInfo) -> None:

@AsyncStatus.wrap
async def kickoff(self):
assert self._trigger_info, "Prepare must be called before kickoff!"
if self._iterations_completed >= self._trigger_info.iteration:
raise Exception(
if self._trigger_info is None or self._number_of_triggers_iter is None:
raise RuntimeError("Prepare must be called before kickoff!")
try:
self._frames_to_complete = next(self._number_of_triggers_iter)
self._completable_frames += self._frames_to_complete
except StopIteration as err:
raise RuntimeError(
f"Kickoff called more than the configured number of "
f"{self._trigger_info.iteration} iteration(s)!"
)
self._iterations_completed += 1
f"{self._trigger_info.total_number_of_triggers} iteration(s)!"
) from err

@WatchableAsyncStatus.wrap
async def complete(self):
assert self._trigger_info
async for index in self.writer.observe_indices_written(
indices_written = self.writer.observe_indices_written(
self._trigger_info.frame_timeout
or (
DEFAULT_TIMEOUT
+ (self._trigger_info.livetime or 0)
+ (self._trigger_info.deadtime or 0)
)
):
yield WatcherUpdate(
name=self.name,
current=index,
initial=self._initial_frame,
target=self._trigger_info.number,
unit="",
precision=0,
time_elapsed=time.monotonic() - self._fly_start,
)
if index >= self._trigger_info.number:
break
if self._iterations_completed == self._trigger_info.iteration:
self._iterations_completed = 0
await self.controller.wait_for_idle()
)
try:
async for index in indices_written:
yield WatcherUpdate(
name=self.name,
current=index,
initial=self._initial_frame,
target=self._frames_to_complete,
unit="",
precision=0,
time_elapsed=time.monotonic() - self._fly_start
if self._fly_start
else None,
)
if index >= self._frames_to_complete:
break
finally:
await indices_written.aclose()
if self._completable_frames >= self._trigger_info.total_number_of_triggers:
self._completable_frames = 0
self._frames_to_complete = 0
self._number_of_triggers_iter = None
await self.controller.wait_for_idle()

async def describe_collect(self) -> dict[str, DataKey]:
return self._describe
Expand Down
6 changes: 3 additions & 3 deletions src/ophyd_async/core/_flyer.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
from ._utils import T


class TriggerLogic(ABC, Generic[T]):
class FlyerController(ABC, Generic[T]):
@abstractmethod
async def prepare(self, value: T):
"""Move to the start of the flyscan"""
Expand All @@ -35,14 +35,14 @@ class StandardFlyer(
):
def __init__(
self,
trigger_logic: TriggerLogic[T],
trigger_logic: FlyerController[T],
name: str = "",
):
self._trigger_logic = trigger_logic
super().__init__(name=name)

@property
def trigger_logic(self) -> TriggerLogic[T]:
def trigger_logic(self) -> FlyerController[T]:
return self._trigger_logic

@AsyncStatus.wrap
Expand Down
8 changes: 4 additions & 4 deletions src/ophyd_async/epics/adaravis/_aravis_controller.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
from typing import Literal

from ophyd_async.core import (
DetectorControl,
DetectorController,
DetectorTrigger,
TriggerInfo,
set_and_wait_for_value,
Expand All @@ -18,7 +18,7 @@
_HIGHEST_POSSIBLE_DEADTIME = 1961e-6


class AravisController(DetectorControl):
class AravisController(DetectorController):
GPIO_NUMBER = Literal[1, 2, 3, 4]

def __init__(self, driver: AravisDriverIO, gpio_number: GPIO_NUMBER) -> None:
Expand All @@ -30,7 +30,7 @@ def get_deadtime(self, exposure: float | None) -> float:
return _HIGHEST_POSSIBLE_DEADTIME

async def prepare(self, trigger_info: TriggerInfo):
if (num := trigger_info.number) == 0:
if trigger_info.total_number_of_triggers == 0:
image_mode = adcore.ImageMode.continuous
else:
image_mode = adcore.ImageMode.multiple
Expand All @@ -43,7 +43,7 @@ async def prepare(self, trigger_info: TriggerInfo):

await asyncio.gather(
self._drv.trigger_source.set(trigger_source),
self._drv.num_images.set(num),
self._drv.num_images.set(trigger_info.total_number_of_triggers),
self._drv.image_mode.set(image_mode),
)

Expand Down
4 changes: 2 additions & 2 deletions src/ophyd_async/epics/adcore/_core_logic.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
DEFAULT_TIMEOUT,
AsyncStatus,
DatasetDescriber,
DetectorControl,
DetectorController,
set_and_wait_for_value,
)
from ophyd_async.epics.adcore._utils import convert_ad_dtype_to_np
Expand Down Expand Up @@ -34,7 +34,7 @@ async def shape(self) -> tuple[int, int]:


async def set_exposure_time_and_acquire_period_if_supplied(
controller: DetectorControl,
controller: DetectorController,
driver: ADBaseIO,
exposure: float | None = None,
timeout: float = DEFAULT_TIMEOUT,
Expand Down
6 changes: 3 additions & 3 deletions src/ophyd_async/epics/adkinetix/_kinetix_controller.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import asyncio

from ophyd_async.core import DetectorControl, DetectorTrigger
from ophyd_async.core import DetectorController, DetectorTrigger
from ophyd_async.core._detector import TriggerInfo
from ophyd_async.core._status import AsyncStatus
from ophyd_async.epics import adcore
Expand All @@ -15,7 +15,7 @@
}


class KinetixController(DetectorControl):
class KinetixController(DetectorController):
def __init__(
self,
driver: KinetixDriverIO,
Expand All @@ -29,7 +29,7 @@ def get_deadtime(self, exposure: float | None) -> float:
async def prepare(self, trigger_info: TriggerInfo):
await asyncio.gather(
self._drv.trigger_mode.set(KINETIX_TRIGGER_MODE_MAP[trigger_info.trigger]),
self._drv.num_images.set(trigger_info.number),
self._drv.num_images.set(trigger_info.total_number_of_triggers),
self._drv.image_mode.set(adcore.ImageMode.multiple),
)
if trigger_info.livetime is not None and trigger_info.trigger not in [
Expand Down
Loading

0 comments on commit 592b6c1

Please sign in to comment.