简介:这是一份面向自动化测试初学者与仪器控制开发者的Python+PyQt5实战项目源码,围绕泰克MDO3014示波器展开,解决上位机界面实时显示波形、保存图像与读取日志的问题。资源包共16个文件,约139KB,以py主程序、ui界面文件、xml工程配置、png界面截图与txt日志样例为主,另含pyc缓存与gitignore等辅助项,结构紧凑便于直接运行与二次修改。项目支持示波器波形在界面实时刷新并保存图片,log信息实时读取显示且可导出窗口内容,同时提供单次循环执行测试用例的框架,用例逻辑需使用者自行编写。代码中详细演示了常用Qt控件在Python中的调用方式,配合演示界面截图与日志样例,能帮助读者快速理解信号槽、界面布局与仪器通信的整合思路。目前已有3659人学习下载,适合作为仪器自动化测试入门与PyQt5界面开发的参考范例。
1. 从手动按示波器到自动化测试:这套方案要解决什么
实验室里做电源纹波测试,最磨人的不是测不出来,而是重复。一个测试项要改三次负载、等波形稳定、手动按示波器截图、把数据抄进表格,一轮下来十几分钟,测二十轮就是大半天。更麻烦的是泰克3000系列示波器屏幕上的读数靠人眼抄,抄错一位数整组数据就得重来。这套基于 Python + PyQt5 + 泰克示波器的自动化测试方案,就是冲着这个场景去的:用上位机通过 VISA 接口远程控制示波器,自动完成参数配置、波形采集、数据解析和报告输出,把测试人员从重复劳动里解放出来。它适合做硬件测试的在校学生、刚入行的测试工程师,以及需要给产线做小型自动化测试工装的开发者。整套方案不依赖昂贵的商业测试软件,一台带 USB 或 LAN 接口的泰克3000系列示波器、一台装了 Python 的电脑就能跑起来。
2. 需求拆解与技术选型:为什么是 PyQt5 加 VISA 而不是 LabVIEW
2.1 先把测试流程拆成可编程的步骤
自动化测试不是把示波器连上电脑就完事,得先把人工测试的每一步翻译成程序能执行的指令。以纹波测试为例,人工流程是:设置通道耦合为 AC、带宽限制 20MHz、探头衰减 10X、时基和垂直档位调到合适范围、打开测量项、等波形稳定、读取峰峰值、记录。对应到程序里,就是一组 SCPI 命令的序列。泰克3000系列支持标准 SCPI 指令集,比如:CH1:COUPLING AC设置耦合,:MEASU:MEAS1:SOURCE CH1指定测量源,:MEASU:MEAS1:TYPE PK2PK设置峰峰值测量,:MEASU:MEAS1:VALUE?查询结果。把这些命令按顺序封装成函数,一个测试项就成型了。需求拆解的关键是分清哪些步骤必须由程序控制(参数配置、数据读取),哪些可以保留人工判断(比如异常波形的人工复核),不要一上来就追求全自动。
2.2 技术选型:PyQt5 负责界面,PyVISA 负责通信
上位机框架选 PyQt5,核心原因是它和 Python 生态结合紧密,信号槽机制天然适合处理「后台采集线程更新界面」这种场景。测试过程中示波器采集是阻塞操作,如果放在主线程里界面会卡死,PyQt5 的QThread加自定义信号可以干净地解决这个问题。通信层用 PyVISA,它是 VISA 库的 Python 封装,支持 USB、LAN、GPIB 多种接口,换一台带 LAN 口的示波器只需要改资源地址字符串,代码逻辑不用动。数据解析和存储用 NumPy 加 openpyxl,波形数据存成 CSV 方便后续用 MATLAB 或 Python 复现,测试结果汇总到 Excel 报告里。这套组合的优点是全部开源、文档齐全、社区踩坑记录多,遇到问题搜得到答案。
2.3 实时刷新图片和打印信息的技术方案
关键词里提到的「实时刷新图片」和「实时刷新打印信息」是两个独立的刷新通道。图片刷新指的是把示波器屏幕截图或波形数据实时画到 PyQt5 界面上,通常用pyqtgraph而不是 matplotlib,因为 pyqtgraph 的刷新率能到几十帧,matplotlib 在频繁重绘时明显卡顿。打印信息刷新指的是把 SCPI 命令收发日志、测试进度、异常提示实时追加到界面上的文本框里,用QTextEdit配合append()方法,注意要在子线程里通过信号触发主线程更新,不能直接在子线程操作 UI 控件。下面是一段采集线程的核心代码结构:
import pyvisa import numpy as np from PyQt5.QtCore import QThread, pyqtSignal class ScopeWorker(QThread): # 定义信号:波形数据、日志信息、测试完成 wave_ready = pyqtSignal(np.ndarray, np.ndarray) log_signal = pyqtSignal(str) finished = pyqtSignal() def __init__(self, resource_addr): super().__init__() self.resource_addr = resource_addr self.running = True def run(self): rm = pyvisa.ResourceManager() scope = rm.open_resource(self.resource_addr) scope.timeout = 5000 # 超时5秒,避免死等 self.log_signal.emit(f"已连接: {scope.query('*IDN?').strip()}") while self.running: # 配置采集参数 scope.write(':CH1:COUPLING AC') scope.write(':CH1:BANDWIDTH 20E6') scope.write(':MEASU:MEAS1:SOURCE CH1') scope.write(':MEASU:MEAS1:TYPE PK2PK') # 读取波形数据 scope.write(':DATA:SOURCE CH1') scope.write(':DATA:ENC RPB') # 二进制编码,传输快 raw = scope.query_binary_values(':CURVE?', datatype='B', is_big_endian=True) # 解析成电压和时间轴 y = (np.array(raw) - 128) * scope.query(':WFMPRE:YMULT?') # 简化示意 x = np.linspace(0, 1e-3, len(y)) self.wave_ready.emit(x, y) self.log_signal.emit(f"采集完成,峰峰值: {scope.query(':MEASU:MEAS1:VALUE?')} V") self.msleep(500) # 刷新间隔500ms scope.close() self.finished.emit()这段代码里wave_ready信号把 NumPy 数组传给主线程绘图,log_signal把字符串传给日志框,两个信号互不干扰。scope.timeout必须设,否则示波器没响应时程序会永久挂起。msleep控制刷新节奏,太快会占满 USB 带宽,太慢界面看起来卡顿,500ms 是实测比较舒服的值。
3. 总体架构设计:三层结构与四个核心模块的职责划分
3.1 分层架构:界面层、控制层、设备层
整套程序按三层划分。界面层用 PyQt5 实现,包含主窗口、波形显示区、日志输出区、参数配置面板和测试控制按钮,只负责展示和接收用户输入,不碰任何 SCPI 命令。控制层是核心,包含测试流程调度、SCPI 命令封装、数据解析和报告生成,它接收界面层的指令,调用设备层接口,把结果回传给界面。设备层封装 PyVISA 的资源管理和读写操作,对外暴露connect()、write()、query()、close()几个方法,换通信接口时只改这一层。分层的意义在于:调试通信问题时只看设备层,改界面样式时只动界面层,加测试项时只改控制层的流程配置,互不牵连。
3.2 四个核心模块:连接管理、参数配置、数据采集、报告输出
连接管理模块负责扫描可用 VISA 资源、建立连接、检测设备身份(发*IDN?确认是泰克3000系列)、断线重连。参数配置模块把测试项的 SCPI 命令序列存成字典或 JSON 文件,比如{"耦合": ":CH1:COUPLING AC", "带宽": ":CH1:BANDWIDTH 20E6"},界面上改下拉框就映射到对应命令。数据采集模块是前面代码展示的 QThread 子类,负责定时触发采集、解析二进制波形、计算测量值。报告输出模块把每次采集的时间戳、测量项、数值、合格判定写入 Excel,同时把波形数据存成 CSV。四个模块之间通过信号和回调通信,不直接互相引用,方便单独测试。
3.3 数据流与线程模型
数据流是单向的:用户点「开始测试」→ 界面层发信号 → 控制层启动采集线程 → 设备层执行 SCPI → 采集线程发wave_ready和log_signal→ 界面层更新波形和日志 → 控制层把结果写报告。线程模型上,主线程只管 UI,采集线程管设备通信,报告写入如果耗时较长可以再开一个线程,但通常 Excel 写入很快,放在采集线程末尾同步执行就行。要注意的是 PyVISA 的 ResourceManager 不是线程安全的,多个线程同时操作同一个示波器会报错,所以采集线程只能有一个,用QThread的isRunning()判断避免重复启动。
4. 关键模块实现:SCPI 封装、实时绘图与日志刷新
4.1 SCPI 命令封装与参数映射
直接在主逻辑里写scope.write(':CH1:COUPLING AC')不利于维护,通常的做法是封装一个ScopeController类,把常用操作变成方法:
class ScopeController: def __init__(self, scope): self.scope = scope def set_channel(self, ch, coupling='AC', bandwidth=20e6, scale=1.0): """配置通道耦合、带宽限制和垂直档位""" self.scope.write(f':CH{ch}:COUPLING {coupling}') self.scope.write(f':CH{ch}:BANDWIDTH {bandwidth}') self.scope.write(f':CH{ch}:SCALE {scale}') def set_measure(self, slot, source, mtype): """配置测量项,slot为1-4,mtype如PK2PK、RMS、FREQ""" self.scope.write(f':MEASU:MEAS{slot}:SOURCE CH{source}') self.scope.write(f':MEASU:MEAS{slot}:TYPE {mtype}') def read_measure(self, slot): """读取测量值,返回浮点数""" return float(self.scope.query(f':MEASU:MEAS{slot}:VALUE?')) def get_waveform(self, ch): """读取指定通道波形,返回时间轴和电压数组""" self.scope.write(f':DATA:SOURCE CH{ch}') self.scope.write(':DATA:ENC RPB') self.scope.write(':DATA:WIDTH 1') ymult = float(self.scope.query(':WFMPRE:YMULT?')) yoff = float(self.scope.query(':WFMPRE:YOFF?')) yzero = float(self.scope.query(':WFMPRE:YZERO?')) raw = self.scope.query_binary_values(':CURVE?', datatype='B', is_big_endian=True) volts = (np.array(raw) - yoff) * ymult + yzero tscale = float(self.scope.query(':WFMPRE:XINCR?')) time = np.arange(len(volts)) * tscale return time, voltsset_channel里的bandwidth参数单位是 Hz,传20e6表示 20MHz 带宽限制,这是纹波测试的常规设置。get_waveform里的YMULT、YOFF、YZERO是泰克示波器波形缩放的三要素,缺一个算出来的电压就是错的,这是血泪经验,第一次写的时候只乘了 YMULT,波形整体偏移,排查了半天。query_binary_values的datatype='B'表示无符号字节,is_big_endian=True是泰克默认的字节序,换成小端序数据会乱。
4.2 实时波形刷新:pyqtgraph 嵌入 PyQt5
波形显示用 pyqtgraph 的PlotWidget,它可以直接嵌入 PyQt5 的布局里。主窗口里创建一个PlotWidget,采集线程的wave_ready信号连接到更新函数:
import pyqtgraph as pg from PyQt5.QtWidgets import QWidget, QVBoxLayout class WaveDisplay(QWidget): def __init__(self): super().__init__() self.plot_widget = pg.PlotWidget() self.plot_widget.setLabel('left', '电压', units='V') self.plot_widget.setLabel('bottom', '时间', units='s') self.plot_widget.setYRange(-1, 1) self.curve = self.plot_widget.plot(pen='y') layout = QVBoxLayout() layout.addWidget(self.plot_widget) self.setLayout(layout) def update_wave(self, x, y): """槽函数,接收采集线程发来的波形数据""" self.curve.setData(x, y)setData是原地更新,不会重建整个图,所以刷新率高。setYRange固定纵轴范围避免波形跳动时坐标轴跟着缩放,看起来更稳。如果波形数据量大(比如 10k 点以上),可以在采集线程里先做降采样再发信号,减少 UI 线程负担。
4.3 日志实时刷新与线程安全
日志框用QTextEdit,采集线程通过log_signal发字符串,主线程的槽函数调用append()。注意不要在子线程里直接self.text_edit.append(),PyQt5 的 UI 控件不是线程安全的,跨线程操作会随机崩溃。日志内容建议包含时间戳、方向(发送/接收)、内容,方便回溯:
from datetime import datetime def append_log(self, msg): timestamp = datetime.now().strftime('%H:%M:%S.%f')[:-3] self.text_edit.append(f'[{timestamp}] {msg}') # 自动滚动到底部 self.text_edit.verticalScrollBar().setValue( self.text_edit.verticalScrollBar().maximum() )日志量大的时候QTextEdit会变慢,通常的做法是限制最大行数,比如超过 5000 行就删掉最早的 1000 行,或者用QPlainTextEdit替代,后者在大量文本追加时性能更好。
5. 避坑与常见问题排查:连接、数据、界面三类翻车点
5.1 连接失败:资源地址不对或驱动没装
现象是rm.open_resource()抛VisaIOError,提示找不到设备。原因通常是资源地址写错,或者电脑没装 VISA 驱动。泰克示波器用 USB 连接时,资源地址形如USB0::0x0699::0x0401::C012345::INSTR,其中0x0699是泰克的厂商 ID,后面是型号和序列号。用 LAN 连接时是TCPIP0::192.168.1.100::inst0::INSTR。解决方法是先用rm.list_resources()打印所有可用资源,确认地址后再填进程序。驱动方面,Windows 上装泰克官方的 OpenChoice 或 NI-VISA 都行,装完重启一次,否则list_resources()返回空元组。
5.2 波形数据错乱:缩放因子和字节序搞反
现象是画出来的波形幅度明显不对,或者波形像噪声一样乱跳。原因通常是YMULT、YOFF、YZERO没取全,或者is_big_endian设错。泰克3000系列的:CURVE?返回的是二进制块,前面有#加长度描述符,PyVISA 的query_binary_values会自动处理这个头,但字节序要手动指定。解决方法是先用:DATA:ENC ASCII让示波器返回 ASCII 格式的波形数据,虽然慢但肉眼可读,确认解析逻辑正确后再换回RPB二进制格式提速。另外:DATA:WIDTH要设成 1 或 2,和datatype参数对应,设成 2 时datatype='H'。
5.3 界面卡死:阻塞操作放在了主线程
现象是点了「开始测试」后窗口无响应,拖动不了,日志也不刷新。原因是 SCPI 查询或time.sleep()写在了主线程里。解决方法是所有设备通信和延时都放进QThread子类,主线程只处理 UI 事件。如果已经写了阻塞代码,可以用QApplication.processEvents()临时缓解,但这是治标不治本,正确做法还是拆线程。另外scope.timeout要设一个合理值,设太大(比如 60 秒)时设备掉线会卡很久,设太小(比如 100ms)正常查询也会超时,5000ms 是实测比较稳的值。
5.4 报告数据对不上:测量项没等波形稳定就读取
现象是 Excel 里的峰峰值和示波器屏幕上显示的不一致,偏差有时大有时小。原因是 SCPI 命令发出去后示波器需要时间重新采集和计算,程序紧接着就query测量值,读到的是上一次的结果。解决方法是在配置完参数后加一个固定延时(比如 200ms),或者用*OPC?查询等待操作完成。更稳妥的做法是连续读三次测量值,如果三次差异小于阈值就取平均,差异大就继续等,这样能过滤掉过渡态的数据。
5.5 长时间运行内存上涨:波形数据没释放
现象是程序跑一两个小时后内存占用从几十兆涨到几百兆。原因是每次采集都往列表里append波形数组,旧数据没释放。解决方法是在报告写入完成后把波形数组置为None,或者只保留最近 N 次采集的数据用于界面回看,更早的存盘后从内存删除。pyqtgraph 的setData本身不会泄漏,但如果你每次plot()新建曲线对象就会累积,正确做法是初始化时创建一次curve,后续只调setData。
6. 验收与答辩:怎么证明这套自动化测试真的跑通了
验收这套方案,核心是证明三件事:能连上、能采准、能出报告。我一般会准备一份验收清单,逐项打勾。第一项是连接测试,程序启动后能自动扫描到示波器资源并显示设备型号,断开 USB 后能提示重连,重插后能恢复。第二项是参数配置测试,在界面上改耦合、带宽、档位,用*IDN?之外的查询命令回读确认设置生效,比如发:CH1:COUPLING?应该返回AC。第三项是数据准确性测试,用一个已知幅度的信号源(比如函数发生器输出 1Vpp 正弦波)接入示波器,程序读出的峰峰值和示波器屏幕显示值偏差在 5% 以内,连续测 10 次的标准差小于 1%。第四项是实时性测试,观察波形刷新和日志追加是否流畅,用秒表掐一下从点「开始」到第一帧波形出现的时间,通常应该在 2 秒以内。第五项是报告完整性测试,跑完 20 轮测试后打开 Excel,检查每轮的时间戳、测量值、合格判定是否齐全,CSV 波形文件能否用 Python 重新画出来。
答辩时老师或评委最常问的是「你这个和 LabVIEW 比有什么优势」。回答的落脚点不要放在「免费」上,而是放在可定制性和可复现性:Python 代码可以直接嵌入现有的数据处理流程,比如用 pandas 做统计分析、用 scikit-learn 做异常检测,LabVIEW 做这些要额外调库;另外 Python 脚本可以用 Git 管理版本,测试逻辑的每次修改都有记录,产线换型时改配置比改图形化程序快。如果被问到「测量精度怎么保证」,就拿出前面说的三次读取取平均的策略,以及和示波器屏幕读数的对比数据。
还有一个容易被忽略的验收点是异常处理。评委可能会故意拔掉 USB 线或者关掉示波器电源,看程序是崩溃还是给出友好提示。我的习惯是在ScopeController的每个方法外面包一层 try-except,捕获VisaIOError后发信号通知界面显示「设备连接中断,请检查线缆」,同时停止采集线程,避免日志刷屏。这个细节在答辩时演示一次,比说十句「考虑了异常情况」都有用。
从那以后我每次做设备通信类的项目,都会先把「连接-配置-采集-断开」这条最小闭环跑通,再往上堆界面和报告功能,避免在 UI 上花了两天结果发现 SCPI 命令根本发不出去。希望这套结构能帮到你,不管是交毕设还是搭产线工装,照着这个顺序推一遍,至少不会在验收现场翻车。
本文还有配套的精品资源,点击获取