From 865f1c087a1613019d96bc2d891e54bf0272775e Mon Sep 17 00:00:00 2001 From: "GukSang.Jin" Date: Mon, 18 May 2026 16:53:29 +0800 Subject: [PATCH] =?UTF-8?q?=E6=9B=B4=E6=96=B02026?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Models/PlcConfiguration.cs | 1 + Models/TestBatch.cs | 14 ++ Services/ExcelExportService.cs | 378 ++++++++++++++--------------- Services/TestCalculationService.cs | 147 +++++++++++ ViewModels/StationViewModel.cs | 205 +++++++++++----- Views/HistoryWindow.xaml | 4 + Views/SettingsWindow.xaml | 12 +- 7 files changed, 500 insertions(+), 261 deletions(-) create mode 100644 Services/TestCalculationService.cs diff --git a/Models/PlcConfiguration.cs b/Models/PlcConfiguration.cs index b080257..e519e87 100644 --- a/Models/PlcConfiguration.cs +++ b/Models/PlcConfiguration.cs @@ -20,6 +20,7 @@ public ushort HardnessShishilizhi { get; set; } // 兼容旧代码:硬度完成线圈与溶出或硬度实时值寄存器地址 + public ushort HardnessOver { get; set; } public ushort HardnessCompleteCoil { get; set; } public ushort HardnessPoSun { get; set; } // 脆碎度 diff --git a/Models/TestBatch.cs b/Models/TestBatch.cs index ef1f15f..ab386a9 100644 --- a/Models/TestBatch.cs +++ b/Models/TestBatch.cs @@ -69,6 +69,20 @@ namespace TabletTester2025.Models public bool DisintegrationPass { get; set; } public bool DissolutionPass { get; set; } + [NotMapped] + public string HardnessPassText => ToPassText(HardnessPass); + + [NotMapped] + public string FriabilityPassText => ToPassText(FriabilityPass); + + [NotMapped] + public string DisintegrationPassText => ToPassText(DisintegrationPass); + + [NotMapped] + public string DissolutionPassText => ToPassText(DissolutionPass); + public string TestType { get; set; } // "硬度", "脆碎度", "崩解", "溶出" + + private static string ToPassText(bool value) => value ? "合格" : "不合格"; } } diff --git a/Services/ExcelExportService.cs b/Services/ExcelExportService.cs index dd84c08..62c412c 100644 --- a/Services/ExcelExportService.cs +++ b/Services/ExcelExportService.cs @@ -1,151 +1,29 @@ -using OfficeOpenXml; +using OfficeOpenXml; +using OfficeOpenXml.Drawing.Chart; +using OfficeOpenXml.Style; +using System; using System.Collections.Generic; using System.IO; +using System.Linq; using TabletTester2025.Models; namespace TabletTester2025.Services { public class ExcelExportService { - public void ExportToExcel(IEnumerable batches, string filePath) { using var package = new ExcelPackage(new FileInfo(filePath)); - // 按测试类型分组 var hardnessData = batches.Where(b => b.TestType == "硬度").ToList(); var friabilityData = batches.Where(b => b.TestType == "脆碎度").ToList(); var disintegrationData = batches.Where(b => b.TestType == "崩解").ToList(); var dissolutionData = batches.Where(b => b.TestType == "溶出").ToList(); - // 硬度表 - if (hardnessData.Any()) - { - var sheet = package.Workbook.Worksheets.Add("硬度报表"); - // 表头 - sheet.Cells[1, 1].Value = "检测时间"; - sheet.Cells[1, 2].Value = "样品名称"; - sheet.Cells[1, 3].Value = "平均值(N)"; - sheet.Cells[1, 4].Value = "RSD(%)"; - sheet.Cells[1, 5].Value = "最大值(N)"; - sheet.Cells[1, 6].Value = "最小值(N)"; - sheet.Cells[1, 7].Value = "测试次数"; - sheet.Cells[1, 8].Value = "内控下限(N)"; - sheet.Cells[1, 9].Value = "内控上限(N)"; - int row = 2; - foreach (var b in hardnessData) - { - sheet.Cells[row, 1].Value = b.TestTime.ToString("yyyy-MM-dd HH:mm:ss"); - sheet.Cells[row, 2].Value = b.SampleName; - sheet.Cells[row, 3].Value = b.HardnessAvg; - sheet.Cells[row, 4].Value = b.HardnessRSD; - sheet.Cells[row, 5].Value = b.HardnessMax; - sheet.Cells[row, 6].Value = b.HardnessMin; - sheet.Cells[row, 7].Value = b.HardnessTestCount; - sheet.Cells[row, 8].Value = b.HardnessInternalMin; - sheet.Cells[row, 9].Value = b.HardnessInternalMax; - row++; - } - sheet.Cells.AutoFitColumns(); - } - else - { - // 如果没有数据也创建空表(可选),根据需求决定 - var sheet = package.Workbook.Worksheets.Add("硬度报表"); - sheet.Cells[1, 1].Value = "无硬度测试数据"; - } - - // 脆碎度表 - if (friabilityData.Any()) - { - var sheet = package.Workbook.Worksheets.Add("脆碎度报表"); - sheet.Cells[1, 1].Value = "检测时间"; - sheet.Cells[1, 2].Value = "样品名称"; - sheet.Cells[1, 3].Value = "失重率(%)"; - sheet.Cells[1, 4].Value = "设定转速(r/min)"; - sheet.Cells[1, 5].Value = "试验转数"; - sheet.Cells[1, 6].Value = "前重(g)"; - sheet.Cells[1, 7].Value = "后重(g)"; - int row = 2; - foreach (var b in friabilityData) - { - sheet.Cells[row, 1].Value = b.TestTime.ToString("yyyy-MM-dd HH:mm:ss"); - sheet.Cells[row, 2].Value = b.SampleName; - sheet.Cells[row, 3].Value = b.FriabilityLoss; - sheet.Cells[row, 4].Value = b.FriabilityTargetRpm; - sheet.Cells[row, 5].Value = b.FriabilityTargetRounds; - sheet.Cells[row, 6].Value = b.WeightBefore; - sheet.Cells[row, 7].Value = b.WeightAfter; - row++; - } - sheet.Cells.AutoFitColumns(); - } - else - { - var sheet = package.Workbook.Worksheets.Add("脆碎度报表"); - sheet.Cells[1, 1].Value = "无脆碎度测试数据"; - } - - // 崩解表 - if (disintegrationData.Any()) - { - var sheet = package.Workbook.Worksheets.Add("崩解报表"); - sheet.Cells[1, 1].Value = "检测时间"; - sheet.Cells[1, 2].Value = "样品名称"; - sheet.Cells[1, 3].Value = "剂型规则"; - sheet.Cells[1, 4].Value = "时限(秒)"; - sheet.Cells[1, 5].Value = "崩解时间(秒)"; - sheet.Cells[1, 6].Value = "剩余未崩解管"; - sheet.Cells[1, 7].Value = "水浴温度(℃)"; - int row = 2; - foreach (var b in disintegrationData) - { - sheet.Cells[row, 1].Value = b.TestTime.ToString("yyyy-MM-dd HH:mm:ss"); - sheet.Cells[row, 2].Value = b.SampleName; - sheet.Cells[row, 3].Value = b.DisintegrationDosageForm; - sheet.Cells[row, 4].Value = b.DisintegrationLimitSeconds; - sheet.Cells[row, 5].Value = b.DisintegrationTimeSec; - sheet.Cells[row, 6].Value = b.RemainingTubesAtEnd; - sheet.Cells[row, 7].Value = b.DisintegrationTemp; - row++; - } - sheet.Cells.AutoFitColumns(); - } - else - { - var sheet = package.Workbook.Worksheets.Add("崩解报表"); - sheet.Cells[1, 1].Value = "无崩解测试数据"; - } - - // 溶出表 - if (dissolutionData.Any()) - { - var sheet = package.Workbook.Worksheets.Add("溶出报表"); - sheet.Cells[1, 1].Value = "检测时间"; - sheet.Cells[1, 2].Value = "样品名称"; - sheet.Cells[1, 3].Value = "通道"; - sheet.Cells[1, 4].Value = "30min溶出度(%)"; - sheet.Cells[1, 5].Value = "R²"; - sheet.Cells[1, 6].Value = "取样明细"; - int row = 2; - foreach (var b in dissolutionData) - { - sheet.Cells[row, 1].Value = b.TestTime.ToString("yyyy-MM-dd HH:mm:ss"); - sheet.Cells[row, 2].Value = b.SampleName; - sheet.Cells[row, 3].Value = b.DissolutionChannel; - sheet.Cells[row, 4].Value = b.DissolutionRate30Min; - sheet.Cells[row, 5].Value = b.DissolutionRSquared; - sheet.Cells[row, 6].Value = b.DissolutionSampleSummary; - row++; - } - sheet.Cells.AutoFitColumns(); - AddDissolutionSamplesSheet(package, dissolutionData); - } - else - { - var sheet = package.Workbook.Worksheets.Add("溶出报表"); - sheet.Cells[1, 1].Value = "无溶出测试数据"; - } + AddHardnessSheet(package, hardnessData); + AddFriabilitySheet(package, friabilityData); + AddDisintegrationSheet(package, disintegrationData); + AddDissolutionSheet(package, dissolutionData); package.Save(); } @@ -153,20 +31,46 @@ namespace TabletTester2025.Services public void ExportHardnessToExcel(IEnumerable batches, string filePath) { using var package = new ExcelPackage(new FileInfo(filePath)); + AddHardnessSheet(package, batches); + package.Save(); + } + + public void ExportFriabilityToExcel(IEnumerable batches, string filePath) + { + using var package = new ExcelPackage(new FileInfo(filePath)); + AddFriabilitySheet(package, batches); + package.Save(); + } + + public void ExportDisintegrationToExcel(IEnumerable batches, string filePath) + { + using var package = new ExcelPackage(new FileInfo(filePath)); + AddDisintegrationSheet(package, batches); + package.Save(); + } + + public void ExportDissolutionToExcel(IEnumerable batches, string filePath) + { + using var package = new ExcelPackage(new FileInfo(filePath)); + AddDissolutionSheet(package, batches); + package.Save(); + } + + private static void AddHardnessSheet(ExcelPackage package, IEnumerable batches) + { + var data = batches.ToList(); var sheet = package.Workbook.Worksheets.Add("硬度报表"); - // 只导出硬度相关列 - sheet.Cells[1, 1].Value = "检测时间"; - sheet.Cells[1, 2].Value = "样品名称"; - sheet.Cells[1, 3].Value = "平均值(N)"; - sheet.Cells[1, 4].Value = "RSD(%)"; - sheet.Cells[1, 5].Value = "最大值(N)"; - sheet.Cells[1, 6].Value = "最小值(N)"; - sheet.Cells[1, 7].Value = "测试次数"; - sheet.Cells[1, 8].Value = "内控下限(N)"; - sheet.Cells[1, 9].Value = "内控上限(N)"; + WriteHeader(sheet, "检测时间", "样品名称", "平均值(N)", "RSD(%)", "最大值(N)", "最小值(N)", "测试次数", "内控下限(N)", "内控上限(N)", "判定"); + + if (data.Count == 0) + { + sheet.Cells[2, 1].Value = "无硬度测试数据"; + sheet.Cells.AutoFitColumns(); + return; + } int row = 2; - foreach (var b in batches) + foreach (var b in data) { sheet.Cells[row, 1].Value = b.TestTime.ToString("yyyy-MM-dd HH:mm:ss"); sheet.Cells[row, 2].Value = b.SampleName; @@ -177,26 +81,28 @@ namespace TabletTester2025.Services sheet.Cells[row, 7].Value = b.HardnessTestCount; sheet.Cells[row, 8].Value = b.HardnessInternalMin; sheet.Cells[row, 9].Value = b.HardnessInternalMax; + sheet.Cells[row, 10].Value = b.HardnessPassText; row++; } + sheet.Cells.AutoFitColumns(); - package.Save(); } - public void ExportFriabilityToExcel(IEnumerable batches, string filePath) + private static void AddFriabilitySheet(ExcelPackage package, IEnumerable batches) { - using var package = new ExcelPackage(new FileInfo(filePath)); + var data = batches.ToList(); var sheet = package.Workbook.Worksheets.Add("脆碎度报表"); - sheet.Cells[1, 1].Value = "检测时间"; - sheet.Cells[1, 2].Value = "样品名称"; - sheet.Cells[1, 3].Value = "失重率(%)"; - sheet.Cells[1, 4].Value = "设定转速(r/min)"; - sheet.Cells[1, 5].Value = "试验转数"; - sheet.Cells[1, 6].Value = "前重(g)"; - sheet.Cells[1, 7].Value = "后重(g)"; + WriteHeader(sheet, "检测时间", "样品名称", "失重率(%)", "设定转速(r/min)", "试验转数", "前重(g)", "后重(g)", "判定"); + + if (data.Count == 0) + { + sheet.Cells[2, 1].Value = "无脆碎度测试数据"; + sheet.Cells.AutoFitColumns(); + return; + } int row = 2; - foreach (var b in batches) + foreach (var b in data) { sheet.Cells[row, 1].Value = b.TestTime.ToString("yyyy-MM-dd HH:mm:ss"); sheet.Cells[row, 2].Value = b.SampleName; @@ -205,26 +111,28 @@ namespace TabletTester2025.Services sheet.Cells[row, 5].Value = b.FriabilityTargetRounds; sheet.Cells[row, 6].Value = b.WeightBefore; sheet.Cells[row, 7].Value = b.WeightAfter; + sheet.Cells[row, 8].Value = b.FriabilityPassText; row++; } + sheet.Cells.AutoFitColumns(); - package.Save(); } - public void ExportDisintegrationToExcel(IEnumerable batches, string filePath) + private static void AddDisintegrationSheet(ExcelPackage package, IEnumerable batches) { - using var package = new ExcelPackage(new FileInfo(filePath)); + var data = batches.ToList(); var sheet = package.Workbook.Worksheets.Add("崩解报表"); - sheet.Cells[1, 1].Value = "检测时间"; - sheet.Cells[1, 2].Value = "样品名称"; - sheet.Cells[1, 3].Value = "剂型规则"; - sheet.Cells[1, 4].Value = "时限(秒)"; - sheet.Cells[1, 5].Value = "崩解时间(秒)"; - sheet.Cells[1, 6].Value = "剩余未崩解管"; - sheet.Cells[1, 7].Value = "水浴温度(℃)"; + WriteHeader(sheet, "检测时间", "样品名称", "剂型规则", "时限(秒)", "崩解时间(秒)", "剩余未崩解管", "水浴温度(℃)", "判定"); + + if (data.Count == 0) + { + sheet.Cells[2, 1].Value = "无崩解测试数据"; + sheet.Cells.AutoFitColumns(); + return; + } int row = 2; - foreach (var b in batches) + foreach (var b in data) { sheet.Cells[row, 1].Value = b.TestTime.ToString("yyyy-MM-dd HH:mm:ss"); sheet.Cells[row, 2].Value = b.SampleName; @@ -233,25 +141,28 @@ namespace TabletTester2025.Services sheet.Cells[row, 5].Value = b.DisintegrationTimeSec; sheet.Cells[row, 6].Value = b.RemainingTubesAtEnd; sheet.Cells[row, 7].Value = b.DisintegrationTemp; + sheet.Cells[row, 8].Value = b.DisintegrationPassText; row++; } + sheet.Cells.AutoFitColumns(); - package.Save(); } - public void ExportDissolutionToExcel(IEnumerable batches, string filePath) + private static void AddDissolutionSheet(ExcelPackage package, IEnumerable batches) { - using var package = new ExcelPackage(new FileInfo(filePath)); + var data = batches.ToList(); var sheet = package.Workbook.Worksheets.Add("溶出报表"); - sheet.Cells[1, 1].Value = "检测时间"; - sheet.Cells[1, 2].Value = "样品名称"; - sheet.Cells[1, 3].Value = "通道"; - sheet.Cells[1, 4].Value = "30min溶出度(%)"; - sheet.Cells[1, 5].Value = "R²"; - sheet.Cells[1, 6].Value = "取样明细"; + WriteHeader(sheet, "检测时间", "样品名称", "通道", "30min溶出度(%)", "R²", "取样明细", "判定"); + + if (data.Count == 0) + { + sheet.Cells[2, 1].Value = "无溶出测试数据"; + sheet.Cells.AutoFitColumns(); + return; + } int row = 2; - foreach (var b in batches) + foreach (var b in data) { sheet.Cells[row, 1].Value = b.TestTime.ToString("yyyy-MM-dd HH:mm:ss"); sheet.Cells[row, 2].Value = b.SampleName; @@ -259,34 +170,23 @@ namespace TabletTester2025.Services sheet.Cells[row, 4].Value = b.DissolutionRate30Min; sheet.Cells[row, 5].Value = b.DissolutionRSquared; sheet.Cells[row, 6].Value = b.DissolutionSampleSummary; + sheet.Cells[row, 7].Value = b.DissolutionPassText; row++; } + sheet.Cells.AutoFitColumns(); - AddDissolutionSamplesSheet(package, batches); - package.Save(); + AddDissolutionSamplesSheet(package, data); + AddDissolutionChartSheet(package, data); } private static void AddDissolutionSamplesSheet(ExcelPackage package, IEnumerable batches) { - var samples = batches - .SelectMany(batch => (batch.DissolutionSamples ?? Enumerable.Empty()) - .Select(sample => new { Batch = batch, Sample = sample })) - .OrderBy(x => x.Batch.TestTime) - .ThenBy(x => x.Sample.Channel) - .ThenBy(x => x.Sample.ScheduledTimeMin) - .ToList(); - + var samples = GetDissolutionSampleRows(batches).ToList(); if (samples.Count == 0) return; var sheet = package.Workbook.Worksheets.Add("溶出取样明细"); - sheet.Cells[1, 1].Value = "检测时间"; - sheet.Cells[1, 2].Value = "样品名称"; - sheet.Cells[1, 3].Value = "通道"; - sheet.Cells[1, 4].Value = "计划时间(min)"; - sheet.Cells[1, 5].Value = "实际时间(min)"; - sheet.Cells[1, 6].Value = "溶出度(%)"; - sheet.Cells[1, 7].Value = "记录时间"; + WriteHeader(sheet, "检测时间", "样品名称", "通道", "计划时间(min)", "实际时间(min)", "溶出度(%)", "记录时间"); int row = 2; foreach (var item in samples) @@ -304,7 +204,103 @@ namespace TabletTester2025.Services sheet.Cells.AutoFitColumns(); } + private static void AddDissolutionChartSheet(ExcelPackage package, IEnumerable batches) + { + var seriesGroups = GetDissolutionSampleRows(batches) + .Where(x => x.Sample.Percent.HasValue + && double.IsFinite(x.Sample.ScheduledTimeMin) + && x.Sample.ScheduledTimeMin >= 0 + && double.IsFinite(x.Sample.Percent.Value)) + .GroupBy(x => new + { + x.Batch.Id, + x.Batch.TestTime, + x.Batch.SampleName, + x.Sample.ChannelName + }) + .Select(group => new + { + Name = $"{group.Key.TestTime:MM-dd HH:mm} {group.Key.SampleName} {group.Key.ChannelName}", + Points = group + .OrderBy(x => x.Sample.ScheduledTimeMin) + .Select(x => new { Time = x.Sample.ScheduledTimeMin, Percent = x.Sample.Percent!.Value }) + .ToList() + }) + .Where(group => group.Points.Count > 0) + .ToList(); + var sheet = package.Workbook.Worksheets.Add("溶出曲线"); + if (seriesGroups.Count == 0) + { + sheet.Cells[1, 1].Value = "无可绘制溶出曲线数据"; + sheet.Cells.AutoFitColumns(); + return; + } + int column = 1; + foreach (var group in seriesGroups) + { + sheet.Cells[1, column].Value = $"{group.Name} 时间(min)"; + sheet.Cells[1, column + 1].Value = $"{group.Name} 溶出度(%)"; + + int row = 2; + foreach (var point in group.Points) + { + sheet.Cells[row, column].Value = point.Time; + sheet.Cells[row, column + 1].Value = point.Percent; + row++; + } + + column += 3; + } + + using (var header = sheet.Cells[1, 1, 1, Math.Max(1, column - 2)]) + { + header.Style.Font.Bold = true; + } + + var chart = sheet.Drawings.AddChart("DissolutionCurve", eChartType.LineMarkers); + chart.Title.Text = "溶出曲线"; + chart.SetPosition(1, 0, column, 0); + chart.SetSize(900, 420); + chart.XAxis.Title.Text = "计划时间(min)"; + chart.YAxis.Title.Text = "溶出度(%)"; + chart.YAxis.MinValue = 0; + chart.YAxis.MaxValue = 150; + chart.Legend.Position = eLegendPosition.Bottom; + + column = 1; + foreach (var group in seriesGroups) + { + int endRow = group.Points.Count + 1; + var series = chart.Series.Add( + sheet.Cells[2, column + 1, endRow, column + 1], + sheet.Cells[2, column, endRow, column]); + series.Header = group.Name; + column += 3; + } + + sheet.Cells.AutoFitColumns(); + } + + private static IEnumerable<(TestBatch Batch, DissolutionSamplePoint Sample)> GetDissolutionSampleRows(IEnumerable batches) + { + return batches + .SelectMany(batch => (batch.DissolutionSamples ?? Enumerable.Empty()) + .Select(sample => (Batch: batch, Sample: sample))) + .OrderBy(x => x.Batch.TestTime) + .ThenBy(x => x.Sample.Channel) + .ThenBy(x => x.Sample.ScheduledTimeMin); + } + + private static void WriteHeader(ExcelWorksheet sheet, params string[] headers) + { + for (int i = 0; i < headers.Length; i++) + sheet.Cells[1, i + 1].Value = headers[i]; + + using var range = sheet.Cells[1, 1, 1, headers.Length]; + range.Style.Font.Bold = true; + range.Style.HorizontalAlignment = ExcelHorizontalAlignment.Center; + } } } diff --git a/Services/TestCalculationService.cs b/Services/TestCalculationService.cs new file mode 100644 index 0000000..ed77676 --- /dev/null +++ b/Services/TestCalculationService.cs @@ -0,0 +1,147 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using TabletTester2025.Models; + +namespace TabletTester2025.Services +{ + public readonly record struct HardnessStatistics( + double Average, + double RsdPercent, + double Maximum, + double Minimum, + int Count, + bool IsPass); + + public static class TestCalculationService + { + public static HardnessStatistics CalculateHardness( + IReadOnlyCollection values, + double internalMin, + double internalMax, + int requiredCount) + { + var validValues = values + .Where(value => double.IsFinite(value) && value > 0) + .ToList(); + + if (validValues.Count == 0) + return new HardnessStatistics(0, 0, 0, 0, 0, false); + + double average = validValues.Average(); + double standardDeviation = CalculateSampleStandardDeviation(validValues, average); + double rsd = average == 0 ? 0 : standardDeviation / average * 100; + bool countMet = validValues.Count >= Math.Max(1, requiredCount); + bool inRange = validValues.All(value => value >= internalMin && value <= internalMax); + + return new HardnessStatistics( + average, + rsd, + validValues.Max(), + validValues.Min(), + validValues.Count, + countMet && inRange); + } + + public static double CalculateFriabilityLossPercent(double weightBefore, double weightAfter) + { + if (!double.IsFinite(weightBefore) || !double.IsFinite(weightAfter) || weightBefore <= 0) + throw new InvalidOperationException("脆碎前重量必须大于0"); + + if (weightAfter < 0) + throw new InvalidOperationException("脆碎后重量数据异常"); + + if (weightAfter > weightBefore) + throw new InvalidOperationException("脆碎后重量不能大于初始重量"); + + return (weightBefore - weightAfter) / weightBefore * 100; + } + + public static bool TryGetDissolutionRateAt30Min( + IReadOnlyList times, + IReadOnlyList values, + out double rate) + { + rate = 0; + if (times.Count == 0 || times.Count != values.Count) + return false; + + var points = times + .Zip(values, (time, value) => new { Time = time, Value = value }) + .Where(point => double.IsFinite(point.Time) + && double.IsFinite(point.Value) + && point.Time >= 0 + && point.Value >= 0 + && point.Value <= 150) + .OrderBy(point => point.Time) + .ToList(); + + if (points.Count == 0) + return false; + + var exact = points.FirstOrDefault(point => Math.Abs(point.Time - 30) < 0.0001); + if (exact != null) + { + rate = exact.Value; + return true; + } + + var before = points.LastOrDefault(point => point.Time < 30); + var after = points.FirstOrDefault(point => point.Time > 30); + if (before == null || after == null || after.Time <= before.Time) + return false; + + double fraction = (30 - before.Time) / (after.Time - before.Time); + rate = before.Value + (after.Value - before.Value) * fraction; + return double.IsFinite(rate); + } + + public static double CalculateRSquared(IReadOnlyList timeMinutes, IReadOnlyList concentration) + { + if (timeMinutes.Count < 2 || timeMinutes.Count != concentration.Count) + return 0; + + int n = timeMinutes.Count; + double sumX = timeMinutes.Sum(); + double sumY = concentration.Sum(); + double sumXY = timeMinutes.Zip(concentration, (x, y) => x * y).Sum(); + double sumX2 = timeMinutes.Select(x => x * x).Sum(); + double sumY2 = concentration.Select(y => y * y).Sum(); + + double numerator = n * sumXY - sumX * sumY; + double denominator = Math.Sqrt((n * sumX2 - sumX * sumX) * (n * sumY2 - sumY * sumY)); + if (denominator <= 0 || double.IsNaN(denominator)) + return 0; + + double r = numerator / denominator; + double result = r * r; + return double.IsFinite(result) ? result : 0; + } + + public static bool ResolveCurrentTestQualified( + TestType currentTest, + bool hardnessPass, + bool friabilityPass, + bool disintegrationPass, + bool dissolutionPass) + { + return currentTest switch + { + TestType.Hardness => hardnessPass, + TestType.Friability => friabilityPass, + TestType.Disintegration => disintegrationPass, + TestType.Dissolution => dissolutionPass, + _ => false + }; + } + + private static double CalculateSampleStandardDeviation(IReadOnlyList values, double average) + { + if (values.Count < 2) + return 0; + + double sum = values.Sum(value => Math.Pow(value - average, 2)); + return Math.Sqrt(sum / (values.Count - 1)); + } + } +} diff --git a/ViewModels/StationViewModel.cs b/ViewModels/StationViewModel.cs index 8fa4303..b28d5b1 100644 --- a/ViewModels/StationViewModel.cs +++ b/ViewModels/StationViewModel.cs @@ -386,7 +386,7 @@ namespace TabletTester2025.ViewModels if (completed && !TubesCompleted[i]) { TubesCompleted[i] = true; - RemainingTubes = 6 - TubesCompleted.Count(c => c); + RemainingTubes = TubesCompleted.Length - TubesCompleted.Count(c => c); } } break; @@ -405,16 +405,28 @@ namespace TabletTester2025.ViewModels DisintegrationTemp = await _plc.ReadFloatAsync(_plcConfig.DisintegrationTemp); if (_isDissolution1Running) + { + await ReadDissolutionChannelAsync(1); await CheckDissolutionSampleAsync(1); + } if (_isDissolution2Running) + { + await ReadDissolutionChannelAsync(2); await CheckDissolutionSampleAsync(2); + } UpdateDissolutionClock(); } private async Task CheckDissolutionSampleAsync(int channel) { + if (channel == 2 && _plcConfig.Dissolution2Percent == 0) + { + DissolutionCurveStatus = "溶出2溶出度寄存器未配置,未启用取样计算"; + return; + } + ushort coilAddress = channel == 1 ? _plcConfig.Dissolution1SampleAckCoil : _plcConfig.Dissolution2SampleAckCoil; @@ -1130,41 +1142,115 @@ namespace TabletTester2025.ViewModels private async Task RunHardnessAsync() { - // 点击启动才执行一次 if (Phase != TestPhase.Idle) return; CurrentTest = TestType.Hardness; Phase = TestPhase.Running; + HardnessPass = false; + _hardnessResults.Clear(); + HardnessCurrentCount = 0; + HardnessAvg = 0; + HardnessRSD = 0; + HardnessMax = 0; + HardnessMin = 0; try { int count = Math.Max(1, HardnessTestCount); - double min = HardnessInternalMin; - double max = HardnessInternalMax; + ushort completeCoil = ResolveHardnessCompleteCoil(); + if (completeCoil == 0) + throw new InvalidOperationException("硬度完成线圈未配置"); + if (_plcConfig.HardnessMax == 0) + throw new InvalidOperationException("硬度最大力寄存器未配置"); + await _plc.WriteFloatAsync(_plcConfig.HardnessSudu, (float)HardnessSudu); + await _plc.WriteFloatAsync(_plcConfig.HardnessWeiyi, (float)HardnessWeiyi); - double currentSpeed = HardnessSudu; - double currentWeiyi = HardnessWeiyi; - + if (await _plc.ReadCoilAsync(completeCoil)) + await WaitForCoilStateAsync(completeCoil, false, TimeSpan.FromSeconds(10), "硬度完成信号未复位"); - // 写入PLC - await _plc.WriteFloatAsync(_plcConfig.HardnessSudu, (float)currentSpeed); - await _plc.WriteFloatAsync(_plcConfig.HardnessWeiyi, (float)currentWeiyi); - await _plc.WriteCoilAsync(_plcConfig.HardnessStartCoil, true);//启动 + await PulseCoilAsync(_plcConfig.HardnessStartCoil); + while (Phase == TestPhase.Running && _hardnessResults.Count < count) + { + await WaitForCoilStateAsync(completeCoil, true, TimeSpan.FromSeconds(120), "等待硬度完成信号超时"); + double value = await ReadHardnessResultAsync(); + _hardnessResults.Add(value); + ApplyHardnessStatistics(count); + await WaitForCoilStateAsync(completeCoil, false, TimeSpan.FromSeconds(10), "硬度完成信号未回落"); + } - + if (_hardnessResults.Count < count) + throw new InvalidOperationException("硬度测试已停止,未保存结果"); + + ApplyHardnessStatistics(count); + Phase = TestPhase.Completed; } catch (Exception ex) { - + await App.Current.Dispatcher.InvokeAsync(() => + MessageBox.Show($"硬度测试出错: {ex.Message}")); + Phase = TestPhase.Error; } finally { + bool resultReady = Phase == TestPhase.Completed; Phase = TestPhase.Idle; - await SaveBatchResult(); + if (resultReady) + await SaveBatchResult(); } } + private ushort ResolveHardnessCompleteCoil() + { + return _plcConfig.HardnessOver != 0 + ? _plcConfig.HardnessOver + : _plcConfig.HardnessCompleteCoil; + } + + private async Task ReadHardnessResultAsync() + { + double value = await _plc.ReadFloatAsync(_plcConfig.HardnessMax); + if (!double.IsFinite(value) || value <= 0) + throw new InvalidOperationException("硬度最大力数据异常"); + + HardnessValue = value; + HardnessShishilizhi = value; + return value; + } + + private async Task WaitForCoilStateAsync(ushort coilAddress, bool expectedState, TimeSpan timeout, string timeoutMessage) + { + DateTime deadline = DateTime.Now.Add(timeout); + while (Phase == TestPhase.Running && DateTime.Now <= deadline) + { + if (await _plc.ReadCoilAsync(coilAddress) == expectedState) + return; + + await Task.Delay(100); + } + + if (Phase != TestPhase.Running) + throw new InvalidOperationException("硬度测试已停止,未保存结果"); + + throw new TimeoutException(timeoutMessage); + } + + private void ApplyHardnessStatistics(int requiredCount) + { + var stats = TestCalculationService.CalculateHardness( + _hardnessResults, + HardnessInternalMin, + HardnessInternalMax, + requiredCount); + + HardnessAvg = stats.Average; + HardnessRSD = stats.RsdPercent; + HardnessMax = stats.Maximum; + HardnessMin = stats.Minimum; + HardnessCurrentCount = stats.Count; + HardnessPass = stats.IsPass; + } + /// 脆碎度测试主逻辑(实时状态显示) private async Task RunFriabilityAsync() @@ -1197,7 +1283,7 @@ namespace TabletTester2025.ViewModels FriabilityTargetTimeSec = (int)Math.Ceiling(totalRounds / rpm * 60); FriabilityRemainingRounds = totalRounds; FriabilityCurrentRpm = rpm; - await _plc.WriteCoilAsync(startCoil, true); + await PulseCoilAsync(startCoil); int durationMs = (int)((totalRounds / rpm) * 60 * 1000); // 总运行时间(毫秒) for (int i = 0; i < durationMs; i += 100) @@ -1222,11 +1308,8 @@ namespace TabletTester2025.ViewModels double weightAfter = await ReadFriabilityWeightAsync(_plcConfig.WeightAfter, "脆碎后重量"); SetFriabilityWeightFromPlc(weightAfter: weightAfter); - if (WeightAfter > WeightBefore) - throw new InvalidOperationException("脆碎后重量不能大于初始重量"); - FriabilityCurrentRpm = rpm; - LossPercent = (WeightBefore - WeightAfter) / WeightBefore * 100;//失重率 + LossPercent = TestCalculationService.CalculateFriabilityLossPercent(WeightBefore, WeightAfter); FriabilityPass = LossPercent <= FriabilityMaxLossPercent; //标准值 resultReady = true; // 标记测试为已完成 @@ -1241,6 +1324,12 @@ namespace TabletTester2025.ViewModels } finally { + if (_plcConfig.FriabilityStartCoil != 0) + { + try { await _plc.WriteCoilAsync(_plcConfig.FriabilityStartCoil, false); } + catch { } + } + Phase = TestPhase.Idle; FriabilityRemainingRounds = FriabilityTargetRounds; if (resultReady) @@ -1266,14 +1355,17 @@ namespace TabletTester2025.ViewModels CurrentTest = TestType.Disintegration; Phase = TestPhase.Running; DisintegrationPass = false; // 添加这一行 - TubesCompleted = new bool[6]; - RemainingTubes = 6; + int tubeCount = Math.Max(1, _plcConfig.DisintegrationCompleteCoils.Length); + TubesCompleted = new bool[tubeCount]; + RemainingTubes = tubeCount; DisintegrationSeconds = 0; + bool resultReady = false; + DateTime startedAt = DateTime.Now; _disintegrationTimer = new DispatcherTimer { Interval = TimeSpan.FromSeconds(1) }; _disintegrationTimer.Tick += (s, e) => { if (Phase == TestPhase.Running) - DisintegrationSeconds++; + DisintegrationSeconds = Math.Max(0, (int)Math.Floor((DateTime.Now - startedAt).TotalSeconds)); }; _disintegrationTimer.Start(); @@ -1286,10 +1378,13 @@ namespace TabletTester2025.ViewModels DisintegrationTimeMin = maxSec / 60.0; while (RemainingTubes > 0 && DisintegrationSeconds < maxSec && Phase == TestPhase.Running) { + DisintegrationSeconds = Math.Max(0, (int)Math.Floor((DateTime.Now - startedAt).TotalSeconds)); await Task.Delay(500); } + DisintegrationSeconds = Math.Max(0, (int)Math.Floor((DateTime.Now - startedAt).TotalSeconds)); _disintegrationTimer.Stop(); Phase = TestPhase.Completed; + resultReady = true; } catch (Exception ex) { @@ -1303,7 +1398,7 @@ namespace TabletTester2025.ViewModels int maxSec = ResolveDisintegrationLimitSeconds(); DisintegrationPass = !_discardDisintegrationResult && RemainingTubes == 0 && DisintegrationSeconds <= maxSec; - if (!_discardDisintegrationResult) + if (resultReady && !_discardDisintegrationResult) await SaveBatchResult(); _discardDisintegrationResult = false; @@ -1312,6 +1407,7 @@ namespace TabletTester2025.ViewModels private async Task StopDisintegrationAsync() { + _discardDisintegrationResult = CurrentTest == TestType.Disintegration && Phase == TestPhase.Running; try { await PulseCoilAsync(_plcConfig.DisintegrationStopCoil); @@ -1401,7 +1497,10 @@ namespace TabletTester2025.ViewModels DissolutionPass = false; ResetDissolutionChannel(2); ResetDissolutionSampleState(2); - CreateDissolutionSampleSchedule(2); + if (_plcConfig.Dissolution2Percent != 0) + CreateDissolutionSampleSchedule(2); + else + DissolutionCurveStatus = "溶出2溶出度寄存器未配置,仅执行设备控制,不保存计算结果"; _dissolution2StartTime = DateTime.Now; _isDissolution2Running = true; DissolutionPlotModel.Title = "溶出曲线"; @@ -1487,7 +1586,14 @@ namespace TabletTester2025.ViewModels } _dissolutionResultChannel = $"溶出{channel}"; - _dissolutionResultRate30Min = GetDissolutionRateAt30Min(times, values); + if (!TestCalculationService.TryGetDissolutionRateAt30Min(times, values, out double rate30Min)) + { + DissolutionCurveStatus = $"溶出{channel}缺少有效30min溶出度,未保存结果"; + LocalAlarm = DissolutionCurveStatus; + return; + } + + _dissolutionResultRate30Min = rate30Min; _dissolutionResultRSquared = CalculateRSquared(times, values); DissolutionPercent = _dissolutionResultRate30Min; DissolutionRSquared = _dissolutionResultRSquared; @@ -1500,26 +1606,6 @@ namespace TabletTester2025.ViewModels await SaveBatchResult(); } - private static double GetDissolutionRateAt30Min(List times, List values) - { - if (values.Count == 0) - return 0; - - int index = 0; - double nearestDistance = double.MaxValue; - for (int i = 0; i < times.Count; i++) - { - double distance = Math.Abs(times[i] - 30); - if (distance < nearestDistance) - { - nearestDistance = distance; - index = i; - } - } - - return values[index]; - } - private async Task RunDissolutionAsync() { await StartDissolution1Async(); @@ -1545,7 +1631,8 @@ namespace TabletTester2025.ViewModels // 硬度 HardnessAvg = HardnessAvg, HardnessRSD = HardnessRSD, - + HardnessMax = HardnessMax, + HardnessMin = HardnessMin, HardnessTestCount = HardnessTestCount, HardnessInternalMin = HardnessInternalMin, HardnessInternalMax = HardnessInternalMax, @@ -1592,7 +1679,12 @@ namespace TabletTester2025.ViewModels _ => "" }, - IsQualified = HardnessPass && FriabilityPass && DisintegrationPass && DissolutionPass + IsQualified = TestCalculationService.ResolveCurrentTestQualified( + CurrentTest, + HardnessPass, + FriabilityPass, + DisintegrationPass, + DissolutionPass) }; var dissolutionSamples = CurrentTest == TestType.Dissolution ? DissolutionSamplePoints @@ -1625,22 +1717,7 @@ namespace TabletTester2025.ViewModels private double CalculateRSquared(List timeMinutes, List concentration) { - if (timeMinutes.Count < 2 || timeMinutes.Count != concentration.Count) return 0; - int n = timeMinutes.Count; - double sumX = timeMinutes.Sum(); - double sumY = concentration.Sum(); - double sumXY = timeMinutes.Zip(concentration, (x, y) => x * y).Sum(); - double sumX2 = timeMinutes.Select(x => x * x).Sum(); - double sumY2 = concentration.Select(y => y * y).Sum(); - - double numerator = (n * sumXY - sumX * sumY); - double denominator = Math.Sqrt((n * sumX2 - sumX * sumX) * (n * sumY2 - sumY * sumY)); - if (denominator <= 0 || double.IsNaN(denominator)) - return 0; - - double r = numerator / denominator; - double result = r * r; - return double.IsFinite(result) ? result : 0; + return TestCalculationService.CalculateRSquared(timeMinutes, concentration); } private async Task ExportHistoryAsync() @@ -1656,7 +1733,7 @@ namespace TabletTester2025.ViewModels if (values.Count == 0) return 0; double avg = values.Average(); double sum = values.Sum(v => Math.Pow(v - avg, 2)); - return Math.Sqrt(sum / values.Count); + return values.Count < 2 ? 0 : Math.Sqrt(sum / (values.Count - 1)); } } diff --git a/Views/HistoryWindow.xaml b/Views/HistoryWindow.xaml index 31b843e..33a8b43 100644 --- a/Views/HistoryWindow.xaml +++ b/Views/HistoryWindow.xaml @@ -176,6 +176,7 @@ + @@ -199,6 +200,7 @@ + @@ -222,6 +224,7 @@ + @@ -244,6 +247,7 @@ + diff --git a/Views/SettingsWindow.xaml b/Views/SettingsWindow.xaml index 245f784..0c1dad8 100644 --- a/Views/SettingsWindow.xaml +++ b/Views/SettingsWindow.xaml @@ -92,7 +92,7 @@ - + @@ -108,12 +108,12 @@ - - + @@ -142,12 +142,12 @@ - - + @@ -181,7 +181,7 @@ Width="430" helpers:NumericInput.IsEnabled="False"/> -