简介:这套基于统计过程控制(SPC)的在线质量监控系统源码,采用C#语言开发,运行于Visual Studio 2013与SQL Server 2012环境,是作者毕业设计成果,在校内评审中获得高分,适合计算机、自动化等专业学生用于课程设计、毕业课题,也可供制造企业质量管理人员参考。系统完整实现了用户身份验证、人员信息录入、产品与车间管理、工艺流程设定、设备资料维护、控制图异常判定参数设置等模块,覆盖质量管理从数据维护到过程监控的全流程。压缩包共199个文件、3.08MB,36个.cs源文件承载核心业务逻辑,79个.png图片为界面与图表素材,还包含zbak数据库备份、resx/resources资源文件、config配置、sln工程及exe可执行程序等,便于恢复环境并直接运行。系统集中展示了Xbar-R、Xmedian-R、X-Rs等控制图以及分布直方图、过程能力指数图的工程实现方式,数据列表、异常标识与任务处理状态管理也完整包含在内。目前已有71人学习下载,适合具备一定编程能力者在此基础上做功能扩展与优化。
1. 基于SPC的在线质量监控系统:一套C#源码怎么把控制图从Excel搬进车间
做质量的人对SPC都不陌生,但真正把SPC落地成在线监控系统,绝大多数企业停留在Excel里手工画控制图的阶段。我在给一个机加工车间做产线数据采集时,发现上午抽检的30个数据要下午才能算出均值极差图,等发现过程失控早就过了整改窗口。后来我拆解了一套基于SPC技术的C#源码,它把数据采集、控制图绘制、判异规则、报警联动完整串起来,适合做毕业设计、上位机入门,也适合质量工程师理解SPC的工程化实现。这套源码解决的核心问题是从数据到报警的闭环,而不是停留在理论公式上。下面按我从拆解到复现的路径来写,重点落在你能直接抄走的代码和参数上。
2. 控制图核心算法落地:均值极差图与判异规则的C#实现
2.1 为什么均值极差图是毕业设计与工程项目的默认选择
SPC里常用的控制图有均值极差图(Xbar-R)、均值标准差图(Xbar-S)、单值移动极差图(I-MR)。我见到的绝大多数C#毕业设计和中小工厂的在线监控系统,都选均值极差图,原因不在于标准差算起来复杂,而在于子组容量。
均值极差图的适用条件是子组容量n在2到9之间,这是最典型的抽检场景,比如每个班次每2小时抽5件产品做测量。当n大于9时,极差对数据离散程度的估计效率会明显下降,这时候才需要转向标准差法。对于一套通用源码来说,用极差法可以避开计算总体标准差时对n的依赖,也让控制限的更新变得轻量。
另一个现实原因是均值极差图的计算结果可以直接查表得到,A2、D3、D4这些系数是固定的,不依赖复杂的统计函数库。这对C#实现来说,意味着即使项目里没有引用MathNet.Numerics这类库,也能用最朴素的算术运算把控制限算出来。源码里通常会把系数表硬编码为一个字典,按子组容量n查表,这套做法在工程上足够可靠。
2.2 控制上下限计算:查表系数与C#核心实现
均值极差图的计算分两步走。先按子组算出均值和极差,再把所有子组的均值平均得到总均值X̄,把所有极差平均得到R̄。然后套用三个公式:
- 均值图的中心线CL = X̄
- 均值图的上限UCL = X̄ + A2 × R̄
- 均值图的下限LCL = X̄ - A2 × R̄
- 极差图的上限UCL = D4 × R̄
- 极差图的下限LCL = D3 × R̄
注意极差图的D3在n小于7的时候是0,这意味着小样本下极差图没有下限,这是很多初学者会忽略的边界条件。我见过有人把D3硬编码成0.076然后发现极差图的下限是负数,这就是查表查错了。
下面这段C#代码实现了控制限计算的核心方法,我按工程习惯把系数表提取成静态字典,并预留了样本不足时的异常处理。
public class SpcCalculator { private static readonly Dictionary<int, double[]> CoefTable = new Dictionary<int, double[]> { {2, new double[] {1.880, 0, 3.267}}, {3, new double[] {1.023, 0, 2.574}}, {4, new double[] {0.729, 0, 2.282}}, {5, new double[] {0.577, 0, 2.114}}, {6, new double[] {0.483, 0, 2.004}}, {7, new double[] {0.419, 0.076, 1.924}}, {8, new double[] {0.373, 0.136, 1.864}}, {9, new double[] {0.337, 0.184, 1.816}} }; public static SpcResult ComputeControlLimits(List<double[]> subgroups) { if (subgroups == null || subgroups.Count < 25) throw new ArgumentException("子组数量不足25个,控制限不可靠"); int n = subgroups[0].Length; if (!CoefTable.ContainsKey(n)) throw new NotSupportedException("仅支持子组容量2-9"); double[] coef = CoefTable[n]; double sumXBar = 0, sumR = 0; foreach (var group in subgroups) { double avg = group.Average(); double range = group.Max() - group.Min(); sumXBar += avg; sumR += range; } double grandMean = sumXBar / subgroups.Count; double meanRange = sumR / subgroups.Count; return new SpcResult { CenterLine = grandMean, XBarUcl = grandMean + coef[0] * meanRange, XBarLcl = grandMean - coef[0] * meanRange, RUcl = coef[2] * meanRange, RLcl = coef[1] * meanRange, MeanRange = meanRange }; } }这段代码里有三个细节值得说明。第一,子组数量少于25时会直接抛异常,这是SPC的基本要求,少于25个子组算出的控制限没有统计意义,很多毕设源码偷懒不检查,导致图上控制限乱跳。第二,系数表按子组容量n取值,而不是在方法里临时算,避免了多次转换的精度损失。第三,极差图的下限直接取自系数表,n小于7时是0,这正好对上了前面说的边界条件。
实际使用中,我一般会把控制限计算放到后台线程,因为如果子组数量上万,逐个循环计算均值极差会有可感知的卡顿。源码里如果看到直接用foreach在主线程算的版本,可以改成Parallel.For或者分批聚合。
2.3 判异规则的实现思路与代码
控制限只是SPC的第一层,真正的在线监控价值在于判异规则。经典SPC有八条判异准则,最常用的是前四条:超出控制限、连续7点在中心线同侧、连续7点递增或递减、2/3的点超出2σ区域。毕业设计源码里,我看到最多的是实现了前两条,因为后几条需要维护额外的统计状态。
连续7点同侧这个规则有一个常见的边界错误:判断时用了大于等于7,但每当出现一个反向点就重置计数。正确的做法是维护一个sideCounter,当点落在中心线以上时加1,以下时重置为0,反向同理。这样连续7点同侧才不会被隔断的点干扰。
public class OutOfControlDetector { private int _aboveCounter = 0; private int _belowCounter = 0; public List<string> Check(double value, double centerLine, double ucl, double lcl) { List<string> violations = new List<string>(); if (value > ucl || value < lcl) violations.Add("超出控制限"); if (value > centerLine) { _aboveCounter++; _belowCounter = 0; } else if (value < centerLine) { _belowCounter++; _aboveCounter = 0; } else { _aboveCounter = 0; _belowCounter = 0; } if (_aboveCounter >= 7 || _belowCounter >= 7) violations.Add("连续7点同侧"); return violations; } }这里的关键是连续7点同侧判断用到了双向计数器,而不是简单地数正面点。我在实际调试时发现,如果不将反向计数器重置,两个方向各出现3个点后,系统会误判为连续7点同侧,这在生产环境里会引发大量误报警。判异规则的输出直接作为报警模块的输入,触发记录写入数据库,同时前台Chart控件变色提示,这部分在后续章节展开。
3. 项目架构与数据库设计:WinForms界面与数据访问层怎么组织
3.1 三层结构拆分:UI、业务规则与数据访问
这套源码我拆解之后,发现它的工程结构比一般毕设规范,并没有把所有逻辑堆在Form.cs里。三个层次分别是界面层、业务逻辑层和数据访问层。界面层只负责显示和接收用户操作;业务逻辑层是核心,控制图计算、判异规则、报警判断都在这一层实现;数据访问层封装所有SQL操作,上层通过接口调用。
这样拆的好处很明显。第一个好处是可测试性,SPC算法脱离界面后可以用单元测试直接验证,不需要启动窗体和连接数据库。第二个好处是数据源替换,你后面如果想从SQL Server换成Access,只需要改数据访问层,业务逻辑不用动。很多毕设答辩时老师问一句“你怎么保证代码质量”,三层结构就是最好的回答。
但我也提醒一句,分层不是越细越好。网上有些开源项目拆到七八层,每层只有一个方法,反而让人看不懂。这套源码的三层结构是合理的,Controller层都没有,完全靠业务逻辑类暴露方法,简单直接。
3.2 数据库表设计:从测点定义到报警记录
数据库是整个SPC系统的数据基础。我通常会在源码基础上补充四张核心表的设计,分别是测点定义表、子组样本表、原始测量值表和判异记录表。测点定义表存的是产线、设备、测量参数这些静态信息,子组样本表存的是每个子组的均值和极差汇总,原始测量值表存的是每个工件的具体数值,判异记录表存的是触发报警的时刻和类型。
CREATE TABLE ProductPoint ( PointId INT IDENTITY(1,1) PRIMARY KEY, PointCode NVARCHAR(50) NOT NULL, PointName NVARCHAR(100) NOT NULL, SpecUpper DECIMAL(10,4) NOT NULL, SpecLower DECIMAL(10,4) NOT NULL, SubgroupSize INT NOT NULL DEFAULT 5, SampleInterval INT NOT NULL DEFAULT 30 ); CREATE TABLE SampleData ( SampleId INT IDENTITY(1,1) PRIMARY KEY, PointId INT NOT NULL FOREIGN KEY REFERENCES ProductPoint(PointId), SampleTime DATETIME NOT NULL DEFAULT GETDATE(), MeanValue DECIMAL(10,4) NOT NULL, RangeValue DECIMAL(10,4) NOT NULL ); CREATE TABLE OutOfControlRecord ( RecordId INT IDENTITY(1,1) PRIMARY KEY, SampleId INT NOT NULL FOREIGN KEY REFERENCES SampleData(SampleId), ViolationType NVARCHAR(50) NOT NULL, RecordTime DATETIME NOT NULL DEFAULT GETDATE() );表结构里的关键设计点是子组统计值和原始测量值分开存储。如果你的项目只需要控制图展示和判异历史查询,存SampleData表就够了,查询速度快,数据量也小得多。原始测量值表适合需要追溯每个工件来历的场景,但数据量会随产量线性膨胀,一般我会建议设置保留周期,比如只保留30天。
需要注意SubgroupSize和SampleInterval这两个字段放在测点定义表里,说明每个测点可以有独立的抽样频率和子组大小。这在多产线场景下特别重要,一条线的抽检频率是30分钟,另一条线是1小时,控制图应该按各自的配置刷新,而不是全局统一。
3.3 数据访问层:参数化查询与批量写入
数据访问层我最看重的是参数化查询。很多毕设源码喜欢字符串拼接SQL,一旦测点名称里出现单引号就会炸,更不用说SQL注入风险。这套源码里如果看到SqlCommand对象配合Parameters集合,那就说明作者有安全意识。
批量写入是另一个容易被忽略的细节。如果原始测量值是单条逐次INSERT,当数据采集频率高时会频繁触发日志写入,数据库压力很大。常见做法是用SqlBulkCopy或者表值参数,把一批测量值一次性写入。
public void BatchInsertSamples(DataTable dt) { using (var conn = new SqlConnection(_connectionString)) { conn.Open(); using (var bulkCopy = new SqlBulkCopy(conn)) { bulkCopy.DestinationTableName = "SampleData"; bulkCopy.ColumnMappings.Add("PointId", "PointId"); bulkCopy.ColumnMappings.Add("SampleTime", "SampleTime"); bulkCopy.ColumnMappings.Add("MeanValue", "MeanValue"); bulkCopy.ColumnMappings.Add("RangeValue", "RangeValue"); bulkCopy.BatchSize = 500; bulkCopy.WriteToServer(dt); } } }这里DataTable的结构必须和目标表字段完全一致,类型也要匹配,否则SqlBulkCopy在运行时会抛元数据异常。常见做法是先用DataTable.Columns.Add定义好结构,再逐行添加DataRow。BatchSize设置成500是一个折中值,太大容易占用内存,太小批量写入效果不明显。如果你的数据量特别大,还可以加一个bulkCopy.BulkCopyTimeout = 0,避免大批量写入时超时。
数据访问层的连接字符串建议放到App.config里,同时留一个切换Access的备选方案。很多毕设为了免安装数据库会用Access文件,这也没问题,但要注意Access不支持SqlBulkCopy,需要改用OleDbDataAdapter的UpdateBatchSize属性,性能确实差一些。
4. 实时数据接入与报警联动:串口、TCP和模拟源三种方案
4.1 串口采集:以扭矩仪Power Focus 6000为例
在线质量监控系统要在线,数据源接入就是绕不开的环节。我在车间现场遇到最多的是通过串口读取测量仪器的数据,比如阿特拉斯·科普柯的Power Focus 6000扭矩仪,它通过RS232串口输出测量结果。C#里操作串口用System.IO.Ports.SerialPort类,关键参数是波特率、数据位、停止位和校验位,扭矩仪一般默认是9600波特率、8数据位、1停止位、无校验。
串口数据接收有一个必须注意的坑:DataReceived事件是后台线程触发的,直接在这里更新UI控件会抛出跨线程异常。常规做法是用ConcurrentQueue存数据,再通过定时器或者BeginInvoke切回UI线程处理。
private SerialPort _serialPort; private ConcurrentQueue<string> _dataQueue = new ConcurrentQueue<string>(); public void InitSerialPort(string portName, int baudRate = 9600) { _serialPort = new SerialPort(portName, baudRate, Parity.None, 8, StopBits.One); _serialPort.DataReceived += (sender, e) => { string data = _serialPort.ReadExisting(); _dataQueue.Enqueue(data); }; _serialPort.Open(); } private void ProcessQueueTimer_Tick(object sender, EventArgs e) { if (_dataQueue.TryDequeue(out string rawData)) { double torqueValue = ParseTorqueValue(rawData); if (torqueValue > 0) { var subgroup = AddMeasurement(torqueValue); if (subgroup != null) { var result = SpcCalculator.ComputeControlLimits(_subgroups); UpdateChart(result, subgroup); } } } }这个实现的关键点在于用ConcurrentQueue做线程安全缓冲,DataReceived只负责往队列里塞数据,UI线程通过Timer定期取数据解析,彻底避开跨线程问题。解析扭矩值是个细活,Power Focus的输出格式一般包含测量编号、扭矩值、单位,常见做法是用正则提取数字部分,或者按逗号/制表符拆分。
需要注意SerialPort.ReadExisting()会读取缓冲区中的所有字符,如果一次事件里包含多条记录,你需要在解析时按换行符拆分。有些仪器是用回车符结尾的,有些用换行符,这个必须以实际抓到的字节流为准。我一般会先用串口调试工具抓一帧数据,确认帧头和帧尾,再写解析逻辑。
4.2 TCP与OPC:连接PLC与上位机
串口适合单台仪器,如果数据源是PLC或者多台上位机联网,就需要走TCP或者OPC。C#里TCP客户端用TcpClient类,连接到PLC的指定端口后持续接收数据流。OPC方案更适合工厂已经有组态软件的环境,通过OPC DA或OPC UA接口订阅变量变化。
这套源码如果只支持串口,你收到后想接PLC就需要自己加一段TCP处理逻辑。常见做法是在数据访问层之上抽象一个IDataSource接口,串口、TCP、模拟源全部实现同一个接口,这样报警模块和控制图模块不用改。代码上无非是把ReadExisting换成NetworkStream的Read,解析逻辑可以复用。
OPC UA在C#里一般用OPCFoundation的官方库,连接、订阅、读取变量整个过程比串口复杂不少,但稳定性也更高。对于毕设项目,除非题目明确要求对接PLC,否则我不建议一开始就上OPC,串口或模拟数据源足够把核心逻辑跑通。
4.3 模拟数据源:用正态分布生成器测通全流程
没有真实设备的时候,模拟数据源是调试SPC系统的前提。我在复现这套源码时,第一件事就是先用模拟数据把控制图跑通,再接入真实采集。模拟数据必须符合正态分布,因为SPC的前提假设是过程输出服从正态分布。用Box-Muller变换可以从均匀分布生成标准正态分布样本。
public static double[] GenerateNormalSamples(double mean, double stdDev, int count) { Random rand = new Random(); double[] samples = new double[count]; for (int i = 0; i < count; i++) { double u1 = 1.0 - rand.NextDouble(); double u2 = rand.NextDouble(); samples[i] = mean + stdDev * Math.Sqrt(-2.0 * Math.Log(u1)) * Math.Cos(2.0 * Math.PI * u2); } return samples; }这里用1.0减去随机值是为了避免u1取到0,因为Math.Log(0)是负无穷,会把结果变成NaN。每次采样前先检查均值是否在上一个控制限范围内,如果持续超出就触发报警,这样可以模拟过程漂移的效果。
报警联动在源码里一般有两种实现方式。一种是WinForms里的MessageBox弹窗,简单但会阻塞操作,频繁报警时很烦人。更合理的是通过事件驱动,判异规则触发后调用报警方法,在界面上非阻塞地改变状态栏颜色,同时写入OutOfControlRecord表。报警记录写入要保证时序,一般是先写记录,后刷新界面,避免界面更新失败导致记录丢失。
5. 常见问题排查:控制图不稳定的五个实测坑
5.1 控制限算出来是直线:子组数据没有按时间分段
现象:产线跑了三天,控制限在图上是一条水平的直线,完全不随过程波动变化。
原因:代码把所有历史数据一次性做全量均值,而不是按子组周期分段统计。控制限代表的是过程的基准状态,应该定期重新计算,比如每个班次结束后用当班的子组数据重算一次。
解决:在SPC配置里增加一个重算周期参数,源码里通常用Timer定时触发控制限重算,把最近N个子组传入ComputeControlLimits方法,而不是把全部数据都传进去。
5.2 均值图上频繁超限:没有先做粗差剔除
现象:控制图上线第一天就报了十几个超限点,但现场测量记录显示数据本身没有问题。
原因:传感器偶发毛刺或者测量夹具松动,让个别极端值混入了子组,计算子组均值时把整个子组拉偏了。SPC计算前通常需要对单点做粗差检验,剔除超出均值±3σ的离散值。
解决:在数据入口处加一个预处理过滤器,单点数值超出规格上下限的一定比例时标记为可疑数据,不参与子组统计,只在原始数据表里保留备查。
5.3 串口数据粘包导致解析错乱:没有做帧缓冲
现象:接收到的数据有时候一帧少几个字符,有时候两帧拼在一起,解析出的扭矩值偶尔是负数。
原因:串口事件触发时缓冲区里的数据不一定是完整的一帧,上位机可能只收到半个帧,下一次事件又携带着下一帧的头部,直接解析必然错乱。
解决:引入一个字符串缓冲,每次收到数据先追加到buffer末尾,然后按帧结束符换行或回车对缓冲做切分,只把包含完整结束符的帧取出来解析,剩余部分留到下次。
5.4 连续7点同侧误报:计数器没有在反向时重置
现象:明明曲线在上下波动,系统却报出连续7点同侧,操作工都怀疑系统疯了。
原因:判异代码里用单一计数器累加,点在上方时不管中间是否落回下方,只要总数达到7就报警。正确的连续7点同侧要求7个点全部在同一侧,中间出现对侧点计数归零。
解决:按前面OutOfControlDetector示例,用两个方向独立的计数器,状态转换时重置对应计数器,同时对中心线上的点做清零处理。
5.5 数据库时间错乱8小时:没有统一存储UTC
现象:界面显示的报警时间比实际时间晚8小时。
原因:服务器或本机时区是UTC+8,数据库字段用了datetime类型,插入时传入了DateTime.Now,但读取时用了Convert.ToDateTime后再ToString格式化,中间经过了本地化处理。
解决:数据库统一用datetime类型并按东八区当地时间存储,代码里所有时间字段统一通过DateTime.Now赋值,不要在字符串格式化时再指定时区,同时检查ConnectionString里的Connect Timeout设置是否影响了连接时效。
6. 进阶:子组容量动态调整与控制图报表导出的两个实用技巧
6.1 子组容量变化时如何动态更新控制限
生产节拍变化会导致抽检的子组容量不稳定,比如平时每批抽5件,某段时间订单紧急每批抽3件。控制图系数表是按子组容量区分的,容量变了控制限就该跟着变。技巧是把CoefTable做成按PointId配置的动态查询,而不是全局静态字典。每次数据进来先按当前测点的SubgroupSize查表,如果容量配置变更,立即重新计算控制限并刷新Chart。
6.2 用NPOI把控制图统计结果导出Excel
工厂质量例会需要每周的控制图报表,手动截图太原始。通常用NPOI库操持Excel导出,轻量且不需要安装Office。导出内容包含子组序号、均值、极差、控制限和判异标志,关键代码是将DataTable逐行写入Excel单元格,最后用FileStream保存文件。
public static void ExportToExcel(DataTable dt, string filePath) { using (FileStream fs = new FileStream(filePath, FileMode.Create, FileAccess.Write)) { IWorkbook workbook = new XSSFWorkbook(); ISheet sheet = workbook.CreateSheet("SPC Report"); IRow headerRow = sheet.CreateRow(0); for (int i = 0; i < dt.Columns.Count; i++) headerRow.CreateCell(i).SetCellValue(dt.Columns[i].ColumnName); for (int row = 0; row < dt.Rows.Count; row++) { IRow excelRow = sheet.CreateRow(row + 1); for (int col = 0; col < dt.Columns.Count; col++) excelRow.CreateCell(col).SetCellValue(dt.Rows[row][col].ToString()); } workbook.Write(fs); } }这段代码需要注意XSSFWorkbook对应xlsx格式,如果导出的是xls要用HSSFWorkbook。NPOI在设置单元格时不做类型推断,全按ToString写入,所以数值列在Excel里会变成文本格式,如果需要参与后续计算,可以为数值列单独调用SetCellValue并传入double类型。
从那以后,我每次复现一套SPC源码都会强制走一遍流程:先模拟数据跑通全链路,再查控制限计算的边界条件,最后拿真实串口数据验证解析。希望帮到你。
本文还有配套的精品资源,点击获取