很多上位机的报警功能是这样的:一个 DataTable 收 PLC 的报警字,变化就插一行,红条闪一下、喇叭响一声。上线一周后报警表一天两万条,90% 是同一个液位开关在阈值上抖动;操作工三天就学会了把喇叭关掉、把红条当背景。报警系统的目标不是"多报",而是该响的一条不漏,不该响的一条不吵,每条都有人确认、有记录、能分析。这篇给出一个可落地的报警引擎设计,全部附 C# 代码。

一、先分清:报警(Alarm)不是事件(Event)、更不是日志

概念特征举例处理方式
报警 Alarm有持续状态、会恢复、需要人确认、影响安全或质量反应釜超温、安全门打开、液位过低走完整生命周期:激活→确认→恢复→归档
事件 Event瞬时发生、没有持续态、用于追溯不需要处置换型完成、参数修改、用户登录、批次开始记一条流水即可,不闪不响
日志 Log给开发运维看的系统运行信息PLC重连、数据库超时、线程异常写日志文件,不进业务报警表

▲ 三类混在一张表里,结果必然是"真报警淹没在操作流水里"。存储可以同库,逻辑必须分通道:报警要状态机,事件只追加,日志走文件/Seq。

二、报警分级:五级足够,每一级对应明确的动作

一级 危急红屏+语音+联锁停机,即时电话/短信通知负责人,必须确认
二级 严重报警条+语音+钉钉@责任人,必须确认,可不停机
三级 警告黄条+提示音+群消息,需确认,记录趋势
四级 提示静默入列表,仅记录,不要求确认
五级 诊断维护态信息(传感器脏污等),只在工程师界面可见
配置项含义示例
alarmId / 点号报警唯一标识,与点表关联,不改不删TANK03_TEMP_HH
level上面五级之一,决定 HMI 表现和推送通道1
source / 触发表达式数据来源:PLC Bool、比较表达式、组合条件Temp > 95 && AutoMode
onDelay / offDelay触发延时 / 恢复延时(防抖核心,见第四节)3s / 5s
hysteresis回差带宽,防止阈值附近反复横跳2.0℃
needAck / notifyGroup是否强制确认;通知给哪个值班组true / 反应釜夜班组
interlockTag联动写入的联锁点(紧急停机等),联锁本体必须在PLC/安全继电器PLC.HALT_LINE
安全联锁永远不能放在上位机软件里。Windows 不是实时系统,上位机卡死时联锁必须照常动作。上位机最多"请求停机"并记录,真正切断动力的逻辑写在 PLC 安全程序或安全继电器里,审核时这一条是红线。

三、报警生命周期:缺了"确认"和"恢复"就是半成品

激活 ACTIVE条件持续onDelay成立
入实时表、闪条、推送
已确认 ACKED操作员签收
停闪停响,仍红色置顶
已恢复 RTN条件offDelay成立
记录恢复时间
归档 ARCHIVED恢复后移出实时列表
进历史库可分析

▲ 四种组合都是合法状态:未确认就恢复(恢复后仍挂着等确认)、确认后长期未恢复(持续置顶)、确认后恢复(正常闭环)、激活中反复抖动(下一节处理)。状态字段 + 四个时间戳(激活/确认/恢复/归档)构成完整审计链。

  • 确认是责任移交点:记录谁、在哪台机、几点确认、确认时填了什么处置备注;一级/二级报警未确认时,必须按升级策略(Escalation)逐级通知,例如 5 分钟未确认→班组长,15 分钟→车间主任;
  • 交接班拦截:存在一级未恢复报警时禁止一键交接,强制当面确认;
  • "报警屏蔽"必须有期限和留痕:检修时临时屏蔽某条报警,必须填原因、设置到期自动恢复(最长一班),屏蔽中的报警在界面显著可见——私自长期屏蔽是事故温床。

四、防抖三板斧:延时、回差、风暴合并

1)触发延时 onDelay:条件为真持续 N 秒才激活,滤掉瞬间尖峰。液位开关在注料时被波浪连打十几下,每下只持续几十毫秒,2 秒延时一次都不会报。恢复延时 offDelay 建议比触发更长,避免临界状态来回切:

原始信号: 1·1····1········1 1 1 1 1 1 1 1 1 1 1··········· 0s 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 onDelay=3s: ···················ACTIVE(持续3s才立) 恢复(回差2℃):量值先降回阈值下,再低2℃且持续5sRTN 抖动期间: 不计新报警,活动报警只累计 OccurCount + 时长

