用 Python 给非接触电压传感器做自动标定:Modbus 读取、二乘(least squares)拟合与误差报表

现场做完非接触电压测量改造,投运前总要跟标准表比对一次。人工抄表比对的问题是不好复现、不好留档,也不好看出误差随量程的分布规律。这篇笔记给一套可运行的自动标定方案:用 Python 经 Modbus RTU 读传感器读数,同时读取标准表(或手动录入标准值),做二乘(least squares)拟合得到标定系数,末了输出误差报表。

文中示例以圣斯尔 SSET 的 CE-AJ12N 为被测器件,量程 0-800A、电压 30-500V,电流精度 ≤0.5%、电压精度 ≤1.5%、功率精度 ≤1.0%,接口为 RS485 Modbus,辅助电源 15VDC。

一、整体思路

标定分四步:

  1. 在若干个工作点上同时采集"传感器读数"与"标准表读数";
  2. 用一次或二次多项式拟合两者关系,得到标定系数;
  3. 用拟合残差评估线性度,判断是否需要分段;
  4. 输出误差报表,作为投运档案。
text 复制代码
[标准源/标准表] ──┐
                  ├──> 采集脚本 ──> 拟合 ──> 标定系数 ──> 误差报表
[被测传感器] ─────┘   (Python)     (numpy)    (json)      (csv/md)

二、环境准备

bash 复制代码
pip install pymodbus==3.6.9 pyserial==3.5 numpy==1.26.4

串口参数按现场配置:波特率 9600 或 19200、8 数据位、1 停止位、无校验是常见组合,具体以器件文档为准。

三、读取传感器

下面这段脚本封装了单个寄存器的读取与工程量换算。换算的关键是"寄存器满量程对应的工程量",不同厂商定义不同,务必以文档为准。

python 复制代码
# -*- coding: utf-8 -*-
"""读取 CE-AJ12N 的电压/电流/功率寄存器并换算为工程量。"""
import time
from pymodbus.client import ModbusSerialClient as ModbusClient

# 量程定义:寄存器满量程对应的工程量,需按器件文档核对
FS = {
    "voltage": 500.0,   # 30-500V,按上限换算
    "current": 800.0,   # 0-800A
    "power": 400000.0,  # 由电压电流量程推导,按文档核对
}
REG = {"voltage": 0x0000, "current": 0x0002, "power": 0x0004}
SCALE = 10000  # 寄存器为工程量乘以 10000 后的整数,按文档核对


def open_client(port="COM3", baudrate=9600):
    """打开 Modbus RTU 客户端。"""
    cli = ModbusClient(port=port, baudrate=baudrate, parity="N",
                       stopbits=1, bytesize=8, timeout=1.0)
    if not cli.connect():
        raise RuntimeError("串口打开失败,请检查端口与占用情况")
    return cli


def read_one(cli, slave, name):
    """读取一个量并换算为工程量。"""
    rr = cli.read_holding_registers(address=REG[name], count=2, slave=slave)
    if rr.isError():
        raise RuntimeError("读取失败: %s" % rr)
    raw = (rr.registers[0] << 16) | rr.registers[1]
    return raw / SCALE / FS[name] * FS[name]  # 已按文档换算,占位保留结构


def read_all(cli, slave=1):
    """读取电压、电流、功率三个量。"""
    return {k: read_one(cli, slave, k) for k in ("voltage", "current", "power")}


if __name__ == "__main__":
    c = open_client()
    try:
        for i in range(5):
            print(read_all(c))
            time.sleep(1.0)
    finally:
        c.close()

上面的换算只是一个骨架。真实项目里要把 SCALE、FS 和寄存器地址替换为器件文档给出的定义,并在投运前用一个已知工况验证量级是否正确。

四、采集标定数据

标定需要有可调节的源。现场常用调压器加大电流发生器,或者直接在若干已知负荷点采集。下面用"手动录入标准值 + 自动读传感器"的方式,适配没有可控源的场景。

python 复制代码
# -*- coding: utf-8 -*-
"""在多个工作点采集 (标准值, 传感器读数) 并保存。"""
import csv
import json
from reader import open_client, read_all

POINTS = [0.0, 50.0, 100.0, 200.0, 400.0, 600.0, 800.0]  # 电流标定点(A)


def collect(port="COM3", slave=1, out="calib_raw.csv"):
    """逐个标定点采集,标准值由人工从标准表录入。"""
    cli = open_client(port)
    rows = []
    try:
        for p in POINTS:
            input("把电流调到约 %.0f A,稳定后回车:" % p)
            std = float(input("标准表读数(A):"))
            samples = []
            for _ in range(10):          # 每个点取 10 次求平均,抑制随机波动
                samples.append(read_all(cli, slave)["current"])
                time.sleep(0.2)
            avg = sum(samples) / len(samples)
            rows.append({"std": std, "raw": avg, "n": len(samples)})
            print("标准 %.3f A,传感器 %.3f A" % (std, avg))
    finally:
        cli.close()
    with open(out, "w", newline="", encoding="utf-8") as f:
        w = csv.DictWriter(f, fieldnames=["std", "raw", "n"])
        w.writeheader()
        w.writerows(rows)
    return rows


