Skip to content

Commit

Permalink
Reduce boilerplate in StandardReadable
Browse files Browse the repository at this point in the history
This reduces the amount of duplication and repetition when adding
signals to a StandardReadable.

As part of this, classes defining the types of signal have been created,
which control the behaviour of the Signal being registered

Signals must be registered either using the "add_children_as_readables"
contextmanager, or the "add_readables" function.
  • Loading branch information
AlexanderWells-diamond committed Apr 18, 2024
1 parent 669dc7d commit a0675e5
Show file tree
Hide file tree
Showing 10 changed files with 209 additions and 77 deletions.
8 changes: 7 additions & 1 deletion src/ophyd_async/core/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,11 @@
)
from .signal_backend import SignalBackend
from .sim_signal_backend import SimSignalBackend
from .standard_readable import StandardReadable
from .standard_readable import (
ConfigSignal,
HintedSignal,
StandardReadable,
)
from .utils import (
DEFAULT_TIMEOUT,
Callback,
Expand Down Expand Up @@ -80,6 +84,8 @@
"ShapeProvider",
"StaticDirectoryProvider",
"StandardReadable",
"ConfigSignal",
"HintedSignal",
"TriggerInfo",
"TriggerLogic",
"HardwareTriggeredFlyable",
Expand Down
5 changes: 2 additions & 3 deletions src/ophyd_async/core/detector.py
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,6 @@

from .async_status import AsyncStatus
from .device import Device
from .signal import SignalR
from .utils import DEFAULT_TIMEOUT, merge_gathered_dicts

T = TypeVar("T")
Expand Down Expand Up @@ -161,7 +160,7 @@ def __init__(
self,
controller: DetectorControl,
writer: DetectorWriter,
config_sigs: Sequence[SignalR] = (),
config_sigs: Sequence[AsyncReadable] = (),
name: str = "",
writer_timeout: float = DEFAULT_TIMEOUT,
) -> None:
Expand Down Expand Up @@ -214,7 +213,7 @@ async def stage(self) -> None:
async def _check_config_sigs(self):
"""Checks configuration signals are named and connected."""
for signal in self._config_sigs:
if signal._name == "":
if signal.name == "":
raise Exception(
"config signal must be named before it is passed to the detector"
)
Expand Down
168 changes: 131 additions & 37 deletions src/ophyd_async/core/standard_readable.py
Original file line number Diff line number Diff line change
@@ -1,74 +1,168 @@
from typing import Dict, Sequence, Tuple
from contextlib import contextmanager
from typing import Dict, Generator, List, Optional, Sequence, Type, Union

from bluesky.protocols import Descriptor, Reading, Stageable
from bluesky.protocols import Descriptor, HasHints, Hints, Reading, Stageable

from ophyd_async.protocols import AsyncConfigurable, AsyncReadable
from ophyd_async.protocols import AsyncConfigurable, AsyncReadable, AsyncStageable

from .async_status import AsyncStatus
from .device import Device
from .signal import SignalR
from .utils import merge_gathered_dicts


class StandardReadable(Device, AsyncReadable, AsyncConfigurable, Stageable):
class StandardReadable(
Device, AsyncReadable, AsyncConfigurable, AsyncStageable, HasHints
):
"""Device that owns its children and provides useful default behavior.
- When its name is set it renames child Devices
- Signals can be registered for read() and read_configuration()
- These signals will be subscribed for read() between stage() and unstage()
"""

_read_signals: Tuple[SignalR, ...] = ()
_configuration_signals: Tuple[SignalR, ...] = ()
_read_uncached_signals: Tuple[SignalR, ...] = ()
_readables: List[AsyncReadable] = []
_configurables: List[AsyncConfigurable] = []
_stageables: List[AsyncStageable] = []

def set_readable_signals(
self,
read: Sequence[SignalR] = (),
config: Sequence[SignalR] = (),
read_uncached: Sequence[SignalR] = (),
):
"""
Parameters
----------
read:
Signals to make up :meth:`~StandardReadable.read`
conf:
Signals to make up :meth:`~StandardReadable.read_configuration`
read_uncached:
Signals to make up :meth:`~StandardReadable.read` that won't be cached
"""
self._read_signals = tuple(read)
self._configuration_signals = tuple(config)
self._read_uncached_signals = tuple(read_uncached)
_hints: Hints = {}

@AsyncStatus.wrap
async def stage(self) -> None:
for sig in self._read_signals + self._configuration_signals:
for sig in self._stageables:
await sig.stage().task

@AsyncStatus.wrap
async def unstage(self) -> None:
for sig in self._read_signals + self._configuration_signals:
for sig in self._stageables:
await sig.unstage().task

async def describe_configuration(self) -> Dict[str, Descriptor]:
return await merge_gathered_dicts(
[sig.describe() for sig in self._configuration_signals]
[sig.describe_configuration() for sig in self._configurables]
)

async def read_configuration(self) -> Dict[str, Reading]:
return await merge_gathered_dicts(
[sig.read() for sig in self._configuration_signals]
[sig.read_configuration() for sig in self._configurables]
)

async def describe(self) -> Dict[str, Descriptor]:
return await merge_gathered_dicts(
[sig.describe() for sig in self._read_signals + self._read_uncached_signals]
)
return await merge_gathered_dicts([sig.describe() for sig in self._readables])

async def read(self) -> Dict[str, Reading]:
return await merge_gathered_dicts(
[sig.read() for sig in self._read_signals]
+ [sig.read(cached=False) for sig in self._read_uncached_signals]
)
return await merge_gathered_dicts([sig.read() for sig in self._readables])

@property
def hints(self) -> Hints:
return self._hints

@contextmanager
def add_children_as_readables(
self,
wrapper: Optional[Type[Union["ConfigSignal", "HintedSignal"]]] = None,
) -> Generator[None, None, None]:
dict_copy = self.__dict__.copy()

yield

# Set symmetric difference operator gives all newly added items
new_attributes = dict_copy.items() ^ self.__dict__.items()
new_signals: List[SignalR] = [x[1] for x in new_attributes]

self._wrap_signals(wrapper, new_signals)

def add_readables(
self,
wrapper: Type[Union["ConfigSignal", "HintedSignal"]],
*signals: SignalR,
) -> None:

self._wrap_signals(wrapper, signals)

def _wrap_signals(
self,
wrapper: Optional[Type[Union["ConfigSignal", "HintedSignal"]]],
signals: Sequence[SignalR],
):

for signal in signals:
obj: Union[SignalR, "ConfigSignal", "HintedSignal"] = signal
if wrapper:
obj = wrapper(signal)

if isinstance(obj, AsyncReadable):
self._readables.append(obj)

if isinstance(obj, AsyncConfigurable):
self._configurables.append(obj)

if isinstance(obj, AsyncStageable):
self._stageables.append(obj)

if isinstance(obj, HasHints):
new_hint = obj.hints

# Merge the existing and new hints, based on the type of the value.
# This avoids default dict merge behaviour that overrided the values;
# we want to combine them when they are Sequences, and ensure they are
# identical when string values.
for key, value in new_hint.items():
if isinstance(value, Sequence):
if key in self._hints:
self._hints[key] = ( # type: ignore[literal-required]
self._hints[key] # type: ignore[literal-required]
+ value
)
else:
self._hints[key] = value # type: ignore[literal-required]
elif isinstance(value, str):
if key in self._hints:
assert (
self._hints[key] # type: ignore[literal-required]
== value
), "Hints value may not be overridden"
else:
self._hints[key] = value # type: ignore[literal-required]
else:
raise AssertionError("Unknown type in Hints dictionary")


class ConfigSignal(AsyncConfigurable):

def __init__(self, signal: SignalR) -> None:
self.signal = signal

async def read_configuration(self) -> Dict[str, Reading]:
return await self.signal.read()

async def describe_configuration(self) -> Dict[str, Descriptor]:
return await self.signal.describe()


class HintedSignal(HasHints, AsyncReadable):

def __init__(self, signal: SignalR, cached: Optional[bool] = None) -> None:
self.signal = signal
self.cached = cached
if cached:
self.stage = signal.stage
self.unstage = signal.unstage

async def read(self) -> Dict[str, Reading]:
return await self.signal.read(cached=self.cached)

async def describe(self) -> Dict[str, Descriptor]:
return await self.signal.describe()

@property
def name(self) -> str:
return self.signal.name

@property
def hints(self) -> Hints:
return {"fields": [self.signal.name]}

@classmethod
def uncached(cls, signal: SignalR):
return cls(signal, cached=False)
19 changes: 13 additions & 6 deletions src/ophyd_async/epics/areadetector/single_trigger_det.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,13 @@

from bluesky.protocols import Triggerable

from ophyd_async.core import AsyncStatus, SignalR, StandardReadable
from ophyd_async.core import (
AsyncStatus,
ConfigSignal,
HintedSignal,
SignalR,
StandardReadable,
)

from .drivers.ad_base import ADBase
from .utils import ImageMode
Expand All @@ -20,12 +26,13 @@ def __init__(
) -> None:
self.drv = drv
self.__dict__.update(plugins)
self.set_readable_signals(
# Can't subscribe to read signals as race between monitor coming back and
# caput callback on acquire
read_uncached=[self.drv.array_counter] + list(read_uncached),
config=[self.drv.acquire_time],

self.add_readables(
HintedSignal.uncached, self.drv.array_counter, *read_uncached
)

self.add_readables(ConfigSignal, self.drv.acquire_time)

super().__init__(name=name)

@AsyncStatus.wrap
Expand Down
36 changes: 20 additions & 16 deletions src/ophyd_async/epics/demo/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,14 @@
import numpy as np
from bluesky.protocols import Movable, Stoppable

from ophyd_async.core import AsyncStatus, Device, StandardReadable, observe_value
from ophyd_async.core import (
AsyncStatus,
ConfigSignal,
Device,
HintedSignal,
StandardReadable,
observe_value,
)

from ..signal.signal import epics_signal_r, epics_signal_rw, epics_signal_x

Expand All @@ -33,13 +40,11 @@ class Sensor(StandardReadable):

def __init__(self, prefix: str, name="") -> None:
# Define some signals
self.value = epics_signal_r(float, prefix + "Value")
self.mode = epics_signal_rw(EnergyMode, prefix + "Mode")
# Set name and signals for read() and read_configuration()
self.set_readable_signals(
read=[self.value],
config=[self.mode],
)
with self.add_children_as_readables(HintedSignal):
self.value = epics_signal_r(float, prefix + "Value")
with self.add_children_as_readables(ConfigSignal):
self.mode = epics_signal_rw(EnergyMode, prefix + "Mode")

super().__init__(name=name)


Expand All @@ -49,19 +54,18 @@ class Mover(StandardReadable, Movable, Stoppable):
def __init__(self, prefix: str, name="") -> None:
# Define some signals
self.setpoint = epics_signal_rw(float, prefix + "Setpoint")
self.readback = epics_signal_r(float, prefix + "Readback")
self.velocity = epics_signal_rw(float, prefix + "Velocity")
self.units = epics_signal_r(str, prefix + "Readback.EGU")
self.precision = epics_signal_r(int, prefix + "Readback.PREC")
# Signals that collide with standard methods should have a trailing underscore
self.stop_ = epics_signal_x(prefix + "Stop.PROC")
# Whether set() should complete successfully or not
self._set_success = True
# Set name and signals for read() and read_configuration()
self.set_readable_signals(
read=[self.readback],
config=[self.velocity, self.units],
)

with self.add_children_as_readables(HintedSignal):
self.readback = epics_signal_r(float, prefix + "Readback")
with self.add_children_as_readables(ConfigSignal):
self.velocity = epics_signal_rw(float, prefix + "Velocity")
self.units = epics_signal_r(str, prefix + "Readback.EGU")

super().__init__(name=name)

def set_name(self, name: str):
Expand Down
17 changes: 8 additions & 9 deletions src/ophyd_async/epics/motion/motor.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@

from bluesky.protocols import Movable, Stoppable

from ophyd_async.core import AsyncStatus, StandardReadable
from ophyd_async.core import AsyncStatus, ConfigSignal, HintedSignal, StandardReadable

from ..signal.signal import epics_signal_r, epics_signal_rw, epics_signal_x

Expand All @@ -15,25 +15,24 @@ class Motor(StandardReadable, Movable, Stoppable):
def __init__(self, prefix: str, name="") -> None:
# Define some signals
self.user_setpoint = epics_signal_rw(float, prefix + ".VAL")
self.user_readback = epics_signal_r(float, prefix + ".RBV")
self.velocity = epics_signal_rw(float, prefix + ".VELO")
self.max_velocity = epics_signal_r(float, prefix + ".VMAX")
self.acceleration_time = epics_signal_rw(float, prefix + ".ACCL")
self.motor_egu = epics_signal_r(str, prefix + ".EGU")
self.precision = epics_signal_r(int, prefix + ".PREC")
self.deadband = epics_signal_r(float, prefix + ".RDBD")
self.motor_done_move = epics_signal_r(float, prefix + ".DMOV")
self.low_limit_travel = epics_signal_rw(int, prefix + ".LLM")
self.high_limit_travel = epics_signal_rw(int, prefix + ".HLM")

with self.add_children_as_readables(ConfigSignal):
self.motor_egu = epics_signal_r(str, prefix + ".EGU")
self.velocity = epics_signal_rw(float, prefix + ".VELO")

with self.add_children_as_readables(HintedSignal):
self.user_readback = epics_signal_r(float, prefix + ".RBV")

self.motor_stop = epics_signal_x(prefix + ".STOP")
# Whether set() should complete successfully or not
self._set_success = True
# Set name and signals for read() and read_configuration()
self.set_readable_signals(
read=[self.user_readback],
config=[self.velocity, self.motor_egu],
)
super().__init__(name=name)

def set_name(self, name: str):
Expand Down
4 changes: 2 additions & 2 deletions src/ophyd_async/protocols/__init__.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
from .protocols import AsyncConfigurable, AsyncPausable, AsyncReadable
from .protocols import AsyncConfigurable, AsyncPausable, AsyncReadable, AsyncStageable

__all__ = ["AsyncReadable", "AsyncConfigurable", "AsyncPausable"]
__all__ = ["AsyncReadable", "AsyncConfigurable", "AsyncPausable", "AsyncStageable"]
Loading

0 comments on commit a0675e5

Please sign in to comment.