编辑
2026-04-08
C#
00

目录

一、问题引入
二、经验分析
四类信号的本质区别
常见的错误做法
我最终选择的方向
三、技术方案
整体架构
数据库设计
信号点位元数据表 signal_point
数字量历史表 signaldigitalhistory(边沿存储)
模拟量历史表 signalanaloghistory(周期存储)
四、代码与示例
效果
工程量转换工具类
采集服务核心路由逻辑
业务语义查询示例
五、经验总结
可直接带走的要点
常见坑与避免建议
下一步行动建议

本文面向有一定 C#/.NET 基础、正在接触工业数字化项目的开发者,聚焦离散制造车间中常见的四类现场信号类型,从业务含义到数据库设计再到代码实现,完整梳理一套可落地的映射方案。


一、问题引入

在我参与的一个汽车零部件工厂数字化项目中,现场有一台 PLC 通过 OPC-UA 向上位机推送数据。负责对接的开发同事把所有采集点一股脑存进了一张表,字段只有三个:point_namevaluetimestamp

上线后第一周,工艺工程师来反馈:"你们系统里设备运行状态怎么是 0.9997?设备要么开要么关,哪来的小数?"

开发同事一脸茫然——他不知道这个点位其实是一个 AI(模拟量输入)信号,采集的是主轴电流,单位安培,而不是开关状态。他把所有信号都当成了"数值",完全没有区分信号类型与业务含义。

这个问题在项目现场极其普遍。很多转行工业软件的开发者,在面对 DI/DO/AI/AO 这四类信号时,第一反应是"不就是读个值嘛"——但实际上,信号类型不同,业务语义不同,存储策略不同,处理逻辑也完全不同

一旦混淆,轻则数据展示错误,重则触发错误报警、影响生产决策,甚至导致设备误动作。


二、经验分析

四类信号的本质区别

先把概念说清楚,这是后续一切设计的基础。

信号类型全称方向值域典型业务含义
DIDigital Input(数字量输入)设备 → 系统0 / 1按钮状态、传感器触发、门磁开关
DODigital Output(数字量输出)系统 → 设备0 / 1继电器控制、指示灯、电磁阀
AIAnalog Input(模拟量输入)设备 → 系统连续浮点温度、压力、电流、转速
AOAnalog Output(模拟量输出)系统 → 设备连续浮点变频器频率设定、阀门开度指令

方向是第一个关键维度。DI 和 AI 是"读",DO 和 AO 是"写"。很多开发者在设计接口时忽略了方向,把所有点位都设计成可读可写,导致系统误写了不该写的点位,造成安全隐患。

值域是第二个关键维度。数字量只有 0/1,业务上对应"状态翻转";模拟量是连续值,业务上对应"工艺参数监控"。两者的存储频率、报警逻辑、历史查询方式都完全不同。

常见的错误做法

错误一:用统一的 value VARCHAR 字段存所有信号。 这看起来灵活,实际上丧失了类型约束,数值计算时还需要在代码里强制转换,极易出错。

错误二:DI/DO 用浮点数存储。 0.000000 和 1.000000 在数据库里看起来没问题,但一旦涉及"状态变化次数统计"或"边沿触发检测",浮点比较会带来精度问题。

错误三:所有信号用相同的采集频率。 AI 信号(如温度)可能 1 秒采一次,DI 信号(如急停按钮)需要毫秒级响应,用同一个定时器轮询,要么浪费资源,要么漏掉关键事件。

错误四:忽略工程量转换。 现场 AI 信号通常是 4~20mA 或 0~10V 的电信号,PLC 读到的原始值可能是 0~4095(12位ADC)。不做工程量转换直接存库,业务人员完全看不懂。

我最终选择的方向

根据项目经验,我倾向于将信号点位的元数据采集数据分离存储:

  • 元数据表(signal_point):定义信号类型、方向、工程量范围、单位、业务含义
  • 数字量历史表(signal_digital_history):只存 0/1 的状态变化记录(边沿存储)
  • 模拟量历史表(signal_analog_history):存连续采样值,支持按时间段查询

