Declarative vs Procedural Devices#

Ophyd async has two styles of creating Devices, Declarative and Procedural. This article describes why there are two mechanisms for building Devices, and looks at the pros and cons of each style.

Procedural style#

The procedural style mirrors how you would create a traditional python class, you define an __init__ method, add some class members, then call the superclass __init__ method. In the case of ophyd async those class members are likely to be Signals and other Devices. For example, in the ophyd_async.sim.SimMotor we create its soft signal children in an __init__ method:

    """For usage when simulating a motor."""

    def __init__(self, name="", instant=True) -> None:
        """Simulate a motor, with optional velocity.

        :param name: name of device
        :param instant: whether to move instantly or calculate move time using velocity
        """
        # Define some signals
        with self.add_children_as_readables(Format.HINTED_SIGNAL):
            self.user_readback, self._user_readback_set = soft_signal_r_and_setter(
                float, 0
            )
        with self.add_children_as_readables(Format.CONFIG_SIGNAL):
            self.velocity = soft_signal_rw(float, 0 if instant else 1.0)
            self.acceleration_time = soft_signal_rw(float, 0.5)
            self.units = soft_signal_rw(str, "mm")
        self.user_setpoint = soft_signal_rw(float, 0)

        # Whether set() should complete successfully or not
        self._set_success = True
        self._move_status: AsyncStatus | None = None
        # Stored in prepare
        self._fly_info: FlySimMotorInfo | None = None
        # Set on kickoff(), complete when motor reaches end position
        self._fly_status: WatchableAsyncStatus | None = None

        super().__init__(name=name)

It is explicit and obvious, but verbose. It also allows you to embed arbitrary python logic in the creation of signals, so is required for making soft signals and DeviceVectors with contents based on an argument passed to __init__. It also allows you to use the StandardReadable.add_children_as_readables context manager which can save some typing.

Declarative style#

The declarative style mirrors how you would create a pydantic BaseModel. You create type hints to tell the base class what type of object you create, add annotations to tell it some parameters on how to create it, then the base class __init__ will introspect and create them. For example, in the ophyd_async.fastcs.panda.PulseBlock we define the members we expect, and the baseclass will introspect the selected FastCS transport (EPICS IOC or Tango Device Server) and connect them, adding any extras that are published:

class PulseBlock(Device):
    """Used for configuring pulses in the PandA."""

    delay: SignalRW[float]
    width: SignalRW[float]

For a traditional EPICS IOC there is no such introspection mechanism, so we require a PV Suffix to be supplied via an annotation. For example, in ophyd_async.epics.demo.DemoPointDetectorChannel we describe the PV Suffix and whether the signal appears in read() or read_configuration() using typing.Annotated:

class DemoPointDetectorChannel(StandardReadable, EpicsDevice):
    """A channel for `DemoPointDetector` with int value based on X and Y Motors."""

    value: A[SignalR[int], PvSuffix("Value"), Format.HINTED_UNCACHED_SIGNAL]
    mode: A[SignalRW[EnergyMode], PvSuffix("Mode"), Format.CONFIG_SIGNAL]

It is compact and has the minimum amount of boilerplate, but is limited in its scope to what sorts of Signals and Devices the base class can create. It also requires the usage of a StandardReadableFormat for each Signal if using StandardReadable which may be more verbose than the procedural approach. It is best suited for introspectable FastCS and Tango devices, and repetitive EPICS Devices that are wrapped into larger Devices like areaDetectors.

Grey area#

There is quite a large segment of Devices that could be written both ways, for instance ophyd_async.epics.demo.DemoMotor. This could be written in either style with roughly the same legibility, so is a matter of taste:

    """A demo movable that moves based on velocity."""

    # Whether set() should complete successfully or not
    _set_success = True
    # Define some signals
    readback: A[SignalR[float], PvSuffix("Readback"), Format.HINTED_SIGNAL]
    velocity: A[SignalRW[float], PvSuffix("Velocity"), Format.CONFIG_SIGNAL]
    units: A[SignalR[str], PvSuffix("Readback.EGU"), Format.CONFIG_SIGNAL]
    setpoint: A[SignalRW[float], PvSuffix("Setpoint")]
    precision: A[SignalR[int], PvSuffix("Readback.PREC")]
    # If a signal name clashes with a bluesky verb add _ to the attribute name
    stop_: A[SignalX, PvSuffix("Stop.PROC")]

Conclusion#

Ophyd async supports both the declarative and procedural style, and is not prescriptive about which is used. In the end the decision is likely to come down to personal taste, and the style of the surrounding code.