从零搭建工业控制系统(九):报警系统设计——不只是弹个框那么简单

报警系统设计:不只是弹个框那么简单

这是「从零搭建工业控制系统」系列第9篇。工艺能跑了,数据能看了。但万一设备出故障呢?这篇讲怎么设计一套靠谱的报警系统。


报警系统踩过的坑

最早做报警,就是在代码里弹 MessageBox.Show("温度过高!")。简单粗暴,能用。

然后被现场教训了:

问题1:操作员关了弹框就没了。出了事故翻记录,什么都没有。
问题2:温度过高的报警一直触发,每秒弹一个框,屏幕上堆了几十个对话框。
问题3:程序异常退出后重启,之前哪些报警在持续、什么时候开始的,全不知道。
问题4:进气压力低和加热器过温用同一种方式提示,操作员分不清哪个紧急哪个不紧急。

这些问题逼着我重新设计了报警系统。核心思路:报警不是一次性事件,而是一个有生命周期的状态机。


报警状态机

每条报警有四个状态:

Normal → Active → Recovered
              ↘ RecoveredOnStartup(异常退出后启动自愈)
              ↘ Ignored(调试期忽略)
  • Normal:正常,没有报警
  • Active:报警中,从触发瞬间开始计时
  • Recovered:恢复正常,记录了持续时长
  • RecoveredOnStartup:程序异常退出后重启时自动关闭的遗留记录
  • Ignored:调试模式下忽略的报警

图1:报警状态机生命周期

这个状态机由 AlarmHistoryService 管理:

public interface IAlarmHistoryService
{
    /// <summary>
    /// 启动自愈:关闭所有EndTime为NULL的遗留记录
    /// </summary>
    Task InitializeAsync();

    /// <summary>
    /// 报警源状态跳变入口
    /// Normal→Abnormal: 插入新记录(Status=Active)
    /// Abnormal→Normal: 更新原记录(EndTime/Status=Recovered)
    /// </summary>
    void OnSourceStateChanged(HardwareAlarmSnapshot snapshot);
}

报警源定义

项目里定义了若干类硬件报警源,每类有明确的元数据:

private static readonly IReadOnlyDictionary<string, AlarmSourceMeta> SourceMetaTable =
    new Dictionary<string, AlarmSourceMeta>
    {
        ["ALM_COVER"] = new AlarmSourceMeta
        {
            SourceCode = "ALM_COVER",
            Severity = AlarmSeverity.Critical,
            Description = "腔室盖未关闭",
            DeviceInfo = "ChamberCover",
            TriggerCondition = "腔室盖开到位信号为1(点位号略)",
        },
        ["ALM_ARC"] = new AlarmSourceMeta
        {
            SourceCode = "ALM_ARC",
            Severity = AlarmSeverity.Critical,
            Description = "气柜检测到起弧",
            DeviceInfo = "气柜DI信号",
            TriggerCondition = "起弧检测信号激活",
        },
        ["ALM_GAS_PRESSURE"] = new AlarmSourceMeta
        {
            SourceCode = "ALM_GAS_PRESSURE",
            Severity = AlarmSeverity.Critical,
            Description = "进气压力异常(<0.7MPa)",
            DeviceInfo = "压力AI信号",
            TriggerCondition = "进气压力 < 0.7MPa",
            DefaultThreshold = 0.7m,
            Unit = "MPa",
        },
        ["ALM_HEATER1_OT"] = new AlarmSourceMeta
        {
            SourceCode = "ALM_HEATER1_OT",
            Severity = AlarmSeverity.Critical,
            Description = "加热器1过温",
            DeviceInfo = "Heater1",
            TriggerCondition = "过温DI信号为1(点位号略)",
            Unit = "°C",
        },
        // ... 加热器2、加热器3 类似
    };

三个关键设计:

① 严重等级分三级Critical(必须立即处置)、Warning(需关注)、Info(仅记录)。UI可以根据等级用不同颜色显示。

图2:报警严重等级分类

② 静态元数据表 — 描述、触发条件、设备信息都是写死的。不会因为配置文件丢了就不知道这个报警是什么意思。

③ 加热器拆分 — 原本"加热器过温"是一个报警源,但项目有多个加热器。拆成各自独立的报警源,每个可以独立触发和恢复。


状态跳变处理

OnSourceStateChanged 是报警系统的核心入口。每次设备寄存器变化导致报警源状态翻转时调用:

public void OnSourceStateChanged(HardwareAlarmSnapshot snapshot)
{
    // 启动冷却期守卫
    if (_readyAfter == null || DateTime.Now < _readyAfter)
        return;

    var sourceCode = snapshot.SourceCode;

    if (snapshot.IsAbnormal)
    {
        // Normal → Abnormal:新增Active记录
        // TryAdd原子占位,防止并发重复插入
        if (!_activeAlarms.TryAdd(sourceCode, -1))
        {
            // 已有Active记录,重复触发跳过(去抖)
            return;
        }

        var record = BuildAlarmHistory(snapshot);

        // fire-and-forget:写库不阻塞UI事件链
        _ = Task.Run(async () =>
        {
            var newId = await _databaseService.InsertAlarmHistoryAsync(record);
            if (newId > 0)
                _activeAlarms[sourceCode] = newId;
            else
                _activeAlarms.TryRemove(sourceCode, out _);
        });
    }
    else
    {
        // Abnormal → Normal:更新EndTime
        if (!_activeAlarms.TryRemove(sourceCode, out var activeId))
            return;

        _ = Task.Run(async () =>
        {
            await _databaseService.UpdateAlarmHistoryEndTimeAsync(
                activeId, snapshot.Timestamp, "Recovered");
        });
    }
}

几个关键设计决策:

① 内存字典做去抖_activeAlarmsConcurrentDictionary<string, int>,key是报警源代码,value是数据库ID。用 TryAdd 保证同一个报警源只插入一次。温度过高的报警不管触发多少次,数据库里只有一条Active记录。

② fire-and-forget写库 — 数据库写入是异步的,不阻塞调用线程。因为 OnSourceStateChanged 是在UI事件链里被调用的,如果同步写库,设备状态变化的响应会变慢。

③ 占位值-1TryAdd 时先放-1,INSERT成功后更新为真实ID。这防止了并发场景下重复插入。

图3:报警去抖与状态跳变处理


启动自愈

程序异常退出时,可能有报警记录的 EndTime 还是NULL(报警没恢复就退出了)。重启后这些"悬挂记录"必须处理:

public async Task InitializeAsync()
{
    try
    {
        var startupTime = DateTime.Now;

        // 关闭所有悬挂的报警记录
        var alarmClosed = await _databaseService.CloseHangingAlarmHistoriesAsync(startupTime);
        var traceClosed = await _databaseService.CloseHangingTraceAlarmHistoriesAsync(startupTime);

        _logger.Info($"启动自愈完成:关闭报警历史悬挂记录{alarmClosed}条," +
                     $"Trace报警历史悬挂记录{traceClosed}条");

        // 设置15秒冷却期
        _readyAfter = DateTime.Now.AddSeconds(15);
    }
    catch (Exception ex)
    {
        // 自愈失败不阻断启动
        _logger.Error($"启动自愈失败: {ex.Message}", ex);
    }
}

为什么设15秒冷却期? 程序刚启动时,设备还没完成首轮Modbus轮询,寄存器值都是0。如果立刻开始判断报警,0值可能被误判为"压力异常"等,产生假报警。15秒后设备数据稳定了才开始真正的报警监测。

这个冷却期是踩坑后加的。之前没这个机制,每次启动都会冒出一堆假报警,操作员被搞烦了直接把报警功能关了。

图4:启动自愈与冷却期


报警数据模型

每条报警记录在数据库里长这样:

public class AlarmHistory
{
    public int Id { get; set; }
    public DateTime StartTime { get; set; }       // 报警开始时间
    public DateTime? EndTime { get; set; }        // 结束时间(Active时为NULL)
    public int? DurationSeconds { get; set; }     // 持续秒数
    public string Category { get; set; }          // Hardware/Custom
    public string SourceCode { get; set; }        // 报警源代码
    public string Severity { get; set; }          // Critical/Warning/Info
    public string DeviceInfo { get; set; }        // 设备位号
    public string TriggerCondition { get; set; }  // 触发条件表达式
    public decimal? TriggerValue { get; set; }    // 触发时的瞬时值
    public decimal? Threshold { get; set; }       // 触发阈值
    public string Unit { get; set; }              // 物理单位
    public string Description { get; set; }       // 中文描述
    public string Status { get; set; }            // Active/Recovered/...
    public string Barcode { get; set; }           // 关联晶圆Barcode
    public string SequenceName { get; set; }      // 当前运行Sequence
}

