
14 · 历史库与报警引擎程序集:Hmi.HistorianHmi.Alarms·源码:src/Hmi.Historian/、src/Hmi.Alarms/什么时候你会需要它你要存趋势曲线数据(温度、压力随时间怎么变的)你要做报警:超限报警、开关量报警、报警确认与历史记录你要出班报/日报,查一段时间内的平均值、最大值你在排查历史数据有断点 / 报警没出来 / 报警消不掉这两块经常一起用,所以放在同一章。第一部分:历史库一句话概括历史库订阅实时库,按你配的规则把值批量写进 SQLite;查询时再按时间段取出来。实时库变化 │ Lossless 车道一条都不能丢 ▼ HistorianRecorder ──按规则判断── 该记吗 │ ├─ OnChange值变了才记 │ ├─ Timed 每 N 毫秒记一次 │ └─ Both 两者都要 ▼ SqliteHistorian攒批写入1000 条或 1 秒刷一次 │ ▼ history.dbSQLiteWAL 模式什么时候记:三种记录模式每个点单独配一条规则:public sealed class TagHistoryRule { public int TagId { get; init; } public bool Enabled { get; init; } public HistoryRecordMode Mode { get; init; } // OnChange / Timed / Both public double Deadband { get; init; } // 变化超过多少才算变化 public int TimedIntervalMs { get; init; } // Timed 模式下的记录间隔 public int RetentionDays { get; init; } // 保留多少天 }模式什么时候记适合什么点OnChange值变化时记一条开关量、状态量(变化不频繁)Timed每TimedIntervalMs记一条温度、压力这类需要连续曲线的模拟量Both两者都要既要看变化又要看连续趋势Deadband(死区)很关键:值变化小于这个幅度就不算变化。不配死区 → 传感器末位抖动会被当成变化,历史库迅速膨胀配了死区 → 只在真正有意义的变化时才记首条一定会记。哪怕配的是OnChange,启动后的第一个值也会存下来, 这样查询时至少知道从什么时候开始有数据。存哪:SQLite 单表CREATE TABLE hist ( tag_id INTEGER, -- 点的数字 Id ts_utc INTEGER, -- 时间戳UTC 毫秒 val REAL, -- 值 q INTEGER -- 质量戳 ); CREATE INDEX ... ON hist(tag_id, ts_utc);几个工程上的取舍:用 REAL 存所有数值—— 简单、够用。布尔值存 0/1,查出来自己转。时间戳存整数毫秒—— 比字符串省空间,比较快。WAL 模式—— 读写不互相阻塞(查询的时候还能写)。写和读用两条连接—— 避免长查询把写入卡住。攒批写入:为什么要攒如果每个值变化都立刻写一次磁盘,高频点(比如 200ms 一个点 × 100 个点)会把磁盘打爆。所以SqliteHistorian是攒批的:参数默认值含义BatchSize1000攒够 1000 条写一次FlushIntervalMs1000或者最多等 1 秒也写一次QueueCapacity100000队列上限,满了丢最旧的并计数⚠队列满了会丢数据。这不是静默的 ——DroppedRows会累加,还有OnDataDropped回调。发现丢数据 磁盘跟不上或者查询太慢,要处理。怎么查using Hmi.Historian; var historian new SqliteHistorian(C:\我的工程\history.db); // ① 查一段原始点 IReadOnlyListHistPoint pts historian.Query( tagId: tempId, fromUtc: DateTime.UtcNow.AddHours(-1), toUtc: DateTime.UtcNow); // ② 查聚合值出班报用 double avg historian.Aggregate(tempId, from, to, AggFunc.Avg); double max historian.Aggregate(tempId, from, to, AggFunc.Max);大数据量时用降采样:// 只要 500 个点画曲线不用把 10 万条全取回来 var pts historian.Query(tempId, from, to, maxPoints: 500);传了maxPoints之后,降采样是在 SQL 里做的(用ROW_NUMBER按步长取), 不是全查出来再筛 —— 所以很快。保留清理:别让库无限长// 构造要传规则它从规则里取每点的 RetentionDays / Enabled 可选周期 var sweeper new RetentionSweeper( historian, rules: 所有点的历史规则, period: TimeSpan.FromHours(1)); // 清理周期默认 1 小时 sweeper.Start(); // 起后台周期清理线程重复调只生效一次 // 或手动清一次 long deleted sweeper.SweepOnce();⚠保留期是每点各配的—— 不是全局一个数。LoadRules(rules)可以随时换配置。 只收启用的、且RetentionDays 0的点。有个真踩过的坑:不同保留期不能互相误伤。点 A 配 7 天、点 B 配 90 天, 清理 A 的时候不能顺手把 B 的也删了。删完之后建议Compact()(VACUUM checkpoint)把文件空间收回来,否则文件只会越来越大。⚠SQLite 删了数据文件不会自动变小,必须 VACUUM。这是 SQLite 的固有行为。一次性装配推荐用RuntimeDataServices.Compose,它把报警、历史、保留清理、联动镜像都装好:using Hmi.ScriptRuntime; RuntimeDataServices services RuntimeDataServices.Compose( rtdb: host.Rtdb, project: project, historianDbPath: C:\我的工程\history.db, historian: null, // 不传就自己建 SqliteHistorian retentionPeriod: TimeSpan.FromHours(1), // 多久扫一次保留清理 startSweeper: true, log: (msg, ex) Console.Error.WriteLine(msg), alarmTickPeriod: TimeSpan.FromMilliseconds(250), startAlarmTicker: true);内部已经帮你Subscribe了,而且走的是Lossless车道—— 不会丢数据。诊断属性historian.WrittenRows // 已写入多少行 historian.DroppedRows // 丢了多少行 ← 这个不为 0 就要查 historian.PendingRows // 队列里还堆着多少 historian.OnDataDropped n log.Warn($历史库丢了 {n} 条);历史库的坑QueueCapacity必须走构造参数—— 用对象初始化器设init属性会被静默忽略(这是个已知陷阱)。不配死区—— 历史库迅速膨胀,查询变慢。Purge后不Compact—— 文件不会变小。DroppedRows不为 0 却不处理—— 说明磁盘或查询跟不上,数据在丢。忘了FlushNow()就 Dispose—— 队列里没写完的会丢。停机前先 flush。第二部分:报警引擎一句话概括报警引擎订阅实时库,按你配的规则判定越限,维护每条报警的状态,变化时通知你并落库。报警是怎么产生的:两种来源① 模拟量限值(配置驱动)每个点可以配四个限值:HiHi ── 高高限最严重 Hi ── 高限 ──── 正常区 ──── Lo ── 低限 LoLo ── 低低限最严重public sealed class TagAlarmConfig { public int TagId { get; init; } public string TagName { get; init; } public string Group { get; init; } // 分组按区域/设备归类 public AnalogLimit? HiHi { get; init; } public AnalogLimit? Hi { get; init; } public AnalogLimit? Lo { get; init; } public AnalogLimit? LoLo { get; init; } public double Deadband { get; init; } // 回差 public TimeSpan Delay { get; init; } // 延时 public DigitalAlarm? Digital { get; init; } // 开关量报警 }② 开关量报警(值 触发值就报)// ⚠ Trigger 是 long整型触发值不是 bool —— 写 Trigger true 编不过 DigitalAlarm { Trigger 1, Message 急停按钮被按下, Level AlarmLevel.Critical }③ 脚本主动报Alarm.Trigger(油温过高, level: 2, group: 液压站);见第 13 章的 Alarm 库。⚠ 报警级别有两套写法,别混同一个级别在两个地方写法不一样:含义C# 侧AlarmLevel枚举脚本侧Alarm.Trigger的level信息Info0警告Warn←不是Warning1报警Alarm2紧急/故障Critical3C# 里(AlarmEngine.Trigger、TagAlarmConfig、AnalogLimit):用枚举,写AlarmLevel.Warn。脚本里(Alarm.Trigger(..., level: 2)):用整数0~3。⚠ 枚举里没有Warning—— 写AlarmLevel.Warning编不过。只有Warn。⚠ 工程 JSON 里存的是字符串(level: Warn),同一套名字。两个关键参数:回差 和 延时回差(Deadband)—— 防止报警在临界值反复跳高限 80回差 2 升到 80 → 报警 降到 79 → 还在报警因为没低于 80-278 降到 77 → 报警解除不配回差的话,值在 80 附近抖动会导致报警疯狂触发和解除。延时(Delay)—— 防止瞬时抖动误报值超限必须持续超过Delay时间才真的报警。启动瞬间的冲击、传感器毛刺都会被过滤掉。报警引擎有个Tick()机制:即使值卡住不动,延时到点了也会产生报警。 这个 tick 由AlarmEvaluationTicker驱动,默认250 毫秒一次。状态机:一条报警的一生条件成立 ┌──────────────┐ │ ▼ Normal ActiveUnacked ──确认── ActiveAcked ▲ │ │ │ │ 条件恢复 │ 条件恢复 │ ▼ ▼ └──────── ClearedUnacked ────────────┘ │ 确认/清除状态含义Normal正常,没报警ActiveUnacked报警中,没人确认—— 这是最需要人注意的状态ActiveAcked报警中,已确认(操作员知道了,但问题还在)ClearedUnacked条件已恢复,但还没人确认(需要人知道刚才报过)为什么要区分确认和恢复:现场经常是报警响了 → 操作员点了确认 → 过一会儿故障自己恢复了。 分开记才能知道是谁在多长时间内处理了什么。公开 APIusing Hmi.Alarms.Engine; using Hmi.Alarms.Models; var engine new AlarmEngine( configs: 报警配置列表, clock: null, // 不传用系统时钟测试时可注入假时钟 store: alarmStore); // 落库用不传就只在内存里 // 事件任何状态变化都会触发 engine.AlarmChanged rec Console.WriteLine(${rec.Key} → {rec.State}); // 数据入口由 RTDB 分发线程调用内部已接线 engine.OnTagChanged(batch); // 推进延时无变化时也要调否则值卡住的报警出不来 engine.Tick(); // 查询 IReadOnlyListAlarmRecord active engine.Active(); // 操作 engine.Trigger(脚本报警, AlarmLevel.Warn, group: 测试); // ⚠ 是 Warn不是 Warning engine.Ack(key: OIL_HOT, user: 操作员A); engine.Clear(key: OIL_HOT); bool on engine.IsActive(OIL_HOT);一条报警记录长什么样public sealed class AlarmRecord { public string Key { get; } // 唯一标识 —— 全程不变是去重/更新的依据 public string Message { get; } public AlarmLevel Level { get; } public string Group { get; } public string TagName { get; } public DateTime? OnTime { get; } // 什么时候开始 public DateTime? OffTime { get; } // 什么时候恢复 public DateTime? AckTime { get; } // 什么时候被确认 public string? AckUser { get; } // 谁确认的 public double TriggerValue { get; } // 触发时的值 public AlarmState State { get; } }⭐Key是整条报警的身份。同一条报警从头到尾Key不变, 所以它同时是去重依据和更新目标 —— 同 Key 的报警不会产生两条记录,而是更新那一条。落库:两种实现实现行为SqliteAlarmStore存完整事件历史—— 每条报警的每次变化都记下来,可以查上周三谁确认的InMemoryAlarmStore只保留每个 Key 的最新快照—— 轻量,但重启就没了生产环境用 SQLite 版(报警记录是要留档追责的);测试或轻量场景用内存版。⚠ 两者语义不同,别以为可以随便换 —— 内存版查不到历史事件。报警的坑不配回差—— 值在限值附近抖动会导致报警反复触发/解除。不配延时—— 启动冲击、传感器毛刺会误报。Tick()没人调—— 值卡住不动时,延时到点该报警这件事不会发生。RuntimeDataServices.Compose会自动起 ticker,自己装配时别忘了。AlarmChanged回调里做重活—— 它在引擎的锁内同步触发, 做耗时操作会阻塞整个报警判定。通知类的工作应该丢到别处异步做。以为Clear等于Ack——Clear是条件恢复了,Ack是人确认了,两件事。脚本高频Trigger同一条报警—— 用Alarm.IsActive先判断。小结历史库记录模式三选一:OnChange(变化才记)/Timed(定时记)/Both一定要配Deadband,否则库会爆攒批写入(1000 条或 1 秒),队列满了会丢,看DroppedRows查询用Query(可maxPoints降采样)/Aggregate(聚合)删完数据要Compact(),否则文件不缩小报警三个来源:模拟量限值、开关量、脚本Alarm.Trigger回差防抖动,延时防毛刺 —— 两个都要配状态机:Normal → ActiveUnacked → ActiveAcked → ClearedUnacked → NormalAck(人确认)和Clear(条件恢复)是两件事Key是报警的身份,同 Key 更新而不新建落库用SqliteAlarmStore才有完整事件历史装配:用RuntimeDataServices.Compose一次装好,它内部走Lossless车道不会丢数据。下一步:第 15 章 自动化 API 总览