☰
MES生产看板落地实战:从PLC数据采集到实时渲染的完整技术方案
2026/10/13 11:00:27 网站建设 项目流程

简介:这份资源是一套基于C#实现的MES系统生产看板示例工程,面向希望理解制造执行系统架构与车间可视化开发的初学者和进阶开发者。它围绕生产进度、质量控制、物料消耗等关键指标的实时展示,演示了数据库设计、实时数据获取、WPF或WinForms界面构建、MVVM模式、多线程处理、异常处理与性能优化等核心知识点,可作为学习MES系统基本工作流程与软件工程实践的入门起点。压缩包为rar格式,共36个文件,约199KB,其中包含8个cs源码文件、3个resx资源文件、3个exe可执行程序、1个sln解决方案与1个csproj项目文件,以及若干缓存、配置与调试辅助文件,整体结构紧凑,便于直接打开工程对照源码学习。目前已有4628人浏览学习,适合想通过实际项目掌握C#桌面应用与生产管理功能实现的读者参考借鉴。

1. MES系统生产看板:从车间黑匣子到实时决策的落地路径

车间里最让人头疼的场景,不是设备坏了,而是设备坏了两个小时,管理层才知道。MES系统生产看板要解决的就是这个问题——把生产现场的工单进度、设备状态、质量数据、人员效率从纸质报表和口头汇报里解放出来,变成一块实时刷新的可视化界面。它适合两类人:一类是制造企业的IT工程师,被要求“搞个看板出来”;另一类是生产主管,想知道产线上到底发生了什么。但看板不是买个大屏挂上去就完事,数据从PLC到数据库再到前端渲染,中间每一层都有坑。我见过太多项目,大屏做得漂漂亮亮,数据延迟15分钟,工人该干嘛干嘛,看板成了摆设。这篇笔记按真实落地顺序拆:先讲数据从哪来、怎么采,再讲后端怎么聚合、前端怎么渲染,最后讲部署和避坑。跟着走,你能搭出一个延迟在秒级、能真正驱动车间动作的看板。

2. 数据采集层:从PLC到消息队列的选型与实现

看板的数据源头在设备。PLC、SCADA、传感器、扫码枪,这些数据不采上来,看板就是无源之水。采集层的核心决策是:用什么协议、走什么通道、采多快。

2.1 协议选型:OPC UA、Modbus TCP与MQTT的适用边界

制造业现场协议极其碎片化。常见做法是分三层处理:设备层用Modbus TCP或OPC UA直连PLC,边缘层做协议转换和预处理,平台层用MQTT或Kafka做消息总线。

协议适用场景采集频率典型延迟坑点
Modbus TCP老旧PLC、仪表100ms-1s低寄存器地址映射易错,字节序要确认
OPC UA中高端PLC、跨品牌50ms-500ms中证书配置繁琐,命名空间要遍历
MQTT边缘到平台事件驱动低QoS设置不当会丢消息
Kafka平台内部总线事件驱动低运维成本高,小厂慎用

我一般会建议:如果PLC支持OPC UA,优先走OPC UA,因为它的信息模型自带语义,不用手动维护地址表。如果只有Modbus,那就老老实实建一张寄存器映射表,把每个地址对应的物理含义写清楚,这张表后面会救你无数次。

2.2 用Python写一个Modbus TCP采集器的最小实现

下面是一个可复现的采集脚本,用pymodbus库从一台模拟PLC读取保持寄存器,解析后推送到MQTT。

# modbus_collector.py from pymodbus.client import ModbusTcpClient import paho.mqtt.client as mqtt import struct, time, json # PLC连接配置 PLC_IP = "192.168.1.10" PLC_PORT = 502 SLAVE_ID = 1 # 寄存器映射:地址 -> (名称, 数据类型, 缩放因子) REGISTER_MAP = { 40001: ("work_order_count", "uint16", 1), 40002: ("output_qty", "uint32", 1), # 占两个寄存器 40004: ("device_status", "uint16", 1), 40005: ("temperature", "int16", 0.1), # 实际值 = 原始值 * 0.1 } def read_and_publish(): client = ModbusTcpClient(PLC_IP, port=PLC_PORT) client.connect() mqtt_client = mqtt.Client("mes_collector") mqtt_client.connect("192.168.1.20", 1883, 60) while True: payload = {} for addr, (name, dtype, scale) in REGISTER_MAP.items(): # Modbus地址从0开始,40001对应偏移0 offset = addr - 40001 if dtype == "uint32": rr = client.read_holding_registers(offset, 2, slave=SLAVE_ID) raw = (rr.registers[0] << 16) | rr.registers[1] else: rr = client.read_holding_registers(offset, 1, slave=SLAVE_ID) raw = rr.registers[0] if dtype == "int16" and raw > 32767: raw -= 65536 payload[name] = round(raw * scale, 2) payload["ts"] = int(time.time() * 1000) mqtt_client.publish("mes/line1/plc", json.dumps(payload), qos=1) time.sleep(0.5) # 500ms采集周期 if __name__ == "__main__": read_and_publish()

