SCADA 到底解决什么问题

SCADA 是 Supervisory Control And Data Acquisition 的缩写,直译是“监视控制与数据采集”。它面对的是这样一个场景:设备分散在很大的空间里——可能是一个水厂的几公里管网、一个变电站的多个间隔、一条几公里长的输送线——而操作人员需要在一个房间里同时看到全部状态,并且能在必要时下指令。SCADA 就是把这件“看不过来、管不过来”的事情系统化。

SCADA 的本质是解决“距离”问题:把分散在现场的状态集中到一个屏幕上,把集中下达的指令可靠地送到现场。

它与 PLC 的分工非常清晰:PLC 负责毫秒级的现场联锁与动作,SCADA 负责秒级的监视、记录与调度。把安全联锁放在 SCADA 里实现是一个典型的错误设计——上位机软件的重启、网络抖动、操作系统更新都会让联锁失效,而这类失效往往发生在最不该发生的时候。

四层架构与各层职责

一个可维护的 SCADA 系统,结构上通常分为四层。分层不是为了好看,而是为了把故障限制在单层内:采集层的通信问题不应该让监控层的操作界面卡死,历史库的磁盘写满不应该影响实时数据的刷新。

四层职责
  • 现场层仪表、传感器、PLC、RTU,负责数据的原始产生与本地联锁,是安全的最后一道防线。
  • 采集层协议驱动与采集任务,负责把现场数据变成带时间戳与质量码的标准记录,是整条链路里最需要工程化的一层。
  • 监控层实时数据库、报警引擎、画面组态与人机交互,负责“让人看得见、点得动”。
  • 历史与展示层历史库、报表、趋势分析与对外接口,负责“事后查得到、对外说得清”。

实时数据库是监控层的心脏。它把当前所有测点的值放在内存里,读写延迟在微秒级,因此画面刷新、报警判断、趋势初始化都从它取值,而不是每次都去问设备。历史库则是另一套思路:它要处理的是海量写入与范围查询,与实时库通过“变化推送”的方式衔接。

采集层设计:点表是唯一的合同

采集层的工作看似简单——定时把寄存器读回来——但它是 SCADA 项目里返工最多的地方。原因几乎总是同一个:点表没有成为唯一的事实来源。工程师在 PLC 程序里改了地址,没有同步到上位机点表;工艺专业加了一个测点,只在邮件里提了一句。最终的结果是画面上的数值长期显示为默认值,而没有人发现。

监控大屏上折线曲线、环形图表与柱状图组合的数据可视化插画
监控层的每一根曲线、每一个数字,背后都对应点表里的一行。点表与实际配置脱节,屏幕上华丽的图表就会变成一副安静的假象。

因此点表必须被当作合同来管理,至少包含这些字段,并且每个字段都有明确的责任人:

  • 标识:位号、中文描述、所属工艺段、所在画面。
  • 来源:设备名、通信协议、从站地址、寄存器地址、数据类型、字节序。
  • 换算:原始量程、工程量程、单位、线性或分段映射关系。
  • 采集:采集周期分组(快组、慢组、事件组)、是否存历史、是否参与报警。
  • 报警:阈值、级别、死区、去抖时间、是否需要确认。

把这五组字段一次性在 Excel 里定清楚,导入配置由脚本完成,可以避免大量手工录入错误。点表版本也要纳入配置管理——它比代码更容易被随意修改,因而更危险。

分组轮询:让采集任务按时钟表走

初学者最常写出的采集程序是“一个循环里把所有点都读一遍,然后 sleep”。点位数一多,循环耗时超过预期,实际采集周期就被拉长,快变量的曲线开始出现锯齿,报警响应变慢。正确的做法是分组:把变化快的信号放进短周期任务,把温度、液位这类慢变量放进长周期任务,各自独立计时。

import time
from pymodbus.client import ModbusTcpClient

# 按变化速度分组:快组 200ms,慢组 2000ms
TASKS = [
    {"name": "fast", "period": 0.2, "slave": 1, "addr": 0,   "count": 16},
    {"name": "slow", "period": 2.0, "slave": 1, "addr": 100, "count": 32},
]

def poll_group(client, task, sink):
    rr = client.read_holding_registers(
        address=task["addr"], count=task["count"], slave=task["slave"]
    )
    if not rr.isError():
        # 记录采集时刻,供上层计算数据龄期
        sink[task["name"]] = (time.time(), rr.registers)

def run():
    client = ModbusTcpClient("192.168.1.10", port=502, timeout=1)
    client.connect()
    sink = {}
    last = {t["name"]: 0.0 for t in TASKS}

    while True:
        now = time.time()
        for task in TASKS:
            if now - last[task["name"]] >= task["period"]:
                poll_group(client, task, sink)
                last[task["name"]] = now
        time.sleep(0.05)   # 调度心跳,避免空转占满 CPU

if __name__ == "__main__":
    run()

这段代码还有两处可以继续强化。一是把采集任务按从站分组,用不同的轮询线程并行执行,避免某一个响应慢的从站拖住整条总线;二是让每次采集都记录耗时与失败次数,作为通信质量的长期指标——当某个从站的失败率从 0.1% 升到 3% 时,往往意味着接线端子氧化或者电磁干扰变强,这时候提前处理比等到彻底断线要好得多。

历史库与死区压缩

历史数据的体量很容易被低估。假设有 2000 个测点,每个点每秒存一条,一天就是 1.7 亿条记录,一年超过 600 亿条。绝大多数存储压力其实来自“没有变化的数据”——一个室温测点在整夜可能只在极小的范围内波动,却仍然占用了完整的存储与索引开销。死区压缩就是针对这一点:只有变化超过阈值、或者距上次落库超过心跳间隔时才写入。

class DeadbandWriter:
    """按死区 + 心跳压缩写入:变化超阈值或距上次落库过久才记录"""

    def __init__(self, deadband=0.5, heartbeat=300):
        self.deadband = deadband
        self.heartbeat = heartbeat
        self.last = {}

    def should_write(self, tag, value, now):
        prev = self.last.get(tag)
        if prev is None:                                  # 首次采集
            self.last[tag] = (value, now)
            return True
        if abs(value - prev[0]) >= self.deadband:         # 超死区
            self.last[tag] = (value, now)
            return True
        if now - prev[1] >= self.heartbeat:               # 心跳点,保证趋势不断线
            self.last[tag] = (value, now)
            return True
        return False

w = DeadbandWriter(deadband=0.5)
for t, v in [(0, 25.0), (10, 25.2), (20, 26.1), (30, 25.9), (400, 25.9)]:
    action = "写入" if w.should_write("temp1", v, t) else "跳过"
    print(f"t={t:>4}s  value={v:>5.1f}  {action}")

运行这段代码,五个采样点里只有三个真正落库:25.2 因为变化只有 0.2 被跳过,最后一次 25.9 因为距上次写入 370 秒超过心跳间隔而补写。压缩比通常在 10 倍以上,而且趋势曲线的形状几乎不受影响。真正需要精确保留的是报警事件与操作记录,它们不属于压缩范围。