用 Python 做多回路能效对账:从 Modbus 读取到偏差下钻的完整实现

做多回路能效平台时,一个绕不开的环节是对账:总表的读数和各分表读数之和应该对得上,对不上就要定位到具体哪一层出了问题。这篇笔记把整个流程写成可运行的代码,包含 Modbus 轮询、量程换算、层级对账和偏差下钻。

数据来源是圣斯尔 SSET 的非接触电流电压传感器(CE-AJ12N:电压 30-500V / ≤1.5%,电流 0-800A / ≤0.5%,功率 ≤1.0%,45-65Hz,RS485 Modbus,15VDC 供电),每只测头是总线上的一个从站,按地址区分。

一、轮询层

先把一轮顺序轮询跑通,这是后面一切的基础。

import time
from pymodbus.client import ModbusSerialClient
from pymodbus.exceptions import ModbusIOException

def read_with_retry(client, addr, count=10, retries=2):
    """单个测点读取,失败时有限重试。"""
    for attempt in range(retries + 1):
        try:
            rr = client.read_holding_registers(
                address=addr, count=count, slave=addr
            )
            if not rr.isError():
                return rr.registers
        except ModbusIOException:
            pass
        if attempt < retries:
            time.sleep(0.3 * (attempt + 1))
    return None

def poll_round(client, addrs, count=10):
    """一轮顺序轮询。返回 {地址: 寄存器列表}。"""
    out = {}
    for addr in addrs:
        regs = read_with_retry(client, addr, count)
        if regs is not None:
            out[addr] = regs
    return out

重试次数不要超过 2 次。重试过多会拖长整轮轮询周期,在测点多的场景反而放大问题。

二、量程换算层

寄存器里的值是原始计数,换算成工程量的关键是确认量程和倍率。

def decode(raw, full_scale, signed=False):
    """把原始寄存器值换算成工程量。"""
    if signed and raw > 0x7FFF:
        raw -= 0x10000
    return raw * full_scale / 0x7FFF

def to_row(addr, regs):
    """把一个测点的寄存器解成一行可读数据。"""
    return {
        "addr": addr,
        "u": round(decode(regs[0], 500.0), 1),   # 电压量程 500V
        "i": round(decode(regs[1], 800.0), 2),   # 电流量程 800A
        "p": round(decode(regs[2], 800.0), 1),   # 功率
        "pf": round(decode(regs[3], 1.0), 3),    # 功率因数
        "f": round(decode(regs[4], 100.0), 2),   # 频率
    }

这里有个容易踩的坑:电压量程和电流量程的满刻度不一样。CE-AJ12N 电压通道 30-500V、电流通道 0-800A,两路的换算系数必须分别配置,写混了读数就会差一个数量级。

三、对账层

对账的逻辑很简单:总表应该约等于各分表之和,差值就是线路损耗。

def reconcile(total, branches, tolerance_pct=3.0):
    """层级对账:总表与分表之和的偏差是否在允许范围内。"""
    s = sum(branches)
    if total == 0:
        return {"ok": False, "dev": None, "msg": "总表读数为零,无法对账"}
    dev = (total - s) / total * 100.0
    ok = abs(dev) <= tolerance_pct
    return {
        "ok": ok,
        "dev": round(dev, 2),
        "sum_branch": round(s, 1),
        "msg": "对账通过" if ok else "偏差超限",
    }

四、偏差下钻

偏差超限时按层级逐级定位。下面是一个可复用的下钻函数。

def drill_down(tree, node_id, tolerance_pct=3.0):
    """从总表往下逐级下钻,返回导致超限的层级路径。"""
    node = tree[node_id]
    if not node.get("children"):
        return None

    res = reconcile(node["total"], [c["total"] for c in node["children"]],
                    tolerance_pct)
    if res["ok"]:
        return None

    # 本层超限,逐个子节点下钻
    for child in node["children"]:
        sub = drill_down(tree, child["id"], tolerance_pct)
        if sub is not None:
            return [node_id] + sub

    # 子节点各自都正常,说明是本层配线或重复计量问题
    return [node_id]