TriggerValue 和 Threshold 这两个字段特别有用。比如进气压力报警,不仅记录"压力异常",还记录"当时压力0.5MPa,阈值0.7MPa"。事后分析时一眼就能看出是压力确实低了还是传感器有问题。

Barcode 和 SequenceName 把报警跟工艺批次关联起来。查某个晶圆加工过程中出了什么报警,一条SQL就搞定。


UI层的报警显示

报警在UI上分两部分:状态栏和报警列表。

状态栏显示当前报警概览:

// MainWindowViewModel.AlarmStatus.cs
[ObservableProperty]
private string alarmStatusText = "未知";

[ObservableProperty]
private string alarmStatusTime = string.Empty;

[ObservableProperty]
private string abnormalAlarmSources = "";

public bool HasAlarm => TriggerStatus == TriggerStatus.Alarm;

报警源状态变化时,通过 WeakReferenceMessenger 通知ViewModel更新:

// 监听报警源属性变化
hdps100Status.PropertyChanged += (s, e) =>
{
    if (e.PropertyName == nameof(ChamberSubsystemStatus.AlarmSourceChamberClose) ||
        e.PropertyName == nameof(ChamberSubsystemStatus.AlarmSourceGasBoxActing) ||
        // ... 其他报警源
        e.PropertyName == nameof(ChamberSubsystemStatus.AlarmSourceHeaterOvertemp))
    {
        // 切到UI线程更新
        Application.Current?.Dispatcher?.BeginInvoke(
            new Action(() => UpdateAlarmStatus()));

        // 通知AlarmHistoryService持久化
        NotifyAlarmHistoryService();
    }
};

为什么要切到UI线程? 因为 PropertyChanged 事件可能从Modbus轮询线程触发。UI更新必须在Dispatcher上执行。


报警列表条目

报警列表里的每一项是个简单的model:

public partial class AlarmItem : ObservableObject
{
    public string Message { get; }           // "[硬件] Heater1过温报警"
    public DateTime Time { get; }            // 触发时间
    public string TimeText => Time.ToString("MM-dd HH:mm:ss");

    [ObservableProperty]
    private string elapsedText = string.Empty;  // "1分钟30秒"
}

ElapsedText 是持续时间的文本,由外部定时刷新。报警列表每秒更新一次已持续时间,让操作员知道这个报警已经持续多久了。


报警与工艺的关联

报警系统不是孤立的。AlarmHistoryService 在构造报警记录时,会尝试从 ExecutionProgressService 读取当前工艺上下文:

private AlarmHistory BuildAlarmHistory(HardwareAlarmSnapshot snapshot)
{
    return new AlarmHistory
    {
        StartTime = snapshot.Timestamp,
        SourceCode = meta.SourceCode,
        Severity = meta.Severity.ToString(),
        Description = meta.Description,
        Status = "Active",
        Barcode = TryGetBarcode(),       // 当前晶圆
        SequenceName = TryGetSequenceName(),  // 当前Sequence
    };
}

private string TryGetBarcode()
{
    var prop = _executionProgress?.GetType().GetProperty("Barcode");
    return prop?.GetValue(_executionProgress) as string;
}

用反射是因为 ExecutionProgressServiceBarcode 属性是后加的,不一定每个版本都有。反射安全降级,没有就返回null。


报警踩坑清单

现象解决
弹框式报警关了就没了改用状态机+数据库持久化
重复触发每秒一条记录ConcurrentDictionary去抖
启动假报警寄存器初始值0被误判15秒冷却期
异常退出残留EndTime永远NULL启动自愈关闭悬挂记录
同步写库卡UI设备状态变化响应慢fire-and-forget异步写
分不清严重程度全用红色显示Critical/Warning/Info三级
报警跟工艺脱节不知道哪个晶圆出的关联Barcode和SequenceName

本篇小结

知识点关键做法
报警状态机Normal→Active→Recovered 生命周期
去抖机制ConcurrentDictionary + TryAdd 原子占位
启动自愈关闭EndTime为NULL的悬挂记录
冷却期15秒等设备数据稳定
异步写库fire-and-forget 不阻塞UI
严重等级Critical/Warning/Info 三级分类
工艺关联记录Barcode和SequenceName

好的报警系统不是"出了事告诉你",而是"出了事记录下来、告诉你多严重、事后能追溯"。


下期预告

第10篇:权限管理与角色控制

不是所有人都能操作所有设备。下一篇讲用户认证、角色权限、UI元素按权限显隐。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值