更新2026

This commit is contained in:
GukSang.Jin
2026-05-18 16:53:29 +08:00
parent bf4b491d0f
commit 865f1c087a
7 changed files with 500 additions and 261 deletions

View File

@@ -20,6 +20,7 @@
public ushort HardnessShishilizhi { get; set; }
// 兼容旧代码:硬度完成线圈与溶出或硬度实时值寄存器地址
public ushort HardnessOver { get; set; }
public ushort HardnessCompleteCoil { get; set; }
public ushort HardnessPoSun { get; set; }
// 脆碎度

View File

@@ -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 ? "合格" : "不合格";
}
}

View File

@@ -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<TestBatch> 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<TestBatch> batches, string filePath)
{
using var package = new ExcelPackage(new FileInfo(filePath));
AddHardnessSheet(package, batches);
package.Save();
}
public void ExportFriabilityToExcel(IEnumerable<TestBatch> batches, string filePath)
{
using var package = new ExcelPackage(new FileInfo(filePath));
AddFriabilitySheet(package, batches);
package.Save();
}
public void ExportDisintegrationToExcel(IEnumerable<TestBatch> batches, string filePath)
{
using var package = new ExcelPackage(new FileInfo(filePath));
AddDisintegrationSheet(package, batches);
package.Save();
}
public void ExportDissolutionToExcel(IEnumerable<TestBatch> batches, string filePath)
{
using var package = new ExcelPackage(new FileInfo(filePath));
AddDissolutionSheet(package, batches);
package.Save();
}
private static void AddHardnessSheet(ExcelPackage package, IEnumerable<TestBatch> 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<TestBatch> batches, string filePath)
private static void AddFriabilitySheet(ExcelPackage package, IEnumerable<TestBatch> 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<TestBatch> batches, string filePath)
private static void AddDisintegrationSheet(ExcelPackage package, IEnumerable<TestBatch> 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<TestBatch> batches, string filePath)
private static void AddDissolutionSheet(ExcelPackage package, IEnumerable<TestBatch> 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 = "";
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<TestBatch> batches)
{
var samples = batches
.SelectMany(batch => (batch.DissolutionSamples ?? Enumerable.Empty<DissolutionSamplePoint>())
.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<TestBatch> 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<TestBatch> batches)
{
return batches
.SelectMany(batch => (batch.DissolutionSamples ?? Enumerable.Empty<DissolutionSamplePoint>())
.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;
}
}
}

View File

@@ -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<double> 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<double> times,
IReadOnlyList<double> 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<double> timeMinutes, IReadOnlyList<double> 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<double> 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));
}
}
}

View File

@@ -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), "硬度完成信号未复位");
await PulseCoilAsync(_plcConfig.HardnessStartCoil);
// 写入PLC
await _plc.WriteFloatAsync(_plcConfig.HardnessSudu, (float)currentSpeed);
await _plc.WriteFloatAsync(_plcConfig.HardnessWeiyi, (float)currentWeiyi);
await _plc.WriteCoilAsync(_plcConfig.HardnessStartCoil, true);//启动
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<double> 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<double> times, List<double> 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<double> timeMinutes, List<double> 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));
}
}

View File

