14 · 历史库与报警引擎
程序集:Hmi.Historian + Hmi.Alarms · 源码:src/Hmi.Historian/、src/Hmi.Alarms/
什么时候你会需要它
- 你要存趋势曲线数据(温度、压力随时间怎么变的)
- 你要做报警:超限报警、开关量报警、报警确认与历史记录
- 你要出班报/日报,查一段时间内的平均值、最大值
- 你在排查"历史数据有断点 / 报警没出来 / 报警消不掉"
这两块经常一起用,所以放在同一章。
第一部分:历史库
一句话概括
历史库订阅实时库,按你配的规则把值批量写进 SQLite;查询时再按时间段取出来。
实时库变化
│ Lossless 车道(一条都不能丢)
▼
HistorianRecorder ──按规则判断──> 该记吗?
│ ├─ OnChange:值变了才记
│ ├─ Timed :每 N 毫秒记一次
│ └─ Both :两者都要
▼
SqliteHistorian(攒批写入,1000 条或 1 秒刷一次)
│
▼
history.db(SQLite,WAL 模式)
什么时候记:三种记录模式
每个点单独配一条规则:
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 是攒批的:
| 参数 | 默认值 | 含义 |
|---|---|---|
BatchSize |
1000 | 攒够 1000 条写一次 |
FlushIntervalMs |
1000 | 或者最多等 1 秒也写一次 |
QueueCapacity |
100000 | 队列上限,满了丢最旧的并计数 |
⚠ 队列满了会丢数据。这不是静默的 ——
DroppedRows会累加,还有OnDataDropped回调。 发现丢数据 = 磁盘跟不上或者查询太慢,要处理。
怎么查
using Hmi.Historian;
var historian = new SqliteHistorian(@"C:\我的工程\history.db");
// ① 查一段原始点
IReadOnlyList<HistPoint> 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 |
|---|---|---|
| 信息 | Info |
0 |
| 警告 | Warn ← 不是 Warning |
1 |
| 报警 | Alarm |
2 |
| 紧急/故障 | Critical |
3 |
- C# 里(
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-2=78)
降到 77 → 报警解除
不配回差的话,值在 80 附近抖动会导致报警疯狂触发和解除。
延时(Delay)—— 防止瞬时抖动误报
值超限必须持续超过 Delay 时间才真的报警。启动瞬间的冲击、传感器毛刺都会被过滤掉。
💡 报警引擎有个
Tick()机制:即使值卡住不动,延时到点了也会产生报警。 这个 tick 由AlarmEvaluationTicker驱动,默认 250 毫秒一次。
状态机:一条报警的一生
条件成立
┌──────────────┐
│ ▼
Normal ActiveUnacked ──确认──> ActiveAcked
▲ │ │
│ │ 条件恢复 │ 条件恢复
│ ▼ ▼
└──────── ClearedUnacked <────────────┘
│
确认/清除
| 状态 | 含义 |
|---|---|
Normal |
正常,没报警 |
ActiveUnacked |
报警中,没人确认 —— 这是最需要人注意的状态 |
ActiveAcked |
报警中,已确认(操作员知道了,但问题还在) |
ClearedUnacked |
条件已恢复,但还没人确认(需要人知道"刚才报过") |
为什么要区分"确认"和"恢复":现场经常是"报警响了 → 操作员点了确认 → 过一会儿故障自己恢复了"。 分开记才能知道是谁在多长时间内处理了什么。
公开 API
using 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();
// 查询
IReadOnlyList<AlarmRecord> 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 → Normal Ack(人确认)和Clear(条件恢复)是两件事Key是报警的身份,同 Key 更新而不新建- 落库用
SqliteAlarmStore才有完整事件历史
装配:用 RuntimeDataServices.Compose 一次装好,它内部走 Lossless 车道不会丢数据。