这样的好处是:采集层和业务层解耦,新增点位只需维护元数据,不需要改代码。


三、技术方案

整体架构

image.png

边沿存储的含义是:DI/DO 信号只在状态发生变化时(0→1 或 1→0)写入一条记录,而不是每秒都写。这对于开关量来说极大减少了写入量,同时保留了完整的状态变化历史。

数据库设计

信号点位元数据表 signal_point

sql
CREATE TABLE signal_point ( id BIGINT NOT NULL PRIMARY KEY, -- 点位ID point_code VARCHAR(64) NOT NULL UNIQUE, -- 点位编码,如 "L01_SPINDLE_CURRENT" point_name VARCHAR(128) NOT NULL, -- 点位名称 signal_type TINYINT NOT NULL, -- 1=DI 2=DO 3=AI 4=AO direction TINYINT NOT NULL, -- 1=Input 2=Output device_id BIGINT NOT NULL, -- 关联设备ID raw_min DECIMAL(18,4) NULL, -- 原始值下限(AI/AO用) raw_max DECIMAL(18,4) NULL, -- 原始值上限(AI/AO用) eng_min DECIMAL(18,4) NULL, -- 工程量下限 eng_max DECIMAL(18,4) NULL, -- 工程量上限 unit VARCHAR(32) NULL, -- 单位,如 "A"、"℃"、"rpm" business_tag VARCHAR(128) NULL, -- 业务语义标签,如 "主轴电流" alarm_high DECIMAL(18,4) NULL, -- 高报阈值 alarm_low DECIMAL(18,4) NULL, -- 低报阈值 is_enabled TINYINT NOT NULL DEFAULT 1, -- 是否启用 remark VARCHAR(256) NULL, created_at DATETIME NOT NULL, updated_at DATETIME NOT NULL );

数字量历史表 signal_digital_history(边沿存储)

sql
CREATE TABLE signal_digital_history ( id BIGINT NOT NULL PRIMARY KEY, point_id BIGINT NOT NULL, -- 关联 signal_point.id point_code VARCHAR(64) NOT NULL, -- 冗余存储,查询方便 value TINYINT NOT NULL, -- 0 或 1 edge_type TINYINT NOT NULL, -- 1=上升沿(0→1) 2=下降沿(1→0) occurred_at DATETIME(3) NOT NULL, -- 毫秒精度时间戳 source VARCHAR(32) NULL, -- 数据来源:OPC/Modbus/MQTT INDEX idx_point_time (point_id, occurred_at) );

模拟量历史表 signal_analog_history(周期存储)

sql
CREATE TABLE signal_analog_history ( id BIGINT NOT NULL PRIMARY KEY, point_id BIGINT NOT NULL, point_code VARCHAR(64) NOT NULL, raw_value DECIMAL(18,4) NOT NULL, -- PLC原始值 eng_value DECIMAL(18,4) NOT NULL, -- 工程量换算后的值 quality TINYINT NOT NULL DEFAULT 1, -- 数据质量:1=Good 0=Bad sampled_at DATETIME(3) NOT NULL, -- 采样时间 INDEX idx_point_time (point_id, sampled_at) );

模拟量历史表数据量增长极快。在实际项目中,建议按月分表,或使用 TimescaleDB / InfluxDB 等时序数据库存储 AI/AO 历史数据,SQL Server 或 MySQL 仅保留近 N 天的热数据。


四、代码与示例

效果

image.png

image.png

image.png

工程量转换工具类

这是最容易被忽视的一步。PLC 的 AI 通道读到的是原始 ADC 值,必须线性映射到工程量。

