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:
random-walk:horiz:dt
random-walk:horiz:x
# Device 2:
random-walk:vert:dt
random-walk:vert:x
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=[])
Note
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 | 22:01:59.3 | -1 |
| 2 | 22:02:00.3 | -1 |
| 3 | 22:02:01.3 | -1 |
+-----------+------------+---------------------+
generator count ['780371fe'] (scan num: 1)
Out[14]: ('780371fe-9817-4df8-91a3-190a225e2ef5',)
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 | 22:02:02.4 | -1 | 3 |
| 2 | 22:02:03.4 | -1 | 3 |
| 3 | 22:02:04.4 | -1 | 3 |
+-----------+------------+---------------------+----------------------+
generator count ['3deab98e'] (scan num: 2)
Out[15]: ('3deab98e-7ab4-40bc-8c64-d75875638a0e',)
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]: random_walk_horiz.read()
Out[16]:
OrderedDict([('random_walk_horiz_x',
{'value': -0.6436202683204355, 'timestamp': 1732226523.156793}),
('random_walk_horiz_dt',
{'value': 3.0, 'timestamp': 1732226517.134323})])
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]: random_walk_horiz.read()
Out[21]:
OrderedDict([('random_walk_horiz_x',
{'value': -0.6436202683204355, 'timestamp': 1732226523.156793})])
In [22]: random_walk_horiz.read_configuration()
Out[22]:
OrderedDict([('random_walk_horiz_dt',
{'value': 3.0, 'timestamp': 1732226517.134323})])
Note
In Bluesky’s Document Model, the result of device.read()
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.