最近把手头的 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完成,充钱不用我不白充了吗
Comments NOTHING