逻辑说明:脚本每500毫秒轮询一次PLC的保持寄存器,按映射表解析出工单数、产量、设备状态和温度,打包成JSON推送到MQTT的mes/line1/plc主题。qos=1保证至少送达一次,避免产量数据丢失。

参数说明:SLAVE_ID是PLC的从站地址,多台设备级联时每个从站ID不同。scale是缩放因子,温度寄存器返回的是整数,乘以0.1才是实际摄氏度。time.sleep(0.5)控制采集频率,太快会压垮老旧PLC的通信模块,太慢看板刷新会有明显滞后。500ms是产线看板的常用值。

2.3 边缘预处理:在采集端做过滤和聚合

原始数据直接上传有两个问题:一是流量大,二是噪声多。我习惯在边缘网关做一层预处理:变化不超阈值的值不传,比如温度波动小于0.5度就不发;累计量在边缘做增量计算,只传增量而不是全量。这样MQTT的消息量能降一个数量级。

具体做法是在采集脚本里加一个缓存字典,记录上一次的值,只有变化超过阈值才发布。对于产量这种只增不减的计数器,传增量比传全量更安全,因为网络抖动导致的消息丢失不会影响累计值的正确性。

3. 后端聚合层:实时计算与看板数据模型设计

数据到了MQTT,下一步是消费、聚合、存储。看板对后端的要求很明确:写入要快,查询要快,聚合要准。这一层选型错了,后面前端怎么优化都白搭。

3.1 用Flink还是自己写消费者:小规模场景的务实选择

如果产线数量在10条以内,设备点位在1000个以内,我建议不要上Flink。Flink的运维复杂度对于中小型MES项目来说是过度设计。自己写一个Python或Java消费者,消费MQTT消息,写入时序数据库,再用定时任务做聚合,完全够用。

常见做法是:MQTT -> 消费者 -> 时序数据库(如TDengine或InfluxDB)-> 聚合任务 -> 关系数据库(如PostgreSQL)-> API服务。时序库存原始点位,关系库存聚合结果。看板查询走关系库,因为看板的查询模式是固定的几个维度,关系库的索引足够快。

3.2 看板数据模型:事实表与维度表的字段设计

看板的数据模型不需要太复杂,但几个关键字段必须有。下面是一个工单进度看板的事实表设计。

-- 工单进度事实表,每5分钟聚合一次 CREATE TABLE fact_work_order_progress ( id BIGSERIAL PRIMARY KEY, work_order_no VARCHAR(32) NOT NULL, -- 工单号 line_code VARCHAR(16) NOT NULL, -- 产线编码 product_code VARCHAR(32) NOT NULL, -- 产品编码 plan_qty INTEGER NOT NULL, -- 计划数量 output_qty INTEGER NOT NULL, -- 实际产出 defect_qty INTEGER DEFAULT 0, -- 不良数量 device_status SMALLINT DEFAULT 0, -- 0停机 1运行 2故障 oee NUMERIC(5,2), -- 设备综合效率 stat_time TIMESTAMPTZ NOT NULL, -- 统计时间点 created_at TIMESTAMPTZ DEFAULT NOW() ); -- 按产线和时间建复合索引,看板查询主要走这个 CREATE INDEX idx_progress_line_time ON fact_work_order_progress (line_code, stat_time DESC); -- 按工单号查历史 CREATE INDEX idx_progress_wo ON fact_work_order_progress (work_order_no);

逻辑说明:这张表每5分钟由聚合任务写入一条记录,记录当前时刻各产线各工单的累计产出和状态。看板查询时,取每个工单最新的一条记录,就能算出进度百分比。stat_time用带时区的时间戳,避免跨时区工厂的显示问题。

参数说明:oee字段是计算值,公式是可用率 × 性能率 × 良品率。可用率 = 实际运行时间 / 计划运行时间,性能率 = 理论节拍 × 产出 / 实际运行时间,良品率 = 良品数 / 总产出。这三个率的数据来源不同,可用率来自设备状态日志,性能率来自标准工时表,良品率来自质量模块。聚合任务要跨表关联,建议用物化视图或定时任务预计算,不要在查询时实时算。

