给扫频仪写个上位机:从串口数据到一条能用的曲线

bughei 发布于 3 小时前 45 次阅读


最近把手头的 NWT 扫频上位机重新整理了一遍,更新到了 v1.1.0,也放到了 GitHub。

软件用 Python 写,界面是 Tkinter,绘图用 Matplotlib,串口通信交给 pyserial。现在可以连接仪器、进行扫频、比较多条曲线、寻找谐振谷点,以及保存实验记录。

趁着这次整理,记录一下它背后的实现。界面上看起来只是点一下“开始扫描”,实际要经过参数转换、串口通信、数据解析和曲线分析,最后才得到屏幕上的那条线。

扫频到底在看什么

扫频就是让仪器在一段频率范围内逐步改变输出频率,同时记录每个频率下的响应。

把频率放在横轴,响应幅度放在纵轴,就得到了一条幅频曲线。对于带有谐振特征的器件,曲线上可能出现明显的峰或谷;具体长什么样,和器件、接线方式以及耦合条件都有关系。

拿理想 LC 回路举例,它的谐振频率可以写成:

f₀ = 1 / (2π√LC)

电感或电容发生变化,谐振位置就会跟着移动。因此,扫频曲线除了能用来观察频率响应,也可以帮助比较器件在不同条件下的变化。

不过,一条曲线里还有线缆、接口、耦合和测量环境的影响。分析的时候,需要把这些因素一起考虑进去。

先看看官方软件到底发了什么

最开始写这个上位机的时候,我还不知道仪器的命令结构。串口能打开,但要发什么才能开始扫描、返回的数据又该怎么读,这些都需要先弄清楚。

于是我找来了官方的调用程序,用 com0com 搭建虚拟串口,再写了一个数据采集脚本,观察官方程序和串口之间交换的数据。

com0com 提供了一对相互连通的虚拟串口:官方程序连接其中一端,脚本在另一端接收。这样,原本直接发给仪器的字节,就可以拿出来看了。平时点击“开始扫描”只会看到曲线,这时就能进一步看到按钮背后发送的命令。

接下来要做的,就是把这些字节和界面里的参数对应起来:哪些部分表示起始频率,哪些表示步长和点数,频率有没有倍率转换;返回数据中的字节又怎样组合成一个读数。

通过这种方式,我逐渐弄清楚了发送命令的结构和结果的解析方法,再把它们写进自己的串口驱动里。

# 官方程序连接虚拟串口的一端,采集脚本连接另一端
port = open_serial("虚拟串口另一端")

while capturing:
    data = port.read_available()

    if data:
        save_log(
            time=current_time(),
            hex_data=data.hex()
        )

# 改变官方程序中的扫描参数,重复采集
# 比较不同记录,定位起始频率、步长、点数等字段

电脑负责安排,仪器负责扫描

程序主要分成两个文件:

  • main.py:界面、绘图、分析和实验记录。
  • nwt_driver.py:串口通信、扫频命令和数据接收。

这样拆开之后,界面布局可以单独调整,通信协议也比较容易检查。

当前扫频走的是硬件扫描:电脑把起始频率、步长和点数一次发给仪器,仪器执行扫描,再通过串口返回数据。

命令的大致结构是:

0x8F + x + 9位起始频率 + 8位步长 + 4位点数

这里容易出问题的是频率单位和倍率。界面里的数值、程序内部的 Hz,以及仪器命令里的整数,需要对应起来。

例如,从 100 MHz 扫到 200 MHz,设置 1001 个点,名义步长就是:

(200 MHz − 100 MHz) / (1001 − 1) = 100 kHz

当前代码对倍率为 10 的设备,会把起始频率和步长都除以 10,再写入命令。所以这个例子的起始频率字段对应 10000000,步长字段对应 10000。

# start_hz、stop_hz:起止频率,单位 Hz
# points:扫描点数;multiplier:设备频率倍率
# 此处假设输入参数合法

start_field = int(start_hz / multiplier)

step_field = int(
    (stop_hz - start_hz)
    / (points - 1)
    / multiplier
)

