恒美微站
首页
关于我们
建站服务
主题模板
案例展示
资讯中心
联系我们
泰克示波器自动化测试:PyQt5与VISA/SCPI工程实战
首页
资讯中心
/
泰克示波器自动化测试:PyQt5与VISA/SCPI工程实战
泰克示波器自动化测试:PyQt5与VISA/SCPI工程实战
发布时间:2026/10/17 2:54:17
简介基于Python与PyQt5开发的泰克示波器自动化测试工程面向需要对接MDO3000系列示波器的硬件测试或上位机开发人员用于解决仪器数据实时显示、图像保存及日志监控等重复性工作。工程内包含主要程序文件、界面设计文件、位图资源、日志样本与工程配置信息共16个文件压缩包约139KB结构紧凑便于快速定位源码、UI布局和演示素材。其中GUI_Main.py和MainWindow.py演示了PyQt5中常用控件如按钮、表格、日志窗口的调用方式配合MainWindow.ui可直接查看界面设计log相关代码实现了图像与日志信息实时刷新、窗口内容保存测试用例支持单次循环执行具体用例逻辑可由使用者自行扩展。整体兼顾示例性与实用性已有3657人学习下载适合具备一定Python基础、希望快速上手PyQt5与仪器自动化控制的开发者参考。1. 泰克示波器自动化测试为什么我放弃“手动截屏”转投 PyQt5做电子测量的人都有过这种经历被测板子上电后要在示波器上反复调节时基、触发电平盯着屏幕上的波形判断是否正确再把合格波形一张张截图存档。如果被测点是 8 路甚至 16 路一次测试流程下来光截屏和整理数据就要花掉大半天。我一开始也觉得“示波器截图能有多费事”直到某次评审会上发现 30 张波形图里有 5 张的时基设置不一致数据根本没法横向对比才意识到手动操作不只是费时间更是测试结果失真的大坑。这就是 PyQt5 Python Tektronix 3000 系列示波器自动化测试要解决的问题把示波器的实时波形图片、测量参数、打印信息统一拉回到上位机界面里按测试用例自动完成激励、采集、比对和存档。它面向的不是科研场景里调一次波形而是产线验证、硬件测试、研发自测这类需要反复执行的场景。做完这套东西之后测试时间能从小时级压缩到分钟级而且每次测试的示波器参数都会固化在配置文件里再也不存在“上次时基调了多少”这种玄学问题。下面我直接按这套资源里最核心的实现路径拆开讲。2. Tektronix 3000 的通信链路VISA 与 SCPI 的选型与连接实操2.1 为什么选 VISA 而不是直接走 Socket泰克 3000 系列示波器Tektronix 3000 泛指 TDS3000、MSO3000 等常用型号支持多种程控方式包括 GPIB、RS-232、LAN 和 USB但应用层统一走 SCPIStandard Commands for Programmable Instruments指令集。常见的做法是用 VISAVirtual Instrument Software Architecture做中间层因为 VISA 把 GPIB、串口、网口全部封装成了统一的资源字符串VISA Resource String我在代码里写连接逻辑时不用关心物理链路是网线还是 USB。有人会问既然示波器支持 LAN 口为什么不直接用 socket 发 SCPI 命令我自己试过socket 直连 TDS3000 的问题在于示波器固件对 TCP 连接有会话限制如果上位机在测试中途异常退出socket 没有正常关闭示波器端会保持半开连接重连时就容易出现VI_ERROR_RSRC_BUSY。VISA 库自带资源管理机制在连接释放和会话清理上比裸 socket 稳妥得多。实测在产线环境下连续跑 200 次连接/断开VISA 方式没有出现资源泄漏。2.2 建立连接地址探测与超时参数先安装 Tektronix 提供的 VISA 运行时常见的是 TekVISA 或 NI-VISA两者接口兼容。连接的第一步是枚举设备代码里用pyvisa库最顺手它是 National Instruments VISA 的 Python 绑定泰克自己的 PyVISA 也是基于这层封装。import pyvisa rm pyvisa.ResourceManager() resources rm.list_resources() print(VISA 资源列表:, resources) # 如果资源列表为空先检查 TekVISA 服务是否启动 # 常见资源字符串示例 # TCPIP0::192.168.1.100::inst0::INSTR # USB0::0x0699::0x0368::C012345::INSTR instr rm.open_resource(TCPIP0::192.168.1.100::inst0::INSTR) instr.timeout 3000 # 毫秒 instr.encoding latin-1 # 验证连接 idn instr.query(*IDN?) print(示波器响应:, idn)这段代码里有个关键点encoding必须设置。泰克 3000 系列在返回 SCPI 响应时默认编码不是 UTF-8如果用默认编码解析带单位符号的字符串比如mV、kHz偶尔会出现 UnicodeDecodeError。timeout参数我习惯设 3000 毫秒因为示波器在做自动测量时如果触发电平设置不合理响应会变慢超时太短会导致误报。连接成功后用*IDN?验证身份是通用习惯返回的字符串一般包含厂商、型号、序列号和固件版本我拿到后会做一次字符串比较确保连接的是预期的 3000 系列而非其他型号。2.3 读取实时波形从 CURVE? 到 numpy 数组拿到连接句柄之后的下一件事是把波形数据从示波器里拉回来。TDS3000 和 MSO3000 的波形读取用的都是CURVE?命令但在读取之前必须先设置要读取的通道和传输格式否则返回的数据可能不是你想要的通道。# 读取 CH1 的完整波形数据 instr.write(DATA:SOURce CH1) instr.write(DATA:WIDth 1) # 1 字节宽度速度快 instr.write(DATA:ENCdg RIBinary) # 有符号整数高位在前 # 获取水平参数用于后续坐标映射 x_inc float(instr.query(WFMPre:XINcr?)) x_zero float(instr.query(WFMPre:XZEro?)) y_mult float(instr.query(WFMPre:YMULT?)) y_zero float(instr.query(WFMPre:YZEro?)) # 读波形 raw_data instr.query_binary_values(CURVE?, datatypeb, containerlist) print(f采样点数: {len(raw_data)})这段代码有几个值得注意的细节。DATA:WIDth 1表示每个采样点占 1 字节前提是示波器当前的分辨率不超过 8 位对于 3000 系列的 8 位 ADC 来说够用如果做高精度测量改成DATA:WIDth 2并配合datatypeh即可代价是传输时间翻倍。RIBinary是有符号整数的二进制传输格式相比 ASCII 格式传输效率高很多一次CURVE?返回 10000 个采样点用时不到 100 毫秒。拿到原始数据后还要把 ADC 码值换算成实际的电压值。公式是y_actual (raw_data - y_zero) * y_mult而时间轴则是x_actual x_zero i * x_inc。我通常直接转成 numpy 数组做运算因为后续无论是画波形还是算峰峰值都离不开向量化计算。2.4 配置界面PyQt5 里的定时刷新架构波形数据从示波器透过 VISA 到了 Python接着就是 PyQt5 的活。最忌讳的做法是在 UI 线程里直接同步读示波器因为CURVE?即使再快也有几十毫秒的阻塞界面会出现明显卡顿。这套资源里的做法是典型的「工作线程 信号槽」架构示波器读取放在 QThread 里拿到波形数组后用 PyQt5 的 Signal 发给主线程主线程只负责刷新 QLabel 上的图片或 QTableWidget 里的测量数据。import sys import numpy as np from PyQt5.QtCore import QThread, pyqtSignal, QTimer from PyQt5.QtWidgets import QApplication, QLabel, QVBoxLayout, QWidget class ScopeWorker(QThread): wave_ready pyqtSignal(object) # 波形数组 info_ready pyqtSignal(str) # 打印信息 def __init__(self, instr): super().__init__() self.instr instr self.running True def run(self): while self.running: # 读取波形并解析 data self.read_waveform() self.wave_ready.emit(data) # 读取测到的频率和幅值 info self.instr.query(MEASUREMENT:MEAS1:VALUE?) self.info_ready.emit(fCH1 频率: {info.strip()} Hz) self.msleep(50) # 20FPS 刷新 def read_waveform(self): # 省略 CURVE 读取细节参考上文代码 pass class MainWindow(QWidget): def __init__(self): super().__init__() self.label QLabel(等待波形...) lay QVBoxLayout() lay.addWidget(self.label) self.setLayout(lay) def update_wave(self, data): # 把数组转成 QPixmap 再刷新 pass app QApplication(sys.argv) win MainWindow() win.show() sys.exit(app.exec_())这里特别说明一下刷新频率的设计。msleep(50)对应 20 FPS 的刷新率实际使用中我发现在 3000 系列上CURVE?一次约 50~80 毫秒加上数据解析实际帧率只能到 10FPS 左右。如果追求更高刷新率可以打开示波器的 FastFrame 模式或缩短采集长度。对于大多数测试场景10FPS 已经足够“实时”屏幕上看起来就是示波器画面在动态更新没有明显的“一帧一帧跳变”感。3. PyQt5 界面与数据的交互设计信号槽、线程与刷新时序3.1 为什么把界面刷新和仪器交互分开刚写这个项目的时候我在一个新手常见的坑里卡了两天直接在QTimer的timeout回调里调用instr.query()结果界面不仅卡顿还出现了“程序未响应”的假死状态。这是因为query()在等示波器返回时是阻塞的而 Qt 的 UI 事件循环也被这个阻塞卡住界面消息无法得到处理。实际上示波器的响应时间不是固定的触发模式、测量项数量、波形长度都会影响响应时长极端情况下能达到秒级。正确的解法是把仪器交互丢给 QThread线程内部跑一个while True循环不断执行「读取波形 → 发射信号 → 休眠」三步。PyQt5 的信号槽机制会把wave_ready信号投递到主线程的事件循环中主线程拾取信号后执行update_wave滑润刷新界面。这样即使示波器响应慢卡住的只是工作线程界面依然能响应用户的按钮点击。3.2 打印信息的实时刷新从串口控制台到 UI 的日志窗口示波器只有一个屏幕但自动化测试场景里除了波形还有测到的频率、峰峰值、上升沿时间等批量测量结果。这些信息如果只是在 Python 控制台里 print测完一排数据根本没法回看如果写文件又不能实时观察。这套资源里用的是 QPlainTextEdit 作为滚动日志窗口每收到一条新数据就 append 一行日志窗口自动滚动到底部。from PyQt5.QtWidgets import QPlainTextEdit class LogPanel(QPlainTextEdit): MAX_BLOCKS 500 # 限制最大行数防止内存增长 def append_log(self, text): self.appendPlainText(text) # 控制行数 doc self.document() if doc.blockCount() self.MAX_BLOCKS: cursor self.textCursor() cursor.movePosition(cursor.Start) cursor.select(cursor.LineUnderCursor) cursor.removeSelectedText() cursor.deleteChar() # 滚动到底部 sb self.verticalScrollBar() sb.setValue(sb.maximum())日志窗口里的每一行我都会加上时间戳前缀格式是[HH:MM:SS.mmm] 消息内容因为产线测试经常需要按时间回溯故障现场。加时间戳有个细节不要在收到信号时才去调用datetime.now()那反应的是界面处理信号的时间和示波器实际采集时间会差几百毫秒更可靠的做法是在工作线程读出数据的那一刻就打上时间戳把(timestamp, message)作为一个元组通过信号传出来。3.3 截图保存波形图自动按用例命名归档测试完成之后波形数据不能看完就扔。最常见的归档需求是把当前波形保存为 PNG 图片命名规则包含测试用例编号和实际测量值。在这个项目里我用QPixmap.grab()抓取绘制波形图片然后保存到一个以日期分层的目录结构里。import os from datetime import datetime def save_wave_image(self, case_id, value): date_str datetime.now().strftime(%Y%m%d) hour_str datetime.now().strftime(%H%M%S) folder os.path.join(reports, date_str, case_id) os.makedirs(folder, exist_okTrue) filename f{hour_str}_{value:.3f}V.png filepath os.path.join(folder, filename) self.label.pixmap().save(filepath, PNG) self.log_panel.append_log(f已保存: {filepath})这里有个版本兼容问题QLabel.pixmap()在 PyQt6 里改成了pixmap()方法返回QPixmap的拷贝在 PyQt5 里同样可用但如果你用的是 PySide2/PySide6接口略有差别。这套资源基于 PyQt5所以直接用label.pixmap()就行。保存图片这一步虽然看起来简单但容易被忽略的是如果波形还没绘制完成第一帧还没到就去保存保存下来的是一张空白占位图。我一般会在工作线程发出first_frame_ready信号后才启用保存按钮。4. 自动化测试时序激励、采集、比对与报表输出4.1 测试流程的状态机设计自动化测试不能只是“把示波器画面搬到电脑上”核心价值在于可编排的测试流程。以电源板纹波测试为例标准流程是上位机设置示波器通道、自动设置触发、等待波形稳定、读取峰峰值、与规格值比对、生成报告。这个流程里有明确的时序依赖上一动作完成才能开始下一个动作所以用简单的线性顺序执行不够健壮——如果第 2 步因为信号没接好导致示波器一直没有触发第 3 步就会卡死等待。推荐的做法是写一个轻量级状态机。这里给出一个实用的骨架class TestState: IDLE 0 CONFIG 1 WAIT_TRIGGER 2 ACQUIRE 3 COMPARE 4 REPORT 5 ERROR 99 class AutoTestRunner(QThread): state_changed pyqtSignal(int, str) def __init__(self, instr, spec: dict): super().__init__() self.instr instr self.spec spec self.state TestState.IDLE def run(self): self.state TestState.CONFIG self.state_changed.emit(self.state, 配置示波器) self.configure_scope() self.state TestState.WAIT_TRIGGER self.state_changed.emit(self.state, 等待信号触发) if not self.wait_for_trigger(timeout5000): self.state TestState.ERROR self.state_changed.emit(self.state, 触发超时) return self.state TestState.ACQUIRE self.state_changed.emit(self.state, 采集 100 帧波形) waveforms self.acquire_frames(100) self.state TestState.COMPARE self.state_changed.emit(self.state, 计算峰峰值并比对) pv self.compute_peak_to_peak(waveforms) result PASS if pv self.spec[max_ripple] else FAIL self.state TestState.REPORT self.state_changed.emit(self.state, f生成报告: {result}) self.generate_report(result, pv)这套状态机的好处是每一步都能发信号通知界面更新进度状态卡住时也能明确知道卡在哪一步。wait_for_trigger的实现我一般用TRIGger:STATUS?查询而不是傻等一定时间。3000 系列支持返回TRIGGER或READY等状态轮询间隔 100 毫秒超时 5 秒就判定异常。4.2 批量测量参数配置与固化测试要可重复示波器的设置必须可固化。在这个项目的配置模块里所有示波器参数以 YAML 文件保存包括通道开启状态、垂直刻度、水平时基、触发电平、测量项列表。channel: CH1: enabled: true scale: 0.5 # V/div position: -2 # div CH2: enabled: false scale: 0.1 position: 0 horizontal: scale: 0.00001 # s/div对应 10us position: 5 trigger: source: CH1 level: 0.1 # V slope: RISING measurements: - PK2Pk - FREQuency - RISeTime应用这份配置到示波器的代码本质是把 YAML 逐项翻译成 SCPI 写入命令。CH1:SCAle 0.5、HORizontal:MAIn:SCAle 1e-5、TRIGger:MAIn:LEVel 0.1等等。这里要提醒一个 3000 系列的特殊性水平时基的写法和垂直不一样水平分成HORizontal:MAIn和HORizontal:FAST两个子空间普通采集用MAInFastAcq 模式才涉及FAST配置时如果不带MAIn直接写HORIZONTAL:SCALE部分固件版本会报错。4.3 报告生成CSV HTML 双份输出测试记录不能只留在上位机界面上对工程师来说一份可追溯的测试报告比一堆截图要有用得多。这个项目里报告输出分两路一路是 CSV方便用 Excel 打开做二次统计分析另一路是 HTML可以直接打印或者归档到内部系统。import csv import html rows [ {case_id: PWR-001, ripple_mv: 18.5, result: PASS}, {case_id: PWR-002, ripple_mv: 35.2, result: FAIL}, ] csv_path reports/20240520_ripple_test.csv with open(csv_path, w, newline) as f: writer csv.DictWriter(f, fieldnames[case_id, ripple_mv, result]) writer.writeheader() writer.writerows(rows) html_path reports/20240520_ripple_test.html tbl table border1trth用例/thth纹波/thth结果/th/tr for r in rows: tbl ftrtd{r[case_id]}/tdtd{r[ripple_mv]}/td tbl ftd{html.escape(r[result])}/td/tr tbl /table with open(html_path, w) as f: f.write(fhtmlbodyh2纹波测试报告/h2{tbl}/body/html)这里代码本身不难但有一个工程细节CSV 写成 UTF-8 格式时如果测试用例编号里包含中文比如“电源模块-001”用 Excel 默认打开会乱码因为 Excel 默认按 ANSI 解析。解决方法是写 CSV 时用utf-8-sig编码具体做法是在open()时指定encodingutf-8-sig。这个小坑我同事踩过一次后来我把这个细节写进了配置说明文档。5. 泰克 3000 自动化测试避坑指南五个真实翻车记录5.1 示波器连不上IP 地址变了但程序还在用缓存现象资源列表里能看到示波器但open_resource一直报超时程序重启后又能连上。原因泰克 3000 在 DHCP 环境下 IP 会变化而 VISA 的资源管理器在进程内会缓存设备描述符。第一次连接成功后如果示波器断电重连或网络重新分配了 IP旧的 resource string 可能指向失效的地址。解决不要在程序启动时一次性打开资源然后永久持有。每次测试序列开始前重新调用list_resources()并匹配型号名称连接失败时自动重试。如果示波器是固定 IP直接在路由上做 MAC 绑定这是产线最省事的方式。5.2 波形数据全是同一数值DATA:SOURce 没有重设现象通道 CH1 有正常波形但读取CURVE?返回的数据全是 128 附近的一个死数而且点数也异常。原因上一个测试用例设置了DATA:SOURce CH2CH2 没接信号采样出来就是基线电平。重新连接示波器或者切换用例后没有把数据源重新指向 CH1示波器只是忠实地返回了 CH2 的数据。解决在每次采集前强制写一遍DATA:SOURce CH1不要依赖默认状态。同理DATA:WIDth和DATA:ENCdg也需要在采集前重写因为其他代码块可能改过这些参数。5.3 界面假死阻塞查询放进了 UI 线程现象连接示波器后PyQt5 窗口拖不动、按钮点击没反应但过几秒又恢复正常。原因某个按钮的槽函数里直接调用了instr.query()示波器响应慢时阻塞了 UI 线程。解决全局搜索代码里所有.query(和.write(确认是否都在 QThread 中执行。界面上只需要拿到结果信号后再更新 UI。如果只是偶发查询也可以用QTimer.singleShot(0, ...)丢到下一次事件循环但根治方案还是线程分离。5.4 FPS 上不去示波器传输模式不对现象实时刷新界面显示只有 3~5 FPS看起来像幻灯片。原因默认的 ASCII 波形传输模式每个采样点要转成数字字符串再传回电脑10000 个点就要传几万字节加上字符串解析开销速度极慢。解决改用二进制传输DATA:ENCdg RIBinary配合DATA:WIDth 1单帧传输时间能从 500 毫秒降到 80 毫秒左右。如果还不满足可以缩短采集长度比如只采集 5000 点。对于“实时预览”需求5000 点画出的波形在屏幕上已经完全足够。5.5 测量值读到NaN触发条件不满足现象MEASUREMENT:MEAS1:VALUE?返回的是文本NaN程序转 float 时报错。原因当示波器当前没有稳定触发时自动测量项无法计算有效值返回 NaN 是正常行为。解决在自动测量之前先检查触发状态轮询TRIGger:STATE?返回READY或TRIGGER时才发起测量查询同时把响应字符串做一次正则判断匹配到 NaN 就丢弃这帧数据不影响到界面的实时刷新。6. 数据生图把波形数组转成 QPixmap 的高效管线6.1 从 numpy 到 QImage 的两种路径前面的信号槽把波形数组发射到主线程但还没讲怎么把它变成屏幕上看到的那张波形曲线图。最常见的路径有两种一是用 QPainter 一笔一笔地画折线二是先把 n umpy 数组转成 QImage 再贴到 QLabel 上。第一种的优点是曲线抗锯齿效果好而且可以叠加网格和光标缺点是绘制耗时约 10~20 毫秒第二种速度快但缩放失真。我在这套资源里用的方案是“折中”波形曲线用 QPainter 绘制背景网格提前渲染成静态 QPixmap 缓存每次刷新只有曲线是动态画的。from PyQt5.QtGui import QPainter, QImage, QPixmap, QColor, QPen from PyQt5.QtCore import Qt class WaveformWidget(QLabel): def __init__(self): super().__init__() self._wave None self._pixmap QPixmap(800, 400) self.setFixedSize(800, 400) def set_wave(self, wave_data): self._wave wave_data self.update() # 触发重绘 def paintEvent(self, event): super().paintEvent(event) pixmap QPixmap(800, 400) pixmap.fill(QColor(#1e1e1e)) painter QPainter(pixmap) pen QPen(QColor(#00ff00), 1.5) painter.setPen(pen) if self._wave is not None and len(self._wave) 1: n len(self._wave) w, h 800, 400 step w / (n - 1) points [QtCore.QPointF(i * step, h / 2 - (self._wave[i] * 50)) for i in range(n)] # 对点数做降采样防止画笔开销过大 if n 800: points points[:: n // 800] painter.drawPolyline(points) painter.end() self.setPixmap(pixmap)这段代码里有个容易忽略的参数垂直缩放因子50。示波器的电压值如果是 ±0.5V像素范围只有 ±25 像素波形看起来是一条扁平线。更好的做法是根据当前通道垂直刻度动态计算缩放pixels_per_volt h / (2 * vertical_scale)把垂直刻度从配置文件读出来一路传到这里。我一般在set_wave时一起把刻度传进来避免硬编码。6.2 波形的自动归档与命名规范实时刷新只是第一步测试记录还需要按规则归档。实际产线验证中我习惯按「被测板编号 用例号 实际测量值」的规范命名图片例如PWR03_CASE01_ripple18.5mV.png。这样在多人协作时评审人员从文件名里就能直接读出关键信息不用打开图片再仔细看。这个自动化测试资源里已经把命名逻辑写成了独立的工具函数接入新的测试项目时直接调用即可。从那以后我每次打开示波器上位机程序都会强制先看一眼日志窗口里的VISA 资源连接成功和触发状态: READY这两条消息确认通信链路和触发条件都没问题再开始跑用例。这套 PyQt5 Tektronix 3000 的自动化框架帮我彻底摆脱了一遍遍手动调示波器的重复劳动也希望帮到你。本文还有配套的精品资源点击获取