3.3 聚合任务的实现:从原始点位到看板指标

聚合任务的核心逻辑是:按时间窗口读取原始点位,计算每个工单的累计产出、不良数、设备运行时长,然后写入事实表。下面是一个用Python实现的简化版聚合任务。

# aggregator.py import psycopg2 from datetime import datetime, timedelta def aggregate_line(line_code, window_minutes=5): conn = psycopg2.connect("dbname=mes user=mes password=*** host=127.0.0.1") cur = conn.cursor() end_time = datetime.now() start_time = end_time - timedelta(minutes=window_minutes) # 从原始点位表读取窗口内的产量增量 cur.execute(""" SELECT work_order_no, product_code, SUM(output_delta) AS output_qty, SUM(defect_delta) AS defect_qty, AVG(CASE WHEN device_status = 1 THEN 1 ELSE 0 END) AS run_rate FROM raw_plc_points WHERE line_code = %s AND ts BETWEEN %s AND %s GROUP BY work_order_no, product_code """, (line_code, start_time, end_time)) rows = cur.fetchall() for wo, product, output, defect, run_rate in rows: # 查计划数量 cur.execute("SELECT plan_qty FROM dim_work_order WHERE work_order_no = %s", (wo,)) plan_qty = cur.fetchone()[0] # 计算OEE简化版:运行率 * 良品率 quality_rate = (output - defect) / output if output > 0 else 0 oee = round(run_rate * quality_rate * 100, 2) cur.execute(""" INSERT INTO fact_work_order_progress (work_order_no, line_code, product_code, plan_qty, output_qty, defect_qty, device_status, oee, stat_time) VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s) """, (wo, line_code, product, plan_qty, output, defect, 1 if run_rate > 0.5 else 0, oee, end_time)) conn.commit() cur.close() conn.close()

逻辑说明:任务每5分钟跑一次,读取原始点位表中该窗口的产量增量,按工单分组汇总,然后关联工单计划数量,计算OEE,写入事实表。run_rate是设备运行时间占比,超过50%视为运行状态。

参数说明:window_minutes默认5分钟,看板刷新频率如果是1分钟,这个窗口要相应缩小到1分钟,但聚合频率太高会增加数据库压力。我一般建议看板刷新5分钟一次,聚合窗口也是5分钟,两者对齐。raw_plc_points表是原始点位表,建议用TDengine或TimescaleDB,写入性能比普通PostgreSQL高一个数量级。

4. 前端渲染层:看板布局、实时刷新与性能优化

前端是看板的脸面,但也是最容易翻车的地方。大屏分辨率五花八门,浏览器性能参差不齐,数据刷新频率和渲染帧率要平衡。这一层做不好,前面采得再准也白费。

4.1 布局方案:CSS Grid还是绝对定位

看板布局有两种主流做法:CSS Grid和绝对定位。CSS Grid适合响应式,不同分辨率下自动调整;绝对定位适合固定大屏,像素级控制。

我一般会推荐CSS Grid为主,绝对定位为辅。整体用Grid划分区域,比如顶部状态栏、左侧产线列表、中间主图表、右侧报警面板。每个区域内部用Flexbox排列卡片。这样在1920×1080和3840×2160上都能正常显示,不用为每个分辨率单独写一套样式。

关键参数:设计稿按1920×1080做,用rem或vw做单位,根字体大小设为16px,然后所有尺寸用rem。大屏上通过JavaScript动态调整根字体大小,比如document.documentElement.style.fontSize = (window.innerWidth / 1920 * 16) + 'px',这样整体等比缩放。

4.2 实时刷新:WebSocket与轮询的取舍

看板数据刷新有两种方式:WebSocket推送和HTTP轮询。WebSocket实时性高,但连接管理和断线重连要处理好;轮询实现简单,但延迟和服务器压力是问题。

我的经验是:关键指标(如设备故障、产线停线)用WebSocket推送,普通指标(如产量进度)用30秒轮询。这样既保证关键事件秒级到达,又不会让WebSocket连接数过多。

下面是一个WebSocket客户端的实现,带自动重连和心跳。

