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} 条");

历史库的坑

  1. QueueCapacity 必须走构造参数 —— 用对象初始化器设 init 属性会被静默忽略(这是个已知陷阱)。
  2. 不配死区 —— 历史库迅速膨胀,查询变慢。
  3. Purge 后不 Compact —— 文件不会变小。
  4. DroppedRows 不为 0 却不处理 —— 说明磁盘或查询跟不上,数据在丢。
  5. 忘了 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 版(报警记录是要留档追责的);测试或轻量场景用内存版。

⚠ 两者语义不同,别以为可以随便换 —— 内存版查不到历史事件。

报警的坑

  1. 不配回差 —— 值在限值附近抖动会导致报警反复触发/解除。
  2. 不配延时 —— 启动冲击、传感器毛刺会误报。
  3. Tick() 没人调 —— 值卡住不动时,"延时到点该报警"这件事不会发生。 RuntimeDataServices.Compose 会自动起 ticker,自己装配时别忘了。
  4. AlarmChanged 回调里做重活 —— 它在引擎的锁内同步触发, 做耗时操作会阻塞整个报警判定。通知类的工作应该丢到别处异步做。
  5. 以为 Clear 等于 Ack —— Clear 是"条件恢复了",Ack 是"人确认了",两件事。
  6. 脚本高频 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 车道不会丢数据。

下一步:第 15 章 自动化 API 总览