无线时代

使用AI创建射频功放自动化测试程序

无线杂谈  ·  

随着公司射频功放类产品出货量的不断增长,射频功放的自动化测试需求也变得越来越强烈。然而,我们是一家专注与硬件产品研发的公司,软件方面只是略懂皮毛。大家都知道AI越来越火,在生活中用得越来越多,抱着试试看的心态,我使用AI创建这套射频功放自动化测试程序,结果还真就不错。

一开始我只是对AI提出一个简单的需求:

使用N9020B和N5182B对射频功放进行自动化测试,测试指标包括功率、ACP与增益,需要扫频,生成图表,支持一键测试,具备图形界面,使用Python实现。

我知道Python是当前特别流行的编程语言,做图形界面也比较容易,所以就使用Python。结果总是报出各种各样的错误,难以调试。为了快速定位问题,我采用了分步测试的思路,先让AI生成可以操控N5182B的程序,支持的功能包括设置仪器地址,选择波形文件(波形文件已使用Signal Studio软件生成并存放于N5182B中),设置工作频率,设置输出信号大小。我花了半天的时间,总算搞定,最耗时间的环节就是控制N5182B选择波形文件。随后我又让AI生成可以操控N9020B的程序,包括设置仪器地址,切换工作模式,设置工作带宽,设置工作频率等,花了大概3小时的时间,最终搞定,其中最耗时间的环节是控制N9020B设置工作带宽。

最终,结合N5182B和N9020B的分步调试结果,我对AI提出了更为准确的需求,如下:

我有一台N5182B和一台N9020B,我需要使用这两台仪器对射频功率放大器进行自动化测试。

N5182B的VISA地址是TCPIP0::192.168.2.70::hislip0::INSTR,可以通过:SOURce:RADio:ARB:WAVeform "LTE_FDD_10MHZ.WFM"命令加载仪器内部的波形,已经验证过的N5182B SCPI命令包括:

*RST

:SOURce:RADio:ARB:STATe OFF

:SOURce:RADio:ARB:WAVeform "LTE_FDD_10MHZ.WFM"

:FREQuency 2.14GHz

:POWer -10dBm

:SOURce:RADio:ARB:STATe ON

:OUTPut:STATe ON

N9020B的VISA地址是TCPIP0::192.168.2.71::hislip0::INSTR,可以通过:MMEMory:LOAD:STATe "D:\Users\Instrument\Documents\LTEAFDD\state\LTE_FDD_10MHz.state"设置其工作状态,已知:FETCh:ACP?命令可以获取载波功率与ACP功率,但是原始返回值包含很多无用信息,返回值第2,4个值是主载波功率,第5,7分别是上下临道功率,第9和第11个分别是上隔道功率和下隔道功率。

现在需要一套使用python+tkinter实现的图形化测试程序,可以同时设置N5182B与N9020B的工作频率与工作带宽,支持设定测试频率的起始范围,步进频率,支持设定N5182B的输出功率,然后读取射频功放输出的主信道功率及上下临道功率并在界面上显示出来,图形界面上可以通过图表显示不同频率下射频功放的输出功率,ACP及增益。界面上需要支持5/10/20MHz带宽,N5182B设置信道带宽是通过加载不同的波形文件实现的,N9020B设置不同的带宽是通过加载state文件实现的。请生成完整代码。

在以上输入的情况下,AI生成测试程序已经相当好,但是还有一些小问题,比如N5182B与N9020B的频率同步,支持设定馈线损耗,美化图形界面等,只要在原有基础上提出新的需求,AI总能给出比较满意的修改。AI工具我试过元宝、豆包及ChatGPT,最终还是ChatGPT的效果最好,元宝和豆包生成的程序总是有各种各样的问题,此外,ChatGPT总是能在我给出的需求基础之上给出一个特别好的建议,比如增加测试进度条,增加测试数据导出的特别实用的功能,而元宝和豆包则没有,国产AI仍需继续努力。记得曾经看过一个笑话:有3个程序员在聊天,程序员A说他使用的编程语言是C语言,程序员B使用的是Python,而程序员C使用的是ChatGPT。此前并不觉得AI对于硬件产品研发有多大帮助,通过这一次尝试,我终于领教到了AI的强大,我们要在工作中拥抱AI了,也许我也会成为那个使用ChatGPT编程的人^_^。

最后,我给出由ChatGPT生成的最终的自动化测试程序,供读者参考。

import tkinter as tk
from tkinter import ttk, messagebox, scrolledtext, filedialog
import pyvisa
import csv
import time
import threading
import os
import matplotlib.pyplot as plt
from matplotlib.backends.backend_tkagg import FigureCanvasTkAgg
from matplotlib.ticker import MultipleLocator