// dashboard-ws.js class DashboardSocket { constructor(url, onMessage) { this.url = url; this.onMessage = onMessage; this.reconnectDelay = 1000; this.maxDelay = 30000; this.connect(); } connect() { this.ws = new WebSocket(this.url); this.ws.onopen = () => { console.log('看板WebSocket已连接'); this.reconnectDelay = 1000; // 重置重连延迟 this.startHeartbeat(); }; this.ws.onmessage = (event) => { const data = JSON.parse(event.data); if (data.type === 'pong') return; // 心跳响应 this.onMessage(data); }; this.ws.onclose = () => { this.stopHeartbeat(); console.warn(`连接断开,${this.reconnectDelay}ms后重连`); setTimeout(() => this.connect(), this.reconnectDelay); // 指数退避,最大30秒 this.reconnectDelay = Math.min(this.reconnectDelay * 2, this.maxDelay); }; this.ws.onerror = (err) => { console.error('WebSocket错误', err); this.ws.close(); }; } startHeartbeat() { this.heartbeatTimer = setInterval(() => { if (this.ws.readyState === WebSocket.OPEN) { this.ws.send(JSON.stringify({ type: 'ping' })); } }, 15000); // 15秒心跳 } stopHeartbeat() { if (this.heartbeatTimer) clearInterval(this.heartbeatTimer); } } // 使用示例 const socket = new DashboardSocket('ws://192.168.1.30:8080/ws/dashboard', (data) => { if (data.metric === 'device_status') { updateDeviceStatus(data.line_code, data.status); } else if (data.metric === 'output_qty') { updateOutputChart(data.work_order_no, data.qty); } });

逻辑说明:类封装了WebSocket的连接、心跳、断线重连。onmessage里区分心跳响应和业务数据。断线后指数退避重连,避免服务器刚重启就被大量重连请求打垮。

参数说明:心跳间隔15秒,服务端超过30秒没收到心跳就断开连接。重连延迟从1秒开始,每次翻倍,最大30秒。这个策略在车间网络不稳定的环境下很实用,不会因为一次网络抖动就疯狂重连。

4.3 图表性能:ECharts大数据量下的降采样与增量更新

看板上的趋势图如果展示一天的秒级数据,就是86400个点,ECharts直接渲染会卡死。解决办法是降采样和增量更新。

降采样用LTTB算法,把86400个点降到1000个点,视觉上几乎无差别。ECharts的sampling: 'lttb'配置可以直接开启。增量更新是只更新变化的数据点,而不是每次setOption全量重绘。

// 趋势图配置 const chartOption = { series: [{ type: 'line', sampling: 'lttb', // 开启LTTB降采样 large: true, // 开启大数据量优化 largeThreshold: 2000, // 超过2000点启用 data: [] // 初始为空 }] }; // 增量更新:只追加新数据点 function appendData(chart, newPoints) { const series = chart.getOption().series[0]; const data = series.data.concat(newPoints); // 只保留最近2000个点 if (data.length > 2000) data.splice(0, data.length - 2000); chart.setOption({ series: [{ data }] }); }

逻辑说明:sampling: 'lttb'让ECharts在渲染时自动降采样,large: true开启大数据量渲染优化。增量更新时只保留最近2000个点,避免内存无限增长。

参数说明:largeThreshold设为2000,超过这个点数才启用优化。如果看板刷新频率是5秒,2000个点覆盖约2.7小时,足够展示一个班次的生产趋势。

5. 避坑与排查:看板项目中最容易翻车的五个问题

看板项目从POC到稳定运行,中间有一堆坑。下面五个是我踩过最狠的,按现象、原因、解决三段式写清楚。

5.1 数据延迟越来越大,重启后恢复

现象:看板运行几天后,数据延迟从秒级变成分钟级,重启采集服务后恢复正常。

原因:采集脚本里用了同步阻塞的time.sleep,如果某次PLC响应慢,整个循环被拖长,消息在MQTT里堆积。另外,如果消费者处理速度跟不上生产速度,MQTT队列会持续增长。

解决:采集端改用异步IO或独立线程,每个PLC一个采集线程,互不阻塞。消费端监控MQTT队列深度,超过阈值就告警。我一般会在消费者里加一个计数器,每处理1000条消息打印一次队列积压量。

5.2 产量数据对不上,总是少一点

现象:看板显示的产量比实际产出少,而且越到后面差得越多。

原因:Modbus寄存器读取时,产量计数器是32位,占两个寄存器。如果只读了一个寄存器,高位丢失,数值就偏小。另外,如果采集周期内产量变化超过一个采集周期,增量计算会漏掉。

解决:确认寄存器映射表里32位数据的读取方式,高位和低位寄存器的顺序要对。增量计算时,用当前值减上一次值,如果当前值小于上一次值,说明计数器溢出或PLC重启,要特殊处理。我一般会在边缘端维护一个累计值,每次用PLC原始值更新,而不是依赖增量。

5.3 大屏浏览器内存泄漏,几天后卡死

现象:看板大屏的浏览器开了几天后越来越卡,最后白屏。

