Skip to content

Commit

Permalink
Add docstring for DeviceVector and document how to group devices (#183)
Browse files Browse the repository at this point in the history
* Add docstring for DeviceVector

* Document How to Group Devices

Move initial how-to on assembling devices into its own page and add a
section about using DeviceVector to make arbitrary-length groups. Add an
example to the epics demo with tests.

* Add sensor group to demo IOC

* Test IOC starts

* Add timeout to IOC test

* Only mock with IOC test for now
  • Loading branch information
callumforrester authored Apr 17, 2024
1 parent 2fc44f2 commit 6865e87
Show file tree
Hide file tree
Showing 7 changed files with 171 additions and 32 deletions.
1 change: 1 addition & 0 deletions docs/user/examples/epics_demo.py
Original file line number Diff line number Diff line change
Expand Up @@ -33,4 +33,5 @@ class OldSensor(Device):
# Create ophyd-async devices
with DeviceCollector():
det = demo.Sensor(pv_prefix)
det_group = demo.SensorGroup(pv_prefix)
samp = demo.SampleStage(pv_prefix)
44 changes: 44 additions & 0 deletions docs/user/how-to/compound-devices.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
.. note::

Ophyd async is included on a provisional basis until the v1.0 release and
may change API on minor release numbers before then

Compound Devices Together
=========================

Assembly
--------

Compound assemblies can be used to group Devices into larger logical Devices:

.. literalinclude:: ../../../src/ophyd_async/epics/demo/__init__.py
:pyobject: SampleStage

This applies prefixes on construction:

- SampleStage is passed a prefix like ``DEVICE:``
- SampleStage.x will append its prefix ``X:`` to get ``DEVICE:X:``
- SampleStage.x.velocity will append its suffix ``Velocity`` to get
``DEVICE:X:Velocity``

If SampleStage is further nested in another Device another layer of prefix nesting would occur

.. note::

SampleStage does not pass any signals into its superclass init. This means
that its ``read()`` method will return an empty dictionary. This means you
can ``rd sample_stage.x``, but not ``rd sample_stage``.


Grouping by Index
-----------------

Sometimes, it makes sense to group devices by number, say an array of sensors:

.. literalinclude:: ../../../src/ophyd_async/epics/demo/__init__.py
:pyobject: SensorGroup

:class:`~ophyd-async.core.DeviceVector` allows writing maintainable, arbitrary-length device groups instead of fixed classes for each possible grouping. A :class:`~ophyd-async.core.DeviceVector` can be accessed via indices, for example: ``my_sensor_group.sensors[2]``. Here ``sensors`` is a dictionary with integer indices rather than a list so that the most semantically sensible indices may be used, the sensor group above may be 1-indexed, for example, because the sensors' datasheet calls them "sensor 1", "sensor 2" etc.

.. note::
The :class:`~ophyd-async.core.DeviceVector` adds an extra level of nesting to the device tree compared to static components like ``sensor_1``, ``sensor_2`` etc. so the behavior is not completely equivalent.
24 changes: 0 additions & 24 deletions docs/user/how-to/make-a-simple-device.rst
Original file line number Diff line number Diff line change
Expand Up @@ -64,27 +64,3 @@ completes. This co-routine is wrapped in a timeout handler, and passed to an
`AsyncStatus` which will start executing it as soon as the Run Engine adds a
callback to it. The ``stop()`` method then pokes a PV if the move needs to be
interrupted.

Assembly
--------

Compound assemblies can be used to group Devices into larger logical Devices:

.. literalinclude:: ../../../src/ophyd_async/epics/demo/__init__.py
:pyobject: SampleStage

This applies prefixes on construction:

- SampleStage is passed a prefix like ``DEVICE:``
- SampleStage.x will append its prefix ``X:`` to get ``DEVICE:X:``
- SampleStage.x.velocity will append its suffix ``Velocity`` to get
``DEVICE:X:Velocity``

If SampleStage is further nested in another Device another layer of prefix
nesting would occur

.. note::

SampleStage does not pass any signals into its superclass init. This means
that its ``read()`` method will return an empty dictionary. This means you
can ``rd sample_stage.x``, but not ``rd sample_stage``.
1 change: 1 addition & 0 deletions docs/user/index.rst
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ side-bar.
:maxdepth: 1

how-to/make-a-simple-device
how-to/compound-devices
how-to/write-tests-for-devices
how-to/run-container

Expand Down
8 changes: 8 additions & 0 deletions src/ophyd_async/core/device.py
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,14 @@ async def connect(self, sim: bool = False, timeout: float = DEFAULT_TIMEOUT):


class DeviceVector(Dict[int, VT], Device):
"""
Defines device components with indices.
In the below example, foos becomes a dictionary on the parent device
at runtime, so parent.foos[2] returns a FooDevice. For example usage see
:class:`~ophyd_async.epics.demo.DynamicSensorGroup`
"""

def children(self) -> Generator[Tuple[str, Device], None, None]:
for attr_name, attr in self.items():
if isinstance(attr, Device):
Expand Down
36 changes: 33 additions & 3 deletions src/ophyd_async/epics/demo/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,13 @@
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,
Device,
DeviceVector,
StandardReadable,
observe_value,
)

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

