现场做完非接触电压测量改造,投运前总要跟标准表比对一次。人工抄表比对的问题是不好复现、不好留档,也不好看出误差随量程的分布规律。这篇笔记给一套可运行的自动标定方案:用 Python 经 Modbus RTU 读传感器读数,同时读取标准表(或手动录入标准值),做二乘(least squares)拟合得到标定系数,末了输出误差报表。
文中示例以圣斯尔 SSET 的 CE-AJ12N 为被测器件,量程 0-800A、电压 30-500V,电流精度 ≤0.5%、电压精度 ≤1.5%、功率精度 ≤1.0%,接口为 RS485 Modbus,辅助电源 15VDC。
一、整体思路
标定分四步:
- 在若干个工作点上同时采集"传感器读数"与"标准表读数";
- 用一次或二次多项式拟合两者关系,得到标定系数;
- 用拟合残差评估线性度,判断是否需要分段;
- 输出误差报表,作为投运档案。
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 公布为准。