Source code for ophyd_async.core._flyable

import asyncio
from abc import abstractmethod
from collections.abc import AsyncIterator
from enum import Enum
from functools import cached_property
from typing import Generic, TypeVar, cast

from bluesky.protocols import Flyable, Preparable
from pydantic import Field

from ._movable import MovableLogic
from ._signal import observe_value
from ._standard_base import _StandardBase
from ._status import AsyncStatus, WatchableAsyncStatus
from ._utils import (
    CALCULATE_TIMEOUT,
    CalculatableTimeout,
    ConfinedModel,
    WatcherUpdate,
)

#: The per-scan info passed to `FlyableLogic.on_prepare` (often a
#: [`scanspec.core.Path`](inv:scanspec#scanspec.core.Path), but any type).
PrepareT = TypeVar("PrepareT")
#: The context object threaded from `on_prepare` through `on_kickoff` to
#: `on_complete`. Use `None` for flyers that carry no state between stages.
CtxT = TypeVar("CtxT")


[docs] class FlyableLogic(Generic[PrepareT, CtxT]): """Minimum logic needed for controlling a `StandardFlyable`. Inherit and fill in the hooks for a particular flyable (e.g. a motion or trigger system). This base holds no state; concrete subclasses are typically `@dataclass`es that hold whatever signals or sub-devices they need as fields. State that must be carried between stages is threaded through an explicit context object rather than stored on the logic: `on_prepare` returns it, `on_kickoff` receives and returns it, and `on_complete` receives it. `StandardFlyable` owns that context and enforces the call ordering, so subclasses do not need to guard against being called out of order. A flyer with no cross-stage state uses `None` for the context (see the PandA trigger logics). """
[docs] @abstractmethod async def on_prepare(self, value: PrepareT) -> CtxT: """Move to the start of the fly scan, set it up, and return its context. :param value: the per-scan info for this fly scan. """
[docs] @abstractmethod async def on_kickoff(self, ctx: CtxT) -> CtxT: """Start the fly scan. :param ctx: the context returned by `on_prepare`. Return the (possibly updated) context to be passed to `on_complete`. """
[docs] @abstractmethod async def on_complete(self, ctx: CtxT) -> None: """Block until the fly scan is done. :param ctx: the context returned by `on_kickoff`. """
[docs] async def stop(self) -> None: """Stop/disarm the flyer and wait for everything to be stopped. Called by `on_stage` and `on_unstage` by default; override those instead if stage and unstage need to differ. """ pass
[docs] async def on_stage(self) -> None: """Set the flyer up on `stage()`. Defaults to `stop`.""" await self.stop()
[docs] async def on_unstage(self) -> None: """Clean the flyer up on `unstage()`. Defaults to `stop`.""" await self.stop()
[docs] def with_device(self, name: str = "") -> "StandardFlyable": """Wrap this logic in an ephemeral `StandardFlyable` for use in a plan.""" flyer = StandardFlyable(name=name) flyer.flyable_logic = self return flyer
[docs] class FlyMotorInfo(ConfinedModel): """Minimal set of information required to fly a motor.""" start_position: float = Field(frozen=True) """Absolute position of the motor once it finishes accelerating to desired velocity, in motor EGUs""" end_position: float = Field(frozen=True) """Absolute position of the motor once it begins decelerating from desired velocity, in EGUs""" time_for_move: float = Field(frozen=True, gt=0) """Time taken for the motor to get from start_position to end_position, excluding run-up and run-down, in seconds.""" timeout: CalculatableTimeout = Field(frozen=True, default=CALCULATE_TIMEOUT) """Maximum time for the complete motor move, including run up and run down. Defaults to `time_for_move` + run up and run down times + 10s.""" @property def speed(self) -> float: """Calculate the speed of the constant velocity phase, always positive.""" return abs(self.end_position - self.start_position) / self.time_for_move
[docs] def ramp_up_start_pos(self, acceleration_time: float) -> float: """Calculate the start position with run-up distance added on.""" return self.start_position - self._ramp_distance(acceleration_time)
[docs] def ramp_down_end_pos(self, acceleration_time: float) -> float: """Calculate the end position with run-down distance added on.""" return self.end_position + self._ramp_distance(acceleration_time)
def _ramp_distance(self, acceleration_time: float) -> float: # Signed so run-up/run-down land on the correct side for either direction. return ( acceleration_time * (self.end_position - self.start_position) / (2 * self.time_for_move) )
class _FlyStage(Enum): """Lifecycle stage of a `StandardFlyable`, used to enforce call ordering.""" IDLE = "IDLE" PREPARED = "PREPARED" KICKED_OFF = "KICKED_OFF"
[docs] class StandardFlyable( _StandardBase, Preparable, Flyable, Generic[PrepareT, CtxT], ): """Device that provides standard logic for flying. This class must be inherited and have a `flyable_logic` @cached_property. For an ephemeral flyer in a plan, call `FlyableLogic.with_device` instead of inheriting. It owns the context threaded between the logic's stages and enforces prepare -> kickoff -> complete ordering. `stage()`/`unstage()` run the logic's `on_stage`/`on_unstage` (which default to `stop`) and reset the context. """ def __init__(self, *args, **kwargs) -> None: super().__init__(*args, **kwargs) # Contribute the flyer's stage/unstage hooks (composes with any other # _StandardBase mix-in, e.g. StandardReadable on a Motor). self._stage_funcs += (self._on_stage,) self._unstage_funcs += (self._on_unstage,) # Context threaded prepare -> kickoff -> complete. Only meaningful once # prepared; access is guarded by _fly_stage. self._fly_ctx: CtxT = cast(CtxT, None) self._fly_stage = _FlyStage.IDLE @cached_property def flyable_logic(self) -> FlyableLogic[PrepareT, CtxT]: """The logic object that describes how this device flies. A static flyer (e.g. `Motor`) provides this as a `@cached_property` that builds a `FlyableLogic` from its signals. An ephemeral flyer created by `FlyableLogic.with_device` has it set directly on the instance. """ raise NotImplementedError( f"{type(self).__name__} has no flyable_logic; override it as a " "@cached_property or create the flyer via FlyableLogic.with_device()" ) def _reset_fly_state(self) -> None: self._fly_ctx = cast(CtxT, None) self._fly_stage = _FlyStage.IDLE @AsyncStatus.wrap async def _on_stage(self) -> None: await self.flyable_logic.on_stage() self._reset_fly_state() @AsyncStatus.wrap async def _on_unstage(self) -> None: await self.flyable_logic.on_unstage() self._reset_fly_state()
[docs] @AsyncStatus.wrap async def prepare(self, value: PrepareT) -> None: """Move to the start and set up the fly scan.""" self._fly_ctx = await self.flyable_logic.on_prepare(value) self._fly_stage = _FlyStage.PREPARED
[docs] @AsyncStatus.wrap async def kickoff(self) -> None: """Start the fly scan.""" if self._fly_stage is not _FlyStage.PREPARED: raise RuntimeError( f"{self.name}: prepare() must be called before kickoff()" ) self._fly_ctx = await self.flyable_logic.on_kickoff(self._fly_ctx) self._fly_stage = _FlyStage.KICKED_OFF
[docs] @WatchableAsyncStatus.wrap async def complete(self) -> AsyncIterator[WatcherUpdate]: """Block until the fly scan is done. If the logic is also a `MovableLogic` (e.g. a flying `Motor`), report progress to watchers by observing its readback while the fly scan runs, reusing the same watcher-update stream as `StandardMovable.set`. Flyers whose logic is not movable simply block with no progress updates. """ if self._fly_stage is not _FlyStage.KICKED_OFF: raise RuntimeError( f"{self.name}: kickoff() must be called before complete()" ) logic = self.flyable_logic if isinstance(logic, MovableLogic): initial, target, (units, precision) = await asyncio.gather( logic.readback.get_value(), logic.setpoint.get_value(), logic.get_units_precision(), ) async with AsyncStatus(logic.on_complete(self._fly_ctx)) as completing: async for current_position in observe_value( logic.readback, done_status=completing ): yield WatcherUpdate( current=current_position, initial=initial, target=target, name=self.name, unit=units, precision=precision, ) else: await self.flyable_logic.on_complete(self._fly_ctx) self._fly_stage = _FlyStage.IDLE