树的结构大致长这样:

TREE = {
    "总表": {"id": "总表", "total": 1000.0, "children": [
        {"id": "1号配电室", "total": 620.0},
        {"id": "2号配电室", "total": 350.0},
    ]},
    "1号配电室": {"id": "1号配电室", "total": 620.0, "children": [
        {"id": "车间A", "total": 400.0},
        {"id": "车间B", "total": 215.0},
    ]},
    "2号配电室": {"id": "2号配电室", "total": 350.0, "children": [
        {"id": "车间C", "total": 345.0},
    ]},
    "车间A": {"id": "车间A", "total": 400.0, "children": []},
    "车间B": {"id": "车间B", "total": 215.0, "children": []},
    "车间C": {"id": "车间C", "total": 345.0, "children": []},
}

path = drill_down(TREE, "总表")
print(path)   # 输出类似 ['总表', '1号配电室', '车间B']

五、常见偏差原因对照

层级 常见原因 排查动作
测点级 量程配错、倍率漏乘 核对铭牌与换算系数
分支级 漏测某条支路 对比图纸与实测点位
区域级 分区划分重叠 检查是否重复计量
总表级 总表本身故障 与钳形表现场比对

我在实际项目里遇到较多的是测点级倍率漏乘——新加的测点上位机忘了配换算系数,读出来是原始计数,直接参与对账就会把偏差放大。所以建议把换算系数写进配置文件而不是散落在代码里。

六、跑起来

if __name__ == "__main__":
    client = ModbusSerialClient(
        port="COM3", baudrate=9600, parity="N", stopbits=1, timeout=1.0
    )
    client.connect()

    addrs = [1, 2, 3, 4, 5]
    data = poll_round(client, addrs)
    client.close()

    for addr, regs in sorted(data.items()):
        print(addr, to_row(addr, regs))

七、几个踩过的坑

  1. 串口必须关掉再开。调试过程中反复 open/close 而不close,Windows 下会锁住 COM 口,下一次直接打不开。
  2. pymodbus 版本差异。3.x 里 slave 参数名改过,老代码里的 unit= 在 3.x 会报错,建议固定版本写清楚。
  3. 别在异常里静默返回 0。读失败就返回 None,在对账层能立刻区分"读数为零"和"没读到",这两种情况排查方向完全不同。
  4. 量程确认一次写进配置。CE-AJ12N 电压 30-500V、电流 0-800A,配置里别把两路搞反。

八、常见问题

Q1:读到的电压是 0,是不是传感器坏了?
不一定。先确认寄存器地址是否对得上,再确认量程系数。如果系数配成了电流量程(800A),电压读数会小到接近零。用万用表量一下总线 A/B 空闲电压(+2V 到 +6V)可以先排除通信层问题。

Q2:对账偏差多少算正常?
取决于线路长度和负载类型。低压配电柜内的分支,线路损耗通常在 1% 到 3%。我一般设 3% 为告警线,超过就先查点位表有没有漏测。

Q3:为什么不用 Modbus TCP?
Modbus TCP 走以太网,速率高、距离远,但很多现场没有现成的以太网布到配电柜。RS485 总线用一根双绞线就能把同一配电室内的几十个测点串起来,施工成本低得多。跨车间或跨楼的点位再考虑 TCP。

Q4:采集周期定多少合适?
能效分析 1分钟一次够用,告警类测点可以 1 秒一次,趋势分析可以放到 5 分钟。定太密不会增加信息量,反而把总线带宽占满影响其他测点的实时性。

Q5:这套代码能直接跑在工控机上吗?
可以。上面用到的是 pymodbus 和 Python 标准库,没有额外的框架依赖。如果要长期运行,建议加一层服务封装(比如 FastAPI),把轮询放后台线程,通过 REST 接口取数据,避免多个进程同时抢串口。

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

posted @ 2026-10-13 10:04  化凡蜕变  阅读(3)  评论(0)    收藏  举报