csharp
namespace AppMonitor.Services; public static class SignalConverter { /// <summary> /// 线性工程量转换:rawValue → engValue /// </summary> public static double ToEngValue( double rawValue, double rawMin, double rawMax, double engMin, double engMax) { if (Math.Abs(rawMax - rawMin) < 1e-9) throw new ArgumentException("rawMin 与 rawMax 不能相等"); rawValue = Math.Clamp(rawValue, rawMin, rawMax); return engMin + (rawValue - rawMin) / (rawMax - rawMin) * (engMax - engMin); } public static bool IsValidDigital(int value) => value == 0 || value == 1; }

采集服务核心路由逻辑

csharp
using AppMonitor.Database; using AppMonitor.Models; using Microsoft.Data.Sqlite; namespace AppMonitor.Services; /// <summary> /// 采集数据路由:根据信号类型分发到数字量或模拟量存储,并触发报警检测 /// </summary> public class SignalDataRouter { private readonly Dictionary<long, int> _lastDigitalState = new(); // 报警事件:(点位, 工程量值, 报警描述) public event Action<SignalPoint, double, string>? AlarmTriggered; // 当有新的历史记录被写入时触发,参数为点位 Id public event Action<long>? DigitalDataInserted; public event Action<long>? AnalogDataInserted; public void Process(SignalPoint meta, double rawValue, DateTime occurredAt) { try { if (meta.IsDigital) ProcessDigital(meta, (int)rawValue, occurredAt); else ProcessAnalog(meta, rawValue, occurredAt); } catch (Exception ex) { System.Diagnostics.Debug.WriteLine($"[Router] Error {meta.PointCode}: {ex.Message}"); } } private void ProcessDigital(SignalPoint meta, int current, DateTime occurredAt) { if (!SignalConverter.IsValidDigital(current)) return; bool hasLast = _lastDigitalState.TryGetValue(meta.Id, out int last); if (!hasLast || last != current) { int edgeType = current == 1 ? 1 : 2; using var conn = new SqliteConnection(DbHelper.ConnectionString); conn.Open(); var cmd = conn.CreateCommand(); cmd.CommandText = @" INSERT INTO signal_digital_history (point_id, point_code, value, edge_type, occurred_at, source) VALUES (@pid, @pc, @val, @et, @oat, @src)"; cmd.Parameters.AddWithValue("@pid", meta.Id); cmd.Parameters.AddWithValue("@pc", meta.PointCode); cmd.Parameters.AddWithValue("@val", current); cmd.Parameters.AddWithValue("@et", edgeType); cmd.Parameters.AddWithValue("@oat", occurredAt.ToString("yyyy-MM-dd HH:mm:ss.fff")); cmd.Parameters.AddWithValue("@src", "Simulator"); cmd.ExecuteNonQuery(); _lastDigitalState[meta.Id] = current; // 通知订阅者(UI 可据此刷新历史视图) DigitalDataInserted?.Invoke(meta.Id); } } private void ProcessAnalog(SignalPoint meta, double rawValue, DateTime occurredAt) { double engValue = rawValue; if (meta.RawMin.HasValue && meta.RawMax.HasValue && meta.EngMin.HasValue && meta.EngMax.HasValue) { engValue = SignalConverter.ToEngValue( rawValue, meta.RawMin.Value, meta.RawMax.Value, meta.EngMin.Value, meta.EngMax.Value); } engValue = Math.Round(engValue, 4); using var conn = new SqliteConnection(DbHelper.ConnectionString); conn.Open(); var cmd = conn.CreateCommand(); cmd.CommandText = @" INSERT INTO signal_analog_history (point_id, point_code, raw_value, eng_value, quality, sampled_at) VALUES (@pid, @pc, @rv, @ev, 1, @sat)"; cmd.Parameters.AddWithValue("@pid", meta.Id); cmd.Parameters.AddWithValue("@pc", meta.PointCode); cmd.Parameters.AddWithValue("@rv", rawValue); cmd.Parameters.AddWithValue("@ev", engValue); cmd.Parameters.AddWithValue("@sat", occurredAt.ToString("yyyy-MM-dd HH:mm:ss.fff")); cmd.ExecuteNonQuery(); // 报警检测 // 通知订阅者(UI 可据此刷新历史视图) AnalogDataInserted?.Invoke(meta.Id); if (meta.AlarmHigh.HasValue && engValue > meta.AlarmHigh.Value) AlarmTriggered?.Invoke(meta, engValue, $"高报警 > {meta.AlarmHigh.Value}{meta.Unit}"); else if (meta.AlarmLow.HasValue && engValue < meta.AlarmLow.Value) AlarmTriggered?.Invoke(meta, engValue, $"低报警 < {meta.AlarmLow.Value}{meta.Unit}"); } public List<SignalDigitalHistory> GetDigitalHistory(long pointId, int limit = 100) { var list = new List<SignalDigitalHistory>(); using var conn = new SqliteConnection(DbHelper.ConnectionString); conn.Open(); var cmd = conn.CreateCommand(); cmd.CommandText = @" SELECT * FROM signal_digital_history WHERE point_id=@pid ORDER BY occurred_at DESC LIMIT @lim"; cmd.Parameters.AddWithValue("@pid", pointId); cmd.Parameters.AddWithValue("@lim", limit); using var r = cmd.ExecuteReader(); while (r.Read()) { list.Add(new SignalDigitalHistory { Id = r.GetInt64(0), PointId = r.GetInt64(1), PointCode = r.GetString(2), Value = r.GetInt32(3), EdgeType = r.GetInt32(4), OccurredAt = DateTime.Parse(r.GetString(5)), Source = r.IsDBNull(6) ? null : r.GetString(6) }); } return list; } public List<SignalAnalogHistory> GetAnalogHistory(long pointId, int limit = 100) { var list = new List<SignalAnalogHistory>(); using var conn = new SqliteConnection(DbHelper.ConnectionString); conn.Open(); var cmd = conn.CreateCommand(); cmd.CommandText = @" SELECT * FROM signal_analog_history WHERE point_id=@pid ORDER BY sampled_at DESC LIMIT @lim"; cmd.Parameters.AddWithValue("@pid", pointId); cmd.Parameters.AddWithValue("@lim", limit); using var r = cmd.ExecuteReader(); while (r.Read()) { list.Add(new SignalAnalogHistory { Id = r.GetInt64(0), PointId = r.GetInt64(1), PointCode = r.GetString(2), RawValue = r.GetDouble(3), EngValue = r.GetDouble(4), Quality = r.GetInt32(5), SampledAt = DateTime.Parse(r.GetString(6)) }); } return list; } public (double Avg, double Max, double Min, int Count) GetAnalogStats(long pointId) { using var conn = new SqliteConnection(DbHelper.ConnectionString); conn.Open(); var cmd = conn.CreateCommand(); cmd.CommandText = @" SELECT AVG(eng_value), MAX(eng_value), MIN(eng_value), COUNT(*) FROM signal_analog_history WHERE point_id=@pid AND quality=1 AND sampled_at >= datetime('now','-1 hour')"; cmd.Parameters.AddWithValue("@pid", pointId); using var r = cmd.ExecuteReader(); if (r.Read() && !r.IsDBNull(0)) return (r.GetDouble(0), r.GetDouble(1), r.GetDouble(2), r.GetInt32(3)); return (0, 0, 0, 0); } public int GetDigitalEdgeCount(long pointId, int edgeType) { using var conn = new SqliteConnection(DbHelper.ConnectionString); conn.Open(); var cmd = conn.CreateCommand(); cmd.CommandText = @" SELECT COUNT(*) FROM signal_digital_history WHERE point_id=@pid AND edge_type=@et AND occurred_at >= date('now')"; cmd.Parameters.AddWithValue("@pid", pointId); cmd.Parameters.AddWithValue("@et", edgeType); return Convert.ToInt32(cmd.ExecuteScalar() ?? 0); } }

业务语义查询示例

业务层查询"某设备主轴电流在过去 1 小时的平均值":

sql
-- 查询指定点位最近1小时的模拟量平均值、最大值、最小值 SELECT sp.business_tag AS 业务名称, sp.unit AS 单位, AVG(sah.eng_value) AS 平均值, MAX(sah.eng_value) AS 最大值, MIN(sah.eng_value) AS 最小值, COUNT(*) AS 采样点数 FROM signal_analog_history sah INNER JOIN signal_point sp ON sp.id = sah.point_id WHERE sah.point_code = 'L01_SPINDLE_CURRENT' AND sah.sampled_at >= DATE_SUB(NOW(), INTERVAL 1 HOUR) AND sah.quality = 1 -- 只统计质量好的数据 GROUP BY sp.business_tag, sp.unit;

查询某设备急停按钮今天的触发次数(数字量边沿统计):

sql
-- 统计今日上升沿次数(0→1,即"被按下"的次数) SELECT point_code, COUNT(*) AS 触发次数 FROM signal_digital_history WHERE point_code = 'L01_ESTOP_BTN' AND edge_type = 1 -- 上升沿 AND occurred_at >= CURDATE() GROUP BY point_code;

五、经验总结

可直接带走的要点

1. 元数据驱动,点位配置化。 把信号类型、量程、单位、业务标签全部存进 signal_point 表,采集逻辑读元数据动态处理,新增点位无需改代码。

2. 数字量用边沿存储,不要全量存储。 一个开关量每秒采集一次,一年就是 3100 万条记录,而状态实际可能一天只变化几十次。边沿存储可将存储量降低 99% 以上。

3. 模拟量必须做工程量转换,且转换参数入库。 原始值和工程量都要存,方便事后排查传感器漂移或标定问题。

4. 方向(Input/Output)必须在元数据中明确。 任何对 Output 点位的写操作都需要额外的权限校验和操作日志,不能和读操作混在一起处理。

5. 采集时间用设备时间戳,不用服务器时间。 网络延迟、服务重启都会导致服务器时间不准,设备时间戳才是真实的事件发生时间。

常见坑与避免建议

