Ophyd是一个用于与硬件交互的Python库。它提供了一个抽象层,它使得实验编排和数据采集代码能够运行在特定设备和控制系统的细节上。
Ophyd一般与Bluesky运行引擎一起使用在实验编排和数据采集上。有时它也被以单独方式使用。
很多设施使用ophyd与使用EPICS的控制系统集成,但ophyd的设计和其某些目标是也与其它控制系统做集成。
- 把专用于设备活控制系统的细节放在了像trigger(), read()和set(...)的高级接口后面。
- 分组单独的控制通道(诸如:EPICS V3 PVs)为要作为具有内部协调单位的逻辑"设备",被配置和使用的。
- 分配具有对数据分析有意义的读取,它们将复制到元数据中。
- 通过"类型"(主读取,配置,工程/调试)归类读取,它们可以被选择性读取。
PyPI | pip install ophyd |
Conda | conda install -c conda-forge ophyd |
安装教程
本教程包含:
- 使用conda安装
- 使用Pip安装
- 从源代码安装
Conda
我们强烈推荐创建一个新环境。
conda create -n try-ophyd
conda activate try-ophyd
从由NSLS-II维护的nsls2forge conda通道安装Ophyd(这个conda包也将安装pyepics。并不是所有使用情况都需要它,但常见使用中使得Ophyd能与EPICS一起工作)。
conda install -c nsls2forge ophyd
最终,遵照EPICS手册,你也应该安装caproto来用模拟硬件一起运行EPICS服务器,以及bluesky用RunEngine编排扫描。
conda install -c nsls2forge bluesky caproto
Pip
我们推荐创建一个新环境。
python3 -m venv try-ophyd
source try-ophyd/bin/activate
从PyPI安装Ophyd
python3 -m pip install ophyd
如果你打算和EPICS一起使用ophyd,你也应该为ophyd安装要使用的EPICS客户端库-要么pyepics(推荐)或caproto(实验的)。
python3 -m pip install pyepics # 或者如果你有冒险精神,caproto
最终,按照EPICS教程,你也应该安装caproto来用仿真硬件运行EPICS服务器,以及bluesky用RunEngine编排扫描。
python3 -m pip install bluesky caproto[standard]
源
为本地开发安装一个可编辑的安装。
git clone https://github.com/bluesky/ophyd
cd ophyd
pip install -e .
单个EPICS PVs
在本教程中,我们将在ophyd中读,写和监视一个EPICS PV。
为教程进行设置
在你开始前,按照安装教程安装ophyd, pyepics, bluesky和caproto。
我们将启动实现了一个random walk的仿真硬件。它仅有两个PVs。一个PV是一个可调节的参数random_walk:dt,步与步之间的时间。另一个PV是random_walk:x,随机行走者的当前位置。
(bluesky-tutorials) [blctrl@localhost ~]$ python -m caproto.ioc_examples.random_walk --list-pvs
[I 13:16:15.935 server: 152] Asyncio server starting up...
[I 13:16:15.936 server: 159] Listening on 0.0.0.0:5064
[I 13:16:15.937 server: 196] Server startup complete.
[I 13:16:15.937 server: 198] PVs available:
random_walk:dt
random_walk:x
[root@localhost blctrl]# caget random_walk:dt
random_walk:dt 3
[root@localhost blctrl]# caget random_walk:x
random_walk:x 7.31709
启动你最喜欢的交互Python环境,诸如ipython或jupyter lab。
从Opyyd连接一个PV
让我们从Ophyd连接PV random_walk:dt。我们需要两部分信息:
- PV名,random_walk:dt
- 一个人性化名称。此名称用于标识这些读取并且将在任何下游数据分析或文件编写代码中被使用。我们可以选择,例如,time_delta。
(bluesky-tutorials) [blctrl@localhost ~]$ ipython
Python 3.8.18 (default, Sep 11 2023, 13:40:15)
Type 'copyright', 'credits' or 'license' for more information
IPython 8.12.3 -- An enhanced Interactive Python. Type '?' for help.
In [1]: from ophyd.signal import EpicsSignal
In [2]: time_delta = EpicsSignal("random_walk:dt", name="time_delta")
In [3]:
注意:习惯上指定在左侧的Python变量的名称与name的值相同,但不是必须的。即,这是习惯...
a = EpicsSignal("...", name="a")
...但所有这些都是允许的。
a = EpicsSignal("...", name="b") # 本地变量不同于name指定的名称
a = EpicsSignal("...", name="some name with spaces in it")
a = b = EpicsSignal("...", name="b") # 两个本地变量
接下来,我们连接random_walk:x。遇到这个PV是不可写时,任何写入将被EPICS拒绝-因此,我们应该使用一个只读EpicsSignal,EPICSSignalRO,在ophyd中表示它。在EPICS中,你有关你的硬件只需要知道这点。幸运地,如果我们忽略了,我们而是使用了可写的EpicsSignal,我们仍然可以使用它读取这个PV。它只是有了一个不工作的残留set()方法。
In [3]: from ophyd.signal import EpicsSignalRO
...:
...: x = EpicsSignalRO("random_walk:x", name="x")
用Bluesky RunEngine使用它
信号可以被Bluesky RunEngine使用。让我们配置一个RunEngine打印一个表格。
In [5]: from bluesky import RunEngine
In [6]: from bluesky.callbacks import LiveTable
In [7]: RE = RunEngine()
In [8]: token = RE.subscribe(LiveTable(["time_delta", "x"]))
因为time_delta是可写的,它可以像一个'motor'被扫描。它也可以像一个'探测器'被读取。(在Bluesky中,所有是"电机"的东西也是"探测器")。
In [9]: from bluesky.plans import count, list_scan
In [10]: RE(count([time_delta])) # 用作"探测器"
+-----------+------------+------------+
| seq_num | time | time_delta |
+-----------+------------+------------+
| 1 | 13:40:16.3 | 3 |
+-----------+------------+------------+
generator count ['517cf776'] (scan num: 1)
Out[10]: ('517cf776-5c25-424b-82bc-fb4fb3809996',)
In [15]: RE(list_scan([], time_delta, [0.1, 0.3, 1, 3, 5])) # 用作"电机"
+-----------+------------+------------+
| seq_num | time | time_delta |
+-----------+------------+------------+
| 1 | 13:49:33.0 | 0 |
| 2 | 13:49:33.0 | 0 |
| 3 | 13:49:33.0 | 1 |
| 4 | 13:49:33.1 | 3 |
| 5 | 13:49:33.1 | 5 |
+-----------+------------+------------+
generator list_scan ['b768ad04'] (scan num: 6)
Out[15]: ('b768ad04-4e73-4fcd-ab37-b59900b68cb5',)
对于以下示例,设置time_delta为1。
In [16]: from bluesky.plan_stubs import mv
In [17]: RE(mv(time_delta,1))
Out[17]: ()
我们知道x表示一个时变的变量。我们可以用固定间隔轮询它。
In [18]: RE(count([x], num=5, delay=0.5)) # 每0.5是读取
+-----------+------------+------------+------------+
| seq_num | time | time_delta | x |
+-----------+------------+------------+------------+
| 1 | 13:55:07.0 | | 7 |
| 2 | 13:55:07.5 | | 7 |
| 3 | 13:55:08.0 | | 7 |
| 4 | 13:55:08.5 | | 7 |
| 5 | 13:55:09.0 | | 7 |
+-----------+------------+------------+------------+
generator count ['84a68074'] (scan num: 7)
Out[18]: ('84a68074-cbf7-4e5f-bb46-ac4e4bc25d6d',)
但需要我们选择一个更新频率(0.5)。依赖控制系统告诉我们一个新值何时可用经常是更好的。在本例中,当x变化时,我们累积它的更新。
In [19]: from bluesky.plan_stubs import monitor, unmonitor, open_run, close_run, sleep
In [20]: def monitor_x_for(duration, md=None):
...: yield from open_run(md) # 可选的元数据
...: yield from monitor(x, name="x_monitor")
...: yield from sleep(duration) # 等待要累积的读取
...: yield from unmonitor(x)
...: yield from close_run()
...:
In [21]: RE.unsubscribe(token) # 移除老表
In [22]: RE(monitor_x_for(3), LiveTable(["x"], stream_name="x_monitor"))
+-----------+------------+------------+
| seq_num | time | x |
+-----------+------------+------------+
| 1 | 14:00:54.4 | 1 |
| 2 | 14:00:54.4 | 1 |
| 3 | 14:00:55.0 | 1 |
| 4 | 14:00:56.0 | 1 |
| 5 | 14:00:57.1 | 0 |
+-----------+------------+------------+
generator monitor_x_for ['4fe9028b'] (scan num: 8)
Out[22]: ('4fe9028b-619f-4dc1-9fcb-6dc31cf90c04',)
如果你时一位目标为和Bluesky运行引擎一起使用Ophyd的科学家,你可以到此停止了或者继续阅读学习有关运行引擎如何与这些信号交互。如果你时一个控制工程师,以下细节可能对你重要。
直接使用它
这些方法不应该在一个Bluesky计划内被调用。
Read
这个信号能够被读取。它返回一个有一项的字典。键是我们指定的人性化的name。这个值是另一个字典,包含了从控制系统读取的value和timestamp(在此处,EPICS)。
In [23]: time_delta.read()
Out[23]: {'time_delta': {'value': 1.0, 'timestamp': 1709099528.666511}}
Describe
获取更多元数据。者总是包含数据类型,形状,和源(PV)。它也可以包含单元和其它元数据。
In [25]: time_delta.describe()
Out[25]:
{'time_delta': {'source': 'PV:random_walk:dt',
'dtype': 'number',
'shape': [],
'units': '',
'lower_ctrl_limit': 0.0,
'upper_ctrl_limit': 0.0,
'precision': 0}}
Set
此信号是可写的,因此,它也可以被设置。
In [26]: time_delta.set(10).wait() # 设置它为10并且等待它到那里。
In [27]: time_delta.read()
Out[27]: {'time_delta': {'value': 10.0, 'timestamp': 1709101073.153636}}
有时硬件卡住,或者没有做你告诉它的事情,因此,在确定出现了一个需要某种程度被处理的错误前,放置一个你愿意等待多久的超时时间是一个好习惯。
In [28]: time_delta.set(5).wait(timeout=1) # 设置它为5并且最长等待1秒钟
In [29]: time_delta.read()
Out[29]: {'time_delta': {'value': 5.0, 'timestamp': 1709101118.197264}}
如果信号不能出现,将产生一个TimeoutError。
注意:set(...)启动移动,但不等待它结束。它是一个快速,"非阻塞"操作。这使你能够在移动和结束它之间运行代码。
In [30]: status=time_delta.set(20)
In [31]: print("Moving to 20...")
Moving to 20...
In [32]: status.wait(timeout=1)
In [33]: print("Moving to 20 .")
Moving to 20 .
注意:要并行移动多个信号,使用opyd.status_wait()函数。
In [34]: from ophyd.status import wait
# 给定信号a和b,在移动中设置这个两个
In [35]: status1=a.set(1)
In [36]: status2=b.set(1)
# 等待两个一起结束
In [37]: wait(status1, status2, timeout=1)
Subscribe
读取一个像x信号的随时间变化的信号,最好的方法是什么。
首先,设置time_delta为一个像1的合理值。这控制在我们随机行走仿真中x的更新率。
time_delta.set(1).wait()
我们在一个循环中轮询这个信号,并且用T秒间隔分隔采集N个读取。
# 不要这么做
N = 5
T = 0.5
readings = []
for _ in range(N):
time.sleep(T)
reading = x.read()
readings.append(reading)
此反例中有两个问题。
1、我们不知道我们需要多久来检查更新。
2、我们经常需要用不同更新率监视多个信号,并且这种模式很快变得糟糕。
我们而是使用订阅。
In [40]: from collections import deque
In [41]: def accumulate(value, old_value, timestamp, **kwargs):
...: readings.append({"x": {"value": value, "timestamp": timestamp}})
...: readings = deque(maxlen=5)
...: x.subscribe(accumulate)
Out[41]: 1
当控制系统有一个新reading给我们时,它从一个后台现场调用readings.append(reading)。如果我们做其它工作或者睡眠一会,并且接着回去检查readings,我们将在它中看到一些项。
In [42]: readings
Out[42]:
deque([{'x': {'value': 26.289444372701894, 'timestamp': 1709102567.931906}},
{'x': {'value': 25.444962208558344, 'timestamp': 1709102568.933955}},
{'x': {'value': 25.717511172394744, 'timestamp': 1709102569.936081}},
{'x': {'value': 26.66083259376525, 'timestamp': 1709102570.938012}},
{'x': {'value': 26.334518598497937, 'timestamp': 1709102571.940063}}],
maxlen=5)
它将保存最新的5个。这里我们使用deque替代普通的list,因为list将无限增长,如果让其运行足够长,消耗所有可用内存,使得这个程序崩溃。
分组信号成为设备
在本教程中,我们将分组多个信号成为一个简单的自定义设备,它使我们在批处理中方便地连接它们和读取它们。
为本教程设置
在你开始前,按照安装教程安装ophyd, pyepics, bluesky和caproto。
我们将启动实现了随机行走的两个仿真设备。
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 &
启动你最喜华的交互Python环境,ipython或jupyter lab。
定义一个自定义设备
有给定硬件的多个实例并且在EPICS中用不同"前缀"表示每个实例十常见的,如在:
# Device 1:
random-walk:horiz:dt
random-walk:horiz:x
# Device 2:
random-walk:vert:dt
random-walk:vert:x
Ophyd使得在适应处使用PV字符串的嵌套结构体变得简单。定义一个ophyd.Device的子类。
In [1]: from ophyd import Component, Device, EpicsSignal, EpicsSignalRO
...:
...: class RandomWalk(Device):
...: x = Component(EpicsSignalRO, 'x')
...: dt = Component(EpicsSignal, 'dt')
...:
到此,我们还未实际创建任何信号或者连接任何硬件。我们只定义了设备的结构体,并且提供了相关PVs的后缀('x', 'dt')。
现在,我们创建了设备的一个实例,提供标识我们其中之一IOCs的PV前缀。
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=[])
用相同方式,我们连接另一个IOC。我们创建相同类的第二个实例。
In [6]: random_walk_vert = RandomWalk('random-walk:vert:', name='random_walk_vert')
In [8]: random_walk_vert.wait_for_connection()
In [9]: random_walk_vert
Out[9]: RandomWalk(prefix='random-walk:vert:', name='random_walk_vert', read_attrs=['x', 'dt'], configuration_attrs=[])
用Bluesky RunEngine使用它
这些信号可以被Bluesky RunEngine使用。让我们配置一个RunEngine打印一个表格。
In [10]: from bluesky import RunEngine
...:
...: from bluesky.callbacks import LiveTable
...:
...: RE = RunEngine()
...:
...: token = RE.subscribe(LiveTable(["random_walk_horiz_x", "random_walk_horiz_dt"]))
我们可以像random_walk_horiz.x访问random_walk_horiz的组件,并且单独地读取它们。
In [14]: RE(count([random_walk_horiz.x], num=3, delay=1.5))
+-----------+------------+---------------------+
| seq_num | time | random_walk_horiz_x |
+-----------+------------+---------------------+
| 1 | 15:22:24.6 | -0 |
| 2 | 15:22:26.1 | -0 |
| 3 | 15:22:27.6 | 1 |
+-----------+------------+---------------------+
generator count ['d115f303'] (scan num: 3)
Out[14]: ('d115f303-ace9-45a7-85ef-473d0d44cbc3',)
我么也可以以一个单元用其整体读取random_walk_horiz,将其当成一个复合的"探测器"。
In [16]: RE(count([random_walk_horiz], num=3, delay=1.5))
+-----------+------------+---------------------+----------------------+
| seq_num | time | random_walk_horiz_x | random_walk_horiz_dt |
+-----------+------------+---------------------+----------------------+
| 1 | 15:26:31.0 | -10 | 3 |
| 2 | 15:26:32.5 | -10 | 3 |
| 3 | 15:26:34.0 | -9 | 3 |
+-----------+------------+---------------------+----------------------+
generator count ['ccb6e1ff'] (scan num: 5)
Out[16]: ('ccb6e1ff-7c00-46df-bca4-ac69b6778fc7',)
给组件分配一个"kind"
在以上示例中,注意,我们在每行中记录了random_walk_horiz_dt,因为在读取中,它和random_walk_horiz_x一起返回。
In [18]: random_walk_horiz.read()
Out[18]:
OrderedDict([('random_walk_horiz_x',
{'value': -6.605579862560207, 'timestamp': 1709105321.877998}),
('random_walk_horiz_dt',
{'value': 3.0, 'timestamp': 1709103796.233829})])
这可能不是必要的。除非我们有预计其会被更改的某个原因,否则每个Run记录random_walk_horiz_dt更有用,作为设备配置的组成。
Ophyd使我们像这样能做这件事:
In [20]: from ophyd import Kind
...:
...: random_walk_horiz.dt.kind = Kind.config
作为快捷方式,也接受一个字符串别名,并且被归一化为那个名称枚举成员。
In [21]: random_walk_horiz.dt.kind = "config"
...:
...: random_walk_horiz.dt.kind
Out[21]: <Kind.config: 2>
当我们先定义设备时,我们设置kind,像这样:
In [22]: class RandomWalk(Device):
...: x = Component(EpicsSignalRO, 'x')
...: dt = Component(EpicsSignal, 'dt', kind="config")
...:
再次,接受枚举Kind.config或者字符串"config"。
结果是random_walk_horiz_dt被从read()移动到了read_configuration()。
In [23]: random_walk_horiz.read()
Out[23]:
OrderedDict([('random_walk_horiz_x',
{'value': -1.8513088591635551, 'timestamp': 1709105724.321547})])
In [24]: random_walk_horiz.read_configuration()
Out[24]:
OrderedDict([('random_walk_horiz_dt',
{'value': 3.0, 'timestamp': 1709103796.233829})])
注意:在Bluesky的Document模型中,device.read()的结果被放在了一个Event文档中,而device.read_configuration()的结果被放置在了一个Event Descriptor文档中。Bluesky RunEngine总是在首次读取指定device时调用device.read_configuration()并且捕获此信息。