# 按协议规定的固定宽度补零
command = (
    "x"
    + decimal(start_field, width=9)
    + decimal(step_field, width=8)
    + decimal(points, width=4)
)

serial.write(b"\x8f" + command.encode("ascii"))

这种地方即使只差一个倍率,程序仍可能收到数据、画出曲线,但横轴就可能和实际扫描不一致。通信部分需要同时核对命令格式和数值含义。串口驱动源码

串口返回的字节,怎么变成曲线

当前驱动按照每个扫描点 4 个字节解析数据,其中前两个字节作为 CH1 的 ADC 读数,后两个字节对应 CH2。现在绘图使用的是 CH1。

前两个字节按低字节在前的顺序组合:

ADC = low_byte + (high_byte << 8)

随后通过线性换算得到纵轴数值:

y = ADC × scale + offset

scale 控制比例,offset 控制偏移。

虽然界面中使用了 dBm 标识,但读数是否能对应实际功率,取决于检测器和这些系数的校准。把 ADC 数值换算成一个看起来合理的范围,只完成了显示上的转换;要进行定量测量,还需要校准依据。

横轴则按照设置的起始频率、终止频率和点数重建。这里还有一个后续需要完善的细节:下发给硬件的步长会取整,而当前横轴使用的是名义步长。两者应进一步核对一致,尤其是扫描范围较窄的时候。

扫描和接收放在后台线程里进行。收到数据后,再通过 Tkinter 的 after() 把绘图更新安排到主线程,避免等待串口时把整个窗口卡住。

当前实现是在一轮数据接收结束后更新曲线,接收过程中更新进度;连续扫描则重复这个过程。界面与数据处理源码

# 后台线程:每个扫描点预计返回 4 字节
raw = bytearray()

while scanning and len(raw) < points * 4:
    chunk = serial.read(points * 4 - len(raw))

    if not chunk:
        break                 # 超时,结束本轮接收

    raw.extend(chunk)
    update_progress(len(raw) / (points * 4))

frequencies = []
values = []
step_hz = (stop_hz - start_hz) / (points - 1)