2)回差(Hysteresis):模拟量报警双阈值。高报 95℃ 激活,要降到 93℃ 才允许恢复,温度在 94.8/95.2 之间横跳时不会反复激活。阈值和回差做成参数,按传感器精度和工艺余量标定。

3)报警风暴合并:某设备真出问题时可能一秒触发几十条相关报警(电机停了,流量、压力、液位连锁全红)。引擎对活动中的同一报警只保留一条,重复触发累计 OccurCount 与最后发生时间;对同一设备/同一根因组的报警在 HMI 折叠为一组("冷却系统:7 条相关报警"),点开才展开——操作工看到的是"出了什么事",而不是一屏滚动的红色。

五、C# 报警引擎核心实现

引擎输入是采集层已经带时间戳的测点值(来自 PLC/OPC UA/串口都无所谓),输出是状态变化事件。规则配置从数据库加载,运行时不硬编码:

public enum AlarmState { Inactive, PendingOn, Active, Acked, PendingOff }
public enum AlarmLevel { Critical=1, Severe=2, Warning=3, Info=4, Diagnostic=5 }

public class AlarmRuntime
{
    public AlarmRule Rule;
    public AlarmState State;
    public DateTime? Since;          // 当前条件态起点(防抖计时用)
    public DateTime? ActiveAt, AckedAt, RtnAt;
    public int OccurCount;          // 活动期内重复触发次数(风暴合并)
    public bool Blocked;             // 检修屏蔽(有到期时间,见Rule.BlockUntil)
}

public class AlarmEngine
{
    private readonly ConcurrentDictionary<string, AlarmRuntime> _alarms = new();
    public event Action<AlarmRecord> Raised;   // 新激活:订阅方做HMI/推送/联锁请求
    public event Action<AlarmRecord> Returned;

    public void Evaluate(string point, decimal value, bool quality, DateTime ts)
    {
        foreach (var rt in _alarms.Values)
        {
            if (!rt.Rule.Watches(point) || rt.Blocked) continue;
            if (!quality) continue;                  // 坏数据不触发报警(也不复位!)

            bool cond = rt.Rule.Test(value);           // 表达式求值 + 回差由Rule内部实现
            switch (rt.State)
            {
                case AlarmState.Inactive:
                    if (cond) { rt.State = AlarmState.PendingOn; rt.Since = ts; }
                    break;

                case AlarmState.PendingOn:
                    if (!cond) rt.State = AlarmState.Inactive;          // 没撑过延时,尖峰丢弃
                    else if ((ts - rt.Since!.Value).TotalSeconds >= rt.Rule.OnDelaySec)
                    {
                        rt.State = AlarmState.Active;
                        rt.ActiveAt = ts; rt.OccurCount = 1;
                        Raised?.Invoke(rt.Snapshot());
                    }
                    break;

                case AlarmState.Active:
                case AlarmState.Acked:
                    if (cond) rt.OccurCount++;                        // 活动中:只计数不重复推送
                    else { rt.State = AlarmState.PendingOff; rt.Since = ts; }
                    break;

                case AlarmState.PendingOff:
                    if (cond) rt.State = rt.AckedAt.HasValue
                        ? AlarmState.Acked : AlarmState.Active;          // 恢复途中又变差,撤回
                    else if ((ts - rt.Since!.Value).TotalSeconds >= rt.Rule.OffDelaySec)
                    {
                        rt.State = AlarmState.Inactive; rt.RtnAt = ts;
                        Returned?.Invoke(rt.Snapshot());
                    }
                    break;
            }
        }
    }

    public void Acknowledge(string id, string user, string note)
    {
        if (!_alarms.TryGetValue(id, out var rt) ||
            rt.State is AlarmState.Inactive or AlarmState.PendingOn or AlarmState.PendingOff) return;
        rt.AckedAt = DateTime.Now;
        rt.State = AlarmState.Acked;
        _repo.SaveAck(id, user, note, rt.AckedAt.Value);   // 确认留痕
    }
}
回差放进 Rule.Test 内部实现。规则对象记住当前是"高位态"还是"低位态":高位态用 下限−回差 判断恢复,低位态用上限判断触发,天然形成两个阈值。配合 onDelay/offDelay,现场 95% 的抖动假报警都能消灭,且参数全部可调,不用改代码。

