Python 与 DAQmx
用 Python 调用 DAQmx:把真实信号采下来,而不是跟驱动较劲
Python 包只是 NI-DAQmx 驱动外面很薄的一层,掌握时序的是驱动,不是你的循环。想清楚这一点,第一次写采集脚本时的大多数意外就不再是意外:采样率是全卡共用的、回调有它的规矩,而报错码往往会直接点出你请求了硬件给不了的东西。
驱动与 Python 包的安装顺序、每个任务都逃不掉的那四次调用、为什么采样率是全卡共用的而不是每通道的、有限采集/连续采集/软件定时的区别、模拟输出与计数器输出、你真正会遇到的几个报错码,以及硬件会拒绝哪些请求。
在写第一行 Python 之前,先接受一件事:Python 不是实时系统。某个采样点究竟在哪一刻被采到,是由 DAQmx 驱动和采集卡上的时钟决定的。Python 的职责是配置任务,然后把硬件已经采好的数据取回来。想通这一点,第一周常见的那几个困惑 —— 为什么我的循环不够快、为什么时间戳不对、为什么加一句打印就出问题 —— 答案其实是同一个。
一、两层东西,顺序不能反
要装的是两样东西,而且不能互相替代。底层是 NI-DAQmx 驱动,真正跟硬件讲话的是它;它单独安装,不是一个 Python 包。上层是 nidaqmx,把这个驱动的 C API 包装成 Python 对象。它在 PyPI 上的名字就是 nidaqmx,nidaqmx-python 只是 GitHub 仓库名,用它去装是装不上的。
import nidaqmx
print(nidaqmx.__version__) # e.g. 1.6.0
system = nidaqmx.system.System.local()
for device in system.devices:
print(device.name, device.product_type)
for channel in device.ai_physical_chans:
print(" ", channel.name)
name 那一列(Dev1 之类)就是后面所有通道字符串的前缀。设备名写错时,报错发生在添加通道的那一步而不是 import 的时候,而且信息里点的是设备名,不是你的代码行号。
二、每个任务都是同样四步
这套 API 的形状非常固定。从一路热电偶到 32 通道 250 kS/s,每一个采集任务都是下面这四步,区别只在参数:
- 建通道 —— 告诉驱动用哪个物理通道、量程是多少。
- 配时序 —— 谁来提供采样时钟、多快、采多少个点。
- 启动,然后读或者写。
- 关闭任务,把设备释放掉。
import nidaqmx
from nidaqmx.constants import AcquisitionType
RATE = 10_000.0 # samples per second, shared by every enabled channel
COUNT = 5_000 # samples per channel
with nidaqmx.Task() as task:
task.ai_channels.add_ai_voltage_chan(
"Dev1/ai0",
min_val=-10.0,
max_val=10.0,
)
task.timing.cfg_samp_clk_timing(
rate=RATE,
sample_mode=AcquisitionType.FINITE,
samps_per_chan=COUNT,
)
data = task.read(number_of_samples_per_channel=COUNT)
print(len(data)) # 5000
print(sum(data) / len(data)) # mean, in volts
第四步是最常被跳过的一步,而设备不会自己释放。上一轮留下的任务还开着,下一次运行就会报「指定的资源已被占用」。with 会在退出时把任务清掉,这也是即使写个一次性脚本也值得用它管任务的原因。从 nidaqmx 1.6.0 起,close() 成了 clear_task() 的别名,两个名字现在都能用。
min_val 和 max_val 不是「我的信号有多大」,而是「请把量程设成多大」,而量程决定分辨率。16 位转换器上,±10 V 一个码是 305 µV,±0.2 V 一个码是 6.1 µV。量程比信号宽,多花掉的是本来不必花的分辨率;量程比信号窄,超出的部分会被削平。
三、采样率是全卡的,不是每通道的
这是第一次写 DAQmx 程序时最常见的误解。cfg_samp_clk_timing 里的 rate 是整台设备的总采样率,由所有已启用通道共同分摊。四路各要 50 kS/s,就要写 rate=200_000;只写 rate=50_000,每路只有 12.5 kS/s,波形会和真实情况差四倍。数据手册上「最大采样率」写的也是这个总数。
这个数字背后的架构是多路复用:卡上通常只有一个转换器,一次扫描把它依次轮到每一路上。(USB-4431 与 USB-4432 是例外,它们每通道带一个独立转换器。)两个后果会直接体现在代码里。
import numpy as np
import nidaqmx
from nidaqmx.constants import AcquisitionType, TerminalConfiguration
RATE = 50_000.0 # aggregate across all four channels -> 12.5 kS/s each
COUNT = 20_000 # samples per channel
with nidaqmx.Task() as task:
task.ai_channels.add_ai_voltage_chan(
"Dev1/ai0:3",
min_val=-1.0,
max_val=1.0,
terminal_config=TerminalConfiguration.DIFF,
)
task.timing.cfg_samp_clk_timing(
rate=RATE,
sample_mode=AcquisitionType.FINITE,
samps_per_chan=COUNT,
)
data = task.read(number_of_samples_per_channel=COUNT)
block = np.array(data, dtype=np.float64) # shape (channels, samples)
print(block.shape) # (4, 20000)
t = np.arange(COUNT) / RATE # one time axis, shared by all four
- 四路共用一条时间轴,但它们并不是在同一瞬间被采到的。一次扫描依次取它们,相邻两个通道相隔一个「通道间隔」,等于通道数除以总采样率,这里是 4 / 50 000 = 80 µs。任何跨通道的相位比较都必须把这个数字算进去。真正的同时采样只有每通道独立转换器的型号才做得到。
Dev1/ai0:3配TerminalConfiguration.DIFF占用的是四对引脚,不是四个引脚。标称 16SE/8DI 的模块在差分模式下只剩八路可用。信号小、或者传感器与采集卡之间不共地的时候,差分都是该选的那一种。
四、有限、连续,以及「其实没有采样率」的那种
采集有三种做法,其中只有两种是由硬件定时的。
| 模式 | 怎么配 | 用在哪 |
|---|---|---|
| 有限采集 | sample_mode=AcquisitionType.FINITE | 定长记录:采固定点数,采完自己停。 |
| 连续采集 | sample_mode=AcquisitionType.CONTINUOUS | 长时间监测:硬件不断采进环形缓冲区,程序按块把数据取走。 |
| 软件定时 | 完全不调用 cfg_samp_clk_timing | 慢量:读温度、读状态。每调用一次 read 才转换一次。 |
左右滑动表格可查看全部列
第三种必须讲清楚:它没有采样率。采样点是在程序开口要的那一刻才产生的,所以间隔取决于操作系统调度、取决于解释器、也取决于那一毫秒里你的代码还在忙什么。抖动以毫秒计。任何与时间有关的量它都测不了 —— 频率、相位、波形都不行。另外两种模式默认就是硬件定时,而硬件定时是唯一能给出可信时间轴的东西。
import functools
import numpy as np
import nidaqmx
from nidaqmx import constants, stream_readers
RATE = 20_000.0 # aggregate across all enabled channels
CHUNK = 2_000 # samples per channel handed over on each callback
CHANNELS = 4
def on_samples(reader, store, task_handle, event_type, num_samples, callback_data):
reader.read_many_sample(store["buffer"], num_samples)
store["blocks"].append(store["buffer"].copy())
return 0 # a non-zero return tells the driver to stop the task
def main():
store = {
"buffer": np.zeros((CHANNELS, CHUNK), dtype=np.float64),
"blocks": [],
}
with nidaqmx.Task() as task:
task.ai_channels.add_ai_voltage_chan(
"Dev1/ai0:3", min_val=-10.0, max_val=10.0
)
task.timing.cfg_samp_clk_timing(
rate=RATE,
sample_mode=constants.AcquisitionType.CONTINUOUS,
samps_per_chan=CHUNK * 10, # buffer size: ten chunks
)
reader = stream_readers.AnalogMultiChannelReader(task.in_stream)
task.register_every_n_samples_acquired_into_buffer_event(
CHUNK, functools.partial(on_samples, reader, store)
)
task.start()
input("Acquiring. Press Enter to stop.")
task.stop()
if __name__ == "__main__":
main()
这段代码里有三条 nidaqmx 特有的规则,不知道的话每一条都会造成「看起来在跑、其实不对」:
- 回调必须
return 0。驱动会读这个返回值,非零会被当成「停止任务」的指令。 - 缓冲区是预先分配、反复复用的。
read_many_sample是把数据填进你给它的数组,而不是返回一个新数组,所以回调里那次copy()不是可省的 —— 省掉之后每一个「块」都会指向同一片内存,最后整段记录就是最后一块数据的重复。 - 额外状态不能靠
callback_data传进来。Python 绑定不会把它递给你(底层固定传None),所以上下文要靠functools.partial或者闭包带进去,就像上面那样。
回调运行在驱动自己的线程里,它唯一的任务就是把数据搬走。在里面画图、写文件或者做计算,都会拖住采集,代价是丢点。不想用回调也可以轮询,代码更短也更好推理,代价是这个循环必须一直跟得上。
import numpy as np
import nidaqmx
from nidaqmx.constants import AcquisitionType
RATE = 20_000.0
CHUNK = 2_000 # samples per channel per read
with nidaqmx.Task() as task:
task.ai_channels.add_ai_voltage_chan("Dev1/ai0:3", min_val=-10.0, max_val=10.0)
task.timing.cfg_samp_clk_timing(
rate=RATE,
sample_mode=AcquisitionType.CONTINUOUS,
samps_per_chan=CHUNK * 10,
)
task.start()
try:
while True:
block = np.array(task.read(number_of_samples_per_channel=CHUNK))
print(block.shape, block.mean(axis=1))
except KeyboardInterrupt:
task.stop()
连续模式下 samps_per_chan 是缓冲区大小,不是目标点数。上面留了十块的余量:只要循环在十块被填满之前回来取一次数据,就什么都不会丢。跟不上,驱动就会覆盖最老的数据 —— 也就是后面会讲到的那条报错。
五、输出侧:模拟波形与计数器
输出和输入的形状一样,只是方向反过来。多个任务需要同时起跑时有一个细节值得知道:write() 默认会顺手把任务启动起来,所以要共用一个起跑时刻,就得写 auto_start=False,之后再显式调用 start()。
import numpy as np
import nidaqmx
from nidaqmx.constants import AcquisitionType
RATE = 100_000.0
N = 1_000
with nidaqmx.Task() as task:
task.ao_channels.add_ao_voltage_chan("Dev1/ao0", min_val=-10.0, max_val=10.0)
task.timing.cfg_samp_clk_timing(
rate=RATE,
sample_mode=AcquisitionType.FINITE,
samps_per_chan=N,
)
wave = 5.0 * np.sin(2.0 * np.pi * 100.0 * np.arange(N) / RATE)
task.write(wave, auto_start=False)
task.start()
task.wait_until_done()
计数器是这一层里最容易被忽略、也最有用的部分。它完全跑在硬件里:不占 CPU,也不受软件抖动影响。测频率、数编码器脉冲、输出精确的脉冲串,都在这里。多功能型号每卡带两个 32 位计数器,时基 80 MHz。
import nidaqmx
from nidaqmx.constants import AcquisitionType, CountDirection, Edge
# Counter input: count rising edges arriving on the counter source terminal.
with nidaqmx.Task() as task:
task.ci_channels.add_ci_count_edges_chan(
"Dev1/ctr0",
edge=Edge.RISING,
initial_count=0,
count_direction=CountDirection.COUNT_UP,
)
task.start()
print(task.read()) # edges counted since the task started
# Counter output: a 1 kHz square wave, made by the hardware alone.
with nidaqmx.Task() as task:
task.co_channels.add_co_pulse_chan_freq(
"Dev1/ctr1",
freq=1_000.0,
duty_cycle=0.5,
)
task.timing.cfg_implicit_timing(sample_mode=AcquisitionType.CONTINUOUS)
task.start()
input("Generating 1 kHz. Press Enter to stop.")
注意计数器输出没有采样率。cfg_implicit_timing 里的 sample_mode 只表示「一直发」,频率是由板上 80 MHz 时基分频得到的,跟你的程序跑多快没有任何关系。
六、报错信息里通常已经写着答案
DAQmx 的报错比大多数驱动都直白。捕获 DaqmxError,error_code 是那个数字,而异常本身打印出来就是一整句说明 —— 那句话就是你这个情形的文档。
import nidaqmx
from nidaqmx.errors import DaqmxError
with nidaqmx.Task() as task:
# An entry-level module is fixed at +/-10 V and cannot run at 500 kS/s.
task.ai_channels.add_ai_voltage_chan("Dev1/ai0", min_val=-0.2, max_val=0.2)
task.timing.cfg_samp_clk_timing(rate=500_000.0)
try:
task.start()
data = task.read(number_of_samples_per_channel=1_000)
except DaqmxError as err:
print("error code:", err.error_code)
print(err) # the driver's own explanation, in full
task.stop()
| 报错码 | 什么情况 | 怎么改 |
|---|---|---|
-200279 | 连续采集时读得太慢,还没取走的样本已经被缓冲区里的新数据覆盖。 | 加大 samps_per_chan、提高读取频率,并且每次读固定点数,而不是「把可用的都读走」。 |
-200284 | 读取超时:在超时时间内没有凑够要求的点数。 | 确认任务真的启动了、采样率不是 0;如果确实该慢,再把 timeout 调大。 |
-200278 | 有限采集已经采完并停止了,程序却还在继续读。 | 有限模式下把外面那层 while 去掉,或者改用连续采样。 |
-200077 | 请求了该通道不支持的值 —— 某个量程、某个采样率、某种接线方式。 | 对着数据手册核一遍。整组量程是多功能系列才齐备的,入门型号固定 ±10 V。 |
-50103 | 设备被另一个任务占着:上一次运行没有关闭,或者 NI MAX 的测试面板还开着。 | 用 with 管好任务,关掉 MAX 面板;实在不行再调 device.reset_device()。 |
左右滑动表格可查看全部列
七、你请求的,硬件不一定给得了
Python 不会拦你。rate=500_000 和 min_val=-0.2 都是合法调用里的合法参数,拒绝发生在后面 —— 驱动去配置硬件的时候。哪一档硬件,最好在写脚本之前就定下来,而不是之后。
| 你在 Python 里要的 | 入门系列 | 多功能系列 | 为什么要在意 |
|---|---|---|---|
| 输入量程 | 仅 ±10 V | ±10 / ±5 / ±1 / ±0.2 V | 小信号配大量程,在进入你的代码之前分辨率就已经浪费掉了。 |
| 通道数 | 8SE / 4DI | 16SE / 8DI(6218 为 32SE / 16DI) | 差分接线每通道占两个引脚,所以差分模式下可用路数减半。 |
| 总采样率 | 20 / 50 / 100 kS/s | 250 kS/s;6212 / 6216 单通道 400 kS/s | 由所有已启用通道共同分摊,见第三节。 |
| 模拟输出 | 2 路,5 kS/s | 2 路,250 kS/s(6210 无) | 输出速率和输入速率是两笔独立的账。 |
| 计数器 | 1 个 | 2 个 32 位,80 MHz 时基 | 测频、测位置、发脉冲,全部在硬件里完成。 |
| 通道对地隔离 | 不提供 | 6215 / 6216 / 6218 为 60 V | 现场共模电压高的时候,这一项决定了是「测不准」还是「卡烧了」。 |
左右滑动表格可查看全部列
八、反复出现的就那么几个坑
- 忘了关任务。设备一直被占着,下一次运行直接报
-50103。 - 把
rate当成每通道的。开四路之后,每路只有你写的那个数字的四分之一。 - 在回调里干正经活。数据搬出去,处理放到另一个线程做。
- 拿
time.sleep当时钟用。Python 的定时是毫秒级的,计数器时基是 80 MHz。 - 忽略缓冲区大小。连续模式下
samps_per_chan就是缓冲区,开小了必然溢出。 - 把采集循环跑在图形界面的主线程上。每一次重绘都是一次错过读取的机会。
- 不记版本号。这个包在大版本之间改过形状 —— 1.6.0 把
close()变成了clear_task()的别名 —— 而驱动与包的版本不匹配是最难查的一类问题。要交付的东西请把两者都固定住。
九、到这里你手上有什么
上面全部是标准的 DAQmx:同样的调用、同样的常量、同样的通道字符串。我们的模块走兼容 DAQmx 的驱动,所以代码不用改 —— 把设备名换成我们卡的名字,其余照旧。这就是「既有程序可以无缝切换」这句话的具体含义,《面向工业现场,而不是实验室》一文里讲得更细。
可以直接跑起来的 Python、LabVIEW 和 .NET 示例在下载页。如果你手上的脚本报错而信息看不明白,把完整报错、通道配置和你用的型号一起发过来 —— 代码、报错码和接线这三样凑在一起,通常只剩一种可能。
本文中每个数字,要么是由公开的分辨率与采样率指标算出的算术结果,要么是所引用标准中给出的数值。凡是取决于具体型号的数字,请在冻结设计前对照该型号的数据手册确认。