原因:WebSocket消息不断到达,每次setOption都创建新的图表实例或DOM节点,旧的对象没被回收。ECharts的setOption默认是合并模式,但如果每次都传完整配置,会创建大量临时对象。

解决:用chart.setOption(option, { notMerge: false, lazyUpdate: true }),开启懒更新。数据更新用appendData而不是全量替换。另外,定时刷新页面,比如每天凌晨3点自动location.reload(),这是最简单的后悔药。

5.4 设备状态显示错误,停机显示运行

现象:设备明明停了,看板上还是绿色运行状态。

原因:设备状态来自PLC的一个寄存器,但PLC在通信中断时会保持最后一个值。如果采集服务断了,看板不知道,继续显示旧状态。

解决:在采集数据里加时间戳,看板端判断数据是否超时。超过3个采集周期没更新,状态置灰并显示“通信中断”。这个逻辑要在前端做,不能依赖后端推送,因为后端也可能挂。

5.5 跨班次产量统计错误,夜班算到白班

现象:交接班时,产量数据归属错误,夜班的产量算到了白班头上。

原因:聚合任务按自然时间窗口切分,但班次边界不是整点。比如白班是8:00-20:00,夜班是20:00-8:00,如果聚合窗口是5分钟,20:00那个窗口的数据会被切到两个班次。

解决:在工单维度加一个shift_code字段,采集时根据当前时间判断班次,写入原始点位。聚合时按shift_code分组,而不是按时间窗口。班次定义表要可配置,不同车间班次时间可能不同。

6. 进阶技巧:用物化视图和缓存把看板查询压到50毫秒内

看板查询的性能瓶颈通常在聚合计算。如果每次刷新都实时算OEE、算累计产量,数据库扛不住。我的做法是用物化视图预计算,再加一层Redis缓存。

物化视图的创建很简单,把聚合查询固化下来,定时刷新。

-- 创建物化视图,预计算每个工单的最新进度 CREATE MATERIALIZED VIEW mv_work_order_latest AS SELECT DISTINCT ON (work_order_no) work_order_no, line_code, product_code, plan_qty, output_qty, defect_qty, device_status, oee, stat_time FROM fact_work_order_progress ORDER BY work_order_no, stat_time DESC; -- 每5分钟刷新一次,不阻塞查询 REFRESH MATERIALIZED VIEW CONCURRENTLY mv_work_order_latest;

逻辑说明:DISTINCT ON取每个工单最新的一条记录,CONCURRENTLY让刷新时不锁表,看板查询不受影响。刷新任务用pg_cron或外部定时器每5分钟跑一次。

参数说明:物化视图的刷新频率要和聚合任务对齐,聚合任务写完事实表后触发刷新。如果数据量很大,CONCURRENTLY需要唯一索引,建在work_order_no上。

Redis缓存层加在API和数据库之间。API收到请求后,先查Redis,命中就返回,没命中再查物化视图,然后写回Redis,过期时间设30秒。这样看板的重复查询基本不走数据库。

# api_cache.py import redis, json from flask import Flask, jsonify app = Flask(__name__) r = redis.Redis(host='127.0.0.1', port=6379, db=0) @app.route('/api/dashboard/<line_code>') def get_dashboard(line_code): cache_key = f"dashboard:{line_code}" cached = r.get(cache_key) if cached: return jsonify(json.loads(cached)) # 查物化视图 cur = db.cursor() cur.execute(""" SELECT work_order_no, product_code, plan_qty, output_qty, defect_qty, device_status, oee FROM mv_work_order_latest WHERE line_code = %s """, (line_code,)) rows = cur.fetchall() result = [dict(zip([d[0] for d in cur.description], row)) for row in rows] r.setex(cache_key, 30, json.dumps(result)) # 缓存30秒 return jsonify(result)

逻辑说明:API先查Redis,命中直接返回,没命中查物化视图,结果写回Redis并设30秒过期。这样在30秒内,同一产线的看板请求都走缓存,数据库压力降一个数量级。

参数说明:setex的过期时间30秒,比看板刷新周期略短,保证数据不会太旧。如果看板刷新是5秒,缓存过期时间可以设3秒,但太短了缓存意义不大。我一般设30秒,看板端做增量更新,关键指标走WebSocket推送,不依赖这个缓存。

最后说一个我自己的习惯:看板上线前,我会用JMeter模拟50个并发请求,持续跑10分钟,看P99延迟和数据库连接数。如果P99超过200毫秒,就加缓存或优化索引。这个压测不做,上线后大概率被投诉。希望帮到你。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询