class PATestApp(tk.Tk):
    def __init__(self):
        super().__init__()
        self.title("PA Auto Test - N5182B + N9020B")
        self.geometry("1180x880")
        self.configure(bg="#f4f4f4")

        self.rm = None
        self.src = None
        self.spec = None
        self.is_connected = False
        self.stop_test = False
        self.results = []  # (freq, main, left, right, gain)
        self.serial_number = tk.StringVar()
        self.feed_loss = tk.DoubleVar(value=0.0)

        self.create_ui()

    # ---------------- UI ---------------- #
    def create_ui(self):
        style = ttk.Style()
        # 使用更现代的 Segoe UI 字体
        style.configure("TButton", font=("Segoe UI", 10))
        style.configure("TLabel", font=("Segoe UI", 10))
        style.configure("TLabelframe", font=("Segoe UI", 11, "bold"))

        # --- Instrument Frame ---
        inst_frame = ttk.LabelFrame(self, text="Instrument Connection", padding=10)
        inst_frame.pack(fill="x", padx=10, pady=5)

        ttk.Label(inst_frame, text="N5182B Address:").grid(row=0, column=0, sticky="e", padx=3, pady=2)
        self.src_addr = ttk.Entry(inst_frame, width=45)
        self.src_addr.insert(0, "TCPIP0::192.168.2.70::hislip0::INSTR")
        self.src_addr.grid(row=0, column=1, padx=5, pady=2)

        ttk.Label(inst_frame, text="N9020B Address:").grid(row=1, column=0, sticky="e", padx=3, pady=2)
        self.spec_addr = ttk.Entry(inst_frame, width=45)
        self.spec_addr.insert(0, "TCPIP0::192.168.2.71::hislip0::INSTR")
        self.spec_addr.grid(row=1, column=1, padx=5, pady=2)

        connect_frame = tk.Frame(inst_frame, bg="#f4f4f4")
        connect_frame.grid(row=0, column=2, rowspan=2, padx=10, sticky="w")
        self.connect_btn = ttk.Button(connect_frame, text="Connect", command=self.connect_instruments, width=12)
        self.connect_btn.pack(side="left", padx=(0, 10))
        ttk.Label(connect_frame, text="Status:", background="#f4f4f4").pack(side="left")
        self.status_light = tk.Canvas(connect_frame, width=20, height=20, highlightthickness=0, bg="#f4f4f4")
        self.status_light.pack(side="left")
        self.set_status_light("red")

        # --- Serial Number Frame ---
        sn_frame = ttk.LabelFrame(self, text="Product Information", padding=10)
        sn_frame.pack(fill="x", padx=10, pady=5)
        ttk.Label(sn_frame, text="Product Serial No. (13 digits):").grid(row=0, column=0, padx=5, pady=5, sticky="e")
        self.sn_entry = ttk.Entry(sn_frame, textvariable=self.serial_number, width=20)
        self.sn_entry.grid(row=0, column=1, padx=5, pady=5)

        # --- Test Config Frame ---
        cfg_frame = ttk.LabelFrame(self, text="Test Configuration", padding=10)
        cfg_frame.pack(fill="x", padx=10, pady=5)

        ttk.Label(cfg_frame, text="Start Freq (MHz):").grid(row=0, column=0)
        self.start_freq = ttk.Entry(cfg_frame, width=10)
        self.start_freq.insert(0, "2140")
        self.start_freq.grid(row=0, column=1)

        ttk.Label(cfg_frame, text="Stop Freq (MHz):").grid(row=0, column=2)
        self.stop_freq = ttk.Entry(cfg_frame, width=10)
        self.stop_freq.insert(0, "2160")
        self.stop_freq.grid(row=0, column=3)

        ttk.Label(cfg_frame, text="Step (MHz):").grid(row=0, column=4)
        self.step_freq = ttk.Entry(cfg_frame, width=10)
        self.step_freq.insert(0, "5")
        self.step_freq.grid(row=0, column=5)

        ttk.Label(cfg_frame, text="Power (dBm):").grid(row=0, column=6)
        self.power_entry = ttk.Entry(cfg_frame, width=10)
        self.power_entry.insert(0, "-10")
        self.power_entry.grid(row=0, column=7)

        ttk.Label(cfg_frame, text="Bandwidth:").grid(row=0, column=8)
        self.bw_var = tk.IntVar(value=10)
        ttk.Combobox(cfg_frame, textvariable=self.bw_var, values=[5, 10, 20],
                     width=8, state="readonly").grid(row=0, column=9, padx=5)

        # --- Feedline Loss ---
        ttk.Label(cfg_frame, text="Feedline Loss (dB):").grid(row=0, column=10)
        self.feed_loss_entry = ttk.Entry(cfg_frame, textvariable=self.feed_loss, width=8)
        self.feed_loss_entry.grid(row=0, column=11, padx=5)

        # --- Buttons ---
        btn_frame = tk.Frame(cfg_frame, bg="#f4f4f4")
        btn_frame.grid(row=1, column=0, columnspan=12, pady=8)
        ttk.Button(btn_frame, text="Start Test", command=self.start_test_thread, width=14).pack(side="left", padx=8)
        ttk.Button(btn_frame, text="Stop", command=self.stop, width=12).pack(side="left", padx=8)
        ttk.Button(btn_frame, text="Export CSV", command=self.export_csv, width=14).pack(side="left", padx=8)

        # --- Progress Bar ---
        self.progress = ttk.Progressbar(self, length=950, mode="determinate")
        self.progress.pack(pady=5)

        # --- Plot Frame ---
        plot_frame = ttk.LabelFrame(self, text="Measurement Curves", padding=10)
        plot_frame.pack(fill="both", expand=True, padx=10, pady=5)
        self.fig, self.ax = plt.subplots(figsize=(9.5, 4))
        self.ax2 = self.ax.twinx()
        self.ax.grid(True, linestyle="--", alpha=0.6)
        self.canvas = FigureCanvasTkAgg(self.fig, master=plot_frame)
        self.canvas.get_tk_widget().pack(fill="both", expand=True)

        # --- Log Frame ---
        log_frame = ttk.LabelFrame(self, text="Log Output", padding=10)
        log_frame.pack(fill="both", expand=True, padx=10, pady=5)
        # 使用 Consolas 等宽字体
        self.log = scrolledtext.ScrolledText(log_frame, height=14, font=("Consolas", 10), wrap=tk.WORD)
        self.log.pack(fill="both", expand=True)

    # ---------------- Core Functions ---------------- #
    def set_status_light(self, color):
        self.status_light.delete("all")
        self.status_light.create_oval(3, 3, 17, 17, fill=color, outline=color)

    def log_msg(self, msg):
        self.log.insert(tk.END, f"[{time.strftime('%H:%M:%S')}] {msg}\n")
        self.log.see(tk.END)
        self.update()

    def connect_instruments(self):
        try:
            self.rm = pyvisa.ResourceManager()
            self.src = self.rm.open_resource(self.src_addr.get())
            self.spec = self.rm.open_resource(self.spec_addr.get())
            self.is_connected = True
            self.set_status_light("green")
            self.log_msg("✅ Instruments connected successfully.")
        except Exception as e:
            self.is_connected = False
            self.set_status_light("red")
            messagebox.showerror("Connection Error", str(e))
            self.log_msg(f"❌ Connection failed: {e}")

    def configure_instruments(self, freq_mhz, power_dbm, bw):
        wf = {5: "LTE_FDD_5MHz.WFM", 10: "LTE_FDD_10MHz.WFM", 20: "LTE_FDD_20MHz.WFM"}[bw]
        state = f'D:\\Users\\Instrument\\Documents\\LTEAFDD\\state\\LTE_FDD_{bw}MHz.state'
        self.src.write(":SOURce:RADio:ARB:STATe OFF")
        self.src.write(f':SOURce:RADio:ARB:WAVeform "{wf}"')
        self.src.write(":SOURce:RADio:ARB:STATe ON")
        self.src.write(f":FREQuency {freq_mhz}MHz")
        self.src.write(f":POWer {power_dbm}dBm")
        self.src.write(":OUTPut:STATe ON")
        self.spec.write(f':MMEMory:LOAD:STATe "{state}"')
        time.sleep(0.3)
        self.spec.write(f":FREQuency:CENTer {freq_mhz}MHz")
        self.spec.write(f":FREQuency:SPAN {bw * 2}MHz")
        self.spec.write(":INITiate:IMM")

    def fetch_measurement(self):
        raw = self.spec.query(":FETCh:ACP?")
        vals = [float(x) for x in raw.split(",") if x.strip()]
        main_pwr = vals[1]
        acp_l = vals[4]
        acp_r = vals[6]
        return main_pwr, acp_l, acp_r

    def start_test_thread(self):
        threading.Thread(target=self.run_test, daemon=True).start()

    def run_test(self):
        sn = self.serial_number.get().strip()
        if not sn.isdigit() or len(sn) != 13:
            messagebox.showerror("Invalid Serial Number", "Please enter a valid 13-digit product serial number.")
            return

        if not self.is_connected:
            messagebox.showwarning("Warning", "Please connect instruments first!")
            return

        start = float(self.start_freq.get())
        stop = float(self.stop_freq.get())
        step = float(self.step_freq.get())
        power = float(self.power_entry.get())
        bw = self.bw_var.get()
        feed_loss = float(self.feed_loss.get())

        freqs = list(self.frange(start, stop, step))
        self.results.clear()
        self.progress["value"] = 0
        self.progress["maximum"] = len(freqs)
        self.ax.clear()
        self.ax2.clear()
        self.log_msg(f"🚀 Test started for SN: {sn} (Feedline Loss = {feed_loss:.2f} dB)")

        for i, f in enumerate(freqs):
            if self.stop_test:
                self.log_msg("⛔ Test stopped by user.")
                break
            self.configure_instruments(f, power, bw)
            time.sleep(0.5)
            main, left, right = self.fetch_measurement()

            main_corr = main + feed_loss
            gain_corr = main_corr - power

            self.results.append((f, main_corr, left, right, gain_corr))
            self.log_msg(f"{f:.1f} MHz → Main: {main_corr:.2f} dBm, ACP(L): {left:.2f}, ACP(R): {right:.2f}, Gain: {gain_corr:.2f} dB")
            self.update_plot()
            self.progress["value"] = i + 1
            self.update()

        self.auto_save_csv(sn, feed_loss)
        self.log_msg("✅ Test finished and results saved.")

    def update_plot(self):
        if not self.results:
            return
        freqs, main, left, right, gain = zip(*self.results)
        self.ax.clear()
        self.ax2.clear()
        self.ax.plot(freqs, main, "o-", label="Main Channel", linewidth=1.5)
        self.ax.plot(freqs, left, "o--", label="ACP Left", linewidth=1.2)
        self.ax.plot(freqs, right, "o--", label="ACP Right", linewidth=1.2)
        self.ax.set_xlabel("Frequency (MHz)", fontsize=10)
        self.ax.set_ylabel("Power (dBm)", fontsize=10, color="black")
        self.ax.tick_params(axis='y', labelcolor="black")
        self.ax.set_title("PA Output Power and Gain vs Frequency", fontsize=12)
        self.ax.legend(loc="upper left")
        self.ax.grid(True, linestyle="--", alpha=0.6)
        self.ax2.plot(freqs, gain, "s-", color="purple", label="Gain (dB)", linewidth=1.6)
        self.ax2.set_ylabel("Gain (dB)", fontsize=10, color="purple")
        self.ax2.tick_params(axis='y', labelcolor="purple")
        min_gain = int(min(gain) // 10) * 10
        max_gain = int(max(gain) // 10 + 1) * 10
        self.ax2.set_ylim(min_gain, max_gain)
        self.ax2.yaxis.set_major_locator(MultipleLocator(10))
        self.ax2.legend(loc="upper right")
        self.canvas.draw()

    def export_csv(self):
        if not self.results:
            messagebox.showinfo("Info", "No data to export.")
            return
        filename = filedialog.asksaveasfilename(defaultextension=".csv", filetypes=[("CSV Files", "*.csv")])
        if not filename:
            return
        sn = self.serial_number.get().strip() or "Unknown"
        feed_loss = float(self.feed_loss.get())
        with open(filename, "w", newline="") as f:
            writer = csv.writer(f)
            writer.writerow(["Product SN:", sn])
            writer.writerow(["Feedline Loss (dB):", feed_loss])
            writer.writerow(["Freq(MHz)", "Main(dBm, Compensated)", "ACP_Left", "ACP_Right", "Gain(dB, Compensated)"])
            writer.writerows(self.results)
        self.log_msg(f"💾 Results exported to: {filename}")

    def auto_save_csv(self, sn, feed_loss):
        os.makedirs("results", exist_ok=True)
        filename = f"results/{sn}.csv"
        with open(filename, "w", newline="") as f:
            writer = csv.writer(f)
            writer.writerow(["Product SN:", sn])
            writer.writerow(["Feedline Loss (dB):", feed_loss])
            writer.writerow(["Freq(MHz)", "Main(dBm, Compensated)", "ACP_Left", "ACP_Right", "Gain(dB, Compensated)"])
            writer.writerows(self.results)
        self.log_msg(f"📁 Auto-saved test data to: {filename}")

    def stop(self):
        self.stop_test = True

    @staticmethod
    def frange(start, stop, step):
        while start <= stop:
            yield start
            start += step


if __name__ == "__main__":
    app = PATestApp()
    app.mainloop()

# # # #