@@ -176,6 +176,7 @@
<DataGridTextColumn Header="测试次数" Binding="{Binding HardnessTestCount}" Width="70"/>
<DataGridTextColumn Header="内控下限(N)" Binding="{Binding HardnessInternalMin, StringFormat=F1}" Width="100"/>
<DataGridTextColumn Header="内控上限(N)" Binding="{Binding HardnessInternalMax, StringFormat=F1}" Width="100"/>
<DataGridTextColumn Header="判定" Binding="{Binding HardnessPassText}" Width="70"/>
</DataGrid.Columns>
</DataGrid>
</Grid>
@@ -199,6 +200,7 @@
<DataGridTextColumn Header="试验转数" Binding="{Binding FriabilityTargetRounds}" Width="80"/>
<DataGridTextColumn Header="前重(g)" Binding="{Binding WeightBefore, StringFormat=F3}" Width="90"/>
<DataGridTextColumn Header="后重(g)" Binding="{Binding WeightAfter, StringFormat=F3}" Width="90"/>
<DataGridTextColumn Header="判定" Binding="{Binding FriabilityPassText}" Width="70"/>
</DataGrid.Columns>
</DataGrid>
</Grid>
@@ -222,6 +224,7 @@
<DataGridTextColumn Header="崩解时间(秒)" Binding="{Binding DisintegrationTimeSec}" Width="100"/>
<DataGridTextColumn Header="剩余未崩解管" Binding="{Binding RemainingTubesAtEnd}" Width="110"/>
<DataGridTextColumn Header="水浴温度(℃)" Binding="{Binding DisintegrationTemp, StringFormat=F1}" Width="110"/>
<DataGridTextColumn Header="判定" Binding="{Binding DisintegrationPassText}" Width="70"/>
</DataGrid.Columns>
</DataGrid>
</Grid>
@@ -244,6 +247,7 @@
<DataGridTextColumn Header="30min溶出度(%)" Binding="{Binding DissolutionRate30Min, StringFormat=F1}" Width="130"/>
<DataGridTextColumn Header="R²" Binding="{Binding DissolutionRSquared, StringFormat=F4}" Width="80"/>
<DataGridTextColumn Header="取样明细" Binding="{Binding DissolutionSampleSummary}" Width="*"/>
<DataGridTextColumn Header="判定" Binding="{Binding DissolutionPassText}" Width="70"/>
</DataGrid.Columns>
</DataGrid>
</Grid>

View File

@@ -92,7 +92,7 @@
</StackPanel>
</GroupBox>
<GroupBox Header="脆碎度 - 通则0923">
<GroupBox Header="脆碎度">
<StackPanel>
<WrapPanel>
<StackPanel Style="{StaticResource ParamRow}">
@@ -108,12 +108,12 @@
<TextBox x:Name="FriabilityMaxLossBox"/>
</StackPanel>
</WrapPanel>
<TextBlock Text="通则默认转速25±1 r/min转动100次减失重量不得过1.0%,且不得检出断裂、龟裂或粉碎的片。"
<TextBlock Text="默认转速25±1 r/min转动100次减失重量不得过1.0%,且不得检出断裂、龟裂或粉碎的片。"
Style="{StaticResource StandardNote}"/>
</StackPanel>
</GroupBox>
<GroupBox Header="崩解时限 - 通则0921">
<GroupBox Header="崩解">
<StackPanel>
<WrapPanel>
<StackPanel Style="{StaticResource ParamRow}">
@@ -142,12 +142,12 @@
<TextBox x:Name="DisintegrationTempBox"/>
</StackPanel>
</WrapPanel>
<TextBlock Text="通则默认升降频率30-32次/min介质温度37±1℃。不同剂型按药典或品种正文规定时限执行。"
<TextBlock Text="默认升降频率30-32次/min介质温度37±1℃。不同剂型按药典或品种正文规定时限执行。"
Style="{StaticResource StandardNote}"/>
</StackPanel>
</GroupBox>
<GroupBox Header="溶出度 - 通则0931">
<GroupBox Header="溶出度">
<StackPanel>
<WrapPanel>
<StackPanel Style="{StaticResource ParamRow}">
@@ -181,7 +181,7 @@
Width="430"
helpers:NumericInput.IsEnabled="False"/>
</StackPanel>
<TextBlock Text="通则默认普通制剂通常取6片溶出介质温度37±0.5℃Q值、介质、转速和取样点应按具体品种正文或企业批准标准录入。"
<TextBlock Text="默认普通制剂通常取6片溶出介质温度37±0.5℃Q值、介质、转速和取样点应按具体品种正文或企业批准标准录入。"
Style="{StaticResource StandardNote}"/>
</StackPanel>
</GroupBox>