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

Before you begin, install ophyd, pyepics, bluesky, and caproto, following the Installation Tutorial.

We’ll start two simulated devices that implement a random walk.

python -m caproto.ioc_examples.random_walk --prefix="random-walk:horiz:" --list-pvs
python -m caproto.ioc_examples.random_walk --prefix="random-walk:vert:" --list-pvs

Start your favorite interactive Python environment, such as ipython or jupyter lab.

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 the nested structure of PV string, where applicable. Define a subclass of ophyd.Device.

In [1]: from ophyd import Component, Device, EpicsSignal, EpicsSignalRO

In [2]: 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.

In [3]: random_walk_horiz = RandomWalk('random-walk:horiz:', name='random_walk_horiz')

In [4]: random_walk_horiz.wait_for_connection()

In [5]: random_walk_horiz
Out[5]: 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")

…but all of these are also allowed.

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.

In [6]: random_walk_vert = RandomWalk('random-walk:vert:', name='random_walk_vert')

In [7]: random_walk_vert.wait_for_connection()

In [8]: random_walk_vert
Out[8]: 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.

In [9]: from bluesky import RunEngine

In [10]: from bluesky.callbacks import LiveTable

In [11]: RE = RunEngine()

In [12]: 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.

In [13]: from bluesky.plans import count

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

|   seq_num |       time | random_walk_horiz_x |
|         1 | 12:35:58.8 |                   1 |
|         2 | 12:35:59.8 |                   1 |
|         3 | 12:36:00.8 |                   1 |
generator count ['8a132bbe'] (scan num: 1)

Out[14]: ('8a132bbe-0b65-4193-9aad-92f09374cf27',)

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

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

|   seq_num |       time | random_walk_horiz_x | random_walk_horiz_dt |
|         1 | 12:36:02.0 |                   1 |                    3 |
|         2 | 12:36:03.0 |                   1 |                    3 |
|         3 | 12:36:04.0 |                   1 |                    3 |
generator count ['ea4d884c'] (scan num: 2)

Out[15]: ('ea4d884c-4fef-44a1-be66-bcbf2b727539',)

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.

In [16]:
              {'value': 1.0052266048844096, 'timestamp': 1662640562.86887}),
              {'value': 3.0, 'timestamp': 1662640556.845832})])

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:

In [17]: from ophyd import Kind

In [18]: random_walk_horiz.dt.kind = Kind.config

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

In [19]: random_walk_horiz.dt.kind = "config"

In [20]: random_walk_horiz.dt.kind
Out[20]: <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().

In [21]:
              {'value': 1.0052266048844096, 'timestamp': 1662640562.86887})])

In [22]: random_walk_horiz.read_configuration()
              {'value': 3.0, 'timestamp': 1662640556.845832})])


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.

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