  • 坑1:value 字段用 VARCHAR 存所有信号值。 避免方法:数字量和模拟量分表,字段类型明确。

  • 坑2:浮点数比较判断数字量状态变化。 避免方法:数字量只存 TINYINT,用整数判等。

  • 坑3:工程量转换写在前端展示层。 避免方法:转换在采集服务完成,数据库存工程量,展示层直接用。

  • 坑4:所有点位共用一个采集定时器,频率相同。 避免方法:DI/DO 用事件订阅(OPC-UA DataChange),AI/AO 用轮询,频率分级配置。

  • 坑5:忽略数据质量标记。 避免方法:OPC-UA 本身有 Quality 字段(Good/Bad/Uncertain),一定要存下来,报表统计时过滤掉 Bad 数据,否则会出现异常峰值。

下一步行动建议

如果你现在正在做类似的项目,建议先把 signal_point 元数据表建好,和现场工艺工程师一起把每个点位的业务含义、量程、单位逐一确认填入——这张表是整个信号系统的"地基",后续所有采集、存储、报警、报表都依赖它。不要急着写采集代码,先把数据字典定义清楚。


你在项目里遇到过类似的信号类型混淆问题吗?或者在工程量转换、边沿检测这块有不同的实践方式?欢迎在评论区分享你的做法。

相关信息

通过网盘分享的文件:AppMonitor.zip 链接: https://pan.baidu.com/s/1Kf2-uoY1OpmtviASca5f4Q?pwd=2sst 提取码: 2sst --来自百度网盘超级会员v9的分享

本文作者:技术老小子

本文链接:

版权声明:本博客所有文章除特别声明外,均采用 BY-NC-SA 许可协议。转载请注明出处!