From c90e403c5955c3990d0ca53bb9213838d5c7513e Mon Sep 17 00:00:00 2001 From: Zoheb Shaikh Date: Wed, 4 Dec 2024 09:16:38 +0000 Subject: [PATCH] update to bps.collect_while_completeing --- .../pull_request_template.md | 2 + .github/workflows/_test.yml | 1 + .github/workflows/ci.yml | 2 +- .pre-commit-config.yaml | 8 + pyproject.toml | 51 ++ src/ophyd_async/core/_detector.py | 18 +- src/ophyd_async/core/_device.py | 32 +- src/ophyd_async/core/_mock_signal_utils.py | 6 +- src/ophyd_async/core/_signal.py | 49 +- src/ophyd_async/core/_table.py | 13 +- src/ophyd_async/core/_utils.py | 13 +- .../epics/adaravis/_aravis_controller.py | 16 +- src/ophyd_async/epics/adaravis/_aravis_io.py | 8 +- src/ophyd_async/epics/adcore/_core_io.py | 42 +- src/ophyd_async/epics/adcore/_core_logic.py | 2 +- src/ophyd_async/epics/adcore/_hdf_writer.py | 9 +- .../epics/adcore/_single_trigger.py | 2 +- src/ophyd_async/epics/adcore/_utils.py | 70 +- .../epics/adkinetix/_kinetix_controller.py | 14 +- .../epics/adkinetix/_kinetix_io.py | 14 +- src/ophyd_async/epics/adpilatus/_pilatus.py | 6 +- .../epics/adpilatus/_pilatus_controller.py | 8 +- .../epics/adpilatus/_pilatus_io.py | 10 +- .../epics/adsimdetector/_sim_controller.py | 4 +- .../epics/advimba/_vimba_controller.py | 28 +- src/ophyd_async/epics/advimba/_vimba_io.py | 46 +- src/ophyd_async/epics/core/_pvi_connector.py | 6 +- src/ophyd_async/epics/demo/_mover.py | 4 +- src/ophyd_async/epics/demo/_sensor.py | 4 +- .../epics/eiger/_eiger_controller.py | 8 +- src/ophyd_async/epics/eiger/_eiger_io.py | 6 +- src/ophyd_async/epics/motor.py | 13 +- src/ophyd_async/epics/testing/__init__.py | 24 + src/ophyd_async/epics/testing/_example_ioc.py | 105 +++ src/ophyd_async/epics/testing/_utils.py | 78 +++ src/ophyd_async/epics/testing/test_records.db | 158 +++++ .../epics/testing/test_records_pva.db | 177 +++++ src/ophyd_async/fastcs/core.py | 4 +- src/ophyd_async/fastcs/panda/_block.py | 18 +- src/ophyd_async/fastcs/panda/_control.py | 4 +- src/ophyd_async/fastcs/panda/_hdf_panda.py | 5 +- src/ophyd_async/fastcs/panda/_trigger.py | 14 +- src/ophyd_async/plan_stubs/_fly.py | 21 +- src/ophyd_async/tango/__init__.py | 43 -- .../tango/base_devices/__init__.py | 4 - .../tango/{signal => core}/__init__.py | 9 +- .../{base_devices => core}/_base_device.py | 102 ++- .../tango/{signal => core}/_signal.py | 16 +- .../{base_devices => core}/_tango_readable.py | 7 +- .../{signal => core}/_tango_transport.py | 0 src/ophyd_async/tango/demo/_counter.py | 13 +- src/ophyd_async/tango/demo/_mover.py | 15 +- src/ophyd_async/testing/__init__.py | 22 + system_tests/epics/eiger/test_eiger_system.py | 2 +- tests/conftest.py | 2 +- tests/core/test_device.py | 18 +- tests/core/test_device_save_loader.py | 86 +-- tests/core/test_flyer.py | 38 +- tests/core/test_mock_signal_backend.py | 4 +- tests/core/test_observe.py | 27 +- tests/core/test_soft_signal_backend.py | 14 +- tests/core/test_subset_enum.py | 12 +- tests/core/test_table.py | 6 +- tests/epics/adaravis/test_aravis.py | 4 +- tests/epics/adcore/test_drivers.py | 8 +- tests/epics/adcore/test_scans.py | 2 +- tests/epics/adcore/test_single_trigger.py | 4 +- tests/epics/adkinetix/test_kinetix.py | 10 +- tests/epics/adpilatus/test_pilatus.py | 24 +- tests/epics/adsimdetector/test_sim.py | 10 +- tests/epics/advimba/test_vimba.py | 14 +- tests/epics/demo/test_demo.py | 15 +- tests/epics/eiger/test_eiger_detector.py | 2 +- tests/epics/signal/test_records.db | 330 --------- tests/epics/signal/test_signals.py | 639 ++++++++++-------- tests/epics/test_motor.py | 32 +- tests/fastcs/panda/test_panda_connect.py | 6 +- tests/fastcs/panda/test_panda_control.py | 6 +- tests/fastcs/panda/test_trigger.py | 4 +- tests/tango/test_base_device.py | 2 +- tests/tango/test_tango_signals.py | 5 +- tests/tango/test_tango_transport.py | 2 +- tests/test_data/test_yaml_save.yml | 86 ++- 83 files changed, 1551 insertions(+), 1217 deletions(-) create mode 100644 src/ophyd_async/epics/testing/__init__.py create mode 100644 src/ophyd_async/epics/testing/_example_ioc.py create mode 100644 src/ophyd_async/epics/testing/_utils.py create mode 100644 src/ophyd_async/epics/testing/test_records.db create mode 100644 src/ophyd_async/epics/testing/test_records_pva.db delete mode 100644 src/ophyd_async/tango/base_devices/__init__.py rename src/ophyd_async/tango/{signal => core}/__init__.py (81%) rename src/ophyd_async/tango/{base_devices => core}/_base_device.py (54%) rename src/ophyd_async/tango/{signal => core}/_signal.py (94%) rename src/ophyd_async/tango/{base_devices => core}/_tango_readable.py (87%) rename src/ophyd_async/tango/{signal => core}/_tango_transport.py (100%) create mode 100644 src/ophyd_async/testing/__init__.py delete mode 100644 tests/epics/signal/test_records.db diff --git a/.github/PULL_REQUEST_TEMPLATE/pull_request_template.md b/.github/PULL_REQUEST_TEMPLATE/pull_request_template.md index 8200afe5c4..37552994d5 100644 --- a/.github/PULL_REQUEST_TEMPLATE/pull_request_template.md +++ b/.github/PULL_REQUEST_TEMPLATE/pull_request_template.md @@ -6,3 +6,5 @@ Fixes #ISSUE ### Checks for reviewer - [ ] Would the PR title make sense to a user on a set of release notes +- [ ] If the change requires a bump in an IOC version, is that specified in a `##Changes` section in the body of the PR +- [ ] If the change requires a bump in the PandABlocks-ioc version, is the `ophyd_async.fastcs.panda._hdf_panda.MINIMUM_PANDA_IOC` variable updated to match diff --git a/.github/workflows/_test.yml b/.github/workflows/_test.yml index 37d997af1a..f652d4145f 100644 --- a/.github/workflows/_test.yml +++ b/.github/workflows/_test.yml @@ -49,6 +49,7 @@ jobs: with: python-version: ${{ inputs.python-version }} pip-install: ".[dev]" + - name: Run tests run: tox -e tests diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d11c9c6dca..1f57059612 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -21,7 +21,7 @@ jobs: strategy: matrix: runs-on: ["ubuntu-latest", "windows-latest"] # can add macos-latest - python-version: ["3.10","3.11"] # 3.12 should be added when p4p is updated + python-version: ["3.10", "3.11"] # 3.12 should be added when p4p is updated include: # Include one that runs in the dev environment - runs-on: "ubuntu-latest" diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 60fc23f9a7..72e6dd577d 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -22,3 +22,11 @@ repos: entry: ruff format --force-exclude types: [python] require_serial: true + + - id: import-contracts + name: Ensure import directionality + pass_filenames: false + language: system + entry: lint-imports + types: [python] + require_serial: false diff --git a/pyproject.toml b/pyproject.toml index 41975b0ae7..ef4834750c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -42,6 +42,7 @@ dev = [ "inflection", "ipython", "ipywidgets", + "import-linter", "matplotlib", "myst-parser", "numpydoc", @@ -164,3 +165,53 @@ lint.preview = true # so that preview mode PLC2701 is enabled # See https://github.com/DiamondLightSource/python-copier-template/issues/154 # Remove this line to forbid private member access in tests "tests/**/*" = ["SLF001"] + + +[tool.importlinter] +root_package = "ophyd_async" + +[[tool.importlinter.contracts]] +name = "Core is independent" +type = "independence" +modules = "ophyd_async.core" + +[[tool.importlinter.contracts]] +name = "Epics depends only on core" +type = "forbidden" +source_modules = "ophyd_async.epics" +forbidden_modules = [ + "ophyd_async.fastcs", + "ophyd_async.plan_stubs", + "ophyd_async.sim", + "ophyd_async.tango", +] + +[[tool.importlinter.contracts]] +name = "tango depends only on core" +type = "forbidden" +source_modules = "ophyd_async.tango" +forbidden_modules = [ + "ophyd_async.epics", + "ophyd_async.fastcs", + "ophyd_async.plan_stubs", + "ophyd_async.sim", +] + + +[[tool.importlinter.contracts]] +name = "sim depends only on core" +type = "forbidden" +source_modules = "ophyd_async.sim" +forbidden_modules = [ + "ophyd_async.epics", + "ophyd_async.fastcs", + "ophyd_async.plan_stubs", + "ophyd_async.tango", +] + + +[[tool.importlinter.contracts]] +name = "Fastcs depends only on core, epics, tango" +type = "forbidden" +source_modules = "ophyd_async.fastcs" +forbidden_modules = ["ophyd_async.plan_stubs", "ophyd_async.sim"] diff --git a/src/ophyd_async/core/_detector.py b/src/ophyd_async/core/_detector.py index d2990c9fc8..507c33f50e 100644 --- a/src/ophyd_async/core/_detector.py +++ b/src/ophyd_async/core/_detector.py @@ -30,13 +30,13 @@ class DetectorTrigger(StrictEnum): """Type of mechanism for triggering a detector to take frames""" #: Detector generates internal trigger for given rate - internal = "internal" + INTERNAL = "internal" #: Expect a series of arbitrary length trigger signals - edge_trigger = "edge_trigger" + EDGE_TRIGGER = "edge_trigger" #: Expect a series of constant width external gate signals - constant_gate = "constant_gate" + CONSTANT_GATE = "constant_gate" #: Expect a series of variable width external gate signals - variable_gate = "variable_gate" + VARIABLE_GATE = "variable_gate" class TriggerInfo(BaseModel): @@ -53,7 +53,7 @@ class TriggerInfo(BaseModel): #: - 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) + trigger: DetectorTrigger = Field(default=DetectorTrigger.INTERNAL) #: What is the minimum deadtime between triggers deadtime: float | None = Field(default=None, ge=0) #: What is the maximum high time of the triggers @@ -265,14 +265,14 @@ async def trigger(self) -> None: await self.prepare( TriggerInfo( number_of_triggers=1, - trigger=DetectorTrigger.internal, + trigger=DetectorTrigger.INTERNAL, deadtime=None, livetime=None, frame_timeout=None, ) ) assert self._trigger_info - assert self._trigger_info.trigger is DetectorTrigger.internal + assert self._trigger_info.trigger is DetectorTrigger.INTERNAL # Arm the detector and wait for it to finish. indices_written = await self.writer.get_indices_written() await self.controller.arm() @@ -303,7 +303,7 @@ async def prepare(self, value: TriggerInfo) -> None: Args: value: TriggerInfo describing how to trigger the detector """ - if value.trigger != DetectorTrigger.internal: + if value.trigger != DetectorTrigger.INTERNAL: assert ( value.deadtime ), "Deadtime must be supplied when in externally triggered mode" @@ -323,7 +323,7 @@ async def prepare(self, value: TriggerInfo) -> None: self._describe, _ = await asyncio.gather( self.writer.open(value.multiplier), self.controller.prepare(value) ) - if value.trigger != DetectorTrigger.internal: + if value.trigger != DetectorTrigger.INTERNAL: await self.controller.arm() self._fly_start = time.monotonic() diff --git a/src/ophyd_async/core/_device.py b/src/ophyd_async/core/_device.py index 11501dd228..0439052db9 100644 --- a/src/ophyd_async/core/_device.py +++ b/src/ophyd_async/core/_device.py @@ -71,13 +71,16 @@ class Device(HasName, Connectable): _connect_task: asyncio.Task | None = None # The mock if we have connected in mock mode _mock: LazyMock | None = None + # The separator to use when making child names + _child_name_separator: str = "-" def __init__( self, name: str = "", connector: DeviceConnector | None = None ) -> None: self._connector = connector or DeviceConnector() self._connector.create_children_from_annotations(self) - self.set_name(name) + if name: + self.set_name(name) @property def name(self) -> str: @@ -97,21 +100,30 @@ def log(self) -> LoggerAdapter: getLogger("ophyd_async.devices"), {"ophyd_async_device_name": self.name} ) - def set_name(self, name: str): + def set_name(self, name: str, *, child_name_separator: str | None = None) -> None: """Set ``self.name=name`` and each ``self.child.name=name+"-child"``. Parameters ---------- name: New name to set + child_name_separator: + Use this as a separator instead of "-". Use "_" instead to make the same + names as the equivalent ophyd sync device. """ self._name = name + if child_name_separator: + self._child_name_separator = child_name_separator # Ensure logger is recreated after a name change if "log" in self.__dict__: del self.log - for child_name, child in self.children(): - child_name = f"{self.name}-{child_name.strip('_')}" if self.name else "" - child.set_name(child_name) + for attr_name, child in self.children(): + child_name = ( + f"{self.name}{self._child_name_separator}{attr_name}" + if self.name + else "" + ) + child.set_name(child_name, child_name_separator=self._child_name_separator) def __setattr__(self, name: str, value: Any) -> None: # Bear in mind that this function is called *a lot*, so @@ -147,6 +159,10 @@ async def connect( timeout: Time to wait before failing with a TimeoutError. """ + assert hasattr(self, "_connector"), ( + f"{self}: doesn't have attribute `_connector`," + " did you call `super().__init__` in your `__init__` method?" + ) if mock: # Always connect in mock mode serially if isinstance(mock, LazyMock): @@ -247,6 +263,8 @@ class DeviceCollector: set_name: If True, call ``device.set_name(variable_name)`` on all collected Devices + child_name_separator: + Use this as a separator if we call ``set_name``. connect: If True, call ``device.connect(mock)`` in parallel on all collected Devices @@ -271,11 +289,13 @@ class DeviceCollector: def __init__( self, set_name=True, + child_name_separator: str = "-", connect=True, mock=False, timeout: float = 10.0, ): self._set_name = set_name + self._child_name_separator = child_name_separator self._connect = connect self._mock = mock self._timeout = timeout @@ -311,7 +331,7 @@ async def _on_exit(self) -> None: for name, obj in self._objects_on_exit.items(): if name not in self._names_on_enter and isinstance(obj, Device): if self._set_name and not obj.name: - obj.set_name(name) + obj.set_name(name, child_name_separator=self._child_name_separator) if self._connect: connect_coroutines[name] = obj.connect( self._mock, timeout=self._timeout diff --git a/src/ophyd_async/core/_mock_signal_utils.py b/src/ophyd_async/core/_mock_signal_utils.py index 08976a0468..7037e8deba 100644 --- a/src/ophyd_async/core/_mock_signal_utils.py +++ b/src/ophyd_async/core/_mock_signal_utils.py @@ -1,5 +1,5 @@ from collections.abc import Awaitable, Callable, Iterable -from contextlib import asynccontextmanager, contextmanager +from contextlib import contextmanager from unittest.mock import AsyncMock, Mock from ._device import Device @@ -40,8 +40,8 @@ def set_mock_put_proceeds(signal: Signal, proceeds: bool): backend.put_proceeds.clear() -@asynccontextmanager -async def mock_puts_blocked(*signals: Signal): +@contextmanager +def mock_puts_blocked(*signals: Signal): for signal in signals: set_mock_put_proceeds(signal, False) yield diff --git a/src/ophyd_async/core/_signal.py b/src/ophyd_async/core/_signal.py index 8aa2d95162..180cee5604 100644 --- a/src/ophyd_async/core/_signal.py +++ b/src/ophyd_async/core/_signal.py @@ -2,6 +2,7 @@ import asyncio import functools +import time from collections.abc import AsyncGenerator, Awaitable, Callable, Mapping from typing import Any, Generic, cast @@ -122,7 +123,7 @@ async def get_value(self) -> SignalDatatypeT: def _callback(self, reading: Reading[SignalDatatypeT]): self._signal.log.debug( - f"Updated subscription: reading of source {self._signal.source} changed" + f"Updated subscription: reading of source {self._signal.source} changed " f"from {self._reading} to {reading}" ) self._reading = reading @@ -425,6 +426,7 @@ async def observe_value( signal: SignalR[SignalDatatypeT], timeout: float | None = None, done_status: Status | None = None, + done_timeout: float | None = None, ) -> AsyncGenerator[SignalDatatypeT, None]: """Subscribe to the value of a signal so it can be iterated from. @@ -439,9 +441,17 @@ async def observe_value( done_status: If this status is complete, stop observing and make the iterator return. If it raises an exception then this exception will be raised by the iterator. + done_timeout: + If given, the maximum time to watch a signal, in seconds. If the loop is still + being watched after this length, raise asyncio.TimeoutError. This should be used + instead of on an 'asyncio.wait_for' timeout Notes ----- + Due to a rare condition with busy signals, it is not recommended to use this + function with asyncio.timeout, including in an 'asyncio.wait_for' loop. Instead, + this timeout should be given to the done_timeout parameter. + Example usage:: async for value in observe_value(sig): @@ -449,15 +459,26 @@ async def observe_value( """ async for _, value in observe_signals_value( - signal, timeout=timeout, done_status=done_status + signal, + timeout=timeout, + done_status=done_status, + done_timeout=done_timeout, ): yield value +def _get_iteration_timeout( + timeout: float | None, overall_deadline: float | None +) -> float | None: + overall_deadline = overall_deadline - time.monotonic() if overall_deadline else None + return min([x for x in [overall_deadline, timeout] if x is not None], default=None) + + async def observe_signals_value( *signals: SignalR[SignalDatatypeT], timeout: float | None = None, done_status: Status | None = None, + done_timeout: float | None = None, ) -> AsyncGenerator[tuple[SignalR[SignalDatatypeT], SignalDatatypeT], None]: """Subscribe to the value of a signal so it can be iterated from. @@ -472,6 +493,10 @@ async def observe_signals_value( done_status: If this status is complete, stop observing and make the iterator return. If it raises an exception then this exception will be raised by the iterator. + done_timeout: + If given, the maximum time to watch a signal, in seconds. If the loop is still + being watched after this length, raise asyncio.TimeoutError. This should be used + instead of on an 'asyncio.wait_for' timeout Notes ----- @@ -486,12 +511,6 @@ async def observe_signals_value( q: asyncio.Queue[tuple[SignalR[SignalDatatypeT], SignalDatatypeT] | Status] = ( asyncio.Queue() ) - if timeout is None: - get_value = q.get - else: - - async def get_value(): - return await asyncio.wait_for(q.get(), timeout) cbs: dict[SignalR, Callback] = {} for signal in signals: @@ -504,13 +523,17 @@ def queue_value(value: SignalDatatypeT, signal=signal): if done_status is not None: done_status.add_callback(q.put_nowait) - + overall_deadline = time.monotonic() + done_timeout if done_timeout else None try: while True: - # yield here in case something else is filling the queue - # like in test_observe_value_times_out_with_no_external_task() - await asyncio.sleep(0) - item = await get_value() + if overall_deadline and time.monotonic() >= overall_deadline: + raise asyncio.TimeoutError( + f"observe_value was still observing signals " + f"{[signal.source for signal in signals]} after " + f"timeout {done_timeout}s" + ) + iteration_timeout = _get_iteration_timeout(timeout, overall_deadline) + item = await asyncio.wait_for(q.get(), iteration_timeout) if done_status and item is done_status: if exc := done_status.exception(): raise exc diff --git a/src/ophyd_async/core/_table.py b/src/ophyd_async/core/_table.py index 2b58a1af87..bf912fea22 100644 --- a/src/ophyd_async/core/_table.py +++ b/src/ophyd_async/core/_table.py @@ -1,6 +1,6 @@ from __future__ import annotations -from collections.abc import Sequence +from collections.abc import Callable, Sequence from typing import Annotated, Any, TypeVar, get_origin import numpy as np @@ -19,6 +19,13 @@ def _concat(value1, value2): return value1 + value2 +def _make_default_factory(dtype: np.dtype) -> Callable[[], np.ndarray]: + def numpy_array_default_factory() -> np.ndarray: + return np.array([], dtype) + + return numpy_array_default_factory + + class Table(BaseModel): """An abstraction of a Table of str to numpy array.""" @@ -45,9 +52,7 @@ def __init_subclass__(cls): NpArrayPydanticAnnotation.factory( data_type=dtype.type, dimensions=1, strict_data_typing=False ), - Field( - default_factory=lambda dtype=dtype: np.array([], dtype=dtype) - ), + Field(default_factory=_make_default_factory(dtype)), ] elif get_origin(anno) is Sequence: new_anno = Annotated[anno, Field(default_factory=list)] diff --git a/src/ophyd_async/core/_utils.py b/src/ophyd_async/core/_utils.py index edb8b2c4b2..43ce473b8b 100644 --- a/src/ophyd_async/core/_utils.py +++ b/src/ophyd_async/core/_utils.py @@ -17,11 +17,20 @@ ErrorText = str | Mapping[str, Exception] -class StrictEnum(str, Enum): +class StrictEnumMeta(EnumMeta): + def __new__(metacls, *args, **kwargs): + ret = super().__new__(metacls, *args, **kwargs) + lowercase_names = [x.name for x in ret if not x.name.isupper()] # type: ignore + if lowercase_names: + raise TypeError(f"Names {lowercase_names} should be uppercase") + return ret + + +class StrictEnum(str, Enum, metaclass=StrictEnumMeta): """All members should exist in the Backend, and there will be no extras""" -class SubsetEnumMeta(EnumMeta): +class SubsetEnumMeta(StrictEnumMeta): def __call__(self, value, *args, **kwargs): # type: ignore if isinstance(value, str) and not isinstance(value, self): return value diff --git a/src/ophyd_async/epics/adaravis/_aravis_controller.py b/src/ophyd_async/epics/adaravis/_aravis_controller.py index ee75affe4d..2137785ddd 100644 --- a/src/ophyd_async/epics/adaravis/_aravis_controller.py +++ b/src/ophyd_async/epics/adaravis/_aravis_controller.py @@ -31,9 +31,9 @@ def get_deadtime(self, exposure: float | None) -> float: async def prepare(self, trigger_info: TriggerInfo): if trigger_info.total_number_of_triggers == 0: - image_mode = adcore.ImageMode.continuous + image_mode = adcore.ImageMode.CONTINUOUS else: - image_mode = adcore.ImageMode.multiple + image_mode = adcore.ImageMode.MULTIPLE if (exposure := trigger_info.livetime) is not None: await self._drv.acquire_time.set(exposure) @@ -58,9 +58,9 @@ def _get_trigger_info( self, trigger: DetectorTrigger ) -> tuple[AravisTriggerMode, AravisTriggerSource]: supported_trigger_types = ( - DetectorTrigger.constant_gate, - DetectorTrigger.edge_trigger, - DetectorTrigger.internal, + DetectorTrigger.CONSTANT_GATE, + DetectorTrigger.EDGE_TRIGGER, + DetectorTrigger.INTERNAL, ) if trigger not in supported_trigger_types: raise ValueError( @@ -68,10 +68,10 @@ def _get_trigger_info( f"types: {supported_trigger_types} but was asked to " f"use {trigger}" ) - if trigger == DetectorTrigger.internal: - return AravisTriggerMode.off, AravisTriggerSource.freerun + if trigger == DetectorTrigger.INTERNAL: + return AravisTriggerMode.OFF, AravisTriggerSource.FREERUN else: - return (AravisTriggerMode.on, f"Line{self.gpio_number}") # type: ignore + return (AravisTriggerMode.ON, f"Line{self.gpio_number}") # type: ignore async def disarm(self): await adcore.stop_busy_record(self._drv.acquire, False, timeout=1) diff --git a/src/ophyd_async/epics/adaravis/_aravis_io.py b/src/ophyd_async/epics/adaravis/_aravis_io.py index e16beb41f4..40db8e2aec 100644 --- a/src/ophyd_async/epics/adaravis/_aravis_io.py +++ b/src/ophyd_async/epics/adaravis/_aravis_io.py @@ -6,8 +6,8 @@ class AravisTriggerMode(StrictEnum): """GigEVision GenICAM standard: on=externally triggered""" - on = "On" - off = "Off" + ON = "On" + OFF = "Off" """A minimal set of TriggerSources that must be supported by the underlying record. @@ -20,8 +20,8 @@ class AravisTriggerMode(StrictEnum): class AravisTriggerSource(SubsetEnum): - freerun = "Freerun" - line1 = "Line1" + FREERUN = "Freerun" + LINE1 = "Line1" class AravisDriverIO(adcore.ADBaseIO): diff --git a/src/ophyd_async/epics/adcore/_core_io.py b/src/ophyd_async/epics/adcore/_core_io.py index 97332d9a15..8f231b425e 100644 --- a/src/ophyd_async/epics/adcore/_core_io.py +++ b/src/ophyd_async/epics/adcore/_core_io.py @@ -9,8 +9,8 @@ class Callback(StrictEnum): - Enable = "Enable" - Disable = "Disable" + ENABLE = "Enable" + DISABLE = "Disable" class NDArrayBaseIO(Device): @@ -72,17 +72,17 @@ class DetectorState(StrictEnum): See definition in ADApp/ADSrc/ADDriver.h in https://github.com/areaDetector/ADCore """ - Idle = "Idle" - Acquire = "Acquire" - Readout = "Readout" - Correct = "Correct" - Saving = "Saving" - Aborting = "Aborting" - Error = "Error" - Waiting = "Waiting" - Initializing = "Initializing" - Disconnected = "Disconnected" - Aborted = "Aborted" + IDLE = "Idle" + ACQUIRE = "Acquire" + READOUT = "Readout" + CORRECT = "Correct" + SAVING = "Saving" + ABORTING = "Aborting" + ERROR = "Error" + WAITING = "Waiting" + INITIALIZING = "Initializing" + DISCONNECTED = "Disconnected" + ABORTED = "Aborted" class ADBaseIO(NDArrayBaseIO): @@ -99,14 +99,14 @@ def __init__(self, prefix: str, name: str = "") -> None: class Compression(StrictEnum): - none = "None" - nbit = "N-bit" - szip = "szip" - zlib = "zlib" - blosc = "Blosc" - bslz4 = "BSLZ4" - lz4 = "LZ4" - jpeg = "JPEG" + NONE = "None" + NBIT = "N-bit" + SZIP = "szip" + ZLIB = "zlib" + BLOSC = "Blosc" + BSLZ4 = "BSLZ4" + LZ4 = "LZ4" + JPEG = "JPEG" class NDFileHDFIO(NDPluginBaseIO): diff --git a/src/ophyd_async/epics/adcore/_core_logic.py b/src/ophyd_async/epics/adcore/_core_logic.py index e927717046..54e642a081 100644 --- a/src/ophyd_async/epics/adcore/_core_logic.py +++ b/src/ophyd_async/epics/adcore/_core_logic.py @@ -14,7 +14,7 @@ # Default set of states that we should consider "good" i.e. the acquisition # is complete and went well DEFAULT_GOOD_STATES: frozenset[DetectorState] = frozenset( - [DetectorState.Idle, DetectorState.Aborted] + [DetectorState.IDLE, DetectorState.ABORTED] ) diff --git a/src/ophyd_async/epics/adcore/_hdf_writer.py b/src/ophyd_async/epics/adcore/_hdf_writer.py index 7d9bfd2b11..fc7d1ca3fe 100644 --- a/src/ophyd_async/epics/adcore/_hdf_writer.py +++ b/src/ophyd_async/epics/adcore/_hdf_writer.py @@ -20,7 +20,7 @@ wait_for_value, ) -from ._core_io import NDArrayBaseIO, NDFileHDFIO +from ._core_io import Callback, NDArrayBaseIO, NDFileHDFIO from ._utils import ( FileWriteMode, convert_param_dtype_to_np, @@ -67,10 +67,11 @@ async def open(self, multiplier: int = 1) -> dict[str, DataKey]: self.hdf.file_path.set(str(info.directory_path)), self.hdf.file_name.set(info.filename), self.hdf.file_template.set("%s/%s.h5"), - self.hdf.file_write_mode.set(FileWriteMode.stream), + self.hdf.file_write_mode.set(FileWriteMode.STREAM), # Never use custom xml layout file but use the one defined # in the source code file NDFileHDF5LayoutXML.cpp self.hdf.xml_file_name.set(""), + self.hdf.enable_callbacks.set(Callback.ENABLE), ) assert ( @@ -80,7 +81,9 @@ async def open(self, multiplier: int = 1) -> dict[str, DataKey]: # Overwrite num_capture to go forever await self.hdf.num_capture.set(0) # Wait for it to start, stashing the status that tells us when it finishes - self._capture_status = await set_and_wait_for_value(self.hdf.capture, True) + self._capture_status = await set_and_wait_for_value( + self.hdf.capture, True, wait_for_set_completion=False + ) name = self._name_provider() detector_shape = await self._dataset_describer.shape() np_dtype = await self._dataset_describer.np_datatype() diff --git a/src/ophyd_async/epics/adcore/_single_trigger.py b/src/ophyd_async/epics/adcore/_single_trigger.py index 165204d371..e3caa40af2 100644 --- a/src/ophyd_async/epics/adcore/_single_trigger.py +++ b/src/ophyd_async/epics/adcore/_single_trigger.py @@ -34,7 +34,7 @@ def __init__( @AsyncStatus.wrap async def stage(self) -> None: await asyncio.gather( - self.drv.image_mode.set(ImageMode.single), + self.drv.image_mode.set(ImageMode.SINGLE), self.drv.wait_for_plugins.set(True), ) await super().stage() diff --git a/src/ophyd_async/epics/adcore/_utils.py b/src/ophyd_async/epics/adcore/_utils.py index bedbd474c2..ec56efb723 100644 --- a/src/ophyd_async/epics/adcore/_utils.py +++ b/src/ophyd_async/epics/adcore/_utils.py @@ -11,42 +11,42 @@ class ADBaseDataType(StrictEnum): - Int8 = "Int8" - UInt8 = "UInt8" - Int16 = "Int16" - UInt16 = "UInt16" - Int32 = "Int32" - UInt32 = "UInt32" - Int64 = "Int64" - UInt64 = "UInt64" - Float32 = "Float32" - Float64 = "Float64" + INT8 = "Int8" + UINT8 = "UInt8" + INT16 = "Int16" + UINT16 = "UInt16" + INT32 = "Int32" + UINT32 = "UInt32" + INT64 = "Int64" + UINT64 = "UInt64" + FLOAT32 = "Float32" + FLOAT64 = "Float64" def convert_ad_dtype_to_np(ad_dtype: ADBaseDataType) -> str: ad_dtype_to_np_dtype = { - ADBaseDataType.Int8: "|i1", - ADBaseDataType.UInt8: "|u1", - ADBaseDataType.Int16: " str: _pvattribute_to_ad_datatype = { - "DBR_SHORT": ADBaseDataType.Int16, - "DBR_ENUM": ADBaseDataType.Int16, - "DBR_INT": ADBaseDataType.Int32, - "DBR_LONG": ADBaseDataType.Int32, - "DBR_FLOAT": ADBaseDataType.Float32, - "DBR_DOUBLE": ADBaseDataType.Float64, + "DBR_SHORT": ADBaseDataType.INT16, + "DBR_ENUM": ADBaseDataType.INT16, + "DBR_INT": ADBaseDataType.INT32, + "DBR_LONG": ADBaseDataType.INT32, + "DBR_FLOAT": ADBaseDataType.FLOAT32, + "DBR_DOUBLE": ADBaseDataType.FLOAT64, } if datatype in ["DBR_STRING", "DBR_CHAR"]: np_datatype = "s40" @@ -62,9 +62,9 @@ def convert_pv_dtype_to_np(datatype: str) -> str: def convert_param_dtype_to_np(datatype: str) -> str: _paramattribute_to_ad_datatype = { - "INT": ADBaseDataType.Int32, - "INT64": ADBaseDataType.Int64, - "DOUBLE": ADBaseDataType.Float64, + "INT": ADBaseDataType.INT32, + "INT64": ADBaseDataType.INT64, + "DOUBLE": ADBaseDataType.FLOAT64, } if datatype in ["STRING"]: np_datatype = "s40" @@ -79,15 +79,15 @@ def convert_param_dtype_to_np(datatype: str) -> str: class FileWriteMode(StrictEnum): - single = "Single" - capture = "Capture" - stream = "Stream" + SINGLE = "Single" + CAPTURE = "Capture" + STREAM = "Stream" class ImageMode(StrictEnum): - single = "Single" - multiple = "Multiple" - continuous = "Continuous" + SINGLE = "Single" + MULTIPLE = "Multiple" + CONTINUOUS = "Continuous" class NDAttributeDataType(StrictEnum): diff --git a/src/ophyd_async/epics/adkinetix/_kinetix_controller.py b/src/ophyd_async/epics/adkinetix/_kinetix_controller.py index 7bc142d321..53dbb9a029 100644 --- a/src/ophyd_async/epics/adkinetix/_kinetix_controller.py +++ b/src/ophyd_async/epics/adkinetix/_kinetix_controller.py @@ -11,10 +11,10 @@ from ._kinetix_io import KinetixDriverIO, KinetixTriggerMode KINETIX_TRIGGER_MODE_MAP = { - DetectorTrigger.internal: KinetixTriggerMode.internal, - DetectorTrigger.constant_gate: KinetixTriggerMode.gate, - DetectorTrigger.variable_gate: KinetixTriggerMode.gate, - DetectorTrigger.edge_trigger: KinetixTriggerMode.edge, + DetectorTrigger.INTERNAL: KinetixTriggerMode.INTERNAL, + DetectorTrigger.CONSTANT_GATE: KinetixTriggerMode.GATE, + DetectorTrigger.VARIABLE_GATE: KinetixTriggerMode.GATE, + DetectorTrigger.EDGE_TRIGGER: KinetixTriggerMode.EDGE, } @@ -33,11 +33,11 @@ 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.total_number_of_triggers), - self._drv.image_mode.set(adcore.ImageMode.multiple), + self._drv.image_mode.set(adcore.ImageMode.MULTIPLE), ) if trigger_info.livetime is not None and trigger_info.trigger not in [ - DetectorTrigger.variable_gate, - DetectorTrigger.constant_gate, + DetectorTrigger.VARIABLE_GATE, + DetectorTrigger.CONSTANT_GATE, ]: await self._drv.acquire_time.set(trigger_info.livetime) diff --git a/src/ophyd_async/epics/adkinetix/_kinetix_io.py b/src/ophyd_async/epics/adkinetix/_kinetix_io.py index bbe53eb410..d2fda58562 100644 --- a/src/ophyd_async/epics/adkinetix/_kinetix_io.py +++ b/src/ophyd_async/epics/adkinetix/_kinetix_io.py @@ -4,16 +4,16 @@ class KinetixTriggerMode(StrictEnum): - internal = "Internal" - edge = "Rising Edge" - gate = "Exp. Gate" + INTERNAL = "Internal" + EDGE = "Rising Edge" + GATE = "Exp. Gate" class KinetixReadoutMode(StrictEnum): - sensitivity = 1 - speed = 2 - dynamic_range = 3 - sub_electron = 4 + SENSITIVITY = 1 + SPEED = 2 + DYNAMIC_RANGE = 3 + SUB_ELECTRON = 4 class KinetixDriverIO(adcore.ADBaseIO): diff --git a/src/ophyd_async/epics/adpilatus/_pilatus.py b/src/ophyd_async/epics/adpilatus/_pilatus.py index 113b780fa2..89c464c145 100644 --- a/src/ophyd_async/epics/adpilatus/_pilatus.py +++ b/src/ophyd_async/epics/adpilatus/_pilatus.py @@ -17,10 +17,10 @@ class PilatusReadoutTime(float, Enum): """Pilatus readout time per model in ms""" # Cite: https://media.dectris.com/User_Manual-PILATUS2-V1_4.pdf - pilatus2 = 2.28e-3 + PILATUS2 = 2.28e-3 # Cite: https://media.dectris.com/user-manual-pilatus3-2020.pdf - pilatus3 = 0.95e-3 + PILATUS3 = 0.95e-3 class PilatusDetector(StandardDetector): @@ -33,7 +33,7 @@ def __init__( self, prefix: str, path_provider: PathProvider, - readout_time: PilatusReadoutTime = PilatusReadoutTime.pilatus3, + readout_time: PilatusReadoutTime = PilatusReadoutTime.PILATUS3, drv_suffix: str = "cam1:", hdf_suffix: str = "HDF1:", name: str = "", diff --git a/src/ophyd_async/epics/adpilatus/_pilatus_controller.py b/src/ophyd_async/epics/adpilatus/_pilatus_controller.py index 89a47914f4..46966357b8 100644 --- a/src/ophyd_async/epics/adpilatus/_pilatus_controller.py +++ b/src/ophyd_async/epics/adpilatus/_pilatus_controller.py @@ -15,9 +15,9 @@ class PilatusController(DetectorController): _supported_trigger_types = { - DetectorTrigger.internal: PilatusTriggerMode.internal, - DetectorTrigger.constant_gate: PilatusTriggerMode.ext_enable, - DetectorTrigger.variable_gate: PilatusTriggerMode.ext_enable, + DetectorTrigger.INTERNAL: PilatusTriggerMode.INTERNAL, + DetectorTrigger.CONSTANT_GATE: PilatusTriggerMode.EXT_ENABLE, + DetectorTrigger.VARIABLE_GATE: PilatusTriggerMode.EXT_ENABLE, } def __init__( @@ -44,7 +44,7 @@ async def prepare(self, trigger_info: TriggerInfo): if trigger_info.total_number_of_triggers == 0 else trigger_info.total_number_of_triggers ), - self._drv.image_mode.set(adcore.ImageMode.multiple), + self._drv.image_mode.set(adcore.ImageMode.MULTIPLE), ) async def arm(self): diff --git a/src/ophyd_async/epics/adpilatus/_pilatus_io.py b/src/ophyd_async/epics/adpilatus/_pilatus_io.py index 093398ec61..54c69f72c8 100644 --- a/src/ophyd_async/epics/adpilatus/_pilatus_io.py +++ b/src/ophyd_async/epics/adpilatus/_pilatus_io.py @@ -4,11 +4,11 @@ class PilatusTriggerMode(StrictEnum): - internal = "Internal" - ext_enable = "Ext. Enable" - ext_trigger = "Ext. Trigger" - mult_trigger = "Mult. Trigger" - alignment = "Alignment" + INTERNAL = "Internal" + EXT_ENABLE = "Ext. Enable" + EXT_TRIGGER = "Ext. Trigger" + MULT_TRIGGER = "Mult. Trigger" + ALIGNMENT = "Alignment" class PilatusDriverIO(adcore.ADBaseIO): diff --git a/src/ophyd_async/epics/adsimdetector/_sim_controller.py b/src/ophyd_async/epics/adsimdetector/_sim_controller.py index cf10674f12..84b458c78b 100644 --- a/src/ophyd_async/epics/adsimdetector/_sim_controller.py +++ b/src/ophyd_async/epics/adsimdetector/_sim_controller.py @@ -26,14 +26,14 @@ def get_deadtime(self, exposure: float | None) -> float: async def prepare(self, trigger_info: TriggerInfo): assert ( - trigger_info.trigger == DetectorTrigger.internal + trigger_info.trigger == DetectorTrigger.INTERNAL ), "fly scanning (i.e. external triggering) is not supported for this device" self.frame_timeout = ( DEFAULT_TIMEOUT + await self.driver.acquire_time.get_value() ) await asyncio.gather( self.driver.num_images.set(trigger_info.total_number_of_triggers), - self.driver.image_mode.set(adcore.ImageMode.multiple), + self.driver.image_mode.set(adcore.ImageMode.MULTIPLE), ) async def arm(self): diff --git a/src/ophyd_async/epics/advimba/_vimba_controller.py b/src/ophyd_async/epics/advimba/_vimba_controller.py index 69aba6bf39..b96e5a208d 100644 --- a/src/ophyd_async/epics/advimba/_vimba_controller.py +++ b/src/ophyd_async/epics/advimba/_vimba_controller.py @@ -11,17 +11,17 @@ from ._vimba_io import VimbaDriverIO, VimbaExposeOutMode, VimbaOnOff, VimbaTriggerSource TRIGGER_MODE = { - DetectorTrigger.internal: VimbaOnOff.off, - DetectorTrigger.constant_gate: VimbaOnOff.on, - DetectorTrigger.variable_gate: VimbaOnOff.on, - DetectorTrigger.edge_trigger: VimbaOnOff.on, + DetectorTrigger.INTERNAL: VimbaOnOff.OFF, + DetectorTrigger.CONSTANT_GATE: VimbaOnOff.ON, + DetectorTrigger.VARIABLE_GATE: VimbaOnOff.ON, + DetectorTrigger.EDGE_TRIGGER: VimbaOnOff.ON, } EXPOSE_OUT_MODE = { - DetectorTrigger.internal: VimbaExposeOutMode.timed, - DetectorTrigger.constant_gate: VimbaExposeOutMode.trigger_width, - DetectorTrigger.variable_gate: VimbaExposeOutMode.trigger_width, - DetectorTrigger.edge_trigger: VimbaExposeOutMode.timed, + DetectorTrigger.INTERNAL: VimbaExposeOutMode.TIMED, + DetectorTrigger.CONSTANT_GATE: VimbaExposeOutMode.TRIGGER_WIDTH, + DetectorTrigger.VARIABLE_GATE: VimbaExposeOutMode.TRIGGER_WIDTH, + DetectorTrigger.EDGE_TRIGGER: VimbaExposeOutMode.TIMED, } @@ -41,17 +41,17 @@ async def prepare(self, trigger_info: TriggerInfo): self._drv.trigger_mode.set(TRIGGER_MODE[trigger_info.trigger]), self._drv.exposure_mode.set(EXPOSE_OUT_MODE[trigger_info.trigger]), self._drv.num_images.set(trigger_info.total_number_of_triggers), - self._drv.image_mode.set(adcore.ImageMode.multiple), + self._drv.image_mode.set(adcore.ImageMode.MULTIPLE), ) if trigger_info.livetime is not None and trigger_info.trigger not in [ - DetectorTrigger.variable_gate, - DetectorTrigger.constant_gate, + DetectorTrigger.VARIABLE_GATE, + DetectorTrigger.CONSTANT_GATE, ]: await self._drv.acquire_time.set(trigger_info.livetime) - if trigger_info.trigger != DetectorTrigger.internal: - self._drv.trigger_source.set(VimbaTriggerSource.line1) + if trigger_info.trigger != DetectorTrigger.INTERNAL: + self._drv.trigger_source.set(VimbaTriggerSource.LINE1) else: - self._drv.trigger_source.set(VimbaTriggerSource.freerun) + self._drv.trigger_source.set(VimbaTriggerSource.FREERUN) async def arm(self): self._arm_status = await adcore.start_acquiring_driver_and_ensure_status( diff --git a/src/ophyd_async/epics/advimba/_vimba_io.py b/src/ophyd_async/epics/advimba/_vimba_io.py index c95a873831..82d877d136 100644 --- a/src/ophyd_async/epics/advimba/_vimba_io.py +++ b/src/ophyd_async/epics/advimba/_vimba_io.py @@ -4,44 +4,44 @@ class VimbaPixelFormat(StrictEnum): - internal = "Mono8" - ext_enable = "Mono12" - ext_trigger = "Ext. Trigger" - mult_trigger = "Mult. Trigger" - alignment = "Alignment" + INTERNAL = "Mono8" + EXT_ENABLE = "Mono12" + EXT_TRIGGER = "Ext. Trigger" + MULT_TRIGGER = "Mult. Trigger" + ALIGNMENT = "Alignment" class VimbaConvertFormat(StrictEnum): - none = "None" - mono8 = "Mono8" - mono16 = "Mono16" - rgb8 = "RGB8" - rgb16 = "RGB16" + NONE = "None" + MONO8 = "Mono8" + MONO16 = "Mono16" + RGB8 = "RGB8" + RGB16 = "RGB16" class VimbaTriggerSource(StrictEnum): - freerun = "Freerun" - line1 = "Line1" - line2 = "Line2" - fixed_rate = "FixedRate" - software = "Software" - action0 = "Action0" - action1 = "Action1" + FREERUN = "Freerun" + LINE1 = "Line1" + LINE2 = "Line2" + FIXED_RATE = "FixedRate" + SOFTWARE = "Software" + ACTION0 = "Action0" + ACTION1 = "Action1" class VimbaOverlap(StrictEnum): - off = "Off" - prev_frame = "PreviousFrame" + OFF = "Off" + PREV_FRAME = "PreviousFrame" class VimbaOnOff(StrictEnum): - on = "On" - off = "Off" + ON = "On" + OFF = "Off" class VimbaExposeOutMode(StrictEnum): - timed = "Timed" # Use ExposureTime PV - trigger_width = "TriggerWidth" # Expose for length of high signal + TIMED = "Timed" # Use ExposureTime PV + TRIGGER_WIDTH = "TriggerWidth" # Expose for length of high signal class VimbaDriverIO(adcore.ADBaseIO): diff --git a/src/ophyd_async/epics/core/_pvi_connector.py b/src/ophyd_async/epics/core/_pvi_connector.py index 1c5c0eceb6..712b58d2e9 100644 --- a/src/ophyd_async/epics/core/_pvi_connector.py +++ b/src/ophyd_async/epics/core/_pvi_connector.py @@ -32,10 +32,11 @@ def _get_signal_details(entry: Entry) -> tuple[type[Signal], str, str]: class PviDeviceConnector(DeviceConnector): - def __init__(self, prefix: str = "") -> None: + def __init__(self, prefix: str = "", error_hint: str = "") -> None: # TODO: what happens if we get a leading "pva://" here? self.prefix = prefix self.pvi_pv = prefix + "PVI" + self.error_hint = error_hint def create_children_from_annotations(self, device: Device): if not hasattr(self, "filler"): @@ -85,7 +86,8 @@ async def connect_real( if e: self._fill_child(name, e, i) # Check that all the requested children have been filled - self.filler.check_filled(f"{self.pvi_pv}: {entries}") + suffix = f"\n{self.error_hint}" if self.error_hint else "" + self.filler.check_filled(f"{self.pvi_pv}: {entries}{suffix}") # Set the name of the device to name all children device.set_name(device.name) return await super().connect_real(device, timeout, force_reconnect) diff --git a/src/ophyd_async/epics/demo/_mover.py b/src/ophyd_async/epics/demo/_mover.py index 4c1e35fa8d..88bd3fd655 100644 --- a/src/ophyd_async/epics/demo/_mover.py +++ b/src/ophyd_async/epics/demo/_mover.py @@ -37,8 +37,8 @@ def __init__(self, prefix: str, name="") -> None: super().__init__(name=name) - def set_name(self, name: str): - super().set_name(name) + def set_name(self, name: str, *, child_name_separator: str | None = None) -> None: + super().set_name(name, child_name_separator=child_name_separator) # Readback should be named the same as its parent in read() self.readback.set_name(name) diff --git a/src/ophyd_async/epics/demo/_sensor.py b/src/ophyd_async/epics/demo/_sensor.py index 1004a04dae..0cc99d090a 100644 --- a/src/ophyd_async/epics/demo/_sensor.py +++ b/src/ophyd_async/epics/demo/_sensor.py @@ -15,9 +15,9 @@ class EnergyMode(StrictEnum): """Energy mode for `Sensor`""" #: Low energy mode - low = "Low Energy" + LOW = "Low Energy" #: High energy mode - high = "High Energy" + HIGH = "High Energy" class Sensor(StandardReadable, EpicsDevice): diff --git a/src/ophyd_async/epics/eiger/_eiger_controller.py b/src/ophyd_async/epics/eiger/_eiger_controller.py index b8d5319dad..2b5049eac2 100644 --- a/src/ophyd_async/epics/eiger/_eiger_controller.py +++ b/src/ophyd_async/epics/eiger/_eiger_controller.py @@ -11,10 +11,10 @@ from ._eiger_io import EigerDriverIO, EigerTriggerMode EIGER_TRIGGER_MODE_MAP = { - DetectorTrigger.internal: EigerTriggerMode.internal, - DetectorTrigger.constant_gate: EigerTriggerMode.gate, - DetectorTrigger.variable_gate: EigerTriggerMode.gate, - DetectorTrigger.edge_trigger: EigerTriggerMode.edge, + DetectorTrigger.INTERNAL: EigerTriggerMode.INTERNAL, + DetectorTrigger.CONSTANT_GATE: EigerTriggerMode.GATE, + DetectorTrigger.VARIABLE_GATE: EigerTriggerMode.GATE, + DetectorTrigger.EDGE_TRIGGER: EigerTriggerMode.EDGE, } diff --git a/src/ophyd_async/epics/eiger/_eiger_io.py b/src/ophyd_async/epics/eiger/_eiger_io.py index ef4451aa7d..484843ed30 100644 --- a/src/ophyd_async/epics/eiger/_eiger_io.py +++ b/src/ophyd_async/epics/eiger/_eiger_io.py @@ -3,9 +3,9 @@ class EigerTriggerMode(StrictEnum): - internal = "ints" - edge = "exts" - gate = "exte" + INTERNAL = "ints" + EDGE = "exts" + GATE = "exte" class EigerDriverIO(Device): diff --git a/src/ophyd_async/epics/motor.py b/src/ophyd_async/epics/motor.py index f03a29bcb0..c512f4ba03 100644 --- a/src/ophyd_async/epics/motor.py +++ b/src/ophyd_async/epics/motor.py @@ -20,7 +20,7 @@ observe_value, ) from ophyd_async.core import StandardReadableFormat as Format -from ophyd_async.epics.core import epics_signal_r, epics_signal_rw, epics_signal_x +from ophyd_async.epics.core import epics_signal_r, epics_signal_rw, epics_signal_w class MotorLimitsException(Exception): @@ -76,7 +76,10 @@ def __init__(self, prefix: str, name="") -> None: self.low_limit_travel = epics_signal_rw(float, prefix + ".LLM") self.high_limit_travel = epics_signal_rw(float, prefix + ".HLM") - self.motor_stop = epics_signal_x(prefix + ".STOP") + # Note:cannot use epics_signal_x here, as the motor record specifies that + # we must write 1 to stop the motor. Simply processing the record is not + # sufficient. + self.motor_stop = epics_signal_w(int, prefix + ".STOP") # Whether set() should complete successfully or not self._set_success = True @@ -91,8 +94,8 @@ def __init__(self, prefix: str, name="") -> None: super().__init__(name=name) - def set_name(self, name: str): - super().set_name(name) + def set_name(self, name: str, *, child_name_separator: str | None = None) -> None: + super().set_name(name, child_name_separator=child_name_separator) # Readback should be named the same as its parent in read() self.user_readback.set_name(name) @@ -178,7 +181,7 @@ async def stop(self, success=False): self._set_success = success # Put with completion will never complete as we are waiting for completion on # the move above, so need to pass wait=False - await self.motor_stop.trigger(wait=False) + await self.motor_stop.set(1, wait=False) async def _prepare_velocity( self, start_position: float, end_position: float, time_for_move: float diff --git a/src/ophyd_async/epics/testing/__init__.py b/src/ophyd_async/epics/testing/__init__.py new file mode 100644 index 0000000000..d8f39ef841 --- /dev/null +++ b/src/ophyd_async/epics/testing/__init__.py @@ -0,0 +1,24 @@ +from ._example_ioc import ( + CA_PVA_RECORDS, + PVA_RECORDS, + ExampleCaDevice, + ExampleEnum, + ExamplePvaDevice, + ExampleTable, + connect_example_device, + get_example_ioc, +) +from ._utils import TestingIOC, generate_random_PV_prefix + +__all__ = [ + "CA_PVA_RECORDS", + "PVA_RECORDS", + "ExampleCaDevice", + "ExampleEnum", + "ExamplePvaDevice", + "ExampleTable", + "connect_example_device", + "get_example_ioc", + "TestingIOC", + "generate_random_PV_prefix", +] diff --git a/src/ophyd_async/epics/testing/_example_ioc.py b/src/ophyd_async/epics/testing/_example_ioc.py new file mode 100644 index 0000000000..7c1b64ed11 --- /dev/null +++ b/src/ophyd_async/epics/testing/_example_ioc.py @@ -0,0 +1,105 @@ +from collections.abc import Sequence +from pathlib import Path +from typing import Annotated as A +from typing import Literal + +import numpy as np + +from ophyd_async.core import ( + Array1D, + SignalRW, + StrictEnum, + Table, +) +from ophyd_async.epics.core import ( + EpicsDevice, + PvSuffix, +) + +from ._utils import TestingIOC + +CA_PVA_RECORDS = str(Path(__file__).parent / "test_records.db") +PVA_RECORDS = str(Path(__file__).parent / "test_records_pva.db") + + +class ExampleEnum(StrictEnum): + A = "Aaa" + B = "Bbb" + C = "Ccc" + + +class ExampleTable(Table): + bool: Array1D[np.bool_] + int: Array1D[np.int32] + float: Array1D[np.float64] + str: Sequence[str] + enum: Sequence[ExampleEnum] + + +class ExampleCaDevice(EpicsDevice): + my_int: A[SignalRW[int], PvSuffix("int")] + my_float: A[SignalRW[float], PvSuffix("float")] + my_str: A[SignalRW[str], PvSuffix("str")] + my_bool: A[SignalRW[bool], PvSuffix("bool")] + enum: A[SignalRW[ExampleEnum], PvSuffix("enum")] + enum2: A[SignalRW[ExampleEnum], PvSuffix("enum2")] + bool_unnamed: A[SignalRW[bool], PvSuffix("bool_unnamed")] + partialint: A[SignalRW[int], PvSuffix("partialint")] + lessint: A[SignalRW[int], PvSuffix("lessint")] + uint8a: A[SignalRW[Array1D[np.uint8]], PvSuffix("uint8a")] + int16a: A[SignalRW[Array1D[np.int16]], PvSuffix("int16a")] + int32a: A[SignalRW[Array1D[np.int32]], PvSuffix("int32a")] + float32a: A[SignalRW[Array1D[np.float32]], PvSuffix("float32a")] + float64a: A[SignalRW[Array1D[np.float64]], PvSuffix("float64a")] + stra: A[SignalRW[Sequence[str]], PvSuffix("stra")] + + +class ExamplePvaDevice(ExampleCaDevice): # pva can support all signal types that ca can + int8a: A[SignalRW[Array1D[np.int8]], PvSuffix("int8a")] + uint16a: A[SignalRW[Array1D[np.uint16]], PvSuffix("uint16a")] + uint32a: A[SignalRW[Array1D[np.uint32]], PvSuffix("uint32a")] + int64a: A[SignalRW[Array1D[np.int64]], PvSuffix("int64a")] + uint64a: A[SignalRW[Array1D[np.uint64]], PvSuffix("uint64a")] + table: A[SignalRW[ExampleTable], PvSuffix("table")] + ntndarray_data: A[SignalRW[Array1D[np.int64]], PvSuffix("ntndarray:data")] + + +async def connect_example_device( + ioc: TestingIOC, protocol: Literal["ca", "pva"] +) -> ExamplePvaDevice | ExampleCaDevice: + """Helper function to return a connected example device. + + Parameters + ---------- + + ioc: TestingIOC + TestingIOC configured to provide the records needed for the device + + protocol: Literal["ca", "pva"] + The transport protocol of the device + + Returns + ------- + ExamplePvaDevice | ExampleCaDevice + a connected EpicsDevice with signals of many EPICS record types + """ + device_cls = ExamplePvaDevice if protocol == "pva" else ExampleCaDevice + device = device_cls(f"{protocol}://{ioc.prefix_for(device_cls)}") + await device.connect() + return device + + +def get_example_ioc() -> TestingIOC: + """Get TestingIOC instance with the example databases loaded. + + Returns + ------- + TestingIOC + instance with test_records.db loaded for ExampleCaDevice and + test_records.db and test_records_pva.db loaded for ExamplePvaDevice. + """ + ioc = TestingIOC() + ioc.database_for(PVA_RECORDS, ExamplePvaDevice) + ioc.database_for(CA_PVA_RECORDS, ExamplePvaDevice) + ioc.database_for(CA_PVA_RECORDS, ExampleCaDevice) + return ioc diff --git a/src/ophyd_async/epics/testing/_utils.py b/src/ophyd_async/epics/testing/_utils.py new file mode 100644 index 0000000000..8e156f5c0b --- /dev/null +++ b/src/ophyd_async/epics/testing/_utils.py @@ -0,0 +1,78 @@ +import random +import string +import subprocess +import sys +import time +from pathlib import Path + +from aioca import purge_channel_caches + +from ophyd_async.core import Device + + +def generate_random_PV_prefix() -> str: + return "".join(random.choice(string.ascii_lowercase) for _ in range(12)) + ":" + + +class TestingIOC: + _dbs: dict[type[Device], list[Path]] = {} + _prefixes: dict[type[Device], str] = {} + + @classmethod + def with_database(cls, db: Path | str): # use as a decorator + def inner(device_cls: type[Device]): + cls.database_for(db, device_cls) + return device_cls + + return inner + + @classmethod + def database_for(cls, db, device_cls): + path = Path(db) + if not path.is_file(): + raise OSError(f"{path} is not a file.") + if device_cls not in cls._dbs: + cls._dbs[device_cls] = [] + cls._dbs[device_cls].append(path) + + def prefix_for(self, device_cls): + # generate random prefix, return existing if already generated + return self._prefixes.setdefault(device_cls, generate_random_PV_prefix()) + + def start_ioc(self): + ioc_args = [ + sys.executable, + "-m", + "epicscorelibs.ioc", + ] + for device_cls, dbs in self._dbs.items(): + prefix = self.prefix_for(device_cls) + for db in dbs: + ioc_args += ["-m", f"device={prefix}", "-d", str(db)] + self._process = subprocess.Popen( + ioc_args, + stdin=subprocess.PIPE, + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + universal_newlines=True, + ) + start_time = time.monotonic() + while "iocRun: All initialization complete" not in ( + self._process.stdout.readline().strip() # type: ignore + ): + if time.monotonic() - start_time > 10: + try: + print(self._process.communicate("exit()")[0]) + except ValueError: + # Someone else already called communicate + pass + raise TimeoutError("IOC did not start in time") + + def stop_ioc(self): + # close backend caches before the event loop + purge_channel_caches() + try: + print(self._process.communicate("exit()")[0]) + except ValueError: + # Someone else already called communicate + pass diff --git a/src/ophyd_async/epics/testing/test_records.db b/src/ophyd_async/epics/testing/test_records.db new file mode 100644 index 0000000000..ff45a6350c --- /dev/null +++ b/src/ophyd_async/epics/testing/test_records.db @@ -0,0 +1,158 @@ +record(bo, "$(device)bool") { + field(ZNAM, "No") + field(ONAM, "Yes") + field(VAL, "1") + field(PINI, "YES") +} + +record(bo, "$(device)bool_unnamed") { + field(VAL, "1") + field(PINI, "YES") +} + +record(longout, "$(device)int") { + field(LLSV, "MAJOR") # LOLO is alarm + field(LSV, "MINOR") # LOW is warning + field(HSV, "MINOR") # HIGH is warning + field(HHSV, "MAJOR") # HIHI is alarm + field(HOPR, "100") + field(HIHI, "98") + field(HIGH, "96") + field(DRVH, "90") + field(DRVL, "10") + field(LOW, "5") + field(LOLO, "2") + field(LOPR, "0") + field(VAL, "42") + field(PINI, "YES") +} + +record(longout, "$(device)partialint") { + field(LLSV, "MAJOR") # LOLO is alarm + field(HHSV, "MAJOR") # HIHI is alarm + field(HOPR, "100") + field(HIHI, "98") + field(DRVH, "90") + field(DRVL, "10") + field(LOLO, "2") + field(LOPR, "0") + field(VAL, "42") + field(PINI, "YES") +} + +record(longout, "$(device)lessint") { + field(HSV, "MINOR") # LOW is warning + field(LSV, "MINOR") # HIGH is warning + field(HOPR, "100") + field(HIGH, "98") + field(LOW, "2") + field(LOPR, "0") + field(VAL, "42") + field(PINI, "YES") +} + +record(ao, "$(device)float") { + field(PREC, "1") + field(EGU, "mm") + field(VAL, "3.141") + field(PINI, "YES") +} + +record(ao, "$(device)float_prec_0") { + field(PREC, "0") + field(EGU, "mm") + field(VAL, "3") + field(PINI, "YES") +} + +record(ao, "$(device)float_prec_1") { + field(PREC, "1") + field(EGU, "mm") + field(VAL, "3") + field(PINI, "YES") +} + +record(stringout, "$(device)str") { + field(VAL, "hello") + field(PINI, "YES") +} + +record(mbbo, "$(device)enum") { + field(ZRST, "Aaa") + field(ZRVL, "5") + field(ONST, "Bbb") + field(ONVL, "6") + field(TWST, "Ccc") + field(TWVL, "7") + field(VAL, "1") + field(PINI, "YES") +} + +record(mbbo, "$(device)enum2") { + field(ZRST, "Aaa") + field(ONST, "Bbb") + field(TWST, "Ccc") + field(VAL, "1") + field(PINI, "YES") +} + +record(waveform, "$(device)uint8a") { + field(NELM, "3") + field(FTVL, "UCHAR") + field(INP, {const:[0, 255]}) + field(PINI, "YES") +} + +record(waveform, "$(device)int16a") { + field(NELM, "3") + field(FTVL, "SHORT") + field(INP, {const:[-32768, 32767]}) + field(PINI, "YES") +} + +record(waveform, "$(device)int32a") { + field(NELM, "3") + field(FTVL, "LONG") + field(INP, {const:[-2147483648, 2147483647]}) + field(PINI, "YES") +} + +record(waveform, "$(device)float32a") { + field(NELM, "3") + field(FTVL, "FLOAT") + field(INP, {const:[0.000002, -123.123]}) + field(PINI, "YES") +} + +record(waveform, "$(device)float64a") { + field(NELM, "3") + field(FTVL, "DOUBLE") + field(INP, {const:[0.1, -12345678.123]}) + field(PINI, "YES") +} + +record(waveform, "$(device)stra") { + field(NELM, "3") + field(FTVL, "STRING") + field(INP, {const:["five", "six", "seven"]}) + field(PINI, "YES") +} + +record(waveform, "$(device)longstr") { + field(NELM, "80") + field(FTVL, "CHAR") + field(INP, {const:"a string that is just longer than forty characters"}) + field(PINI, "YES") +} + +record(lsi, "$(device)longstr2") { + field(SIZV, "80") + field(INP, {const:"a string that is just longer than forty characters"}) + field(PINI, "YES") +} + +record(calc, "$(device)ticking") { + field(INPA, "$(device)ticking") + field(CALC, "A+1") + field(SCAN, ".1 second") +} diff --git a/src/ophyd_async/epics/testing/test_records_pva.db b/src/ophyd_async/epics/testing/test_records_pva.db new file mode 100644 index 0000000000..f00e95dda8 --- /dev/null +++ b/src/ophyd_async/epics/testing/test_records_pva.db @@ -0,0 +1,177 @@ +record(waveform, "$(device)int8a") { + field(NELM, "3") + field(FTVL, "CHAR") + field(INP, {const:[-128, 127]}) + field(PINI, "YES") +} + +record(waveform, "$(device)uint16a") { + field(NELM, "3") + field(FTVL, "USHORT") + field(INP, {const:[0, 65535]}) + field(PINI, "YES") +} + +record(waveform, "$(device)uint32a") { + field(NELM, "3") + field(FTVL, "ULONG") + field(INP, {const:[0, 4294967295]}) + field(PINI, "YES") +} + +record(waveform, "$(device)int64a") { + field(NELM, "3") + field(FTVL, "INT64") + # Can't do 64-bit int with JSON numbers in a const link... + field(INP, {const:[-2147483649, 2147483648]}) + field(PINI, "YES") +} + +record(waveform, "$(device)uint64a") { + field(NELM, "3") + field(FTVL, "UINT64") + field(INP, {const:[0, 4294967297]}) + field(PINI, "YES") +} + +record(waveform, "$(device)table:labels") { + field(FTVL, "STRING") + field(NELM, "5") + field(INP, {const:["Bool", "Int", "Float", "Str", "Enum"]}) + field(PINI, "YES") + info(Q:group, { + "$(device)table": { + "+id": "epics:nt/NTTable:1.0", + "labels": { + "+type": "plain", + "+channel": "VAL" + } + } + }) +} + +record(waveform, "$(device)table:bool") +{ + field(FTVL, "UCHAR") + field(NELM, "4096") + field(INP, {const:[false, false, true, true]}) + field(PINI, "YES") + info(Q:group, { + "$(device)table": { + "value.bool": { + "+type": "plain", + "+channel": "VAL", + "+putorder": 1 + } + } + }) +} + +record(waveform, "$(device)table:int") +{ + field(FTVL, "LONG") + field(NELM, "4096") + field(INP, {const:[1, 8, -9, 32]}) + field(PINI, "YES") + info(Q:group, { + "$(device)table": { + "value.int": { + "+type": "plain", + "+channel": "VAL", + "+putorder": 2 + } + } + }) +} + +record(waveform, "$(device)table:float") +{ + field(FTVL, "DOUBLE") + field(NELM, "4096") + field(INP, {const:[1.8, 8.2, -6, 32.9887]}) + field(PINI, "YES") + info(Q:group, { + "$(device)table": { + "value.float": { + "+type": "plain", + "+channel": "VAL", + "+putorder": 3 + } + } + }) +} + +record(waveform, "$(device)table:str") +{ + field(FTVL, "STRING") + field(NELM, "4096") + field(INP, {const:["Hello", "World", "Foo", "Bar"]}) + field(PINI, "YES") + info(Q:group, { + "$(device)table": { + "value.str": { + "+type": "plain", + "+channel": "VAL", + "+putorder": 4 + } + } + }) +} + +record(waveform, "$(device)table:enum") +{ + field(FTVL, "STRING") + field(NELM, "4096") + field(INP, {const:["Aaa", "Bbb", "Aaa", "Ccc"]}) + field(PINI, "YES") + info(Q:group, { + "$(device)table": { + "value.enum": { + "+type": "plain", + "+channel": "VAL", + "+putorder": 5, + "+trigger": "*", + }, + "": {"+type": "meta", "+channel": "VAL"} + } + }) +} + +record(longout, "$(device)ntndarray:ArraySize0_RBV") { + field(VAL, "3") + field(PINI, "YES") + info(Q:group, { + "$(device)ntndarray":{ + "dimension[0].size":{+channel:"VAL", +type:"plain", +putorder:0} + } + }) +} + +record(longout, "$(device)ntndarray:ArraySize1_RBV") { + field(VAL, "2") + field(PINI, "YES") + info(Q:group, { + "$(device)ntndarray":{ + "dimension[1].size":{+channel:"VAL", +type:"plain", +putorder:0} + } + }) +} + +record(waveform, "$(device)ntndarray:data") +{ + field(FTVL, "INT64") + field(NELM, "6") + field(INP, {const:[0, 0, 0, 0, 0, 0]}) + field(PINI, "YES") + info(Q:group, { + "$(device)ntndarray":{ + +id:"epics:nt/NTNDArray:1.0", + "value":{ + +type:"any", + +channel:"VAL", + +trigger:"*", + }, + "": {+type:"meta", +channel:"SEVR"} + } + }) +} diff --git a/src/ophyd_async/fastcs/core.py b/src/ophyd_async/fastcs/core.py index d7561c7578..07e6581164 100644 --- a/src/ophyd_async/fastcs/core.py +++ b/src/ophyd_async/fastcs/core.py @@ -2,8 +2,8 @@ from ophyd_async.epics.core import PviDeviceConnector -def fastcs_connector(device: Device, uri: str) -> DeviceConnector: +def fastcs_connector(device: Device, uri: str, error_hint: str = "") -> DeviceConnector: # TODO: add Tango support based on uri scheme - connector = PviDeviceConnector(uri) + connector = PviDeviceConnector(uri, error_hint) connector.create_children_from_annotations(device) return connector diff --git a/src/ophyd_async/fastcs/panda/_block.py b/src/ophyd_async/fastcs/panda/_block.py index 67767ba372..45943a491f 100644 --- a/src/ophyd_async/fastcs/panda/_block.py +++ b/src/ophyd_async/fastcs/panda/_block.py @@ -36,14 +36,14 @@ class PulseBlock(Device): class PcompDirection(StrictEnum): - positive = "Positive" - negative = "Negative" - either = "Either" + POSITIVE = "Positive" + NEGATIVE = "Negative" + EITHER = "Either" class BitMux(SubsetEnum): - zero = "ZERO" - one = "ONE" + ZERO = "ZERO" + ONE = "ONE" class PcompBlock(Device): @@ -57,10 +57,10 @@ class PcompBlock(Device): class TimeUnits(StrictEnum): - min = "min" - s = "s" - ms = "ms" - us = "us" + MIN = "min" + S = "s" + MS = "ms" + US = "us" class SeqBlock(Device): diff --git a/src/ophyd_async/fastcs/panda/_control.py b/src/ophyd_async/fastcs/panda/_control.py index 1fe14c7909..35a995aaae 100644 --- a/src/ophyd_async/fastcs/panda/_control.py +++ b/src/ophyd_async/fastcs/panda/_control.py @@ -19,8 +19,8 @@ def get_deadtime(self, exposure: float | None) -> float: async def prepare(self, trigger_info: TriggerInfo): assert trigger_info.trigger in ( - DetectorTrigger.constant_gate, - DetectorTrigger.variable_gate, + DetectorTrigger.CONSTANT_GATE, + DetectorTrigger.VARIABLE_GATE, ), "Only constant_gate and variable_gate triggering is supported on the PandA" async def arm(self): diff --git a/src/ophyd_async/fastcs/panda/_hdf_panda.py b/src/ophyd_async/fastcs/panda/_hdf_panda.py index f75403bbb3..3c33611aaa 100644 --- a/src/ophyd_async/fastcs/panda/_hdf_panda.py +++ b/src/ophyd_async/fastcs/panda/_hdf_panda.py @@ -9,6 +9,8 @@ from ._control import PandaPcapController from ._writer import PandaHDFWriter +MINIMUM_PANDA_IOC = "0.11.4" + class HDFPanda(CommonPandaBlocks, StandardDetector): def __init__( @@ -18,8 +20,9 @@ def __init__( config_sigs: Sequence[SignalR] = (), name: str = "", ): + error_hint = f"Is PandABlocks-ioc at least version {MINIMUM_PANDA_IOC}?" # This has to be first so we make self.pcap - connector = fastcs_connector(self, prefix) + connector = fastcs_connector(self, prefix, error_hint) controller = PandaPcapController(pcap=self.pcap) writer = PandaHDFWriter( path_provider=path_provider, diff --git a/src/ophyd_async/fastcs/panda/_trigger.py b/src/ophyd_async/fastcs/panda/_trigger.py index 0aa3633760..05702a0b14 100644 --- a/src/ophyd_async/fastcs/panda/_trigger.py +++ b/src/ophyd_async/fastcs/panda/_trigger.py @@ -20,8 +20,8 @@ def __init__(self, seq: SeqBlock) -> None: async def prepare(self, value: SeqTableInfo): await asyncio.gather( - self.seq.prescale_units.set(TimeUnits.us), - self.seq.enable.set(BitMux.zero), + self.seq.prescale_units.set(TimeUnits.US), + self.seq.enable.set(BitMux.ZERO), ) await asyncio.gather( self.seq.prescale.set(value.prescale_as_us), @@ -30,14 +30,14 @@ async def prepare(self, value: SeqTableInfo): ) async def kickoff(self) -> None: - await self.seq.enable.set(BitMux.one) + await self.seq.enable.set(BitMux.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(BitMux.zero) + await self.seq.enable.set(BitMux.ZERO) await wait_for_value(self.seq.active, False, timeout=1) @@ -68,7 +68,7 @@ def __init__(self, pcomp: PcompBlock) -> None: self.pcomp = pcomp async def prepare(self, value: PcompInfo): - await self.pcomp.enable.set(BitMux.zero) + await self.pcomp.enable.set(BitMux.ZERO) await asyncio.gather( self.pcomp.start.set(value.start_postion), self.pcomp.width.set(value.pulse_width), @@ -78,12 +78,12 @@ async def prepare(self, value: PcompInfo): ) async def kickoff(self) -> None: - await self.pcomp.enable.set(BitMux.one) + await self.pcomp.enable.set(BitMux.ONE) await wait_for_value(self.pcomp.active, True, timeout=1) async def complete(self, timeout: float | None = None) -> None: await wait_for_value(self.pcomp.active, False, timeout=timeout) async def stop(self): - await self.pcomp.enable.set(BitMux.zero) + await self.pcomp.enable.set(BitMux.ZERO) await wait_for_value(self.pcomp.active, False, timeout=1) diff --git a/src/ophyd_async/plan_stubs/_fly.py b/src/ophyd_async/plan_stubs/_fly.py index d2e757681e..e10030a344 100644 --- a/src/ophyd_async/plan_stubs/_fly.py +++ b/src/ophyd_async/plan_stubs/_fly.py @@ -15,6 +15,8 @@ SeqTableInfo, ) +DEFAULT_FLUSH_PERIOD = 0.5 + def prepare_static_pcomp_flyer_and_detectors( flyer: StandardFlyer[PcompInfo], @@ -62,7 +64,7 @@ def prepare_static_seq_table_flyer_and_detectors_with_same_trigger( trigger_info = TriggerInfo( number_of_triggers=number_of_frames * repeats, - trigger=DetectorTrigger.constant_gate, + trigger=DetectorTrigger.CONSTANT_GATE, deadtime=deadtime, livetime=exposure, frame_timeout=frame_timeout, @@ -104,6 +106,7 @@ def fly_and_collect( stream_name: str, flyer: StandardFlyer[SeqTableInfo] | StandardFlyer[PcompInfo], detectors: list[StandardDetector], + flush_period: float = DEFAULT_FLUSH_PERIOD, ): """Kickoff, complete and collect with a flyer and multiple detectors. @@ -116,8 +119,6 @@ def fly_and_collect( yield from bps.kickoff(flyer, wait=True) for detector in detectors: yield from bps.kickoff(detector) - - # collect_while_completing group = short_uid(label="complete") yield from bps.complete(flyer, wait=False, group=group) @@ -126,18 +127,8 @@ def fly_and_collect( done = False while not done: - try: - yield from bps.wait(group=group, timeout=0.5) - except TimeoutError: - pass - else: - done = True - yield from bps.collect( - *detectors, - return_payload=False, - name=stream_name, - ) - yield from bps.wait(group=group) + done = yield from bps.wait(group=group, timeout=flush_period, move_on=True) + yield from bps.collect(*detectors, name=stream_name) def fly_and_collect_with_static_pcomp( diff --git a/src/ophyd_async/tango/__init__.py b/src/ophyd_async/tango/__init__.py index 4cf7197a91..e69de29bb2 100644 --- a/src/ophyd_async/tango/__init__.py +++ b/src/ophyd_async/tango/__init__.py @@ -1,43 +0,0 @@ -from .base_devices import ( - TangoDevice, - TangoReadable, - tango_polling, -) -from .signal import ( - AttributeProxy, - CommandProxy, - TangoSignalBackend, - ensure_proper_executor, - get_dtype_extended, - get_python_type, - get_tango_trl, - get_trl_descriptor, - infer_python_type, - infer_signal_type, - make_backend, - tango_signal_r, - tango_signal_rw, - tango_signal_w, - tango_signal_x, -) - -__all__ = [ - "TangoDevice", - "TangoReadable", - "tango_polling", - "TangoSignalBackend", - "get_python_type", - "get_dtype_extended", - "get_trl_descriptor", - "get_tango_trl", - "infer_python_type", - "infer_signal_type", - "make_backend", - "AttributeProxy", - "CommandProxy", - "ensure_proper_executor", - "tango_signal_r", - "tango_signal_rw", - "tango_signal_w", - "tango_signal_x", -] diff --git a/src/ophyd_async/tango/base_devices/__init__.py b/src/ophyd_async/tango/base_devices/__init__.py deleted file mode 100644 index ecba4e1b23..0000000000 --- a/src/ophyd_async/tango/base_devices/__init__.py +++ /dev/null @@ -1,4 +0,0 @@ -from ._base_device import TangoDevice, tango_polling -from ._tango_readable import TangoReadable - -__all__ = ["TangoDevice", "TangoReadable", "tango_polling"] diff --git a/src/ophyd_async/tango/signal/__init__.py b/src/ophyd_async/tango/core/__init__.py similarity index 81% rename from src/ophyd_async/tango/signal/__init__.py rename to src/ophyd_async/tango/core/__init__.py index 4462f6d8b8..8782e1c481 100644 --- a/src/ophyd_async/tango/signal/__init__.py +++ b/src/ophyd_async/tango/core/__init__.py @@ -1,3 +1,4 @@ +from ._base_device import TangoDevice, TangoPolling from ._signal import ( infer_python_type, infer_signal_type, @@ -7,6 +8,7 @@ tango_signal_w, tango_signal_x, ) +from ._tango_readable import TangoReadable from ._tango_transport import ( AttributeProxy, CommandProxy, @@ -18,7 +20,7 @@ get_trl_descriptor, ) -__all__ = ( +__all__ = [ "AttributeProxy", "CommandProxy", "ensure_proper_executor", @@ -34,4 +36,7 @@ "tango_signal_rw", "tango_signal_w", "tango_signal_x", -) + "TangoDevice", + "TangoReadable", + "TangoPolling", +] diff --git a/src/ophyd_async/tango/base_devices/_base_device.py b/src/ophyd_async/tango/core/_base_device.py similarity index 54% rename from src/ophyd_async/tango/base_devices/_base_device.py rename to src/ophyd_async/tango/core/_base_device.py index 1f98c4bd4f..6149e3ccda 100644 --- a/src/ophyd_async/tango/base_devices/_base_device.py +++ b/src/ophyd_async/tango/core/_base_device.py @@ -1,17 +1,14 @@ from __future__ import annotations -from typing import TypeVar - -from ophyd_async.core import Device, DeviceConnector, DeviceFiller -from ophyd_async.core._utils import LazyMock -from ophyd_async.tango.signal import ( - TangoSignalBackend, - infer_python_type, - infer_signal_type, -) +from dataclasses import dataclass +from typing import Any, Generic, TypeVar + +from ophyd_async.core import Device, DeviceConnector, DeviceFiller, LazyMock from tango import DeviceProxy as DeviceProxy from tango.asyncio import DeviceProxy as AsyncDeviceProxy +from ._signal import TangoSignalBackend, infer_python_type, infer_signal_type + T = TypeVar("T") @@ -32,63 +29,45 @@ class TangoDevice(Device): trl: str = "" proxy: DeviceProxy | None = None - _polling: tuple[bool, float, float | None, float | None] = (False, 0.1, None, 0.1) - _signal_polling: dict[str, tuple[bool, float, float, float]] = {} - _poll_only_annotated_signals: bool = True def __init__( self, trl: str | None = None, device_proxy: DeviceProxy | None = None, + support_events: bool = False, name: str = "", ) -> None: connector = TangoDeviceConnector( - trl=trl, - device_proxy=device_proxy, - polling=self._polling, - signal_polling=self._signal_polling, + trl=trl, device_proxy=device_proxy, support_events=support_events ) super().__init__(name=name, connector=connector) -def tango_polling( - polling: tuple[float, float, float] - | dict[str, tuple[float, float, float]] - | None = None, - signal_polling: dict[str, tuple[float, float, float]] | None = None, -): - """ - Class decorator to configure polling for Tango devices. - - This decorator allows for the configuration of both device-level and signal-level - polling for Tango devices. Polling is useful for device servers that do not support - event-driven updates. - - Parameters - ---------- - polling : Optional[Union[Tuple[float, float, float], - Dict[str, Tuple[float, float, float]]]], optional - Device-level polling configuration as a tuple of three floats representing the - polling interval, polling timeout, and polling delay. Alternatively, - a dictionary can be provided to specify signal-level polling configurations - directly. - signal_polling : Optional[Dict[str, Tuple[float, float, float]]], optional - Signal-level polling configuration as a dictionary where keys are signal names - and values are tuples of three floats representing the polling interval, polling - timeout, and polling delay. - """ - if isinstance(polling, dict): - signal_polling = polling - polling = None +@dataclass +class TangoPolling(Generic[T]): + ophyd_polling_period: float = 0.1 + abs_change: T | None = None + rel_change: T | None = None - def decorator(cls): - if polling is not None: - cls._polling = (True, *polling) - if signal_polling is not None: - cls._signal_polling = {k: (True, *v) for k, v in signal_polling.items()} - return cls - return decorator +def fill_backend_with_polling( + support_events: bool, backend: TangoSignalBackend, annotations: list[Any] +): + unhandled = [] + while annotations: + annotation = annotations.pop(0) + backend.allow_events(support_events) + if isinstance(annotation, TangoPolling): + backend.set_polling( + not support_events, + annotation.ophyd_polling_period, + annotation.abs_change, + annotation.rel_change, + ) + else: + unhandled.append(annotation) + annotations.extend(unhandled) + # These leftover annotations will now be handled by the iterator class TangoDeviceConnector(DeviceConnector): @@ -96,13 +75,11 @@ def __init__( self, trl: str | None, device_proxy: DeviceProxy | None, - polling: tuple[bool, float, float | None, float | None], - signal_polling: dict[str, tuple[bool, float, float, float]], + support_events: bool, ) -> None: self.trl = trl self.proxy = device_proxy - self._polling = polling - self._signal_polling = signal_polling + self._support_events = support_events def create_children_from_annotations(self, device: Device): if not hasattr(self, "filler"): @@ -110,11 +87,14 @@ def create_children_from_annotations(self, device: Device): device=device, signal_backend_factory=TangoSignalBackend, device_connector_factory=lambda: TangoDeviceConnector( - None, None, (False, 0.1, None, None), {} + None, None, self._support_events ), ) list(self.filler.create_devices_from_annotations(filled=False)) - list(self.filler.create_signals_from_annotations(filled=False)) + for backend, annotations in self.filler.create_signals_from_annotations( + filled=False + ): + fill_backend_with_polling(self._support_events, backend, annotations) self.filler.check_created() async def connect_mock(self, device: Device, mock: LazyMock): @@ -145,12 +125,6 @@ async def connect_real(self, device: Device, timeout: float, force_reconnect: bo backend = self.filler.fill_child_signal(name, signal_type) backend.datatype = await infer_python_type(full_trl, self.proxy) backend.set_trl(full_trl) - if polling := self._signal_polling.get(name, ()): - backend.set_polling(*polling) - backend.allow_events(False) - elif self._polling[0]: - backend.set_polling(*self._polling) - backend.allow_events(False) # Check that all the requested children have been filled self.filler.check_filled(f"{self.trl}: {children}") # Set the name of the device to name all children diff --git a/src/ophyd_async/tango/signal/_signal.py b/src/ophyd_async/tango/core/_signal.py similarity index 94% rename from src/ophyd_async/tango/signal/_signal.py rename to src/ophyd_async/tango/core/_signal.py index 26c954ab3e..ab173e35dd 100644 --- a/src/ophyd_async/tango/signal/_signal.py +++ b/src/ophyd_async/tango/core/_signal.py @@ -16,7 +16,14 @@ SignalW, SignalX, ) -from tango import AttrDataFormat, AttrWriteType, CmdArgType, DeviceProxy, DevState +from tango import ( + AttrDataFormat, + AttrWriteType, + CmdArgType, + DeviceProxy, + DevState, + NonSupportedFeature, # type: ignore +) from tango.asyncio import DeviceProxy as AsyncDeviceProxy from ._tango_transport import TangoSignalBackend, get_python_type @@ -174,8 +181,11 @@ async def infer_signal_type( else: dev_proxy = proxy - if tr_name in dev_proxy.get_pipe_list(): - raise NotImplementedError("Pipes are not supported") + try: + if tr_name in dev_proxy.get_pipe_list(): + raise NotImplementedError("Pipes are not supported") + except NonSupportedFeature: # type: ignore + pass if tr_name not in dev_proxy.get_attribute_list(): if tr_name not in dev_proxy.get_command_list(): diff --git a/src/ophyd_async/tango/base_devices/_tango_readable.py b/src/ophyd_async/tango/core/_tango_readable.py similarity index 87% rename from src/ophyd_async/tango/base_devices/_tango_readable.py rename to src/ophyd_async/tango/core/_tango_readable.py index 4a8fe3d1a4..1765333299 100644 --- a/src/ophyd_async/tango/base_devices/_tango_readable.py +++ b/src/ophyd_async/tango/core/_tango_readable.py @@ -1,11 +1,10 @@ from __future__ import annotations -from ophyd_async.core import ( - StandardReadable, -) -from ophyd_async.tango.base_devices._base_device import TangoDevice +from ophyd_async.core import StandardReadable from tango import DeviceProxy +from ._base_device import TangoDevice + class TangoReadable(TangoDevice, StandardReadable): """ diff --git a/src/ophyd_async/tango/signal/_tango_transport.py b/src/ophyd_async/tango/core/_tango_transport.py similarity index 100% rename from src/ophyd_async/tango/signal/_tango_transport.py rename to src/ophyd_async/tango/core/_tango_transport.py diff --git a/src/ophyd_async/tango/demo/_counter.py b/src/ophyd_async/tango/demo/_counter.py index 402c9ddd65..b8b59ff309 100644 --- a/src/ophyd_async/tango/demo/_counter.py +++ b/src/ophyd_async/tango/demo/_counter.py @@ -2,18 +2,17 @@ from ophyd_async.core import DEFAULT_TIMEOUT, AsyncStatus, SignalR, SignalRW, SignalX from ophyd_async.core import StandardReadableFormat as Format -from ophyd_async.tango import TangoReadable, tango_polling +from ophyd_async.tango.core import TangoPolling, TangoReadable -# Enable device level polling, useful for servers that do not support events -# Polling for individual signal can be enabled with a dict -@tango_polling({"counts": (1.0, 0.1, 0.1), "sample_time": (0.1, 0.1, 0.1)}) class TangoCounter(TangoReadable): # Enter the name and type of the signals you want to use - # If type is None or Signal, the type will be inferred from the Tango device - counts: A[SignalR[int], Format.HINTED_SIGNAL] - sample_time: A[SignalRW[float], Format.CONFIG_SIGNAL] + # If the server doesn't support events, the TangoPolling annotation gives + # the parameters for ophyd to poll instead + counts: A[SignalR[int], Format.HINTED_SIGNAL, TangoPolling(1.0, 0.1, 0.1)] + sample_time: A[SignalRW[float], Format.CONFIG_SIGNAL, TangoPolling(0.1, 0.1, 0.1)] start: SignalX + # If a tango name clashes with a bluesky verb, add a trailing underscore reset_: SignalX @AsyncStatus.wrap diff --git a/src/ophyd_async/tango/demo/_mover.py b/src/ophyd_async/tango/demo/_mover.py index c249afef2b..5f1da8ded9 100644 --- a/src/ophyd_async/tango/demo/_mover.py +++ b/src/ophyd_async/tango/demo/_mover.py @@ -1,4 +1,5 @@ import asyncio +from typing import Annotated as A from bluesky.protocols import Movable, Stoppable @@ -16,18 +17,18 @@ wait_for_value, ) from ophyd_async.core import StandardReadableFormat as Format -from ophyd_async.tango import TangoReadable, tango_polling +from ophyd_async.tango.core import TangoPolling, TangoReadable from tango import DevState -# Enable device level polling, useful for servers that do not support events -@tango_polling((0.1, 0.1, 0.1)) class TangoMover(TangoReadable, Movable, Stoppable): # Enter the name and type of the signals you want to use - # If type is None or Signal, the type will be inferred from the Tango device - position: SignalRW[float] - velocity: SignalRW[float] - state: SignalR[DevState] + # If the server doesn't support events, the TangoPolling annotation gives + # the parameters for ophyd to poll instead + position: A[SignalRW[float], TangoPolling(0.1, 0.1, 0.1)] + velocity: A[SignalRW[float], TangoPolling(0.1, 0.1, 0.1)] + state: A[SignalR[DevState], TangoPolling(0.1)] + # If a tango name clashes with a bluesky verb, add a trailing underscore stop_: SignalX def __init__(self, trl: str | None = "", name=""): diff --git a/src/ophyd_async/testing/__init__.py b/src/ophyd_async/testing/__init__.py new file mode 100644 index 0000000000..d3efd849bd --- /dev/null +++ b/src/ophyd_async/testing/__init__.py @@ -0,0 +1,22 @@ +import asyncio + + +async def wait_for_pending_wakeups(max_yields=20, raise_if_exceeded=True): + """Allow any ready asyncio tasks to be woken up. + + Used in: + + - Tests to allow tasks like ``set()`` to start so that signal + puts can be tested + - `observe_value` to allow it to be wrapped in `asyncio.wait_for` + with a timeout + """ + loop = asyncio.get_event_loop() + # If anything has called loop.call_soon or is scheduled a wakeup + # then let it run + for _ in range(max_yields): + await asyncio.sleep(0) + if not loop._ready: # type: ignore # noqa: SLF001 + return + if raise_if_exceeded: + raise RuntimeError(f"Tasks still scheduling wakeups after {max_yields} yields") diff --git a/system_tests/epics/eiger/test_eiger_system.py b/system_tests/epics/eiger/test_eiger_system.py index 4cfc81dae3..8a27c41689 100644 --- a/system_tests/epics/eiger/test_eiger_system.py +++ b/system_tests/epics/eiger/test_eiger_system.py @@ -72,7 +72,7 @@ async def test_trigger_saves_file(test_eiger: EigerDetector, setup_device: Setup single_shot = EigerTriggerInfo( frame_timeout=None, number_of_triggers=1, - trigger=DetectorTrigger.internal, + trigger=DetectorTrigger.INTERNAL, deadtime=None, livetime=None, energy_ev=10000, diff --git a/tests/conftest.py b/tests/conftest.py index 8e0cf546f8..ce62218413 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -231,7 +231,7 @@ def one_shot_trigger_info() -> TriggerInfo: return TriggerInfo( frame_timeout=None, number_of_triggers=1, - trigger=DetectorTrigger.internal, + trigger=DetectorTrigger.INTERNAL, deadtime=None, livetime=None, ) diff --git a/tests/core/test_device.py b/tests/core/test_device.py index 833ca5541a..9850da8eee 100644 --- a/tests/core/test_device.py +++ b/tests/core/test_device.py @@ -102,7 +102,7 @@ async def test_children_of_device_have_set_names_and_get_connected( ): assert parent.name == "parent" assert parent.child1.name == "parent-child1" - assert parent._child2.name == "parent-child2" + assert parent._child2.name == "parent-_child2" assert parent.dict_with_children.name == "parent-dict_with_children" assert parent.dict_with_children[123].name == "parent-dict_with_children-123" @@ -112,6 +112,20 @@ async def test_children_of_device_have_set_names_and_get_connected( assert parent.dict_with_children[123].connected +async def test_children_of_device_with_different_separator( + parent: DummyDeviceGroup, +): + for separator in ("_", None): + # The second time round, check that it doesn't change name if + # we pass None, as this is what PviConnector does + parent.set_name("parent", child_name_separator=separator) + assert parent.name == "parent" + assert parent.child1.name == "parent_child1" + assert parent._child2.name == "parent__child2" + assert parent.dict_with_children.name == "parent_dict_with_children" + assert parent.dict_with_children[123].name == "parent_dict_with_children_123" + + async def test_device_with_device_collector(): async with DeviceCollector(mock=True): parent = DummyDeviceGroup("parent") @@ -120,7 +134,7 @@ async def test_device_with_device_collector(): assert parent.parent is None assert parent.child1.name == "parent-child1" assert parent.child1.parent == parent - assert parent._child2.name == "parent-child2" + assert parent._child2.name == "parent-_child2" assert parent._child2.parent == parent assert parent.dict_with_children.name == "parent-dict_with_children" assert parent.dict_with_children.parent == parent diff --git a/tests/core/test_device_save_loader.py b/tests/core/test_device_save_loader.py index 8106311ada..f4a5711da5 100644 --- a/tests/core/test_device_save_loader.py +++ b/tests/core/test_device_save_loader.py @@ -1,4 +1,3 @@ -from collections.abc import Sequence from os import path from typing import Any from unittest.mock import patch @@ -24,6 +23,7 @@ walk_rw_signals, ) from ophyd_async.epics.core import epics_signal_r, epics_signal_rw +from ophyd_async.epics.testing import ExampleEnum, ExamplePvaDevice, ExampleTable class EnumTest(StrictEnum): @@ -51,40 +51,6 @@ def __init__(self, name: str): super().__init__(name) -class MyEnum(StrictEnum): - one = "one" - two = "two" - three = "three" - - -class SomeTable(Table): - some_float: Array1D[np.float64] - some_int: Array1D[np.int32] - some_enum: Sequence[MyEnum] - - -class DummyDeviceGroupAllTypes(Device): - def __init__(self, name: str): - self.pv_int: SignalRW = epics_signal_rw(int, "PV1") - self.pv_float: SignalRW = epics_signal_rw(float, "PV2") - self.pv_str: SignalRW = epics_signal_rw(str, "PV2") - self.pv_enum_str: SignalRW = epics_signal_rw(MyEnum, "PV3") - self.pv_enum: SignalRW = epics_signal_rw(MyEnum, "PV4") - self.pv_array_int8 = epics_signal_rw(Array1D[np.int8], "PV5") - self.pv_array_uint8 = epics_signal_rw(Array1D[np.uint8], "PV6") - self.pv_array_int16 = epics_signal_rw(Array1D[np.int16], "PV7") - self.pv_array_uint16 = epics_signal_rw(Array1D[np.uint16], "PV8") - self.pv_array_int32 = epics_signal_rw(Array1D[np.int32], "PV9") - self.pv_array_uint32 = epics_signal_rw(Array1D[np.uint32], "PV10") - self.pv_array_int64 = epics_signal_rw(Array1D[np.int64], "PV11") - self.pv_array_uint64 = epics_signal_rw(Array1D[np.uint64], "PV12") - self.pv_array_float32 = epics_signal_rw(Array1D[np.float32], "PV13") - self.pv_array_float64 = epics_signal_rw(Array1D[np.float64], "PV14") - self.pv_array_str = epics_signal_rw(Sequence[str], "PV16") - self.pv_protocol_device_abstraction = epics_signal_rw(Table, "pva://PV17") - super().__init__(name) - - @pytest.fixture async def device() -> DummyDeviceGroup: device = DummyDeviceGroup("parent") @@ -93,8 +59,8 @@ async def device() -> DummyDeviceGroup: @pytest.fixture -async def device_all_types() -> DummyDeviceGroupAllTypes: - device = DummyDeviceGroupAllTypes("parent") +async def device_all_types() -> ExamplePvaDevice: + device = ExamplePvaDevice("parent") await device.connect(mock=True) return device @@ -123,23 +89,23 @@ async def test_enum_yaml_formatting(tmp_path): async def test_save_device_all_types( - RE: RunEngine, device_all_types: DummyDeviceGroupAllTypes, tmp_path + RE: RunEngine, device_all_types: ExamplePvaDevice, tmp_path ): # Populate fake device with PV's... - await device_all_types.pv_int.set(1) - await device_all_types.pv_float.set(1.234) - await device_all_types.pv_str.set("test_string") - await device_all_types.pv_enum_str.set("two") - await device_all_types.pv_enum.set(MyEnum.two) + await device_all_types.my_int.set(1) + await device_all_types.my_float.set(1.234) + await device_all_types.my_str.set("test_string") + await device_all_types.enum.set(ExampleEnum.B) + await device_all_types.enum2.set("Bbb") for pv, dtype in { - device_all_types.pv_array_int8: np.int8, - device_all_types.pv_array_uint8: np.uint8, - device_all_types.pv_array_int16: np.int16, - device_all_types.pv_array_uint16: np.uint16, - device_all_types.pv_array_int32: np.int32, - device_all_types.pv_array_uint32: np.uint32, - device_all_types.pv_array_int64: np.int64, - device_all_types.pv_array_uint64: np.uint64, + device_all_types.int8a: np.int8, + device_all_types.uint8a: np.uint8, + device_all_types.int16a: np.int16, + device_all_types.uint16a: np.uint16, + device_all_types.int32a: np.int32, + device_all_types.uint32a: np.uint32, + device_all_types.int64a: np.int64, + device_all_types.uint64a: np.uint64, }.items(): await pv.set( np.array( @@ -147,8 +113,8 @@ async def test_save_device_all_types( ) ) for pv, dtype in { - device_all_types.pv_array_float32: np.float32, - device_all_types.pv_array_float64: np.float64, + device_all_types.float32a: np.float32, + device_all_types.float64a: np.float64, }.items(): finfo = np.finfo(dtype) data = np.array( @@ -166,14 +132,16 @@ async def test_save_device_all_types( ) await pv.set(data) - await device_all_types.pv_array_str.set( + await device_all_types.stra.set( ["one", "two", "three"], ) - await device_all_types.pv_protocol_device_abstraction.set( - SomeTable( - some_float=np.arange(3, dtype=np.float64), - some_int=np.arange(3), - some_enum=[MyEnum.one, MyEnum.two, MyEnum.three], + await device_all_types.table.set( + ExampleTable( + bool=np.array([False, False, True, True], np.bool_), + int=np.array([1, 8, -9, 32], np.int32), + float=np.array([1.8, 8.2, -6, 32.9887], np.float64), + str=["Hello", "World", "Foo", "Bar"], + enum=[ExampleEnum.A, ExampleEnum.B, ExampleEnum.A, ExampleEnum.C], ) ) diff --git a/tests/core/test_flyer.py b/tests/core/test_flyer.py index 6c894974b2..44b353534a 100644 --- a/tests/core/test_flyer.py +++ b/tests/core/test_flyer.py @@ -28,28 +28,28 @@ class TriggerState(StrictEnum): - null = "null" - preparing = "preparing" - starting = "starting" - stopping = "stopping" + NULL = "null" + PREPARING = "preparing" + STARTING = "starting" + STOPPING = "stopping" class DummyTriggerLogic(FlyerController[int]): def __init__(self): - self.state = TriggerState.null + self.state = TriggerState.NULL async def prepare(self, value: int): - self.state = TriggerState.preparing + self.state = TriggerState.PREPARING return value async def kickoff(self): - self.state = TriggerState.starting + self.state = TriggerState.STARTING async def complete(self): - self.state = TriggerState.null + self.state = TriggerState.NULL async def stop(self): - self.state = TriggerState.stopping + self.state = TriggerState.STOPPING class DummyWriter(DetectorWriter): @@ -160,7 +160,7 @@ def append_and_print(name, doc): def flying_plan(): yield from bps.stage_all(*detectors, flyer) - assert flyer._trigger_logic.state == TriggerState.stopping + assert flyer._trigger_logic.state == TriggerState.STOPPING # move the flyer to the correct place, before fly scanning. # Prepare the flyer first to get the trigger info for the detectors @@ -172,14 +172,14 @@ def flying_plan(): detector, TriggerInfo( number_of_triggers=number_of_triggers, - trigger=DetectorTrigger.constant_gate, + trigger=DetectorTrigger.CONSTANT_GATE, deadtime=2, livetime=2, ), wait=True, ) - assert flyer._trigger_logic.state == TriggerState.preparing + assert flyer._trigger_logic.state == TriggerState.PREPARING for detector in detectors: detector.controller.disarm.assert_called_once() # type: ignore @@ -195,7 +195,7 @@ def flying_plan(): for detector in detectors: yield from bps.complete(detector, wait=False, group="complete") - assert flyer._trigger_logic.state == TriggerState.null + assert flyer._trigger_logic.state == TriggerState.NULL # Manually increment the index as if a frame was taken frames_completed += frames @@ -227,7 +227,7 @@ def flying_plan(): yield from bps.unstage_all(flyer, *detectors) for detector in detectors: assert detector.controller.disarm.called # type: ignore - assert trigger_logic.state == TriggerState.stopping + assert trigger_logic.state == TriggerState.STOPPING # fly scan RE(flying_plan()) @@ -282,14 +282,14 @@ async def test_hardware_triggered_flyable_too_many_kickoffs( flyer = StandardFlyer(trigger_logic, name="flyer") trigger_info = TriggerInfo( number_of_triggers=number_of_triggers, - trigger=DetectorTrigger.constant_gate, + trigger=DetectorTrigger.CONSTANT_GATE, deadtime=2, livetime=2, ) def flying_plan(): yield from bps.stage_all(*detectors, flyer) - assert flyer._trigger_logic.state == TriggerState.stopping + assert flyer._trigger_logic.state == TriggerState.STOPPING # move the flyer to the correct place, before fly scanning. # Prepare the flyer first to get the trigger info for the detectors @@ -316,7 +316,7 @@ def flying_plan(): for detector in detectors: yield from bps.complete(detector, wait=False, group="complete") - assert flyer._trigger_logic.state == TriggerState.null + assert flyer._trigger_logic.state == TriggerState.NULL # Manually increment the index as if a frame was taken for detector in detectors: @@ -367,7 +367,7 @@ def flying_plan(): ( { "number_of_triggers": 1, - "trigger": DetectorTrigger.constant_gate, + "trigger": DetectorTrigger.CONSTANT_GATE, "deadtime": 2, "livetime": 2, "frame_timeout": "a", @@ -388,7 +388,7 @@ def flying_plan(): ( { "number_of_triggers": 1, - "trigger": DetectorTrigger.internal, + "trigger": DetectorTrigger.INTERNAL, "deadtime": 2, "livetime": 1, "frame_timeout": -1, diff --git a/tests/core/test_mock_signal_backend.py b/tests/core/test_mock_signal_backend.py index c5824b5435..046f0097f6 100644 --- a/tests/core/test_mock_signal_backend.py +++ b/tests/core/test_mock_signal_backend.py @@ -118,7 +118,7 @@ async def test_mock_utils_throw_error_if_backend_isnt_mock_signal_backend(): get_mock_put(signal).assert_called_once_with(10) exc_msgs.append(str(exc.value)) with pytest.raises(AssertionError) as exc: - async with mock_puts_blocked(signal): + with mock_puts_blocked(signal): ... exc_msgs.append(str(exc.value)) with pytest.raises(AssertionError) as exc: @@ -182,7 +182,7 @@ async def mock_signals(): async def test_blocks_during_put(mock_signals): signal1, signal2 = mock_signals - async with mock_puts_blocked(signal1, signal2): + with mock_puts_blocked(signal1, signal2): status1 = signal1.set("second_value", wait=True, timeout=None) status2 = signal2.set("second_value", wait=True, timeout=None) assert await signal1.get_value() == "second_value" diff --git a/tests/core/test_observe.py b/tests/core/test_observe.py index 14b9443ac2..50e0a167b5 100644 --- a/tests/core/test_observe.py +++ b/tests/core/test_observe.py @@ -60,14 +60,14 @@ async def tick(): recv = [] async def watch(): - async for val in observe_value(sig): + async for val in observe_value(sig, done_timeout=0.2): recv.append(val) t = asyncio.create_task(tick()) start = time.time() try: with pytest.raises(asyncio.TimeoutError): - await asyncio.wait_for(watch(), timeout=0.2) + await watch() assert recv == [0, 1] assert time.time() - start == pytest.approx(0.2, abs=0.05) finally: @@ -85,7 +85,7 @@ async def tick(): recv = [] async def watch(): - async for val in observe_value(sig): + async for val in observe_value(sig, done_timeout=0.2): time.sleep(0.15) recv.append(val) @@ -93,7 +93,7 @@ async def watch(): start = time.time() try: with pytest.raises(asyncio.TimeoutError): - await asyncio.wait_for(watch(), timeout=0.2) + await watch() assert recv == [0, 1] assert time.time() - start == pytest.approx(0.3, abs=0.05) finally: @@ -105,13 +105,26 @@ async def test_observe_value_times_out_with_no_external_task(): recv = [] - async def watch(): - async for val in observe_value(sig): + async def watch(done_timeout): + async for val in observe_value(sig, done_timeout=done_timeout): recv.append(val) setter(val + 1) start = time.time() with pytest.raises(asyncio.TimeoutError): - await asyncio.wait_for(watch(), timeout=0.1) + await watch(done_timeout=0.1) assert recv assert time.time() - start == pytest.approx(0.1, abs=0.05) + + +async def test_observe_value_uses_correct_timeout(): + sig, _ = soft_signal_r_and_setter(float) + + async def watch(timeout, done_timeout): + async for _ in observe_value(sig, timeout, done_timeout=done_timeout): + ... + + start = time.time() + with pytest.raises(asyncio.TimeoutError): + await watch(timeout=0.3, done_timeout=0.15) + assert time.time() - start == pytest.approx(0.15, abs=0.05) diff --git a/tests/core/test_soft_signal_backend.py b/tests/core/test_soft_signal_backend.py index 89eb87cc36..f5c0b15c88 100644 --- a/tests/core/test_soft_signal_backend.py +++ b/tests/core/test_soft_signal_backend.py @@ -19,9 +19,9 @@ class MyEnum(StrictEnum): - a = "Aaa" - b = "Bbb" - c = "Ccc" + A = "Aaa" + B = "Bbb" + C = "Ccc" def integer_d(value): @@ -80,7 +80,7 @@ def close(self): (int, 0, 43, integer_d, default_int_type), (float, 0.0, 43.5, number_d, " adpilatus.PilatusDetector: async def test_deadtime_overridable(test_adpilatus: adpilatus.PilatusDetector): pilatus_controller = test_adpilatus._controller - pilatus_controller._readout_time = adpilatus.PilatusReadoutTime.pilatus2 + pilatus_controller._readout_time = adpilatus.PilatusReadoutTime.PILATUS2 # deadtime invariant with exposure time assert pilatus_controller.get_deadtime(0) == 2.28e-3 @@ -37,9 +37,9 @@ async def test_deadtime_invariant( @pytest.mark.parametrize( "detector_trigger,expected_trigger_mode", [ - (DetectorTrigger.internal, adpilatus.PilatusTriggerMode.internal), - (DetectorTrigger.internal, adpilatus.PilatusTriggerMode.internal), - (DetectorTrigger.internal, adpilatus.PilatusTriggerMode.internal), + (DetectorTrigger.INTERNAL, adpilatus.PilatusTriggerMode.INTERNAL), + (DetectorTrigger.INTERNAL, adpilatus.PilatusTriggerMode.INTERNAL), + (DetectorTrigger.INTERNAL, adpilatus.PilatusTriggerMode.INTERNAL), ], ) async def test_trigger_mode_set( @@ -63,7 +63,7 @@ async def test_trigger_mode_set_without_armed_pv( ): async def trigger_and_complete(): await test_adpilatus.controller.prepare( - TriggerInfo(number_of_triggers=1, trigger=DetectorTrigger.internal) + TriggerInfo(number_of_triggers=1, trigger=DetectorTrigger.INTERNAL) ) await test_adpilatus.controller.arm() await test_adpilatus.controller.wait_for_idle() @@ -75,7 +75,7 @@ async def trigger_and_complete(): with pytest.raises(asyncio.TimeoutError): await _trigger( test_adpilatus, - adpilatus.PilatusTriggerMode.internal, + adpilatus.PilatusTriggerMode.INTERNAL, trigger_and_complete, ) @@ -88,7 +88,7 @@ async def _trigger( # Default TriggerMode assert ( await test_adpilatus.drv.trigger_mode.get_value() - ) == adpilatus.PilatusTriggerMode.internal + ) == adpilatus.PilatusTriggerMode.INTERNAL await trigger_and_complete() @@ -109,7 +109,7 @@ async def test_unsupported_trigger_excepts(test_adpilatus: adpilatus.PilatusDete await test_adpilatus.prepare( TriggerInfo( number_of_triggers=1, - trigger=DetectorTrigger.edge_trigger, + trigger=DetectorTrigger.EDGE_TRIGGER, deadtime=1.0, livetime=1.0, ) @@ -127,7 +127,7 @@ async def dummy_open(multiplier: int = 0): await test_adpilatus.prepare( TriggerInfo( number_of_triggers=1, - trigger=DetectorTrigger.internal, + trigger=DetectorTrigger.INTERNAL, deadtime=1.0, livetime=1.0, ) @@ -141,16 +141,16 @@ async def test_pilatus_controller(test_adpilatus: adpilatus.PilatusDetector): pilatus_driver = pilatus._drv set_mock_value(pilatus_driver.armed, True) await pilatus.prepare( - TriggerInfo(number_of_triggers=1, trigger=DetectorTrigger.constant_gate) + TriggerInfo(number_of_triggers=1, trigger=DetectorTrigger.CONSTANT_GATE) ) await pilatus.arm() await pilatus.wait_for_idle() assert await pilatus_driver.num_images.get_value() == 1 - assert await pilatus_driver.image_mode.get_value() == adcore.ImageMode.multiple + assert await pilatus_driver.image_mode.get_value() == adcore.ImageMode.MULTIPLE assert ( await pilatus_driver.trigger_mode.get_value() - == adpilatus.PilatusTriggerMode.ext_enable + == adpilatus.PilatusTriggerMode.EXT_ENABLE ) assert await pilatus_driver.acquire.get_value() is True diff --git a/tests/epics/adsimdetector/test_sim.py b/tests/epics/adsimdetector/test_sim.py index ecb446a6e1..5ab7dea121 100644 --- a/tests/epics/adsimdetector/test_sim.py +++ b/tests/epics/adsimdetector/test_sim.py @@ -78,7 +78,7 @@ async def test_two_detectors_fly_different_rate( ): trigger_info = TriggerInfo( number_of_triggers=15, - trigger=DetectorTrigger.internal, + trigger=DetectorTrigger.INTERNAL, ) docs = defaultdict(list) @@ -166,13 +166,13 @@ def plan(): drv = controller_a.driver assert False is (yield from bps.rd(drv.acquire)) - assert adcore.ImageMode.multiple == (yield from bps.rd(drv.image_mode)) + assert adcore.ImageMode.MULTIPLE == (yield from bps.rd(drv.image_mode)) hdfb = writer_b.hdf assert True is (yield from bps.rd(hdfb.lazy_open)) assert True is (yield from bps.rd(hdfb.swmr_mode)) assert 0 == (yield from bps.rd(hdfb.num_capture)) - assert adcore.FileWriteMode.stream == (yield from bps.rd(hdfb.file_write_mode)) + assert adcore.FileWriteMode.STREAM == (yield from bps.rd(hdfb.file_write_mode)) assert (yield from bps.rd(writer_a.hdf.file_path)) == str(info_a.directory_path) file_name_a = yield from bps.rd(writer_a.hdf.file_name) @@ -364,14 +364,14 @@ async def test_ad_sim_controller(test_adsimdetector: adsimdetector.SimDetector): ad = test_adsimdetector._controller with patch("ophyd_async.core._signal.wait_for_value", return_value=None): await ad.prepare( - TriggerInfo(number_of_triggers=1, trigger=DetectorTrigger.internal) + TriggerInfo(number_of_triggers=1, trigger=DetectorTrigger.INTERNAL) ) await ad.arm() await ad.wait_for_idle() driver = ad.driver assert await driver.num_images.get_value() == 1 - assert await driver.image_mode.get_value() == adcore.ImageMode.multiple + assert await driver.image_mode.get_value() == adcore.ImageMode.MULTIPLE assert await driver.acquire.get_value() is True await ad.disarm() diff --git a/tests/epics/advimba/test_vimba.py b/tests/epics/advimba/test_vimba.py index 2e9c484bc5..adede5f362 100644 --- a/tests/epics/advimba/test_vimba.py +++ b/tests/epics/advimba/test_vimba.py @@ -27,9 +27,9 @@ async def test_get_deadtime( async def test_arming_trig_modes(test_advimba: advimba.VimbaDetector): - set_mock_value(test_advimba.drv.trigger_source, VimbaTriggerSource.freerun) - set_mock_value(test_advimba.drv.trigger_mode, VimbaOnOff.off) - set_mock_value(test_advimba.drv.exposure_mode, VimbaExposeOutMode.timed) + set_mock_value(test_advimba.drv.trigger_source, VimbaTriggerSource.FREERUN) + set_mock_value(test_advimba.drv.trigger_mode, VimbaOnOff.OFF) + set_mock_value(test_advimba.drv.exposure_mode, VimbaExposeOutMode.TIMED) async def setup_trigger_mode(trig_mode: DetectorTrigger): await test_advimba.controller.prepare( @@ -45,22 +45,22 @@ async def setup_trigger_mode(trig_mode: DetectorTrigger): assert (await test_advimba.drv.trigger_mode.get_value()) == "Off" assert (await test_advimba.drv.exposure_mode.get_value()) == "Timed" - await setup_trigger_mode(DetectorTrigger.edge_trigger) + await setup_trigger_mode(DetectorTrigger.EDGE_TRIGGER) assert (await test_advimba.drv.trigger_source.get_value()) == "Line1" assert (await test_advimba.drv.trigger_mode.get_value()) == "On" assert (await test_advimba.drv.exposure_mode.get_value()) == "Timed" - await setup_trigger_mode(DetectorTrigger.constant_gate) + await setup_trigger_mode(DetectorTrigger.CONSTANT_GATE) assert (await test_advimba.drv.trigger_source.get_value()) == "Line1" assert (await test_advimba.drv.trigger_mode.get_value()) == "On" assert (await test_advimba.drv.exposure_mode.get_value()) == "TriggerWidth" - await setup_trigger_mode(DetectorTrigger.internal) + await setup_trigger_mode(DetectorTrigger.INTERNAL) assert (await test_advimba.drv.trigger_source.get_value()) == "Freerun" assert (await test_advimba.drv.trigger_mode.get_value()) == "Off" assert (await test_advimba.drv.exposure_mode.get_value()) == "Timed" - await setup_trigger_mode(DetectorTrigger.variable_gate) + await setup_trigger_mode(DetectorTrigger.VARIABLE_GATE) assert (await test_advimba.drv.trigger_source.get_value()) == "Line1" assert (await test_advimba.drv.trigger_mode.get_value()) == "On" assert (await test_advimba.drv.exposure_mode.get_value()) == "TriggerWidth" diff --git a/tests/epics/demo/test_demo.py b/tests/epics/demo/test_demo.py index e29e13bf29..26385c6ed1 100644 --- a/tests/epics/demo/test_demo.py +++ b/tests/epics/demo/test_demo.py @@ -21,10 +21,7 @@ set_mock_value, ) from ophyd_async.epics import demo - -# Long enough for multiple asyncio event loop cycles to run so -# all the tasks have a chance to run -A_WHILE = 0.001 +from ophyd_async.testing import wait_for_pending_wakeups @pytest.fixture @@ -141,7 +138,7 @@ async def test_mover_moving_well(mock_mover: demo.Mover) -> None: time_elapsed=pytest.approx(0.1, abs=0.05), ) set_mock_value(mock_mover.readback, 0.5499999) - await asyncio.sleep(A_WHILE) + await wait_for_pending_wakeups() assert s.done assert s.success done.assert_called_once_with(s) @@ -279,14 +276,14 @@ async def test_read_sensor(mock_sensor: demo.Sensor): assert (await mock_sensor.read())["mock_sensor-value"]["value"] == 0 assert (await mock_sensor.read_configuration())["mock_sensor-mode"][ "value" - ] == demo.EnergyMode.low + ] == demo.EnergyMode.LOW desc = (await mock_sensor.describe_configuration())["mock_sensor-mode"] assert desc["dtype"] == "string" assert desc["choices"] == ["Low Energy", "High Energy"] - set_mock_value(mock_sensor.mode, demo.EnergyMode.high) + set_mock_value(mock_sensor.mode, demo.EnergyMode.HIGH) assert (await mock_sensor.read_configuration())["mock_sensor-mode"][ "value" - ] == demo.EnergyMode.high + ] == demo.EnergyMode.HIGH async def test_sensor_in_plan(RE: RunEngine, mock_sensor: demo.Sensor): @@ -315,7 +312,7 @@ async def test_assembly_renaming() -> None: thing.set_name("foo") assert thing.x.name == "foo-x" assert thing.x.velocity.name == "foo-x-velocity" - assert thing.x.stop_.name == "foo-x-stop" + assert thing.x.stop_.name == "foo-x-stop_" async def test_dynamic_sensor_group_disconnected(): diff --git a/tests/epics/eiger/test_eiger_detector.py b/tests/epics/eiger/test_eiger_detector.py index 5fe6ebc836..422da84cf1 100644 --- a/tests/epics/eiger/test_eiger_detector.py +++ b/tests/epics/eiger/test_eiger_detector.py @@ -26,7 +26,7 @@ async def test_when_prepared_with_energy_then_energy_set_on_detector(detector): EigerTriggerInfo( frame_timeout=None, number_of_triggers=1, - trigger=DetectorTrigger.internal, + trigger=DetectorTrigger.INTERNAL, deadtime=None, livetime=None, energy_ev=10000, diff --git a/tests/epics/signal/test_records.db b/tests/epics/signal/test_records.db deleted file mode 100644 index 18ad9a6678..0000000000 --- a/tests/epics/signal/test_records.db +++ /dev/null @@ -1,330 +0,0 @@ -record(bo, "$(P)bool") { - field(ZNAM, "No") - field(ONAM, "Yes") - field(VAL, "1") - field(PINI, "YES") -} - -record(bo, "$(P)bool_unnamed") { - field(VAL, "1") - field(PINI, "YES") -} - -record(longout, "$(P)int") { - field(LLSV, "MAJOR") # LOLO is alarm - field(LSV, "MINOR") # LOW is warning - field(HSV, "MINOR") # HIGH is warning - field(HHSV, "MAJOR") # HIHI is alarm - field(HOPR, "100") - field(HIHI, "98") - field(HIGH, "96") - field(DRVH, "90") - field(DRVL, "10") - field(LOW, "5") - field(LOLO, "2") - field(LOPR, "0") - field(VAL, "42") - field(PINI, "YES") -} - -record(longout, "$(P)partialint") { - field(LLSV, "MAJOR") # LOLO is alarm - field(HHSV, "MAJOR") # HIHI is alarm - field(HOPR, "100") - field(HIHI, "98") - field(DRVH, "90") - field(DRVL, "10") - field(LOLO, "2") - field(LOPR, "0") - field(VAL, "42") - field(PINI, "YES") -} - -record(longout, "$(P)lessint") { - field(HSV, "MINOR") # LOW is warning - field(LSV, "MINOR") # HIGH is warning - field(HOPR, "100") - field(HIGH, "98") - field(LOW, "2") - field(LOPR, "0") - field(VAL, "42") - field(PINI, "YES") -} - -record(ao, "$(P)float") { - field(PREC, "1") - field(EGU, "mm") - field(VAL, "3.141") - field(PINI, "YES") -} - -record(ao, "$(P)float_prec_0") { - field(PREC, "0") - field(EGU, "mm") - field(VAL, "3") - field(PINI, "YES") -} - -record(ao, "$(P)float_prec_1") { - field(PREC, "1") - field(EGU, "mm") - field(VAL, "3") - field(PINI, "YES") -} - -record(stringout, "$(P)str") { - field(VAL, "hello") - field(PINI, "YES") -} - -record(mbbo, "$(P)enum") { - field(ZRST, "Aaa") - field(ZRVL, "5") - field(ONST, "Bbb") - field(ONVL, "6") - field(TWST, "Ccc") - field(TWVL, "7") - field(VAL, "1") - field(PINI, "YES") -} - -record(mbbo, "$(P)enum2") { - field(ZRST, "Aaa") - field(ONST, "Bbb") - field(TWST, "Ccc") - field(VAL, "1") - field(PINI, "YES") -} - -record(waveform, "$(P)int8a") { - field(NELM, "3") - field(FTVL, "CHAR") - field(INP, {const:[-128, 127]}) - field(PINI, "YES") -} - -record(waveform, "$(P)uint8a") { - field(NELM, "3") - field(FTVL, "UCHAR") - field(INP, {const:[0, 255]}) - field(PINI, "YES") -} - -record(waveform, "$(P)int16a") { - field(NELM, "3") - field(FTVL, "SHORT") - field(INP, {const:[-32768, 32767]}) - field(PINI, "YES") -} - -record(waveform, "$(P)uint16a") { - field(NELM, "3") - field(FTVL, "USHORT") - field(INP, {const:[0, 65535]}) - field(PINI, "YES") -} - -record(waveform, "$(P)int32a") { - field(NELM, "3") - field(FTVL, "LONG") - field(INP, {const:[-2147483648, 2147483647]}) - field(PINI, "YES") -} - -record(waveform, "$(P)uint32a") { - field(NELM, "3") - field(FTVL, "ULONG") - field(INP, {const:[0, 4294967295]}) - field(PINI, "YES") -} - -record(waveform, "$(P)int64a") { - field(NELM, "3") - field(FTVL, "INT64") - # Can't do 64-bit int with JSON numbers in a const link... - field(INP, {const:[-2147483649, 2147483648]}) - field(PINI, "YES") -} - -record(waveform, "$(P)uint64a") { - field(NELM, "3") - field(FTVL, "UINT64") - field(INP, {const:[0, 4294967297]}) - field(PINI, "YES") -} - -record(waveform, "$(P)float32a") { - field(NELM, "3") - field(FTVL, "FLOAT") - field(INP, {const:[0.000002, -123.123]}) - field(PINI, "YES") -} - -record(waveform, "$(P)float64a") { - field(NELM, "3") - field(FTVL, "DOUBLE") - field(INP, {const:[0.1, -12345678.123]}) - field(PINI, "YES") -} - -record(waveform, "$(P)stra") { - field(NELM, "3") - field(FTVL, "STRING") - field(INP, {const:["five", "six", "seven"]}) - field(PINI, "YES") -} - -record(waveform, "$(P)longstr") { - field(NELM, "80") - field(FTVL, "CHAR") - field(INP, {const:"a string that is just longer than forty characters"}) - field(PINI, "YES") -} - -record(lsi, "$(P)longstr2") { - field(SIZV, "80") - field(INP, {const:"a string that is just longer than forty characters"}) - field(PINI, "YES") -} - -record(waveform, "$(P)table:labels") { - field(FTVL, "STRING") - field(NELM, "5") - field(INP, {const:["Bool", "Int", "Float", "Str", "Enum"]}) - field(PINI, "YES") - info(Q:group, { - "$(P)table": { - "+id": "epics:nt/NTTable:1.0", - "labels": { - "+type": "plain", - "+channel": "VAL" - } - } - }) -} - -record(waveform, "$(P)table:bool") -{ - field(FTVL, "UCHAR") - field(NELM, "4096") - field(INP, {const:[false, false, true, true]}) - field(PINI, "YES") - info(Q:group, { - "$(P)table": { - "value.bool": { - "+type": "plain", - "+channel": "VAL", - "+putorder": 1 - } - } - }) -} - -record(waveform, "$(P)table:int") -{ - field(FTVL, "LONG") - field(NELM, "4096") - field(INP, {const:[1, 8, -9, 32]}) - field(PINI, "YES") - info(Q:group, { - "$(P)table": { - "value.int": { - "+type": "plain", - "+channel": "VAL", - "+putorder": 2 - } - } - }) -} - -record(waveform, "$(P)table:float") -{ - field(FTVL, "DOUBLE") - field(NELM, "4096") - field(INP, {const:[1.8, 8.2, -6, 32.9887]}) - field(PINI, "YES") - info(Q:group, { - "$(P)table": { - "value.float": { - "+type": "plain", - "+channel": "VAL", - "+putorder": 3 - } - } - }) -} - -record(waveform, "$(P)table:str") -{ - field(FTVL, "STRING") - field(NELM, "4096") - field(INP, {const:["Hello", "World", "Foo", "Bar"]}) - field(PINI, "YES") - info(Q:group, { - "$(P)table": { - "value.str": { - "+type": "plain", - "+channel": "VAL", - "+putorder": 4 - } - } - }) -} - -record(waveform, "$(P)table:enum") -{ - field(FTVL, "STRING") - field(NELM, "4096") - field(INP, {const:["Aaa", "Bbb", "Aaa", "Ccc"]}) - field(PINI, "YES") - info(Q:group, { - "$(P)table": { - "value.enum": { - "+type": "plain", - "+channel": "VAL", - "+putorder": 5, - "+trigger": "*", - }, - "": {"+type": "meta", "+channel": "VAL"} - } - }) -} - -record(longout, "$(P)ntndarray:ArraySize0_RBV") { - field(VAL, "3") - field(PINI, "YES") - info(Q:group, { - "$(P)ntndarray":{ - "dimension[0].size":{+channel:"VAL", +type:"plain", +putorder:0} - } - }) -} - -record(longout, "$(P)ntndarray:ArraySize1_RBV") { - field(VAL, "2") - field(PINI, "YES") - info(Q:group, { - "$(P)ntndarray":{ - "dimension[1].size":{+channel:"VAL", +type:"plain", +putorder:0} - } - }) -} - -record(waveform, "$(P)ntndarray:data") -{ - field(FTVL, "INT64") - field(NELM, "6") - field(INP, {const:[0, 0, 0, 0, 0, 0]}) - field(PINI, "YES") - info(Q:group, { - "$(P)ntndarray":{ - +id:"epics:nt/NTNDArray:1.0", - "value":{ - +type:"any", - +channel:"VAL", - +trigger:"*", - }, - "": {+type:"meta", +channel:"SEVR"} - } - }) -} diff --git a/tests/epics/signal/test_signals.py b/tests/epics/signal/test_signals.py index 3f79442c9f..7dedc96d8b 100644 --- a/tests/epics/signal/test_signals.py +++ b/tests/epics/signal/test_signals.py @@ -1,23 +1,17 @@ import asyncio import os -import random -import string -import subprocess -import sys import time from collections.abc import Sequence from contextlib import closing -from dataclasses import dataclass from enum import Enum from pathlib import Path from types import GenericAlias -from typing import Any, Literal +from typing import Any, Literal, get_args from unittest.mock import ANY import bluesky.plan_stubs as bps import numpy as np import pytest -from aioca import purge_channel_caches from bluesky.protocols import Reading from bluesky.run_engine import RunEngine from event_model import DataKey, Limits, LimitsRange @@ -27,83 +21,67 @@ Array1D, NotConnected, SignalBackend, - SignalRW, StrictEnum, SubsetEnum, T, Table, load_from_yaml, + observe_value, save_to_yaml, ) from ophyd_async.epics.core import ( + EpicsDevice, epics_signal_r, epics_signal_rw, epics_signal_rw_rbv, epics_signal_w, epics_signal_x, ) -from ophyd_async.epics.core._signal import _epics_signal_backend # noqa: PLC2701 - -RECORDS = str(Path(__file__).parent / "test_records.db") -PV_PREFIX = "".join(random.choice(string.ascii_lowercase) for _ in range(12)) - - -@dataclass -class IOC: - process: subprocess.Popen - protocol: Literal["ca", "pva"] - - async def make_backend( - self, typ: type | None, suff: str, timeout=10.0 - ) -> SignalBackend: - # Calculate the pv - pv = f"{self.protocol}://{PV_PREFIX}:{self.protocol}:{suff}" - # Make and connect the backend - backend = _epics_signal_backend(typ, pv, pv) - await backend.connect(timeout=timeout) - return backend - - -# Use a module level fixture per protocol so it's fast to run tests. This means -# we need to add a record for every PV that we will modify in tests to stop -# tests interfering with each other -@pytest.fixture(scope="module", params=["pva", "ca"]) -def ioc(request: pytest.FixtureRequest): - protocol = request.param - process = subprocess.Popen( - [ - sys.executable, - "-m", - "epicscorelibs.ioc", - "-m", - f"P={PV_PREFIX}:{protocol}:", - "-d", - RECORDS, - ], - stdin=subprocess.PIPE, - stdout=subprocess.PIPE, - stderr=subprocess.STDOUT, - universal_newlines=True, - ) +from ophyd_async.epics.core._signal import ( + _epics_signal_backend, # noqa: PLC2701 +) +from ophyd_async.epics.testing import ( + ExampleCaDevice, + ExampleEnum, + ExamplePvaDevice, + ExampleTable, + TestingIOC, + connect_example_device, + get_example_ioc, +) - start_time = time.monotonic() - line = "" - while "iocRun: All initialization complete" not in line: - if line: - print(line) - if time.monotonic() - start_time > 10: - raise TimeoutError("IOC did not start in time") - line = process.stdout.readline().strip() # type: ignore - yield IOC(process, protocol) +class MySubsetEnum(SubsetEnum): + A = "Aaa" + B = "Bbb" + C = "Ccc" - # close backend caches before the event loop - purge_channel_caches() - try: - print(process.communicate("exit()")[0]) - except ValueError: - # Someone else already called communicate - pass + +Protocol = Literal["ca", "pva"] +PARAMETERISE_PROTOCOLS = pytest.mark.parametrize("protocol", get_args(Protocol)) + + +@pytest.fixture(scope="module") +def ioc(): + ioc = get_example_ioc() + ioc.start_ioc() + yield ioc + ioc.stop_ioc() + + +def get_prefix(ioc: TestingIOC, protocol: Protocol): + device_cls = ExamplePvaDevice if protocol == "pva" else ExampleCaDevice + return ioc.prefix_for(device_cls) + + +async def _make_backend(ioc, typ: type | None, protocol: str, suff: str, timeout=10.0): + device_cls = ExampleCaDevice if protocol == "ca" else ExamplePvaDevice + prefix = ioc.prefix_for(device_cls) + pv = f"{protocol}://{prefix}{suff}" + # Make and connect the backend + backend = _epics_signal_backend(typ, pv, pv) + await backend.connect(timeout=timeout) + return backend def assert_types_are_equal(t_actual, t_expected, actual_value): @@ -145,11 +123,36 @@ async def assert_updates(self, expected_value, expected_type=None): update_reading = await asyncio.wait_for(self.updates.get(), timeout=5) update_value = update_reading["value"] - assert update_value == expected_value == backend_value + # We can't compare arrays of bool easily so we do it as numpy rows + if issubclass(type(update_value), Table): + assert all( + row1 == row2 + for row1, row2 in zip( + expected_value.numpy_table(), + update_value.numpy_table(), + strict=True, + ) + ) + assert all( + row1 == row2 + for row1, row2 in zip( + expected_value.numpy_table(), + backend_value.numpy_table(), + strict=True, + ) + ) + else: + assert update_value == expected_value == backend_value + if expected_type: assert_types_are_equal(type(update_value), expected_type, update_value) assert_types_are_equal(type(backend_value), expected_type, backend_value) - assert update_reading == expected_reading == backend_reading + + for key in expected_reading: + if key == "value": + continue + assert update_reading[key] == expected_reading[key] + assert backend_reading[key] == expected_reading[key] def close(self): self.backend.set_callback(None) @@ -163,20 +166,23 @@ def _is_numpy_subclass(t): async def assert_monitor_then_put( - ioc: IOC, + ioc: TestingIOC, + device: EpicsDevice, suffix: str, + protocol: Protocol, datakey: dict, initial_value: T, put_value: T, datatype: type[T] | None = None, check_type: bool | None = True, ): - backend = await ioc.make_backend(datatype, suffix) + signal = getattr(device, suffix) + backend = signal._connector.backend # Make a monitor queue that will monitor for updates q = MonitorQueue(backend) try: # Check datakey - source = f"{ioc.protocol}://{PV_PREFIX}:{ioc.protocol}:{suffix}" + source = f"{protocol}://{get_prefix(ioc, protocol)}{suffix}" assert dict(source=source, **datakey) == await backend.get_datakey(source) # Check initial value await q.assert_updates( @@ -192,18 +198,6 @@ async def assert_monitor_then_put( q.close() -class MyEnum(StrictEnum): - a = "Aaa" - b = "Bbb" - c = "Ccc" - - -class MySubsetEnum(SubsetEnum): - a = "Aaa" - b = "Bbb" - c = "Ccc" - - _metadata: dict[str, dict[str, dict[str, Any]]] = { "ca": { "boolean": {"units": ANY, "limits": ANY}, @@ -293,91 +287,91 @@ def get_dtype_numpy(suffix: str) -> str: # type: ignore ls2 = "another string that is just longer than forty characters" +async def assert_backend_get_put_monitor( + ioc: TestingIOC, + datatype: type[T], + suffix: str, + initial_value: T, + put_value: T, + tmp_path: Path, + protocol: Protocol, + device, +): + # With the given datatype, check we have the correct initial value and putting + # works + await assert_monitor_then_put( + ioc, + device, + suffix, + protocol, + datakey(protocol, suffix, initial_value), # type: ignore + initial_value, + put_value, + datatype, + ) + # With datatype guessed from CA/PVA, check we can set it back to the initial value + await assert_monitor_then_put( + ioc, + device, + suffix, + protocol, + datakey(protocol, suffix, put_value), # type: ignore + put_value, + initial_value, + datatype=None, + ) + + yaml_path = tmp_path / "test.yaml" + save_to_yaml([{"test": put_value}], yaml_path) + loaded = load_from_yaml(yaml_path) + assert np.all(loaded[0]["test"] == put_value) + + +@PARAMETERISE_PROTOCOLS @pytest.mark.parametrize( - "datatype, suffix, initial_value, put_value, supported_backends", + "datatype, suffix, initial_value, put_value", [ # python builtin scalars - (int, "int", 42, 43, {"ca", "pva"}), - (float, "float", 3.141, 43.5, {"ca", "pva"}), - (str, "str", "hello", "goodbye", {"ca", "pva"}), - (MyEnum, "enum", MyEnum.b, MyEnum.c, {"ca", "pva"}), + (int, "my_int", 42, 43), + (float, "my_float", 3.141, 43.5), + (str, "my_str", "hello", "goodbye"), + (ExampleEnum, "enum", ExampleEnum.B, ExampleEnum.C), # numpy arrays of numpy types - ( - Array1D[np.int8], - "int8a", - [-128, 127], - [-8, 3, 44], - {"pva"}, - ), ( Array1D[np.uint8], "uint8a", [0, 255], [218], - {"ca", "pva"}, ), ( Array1D[np.int16], "int16a", [-32768, 32767], [-855], - {"ca", "pva"}, - ), - ( - Array1D[np.uint16], - "uint16a", - [0, 65535], - [5666], - {"pva"}, ), ( Array1D[np.int32], "int32a", [-2147483648, 2147483647], [-2], - {"ca", "pva"}, - ), - ( - Array1D[np.uint32], - "uint32a", - [0, 4294967295], - [1022233], - {"pva"}, - ), - ( - Array1D[np.int64], - "int64a", - [-2147483649, 2147483648], - [-3], - {"pva"}, - ), - ( - Array1D[np.uint64], - "uint64a", - [0, 4294967297], - [995444], - {"pva"}, ), ( Array1D[np.float32], "float32a", [0.000002, -123.123], [1.0], - {"ca", "pva"}, ), ( Array1D[np.float64], "float64a", [0.1, -12345678.123], [0.2], - {"ca", "pva"}, ), ( Sequence[str], "stra", ["five", "six", "seven"], ["nine", "ten"], - {"pva", "ca"}, ), # Can't do long strings until https://github.com/epics-base/pva2pva/issues/17 # (str, "longstr", ls1, ls2), @@ -385,47 +379,92 @@ def get_dtype_numpy(suffix: str) -> str: # type: ignore ], ) async def test_backend_get_put_monitor( - ioc: IOC, + ioc, datatype: type[T], suffix: str, initial_value: T, put_value: T, tmp_path: Path, - supported_backends: set[str], + protocol: Protocol, ): - # ca can't support all the types - for backend in supported_backends: - assert backend in ["ca", "pva"] - if ioc.protocol not in supported_backends: - return - # With the given datatype, check we have the correct initial value and putting - # works - await assert_monitor_then_put( + device = await connect_example_device(ioc, protocol) + await assert_backend_get_put_monitor( ioc, + datatype, suffix, - datakey(ioc.protocol, suffix, initial_value), # type: ignore initial_value, put_value, - datatype, + tmp_path, + protocol, + device, ) - # With datatype guessed from CA/PVA, check we can set it back to the initial value - await assert_monitor_then_put( + + +@pytest.mark.parametrize( + "datatype, suffix, initial_value, put_value", + [ + ( + Array1D[np.int8], + "int8a", + [-128, 127], + [-8, 3, 44], + ), + ( + Array1D[np.uint16], + "uint16a", + [0, 65535], + [5666], + ), + ( + Array1D[np.uint32], + "uint32a", + [0, 4294967295], + [1022233], + ), + ( + Array1D[np.int64], + "int64a", + [-2147483649, 2147483648], + [-3], + ), + ( + Array1D[np.uint64], + "uint64a", + [0, 4294967297], + [995444], + ), + # Can't do long strings until https://github.com/epics-base/pva2pva/issues/17 + # (str, "longstr", ls1, ls2), + # (str, "longstr2.VAL$", ls1, ls2), + ], +) +async def test_backend_get_put_monitor_pva( + ioc, + datatype: type[T], + suffix: str, + initial_value: T, + put_value: T, + tmp_path: Path, +): + protocol = "pva" + device = await connect_example_device(ioc, protocol) + await assert_backend_get_put_monitor( ioc, + datatype, suffix, - datakey(ioc.protocol, suffix, put_value), # type: ignore - put_value, initial_value, - datatype=None, + put_value, + tmp_path, + protocol, + device, ) - yaml_path = tmp_path / "test.yaml" - save_to_yaml([{"test": put_value}], yaml_path) - loaded = load_from_yaml(yaml_path) - assert np.all(loaded[0]["test"] == put_value) - -@pytest.mark.parametrize("suffix", ["bool", "bool_unnamed"]) -async def test_bool_conversion_of_enum(ioc: IOC, suffix: str, tmp_path: Path) -> None: +@PARAMETERISE_PROTOCOLS +@pytest.mark.parametrize("suffix", ["my_bool", "bool_unnamed"]) +async def test_bool_conversion_of_enum( + suffix: str, tmp_path: Path, ioc, protocol +) -> None: """Booleans are converted to Short Enumerations with values 0,1 as database does not support boolean natively. The flow of test_backend_get_put_monitor Gets a value with a dtype of None: we @@ -435,10 +474,13 @@ async def test_bool_conversion_of_enum(ioc: IOC, suffix: str, tmp_path: Path) -> """ # With the given datatype, check we have the correct initial value and putting # works + device = await connect_example_device(ioc, protocol) await assert_monitor_then_put( ioc, + device, suffix, - datakey(ioc.protocol, suffix), + protocol, + datakey(protocol, suffix), True, False, bool, @@ -446,8 +488,10 @@ async def test_bool_conversion_of_enum(ioc: IOC, suffix: str, tmp_path: Path) -> # With datatype guessed from CA/PVA, check we can set it back to the initial value await assert_monitor_then_put( ioc, + device, suffix, - datakey(ioc.protocol, suffix, True), + protocol, + datakey(protocol, suffix, True), False, True, bool, @@ -459,15 +503,17 @@ async def test_bool_conversion_of_enum(ioc: IOC, suffix: str, tmp_path: Path) -> assert np.all(loaded[0]["test"] is False) -async def test_error_raised_on_disconnected_PV(ioc: IOC) -> None: - if ioc.protocol == "pva": +@PARAMETERISE_PROTOCOLS +async def test_error_raised_on_disconnected_PV(ioc, protocol) -> None: + if protocol == "pva": expected = "pva://Disconnect" - elif ioc.protocol == "ca": + elif protocol == "ca": expected = "ca://Disconnect" else: raise TypeError() - backend = await ioc.make_backend(bool, "bool") - signal = SignalRW(backend) + device = await connect_example_device(ioc, protocol) + signal = device.my_bool + backend = signal._connector.backend # The below will work without error await signal.set(False) # Change the name of write_pv to mock disconnection @@ -477,9 +523,9 @@ async def test_error_raised_on_disconnected_PV(ioc: IOC) -> None: class BadEnum(StrictEnum): - a = "Aaa" - b = "B" - c = "Ccc" + A = "Aaa" + B = "B" + C = "Ccc" def test_enum_equality(): @@ -489,41 +535,42 @@ def test_enum_equality(): """ class GeneratedChoices(StrictEnum): - a = "Aaa" - b = "B" - c = "Ccc" + A = "Aaa" + B = "B" + C = "Ccc" class ExtendedGeneratedChoices(StrictEnum): - a = "Aaa" - b = "B" - c = "Ccc" - d = "Ddd" + A = "Aaa" + B = "B" + C = "Ccc" + D = "Ddd" for enum_class in (GeneratedChoices, ExtendedGeneratedChoices): - assert BadEnum.a == enum_class.a - assert BadEnum.a.value == enum_class.a - assert BadEnum.a.value == enum_class.a.value - assert BadEnum(enum_class.a) is BadEnum.a - assert BadEnum(enum_class.a.value) is BadEnum.a + assert BadEnum.A == enum_class.A + assert BadEnum.A.value == enum_class.A + assert BadEnum.A.value == enum_class.A.value + assert BadEnum(enum_class.A) is BadEnum.A + assert BadEnum(enum_class.A.value) is BadEnum.A assert not BadEnum == enum_class # We will always PUT BadEnum by String, and GET GeneratedChoices by index, # so shouldn't ever run across this from conversion code, but may occur if # casting returned values or passing as enum rather than value. with pytest.raises(ValueError): - BadEnum(ExtendedGeneratedChoices.d) + BadEnum(ExtendedGeneratedChoices.D) class EnumNoString(Enum): - a = "Aaa" + A = "Aaa" class SubsetEnumWrongChoices(SubsetEnum): - a = "Aaa" - b = "B" - c = "Ccc" + A = "Aaa" + B = "B" + C = "Ccc" +@PARAMETERISE_PROTOCOLS @pytest.mark.parametrize( "typ, suff, errors", [ @@ -580,71 +627,66 @@ class SubsetEnumWrongChoices(SubsetEnum): ), ], ) -async def test_backend_wrong_type_errors(ioc: IOC, typ, suff, errors): +async def test_backend_wrong_type_errors(ioc, typ, suff, errors, protocol): with pytest.raises(TypeError) as cm: - await ioc.make_backend(typ, suff) + await _make_backend(ioc, typ, protocol, suff) for error in errors: assert error in str(cm.value) -async def test_backend_put_enum_string(ioc: IOC) -> None: - backend = await ioc.make_backend(MyEnum, "enum2") +@PARAMETERISE_PROTOCOLS +async def test_backend_put_enum_string(ioc, protocol) -> None: + device = await connect_example_device(ioc, protocol) + backend = device.enum2._connector.backend # Don't do this in production code, but allow on CLI await backend.put("Ccc", wait=True) # type: ignore - assert MyEnum.c == await backend.get_value() + assert ExampleEnum.C == await backend.get_value() -async def test_backend_enum_which_doesnt_inherit_string(ioc: IOC) -> None: +@PARAMETERISE_PROTOCOLS +async def test_backend_enum_which_doesnt_inherit_string(ioc, protocol) -> None: with pytest.raises(TypeError): - backend = await ioc.make_backend(EnumNoString, "enum2") - await backend.put("Aaa", wait=True) + await _make_backend(ioc, EnumNoString, protocol, "enum2") -async def test_backend_get_setpoint(ioc: IOC) -> None: - backend = await ioc.make_backend(MyEnum, "enum2") +@PARAMETERISE_PROTOCOLS +async def test_backend_get_setpoint(ioc, protocol) -> None: + device = await connect_example_device(ioc, protocol) + backend = device.enum2._connector.backend await backend.put("Ccc", wait=True) - assert await backend.get_setpoint() == MyEnum.c + assert await backend.get_setpoint() == ExampleEnum.C def approx_table(datatype: type[Table], table: Table): new_table = datatype(**table.model_dump()) for k, v in new_table: if datatype is Table: - setattr(new_table, k, pytest.approx(v)) + setattr(new_table, k, v) else: - object.__setattr__(new_table, k, pytest.approx(v)) + object.__setattr__(new_table, k, v) return new_table -class MyTable(Table): - bool: Array1D[np.bool_] - int: Array1D[np.int32] - float: Array1D[np.float64] - str: Sequence[str] - enum: Sequence[MyEnum] - - -async def test_pva_table(ioc: IOC) -> None: - if ioc.protocol == "ca": - # CA can't do tables - return - initial = MyTable( +async def test_pva_table(ioc) -> None: + protocol: Protocol = "pva" + # CA can't do tables + initial = ExampleTable( bool=np.array([False, False, True, True], np.bool_), int=np.array([1, 8, -9, 32], np.int32), float=np.array([1.8, 8.2, -6, 32.9887], np.float64), str=["Hello", "World", "Foo", "Bar"], - enum=[MyEnum.a, MyEnum.b, MyEnum.a, MyEnum.c], + enum=[ExampleEnum.A, ExampleEnum.B, ExampleEnum.A, ExampleEnum.C], ) - put = MyTable( + put = ExampleTable( bool=np.array([True, False], np.bool_), int=np.array([-5, 32], np.int32), float=np.array([8.5, -6.97], np.float64), str=["Hello", "Bat"], - enum=[MyEnum.c, MyEnum.b], + enum=[ExampleEnum.C, ExampleEnum.B], ) # Make and connect the backend - for t, i, p in [(MyTable, initial, put), (None, put, initial)]: - backend = await ioc.make_backend(t, "table") + for t, i, p in [(ExampleTable, initial, put), (None, put, initial)]: + backend = await _make_backend(ioc, t, protocol, "table") # Make a monitor queue that will monitor for updates q = MonitorQueue(backend) try: @@ -674,19 +716,18 @@ async def test_pva_table(ioc: IOC) -> None: q.close() -async def test_pva_ntdarray(ioc: IOC): - if ioc.protocol == "ca": - # CA can't do ndarray - return +async def test_pva_ntndarray(ioc): + protocol = "pva" + # CA can't do ndarray put = np.array([1, 2, 3, 4, 5, 6], dtype=np.int64).reshape((2, 3)) initial = np.zeros_like(put) - backend = await ioc.make_backend(np.ndarray, "ntndarray") - + backend = await _make_backend(ioc, np.ndarray, protocol, "ntndarray") # Backdoor into the "raw" data underlying the NDArray in QSrv # not supporting direct writes to NDArray at the moment. - raw_data_backend = await ioc.make_backend(Array1D[np.int64], "ntndarray:data") + device = await connect_example_device(ioc, protocol) + raw_data_backend = device.ntndarray_data._connector.backend # Make a monitor queue that will monitor for updates for i, p in [(initial, put), (put, initial)]: @@ -703,20 +744,18 @@ async def test_pva_ntdarray(ioc: IOC): await q.assert_updates(pytest.approx(p)) -async def test_writing_to_ndarray_raises_typeerror(ioc: IOC): - if ioc.protocol == "ca": - # CA can't do ndarray - return - - backend = await ioc.make_backend(np.ndarray, "ntndarray") +async def test_writing_to_ndarray_raises_typeerror(ioc): + # CA can't do ndarray + backend = await _make_backend(ioc, np.ndarray, "pva", "ntndarray") with pytest.raises(TypeError): await backend.put(np.zeros((6,), dtype=np.int64), wait=True) -async def test_non_existent_errors(ioc: IOC): +@PARAMETERISE_PROTOCOLS +async def test_non_existent_errors(ioc, protocol): with pytest.raises(NotConnected): - await ioc.make_backend(str, "non-existent", timeout=0.1) + await _make_backend(ioc, str, protocol, "non-existent", timeout=0.1) def test_make_backend_fails_for_different_transports(): @@ -759,22 +798,20 @@ def test_signal_helpers(): assert execute._connector.backend.write_pv == "Execute" -async def test_str_enum_returns_enum(ioc: IOC): - await ioc.make_backend(MyEnum, "enum") - pv_name = f"{ioc.protocol}://{PV_PREFIX}:{ioc.protocol}:enum" - - sig = epics_signal_rw(MyEnum, pv_name) - await sig.connect() - val = await sig.get_value() - assert repr(val) == "" - assert val is MyEnum.b +@PARAMETERISE_PROTOCOLS +async def test_str_enum_returns_enum(ioc, protocol): + device = await connect_example_device(ioc, protocol) + val = await device.enum.get_value() + assert repr(val) == "" + assert val is ExampleEnum.B assert val == "Bbb" -async def test_str_datatype_in_mbbo(ioc: IOC): - backend = await ioc.make_backend(MyEnum, "enum") - pv_name = f"{ioc.protocol}://{PV_PREFIX}:{ioc.protocol}:enum" - sig = epics_signal_rw(str, pv_name) +@PARAMETERISE_PROTOCOLS +async def test_str_datatype_in_mbbo(ioc, protocol): + device = await connect_example_device(ioc, protocol) + sig = device.enum + backend = sig._connector.backend datakey = await backend.get_datakey(sig.source) assert datakey["choices"] == ["Aaa", "Bbb", "Ccc"] await sig.connect() @@ -784,9 +821,9 @@ async def test_str_datatype_in_mbbo(ioc: IOC): assert val == "Bbb" -async def test_runtime_enum_returns_str(ioc: IOC): - await ioc.make_backend(MySubsetEnum, "enum") - pv_name = f"{ioc.protocol}://{PV_PREFIX}:{ioc.protocol}:enum" +@PARAMETERISE_PROTOCOLS +async def test_runtime_enum_returns_str(ioc, protocol): + pv_name = f"{protocol}://{get_prefix(ioc, protocol)}enum" sig = epics_signal_rw(MySubsetEnum, pv_name) await sig.connect() @@ -794,32 +831,25 @@ async def test_runtime_enum_returns_str(ioc: IOC): assert val == "Bbb" -async def test_signal_returns_units_and_precision(ioc: IOC): - await ioc.make_backend(float, "float") - pv_name = f"{ioc.protocol}://{PV_PREFIX}:{ioc.protocol}:float" - - sig = epics_signal_rw(float, pv_name) - await sig.connect() +@PARAMETERISE_PROTOCOLS +async def test_signal_returns_units_and_precision(ioc, protocol): + device = await connect_example_device(ioc, protocol) + sig = device.my_float datakey = (await sig.describe())[""] assert datakey["units"] == "mm" assert datakey["precision"] == 1 -async def test_signal_not_return_none_units_and_precision(ioc: IOC): - await ioc.make_backend(str, "str") - pv_name = f"{ioc.protocol}://{PV_PREFIX}:{ioc.protocol}:str" - - sig = epics_signal_rw(str, pv_name) - await sig.connect() - datakey = (await sig.describe())[""] +@PARAMETERISE_PROTOCOLS +async def test_signal_not_return_none_units_and_precision(ioc, protocol): + device = await connect_example_device(ioc, protocol) + datakey = (await device.my_str.describe())[""] assert not hasattr(datakey, "units") assert not hasattr(datakey, "precision") -async def test_signal_returns_limits(ioc: IOC): - await ioc.make_backend(int, "int") - pv_name = f"{ioc.protocol}://{PV_PREFIX}:{ioc.protocol}:int" - +@PARAMETERISE_PROTOCOLS +async def test_signal_returns_limits(ioc, protocol): expected_limits = Limits( # LOW, HIGH warning=LimitsRange(low=5.0, high=96.0), @@ -831,16 +861,13 @@ async def test_signal_returns_limits(ioc: IOC): alarm=LimitsRange(low=2.0, high=98.0), ) - sig = epics_signal_rw(int, pv_name) - await sig.connect() - limits = (await sig.describe())[""]["limits"] + device = await connect_example_device(ioc, protocol) + limits = (await device.my_int.describe())[""]["limits"] assert limits == expected_limits -async def test_signal_returns_partial_limits(ioc: IOC): - await ioc.make_backend(int, "partialint") - pv_name = f"{ioc.protocol}://{PV_PREFIX}:{ioc.protocol}:partialint" - +@PARAMETERISE_PROTOCOLS +async def test_signal_returns_partial_limits(ioc, protocol): expected_limits = Limits( # LOLO, HIHI alarm=LimitsRange(low=2.0, high=98.0), @@ -849,20 +876,16 @@ async def test_signal_returns_partial_limits(ioc: IOC): # LOPR, HOPR display=LimitsRange(low=0.0, high=100.0), ) - if ioc.protocol == "ca": + if protocol == "ca": # HSV, LSV not set, but still present for CA expected_limits["warning"] = LimitsRange(low=0, high=0) - - sig = epics_signal_rw(int, pv_name) - await sig.connect() - limits = (await sig.describe())[""]["limits"] + device = await connect_example_device(ioc, protocol) + limits = (await device.partialint.describe())[""]["limits"] assert limits == expected_limits -async def test_signal_returns_warning_and_partial_limits(ioc: IOC): - await ioc.make_backend(int, "lessint") - pv_name = f"{ioc.protocol}://{PV_PREFIX}:{ioc.protocol}:lessint" - +@PARAMETERISE_PROTOCOLS +async def test_signal_returns_warning_and_partial_limits(ioc, protocol): expected_limits = Limits( # control = display if DRVL, DRVH not set control=LimitsRange(low=0.0, high=100.0), @@ -871,52 +894,54 @@ async def test_signal_returns_warning_and_partial_limits(ioc: IOC): # LOW, HIGH warning=LimitsRange(low=2.0, high=98.0), ) - if ioc.protocol == "ca": + if protocol == "ca": # HSV, LSV not set, but still present for CA expected_limits["alarm"] = LimitsRange(low=0, high=0) - - sig = epics_signal_rw(int, pv_name) + device = await connect_example_device(ioc, protocol) + sig = device.lessint await sig.connect() limits = (await sig.describe())[""]["limits"] assert limits == expected_limits -async def test_signal_not_return_no_limits(ioc: IOC): - await ioc.make_backend(MyEnum, "enum") - pv_name = f"{ioc.protocol}://{PV_PREFIX}:{ioc.protocol}:enum" - sig = epics_signal_rw(MyEnum, pv_name) - await sig.connect() - datakey = (await sig.describe())[""] +@PARAMETERISE_PROTOCOLS +async def test_signal_not_return_no_limits(ioc, protocol): + device = await connect_example_device(ioc, protocol) + datakey = (await device.enum.describe())[""] assert not hasattr(datakey, "limits") -async def test_signals_created_for_prec_0_float_can_use_int(ioc: IOC): - pv_name = f"{ioc.protocol}://{PV_PREFIX}:{ioc.protocol}:float_prec_0" +@PARAMETERISE_PROTOCOLS +async def test_signals_created_for_prec_0_float_can_use_int(ioc, protocol): + pv_name = f"{protocol}://{get_prefix(ioc, protocol)}float_prec_0" sig = epics_signal_rw(int, pv_name) await sig.connect() -async def test_signals_created_for_not_prec_0_float_cannot_use_int(ioc: IOC): - pv_name = f"{ioc.protocol}://{PV_PREFIX}:{ioc.protocol}:float_prec_1" +@PARAMETERISE_PROTOCOLS +async def test_signals_created_for_not_prec_0_float_cannot_use_int(ioc, protocol): + pv_name = f"{protocol}://{get_prefix(ioc, protocol)}float_prec_1" sig = epics_signal_rw(int, pv_name) with pytest.raises( TypeError, - match=f"{ioc.protocol}:float_prec_1 with inferred datatype float" - ".* cannot be coerced to int", + match="float_prec_1 with inferred datatype float" ".* cannot be coerced to int", ): await sig.connect() -async def test_bool_works_for_mismatching_enums(ioc: IOC): - pv_name = f"{ioc.protocol}://{PV_PREFIX}:{ioc.protocol}:bool" +@PARAMETERISE_PROTOCOLS +async def test_bool_works_for_mismatching_enums(ioc, protocol): + pv_name = f"{protocol}://{get_prefix(ioc, protocol)}bool" sig = epics_signal_rw(bool, pv_name, pv_name + "_unnamed") await sig.connect() @pytest.mark.skipif(os.name == "nt", reason="Hangs on windows for unknown reasons") -async def test_can_read_using_ophyd_async_then_ophyd(ioc: IOC): - oa_read = f"{ioc.protocol}://{PV_PREFIX}:{ioc.protocol}:float_prec_1" - ophyd_read = f"{PV_PREFIX}:{ioc.protocol}:float_prec_0" +@PARAMETERISE_PROTOCOLS +async def test_can_read_using_ophyd_async_then_ophyd(ioc, protocol): + prefix = get_prefix(ioc, protocol) + oa_read = f"{protocol}://{prefix}float_prec_1" + ophyd_read = f"{prefix}float_prec_0" ophyd_async_sig = epics_signal_rw(float, oa_read) await ophyd_async_sig.connect() @@ -935,3 +960,25 @@ def my_plan(): def test_signal_module_emits_deprecation_warning(): with pytest.deprecated_call(): import ophyd_async.epics.signal # noqa: F401 + + +@PARAMETERISE_PROTOCOLS +async def test_observe_ticking_signal_with_busy_loop(ioc, protocol): + sig = epics_signal_rw(int, f"{protocol}://{get_prefix(ioc, protocol)}ticking") + await sig.connect() + + recv = [] + + async def watch(): + async for val in observe_value(sig, done_timeout=0.4): + time.sleep(0.3) + recv.append(val) + + start = time.time() + + with pytest.raises(asyncio.TimeoutError): + await watch() + assert time.time() - start == pytest.approx(0.6, abs=0.1) + assert len(recv) == 2 + # Don't check values as CA and PVA have different algorithms for + # dropping updates for slow callbacks diff --git a/tests/epics/test_motor.py b/tests/epics/test_motor.py index 1760f245ba..fff0c5daa0 100644 --- a/tests/epics/test_motor.py +++ b/tests/epics/test_motor.py @@ -10,6 +10,7 @@ AsyncStatus, DeviceCollector, callback_on_mock_put, + get_mock_put, mock_puts_blocked, observe_value, set_mock_put_proceeds, @@ -17,17 +18,7 @@ soft_signal_rw, ) from ophyd_async.epics import motor - - -async def wait_for_wakeups(max_yields=10): - loop = asyncio.get_event_loop() - # If anything has called loop.call_soon or is scheduled a wakeup - # then let it run - for _ in range(max_yields): - await asyncio.sleep(0) - if not loop._ready: - return - raise RuntimeError(f"Tasks still scheduling wakeups after {max_yields} yields") +from ophyd_async.testing import wait_for_pending_wakeups @pytest.fixture @@ -44,7 +35,7 @@ async def sim_motor(): async def wait_for_eq(item, attribute, comparison, timeout): timeout_time = time.monotonic() + timeout while getattr(item, attribute) != comparison: - await wait_for_wakeups() + await wait_for_pending_wakeups() if time.monotonic() > timeout_time: raise TimeoutError @@ -56,7 +47,7 @@ async def test_motor_moving_well(sim_motor: motor.Motor) -> None: s.watch(watcher) done = Mock() s.add_callback(done) - await wait_for_wakeups() + await wait_for_pending_wakeups() await wait_for_eq(watcher, "call_count", 1, 1) assert watcher.call_args == call( name="sim_motor", @@ -86,7 +77,7 @@ async def test_motor_moving_well(sim_motor: motor.Motor) -> None: set_mock_value(sim_motor.motor_done_move, True) set_mock_value(sim_motor.user_readback, 0.55) set_mock_put_proceeds(sim_motor.user_setpoint, True) - await wait_for_wakeups() + await wait_for_pending_wakeups() await wait_for_eq(s, "done", True, 1) done.assert_called_once_with(s) @@ -98,7 +89,7 @@ async def test_motor_moving_well_2(sim_motor: motor.Motor) -> None: s.watch(watcher) done = Mock() s.add_callback(done) - await wait_for_wakeups() + await wait_for_pending_wakeups() assert watcher.call_count == 1 assert watcher.call_args == call( name="sim_motor", @@ -126,7 +117,7 @@ async def test_motor_moving_well_2(sim_motor: motor.Motor) -> None: time_elapsed=pytest.approx(0.1, abs=0.2), ) set_mock_put_proceeds(sim_motor.user_setpoint, True) - await wait_for_wakeups() + await wait_for_pending_wakeups() assert s.done done.assert_called_once_with(s) @@ -164,8 +155,13 @@ async def test_motor_moving_stopped(sim_motor: motor.Motor): await asyncio.sleep(0.2) assert not s.done await sim_motor.stop() + + # Note: needs to explicitly be called with 1, not just processed. + # See https://epics.anl.gov/bcda/synApps/motor/motorRecord.html#Fields_command + get_mock_put(sim_motor.motor_stop).assert_called_once_with(1, wait=False) + set_mock_put_proceeds(sim_motor.user_setpoint, True) - await wait_for_wakeups() + await wait_for_pending_wakeups() assert s.done assert s.success is False @@ -339,7 +335,7 @@ async def test_locatable(sim_motor: motor.Motor) -> None: lambda x, *_, **__: set_mock_value(sim_motor.user_readback, x), ) assert (await sim_motor.locate())["readback"] == 0 - async with mock_puts_blocked(sim_motor.user_setpoint): + with mock_puts_blocked(sim_motor.user_setpoint): move_status = sim_motor.set(10) assert (await sim_motor.locate())["readback"] == 0 await move_status diff --git a/tests/fastcs/panda/test_panda_connect.py b/tests/fastcs/panda/test_panda_connect.py index a5a88d7d87..e395ef1ca4 100644 --- a/tests/fastcs/panda/test_panda_connect.py +++ b/tests/fastcs/panda/test_panda_connect.py @@ -56,7 +56,9 @@ class CommonPandaBlocksNoData(Device): class Panda(CommonPandaBlocksNoData): def __init__(self, uri: str, name: str = ""): - super().__init__(name=name, connector=fastcs_connector(self, uri)) + super().__init__( + name=name, connector=fastcs_connector(self, uri, "Is it ok?") + ) yield Panda @@ -124,7 +126,7 @@ async def test_panda_with_missing_blocks(panda_pva, panda_t): match=re.escape( "mypanda: cannot provision ['pcap'] from PANDAQSRVI:PVI: " "{'pulse': [None, {'d': 'PANDAQSRVI:PULSE1:PVI'}]," - " 'seq': [None, {'d': 'PANDAQSRVI:SEQ1:PVI'}]}" + " 'seq': [None, {'d': 'PANDAQSRVI:SEQ1:PVI'}]}\nIs it ok?" ), ): await panda.connect() diff --git a/tests/fastcs/panda/test_panda_control.py b/tests/fastcs/panda/test_panda_control.py index 4f9bb8c546..867d97330a 100644 --- a/tests/fastcs/panda/test_panda_control.py +++ b/tests/fastcs/panda/test_panda_control.py @@ -30,7 +30,7 @@ class PcapBlock(Device): with patch("ophyd_async.fastcs.panda._control.wait_for_value", return_value=None): with pytest.raises(AttributeError) as exc: await pandaController.prepare( - TriggerInfo(number_of_triggers=1, trigger=DetectorTrigger.constant_gate) + TriggerInfo(number_of_triggers=1, trigger=DetectorTrigger.CONSTANT_GATE) ) await pandaController.arm() assert ("'PcapBlock' object has no attribute 'arm'") in str(exc.value) @@ -40,7 +40,7 @@ async def test_panda_controller_arm_disarm(mock_panda): pandaController = PandaPcapController(mock_panda.pcap) with patch("ophyd_async.fastcs.panda._control.wait_for_value", return_value=None): await pandaController.prepare( - TriggerInfo(number_of_triggers=1, trigger=DetectorTrigger.constant_gate) + TriggerInfo(number_of_triggers=1, trigger=DetectorTrigger.CONSTANT_GATE) ) await pandaController.arm() await pandaController.wait_for_idle() @@ -51,5 +51,5 @@ async def test_panda_controller_wrong_trigger(): pandaController = PandaPcapController(None) with pytest.raises(AssertionError): await pandaController.prepare( - TriggerInfo(number_of_triggers=1, trigger=DetectorTrigger.internal) + TriggerInfo(number_of_triggers=1, trigger=DetectorTrigger.INTERNAL) ) diff --git a/tests/fastcs/panda/test_trigger.py b/tests/fastcs/panda/test_trigger.py index 66d6e1c5f3..af92c817f2 100644 --- a/tests/fastcs/panda/test_trigger.py +++ b/tests/fastcs/panda/test_trigger.py @@ -57,7 +57,7 @@ async def test_pcomp_trigger_logic(mock_panda): pulse_width=1, rising_edge_step=1, number_of_pulses=5, - direction=PcompDirection.positive, + direction=PcompDirection.POSITIVE, ) async def set_active(value: bool): @@ -145,7 +145,7 @@ def full_seq_table(trigger): full_seq_table(["A"]) assert ( "Input should be 'Immediate', 'BITA=0', 'BITA=1', 'BITB=0', 'BITB=1', " - "'BITC...' [type=enum, input_value='A', input_type=str]" + "'BITC... [type=enum, input_value='A', input_type=str]" ) in str(exc) # Pydantic is able to infer type from these diff --git a/tests/tango/test_base_device.py b/tests/tango/test_base_device.py index 3328ba51fe..60c6db9762 100644 --- a/tests/tango/test_base_device.py +++ b/tests/tango/test_base_device.py @@ -12,7 +12,7 @@ import tango from ophyd_async.core import Array1D, DeviceCollector, SignalRW, T from ophyd_async.core import StandardReadableFormat as Format -from ophyd_async.tango import TangoReadable, get_python_type +from ophyd_async.tango.core import TangoReadable, get_python_type from ophyd_async.tango.demo import ( DemoCounter, DemoMover, diff --git a/tests/tango/test_tango_signals.py b/tests/tango/test_tango_signals.py index b128709be0..63c6c25ee9 100644 --- a/tests/tango/test_tango_signals.py +++ b/tests/tango/test_tango_signals.py @@ -11,7 +11,7 @@ from test_base_device import TestDevice from ophyd_async.core import SignalBackend, SignalR, SignalRW, SignalW, SignalX, T -from ophyd_async.tango import ( +from ophyd_async.tango.core import ( TangoSignalBackend, tango_signal_r, tango_signal_rw, @@ -63,6 +63,9 @@ class TestEnum(IntEnum): COMMANDS_SET = [] for type_name, tango_type_name, py_type, values in BASE_TYPES_SET: + # pytango test utils currently fail to handle bool pytest.approx + if type_name == "boolean": + continue ATTRIBUTES_SET.extend( [ ( diff --git a/tests/tango/test_tango_transport.py b/tests/tango/test_tango_transport.py index f1164455c6..51048c4e9c 100644 --- a/tests/tango/test_tango_transport.py +++ b/tests/tango/test_tango_transport.py @@ -14,7 +14,7 @@ from ophyd_async.core import ( NotConnected, ) -from ophyd_async.tango import ( +from ophyd_async.tango.core import ( AttributeProxy, CommandProxy, TangoSignalBackend, diff --git a/tests/test_data/test_yaml_save.yml b/tests/test_data/test_yaml_save.yml index 6a0613d1a8..883433e548 100644 --- a/tests/test_data/test_yaml_save.yml +++ b/tests/test_data/test_yaml_save.yml @@ -1,46 +1,40 @@ -- pv_array_float32: - [ - -3.4028234663852886e+38, - 3.4028234663852886e+38, - 1.1754943508222875e-38, - 1.401298464324817e-45, - 0.0, - 1.2339999675750732, - 234000.0, - 3.4499998946557753e-06, - ] - pv_array_float64: - [ - -1.7976931348623157e+308, - 1.7976931348623157e+308, - 2.2250738585072014e-308, - 5.0e-324, - 0.0, - 1.234, - 234000.0, - 3.45e-06, - ] - pv_array_int16: [-32768, 32767, 0, 1, 2, 3, 4] - pv_array_int32: [-2147483648, 2147483647, 0, 1, 2, 3, 4] - pv_array_int64: [-9223372036854775808, 9223372036854775807, 0, 1, 2, 3, 4] - pv_array_int8: [-128, 127, 0, 1, 2, 3, 4] - pv_array_str: - - one - - two - - three - pv_array_uint16: [0, 65535, 0, 1, 2, 3, 4] - pv_array_uint32: [0, 4294967295, 0, 1, 2, 3, 4] - pv_array_uint64: [0, 18446744073709551615, 0, 1, 2, 3, 4] - pv_array_uint8: [0, 255, 0, 1, 2, 3, 4] - pv_enum: two - pv_enum_str: two - pv_float: 1.234 - pv_int: 1 - pv_protocol_device_abstraction: - some_enum: - - one - - two - - three - some_float: [0.0, 1.0, 2.0] - some_int: [0, 1, 2] - pv_str: test_string +- bool_unnamed: false + enum: Bbb + enum2: Bbb + float32a: [-3.4028234663852886e+38, 3.4028234663852886e+38, 1.1754943508222875e-38, + 1.401298464324817e-45, 0.0, 1.2339999675750732, 234000.0, 3.4499998946557753e-06] + float64a: [-1.7976931348623157e+308, 1.7976931348623157e+308, 2.2250738585072014e-308, + 5.0e-324, 0.0, 1.234, 234000.0, 3.45e-06] + int16a: [-32768, 32767, 0, 1, 2, 3, 4] + int32a: [-2147483648, 2147483647, 0, 1, 2, 3, 4] + int64a: [-9223372036854775808, 9223372036854775807, 0, 1, 2, 3, 4] + int8a: [-128, 127, 0, 1, 2, 3, 4] + lessint: 0 + my_bool: false + my_float: 1.234 + my_int: 1 + my_str: test_string + ntndarray_data: [] + partialint: 0 + stra: + - one + - two + - three + table: + bool: [false, false, true, true] + enum: + - Aaa + - Bbb + - Aaa + - Ccc + float: [1.8, 8.2, -6.0, 32.9887] + int: [1, 8, -9, 32] + str: + - Hello + - World + - Foo + - Bar + uint16a: [0, 65535, 0, 1, 2, 3, 4] + uint32a: [0, 4294967295, 0, 1, 2, 3, 4] + uint64a: [0, 18446744073709551615, 0, 1, 2, 3, 4] + uint8a: [0, 255, 0, 1, 2, 3, 4]