# 9. Procedural vs Declarative Devices

Date: 01/10/24

## Status

Accepted

## Context

In [](./0006-procedural-device-definitions.rst) we decided we preferred the procedural approach to devices, because of the issue of applying structure like `DeviceVector`. Since then we have `FastCS` and `Tango` support which use a declarative approach. We need to decide whether we are happy with this situation, or whether we should go all in one way or the other. A suitable test Device would be:

```python
class EpicsProceduralDevice(StandardReadable):
    def __init__(self, prefix: str, num_values: int, name="") -> None:
        with self.add_children_as_readables():
            self.value = DeviceVector(
                {
                    i: epics_signal_r(float, f"{prefix}Value{i}")
                    for i in range(1, num_values + 1)
                }
            )
        with self.add_children_as_readables(ConfigSignal):
            self.mode = epics_signal_rw(EnergyMode, prefix + "Mode")
        super().__init__(name=name)
```

and a Tango/FastCS procedural equivalent would be (if we add support to StandardReadable for Format.HINTED_SIGNAL and Format.CONFIG_SIGNAL annotations):
```python
class TangoDeclarativeDevice(StandardReadable, TangoDevice):
    value: Annotated[DeviceVector[SignalR[float]], Format.HINTED_SIGNAL]
    mode: Annotated[SignalRW[EnergyMode], Format.CONFIG_SIGNAL]
```

But we could specify the Tango one procedurally (with some slight ugliness around the DeviceVector):
```python
class TangoProceduralDevice(StandardReadable):
    def __init__(self, prefix: str, name="") -> None:
        with self.add_children_as_readables():
            self.value = DeviceVector({0: tango_signal_r(float)})
        with self.add_children_as_readables(ConfigSignal):
            self.mode = tango_signal_rw(EnergyMode)
        super().__init__(name=name, connector=TangoConnector(prefix))
```

or the EPICS one could be declarative:
```python
class EpicsDeclarativeDevice(StandardReadable, EpicsDevice):
    value: Annotated[
        DeviceVector[SignalR[float]], Format.HINTED_SIGNAL, EpicsSuffix("Value%d", "num_values")
    ]
    mode: Annotated[SignalRW[EnergyMode], Format.CONFIG_SIGNAL, EpicsSuffix("Mode")]
```

Which do we prefer?

## Decision

We decided that the declarative approach is to be preferred until we need to write formatted strings. At that point we should drop to an `__init__` method and a for loop. This is not a step towards only supporting the declarative approach and there are no plans to drop the procedural approach.

The two approaches now look like:

```python
class Sensor(StandardReadable, EpicsDevice):
    """A sim sensor that produces a scalar value based on X and Y Movers"""

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


class SensorGroup(StandardReadable):
    def __init__(self, prefix: str, name: str = "", sensor_count: int = 3) -> None:
        with self.add_children_as_readables():
            self.sensors = DeviceVector(
                {i: Sensor(f"{prefix}{i}:") for i in range(1, sensor_count + 1)}
            )
        super().__init__(name)
```

## Consequences

We need to:
- Add support for reading annotations and `PvSuffix` in an `ophyd_async.epics.core.EpicsDevice` baseclass
- Do the `Format.HINTED_SIGNAL` and `Format.CONFIG_SIGNAL` flags in annotations for `StandardReadable`
- Ensure we can always drop to `__init__`


## pvi structure changes
Structure read from `.value` now includes `DeviceVector` support. Requires at least PandABlocks-ioc 0.11.2

## Epics `signal` module moves
`ophyd_async.epics.signal` moves to `ophyd_async.epics.core` with a backwards compat module that emits deprecation warning.
```python
# old
from ophyd_async.epics.signal import epics_signal_rw
# new
from ophyd_async.epics.core import epics_signal_rw
```

## `StandardReadable` wrappers change to `StandardReadableFormat`
`StandardReadable` wrappers change to enum members of `StandardReadableFormat` (normally imported as `Format`)
```python
# old
from ophyd_async.core import ConfigSignal, HintedSignal
class MyDevice(StandardReadable):
    def __init__(self):
        self.add_readables([sig1], ConfigSignal)
        self.add_readables([sig2], HintedSignal)
        self.add_readables([sig3], HintedSignal.uncached)
# new
from ophyd_async.core import StandardReadableFormat as Format
class MyDevice(StandardReadable):
    def __init__(self):
        self.add_readables([sig1], Format.CONFIG_SIGNAL)
        self.add_readables([sig2], Format.HINTED_SIGNAL)
        self.add_readables([sig3], Format.HINTED_UNCACHED_SIGNAL
```

## Declarative Devices are now available
```python
# old
from ophyd_async.core import ConfigSignal, HintedSignal
from ophyd_async.epics.signal import epics_signal_r, epics_signal_rw

class Sensor(StandardReadable):
    def __init__(self, prefix: str, name="") -> None:
        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)
# new
from typing import Annotated as A
from ophyd_async.core import StandardReadableFormat as Format
from ophyd_async.epics.core import EpicsDevice, PvSuffix, epics_signal_r, epics_signal_rw

class Sensor(StandardReadable, EpicsDevice):
    value: A[SignalR[float], PvSuffix("Value"), Format.HINTED_SIGNAL]
    mode: A[SignalRW[EnergyMode], PvSuffix("Mode"), Format.CONFIG_SIGNAL]
```

Note that to use `Annotated as A` with ruff name checking we recommend adding something like:

```
[tool.ruff.lint.flake8-import-conventions.extend-aliases]
# We often shorten "Annotated" to "A" for brevity
"typing.Annotated" = "A"
```
to your `pyproject.toml`.
