很多上位机的报警功能是这样的:一个 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 |
三、报警生命周期:缺了"确认"和"恢复"就是半成品
入实时表、闪条、推送
停闪停响,仍红色置顶
记录恢复时间
进历史库可分析
▲ 四种组合都是合法状态:未确认就恢复(恢复后仍挂着等确认)、确认后长期未恢复(持续置顶)、确认后恢复(正常闭环)、激活中反复抖动(下一节处理)。状态字段 + 四个时间戳(激活/确认/恢复/归档)构成完整审计链。
- 确认是责任移交点:记录谁、在哪台机、几点确认、确认时填了什么处置备注;一级/二级报警未确认时,必须按升级策略(Escalation)逐级通知,例如 5 分钟未确认→班组长,15 分钟→车间主任;
- 交接班拦截:存在一级未恢复报警时禁止一键交接,强制当面确认;
- "报警屏蔽"必须有期限和留痕:检修时临时屏蔽某条报警,必须填原因、设置到期自动恢复(最长一班),屏蔽中的报警在界面显著可见——私自长期屏蔽是事故温床。
四、防抖三板斧:延时、回差、风暴合并
1)触发延时 onDelay:条件为真持续 N 秒才激活,滤掉瞬间尖峰。液位开关在注料时被波浪连打十几下,每下只持续几十毫秒,2 秒延时一次都不会报。恢复延时 offDelay 建议比触发更长,避免临界状态来回切:
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); // 确认留痕 } }
六、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 清单驱动设备维护,报警系统才从"喊话筒"变成"改进依据"。
八、上线前验证清单
- 用信号源给每个模拟量点做阈值上下穿越测试,验证 onDelay/offDelay/回差与配置一致;Bool 点做高频抖动测试(10Hz 通断 1 分钟),活动表始终只有一条、OccurCount 正确;
- 拔通讯线:确认数据质量变坏时既不误触发也不自动复位已有报警,恢复后状态不乱;
- 一级报警全链路演练:HMI 闪条→语音→钉钉@人→超时升级→联锁请求写入→确认→恢复→归档,七步全部留时间戳;
- 屏蔽到期自动恢复测试;跨班次未确认拦截测试;
- 压测:5000 条规则、200ms 全量评估周期,CPU 占用与事件写入吞吐达标;推送风暴(同时激活 200 条)下 HMI 不卡、通知合并发送。
报警引擎做扎实的标准很朴素:操作工不再关喇叭,因为一天只有真正该处理的十几条;班长能看到每条都有人签收;厂长每周拿到高频报警 Top10 去安排检修。少而准、可追溯、能闭环——这比堆再多花哨的闪烁动画都有价值。