六、HMI 与多通道推送:按级别路由,别一视同仁

// 激活事件订阅方:界面刷新、语音、钉钉、短信各自独立,一个通道挂了不影响其他
engine.Raised += rec => {
    _uiThread.Post(_ => AlarmBar.Show(rec));             // WinForm切回UI线程更新报警条
    _alarmStore.InsertActive(rec);                       // 实时表先落库,程序崩了也不丢

    if (rec.Level <= AlarmLevel.Severe)
        _voice.Speak($"{rec.Line},{rec.Message}");      // 一二级语音播报,同条30s内不重复念

    if (rec.Level <= AlarmLevel.Warning)
        _notifier.DispatchAsync(rec);                    // 三级别以上走外部推送

    if (rec.Level == AlarmLevel.Critical)
        _plc.WritePoint(rec.Rule.InterlockTag, true);   // 仅"请求联锁",联锁本体在PLC
};

// 钉钉群机器人(webhook),未确认升级用同一通道@更高值班组
async Task DispatchAsync(AlarmRecord r)
{
    var group = OnCallGroup.Of(r.NotifyGroup, DateTime.Now);  // 按排班表取当前值班人
    var payload = new {
        msgtype = "markdown",
        markdown = new { title = r.Level.ToString(),
            text = $"### [{r.Level}] {r.Message}\n> 产线:{r.Line}\n> 时间:{r.ActiveAt:HH:mm:ss}\n> 值班:{group.Name} 请及时确认" },
        at = new { atMobiles = group.Mobiles, isAtAll = false }
    };
    await _http.PostJsonAsync(_webhook, payload);      // webhook密钥、限流、失败重试在此封装
}
  • 语音防轰炸:同一条报警 30 秒内不重复播报;一级报警在未确认期间按固定间隔重呼(如 60 秒一次),确认后停止;
  • 推送通道做降级:钉钉/企业微信 webhook 失败 → 短信网关;所有外部调用异步、超时 3 秒、失败写日志,绝不能阻塞报警引擎的评估循环;
  • HMI 列表分两页:活动报警(未确认优先、级别优先、时间倒序,红/黄底)与历史报警(已归档可查)。报警条始终置顶显示当前最高级别那一条,确认操作不超过两次点击。

七、存储与统计:报警表是管理改进的数据源

内容关键索引
alarm_active当前活动报警,恢复确认后迁走,保持几十行量级,界面直接读它alarmId 主键
alarm_history每条报警一生一行:激活/确认/恢复时间、确认人、备注、次数、峰值(line, activeAt)、(alarmId, activeAt)
alarm_block_log屏蔽/解除记录:谁屏蔽的、原因、到期时间操作时间

有了完整生命周期数据,管理上能直接算出:MTTA(平均确认响应时间,看班组警觉度)、MTTR(激活到恢复的平均时长,看维修效率)、Top10 高频报警(按 OccurCount 汇总,往往指向一台反复出问题的设备或一个不合理的阈值)、班次/产线报警热力分布。每周用 Top10 清单驱动设备维护,报警系统才从"喊话筒"变成"改进依据"。

八、上线前验证清单

  1. 用信号源给每个模拟量点做阈值上下穿越测试,验证 onDelay/offDelay/回差与配置一致;Bool 点做高频抖动测试(10Hz 通断 1 分钟),活动表始终只有一条、OccurCount 正确;
  2. 拔通讯线:确认数据质量变坏时既不误触发也不自动复位已有报警,恢复后状态不乱;
  3. 一级报警全链路演练:HMI 闪条→语音→钉钉@人→超时升级→联锁请求写入→确认→恢复→归档,七步全部留时间戳;
  4. 屏蔽到期自动恢复测试;跨班次未确认拦截测试;
  5. 压测:5000 条规则、200ms 全量评估周期,CPU 占用与事件写入吞吐达标;推送风暴(同时激活 200 条)下 HMI 不卡、通知合并发送。

报警引擎做扎实的标准很朴素:操作工不再关喇叭,因为一天只有真正该处理的十几条;班长能看到每条都有人签收;厂长每周拿到高频报警 Top10 去安排检修。少而准、可追溯、能闭环——这比堆再多花哨的闪烁动画都有价值。