diff --git a/.devcontainer/Dockerfile b/.devcontainer/Dockerfile index 349a45cc6f..901909949d 100644 --- a/.devcontainer/Dockerfile +++ b/.devcontainer/Dockerfile @@ -3,7 +3,7 @@ # The devcontainer should use the build target and run as root with podman # or docker with user namespaces. # -FROM python:3.9 as build +FROM python:3.10 as build ARG PIP_OPTIONS @@ -24,7 +24,7 @@ WORKDIR /context # install python package into /venv RUN pip install ${PIP_OPTIONS} -FROM python:3.9-slim as runtime +FROM python:3.10-slim as runtime # Add apt-get system dependecies for runtime here if needed diff --git a/.github/actions/install_requirements/action.yml b/.github/actions/install_requirements/action.yml index 89889a65ac..84be1b910f 100644 --- a/.github/actions/install_requirements/action.yml +++ b/.github/actions/install_requirements/action.yml @@ -9,7 +9,7 @@ inputs: required: true python_version: description: Python version to install - default: "3.9" + default: "3.10" runs: using: composite diff --git a/.github/workflows/code.yml b/.github/workflows/code.yml index 05c3d28b6c..41381549fb 100644 --- a/.github/workflows/code.yml +++ b/.github/workflows/code.yml @@ -12,7 +12,7 @@ jobs: steps: - name: Checkout - uses: actions/checkout@v3 + uses: actions/checkout@v4 - name: Install python packages uses: ./.github/actions/install_requirements @@ -29,12 +29,12 @@ jobs: fail-fast: false matrix: os: ["ubuntu-latest"] # can add windows-latest, macos-latest - python: ["3.10", "3.11"] + python: ["3.10","3.11"] # 3.12 should be added when p4p is updated install: ["-e .[dev]"] # Make one version be non-editable to test both paths of version code include: - os: "ubuntu-latest" - python: "3.9" + python: "3.10" install: ".[dev]" runs-on: ${{ matrix.os }} @@ -44,7 +44,7 @@ jobs: steps: - name: Checkout - uses: actions/checkout@v3 + uses: actions/checkout@v4 with: # Need this to get version number from last tag fetch-depth: 0 @@ -63,10 +63,12 @@ jobs: run: tox -e pytest - name: Upload coverage to Codecov - uses: codecov/codecov-action@v3 + uses: codecov/codecov-action@v4 with: name: ${{ matrix.python }}/${{ matrix.os }} files: cov.xml + env: + CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }} dist: if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name != github.repository @@ -74,7 +76,7 @@ jobs: steps: - name: Checkout - uses: actions/checkout@v3 + uses: actions/checkout@v4 with: # Need this to get version number from last tag fetch-depth: 0 @@ -125,7 +127,7 @@ jobs: - name: Github Release # We pin to the SHA, not the tag, for security reasons. # https://docs.github.com/en/actions/learn-github-actions/security-hardening-for-github-actions#using-third-party-actions - uses: softprops/action-gh-release@1e07f4398721186383de40550babbdf2b84acfc5 # v0.1.14 + uses: softprops/action-gh-release@de2c0eb89ae2a093876385947365aca7b0e5f844 # v0.1.15 with: prerelease: ${{ contains(github.ref_name, 'a') || contains(github.ref_name, 'b') || contains(github.ref_name, 'rc') }} files: | diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml index a501f7d030..1d8a687a98 100644 --- a/.github/workflows/docs.yml +++ b/.github/workflows/docs.yml @@ -15,7 +15,7 @@ jobs: run: sleep 60 - name: Checkout - uses: actions/checkout@v3 + uses: actions/checkout@v4 with: # Need this to get version number from last tag fetch-depth: 0 diff --git a/.github/workflows/docs_clean.yml b/.github/workflows/docs_clean.yml index a67e18815c..e324640e78 100644 --- a/.github/workflows/docs_clean.yml +++ b/.github/workflows/docs_clean.yml @@ -18,7 +18,7 @@ jobs: steps: - name: Checkout - uses: actions/checkout@v3 + uses: actions/checkout@v4 with: ref: gh-pages diff --git a/.github/workflows/linkcheck.yml b/.github/workflows/linkcheck.yml index 6b64fdea9f..3b24af5584 100644 --- a/.github/workflows/linkcheck.yml +++ b/.github/workflows/linkcheck.yml @@ -12,7 +12,7 @@ jobs: steps: - name: Checkout - uses: actions/checkout@v3 + uses: actions/checkout@v4 - name: Install python packages uses: ./.github/actions/install_requirements diff --git a/docs/developer/how-to/pin-requirements.rst b/docs/developer/how-to/pin-requirements.rst index e42226fbcf..278bdeed18 100644 --- a/docs/developer/how-to/pin-requirements.rst +++ b/docs/developer/how-to/pin-requirements.rst @@ -46,7 +46,7 @@ of the dependencies and sub-dependencies with pinned versions. You can download any of these files by clicking on them. It is best to use the one that ran with the lowest Python version as this is more likely to be compatible with all the versions of Python in the test matrix. -i.e. ``requirements-test-ubuntu-latest-3.9.txt`` in this example. +i.e. ``requirements-test-ubuntu-latest-3.10.txt`` in this example. Applying the lock file ---------------------- diff --git a/docs/developer/tutorials/dev-install.rst b/docs/developer/tutorials/dev-install.rst index 59f16a919b..0a32bd19a2 100644 --- a/docs/developer/tutorials/dev-install.rst +++ b/docs/developer/tutorials/dev-install.rst @@ -16,7 +16,7 @@ Install dependencies -------------------- You can choose to either develop on the host machine using a `venv` (which -requires python 3.9 or later) or to run in a container under `VSCode +requires python 3.10 or later) or to run in a container under `VSCode `_ .. tab-set:: diff --git a/docs/user/how-to/write-tests-for-devices.rst b/docs/user/how-to/write-tests-for-devices.rst new file mode 100644 index 0000000000..258d195d9a --- /dev/null +++ b/docs/user/how-to/write-tests-for-devices.rst @@ -0,0 +1,45 @@ +.. note:: + + Ophyd async is included on a provisional basis until the v1.0 release and + may change API on minor release numbers before then + +Write Tests for Devices +======================= + +Testing ophyd-async devices using tools like mocking, patching, and fixtures can become complicated very quickly. The library provides several utilities to make it easier. + +Async Tests +----------- + +`pytest-asyncio `_ is required for async tests. It is should be included as a dev dependency of your project. Tests can either be decorated with ``@pytest.mark.asyncio`` or the project can be automatically configured to detect async tests. + +.. code:: toml + + # pyproject.toml + + [tool.pytest.ini_options] + ... + asyncio_mode = "auto" + +Sim Backend +----------- + +Ophyd devices initialized with a sim backend behave in a similar way to mocks, without requiring you to mock out all the dependencies and internals. The :class:`~ophyd-async.core.DeviceCollector` can initialize any number of devices, and their signals and sub-devices (recursively), with a sim backend. + +.. literalinclude:: ../../../tests/epics/demo/test_demo.py + :pyobject: sim_sensor + + +Sim Utility Functions +--------------------- + +Sim signals behave as simply as possible, holding a sensible default value when initialized and retaining any value (in memory) to which they are set. This model breaks down in the case of read-only signals, which cannot be set because there is an expectation of some external device setting them in the real world. There is a utility function, ``set_sim_value``, to mock-set values for sim signals, including read-only ones. + +.. literalinclude:: ../../../tests/epics/demo/test_demo.py + :pyobject: test_sensor_reading_shows_value + + +There is another utility function, ``set_sim_callback``, for hooking in logic when a sim value changes (e.g. because someone puts to it). + +.. literalinclude:: ../../../tests/epics/demo/test_demo.py + :pyobject: test_mover_stopped diff --git a/docs/user/index.rst b/docs/user/index.rst index 036a4b2e1e..c6ef7fdd03 100644 --- a/docs/user/index.rst +++ b/docs/user/index.rst @@ -32,6 +32,7 @@ side-bar. :maxdepth: 1 how-to/make-a-simple-device + how-to/write-tests-for-devices how-to/run-container +++ diff --git a/docs/user/tutorials/installation.rst b/docs/user/tutorials/installation.rst index 9e310adbce..73874b60cf 100644 --- a/docs/user/tutorials/installation.rst +++ b/docs/user/tutorials/installation.rst @@ -9,7 +9,7 @@ Installation Check your version of python ---------------------------- -You will need python 3.9 or later. You can check your version of python by +You will need python 3.10 or later. You can check your version of python by typing into a terminal:: $ python3 --version diff --git a/pyproject.toml b/pyproject.toml index 4e357e907a..d1ccbdb55c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -7,7 +7,6 @@ name = "ophyd-async" classifiers = [ "Development Status :: 3 - Alpha", "License :: OSI Approved :: BSD License", - "Programming Language :: Python :: 3.9", "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", ] @@ -19,7 +18,7 @@ dependencies = [ "packaging", "pint", "bluesky>=1.13.0a3", - "event-model", + "event-model<1.21.0", "p4p", "pyyaml", ] @@ -27,7 +26,7 @@ dependencies = [ dynamic = ["version"] license.file = "LICENSE" readme = "README.rst" -requires-python = ">=3.9" +requires-python = ">=3.10" [project.optional-dependencies] ca = ["aioca>=1.6"] diff --git a/src/ophyd_async/core/__init__.py b/src/ophyd_async/core/__init__.py index c302f0d133..103638019d 100644 --- a/src/ophyd_async/core/__init__.py +++ b/src/ophyd_async/core/__init__.py @@ -6,7 +6,13 @@ StaticDirectoryProvider, ) from .async_status import AsyncStatus -from .detector import DetectorControl, DetectorTrigger, DetectorWriter, StandardDetector +from .detector import ( + DetectorControl, + DetectorTrigger, + DetectorWriter, + StandardDetector, + TriggerInfo, +) from .device import Device, DeviceCollector, DeviceVector from .device_save_loader import ( get_signal_values, @@ -17,7 +23,7 @@ set_signal_values, walk_rw_signals, ) -from .flyer import HardwareTriggeredFlyable, TriggerInfo, TriggerLogic +from .flyer import HardwareTriggeredFlyable, TriggerLogic from .signal import ( Signal, SignalR, diff --git a/src/ophyd_async/core/detector.py b/src/ophyd_async/core/detector.py index d879c731ec..39bd2ac98d 100644 --- a/src/ophyd_async/core/detector.py +++ b/src/ophyd_async/core/detector.py @@ -10,6 +10,7 @@ AsyncIterator, Callable, Dict, + Generic, List, Optional, Sequence, @@ -39,6 +40,8 @@ class DetectorTrigger(str, Enum): + """Type of mechanism for triggering a detector to take frames""" + #: Detector generates internal trigger for given rate internal = "internal" #: Expect a series of arbitrary length trigger signals @@ -51,6 +54,8 @@ class DetectorTrigger(str, Enum): @dataclass(frozen=True) class TriggerInfo: + """Minimal set of information required to setup triggering on a detector""" + #: Number of triggers that will be sent num: int #: Sort of triggers that will be sent @@ -62,6 +67,11 @@ class TriggerInfo: class DetectorControl(ABC): + """ + Classes implementing this interface should hold the logic for + arming and disarming a detector + """ + @abstractmethod def get_deadtime(self, exposure: float) -> float: """For a given exposure, how long should the time between exposures be""" @@ -73,17 +83,32 @@ async def arm( trigger: DetectorTrigger = DetectorTrigger.internal, exposure: Optional[float] = None, ) -> AsyncStatus: - """Arm the detector and return AsyncStatus. + """ + Arm detector, do all necessary steps to prepare detector for triggers. + + Args: + num: Expected number of frames + trigger: Type of trigger for which to prepare the detector. Defaults to + DetectorTrigger.internal. + exposure: Exposure time with which to set up the detector. Defaults to None + if not applicable or the detector is expected to use its previously-set + exposure time. - Awaiting the return value will wait for num frames to be written. + Returns: + AsyncStatus: Status representing the arm operation. This function returning + represents the start of the arm. The returned status completing means + the detector is now armed. """ @abstractmethod async def disarm(self): - """Disarm the detector""" + """Disarm the detector, return detector to an idle state""" class DetectorWriter(ABC): + """Logic for making a detector write data to somewhere persistent + (e.g. an HDF5 file)""" + @abstractmethod async def open(self, multiplier: int = 1) -> Dict[str, Descriptor]: """Open writer and wait for it to be ready for data. @@ -100,7 +125,7 @@ async def open(self, multiplier: int = 1) -> Dict[str, Descriptor]: def observe_indices_written( self, timeout=DEFAULT_TIMEOUT ) -> AsyncGenerator[int, None]: - """Yield each index as it is written""" + """Yield the index of each frame (or equivalent data point) as it is written""" @abstractmethod async def get_indices_written(self) -> int: @@ -112,7 +137,7 @@ def collect_stream_docs(self, indices_written: int) -> AsyncIterator[StreamAsset @abstractmethod async def close(self) -> None: - """Close writer and wait for it to be finished""" + """Close writer, blocks until I/O is complete""" class StandardDetector( @@ -125,14 +150,11 @@ class StandardDetector( Flyable, Collectable, WritesStreamAssets, + Generic[T], ): - """Detector with useful step and flyscan behaviour. - - Must be supplied instances of classes that inherit from DetectorControl and - DetectorData, to dictate how the detector will be controlled (i.e. arming and - disarming) as well as how the detector data will be written (i.e. opening and - closing the writer, and handling data writing indices). - + """ + Useful detector base class for step and fly scanning detectors. + Aggregates controller and writer logic together. """ def __init__( @@ -144,14 +166,18 @@ def __init__( writer_timeout: float = DEFAULT_TIMEOUT, ) -> None: """ - Parameters - ---------- - control: - instance of class which inherits from :class:`DetectorControl` - data: - instance of class which inherits from :class:`DetectorData` - name: - detector name + Constructor + + Args: + controller: Logic for arming and disarming the detector + writer: Logic for making the detector write persistent data + config_sigs: Signals to read when describe and read + configuration are called. Defaults to (). + name: Device name. Defaults to "". + writer_timeout: Timeout for frame writing to start, if the + timeout is reached, ophyd-async assumes the detector + has a problem and raises an error. + Defaults to DEFAULT_TIMEOUT. """ self._controller = controller self._writer = writer @@ -180,12 +206,12 @@ def writer(self) -> DetectorWriter: @AsyncStatus.wrap async def stage(self) -> None: - """Disarm the detector, stop filewriting, and open file for writing.""" - await self.check_config_sigs() + # Disarm the detector, stop filewriting, and open file for writing. + await self._check_config_sigs() await asyncio.gather(self.writer.close(), self.controller.disarm()) self._describe = await self.writer.open() - async def check_config_sigs(self): + async def _check_config_sigs(self): """Checks configuration signals are named and connected.""" for signal in self._config_sigs: if signal._name == "": @@ -202,7 +228,7 @@ async def check_config_sigs(self): @AsyncStatus.wrap async def unstage(self) -> None: - """Stop data writing.""" + # Stop data writing. await self.writer.close() async def read_configuration(self) -> Dict[str, Reading]: @@ -212,7 +238,6 @@ async def describe_configuration(self) -> Dict[str, Descriptor]: return await merge_gathered_dicts(sig.describe() for sig in self._config_sigs) async def read(self) -> Dict[str, Reading]: - """Read the detector""" # All data is in StreamResources, not Events, so nothing to output here return {} @@ -221,7 +246,7 @@ def describe(self) -> Dict[str, Descriptor]: @AsyncStatus.wrap async def trigger(self) -> None: - """Arm the detector and wait for it to finish.""" + # Arm the detector and wait for it to finish. indices_written = await self.writer.get_indices_written() written_status = await self.controller.arm( num=1, @@ -240,11 +265,12 @@ def prepare( self, value: T, ) -> AsyncStatus: - """Arm detector""" + # Just arm detector for the time being return AsyncStatus(self._prepare(value)) async def _prepare(self, value: T) -> None: - """Arm detector. + """ + Arm detector. Prepare the detector with trigger information. This is determined at and passed in from the plan level. @@ -253,6 +279,9 @@ async def _prepare(self, value: T) -> None: trigger information determined in trigger. To do: Unify prepare to be use for both fly and step scans. + + Args: + value: TriggerInfo describing how to trigger the detector """ assert type(value) is TriggerInfo self._trigger_info = value @@ -307,11 +336,9 @@ async def describe_collect(self) -> Dict[str, Descriptor]: async def collect_asset_docs( self, index: Optional[int] = None ) -> AsyncIterator[StreamAsset]: - """Collect stream datum documents for all indices written. - - The index is optional, and provided for flyscans, however this needs to be - retrieved for stepscans. - """ + # Collect stream datum documents for all indices written. + # The index is optional, and provided for fly scans, however this needs to be + # retrieved for step scans. if not index: index = await self.writer.get_indices_written() async for doc in self.writer.collect_stream_docs(index): diff --git a/src/ophyd_async/core/flyer.py b/src/ophyd_async/core/flyer.py index f7872d7a52..0490ca10f2 100644 --- a/src/ophyd_async/core/flyer.py +++ b/src/ophyd_async/core/flyer.py @@ -1,10 +1,9 @@ from abc import ABC, abstractmethod -from typing import Dict, Generic, Optional, Sequence, TypeVar +from typing import Dict, Generic, Sequence, TypeVar from bluesky.protocols import Descriptor, Flyable, Preparable, Reading, Stageable from .async_status import AsyncStatus -from .detector import TriggerInfo from .device import Device from .signal import SignalR from .utils import merge_gathered_dicts @@ -13,18 +12,18 @@ class TriggerLogic(ABC, Generic[T]): - @abstractmethod - def trigger_info(self, value: T) -> TriggerInfo: - """Return info about triggers that will be produced for a given value""" - @abstractmethod async def prepare(self, value: T): """Move to the start of the flyscan""" @abstractmethod - async def start(self): + async def kickoff(self): """Start the flyscan""" + @abstractmethod + async def complete(self): + """Block until the flyscan is done""" + @abstractmethod async def stop(self): """Stop flying and wait everything to be stopped""" @@ -45,19 +44,12 @@ def __init__( ): self._trigger_logic = trigger_logic self._configuration_signals = tuple(configuration_signals) - self._describe: Dict[str, Descriptor] = {} - self._fly_status: Optional[AsyncStatus] = None - self._trigger_info: Optional[TriggerInfo] = None super().__init__(name=name) @property def trigger_logic(self) -> TriggerLogic[T]: return self._trigger_logic - @property - def trigger_info(self) -> Optional[TriggerInfo]: - return self._trigger_info - @AsyncStatus.wrap async def stage(self) -> None: await self.unstage() @@ -71,17 +63,16 @@ def prepare(self, value: T) -> AsyncStatus: return AsyncStatus(self._prepare(value)) async def _prepare(self, value: T) -> None: - self._trigger_info = self._trigger_logic.trigger_info(value) # Move to start and setup the flyscan await self._trigger_logic.prepare(value) @AsyncStatus.wrap async def kickoff(self) -> None: - self._fly_status = AsyncStatus(self._trigger_logic.start()) + await self._trigger_logic.kickoff() - def complete(self) -> AsyncStatus: - assert self._fly_status, "Kickoff not run" - return self._fly_status + @AsyncStatus.wrap + async def complete(self) -> None: + await self._trigger_logic.complete() async def describe_configuration(self) -> Dict[str, Descriptor]: return await merge_gathered_dicts( diff --git a/src/ophyd_async/core/utils.py b/src/ophyd_async/core/utils.py index 42a4b5b7d4..6863c1d1f2 100644 --- a/src/ophyd_async/core/utils.py +++ b/src/ophyd_async/core/utils.py @@ -148,3 +148,20 @@ async def merge_gathered_dicts( async def gather_list(coros: Iterable[Awaitable[T]]) -> List[T]: return await asyncio.gather(*coros) + + +def in_micros(t: float) -> int: + """ + Converts between a positive number of seconds and an equivalent + number of microseconds. + + Args: + t (float): A time in seconds + Raises: + ValueError: if t < 0 + Returns: + t (int): A time in microseconds, rounded up to the nearest whole microsecond, + """ + if t < 0: + raise ValueError(f"Expected a positive time in seconds, got {t!r}") + return int(np.ceil(t * 1e6)) diff --git a/src/ophyd_async/epics/pvi.py b/src/ophyd_async/epics/pvi.py deleted file mode 100644 index a71880ca1f..0000000000 --- a/src/ophyd_async/epics/pvi.py +++ /dev/null @@ -1,70 +0,0 @@ -from typing import Callable, Dict, FrozenSet, Optional, Type, TypedDict, TypeVar - -from ophyd_async.core.signal import Signal -from ophyd_async.core.signal_backend import SignalBackend -from ophyd_async.core.utils import DEFAULT_TIMEOUT -from ophyd_async.epics._backend._p4p import PvaSignalBackend -from ophyd_async.epics.signal.signal import ( - epics_signal_r, - epics_signal_rw, - epics_signal_w, - epics_signal_x, -) - -T = TypeVar("T") - - -_pvi_mapping: Dict[FrozenSet[str], Callable[..., Signal]] = { - frozenset({"r", "w"}): lambda dtype, read_pv, write_pv: epics_signal_rw( - dtype, read_pv, write_pv - ), - frozenset({"rw"}): lambda dtype, read_pv, write_pv: epics_signal_rw( - dtype, read_pv, write_pv - ), - frozenset({"r"}): lambda dtype, read_pv, _: epics_signal_r(dtype, read_pv), - frozenset({"w"}): lambda dtype, _, write_pv: epics_signal_w(dtype, write_pv), - frozenset({"x"}): lambda _, __, write_pv: epics_signal_x(write_pv), -} - - -class PVIEntry(TypedDict, total=False): - d: str - r: str - rw: str - w: str - x: str - - -async def pvi_get( - read_pv: str, timeout: float = DEFAULT_TIMEOUT -) -> Dict[str, PVIEntry]: - """Makes a PvaSignalBackend purely to connect to PVI information. - - This backend is simply thrown away at the end of this method. This is useful - because the backend handles a CancelledError exception that gets thrown on - timeout, and therefore can be used for error reporting.""" - backend: SignalBackend = PvaSignalBackend(None, read_pv, read_pv) - await backend.connect(timeout=timeout) - d: Dict[str, Dict[str, Dict[str, str]]] = await backend.get_value() - pv_info = d.get("pvi") or {} - result = {} - - for attr_name, attr_info in pv_info.items(): - result[attr_name] = PVIEntry(**attr_info) # type: ignore - - return result - - -def make_signal(signal_pvi: PVIEntry, dtype: Optional[Type[T]] = None) -> Signal[T]: - """Make a signal. - - This assumes datatype is None so it can be used to create dynamic signals. - """ - operations = frozenset(signal_pvi.keys()) - pvs = [signal_pvi[i] for i in operations] # type: ignore - signal_factory = _pvi_mapping[operations] - - write_pv = "pva://" + pvs[0] - read_pv = write_pv if len(pvs) < 2 else "pva://" + pvs[1] - - return signal_factory(dtype, read_pv, write_pv) diff --git a/src/ophyd_async/epics/pvi/__init__.py b/src/ophyd_async/epics/pvi/__init__.py new file mode 100644 index 0000000000..307c3b35ef --- /dev/null +++ b/src/ophyd_async/epics/pvi/__init__.py @@ -0,0 +1,3 @@ +from .pvi import PVIEntry, fill_pvi_entries + +__all__ = ["PVIEntry", "fill_pvi_entries"] diff --git a/src/ophyd_async/epics/pvi/pvi.py b/src/ophyd_async/epics/pvi/pvi.py new file mode 100644 index 0000000000..dc211250db --- /dev/null +++ b/src/ophyd_async/epics/pvi/pvi.py @@ -0,0 +1,298 @@ +import re +from dataclasses import dataclass +from inspect import isclass +from typing import ( + Callable, + Dict, + FrozenSet, + Literal, + Optional, + Tuple, + Type, + TypeVar, + Union, + get_args, + get_origin, + get_type_hints, +) + +from ophyd_async.core import Device, DeviceVector, SimSignalBackend +from ophyd_async.core.signal import Signal +from ophyd_async.core.utils import DEFAULT_TIMEOUT +from ophyd_async.epics._backend._p4p import PvaSignalBackend +from ophyd_async.epics.signal.signal import ( + epics_signal_r, + epics_signal_rw, + epics_signal_w, + epics_signal_x, +) + +T = TypeVar("T") +Access = FrozenSet[ + Union[Literal["r"], Literal["w"], Literal["rw"], Literal["x"], Literal["d"]] +] + + +def _strip_number_from_string(string: str) -> Tuple[str, Optional[int]]: + match = re.match(r"(.*?)(\d*)$", string) + assert match + + name = match.group(1) + number = match.group(2) or None + if number: + number = int(number) + return name, number + + +def _strip_union(field: Union[Union[T], T]) -> T: + if get_origin(field) is Union: + args = get_args(field) + for arg in args: + if arg is not type(None): + return arg + return field + + +def _strip_device_vector(field: Union[Type[Device]]) -> Tuple[bool, Type[Device]]: + if get_origin(field) is DeviceVector: + return True, get_args(field)[0] + return False, field + + +@dataclass +class PVIEntry: + """ + A dataclass to represent a single entry in the PVI table. + This could either be a signal or a sub-table. + """ + + sub_entries: Dict[str, Union[Dict[int, "PVIEntry"], "PVIEntry"]] + pvi_pv: Optional[str] = None + device: Optional[Device] = None + common_device_type: Optional[Type[Device]] = None + + +def _verify_common_blocks(entry: PVIEntry, common_device: Type[Device]): + if not entry.sub_entries: + return + common_sub_devices = get_type_hints(common_device) + for sub_name, sub_device in common_sub_devices.items(): + if sub_name in ("_name", "parent"): + continue + assert entry.sub_entries + if sub_name not in entry.sub_entries and get_origin(sub_device) is not Optional: + raise RuntimeError( + f"sub device `{sub_name}:{type(sub_device)}` was not provided by pvi" + ) + if isinstance(entry.sub_entries[sub_name], dict): + for sub_sub_entry in entry.sub_entries[sub_name].values(): # type: ignore + _verify_common_blocks(sub_sub_entry, sub_device) # type: ignore + else: + _verify_common_blocks( + entry.sub_entries[sub_name], sub_device # type: ignore + ) + + +_pvi_mapping: Dict[FrozenSet[str], Callable[..., Signal]] = { + frozenset({"r", "w"}): lambda dtype, read_pv, write_pv: epics_signal_rw( + dtype, "pva://" + read_pv, "pva://" + write_pv + ), + frozenset({"rw"}): lambda dtype, read_write_pv: epics_signal_rw( + dtype, "pva://" + read_write_pv, write_pv="pva://" + read_write_pv + ), + frozenset({"r"}): lambda dtype, read_pv: epics_signal_r(dtype, "pva://" + read_pv), + frozenset({"w"}): lambda dtype, write_pv: epics_signal_w( + dtype, "pva://" + write_pv + ), + frozenset({"x"}): lambda _, write_pv: epics_signal_x("pva://" + write_pv), +} + + +def _parse_type( + is_pvi_table: bool, + number_suffix: Optional[int], + common_device_type: Optional[Type[Device]], +): + if common_device_type: + # pre-defined type + device_type = _strip_union(common_device_type) + is_device_vector, device_type = _strip_device_vector(device_type) + + if ((origin := get_origin(device_type)) and issubclass(origin, Signal)) or ( + isclass(device_type) and issubclass(device_type, Signal) + ): + # if device_type is of the form `Signal` or `Signal[type]` + is_signal = True + signal_dtype = get_args(device_type)[0] + else: + is_signal = False + signal_dtype = None + + elif is_pvi_table: + # is a block, we can make it a DeviceVector if it ends in a number + is_device_vector = number_suffix is not None + is_signal = False + signal_dtype = None + device_type = Device + else: + # is a signal, signals aren't stored in DeviceVectors unless + # they're defined as such in the common_device_type + is_device_vector = False + is_signal = True + signal_dtype = None + device_type = Signal + + return is_device_vector, is_signal, signal_dtype, device_type + + +def _sim_common_blocks(device: Device, stripped_type: Optional[Type] = None): + device_t = stripped_type or type(device) + for sub_name, sub_device_t in get_type_hints(device_t).items(): + if sub_name in ("_name", "parent"): + continue + + # we'll take the first type in the union which isn't NoneType + sub_device_t = _strip_union(sub_device_t) + is_device_vector, sub_device_t = _strip_device_vector(sub_device_t) + is_signal = ( + (origin := get_origin(sub_device_t)) and issubclass(origin, Signal) + ) or (issubclass(sub_device_t, Signal)) + + # TODO: worth coming back to all this code once 3.9 is gone and we can use + # match statments: https://github.com/bluesky/ophyd-async/issues/180 + if is_device_vector: + if is_signal: + signal_type = args[0] if (args := get_args(sub_device_t)) else None + sub_device_1 = sub_device_t(SimSignalBackend(signal_type, sub_name)) + sub_device_2 = sub_device_t(SimSignalBackend(signal_type, sub_name)) + sub_device = DeviceVector( + { + 1: sub_device_1, + 2: sub_device_2, + } + ) + else: + sub_device = DeviceVector( + { + 1: sub_device_t(), + 2: sub_device_t(), + } + ) + for value in sub_device.values(): + value.parent = sub_device + + elif is_signal: + signal_type = args[0] if (args := get_args(sub_device_t)) else None + sub_device = sub_device_t(SimSignalBackend(signal_type, sub_name)) + else: + sub_device = sub_device_t() + + if not is_signal: + if is_device_vector: + for sub_device_in_vector in sub_device.values(): + _sim_common_blocks(sub_device_in_vector, stripped_type=sub_device_t) + else: + _sim_common_blocks(sub_device, stripped_type=sub_device_t) + + setattr(device, sub_name, sub_device) + sub_device.parent = device + + +async def _get_pvi_entries(entry: PVIEntry, timeout=DEFAULT_TIMEOUT): + if not entry.pvi_pv or not entry.pvi_pv.endswith(":PVI"): + raise RuntimeError("Top level entry must be a pvi table") + + pvi_table_signal_backend: PvaSignalBackend = PvaSignalBackend( + None, entry.pvi_pv, entry.pvi_pv + ) + await pvi_table_signal_backend.connect( + timeout=timeout + ) # create table signal backend + + pva_table = (await pvi_table_signal_backend.get_value())["pvi"] + common_device_type_hints = ( + get_type_hints(entry.common_device_type) if entry.common_device_type else {} + ) + + for sub_name, pva_entries in pva_table.items(): + pvs = list(pva_entries.values()) + is_pvi_table = len(pvs) == 1 and pvs[0].endswith(":PVI") + sub_name_split, sub_number_split = _strip_number_from_string(sub_name) + is_device_vector, is_signal, signal_dtype, device_type = _parse_type( + is_pvi_table, + sub_number_split, + common_device_type_hints.get(sub_name_split), + ) + if is_signal: + device = _pvi_mapping[frozenset(pva_entries.keys())](signal_dtype, *pvs) + else: + device = device_type() + + sub_entry = PVIEntry( + device=device, common_device_type=device_type, sub_entries={} + ) + + if is_device_vector: + # If device vector then we store sub_name -> {sub_number -> sub_entry} + # and aggregate into `DeviceVector` in `_set_device_attributes` + sub_number_split = 1 if sub_number_split is None else sub_number_split + if sub_name_split not in entry.sub_entries: + entry.sub_entries[sub_name_split] = {} + entry.sub_entries[sub_name_split][ + sub_number_split + ] = sub_entry # type: ignore + else: + entry.sub_entries[sub_name] = sub_entry + + if is_pvi_table: + sub_entry.pvi_pv = pvs[0] + await _get_pvi_entries(sub_entry) + + if entry.common_device_type: + _verify_common_blocks(entry, entry.common_device_type) + + +def _set_device_attributes(entry: PVIEntry): + for sub_name, sub_entry in entry.sub_entries.items(): + if isinstance(sub_entry, dict): + sub_device = DeviceVector() # type: ignore + for key, device_vector_sub_entry in sub_entry.items(): + sub_device[key] = device_vector_sub_entry.device + if device_vector_sub_entry.pvi_pv: + _set_device_attributes(device_vector_sub_entry) + # Set the device vector entry to have the device vector as a parent + device_vector_sub_entry.device.parent = sub_device # type: ignore + else: + sub_device = sub_entry.device # type: ignore + if sub_entry.pvi_pv: + _set_device_attributes(sub_entry) + + sub_device.parent = entry.device + setattr(entry.device, sub_name, sub_device) + + +async def fill_pvi_entries( + device: Device, root_pv: str, timeout=DEFAULT_TIMEOUT, sim=False +): + """ + Fills a ``device`` with signals from a the ``root_pvi:PVI`` table. + + If the device names match with parent devices of ``device`` then types are used. + """ + if sim: + # set up sim signals for the common annotations + _sim_common_blocks(device) + else: + # check the pvi table for devices and fill the device with them + root_entry = PVIEntry( + pvi_pv=root_pv, + device=device, + common_device_type=type(device), + sub_entries={}, + ) + await _get_pvi_entries(root_entry, timeout=timeout) + _set_device_attributes(root_entry) + + # We call set name now the parent field has been set in all of the + # introspect-initialized devices. This will recursively set the names. + device.set_name(device.name) diff --git a/src/ophyd_async/panda/__init__.py b/src/ophyd_async/panda/__init__.py index 4bae59ff1e..4007412f87 100644 --- a/src/ophyd_async/panda/__init__.py +++ b/src/ophyd_async/panda/__init__.py @@ -1,4 +1,4 @@ -from .panda import PandA, PcapBlock, PulseBlock, PVIEntry, SeqBlock, SeqTable +from .panda import PandA, PcapBlock, PulseBlock, SeqBlock, SeqTable, TimeUnits from .panda_controller import PandaPcapController from .table import ( SeqTable, @@ -13,7 +13,6 @@ "PandA", "PcapBlock", "PulseBlock", - "PVIEntry", "seq_table_from_arrays", "seq_table_from_rows", "SeqBlock", @@ -22,4 +21,5 @@ "SeqTrigger", "phase_sorter", "PandaPcapController", + "TimeUnits", ] diff --git a/src/ophyd_async/panda/panda.py b/src/ophyd_async/panda/panda.py index 0177447ab0..d77dd3ebb3 100644 --- a/src/ophyd_async/panda/panda.py +++ b/src/ophyd_async/panda/panda.py @@ -1,20 +1,9 @@ from __future__ import annotations -import re -from typing import Dict, Optional, Tuple, cast, get_args, get_origin, get_type_hints - -from ophyd_async.core import ( - DEFAULT_TIMEOUT, - Device, - DeviceVector, - Signal, - SignalBackend, - SignalR, - SignalRW, - SignalX, - SimSignalBackend, -) -from ophyd_async.epics.pvi import PVIEntry, make_signal, pvi_get +from enum import Enum + +from ophyd_async.core import DEFAULT_TIMEOUT, Device, DeviceVector, SignalR, SignalRW +from ophyd_async.epics.pvi import fill_pvi_entries from ophyd_async.panda.table import SeqTable @@ -23,9 +12,20 @@ class PulseBlock(Device): width: SignalRW[float] +class TimeUnits(str, Enum): + min = "min" + s = "s" + ms = "ms" + us = "us" + + class SeqBlock(Device): table: SignalRW[SeqTable] active: SignalRW[bool] + repeats: SignalRW[int] + prescale: SignalRW[float] + prescale_units: SignalRW[TimeUnits] + enable: SignalRW[str] class PcapBlock(Device): @@ -33,148 +33,16 @@ class PcapBlock(Device): arm: SignalRW[bool] -def _block_name_number(block_name: str) -> Tuple[str, Optional[int]]: - """Maps a panda block name to a block and number. - - There are exceptions to this rule; some blocks like pcap do not contain numbers. - Other blocks may contain numbers and letters, but no numbers at the end. - - Such block names will only return the block name, and not a number. - - If this function returns both a block name and number, it should be instantiated - into a device vector.""" - m = re.match("^([0-9a-z_-]*)([0-9]+)$", block_name) - if m is not None: - name, num = m.groups() - return name, int(num or 1) # just to pass type checks. - - return block_name, None - - -def _remove_inconsistent_blocks(pvi_info: Optional[Dict[str, PVIEntry]]) -> None: - """Remove blocks from pvi information. - - This is needed because some pandas have 'pcap' and 'pcap1' blocks, which are - inconsistent with the assumption that pandas should only have a 'pcap' block, - for example. - - """ - if pvi_info is None: - return - pvi_keys = set(pvi_info.keys()) - for k in pvi_keys: - kn = re.sub(r"\d*$", "", k) - if kn and k != kn and kn in pvi_keys: - del pvi_info[k] - - -class PandA(Device): +class CommonPandABlocks(Device): pulse: DeviceVector[PulseBlock] seq: DeviceVector[SeqBlock] pcap: PcapBlock + +class PandA(CommonPandABlocks): def __init__(self, prefix: str, name: str = "") -> None: - super().__init__(name) self._prefix = prefix - - def verify_block(self, name: str, num: Optional[int]): - """Given a block name and number, return information about a block.""" - anno = get_type_hints(self, globalns=globals()).get(name) - - block: Device = Device() - - if anno: - type_args = get_args(anno) - block = type_args[0]() if type_args else anno() - - if not type_args: - assert num is None, f"Only expected one {name} block, got {num}" - - return block - - async def _make_block( - self, - name: str, - num: Optional[int], - block_pv: str, - sim: bool = False, - timeout: float = DEFAULT_TIMEOUT, - ): - """Makes a block given a block name containing relevant signals. - - Loops through the signals in the block (found using type hints), if not in - sim mode then does a pvi call, and identifies this signal from the pvi call. - """ - block = self.verify_block(name, num) - - field_annos = get_type_hints(block, globalns=globals()) - block_pvi = await pvi_get(block_pv, timeout=timeout) if not sim else None - - # finds which fields this class actually has, e.g. delay, width... - for sig_name, sig_type in field_annos.items(): - origin = get_origin(sig_type) - args = get_args(sig_type) - - # if not in sim mode, - if block_pvi: - # try to get this block in the pvi. - entry: Optional[PVIEntry] = block_pvi.get(sig_name) - if entry is None: - raise Exception( - f"{self.__class__.__name__} has a {name} block containing a/" - + f"an {sig_name} signal which has not been retrieved by PVI." - ) - - signal: Signal = make_signal(entry, args[0] if len(args) > 0 else None) - - else: - backend: SignalBackend = SimSignalBackend( - args[0] if len(args) > 0 else None, block_pv - ) - signal = SignalX(backend) if not origin else origin(backend) - - setattr(block, sig_name, signal) - - # checks for any extra pvi information not contained in this class - if block_pvi: - for attr, attr_pvi in block_pvi.items(): - if not hasattr(block, attr): - # makes any extra signals - setattr(block, attr, make_signal(attr_pvi)) - - return block - - async def _make_untyped_block( - self, block_pv: str, timeout: float = DEFAULT_TIMEOUT - ): - """Populates a block using PVI information. - - This block is not typed as part of the PandA interface but needs to be - included dynamically anyway. - """ - block = Device() - block_pvi: Dict[str, PVIEntry] = await pvi_get(block_pv, timeout=timeout) - - for signal_name, signal_pvi in block_pvi.items(): - setattr(block, signal_name, make_signal(signal_pvi)) - - return block - - # TODO redo to set_panda_block? confusing name - def set_attribute(self, name: str, num: Optional[int], block: Device): - """Set a block on the panda. - - Need to be able to set device vectors on the panda as well, e.g. if num is not - None, need to be able to make a new device vector and start populating it... - """ - anno = get_type_hints(self, globalns=globals()).get(name) - - # if it's an annotated device vector, or it isn't but we've got a number then - # make a DeviceVector on the class - if get_origin(anno) == DeviceVector or (not anno and num is not None): - self.__dict__.setdefault(name, DeviceVector())[num] = block - else: - setattr(self, name, block) + super().__init__(name) async def connect( self, sim: bool = False, timeout: float = DEFAULT_TIMEOUT @@ -187,55 +55,7 @@ async def connect( If there's no pvi information, that's because we're in sim mode. In that case, makes all required blocks. """ - pvi_info = ( - await pvi_get(self._prefix + "PVI", timeout=timeout) if not sim else None - ) - _remove_inconsistent_blocks(pvi_info) - - hints = { - attr_name: attr_type - for attr_name, attr_type in get_type_hints(self, globalns=globals()).items() - if not attr_name.startswith("_") - } - - # create all the blocks pvi says it should have, - if pvi_info: - pvi_info = cast(Dict[str, PVIEntry], pvi_info) - for block_name, block_pvi in pvi_info.items(): - name, num = _block_name_number(block_name) - - if name in hints: - block = await self._make_block( - name, num, block_pvi["d"], timeout=timeout - ) - else: - block = await self._make_untyped_block( - block_pvi["d"], timeout=timeout - ) - - self.set_attribute(name, num, block) - - # then check if the ones defined in this class are in the pvi info - # make them if there is no pvi info, i.e. sim mode. - for block_name in hints.keys(): - if pvi_info is not None: - pvi_name = block_name - - if get_origin(hints[block_name]) == DeviceVector: - pvi_name += "1" - - entry: Optional[PVIEntry] = pvi_info.get(pvi_name) - - assert entry, f"Expected PandA to contain {block_name} block." - assert list(entry) == [ - "d" - ], f"Expected PandA to only contain blocks, got {entry}" - else: - num = 1 if get_origin(hints[block_name]) == DeviceVector else None - block = await self._make_block( - block_name, num, "sim://", sim=sim, timeout=timeout - ) - self.set_attribute(block_name, num, block) - - self.set_name(self.name) + + await fill_pvi_entries(self, self._prefix + "PVI", timeout=timeout, sim=sim) + await super().connect(sim) diff --git a/src/ophyd_async/panda/trigger.py b/src/ophyd_async/panda/trigger.py new file mode 100644 index 0000000000..ef9251b7f5 --- /dev/null +++ b/src/ophyd_async/panda/trigger.py @@ -0,0 +1,40 @@ +import asyncio +from dataclasses import dataclass + +from ophyd_async.core import TriggerLogic, wait_for_value +from ophyd_async.panda import SeqBlock, SeqTable, TimeUnits + + +@dataclass +class SeqTableInfo: + sequence_table: SeqTable + repeats: int + prescale_as_us: float = 1 # microseconds + + +class StaticSeqTableTriggerLogic(TriggerLogic[SeqTableInfo]): + + def __init__(self, seq: SeqBlock) -> None: + self.seq = seq + + async def prepare(self, value: SeqTableInfo): + await asyncio.gather( + self.seq.prescale_units.set(TimeUnits.us), + self.seq.enable.set("ZERO"), + ) + await asyncio.gather( + self.seq.prescale.set(value.prescale_as_us), + self.seq.repeats.set(value.repeats), + self.seq.table.set(value.sequence_table), + ) + + async def kickoff(self) -> None: + await self.seq.enable.set("ONE") + await wait_for_value(self.seq.active, True, timeout=1) + + async def complete(self) -> None: + await wait_for_value(self.seq.active, False, timeout=None) + + async def stop(self): + await self.seq.enable.set("ZERO") + await wait_for_value(self.seq.active, False, timeout=1) diff --git a/src/ophyd_async/planstubs/__init__.py b/src/ophyd_async/planstubs/__init__.py new file mode 100644 index 0000000000..cc409ce3a1 --- /dev/null +++ b/src/ophyd_async/planstubs/__init__.py @@ -0,0 +1,5 @@ +from .prepare_trigger_and_dets import ( + prepare_static_seq_table_flyer_and_detectors_with_same_trigger, +) + +__all__ = ["prepare_static_seq_table_flyer_and_detectors_with_same_trigger"] diff --git a/src/ophyd_async/planstubs/prepare_trigger_and_dets.py b/src/ophyd_async/planstubs/prepare_trigger_and_dets.py new file mode 100644 index 0000000000..ad86ef0a92 --- /dev/null +++ b/src/ophyd_async/planstubs/prepare_trigger_and_dets.py @@ -0,0 +1,58 @@ +from typing import List + +import bluesky.plan_stubs as bps + +from ophyd_async.core.detector import DetectorTrigger, StandardDetector, TriggerInfo +from ophyd_async.core.flyer import HardwareTriggeredFlyable +from ophyd_async.core.utils import in_micros +from ophyd_async.panda.table import SeqTable, SeqTableRow, seq_table_from_rows +from ophyd_async.panda.trigger import SeqTableInfo + + +def prepare_static_seq_table_flyer_and_detectors_with_same_trigger( + flyer: HardwareTriggeredFlyable[SeqTableInfo], + detectors: List[StandardDetector], + num: int, + width: float, + deadtime: float, + shutter_time: float, + repeats: int = 1, + period: float = 0.0, +): + + trigger_info = TriggerInfo( + num=num * repeats, + trigger=DetectorTrigger.constant_gate, + deadtime=deadtime, + livetime=width, + ) + + trigger_time = num * (width + deadtime) + pre_delay = max(period - 2 * shutter_time - trigger_time, 0) + + table: SeqTable = seq_table_from_rows( + # Wait for pre-delay then open shutter + SeqTableRow( + time1=in_micros(pre_delay), + time2=in_micros(shutter_time), + outa2=True, + ), + # Keeping shutter open, do N triggers + SeqTableRow( + repeats=num, + time1=in_micros(width), + outa1=True, + outb1=True, + time2=in_micros(deadtime), + outa2=True, + ), + # Add the shutter close + SeqTableRow(time2=in_micros(shutter_time)), + ) + + table_info = SeqTableInfo(table, repeats) + + for det in detectors: + yield from bps.prepare(det, trigger_info, wait=False, group="prep") + yield from bps.prepare(flyer, table_info, wait=False, group="prep") + yield from bps.wait(group="prep") diff --git a/tests/conftest.py b/tests/conftest.py index 575c47b122..f21d0094e1 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -8,7 +8,7 @@ import pytest from bluesky.run_engine import RunEngine, TransitionError -RECORD = str(Path(__file__).parent / "panda" / "db" / "panda.db") +PANDA_RECORD = str(Path(__file__).parent / "panda" / "db" / "panda.db") INCOMPLETE_BLOCK_RECORD = str( Path(__file__).parent / "panda" / "db" / "incomplete_block_panda.db" ) @@ -39,7 +39,7 @@ def clean_event_loop(): @pytest.fixture(scope="module", params=["pva"]) -def pva(): +def panda_pva(): processes = [ subprocess.Popen( [ @@ -49,7 +49,7 @@ def pva(): "-m", macros, "-d", - RECORD, + PANDA_RECORD, ], stdin=subprocess.PIPE, stdout=subprocess.PIPE, diff --git a/tests/core/test_flyer.py b/tests/core/test_flyer.py index f25a97c1af..747850f846 100644 --- a/tests/core/test_flyer.py +++ b/tests/core/test_flyer.py @@ -1,6 +1,6 @@ import time from enum import Enum -from typing import AsyncGenerator, AsyncIterator, Dict, Optional, Sequence +from typing import Any, AsyncGenerator, AsyncIterator, Dict, Optional, Sequence from unittest.mock import Mock import bluesky.plan_stubs as bps @@ -35,18 +35,16 @@ class DummyTriggerLogic(TriggerLogic[int]): def __init__(self): self.state = TriggerState.null - def trigger_info(self, value: int) -> TriggerInfo: - return TriggerInfo( - num=value, trigger=DetectorTrigger.constant_gate, deadtime=2, livetime=2 - ) - async def prepare(self, value: int): self.state = TriggerState.preparing return value - async def start(self): + async def kickoff(self): self.state = TriggerState.starting + async def complete(self): + self.state = TriggerState.null + async def stop(self): self.state = TriggerState.stopping @@ -123,13 +121,13 @@ async def dummy_arm_1(self=None, trigger=None, num=0, exposure=None): async def dummy_arm_2(self=None, trigger=None, num=0, exposure=None): return writers[1].dummy_signal.set(1) - detector_1 = StandardDetector( + detector_1: StandardDetector[Any] = StandardDetector( Mock(spec=DetectorControl, get_deadtime=lambda num: num, arm=dummy_arm_1), writers[0], name="detector_1", writer_timeout=3, ) - detector_2 = StandardDetector( + detector_2: StandardDetector[Any] = StandardDetector( Mock(spec=DetectorControl, get_deadtime=lambda num: num, arm=dummy_arm_2), writers[1], name="detector_2", @@ -153,6 +151,9 @@ def append_and_print(name, doc): trigger_logic = DummyTriggerLogic() flyer = HardwareTriggeredFlyable(trigger_logic, [], name="flyer") + trigger_info = TriggerInfo( + num=1, trigger=DetectorTrigger.constant_gate, deadtime=2, livetime=2 + ) def flying_plan(): yield from bps.stage_all(*detector_list, flyer) @@ -166,11 +167,11 @@ def flying_plan(): for detector in detector_list: yield from bps.prepare( detector, - flyer.trigger_info, + trigger_info, wait=True, ) - assert trigger_logic.state == TriggerState.preparing + assert flyer._trigger_logic.state == TriggerState.preparing for detector in detector_list: detector.controller.disarm.assert_called_once # type: ignore @@ -184,7 +185,7 @@ def flying_plan(): yield from bps.complete(flyer, wait=False, group="complete") for detector in detector_list: yield from bps.complete(detector, wait=False, group="complete") - assert trigger_logic.state == TriggerState.starting + assert flyer._trigger_logic.state == TriggerState.null # Manually incremenet the index as if a frame was taken for detector in detector_list: @@ -201,9 +202,8 @@ def flying_plan(): yield from bps.collect( *detector_list, return_payload=False, - # name="main_stream", + name="main_stream", ) - yield from bps.sleep(0.01) yield from bps.wait(group="complete") yield from bps.close_run() @@ -226,16 +226,6 @@ def flying_plan(): ] -def test_flyer_has_trigger_logic_property(): - flyer = HardwareTriggeredFlyable(DummyTriggerLogic(), [], name="flyer") - trigger_info = flyer.trigger_logic.trigger_info(1) - assert type(trigger_info) is TriggerInfo - assert trigger_info.num == 1 - assert trigger_info.trigger == "constant_gate" - assert trigger_info.deadtime == 2 - assert trigger_info.livetime == 2 - - # To do: Populate configuration signals async def test_describe_configuration(): flyer = HardwareTriggeredFlyable(DummyTriggerLogic(), [], name="flyer") diff --git a/tests/epics/areadetector/test_scans.py b/tests/epics/areadetector/test_scans.py index 7c27a9558f..3e13186d29 100644 --- a/tests/epics/areadetector/test_scans.py +++ b/tests/epics/areadetector/test_scans.py @@ -1,6 +1,6 @@ import asyncio from pathlib import Path -from typing import Optional +from typing import Any, Optional from unittest.mock import AsyncMock import bluesky.plan_stubs as bps @@ -28,15 +28,12 @@ class DummyTriggerLogic(TriggerLogic[int]): def __init__(self): ... - def trigger_info(self, value: int) -> TriggerInfo: - return TriggerInfo( - num=value, trigger=DetectorTrigger.constant_gate, deadtime=2, livetime=2 - ) - async def prepare(self, value: int): return value - async def start(self): ... + async def kickoff(self): ... + + async def complete(self): ... async def stop(self): ... @@ -85,7 +82,7 @@ async def test_hdf_writer_fails_on_timeout_with_stepscan( controller: ADSimController, ): set_sim_value(writer.hdf.file_path_exists, True) - detector = StandardDetector( + detector: StandardDetector[Any] = StandardDetector( controller, writer, name="detector", writer_timeout=0.01 ) @@ -99,10 +96,15 @@ def test_hdf_writer_fails_on_timeout_with_flyscan(RE: RunEngine, writer: HDFWrit controller = DummyController() set_sim_value(writer.hdf.file_path_exists, True) - detector = StandardDetector(controller, writer, writer_timeout=0.01) + detector: StandardDetector[Optional[TriggerInfo]] = StandardDetector( + controller, writer, writer_timeout=0.01 + ) trigger_logic = DummyTriggerLogic() flyer = HardwareTriggeredFlyable(trigger_logic, [], name="flyer") + trigger_info = TriggerInfo( + num=1, trigger=DetectorTrigger.constant_gate, deadtime=2, livetime=2 + ) def flying_plan(): """NOTE: the following is a workaround to ensure tests always pass. @@ -113,7 +115,7 @@ def flying_plan(): # Prepare the flyer first to get the trigger info for the detectors yield from bps.prepare(flyer, 1, wait=True) # prepare detector second. - yield from bps.prepare(detector, flyer.trigger_info, wait=True) + yield from bps.prepare(detector, trigger_info, wait=True) yield from bps.open_run() yield from bps.kickoff(flyer) yield from bps.kickoff(detector) diff --git a/tests/epics/demo/test_demo.py b/tests/epics/demo/test_demo.py index ebcb9be1c8..5b8847e842 100644 --- a/tests/epics/demo/test_demo.py +++ b/tests/epics/demo/test_demo.py @@ -1,6 +1,6 @@ import asyncio from typing import Dict -from unittest.mock import Mock, call +from unittest.mock import ANY, Mock, call import pytest from bluesky.protocols import Reading @@ -97,6 +97,28 @@ async def test_mover_moving_well(sim_mover: demo.Mover) -> None: done2.assert_called_once_with(s) +async def test_sensor_reading_shows_value(sim_sensor: demo.Sensor): + # Check default value + assert (await sim_sensor.value.get_value()) == pytest.approx(0.0) + assert (await sim_sensor.read()) == { + "sim_sensor-value": { + "alarm_severity": 0, + "timestamp": ANY, + "value": 0.0, + } + } + + # Check different value + set_sim_value(sim_sensor.value, 5.0) + assert (await sim_sensor.read()) == { + "sim_sensor-value": { + "alarm_severity": 0, + "timestamp": ANY, + "value": 5.0, + } + } + + async def test_mover_stopped(sim_mover: demo.Mover): callbacks = [] set_sim_callback(sim_mover.stop_, lambda r, v: callbacks.append(v)) diff --git a/tests/epics/test_pvi.py b/tests/epics/test_pvi.py new file mode 100644 index 0000000000..6f29441254 --- /dev/null +++ b/tests/epics/test_pvi.py @@ -0,0 +1,96 @@ +from typing import Optional + +import pytest + +from ophyd_async.core import ( + DEFAULT_TIMEOUT, + Device, + DeviceCollector, + DeviceVector, + SignalRW, + SignalX, +) +from ophyd_async.epics.pvi import fill_pvi_entries + + +class Block1(Device): + device_vector_signal_x: DeviceVector[SignalX] + device_vector_signal_rw: DeviceVector[SignalRW[float]] + signal_x: SignalX + signal_rw: SignalRW[int] + + +class Block2(Device): + device_vector: DeviceVector[Block1] + device: Block1 + signal_x: SignalX + signal_rw: SignalRW[int] + + +class Block3(Device): + device_vector: Optional[DeviceVector[Block2]] + device: Block2 + signal_device: Block1 + signal_x: SignalX + signal_rw: SignalRW[int] + + +@pytest.fixture +def pvi_test_device_t(): + """A fixture since pytest discourages init in test case classes""" + + class TestDevice(Block3, Device): + def __init__(self, prefix: str, name: str = ""): + self._prefix = prefix + super().__init__(name) + + async def connect( + self, sim: bool = False, timeout: float = DEFAULT_TIMEOUT + ) -> None: + await fill_pvi_entries(self, self._prefix + "PVI", timeout=timeout, sim=sim) + + await super().connect(sim) + + yield TestDevice + + +async def test_fill_pvi_entries_sim_mode(pvi_test_device_t): + async with DeviceCollector(sim=True): + test_device = pvi_test_device_t("PREFIX:") + + # device vectors are typed + assert isinstance(test_device.device_vector[1], Block2) + assert isinstance(test_device.device_vector[2], Block2) + + # elements of device vectors are typed recursively + assert test_device.device_vector[1].signal_rw._backend.datatype is int + assert isinstance(test_device.device_vector[1].device, Block1) + assert test_device.device_vector[1].device.signal_rw._backend.datatype is int + assert ( + test_device.device_vector[1].device.device_vector_signal_rw[1]._backend.datatype + is float + ) + + # top level blocks are typed + assert isinstance(test_device.signal_device, Block1) + assert isinstance(test_device.device, Block2) + + # elements of top level blocks are typed recursively + assert test_device.device.signal_rw._backend.datatype is int + assert isinstance(test_device.device.device, Block1) + assert test_device.device.device.signal_rw._backend.datatype is int + + assert test_device.signal_rw.parent == test_device + assert test_device.device_vector.parent == test_device + assert test_device.device_vector[1].parent == test_device.device_vector + assert test_device.device_vector[1].device.parent == test_device.device_vector[1] + + assert test_device.name == "test_device" + assert test_device.device_vector.name == "test_device-device_vector" + assert test_device.device_vector[1].name == "test_device-device_vector-1" + assert ( + test_device.device_vector[1].device.name == "test_device-device_vector-1-device" + ) + + # top level signals are typed + assert test_device.signal_rw._backend.datatype is int diff --git a/tests/panda/db/panda.db b/tests/panda/db/panda.db index 59a920ca27..305c9abae6 100644 --- a/tests/panda/db/panda.db +++ b/tests/panda/db/panda.db @@ -43,6 +43,66 @@ record(bi, "$(IOC_NAME=PANDAQSRV):SEQ1:ACTIVE") }) } +record(longin, "$(IOC_NAME=PANDAQSRV):SEQ1:REPEATS") +{ + field(PINI, "YES") + info(Q:group, { + "$(IOC_NAME=PANDAQSRV):SEQ1:PVI": { + "pvi.repeats.rw": { + "+channel": "NAME", + "+type": "plain" + } + } + }) +} + +record(ai, "$(IOC_NAME=PANDAQSRV):SEQ1:PRESCALE") +{ + field(PINI, "YES") + info(Q:group, { + "$(IOC_NAME=PANDAQSRV):SEQ1:PVI": { + "pvi.prescale.rw": { + "+channel": "NAME", + "+type": "plain" + } + } + }) +} + +record(mbbi, "$(IOC_NAME=PANDAQSRV):SEQ1:PRESCALE:UNITS") +{ + field(ZRST, "min") + field(ZRVL, "0") + field(ONST, "s") + field(ONVL, "1") + field(TWST, "ms") + field(TWVL, "2") + field(THST, "us") + field(THVL, "3") + field(PINI, "YES") + info(Q:group, { + "$(IOC_NAME=PANDAQSRV):SEQ1:PVI": { + "pvi.prescale_units.rw": { + "+channel": "NAME", + "+type": "plain" + } + } + }) +} + +record(stringout, "$(IOC_NAME=PANDAQSRV):SEQ1:ENABLE") +{ + field(PINI, "YES") + info(Q:group, { + "$(IOC_NAME=PANDAQSRV):SEQ1:PVI": { + "pvi.enable.rw": { + "+channel": "NAME", + "+type": "plain" + } + } + }) +} + record(bi, "$(IOC_NAME=PANDAQSRV):PCAP:ACTIVE") { field(ZNAM, "0") diff --git a/tests/panda/test_panda.py b/tests/panda/test_panda.py index 8fcf6836bd..f27befd792 100644 --- a/tests/panda/test_panda.py +++ b/tests/panda/test_panda.py @@ -6,10 +6,17 @@ import numpy as np import pytest -from ophyd_async.core import DeviceCollector +from ophyd_async.core import Device, DeviceCollector from ophyd_async.core.utils import NotConnected -from ophyd_async.panda import PandA, PVIEntry, SeqTable, SeqTrigger -from ophyd_async.panda.panda import _remove_inconsistent_blocks +from ophyd_async.epics.pvi import PVIEntry +from ophyd_async.panda import ( + PandA, + PcapBlock, + PulseBlock, + SeqBlock, + SeqTable, + SeqTrigger, +) class DummyDict: @@ -55,21 +62,6 @@ def test_panda_name_set(): assert panda.name == "panda" -async def test_inconsistent_blocks(): - dummy_pvi = { - "pcap": {}, - "pcap1": {}, - "pulse1": {}, - "pulse2": {}, - "sfp3_sync_out1": {}, - "sfp3_sync_out": {}, - } - - _remove_inconsistent_blocks(dummy_pvi) - assert "sfp3_sync_out1" not in dummy_pvi - assert "pcap1" not in dummy_pvi - - async def test_panda_children_connected(sim_panda: PandA): # try to set and retrieve from simulated values... table = SeqTable( @@ -106,13 +98,17 @@ async def test_panda_children_connected(sim_panda: PandA): assert readback_seq == table -async def test_panda_with_missing_blocks(pva): +async def test_panda_with_missing_blocks(panda_pva): panda = PandA("PANDAQSRVI:") - with pytest.raises(AssertionError): + with pytest.raises(RuntimeError) as exc: await panda.connect() + assert ( + str(exc.value) + == "sub device `pcap:` was not provided by pvi" + ) -async def test_panda_with_extra_blocks_and_signals(pva): +async def test_panda_with_extra_blocks_and_signals(panda_pva): panda = PandA("PANDAQSRV:") await panda.connect() @@ -122,13 +118,35 @@ async def test_panda_with_extra_blocks_and_signals(pva): assert panda.pcap.newsignal # type: ignore -async def test_panda_block_missing_signals(pva): +async def test_panda_gets_types_from_common_class(panda_pva): + panda = PandA("PANDAQSRV:") + await panda.connect() + + # sub devices have the correct types + assert isinstance(panda.pcap, PcapBlock) + assert isinstance(panda.seq[1], SeqBlock) + assert isinstance(panda.pulse[1], PulseBlock) + + # others are just Devices + assert isinstance(panda.extra, Device) + + # predefined signals get set up with the correct datatype + assert panda.pcap.active._backend.datatype is bool + + # works with custom datatypes + assert panda.seq[1].table._backend.datatype is SeqTable + + # others are given the None datatype + assert panda.pcap.newsignal._backend.datatype is None + + +async def test_panda_block_missing_signals(panda_pva): panda = PandA("PANDAQSRVIB:") with pytest.raises(Exception) as exc: await panda.connect() assert ( - exc.__str__ + str(exc) == "PandA has a pulse block containing a width signal which has not been " + "retrieved by PVI." ) diff --git a/tests/panda/test_panda_utils.py b/tests/panda/test_panda_utils.py index a30bb3fc9a..9dc21e6971 100644 --- a/tests/panda/test_panda_utils.py +++ b/tests/panda/test_panda_utils.py @@ -25,13 +25,27 @@ async def test_save_panda(mock_save_to_yaml, sim_panda, RE: RunEngine): mock_save_to_yaml.assert_called_once() assert mock_save_to_yaml.call_args[0] == ( [ - {"phase_1_signal_units": 0}, { - "pcap.arm": 0.0, + "phase_1_signal_units": 0, + "seq.1.prescale_units": "min", + "seq.2.prescale_units": "min", + }, + { + "pcap.arm": False, "pulse.1.delay": 0.0, "pulse.1.width": 0.0, + "pulse.2.delay": 0.0, + "pulse.2.width": 0.0, "seq.1.table": {}, "seq.1.active": False, + "seq.1.repeats": 0, + "seq.1.prescale": 0.0, + "seq.1.enable": "", + "seq.2.table": {}, + "seq.2.active": False, + "seq.2.repeats": 0, + "seq.2.prescale": 0.0, + "seq.2.enable": "", }, ], "path", diff --git a/tests/panda/test_trigger.py b/tests/panda/test_trigger.py new file mode 100644 index 0000000000..a4c3dc8a78 --- /dev/null +++ b/tests/panda/test_trigger.py @@ -0,0 +1,22 @@ +import pytest + +from ophyd_async.core.device import DeviceCollector +from ophyd_async.panda import PandA +from ophyd_async.panda.trigger import StaticSeqTableTriggerLogic + + +@pytest.fixture +async def panda(): + async with DeviceCollector(sim=True): + sim_panda = PandA("PANDAQSRV:", "sim_panda") + + assert sim_panda.name == "sim_panda" + yield sim_panda + + +def test_trigger_logic_has_given_methods(panda: PandA): + trigger_logic = StaticSeqTableTriggerLogic(panda.seq[1]) + assert hasattr(trigger_logic, "prepare") + assert hasattr(trigger_logic, "kickoff") + assert hasattr(trigger_logic, "complete") + assert hasattr(trigger_logic, "stop") diff --git a/tests/test_flyer_with_panda.py b/tests/test_flyer_with_panda.py new file mode 100644 index 0000000000..573177e701 --- /dev/null +++ b/tests/test_flyer_with_panda.py @@ -0,0 +1,220 @@ +import time +from typing import AsyncGenerator, AsyncIterator, Dict, Optional, Sequence +from unittest.mock import Mock + +import bluesky.plan_stubs as bps +import pytest +from bluesky.protocols import Descriptor, StreamAsset +from bluesky.run_engine import RunEngine +from event_model import ComposeStreamResourceBundle, compose_stream_resource + +from ophyd_async.core import ( + DEFAULT_TIMEOUT, + DetectorControl, + DetectorWriter, + HardwareTriggeredFlyable, + SignalRW, + SimSignalBackend, +) +from ophyd_async.core.detector import StandardDetector +from ophyd_async.core.device import DeviceCollector +from ophyd_async.core.signal import observe_value, set_sim_value +from ophyd_async.panda import PandA +from ophyd_async.panda.trigger import StaticSeqTableTriggerLogic +from ophyd_async.planstubs import ( + prepare_static_seq_table_flyer_and_detectors_with_same_trigger, +) + + +class DummyWriter(DetectorWriter): + def __init__(self, name: str, shape: Sequence[int]): + self.dummy_signal = SignalRW(backend=SimSignalBackend(int, source="test")) + self._shape = shape + self._name = name + self._file: Optional[ComposeStreamResourceBundle] = None + self._last_emitted = 0 + self.index = 0 + + async def open(self, multiplier: int = 1) -> Dict[str, Descriptor]: + return { + self._name: Descriptor( + source="sim://some-source", + shape=self._shape, + dtype="number", + external="STREAM:", + ) + } + + async def observe_indices_written( + self, timeout=DEFAULT_TIMEOUT + ) -> AsyncGenerator[int, None]: + num_captured: int + async for num_captured in observe_value(self.dummy_signal, timeout): + yield num_captured + + async def get_indices_written(self) -> int: + return self.index + + async def collect_stream_docs( + self, indices_written: int + ) -> AsyncIterator[StreamAsset]: + if indices_written: + if not self._file: + self._file = compose_stream_resource( + spec="AD_HDF5_SWMR_SLICE", + root="/", + data_key=self._name, + resource_path="", + resource_kwargs={ + "path": "", + "multiplier": 1, + "timestamps": "/entry/instrument/NDAttributes/NDArrayTimeStamp", + }, + ) + yield "stream_resource", self._file.stream_resource_doc + + if indices_written >= self._last_emitted: + indices = dict( + start=self._last_emitted, + stop=indices_written, + ) + self._last_emitted = indices_written + self._last_flush = time.monotonic() + yield "stream_datum", self._file.compose_stream_datum(indices) + + async def close(self) -> None: + self._file = None + + +@pytest.fixture +async def detector_list(RE: RunEngine) -> tuple[StandardDetector, StandardDetector]: + writers = [DummyWriter("testa", (1, 1)), DummyWriter("testb", (1, 1))] + await writers[0].dummy_signal.connect(sim=True) + await writers[1].dummy_signal.connect(sim=True) + + async def dummy_arm_1(self=None, trigger=None, num=0, exposure=None): + return writers[0].dummy_signal.set(1) + + async def dummy_arm_2(self=None, trigger=None, num=0, exposure=None): + return writers[1].dummy_signal.set(1) + + detector_1: StandardDetector = StandardDetector( + Mock(spec=DetectorControl, get_deadtime=lambda num: num, arm=dummy_arm_1), + writers[0], + name="detector_1", + writer_timeout=3, + ) + detector_2: StandardDetector = StandardDetector( + Mock(spec=DetectorControl, get_deadtime=lambda num: num, arm=dummy_arm_2), + writers[1], + name="detector_2", + writer_timeout=3, + ) + return (detector_1, detector_2) + + +@pytest.fixture +async def panda(): + async with DeviceCollector(sim=True): + sim_panda = PandA("PANDAQSRV:", "sim_panda") + + assert sim_panda.name == "sim_panda" + yield sim_panda + + +async def test_hardware_triggered_flyable_with_static_seq_table_logic( + RE: RunEngine, + detector_list: tuple[StandardDetector], + panda, +): + """Run a dummy scan using a flyer with a prepare plan stub. + + This runs a dummy plan with two detectors and a flyer that uses + StaticSeqTableTriggerLogic. The flyer and detectors are prepared with the + prepare_static_seq_table_flyer_and_detectors_with_same_trigger plan stub. + This stub creates trigger_info and a sequence table from given parameters + and prepares the fly and both detectors with the same trigger info. + + """ + names = [] + docs = [] + + def append_and_print(name, doc): + names.append(name) + docs.append(doc) + + RE.subscribe(append_and_print) + + shutter_time = 0.004 + exposure = 1 + deadtime = max(det.controller.get_deadtime(1) for det in detector_list) + + trigger_logic = StaticSeqTableTriggerLogic(panda.seq[1]) + flyer = HardwareTriggeredFlyable(trigger_logic, [], name="flyer") + + def flying_plan(): + yield from bps.stage_all(*detector_list, flyer) + + yield from prepare_static_seq_table_flyer_and_detectors_with_same_trigger( + flyer, + detector_list, + num=1, + width=exposure, + deadtime=deadtime, + shutter_time=shutter_time, + ) + + for detector in detector_list: + detector.controller.disarm.assert_called_once # type: ignore + + yield from bps.open_run() + yield from bps.declare_stream(*detector_list, name="main_stream", collect=True) + + set_sim_value(flyer.trigger_logic.seq.active, 1) + + yield from bps.kickoff(flyer, wait=True) + for detector in detector_list: + yield from bps.kickoff(detector) + + yield from bps.complete(flyer, wait=False, group="complete") + for detector in detector_list: + yield from bps.complete(detector, wait=False, group="complete") + + # Manually incremenet the index as if a frame was taken + for detector in detector_list: + detector.writer.index += 1 + + set_sim_value(flyer.trigger_logic.seq.active, 0) + + done = False + while not done: + try: + yield from bps.wait(group="complete", timeout=0.5) + except TimeoutError: + pass + else: + done = True + yield from bps.collect( + *detector_list, + return_payload=False, + name="main_stream", + ) + yield from bps.wait(group="complete") + yield from bps.close_run() + + yield from bps.unstage_all(flyer, *detector_list) + for detector in detector_list: + assert detector.controller.disarm.called # type: ignore + + # fly scan + RE(flying_plan()) + + assert names == [ + "start", + "descriptor", + "stream_resource", + "stream_datum", + "stream_resource", + "stream_datum", + "stop", + ]