# 只解析已经收到的完整扫描点
for i in range(len(raw) // 4):
    point = raw[i * 4 : i * 4 + 4]

    adc = point[0] + (point[1] << 8)   # CH1,低字节在前
    value = adc * scale + offset

    frequencies.append((start_hz + i * step_hz) / 1e6)
    values.append(value)

# 将绘图交回 Tkinter 主线程
after(0, lambda: update_plot(frequencies, values))

谐振频率和 Q 值怎么算

目前程序处理的是谷点型曲线:先找出当前处理后曲线的最低点,把对应频率作为谐振频率估计。

这个方法比较直接,适合目标谷点明显的情况。遇到多个谷点、边缘下滑或者较大的噪声时,最低点未必就是想分析的那个谐振,所以曲线本身仍然要看。

Q 值采用下面的估算:

Q ≈ f₀ / (fᵣ − fₗ)

程序先取谷底幅度,再向上加 3 dB,从谷底向左右寻找交点,得到 fₗ 和 fᵣ。交点通常落在两个采样点之间,因此使用线性插值估计位置。

比如,谷点位于 100 MHz,两侧交点分别在 99 MHz 和 101 MHz,那么按当前算法:

Q ≈ 100 / (101 − 99) = 50

同一中心频率下,谷越窄,算出的 Q 越大。

这里记录的是按谷底上方 3 dB 宽度得到的曲线指标。它能帮助比较曲线形状,但和器件实际品质因数之间的关系,还要结合测量方式、背景和耦合条件判断。

min_index = index_of_minimum(y)
f0 = x[min_index]
target = y[min_index] + 3.0

# 从谷底向两侧寻找最近的交点
f_left = find_crossing(x, y, min_index, target, direction="left")
f_right = find_crossing(x, y, min_index, target, direction="right")

# find_crossing 找到夹住 target 的相邻两点后,
# 用下面的线性插值计算交点频率:
#
# ratio = (target - y1) / (y2 - y1)
# frequency = x1 + ratio * (x2 - x1)

if f_left is None or f_right is None or f_right <= f_left:
    Q = None
    inverse_Q = None
else:
    Q = f0 / (f_right - f_left)
    inverse_Q = 1 / Q

扣底噪和平滑,分别做了什么

在实际使用中,发现如果直接用点线作图,画出来的图十分不美观,还需要自己处理,所以便想让程序可以实现一定的数据处理效果。

软件可以把一次扫描保存为参考曲线,再从后续曲线中扣除。

如果两次扫描的频率点不一致,程序会先把参考曲线插值到当前频率轴,再逐点相减。这样就不用强行要求两次扫描的点数完全相同。

这里所谓的“扣底噪”,实际做的是纵轴数值相减。对于 dB 数据,相减对应的是对数域的相对变化;是否适合这样处理,要看参考曲线代表什么、测量条件是否一致。

平滑则使用五点窗口的滑动平均,减少相邻点之间的小幅抖动。它会让曲线更容易观察,也会改变尖锐谷点的形状。由于谷点和 Q 值分析使用处理后的曲线,开启平滑或背景扣除后,分析结果也可能变化。

原始扫描数据仍然要保留。当前单次扫描 CSV 导出使用原始曲线,实验汇总则记录处理后曲线得到的分析结果。后面还可以把处理选项一起写进汇总文件,方便回头检查当时用了哪些设置。

processed_y = copy(raw_y)

if background_enabled:
    # 将参考曲线插值到当前扫描的频率点
    aligned_background = interpolate(
        background_x, background_y, current_x
    )

    processed_y = [
        y - bg
        for y, bg in zip(processed_y, aligned_background)
    ]

if smoothing_enabled:
    source = copy(processed_y)

    for i in range(len(source)):
        # 五点窗口,边缘位置使用实际存在的点
        left = max(0, i - 2)
        right = min(len(source), i + 3)
        processed_y[i] = mean(source[left:right])

draw_curve(current_x, processed_y)
analyze_resonance(current_x, processed_y)

# 单次扫描 CSV 保留原始曲线
export_scan_csv(current_x, raw_y)

从画出曲线,到方便做实验

除了扫描本身,软件还加入了固定曲线比较和分组记录。

固定曲线可以把几次结果留在同一张图上,观察谷点有没有移动、曲线有没有变宽,比来回切换文件直观一些。

实验记录会保存组别、扫描编号、谐振频率、谷点幅度、Q 和 1/Q,最后导出汇总 CSV。这里有个具体的实现细节:连续扫描模式下,当前记录逻辑会在本次连续扫描出现更深谷点时追加记录,并不是每轮都保存一行。后面可以把记录方式做成可选项,让不同实验按需要使用。

# 每次启动连续扫描时,重置本次扫描的比较基准
session_minimum = infinity

def handle_scan(x, raw_y):
    processed_y = apply_processing(x, raw_y)
    index = index_of_minimum(processed_y)

    frequency = x[index]
    amplitude = processed_y[index]
    Q = calculate_q(x, processed_y, index)

    if not experiment_enabled:
        return

    if continuous_scan:
        if amplitude >= session_minimum:
            return            # 没有出现更深的谷,不追加记录

        session_minimum = amplitude

    append_record(
        group=current_group,
        frequency=frequency,
        amplitude=amplitude,
        Q=Q,
        inverse_Q=1 / Q if Q > 0 else None
    )

碎碎念

要做这个程序,主要是官方的程序太难用了,UI还很差,还没法算Q值,只好自己动手了。

这个程序更新跨越了一年,全程使用AI编写,也是见证了vibe coding从原来还需要自己复制粘贴到如今agent可以直接编辑文件、debug甚至上传到仓库中。

只能说有了AI之后编程门槛确实降低了不少,我这种做硬件的也可以做点小前端用于展示,但具体的代码还是纯纯史山,只能依靠AI进行修改(犹记把程序发给软院希望美化一下,结果人家看完直接跑路了)

感觉之后也不会做这个方向了,也不会继续迭代了,留作纪念吧~

全文依旧大部分由AI完成,充钱不用我不白充了吗

1
最后更新于 2026-10-03