Connect Bluesky with EPICS#
From APS Python Training for Bluesky Data Acquisition.
Objective:
Connect Bluesky with EPICS Process Variable(s).
Fundamentals#
In Bluesky, the ophyd package is used to connect with the underlying control system (EPICS, in this case). The fundamental structure is the EpicsSignal which connects a single EPICS Process Variable (PV) with a single python object. Behind the scenes, the connection is made using the PyEpics package and the EPICS Channel Access protocol. Other information, when available (such as units, limits, displayed precision, data type, enumeration labels), is obtained from EPICS.
ophyd class |
description |
---|---|
|
establish a read/write connection with an EPICS PV, monitor and update the object in the background |
|
read-only version of |
Note that EpicsSignal
& EpicsSignalRO
have many additional configuration features beyond what is described here. Consult the ophyd documentation or source code for more details.
EpicsSignal
#
Start by importing the support from ophyd.
[1]:
from ophyd import EpicsSignal
There is an EPICS PV (gp:gp:bit1
) with which we can connect. This PV is a single bit. Connect with it, naming the python object as bit1
(for convenience, we make an ioc
prefix variable, just in case someone has an EPICS IOC with a different prefix).
In addition to the EPICS PV, it is required to add a name
keyword so the Python object can be constructed with the object name. By convention, use the same name as on the left side of the =
sign.
[2]:
ioc = "gp:"
bit1 = EpicsSignal(f"{ioc}gp:bit1", name="bit1")
print(f"{bit1.connected = }")
bit1.connected = False
Immediately after creating the connection, we tested if the Python object had fully connected with EPICS (usually this takes a short time but it is not instantaneous). As shown, the object has not connected with EPICS yet.
When humans interact with a Jupyter notebook, the EPICS PV connection usually happens within the time it takes to move to the next cell and execute it. But when these PV connections are executed by a single program and the object is needed right away, it may be necessary to wait for the connection to complete before proceeding.
[3]:
bit1.wait_for_connection()
print(f"{bit1.connected = }")
bit1.connected = True
Print the value of the Python object using its get()
method:
[4]:
print(f"{bit1.get() = }")
bit1.get() = 0
There may be labels associated with the two different values this PV can take. Show them with the object’s enum_strs
property:
[5]:
print(f"{bit1.enum_strs = }")
bit1.enum_strs = ('off', 'on')
We can set this with either numbers (0 or 1) or with text (off
or on
) and we can show this as either numbers or text.
[6]:
bit1.put(1)
print(f"{bit1.get() = }")
print(f"{bit1.get(as_string=False) = }")
print(f"{bit1.get(as_string=True) = }")
bit1.put("off")
print(f"{bit1.get() = }")
print(f"{bit1.get(as_string=False) = }")
print(f"{bit1.get(as_string=True) = }")
bit1.get() = 1
bit1.get(as_string=False) = 1
bit1.get(as_string=True) = 'on'
bit1.get() = 0
bit1.get(as_string=False) = 0
bit1.get(as_string=True) = 'off'
We can also make the text representation the default by adding the string=True
keyword when we create the connection:
[7]:
bit1 = EpicsSignal(f"{ioc}gp:bit1", name="bit1", string=True)
bit1.wait_for_connection()
print(f"{bit1.get() = }")
bit1.get() = 'off'
The read()
method (used by data acquisition) shows both value and the timestamp that value was received from EPICS. The timestamp is recorded by Python and is absolute number of seconds since January 1, 1970 GMT.
[8]:
bit1.read()
[8]:
{'bit1': {'value': 'off', 'timestamp': 1629399463.264405}}
EpicsSignalRO
#
The gp:UPTIME
PV tells us how long the IOC has been running. This PV contains information that we cannot change since it reports a value that only the IOC knows. Create an uptime
object with the EpicsSignalRO
class from ophyd
:
[9]:
from ophyd import EpicsSignalRO
uptime = EpicsSignalRO(f"{ioc}UPTIME", name="uptime")
uptime.wait_for_connection()
print(f"{uptime.get() = }")
uptime.get() = '04:47:37'
Device
and Component
#
We may have a group of PVs that are related in some way. It is possible to organize the Python objects into a larger structure called an ophyd.Device
where each of the EpicsSignal
objects is an ophyd.Component
. Import the ophyd structures next:
[10]:
from ophyd import Component, Device
Build a structure that associates these PVs and (hypothetical) uses:
PV |
how it is used |
attribute |
---|---|---|
|
on/off control |
|
|
desired temperature |
|
|
temperature units |
|
|
short label |
|
To group these PVs together, a custom Python class must be created using Device
as a base class and Component
for each of the PVs.
To make this class more useful, we omit the IOC prefix (the first gp:
) from our class. We’ll use the IOC prefix later, when we create the Python object for this structure.
[11]:
class MyGroup(Device):
enable = Component(EpicsSignal, "gp:bit1")
setpoint = Component(EpicsSignal, "gp:float1")
units = Component(EpicsSignal, "gp:float1.EGU")
label = Component(EpicsSignal, "gp:text1")
Use this structure to connect with all the PVs (at once) using the same type of command as with EpicsSignal
above.
[12]:
thing = MyGroup(ioc, name="thing")
thing.wait_for_connection()
Show all the values at once using the read()
method.
[13]:
thing.read()
[13]:
OrderedDict([('thing_enable', {'value': 0, 'timestamp': 1629399463.264405}),
('thing_setpoint',
{'value': 521.7482, 'timestamp': 1629399413.336128}),
('thing_units',
{'value': 'Rankine', 'timestamp': 1629399413.336128}),
('thing_label', {'value': '', 'timestamp': 631152000.0})])
Set the temperature units and set point:
[14]:
thing.setpoint.put(521.7482)
thing.units.put("Rankine")
thing.read()
[14]:
OrderedDict([('thing_enable', {'value': 0, 'timestamp': 1629399463.264405}),
('thing_setpoint',
{'value': 521.7482, 'timestamp': 1629399463.519874}),
('thing_units',
{'value': 'Rankine', 'timestamp': 1629399463.519874}),
('thing_label', {'value': '', 'timestamp': 631152000.0})])
[15]:
print(f"set point: {thing.setpoint.get():.2f} {thing.units.get()}")
set point: 521.75 Rankine
Device
structures can be nested to make even more complex structures. For example:
[16]:
class Stack(Device):
basic = Component(MyGroup, "")
reading = Component(EpicsSignal, "gp:float2")
abstract = Component(EpicsSignal, "gp:longtext2", string=True)
stack = Stack(ioc, name="stack")
stack.wait_for_connection()
stack.read()
[16]:
OrderedDict([('stack_basic_enable',
{'value': 0, 'timestamp': 1629399463.264405}),
('stack_basic_setpoint',
{'value': 521.7482, 'timestamp': 1629399463.519874}),
('stack_basic_units',
{'value': 'Rankine', 'timestamp': 1629399463.519874}),
('stack_basic_label', {'value': '', 'timestamp': 631152000.0}),
('stack_reading', {'value': 0.0, 'timestamp': 631152000.0}),
('stack_abstract', {'value': '', 'timestamp': 631152000.0})])