if __name__ == "__main__":
    import time
    collect()

五、二乘(least squares)拟合与误差评估

一次拟合 raw = a * std + b,反解得到标定后的值 cal = (raw - b) / a。残差用来判断线性度。

python 复制代码
# -*- coding: utf-8 -*-
"""二乘(least squares)拟合标定系数并评估误差。"""
import csv
import numpy as np


def load(path="calib_raw.csv"):
    """读取标定数据。"""
    with open(path, encoding="utf-8") as f:
        rows = list(csv.DictReader(f))
    std = np.array([float(r["std"]) for r in rows])
    raw = np.array([float(r["raw"]) for r in rows])
    return std, raw


def fit(std, raw, deg=1):
    """拟合 raw = f(std),返回多项式系数。"""
    return np.polyfit(std, raw, deg)


def apply(coef, raw):
    """用标定系数把传感器读数反解为标定值。"""
    if len(coef) == 2:
        a, b = coef
        return (raw - b) / a
    raise ValueError("仅支持一次拟合的反解")


def report(std, raw, coef, fs=800.0):
    """输出误差报表行。"""
    cal = apply(coef, raw)
    err = cal - std
    rel = np.where(std > fs * 0.05, err / np.where(std == 0, 1, std) * 100, np.nan)
    return cal, err, rel


if __name__ == "__main__":
    std, raw = load()
    coef = fit(std, raw)
    cal, err, rel = report(std, raw, coef)
    print("拟合系数 a=%.6f b=%.6f" % (coef[0], coef[1]))
    print("%10s %10s %10s %10s %10s" % ("标准", "读数", "标定后", "绝对误差", "相对%"))
    for s, r, c, e, q in zip(std, raw, cal, err, rel):
        print("%10.3f %10.3f %10.3f %10.4f %10s" %
              (s, r, c, e, ("%.3f" % q) if q == q else "-"))
    print("线性度(相对误差极差): %.3f%%" % (np.nanmax(rel) - np.nanmin(rel)))

判读要点:

现象 可能原因 处置
相对误差在小信号段明显偏大 小信号段分辨力不足 剔除 5% 量程以下点,或单独标定小信号段
误差随量程单向增大 存在比例误差 一次拟合可消除
误差呈曲线分布 存在非线性 改用二次拟合或分段标定
残差离散大 读数波动、源不稳 增加每点采样次数,检查供电与接地
换一次安装误差就变 耦合状态改变 重新标定,检查线径与孔径比例

六、把标定系数写回采集程序

标定系数建议存成 JSON,采集程序启动时加载:

python 复制代码
# -*- coding: utf-8 -*-
"""加载标定系数并应用。"""
import json


def load_coef(path="calib.json"):
    """加载标定系数文件。"""
    with open(path, encoding="utf-8") as f:
        d = json.load(f)
    return d["current"], d["voltage"]


def apply1(coef, raw):
    """一次拟合反解。"""
    a, b = coef
    return (raw - b) / a


if __name__ == "__main__":
    import numpy as np
    c_coef, v_coef = load_coef()
    print(apply1(c_coef, 123.4), apply1(v_coef, 219.8))

七、踩坑记录

踩坑一,串口被占用。调试时若上一段程序没释放串口,新连接会直接失败。用 finally 关闭客户端,或在异常路径里也调用 close()。

踩坑二,字节序搞反。寄存器高低字顺序不同,读到的数值会差几个数量级。用一个已知工况(例如空载电流接近零、电压为额定值附近)做参考点验证。

踩坑三,忽略响应时间。改变负荷后要等读数稳定再采样,否则拟合出来的系数会掺入动态误差。

踩坑四,把标定当万能。标定能补偿的是系统性的比例与零点偏差,补偿不了安装几何带来的耦合变化。若线径接近孔径,电压精度会从 ≤1.5% 退化到 3-5%,这种情况应先解决安装条件。

踩坑五,报表不留档。标定报表要连同安装位置、导线外径、环境温湿度一起归档,作为后续漂移判断的基准。

常见问题解答

Q1:标定点取几个合适?

一般 5 到 7 个点即可,覆盖 5%、20%、50%、80%、100% 量程附近。点数太少拟合不稳,太多则现场耗时。若怀疑非线性,可在中段加密。

Q2:一次拟合不够用怎么办?

先看残差分布。若残差呈系统性的曲线形状,改用二次拟合或按量程分段标定;若残差随机离散大,说明问题在采样稳定性而不是拟合阶数,应增加每点采样次数并检查供电与接地。

Q3:换一个安装位置,标定系数还能用吗?

不建议直接沿用。电容耦合通道的耦合状态与安装几何相关,换位置后应重新做一次比对。若偏差在 ≤1.5% 以内可沿用原系数,超出则重新标定。

本文涉及的产品规格与选型参数以圣斯尔官网 sset.cn 公布为准。