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 | 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]: random_walk_horiz.read()
Out[16]:
OrderedDict([('random_walk_horiz_x',
{'value': 1.0052266048844096, 'timestamp': 1662640562.86887}),
('random_walk_horiz_dt',
{'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]: random_walk_horiz.read()
Out[21]:
OrderedDict([('random_walk_horiz_x',
{'value': 1.0052266048844096, 'timestamp': 1662640562.86887})])
In [22]: random_walk_horiz.read_configuration()
Out[22]:
OrderedDict([('random_walk_horiz_dt',
{'value': 3.0, 'timestamp': 1662640556.845832})])
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.