Group Signals into Devices#

In this tutorial we will group multiple Signals into a simple custom Device, which enables us to conveniently connect to them and read them in batch.

Set up for tutorial#

We’ll start our IOCs connected to simulated hardware, some of which implement a random walk that we will use.

The IOCs may already be running in the background. Run this command to verify that they are running: it should produce output with STARTING or RUNNING on each line. In the event of a problem, edit this command to replace status with restart all and run again.

!../supervisor/ status
decay                            RUNNING   pid 2695, uptime 0:00:48
mini_beamline                    RUNNING   pid 2696, uptime 0:00:48
random_walk                      RUNNING   pid 2697, uptime 0:00:48
random_walk_horiz                RUNNING   pid 2698, uptime 0:00:48
random_walk_vert                 RUNNING   pid 2699, uptime 0:00:48
simple                           RUNNING   pid 2700, uptime 0:00:48
thermo_sim                       RUNNING   pid 2701, uptime 0:00:48
trigger_with_pc                  RUNNING   pid 2702, uptime 0:00:48

Define a Custom Device#

It’s common to have more than one instance of a given piece of hardware and to present each instance in EPICS with different “prefixes” as in:

# Device 1:

# Device 2:

Ophyd makes it easy to take advantage of this nested naming convention of PV names, where applicable. Define a subclass of :class:ophyd.Device:

from ophyd import Component, Device, EpicsSignal, EpicsSignalRO

class RandomWalk(Device):
   x = Component(EpicsSignalRO, 'x')
   dt = Component(EpicsSignal, 'dt')

Up to this point we haven’t actually created any signals yet or connected to any hardware. We have only defined the structure of this device and provided the suffixes ('x', 'dt') of the relevant PVs.

Now, we create an instance of the device, providing the PV prefix that identifies one of our IOCs.

random_walk_horiz = RandomWalk('random-walk:horiz:', name='random_walk_horiz')
RandomWalk(prefix='random-walk:horiz:', name='random_walk_horiz', read_attrs=['x', 'dt'], configuration_attrs=[])

It is conventional to name the Python variable on the left the same as the value of name, but not required. That is, this is conventional…

a = RandomWalk("...", name="a")
a = RandomWalk("...", name="b")  # local variable different from name
a = RandomWalk("...", name="some name with spaces in it")
a = b = RandomWalk("...", name="b")  # two local variables

In the same way we can connect to the other IOC. We create a second instance of the same class.

random_walk_vert = RandomWalk('random-walk:vert:', name='random_walk_vert')
RandomWalk(prefix='random-walk:vert:', name='random_walk_vert', read_attrs=['x', 'dt'], configuration_attrs=[])

Use it with the Bluesky RunEngine#

The signals can be used by the Bluesky RunEngine. Let’s configure a RunEngine to print a table.

from bluesky import RunEngine
from bluesky.callbacks import LiveTable
RE = RunEngine()
token = RE.subscribe(LiveTable(["random_walk_horiz_x", "random_walk_horiz_dt"]))

We can access the components of random_walk_horiz like random_walk_horiz.x and use this to read them individually.

from bluesky.plans import count

RE(count([random_walk_horiz.x], num=3, delay=1))


|   seq_num |       time | random_walk_horiz_x |


|         1 | 21:09:03.7 |                   1 |

|         2 | 21:09:04.7 |                   1 |

|         3 | 21:09:05.7 |                   1 |


generator count ['37ab875f'] (scan num: 1)


We can also read random_walk_horiz in its entirety as a unit, treating it as a composite “detector”.

RE(count([random_walk_horiz], num=3, delay=1))


|   seq_num |       time | random_walk_horiz_x | random_walk_horiz_dt |


|         1 | 21:09:06.9 |                   0 |                    3 |

|         2 | 21:09:07.9 |                   0 |                    3 |

|         3 | 21:09:08.9 |                   0 |                    3 |


generator count ['29e10897'] (scan num: 2)


Assign a “Kind” to Components#

In the example just above, notice that we are recording random_walk_horiz_dt in every row (i.e. every Event) because it is returned alongside random_walk_horiz_x in the reading.
              {'value': 1.2681267613790572, 'timestamp': 1682197749.090841}),
              {'value': 3.0, 'timestamp': 1682197694.985232})])

This is probably not necessary. Unless we have some reason to expect that it could be changed, it would be more useful to record random_walk_horiz_dt once per Run as part of the device’s configuration.

Ophyd enables us to do this like so:

from ophyd import Kind

random_walk_horiz.dt.kind = Kind.config

As a shorthand, a string alias is also accepted and normalized to enum member of that name.

random_walk_horiz.dt.kind = "config"
<Kind.config: 2>

Equivalently, we could have set the kind when we first defined the device, like so:

class RandomWalk(Device):
   x = Component(EpicsSignalRO, 'x')
   dt = Component(EpicsSignal, 'dt', kind="config")

Again, either enum Kind.config or string "config" are accepted.

The result is that random_walk_horiz_dt is moved from read() to read_configuration():
              {'value': 1.2681267613790572, 'timestamp': 1682197749.090841})])
              {'value': 3.0, 'timestamp': 1682197694.985232})])

In Bluesky’s Document Model, the result of is placed in an Event Document, and the result of device.read_configuration() is placed in an Event Descriptor document. The Bluesky RunEngine always calls device.read_configuration() and captures that information the first time a given device is read.

Other possible values for Kind are omitted, normal and hinted. For more details, see the Ophyd documentation for Signal.

For a larger example of Kind being used on a real device, see the source code for EpicsMotor.