Expand Down Expand Up @@ -43,6 +49,19 @@ def __init__(self, prefix: str, name="") -> None:
super().__init__(name=name)


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

# Makes read() produce the values of all sensors
self.set_readable_signals(
read=[sensor.value for sensor in self.sensors.values()],
)
super().__init__(name)


class Mover(StandardReadable, Movable, Stoppable):
"""A demo movable that moves based on velocity"""

Expand Down Expand Up @@ -135,11 +154,22 @@ def start_ioc_subprocess() -> str:
pv_prefix = "".join(random.choice(string.ascii_uppercase) for _ in range(12)) + ":"
here = Path(__file__).absolute().parent
args = [sys.executable, "-m", "epicscorelibs.ioc"]

# Create standalone sensor
args += ["-m", f"P={pv_prefix}"]
args += ["-d", str(here / "sensor.db")]
for suff in "XY":
args += ["-m", f"P={pv_prefix}{suff}:"]

# Create sensor group
for suffix in ["1", "2", "3"]:
args += ["-m", f"P={pv_prefix}{suffix}:"]
args += ["-d", str(here / "sensor.db")]

# Create X and Y motors
for suffix in ["X", "Y"]:
args += ["-m", f"P={pv_prefix}{suffix}:"]
args += ["-d", str(here / "mover.db")]

# Start IOC
process = subprocess.Popen(
args,
stdin=subprocess.PIPE,
Expand Down
89 changes: 84 additions & 5 deletions tests/epics/demo/test_demo.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import asyncio
import subprocess
from typing import Dict
from unittest.mock import ANY, Mock, call
from unittest.mock import ANY, Mock, call, patch

import pytest
from bluesky.protocols import Reading
Expand All @@ -19,7 +20,7 @@


@pytest.fixture
async def sim_mover():
async def sim_mover() -> demo.Mover:
async with DeviceCollector(sim=True):
sim_mover = demo.Mover("BLxxI-MO-TABLE-01:X:")
# Signals connected here
Expand All @@ -28,17 +29,27 @@ async def sim_mover():
set_sim_value(sim_mover.units, "mm")
set_sim_value(sim_mover.precision, 3)
set_sim_value(sim_mover.velocity, 1)
yield sim_mover
return sim_mover


@pytest.fixture
async def sim_sensor():
async def sim_sensor() -> demo.Sensor:
async with DeviceCollector(sim=True):
sim_sensor = demo.Sensor("SIM:SENSOR:")
# Signals connected here

assert sim_sensor.name == "sim_sensor"
yield sim_sensor
return sim_sensor


@pytest.fixture
async def sim_sensor_group() -> demo.SensorGroup:
async with DeviceCollector(sim=True):
sim_sensor_group = demo.SensorGroup("SIM:SENSOR:")
# Signals connected here

assert sim_sensor_group.name == "sim_sensor_group"
return sim_sensor_group


class Watcher:
Expand Down Expand Up @@ -224,3 +235,71 @@ def my_plan():

with pytest.raises(RuntimeError, match="Will deadlock run engine if run in a plan"):
RE(my_plan())


async def test_dynamic_sensor_group_disconnected():
with pytest.raises(NotConnected):
async with DeviceCollector(timeout=0.1):
sim_sensor_group_dynamic = demo.SensorGroup("SIM:SENSOR:")

assert sim_sensor_group_dynamic.name == "sim_sensor_group_dynamic"


async def test_dynamic_sensor_group_read_and_describe(
sim_sensor_group: demo.SensorGroup,
):
set_sim_value(sim_sensor_group.sensors[1].value, 0.0)
set_sim_value(sim_sensor_group.sensors[2].value, 0.5)
set_sim_value(sim_sensor_group.sensors[3].value, 1.0)

await sim_sensor_group.stage()
description = await sim_sensor_group.describe()
reading = await sim_sensor_group.read()
await sim_sensor_group.unstage()

assert description == {
"sim_sensor_group-sensors-1-value": {
"dtype": "number",
"shape": [],
"source": "sim://SIM:SENSOR:1:Value",
},
"sim_sensor_group-sensors-2-value": {
"dtype": "number",
"shape": [],
"source": "sim://SIM:SENSOR:2:Value",
},
"sim_sensor_group-sensors-3-value": {
"dtype": "number",
"shape": [],
"source": "sim://SIM:SENSOR:3:Value",
},
}
assert reading == {
"sim_sensor_group-sensors-1-value": {
"alarm_severity": 0,
"timestamp": ANY,
"value": 0.0,
},
"sim_sensor_group-sensors-2-value": {
"alarm_severity": 0,
"timestamp": ANY,
"value": 0.5,
},
"sim_sensor_group-sensors-3-value": {
"alarm_severity": 0,
"timestamp": ANY,
"value": 1.0,
},
}


@patch("ophyd_async.epics.demo.subprocess.Popen")
async def test_ioc_starts(mock_popen: Mock):
demo.start_ioc_subprocess()
mock_popen.assert_called_once_with(
ANY,
stdin=subprocess.PIPE,
stdout=subprocess.PIPE,
stderr=subprocess.STDOUT,
universal_newlines=True,
)

0 comments on commit 6865e87

Please sign in to comment.