From ffdaf36dd99e09989e824219cedf628b3b8977a7 Mon Sep 17 00:00:00 2001 From: "GukSang.Jin" Date: Mon, 15 Jun 2026 14:27:52 +0800 Subject: [PATCH] =?UTF-8?q?=E6=9B=B4=E6=96=B0122?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- DentistryHandpieces/MainWindowViewModel.cs | 244 ++++++++++++++---- .../ModbusTcpPlcCoilService.cs | 94 +++++-- DentistryHandpieces/Models.cs | 8 + 3 files changed, 274 insertions(+), 72 deletions(-) diff --git a/DentistryHandpieces/MainWindowViewModel.cs b/DentistryHandpieces/MainWindowViewModel.cs index 30c4db4..04a542b 100644 --- a/DentistryHandpieces/MainWindowViewModel.cs +++ b/DentistryHandpieces/MainWindowViewModel.cs @@ -49,6 +49,7 @@ public sealed class MainWindowViewModel : ObservableObject private const ushort SpeedTorqueEnabledCoil = 81; private const ushort SpeedTorqueDoneCoil = 82; private const ushort SpeedTorqueStopCoil = 83; + private const ushort SpeedTorqueStableCoil = 87; private const ushort SpeedTorqueResetCoil = 90; private const ushort SpeedTorqueResetEnabledCoil = 91; private const ushort SpeedTorqueResetDoneCoil = 92; @@ -155,6 +156,7 @@ public sealed class MainWindowViewModel : ObservableObject AxialForceModeCoil, AxialDoneCoil, SpeedTorqueDoneCoil, + SpeedTorqueStableCoil, SpeedTorqueResetEnabledCoil, SpeedTorqueResetDoneCoil, AxialResetEnabledCoil, @@ -787,7 +789,11 @@ public sealed class MainWindowViewModel : ObservableObject _realtimeTorque = realtimeTorque; _realtimeSpeed = realtimeSpeed; DateTime sampledAt = DateTime.Now; - AppendTorqueSample(GetScaledTorque(), _realtimeSpeed, sampledAt); + AppendTorqueSample( + GetScaledTorque(), + _realtimeSpeed, + ReadCoilValue(coilValues, SpeedTorqueStableCoil), + sampledAt); if (_isDisplacementRunning) { _maxDisplacement = Math.Max(_maxDisplacement, Math.Abs(_relativeDisplacement)); @@ -1330,6 +1336,7 @@ public sealed class MainWindowViewModel : ObservableObject private ProjectPayload CreateSpeedTorqueRealtimeProject() { TestRunPayload? run = GetLatestCompletedRun("转速/扭矩测试"); + bool hasRun = run is not null; TestParameterConfig parameters = run?.ParameterSnapshot ?? _parameterConfig; RealtimeSamplePayload? lastSample = run?.Samples.LastOrDefault(); TorqueCurvePayload curve = run is null ? CreateTorqueCurvePayload() : CreateTorqueCurvePayload(run); @@ -1337,8 +1344,8 @@ public sealed class MainWindowViewModel : ObservableObject ? run.Samples.Max(static sample => Math.Abs(sample.SpeedTorqueDisplacementMm)) : _maxSpeedTorqueDisplacement; double peakTorque = run?.Samples.Count > 0 - ? run.Samples.Max(static sample => sample.SpeedTorquePeakTorqueMilliNewtonMeters) - : _speedTorquePeakTorque; + ? run.Samples.Max(static sample => sample.RealtimeTorqueMilliNewtonMeters) + : GetScaledTorque(); double? finalDisplacement = run?.FinalDisplacementMm ?? _finalSpeedTorqueDisplacement; double? finalSpeed = run?.FinalSpeedRpm ?? _finalSpeed; double? finalTorque = run?.FinalTorqueMilliNewtonMeters ?? _finalTorque; @@ -1346,7 +1353,7 @@ public sealed class MainWindowViewModel : ObservableObject { Name = "转速/扭矩实时测试", Requirement = "实时记录转速和扭矩;预留设备采集,不参与合格判定", - Result = "记录", + Result = hasRun ? "已记录" : "未测试", TorqueCurve = curve, Points = [ @@ -1360,14 +1367,14 @@ public sealed class MainWindowViewModel : ObservableObject CreateRecordPoint("转速/扭矩实时测试", "转速系数", FormatConfigNumber(parameters.SpeedCoefficient), string.Empty), CreateRecordPoint("转速/扭矩实时测试", "低速停止设置", $"{FormatSpeed(parameters.SpeedStopThreshold)} r/min", "r/min"), CreateRecordPoint("转速/扭矩实时测试", "压力系数", FormatConfigNumber(parameters.PressureCoefficient), string.Empty), - CreateRecordPoint("转速/扭矩实时测试", "末次采样转速", $"{FormatSpeed(lastSample?.RealtimeSpeedRpm ?? _realtimeSpeed)} r/min", "r/min"), - CreateRecordPoint("转速/扭矩实时测试", "末次采样扭矩", $"{FormatTorque(lastSample?.RealtimeTorqueMilliNewtonMeters ?? GetScaledTorque())} {TorqueUnit}", TorqueUnit), - CreateRecordPoint("转速/扭矩实时测试", "末次采样压力", $"{FormatPressure(lastSample?.RealtimePressureKpa ?? _realtimePressure)} kPa", "kPa"), - CreateRecordPoint("转速/扭矩实时测试", "最大扭矩采集", $"{FormatTorque(peakTorque)} {TorqueUnit}", TorqueUnit), - CreateRecordPoint("转速/扭矩实时测试", "最大位移", $"{FormatDisplacement(maxDisplacement)} mm", "mm"), - CreateRecordPoint("转速/扭矩实时测试", "最终位移", finalDisplacement.HasValue ? $"{FormatDisplacement(finalDisplacement.Value)} mm" : "--", "mm", finalDisplacement.HasValue ? "记录" : "待停止"), - CreateRecordPoint("转速/扭矩实时测试", "最终转速", finalSpeed.HasValue ? $"{FormatSpeed(finalSpeed.Value)} r/min" : "--", "r/min", finalSpeed.HasValue ? "记录" : "待停止"), - CreateRecordPoint("转速/扭矩实时测试", "最终扭矩", finalTorque.HasValue ? $"{FormatTorque(finalTorque.Value)} {TorqueUnit}" : "--", TorqueUnit, finalTorque.HasValue ? "记录" : "待停止"), + CreateRecordPoint("转速/扭矩实时测试", "末次采样转速", hasRun ? $"{FormatSpeed(lastSample?.RealtimeSpeedRpm ?? _realtimeSpeed)} r/min" : "--", "r/min", hasRun ? "记录" : "未测试"), + CreateRecordPoint("转速/扭矩实时测试", "末次采样扭矩", hasRun ? $"{FormatTorque(lastSample?.RealtimeTorqueMilliNewtonMeters ?? GetScaledTorque())} {TorqueUnit}" : "--", TorqueUnit, hasRun ? "记录" : "未测试"), + CreateRecordPoint("转速/扭矩实时测试", "末次采样压力", hasRun ? $"{FormatPressure(lastSample?.RealtimePressureKpa ?? _realtimePressure)} kPa" : "--", "kPa", hasRun ? "记录" : "未测试"), + CreateRecordPoint("转速/扭矩实时测试", "实时采样最大扭矩", hasRun ? $"{FormatTorque(peakTorque)} {TorqueUnit}" : "--", TorqueUnit, hasRun ? "记录" : "未测试"), + CreateRecordPoint("转速/扭矩实时测试", "最大位移", hasRun ? $"{FormatDisplacement(maxDisplacement)} mm" : "--", "mm", hasRun ? "记录" : "未测试"), + CreateRecordPoint("转速/扭矩实时测试", "最终位移", hasRun && finalDisplacement.HasValue ? $"{FormatDisplacement(finalDisplacement.Value)} mm" : "--", "mm", hasRun && finalDisplacement.HasValue ? "记录" : "未测试"), + CreateRecordPoint("转速/扭矩实时测试", "最终转速", hasRun && finalSpeed.HasValue ? $"{FormatSpeed(finalSpeed.Value)} r/min" : "--", "r/min", hasRun && finalSpeed.HasValue ? "记录" : "未测试"), + CreateRecordPoint("转速/扭矩实时测试", "最终扭矩", hasRun && finalTorque.HasValue ? $"{FormatTorque(finalTorque.Value)} {TorqueUnit}" : "--", TorqueUnit, hasRun && finalTorque.HasValue ? "记录" : "未测试"), CreateRecordPoint("转速/扭矩实时测试", "转速/扭矩保持时间关系曲线判定", curve.Result, string.Empty, "记录"), CreateRecordPoint("转速/扭矩实时测试", "扭矩变化阈值", $"{FormatTorque(curve.ChangeThresholdMilliNewtonMeters)} {TorqueUnit}", TorqueUnit), CreateRecordPoint("转速/扭矩实时测试", "转速变化阈值", $"{FormatSpeed(curve.SpeedChangeThresholdRpm)} r/min", "r/min"), @@ -1392,7 +1399,7 @@ public sealed class MainWindowViewModel : ObservableObject { Name = "空载转速测试", Requirement = "记录 PLC 空载转速及转速误差率", - Result = run is null ? "待记录" : "记录", + Result = run is null ? "未测试" : "已记录", Points = [ CreateRecordPoint("空载转速测试", "空载转速设置", $"{FormatSpeedSetting(parameters.NoLoadSpeedSetting)} r/min", "r/min"), @@ -1405,6 +1412,7 @@ public sealed class MainWindowViewModel : ObservableObject private ProjectPayload CreateDisplacementProject() { TestRunPayload? run = GetLatestCompletedRun("轴向位移动量测试"); + bool hasRun = run is not null; TestParameterConfig parameters = run?.ParameterSnapshot ?? _parameterConfig; RealtimeSamplePayload? lastSample = run?.Samples.LastOrDefault(); double maxDisplacement = run?.Samples.Count > 0 @@ -1416,16 +1424,16 @@ public sealed class MainWindowViewModel : ObservableObject { Name = "轴向位移动量测试", Requirement = "主轴位移动量测试;第一版记录实时千分表相对位移,不参与合格判定", - Result = "记录", + Result = hasRun ? "已记录" : "未测试", Points = [ - CreateRecordPoint("轴向位移动量测试", "零点读数", $"{FormatDisplacement((lastSample?.DialIndicatorMm ?? _dialZero + _relativeDisplacement) - (lastSample?.RelativeDisplacementMm ?? _relativeDisplacement))} mm", "mm"), - CreateRecordPoint("轴向位移动量测试", "2号当前位置", $"{FormatDisplacement(lastSample?.AxialAxisPositionMm ?? _axialAxisPosition)} mm", "mm"), - CreateRecordPoint("轴向位移动量测试", "采集数据1-1", $"{FormatDisplacement(lastSample?.AxialSampleStartMm ?? _axialSampleStart)} mm", "mm"), - CreateRecordPoint("轴向位移动量测试", "采集数据1-2", $"{FormatDisplacement(lastSample?.AxialSampleEndMm ?? _axialSampleEnd)} mm", "mm"), - CreateRecordPoint("轴向位移动量测试", "数据差值1", $"{FormatDisplacement(lastSample?.AxialSampleDifferenceMm ?? _axialSampleDifference)} mm", "mm"), - CreateRecordPoint("轴向位移动量测试", "最大位移", $"{FormatDisplacement(maxDisplacement)} mm", "mm"), - CreateRecordPoint("轴向位移动量测试", "最终位移", finalDisplacement.HasValue ? $"{FormatDisplacement(finalDisplacement.Value)} mm" : "--", "mm", finalDisplacement.HasValue ? "记录" : "待停止"), + CreateRecordPoint("轴向位移动量测试", "零点读数", hasRun ? $"{FormatDisplacement((lastSample?.DialIndicatorMm ?? _dialZero + _relativeDisplacement) - (lastSample?.RelativeDisplacementMm ?? _relativeDisplacement))} mm" : "--", "mm", hasRun ? "记录" : "未测试"), + CreateRecordPoint("轴向位移动量测试", "轴向位移当前位置", hasRun ? $"{FormatDisplacement(lastSample?.AxialAxisPositionMm ?? _axialAxisPosition)} mm" : "--", "mm", hasRun ? "记录" : "未测试"), + CreateRecordPoint("轴向位移动量测试", "采集数据1-1", hasRun ? $"{FormatDisplacement(lastSample?.AxialSampleStartMm ?? _axialSampleStart)} mm" : "--", "mm", hasRun ? "记录" : "未测试"), + CreateRecordPoint("轴向位移动量测试", "采集数据1-2", hasRun ? $"{FormatDisplacement(lastSample?.AxialSampleEndMm ?? _axialSampleEnd)} mm" : "--", "mm", hasRun ? "记录" : "未测试"), + CreateRecordPoint("轴向位移动量测试", "数据差值1", hasRun ? $"{FormatDisplacement(lastSample?.AxialSampleDifferenceMm ?? _axialSampleDifference)} mm" : "--", "mm", hasRun ? "记录" : "未测试"), + CreateRecordPoint("轴向位移动量测试", "最大位移", hasRun ? $"{FormatDisplacement(maxDisplacement)} mm" : "--", "mm", hasRun ? "记录" : "未测试"), + CreateRecordPoint("轴向位移动量测试", "最终位移", hasRun && finalDisplacement.HasValue ? $"{FormatDisplacement(finalDisplacement.Value)} mm" : "--", "mm", hasRun && finalDisplacement.HasValue ? "记录" : "未测试"), CreateRecordPoint("轴向位移动量测试", "位移极限", $"{FormatDisplacement(parameters.AxialDisplacementLimit)} mm", "mm"), CreateRecordPoint("轴向位移动量测试", "手/自动速度", $"{FormatSpeedSetting(parameters.AxialSpeed)} mm/min", "mm/min"), CreateRecordPoint("轴向位移动量测试", "手动位移", $"{FormatDisplacement(parameters.AxialManualDisplacement)} mm", "mm"), @@ -1436,7 +1444,7 @@ public sealed class MainWindowViewModel : ObservableObject CreateRecordPoint("轴向位移动量测试", "轴向拉力设置", $"{FormatForce(parameters.AxialForceSetpoint)} N", "N", parameters.UseAxialPullForceSetpoint ? "当前" : "备用"), CreateRecordPoint("轴向位移动量测试", "轴向力保护", $"{FormatForce(parameters.AxialForceProtection)} N", "N"), CreateRecordPoint("轴向位移动量测试", "轴向力保持时间设置", $"{FormatConfigNumber(parameters.AxialForceHoldTime)} s", "s"), - CreateRecordPoint("轴向位移动量测试", "最终轴向力", finalAxialForce.HasValue ? $"{FormatForce(finalAxialForce.Value)} N" : "--", "N", finalAxialForce.HasValue ? "记录" : "待停止") + CreateRecordPoint("轴向位移动量测试", "最终轴向力", hasRun && finalAxialForce.HasValue ? $"{FormatForce(finalAxialForce.Value)} N" : "--", "N", hasRun && finalAxialForce.HasValue ? "记录" : "未测试") ] }; } @@ -1556,13 +1564,14 @@ public sealed class MainWindowViewModel : ObservableObject { var sheet = workbook.Worksheets.Add("测试运行记录"); sheet.Cell(1, 1).Value = "测试运行记录"; - sheet.Range(1, 1, 1, 14).Merge().Style.Font.SetBold().Font.SetFontSize(16); + sheet.Range(1, 1, 1, 18).Merge().Style.Font.SetBold().Font.SetFontSize(16); string[] headers = [ "运行编号", "测试类型", "开始时间", "完成时间", "完成状态", "采样数", "最终位移(mm)", "最终轴向力(N)", "最终转速(r/min)", $"最终扭矩({TorqueUnit})", - "空载转速(r/min)", "转速误差率(%)", "参数快照时间", "数据来源" + "空载转速(r/min)", "转速误差率(%)", "参数快照时间", "数据来源", + "保持段采样数", "平均采样间隔(ms)", "最大采样间隔(ms)", "采样质量" ]; WriteHeaderRow(sheet, 2, headers); @@ -1584,6 +1593,12 @@ public sealed class MainWindowViewModel : ObservableObject SetOptionalNumber(sheet.Cell(row, 12), run.NoLoadSpeedErrorRatePercent); sheet.Cell(row, 13).Value = run.StartedAt.ToString("yyyy-MM-dd HH:mm:ss.fff", CultureInfo.InvariantCulture); sheet.Cell(row, 14).Value = "PLC实时采样 + 测试停止最终值"; + SamplingQuality quality = CalculateSamplingQuality(run.Samples); + TorqueCurvePayload? curve = run.TestType == "转速/扭矩测试" ? CreateTorqueCurvePayload(run) : null; + sheet.Cell(row, 15).Value = curve?.EvaluationSampleCount ?? 0; + sheet.Cell(row, 16).Value = quality.AverageIntervalMilliseconds; + sheet.Cell(row, 17).Value = quality.MaximumIntervalMilliseconds; + sheet.Cell(row, 18).Value = quality.Status; } sheet.SheetView.FreezeRows(2); @@ -1594,18 +1609,18 @@ public sealed class MainWindowViewModel : ObservableObject { var sheet = workbook.Worksheets.Add("完整实时数据"); sheet.Cell(1, 1).Value = "完整实时数据(测试运行期间每次有效 PLC 轮询均记录)"; - sheet.Range(1, 1, 1, 25).Merge().Style.Font.SetBold().Font.SetFontSize(16); + sheet.Range(1, 1, 1, 26).Merge().Style.Font.SetBold().Font.SetFontSize(16); string[] headers = [ "运行编号", "测试类型", "采样序号", "采样时间", - "千分表显示(mm)", "相对位移(mm)", "2号当前位置(mm)", + "千分表显示(mm)", "相对位移(mm)", "轴向位移当前位置(mm)", "采集数据1-1(mm)", "采集数据1-2(mm)", "数据差值1(mm)", - "轴向力显示(N)", "1号当前位置(mm)", "1号相对位移(mm)", + "轴向力显示(N)", "转速/扭矩当前位置(mm)", "转速/扭矩相对位移(mm)", $"最大扭矩采集({TorqueUnit})", $"扭矩显示({TorqueUnit})", "转速显示(r/min)", "压力显示(kPa)", "空载转速记录(r/min)", - "转速误差率(%)", "扭矩完成", "复位使能1号", "复位完成1号", - "复位使能2号", "复位完成2号", "参数快照" + "转速误差率(%)", "扭矩完成", "转速/扭矩复位使能", "转速/扭矩复位完成", + "轴向位移复位使能", "轴向位移复位完成", "扭矩稳定保持", "参数快照" ]; WriteHeaderRow(sheet, 2, headers); @@ -1638,7 +1653,8 @@ public sealed class MainWindowViewModel : ObservableObject sheet.Cell(row, 22).Value = sample.SpeedTorqueResetDone ? 1 : 0; sheet.Cell(row, 23).Value = sample.AxialResetEnabled ? 1 : 0; sheet.Cell(row, 24).Value = sample.AxialResetDone ? 1 : 0; - sheet.Cell(row, 25).Value = run.StartedAt.ToString("yyyy-MM-dd HH:mm:ss.fff", CultureInfo.InvariantCulture); + sheet.Cell(row, 25).Value = sample.SpeedTorqueStable ? 1 : 0; + sheet.Cell(row, 26).Value = run.StartedAt.ToString("yyyy-MM-dd HH:mm:ss.fff", CultureInfo.InvariantCulture); row++; } } @@ -1747,6 +1763,48 @@ public sealed class MainWindowViewModel : ObservableObject throw new InvalidOperationException( $"报表校验失败:测试运行 {actualRunCount}/{expectedRunCount},完整采样 {actualSampleCount}/{expectedSampleCount}。"); } + + TestRunPayload? speedTorqueRun = payload.Runs.LastOrDefault(static run => run.TestType == "转速/扭矩测试"); + if (speedTorqueRun is not null) + { + if (!workbook.TryGetWorksheet("转速扭矩曲线", out IXLWorksheet? curveSheet)) + { + throw new InvalidOperationException("报表校验失败:缺少转速扭矩曲线工作表。"); + } + + TorqueCurvePayload expectedCurve = CreateTorqueCurvePayload(speedTorqueRun); + ValidateCurveNumber(curveSheet.Cell(6, 2), expectedCurve.MinTorqueMilliNewtonMeters, expectedCurve.EvaluationSampleCount, "最小扭矩"); + ValidateCurveNumber(curveSheet.Cell(7, 2), expectedCurve.MaxTorqueMilliNewtonMeters, expectedCurve.EvaluationSampleCount, "最大扭矩"); + ValidateCurveNumber(curveSheet.Cell(8, 2), expectedCurve.AverageTorqueMilliNewtonMeters, expectedCurve.EvaluationSampleCount, "平均扭矩"); + ValidateCurveNumber(curveSheet.Cell(9, 2), expectedCurve.FluctuationMilliNewtonMeters, expectedCurve.EvaluationSampleCount, "扭矩波动值"); + } + + foreach (IXLCell cell in workbook.Worksheets.SelectMany(static sheet => sheet.CellsUsed())) + { + string value = cell.GetFormattedString(); + if (value.Contains("1号", StringComparison.Ordinal) || value.Contains("2号", StringComparison.Ordinal)) + { + throw new InvalidOperationException($"报表校验失败:{cell.Worksheet.Name}!{cell.Address} 仍包含内部设备编号。"); + } + } + } + + private static void ValidateCurveNumber(IXLCell cell, double expected, int evaluationSampleCount, string name) + { + if (evaluationSampleCount < 2) + { + if (!cell.IsEmpty()) + { + throw new InvalidOperationException($"报表校验失败:保持段采样不足时{name}应为空。"); + } + + return; + } + + if (!cell.TryGetValue(out double actual) || Math.Abs(actual - expected) > 0.000001) + { + throw new InvalidOperationException($"报表校验失败:{name}与保持段原始采样不一致。"); + } } private static void WriteHeaderRow(IXLWorksheet sheet, int row, IReadOnlyList headers) @@ -1807,13 +1865,17 @@ public sealed class MainWindowViewModel : ObservableObject sheet.Cell(4, 2).Value = curve.ChangeThresholdMilliNewtonMeters; sheet.Cell(5, 1).Value = "曲线判定"; sheet.Cell(5, 2).Value = curve.Result; - sheet.Cell(6, 1).Value = $"最小扭矩({TorqueUnit})"; + sheet.Cell(10, 1).Value = "保持段起点(s)"; + SetOptionalNumber(sheet.Cell(10, 2), curve.EvaluationStartSeconds); + sheet.Cell(10, 4).Value = "保持段终点(s)"; + SetOptionalNumber(sheet.Cell(10, 5), curve.EvaluationEndSeconds); + sheet.Cell(6, 1).Value = $"保持时间内最小扭矩({TorqueUnit})"; sheet.Cell(6, 2).Value = curve.EvaluationSampleCount >= 2 ? curve.MinTorqueMilliNewtonMeters : string.Empty; - sheet.Cell(7, 1).Value = $"最大扭矩({TorqueUnit})"; + sheet.Cell(7, 1).Value = $"保持时间内最大扭矩({TorqueUnit})"; sheet.Cell(7, 2).Value = curve.EvaluationSampleCount >= 2 ? curve.MaxTorqueMilliNewtonMeters : string.Empty; - sheet.Cell(8, 1).Value = $"平均扭矩({TorqueUnit})"; + sheet.Cell(8, 1).Value = $"保持时间内平均扭矩({TorqueUnit})"; sheet.Cell(8, 2).Value = curve.EvaluationSampleCount >= 2 ? curve.AverageTorqueMilliNewtonMeters : string.Empty; - sheet.Cell(9, 1).Value = $"扭矩波动值({TorqueUnit})"; + sheet.Cell(9, 1).Value = $"保持时间内扭矩波动值({TorqueUnit})"; sheet.Cell(9, 2).Value = curve.EvaluationSampleCount >= 2 ? curve.FluctuationMilliNewtonMeters : string.Empty; sheet.Range(3, 1, 9, 1).Style.Font.SetBold(); @@ -1823,29 +1885,31 @@ public sealed class MainWindowViewModel : ObservableObject sheet.Cell(4, 5).Value = $"左:扭矩({TorqueUnit});右:转速(r/min)"; sheet.Cell(5, 4).Value = "转速变化阈值(r/min)"; sheet.Cell(5, 5).Value = curve.SpeedChangeThresholdRpm; - sheet.Cell(6, 4).Value = "最小转速(r/min)"; + sheet.Cell(6, 4).Value = "保持时间内最小转速(r/min)"; sheet.Cell(6, 5).Value = curve.EvaluationSampleCount >= 2 ? curve.MinSpeedRpm : string.Empty; - sheet.Cell(7, 4).Value = "最大转速(r/min)"; + sheet.Cell(7, 4).Value = "保持时间内最大转速(r/min)"; sheet.Cell(7, 5).Value = curve.EvaluationSampleCount >= 2 ? curve.MaxSpeedRpm : string.Empty; - sheet.Cell(8, 4).Value = "平均转速(r/min)"; + sheet.Cell(8, 4).Value = "保持时间内平均转速(r/min)"; sheet.Cell(8, 5).Value = curve.EvaluationSampleCount >= 2 ? curve.AverageSpeedRpm : string.Empty; - sheet.Cell(9, 4).Value = "转速波动值(r/min)"; + sheet.Cell(9, 4).Value = "保持时间内转速波动值(r/min)"; sheet.Cell(9, 5).Value = curve.EvaluationSampleCount >= 2 ? curve.SpeedFluctuationRpm : string.Empty; sheet.Range(3, 4, 9, 4).Style.Font.SetBold(); - sheet.Cell(11, 1).Value = "时间(s)"; - sheet.Cell(11, 2).Value = $"扭矩({TorqueUnit})"; - sheet.Cell(11, 3).Value = "转速(r/min)"; - sheet.Range(11, 1, 11, 3).Style.Fill.SetBackgroundColor(XLColor.FromHtml("#D9EAF7")); - sheet.Range(11, 1, 11, 3).Style.Font.SetBold(); + sheet.Cell(12, 1).Value = "时间(s)"; + sheet.Cell(12, 2).Value = $"扭矩({TorqueUnit})"; + sheet.Cell(12, 3).Value = "转速(r/min)"; + sheet.Cell(12, 4).Value = "稳定保持"; + sheet.Range(12, 1, 12, 4).Style.Fill.SetBackgroundColor(XLColor.FromHtml("#D9EAF7")); + sheet.Range(12, 1, 12, 4).Style.Font.SetBold(); for (int i = 0; i < curve.Samples.Count; i++) { TorqueSamplePayload sample = curve.Samples[i]; - int row = 12 + i; + int row = 13 + i; sheet.Cell(row, 1).Value = sample.ElapsedSeconds; sheet.Cell(row, 2).Value = sample.TorqueMilliNewtonMeters; sheet.Cell(row, 3).Value = sample.SpeedRpm; + sheet.Cell(row, 4).Value = sample.IsStableHold ? 1 : 0; } if (curve.Samples.Count > 0) @@ -3453,7 +3517,7 @@ public sealed class MainWindowViewModel : ObservableObject NoLoadSpeedErrorRateText = $"{FormatErrorRate(_noLoadSpeedErrorRate)} %"; } - private void AppendTorqueSample(double torque, double speed, DateTime sampledAt) + private void AppendTorqueSample(double torque, double speed, bool isStableHold, DateTime sampledAt) { if (!_isSpeedTorqueRunning || !_speedTorqueStartedAt.HasValue @@ -3470,7 +3534,8 @@ public sealed class MainWindowViewModel : ObservableObject { ElapsedSeconds = elapsedSeconds, SpeedRpm = speed, - TorqueMilliNewtonMeters = torque + TorqueMilliNewtonMeters = torque, + IsStableHold = isStableHold }); _cachedTorqueCurve = null; } @@ -3508,7 +3573,8 @@ public sealed class MainWindowViewModel : ObservableObject { ElapsedSeconds = sample.ElapsedSeconds, SpeedRpm = sample.SpeedRpm, - TorqueMilliNewtonMeters = sample.TorqueMilliNewtonMeters + TorqueMilliNewtonMeters = sample.TorqueMilliNewtonMeters, + IsStableHold = sample.IsStableHold }) .ToList(); @@ -3528,7 +3594,8 @@ public sealed class MainWindowViewModel : ObservableObject { ElapsedSeconds = Math.Max(0, (sample.SampledAt - run.StartedAt).TotalSeconds), SpeedRpm = sample.RealtimeSpeedRpm, - TorqueMilliNewtonMeters = sample.RealtimeTorqueMilliNewtonMeters + TorqueMilliNewtonMeters = sample.RealtimeTorqueMilliNewtonMeters, + IsStableHold = sample.SpeedTorqueStable }) .ToList(); @@ -3545,16 +3612,11 @@ public sealed class MainWindowViewModel : ObservableObject double torqueThreshold, double speedThreshold) { - List evaluationSamples = samples - .Where(sample => sample.ElapsedSeconds <= holdTime) - .ToList(); - if (holdTime <= 0) { return new TorqueCurvePayload { HoldTimeSeconds = holdTime, - EvaluationSampleCount = evaluationSamples.Count, ChangeThresholdMilliNewtonMeters = torqueThreshold, SpeedChangeThresholdRpm = speedThreshold, Result = "未设置保持时间,未判定", @@ -3562,15 +3624,44 @@ public sealed class MainWindowViewModel : ObservableObject }; } + int stableStartIndex = samples.FindIndex(static sample => sample.IsStableHold); + if (stableStartIndex < 0) + { + return new TorqueCurvePayload + { + HoldTimeSeconds = holdTime, + ChangeThresholdMilliNewtonMeters = torqueThreshold, + SpeedChangeThresholdRpm = speedThreshold, + Result = "未检测到稳定保持段,未判定", + Samples = samples + }; + } + + double evaluationStart = samples[stableStartIndex].ElapsedSeconds; + double evaluationLimit = evaluationStart + holdTime; + var evaluationSamples = new List(); + for (int i = stableStartIndex; i < samples.Count; i++) + { + TorqueSamplePayload sample = samples[i]; + if (!sample.IsStableHold || sample.ElapsedSeconds > evaluationLimit) + { + break; + } + + evaluationSamples.Add(sample); + } + if (evaluationSamples.Count < 2) { return new TorqueCurvePayload { HoldTimeSeconds = holdTime, EvaluationSampleCount = evaluationSamples.Count, + EvaluationStartSeconds = evaluationStart, + EvaluationEndSeconds = evaluationSamples.LastOrDefault()?.ElapsedSeconds, ChangeThresholdMilliNewtonMeters = torqueThreshold, SpeedChangeThresholdRpm = speedThreshold, - Result = "采样不足,未判定", + Result = "保持段采样不足,未判定", Samples = samples }; } @@ -3595,6 +3686,8 @@ public sealed class MainWindowViewModel : ObservableObject { HoldTimeSeconds = holdTime, EvaluationSampleCount = evaluationSamples.Count, + EvaluationStartSeconds = evaluationStart, + EvaluationEndSeconds = evaluationSamples[^1].ElapsedSeconds, ChangeThresholdMilliNewtonMeters = torqueThreshold, SpeedChangeThresholdRpm = speedThreshold, MinTorqueMilliNewtonMeters = minTorque, @@ -3645,6 +3738,7 @@ public sealed class MainWindowViewModel : ObservableObject NoLoadSpeedRpm = _noLoadSpeedRecord, NoLoadSpeedErrorRatePercent = _noLoadSpeedErrorRate, SpeedTorqueDone = ReadCoilValue(coilValues, SpeedTorqueDoneCoil), + SpeedTorqueStable = ReadCoilValue(coilValues, SpeedTorqueStableCoil), SpeedTorqueResetEnabled = ReadCoilValue(coilValues, SpeedTorqueResetEnabledCoil), SpeedTorqueResetDone = ReadCoilValue(coilValues, SpeedTorqueResetDoneCoil), AxialResetEnabled = ReadCoilValue(coilValues, AxialResetEnabledCoil), @@ -4019,11 +4113,49 @@ public sealed class MainWindowViewModel : ObservableObject StatusText = $"当前整体验收:{CalculateOverallResult()}。完成测试后可导出报表。"; } - private static string CalculateOverallResult() + private string CalculateOverallResult() { - return "记录"; + int completedTypes = new[] + { + "轴向位移动量测试", + "转速/扭矩测试", + "空载转速测试" + }.Count(testType => GetLatestCompletedRun(testType) is not null); + + return completedTypes switch + { + 0 => "未记录", + 3 => "全部测试已记录", + _ => "部分测试已记录" + }; } + private static SamplingQuality CalculateSamplingQuality(IReadOnlyList samples) + { + if (samples.Count < 2) + { + return new SamplingQuality(0, 0, samples.Count == 0 ? "无采样" : "采样不足"); + } + + double totalMilliseconds = 0; + double maximumMilliseconds = 0; + for (int i = 1; i < samples.Count; i++) + { + double interval = Math.Max(0, (samples[i].SampledAt - samples[i - 1].SampledAt).TotalMilliseconds); + totalMilliseconds += interval; + maximumMilliseconds = Math.Max(maximumMilliseconds, interval); + } + + double averageMilliseconds = totalMilliseconds / (samples.Count - 1); + string status = maximumMilliseconds > 300 ? "异常:存在超过300ms采样间隔" : "正常"; + return new SamplingQuality(averageMilliseconds, maximumMilliseconds, status); + } + + private sealed record SamplingQuality( + double AverageIntervalMilliseconds, + double MaximumIntervalMilliseconds, + string Status); + private static double ReadFloatValue(IReadOnlyDictionary values, ushort registerAddress, string label) { if (!values.TryGetValue(registerAddress, out float value) diff --git a/DentistryHandpieces/ModbusTcpPlcCoilService.cs b/DentistryHandpieces/ModbusTcpPlcCoilService.cs index d9de3c3..d6d8aa1 100644 --- a/DentistryHandpieces/ModbusTcpPlcCoilService.cs +++ b/DentistryHandpieces/ModbusTcpPlcCoilService.cs @@ -36,6 +36,9 @@ public sealed class ModbusTcpPlcCoilService : IPlcCoilService, IPlcRegisterServi private readonly object _transactionLock = new(); private readonly SemaphoreSlim _transportLock = new(1, 1); private ushort _transactionId; + private TcpClient? _client; + private NetworkStream? _stream; + private string? _connectionKey; public async Task PulseCoilAsync(PlcConnectionConfig config, ushort coilAddress, CancellationToken cancellationToken = default) { @@ -209,12 +212,9 @@ public sealed class ModbusTcpPlcCoilService : IPlcCoilService, IPlcRegisterServi await _transportLock.WaitAsync(cancellationToken); try { - using var client = new TcpClient(); using var timeoutCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); timeoutCts.CancelAfter(TimeSpan.FromMilliseconds(config.TimeoutMilliseconds)); - - await client.ConnectAsync(config.IpAddress, config.Port, timeoutCts.Token); - await using NetworkStream stream = client.GetStream(); + NetworkStream stream = await GetConnectedStreamAsync(config, timeoutCts.Token); ushort transactionId = NextTransactionId(); byte[] request = BuildWriteSingleCoilRequest(transactionId, config.UnitId, coilAddress, value); @@ -239,6 +239,11 @@ public sealed class ModbusTcpPlcCoilService : IPlcCoilService, IPlcRegisterServi throw new InvalidOperationException("PLC 写线圈响应地址或值不匹配。"); } } + catch + { + ResetConnection(); + throw; + } finally { _transportLock.Release(); @@ -250,12 +255,9 @@ public sealed class ModbusTcpPlcCoilService : IPlcCoilService, IPlcRegisterServi await _transportLock.WaitAsync(cancellationToken); try { - using var client = new TcpClient(); using var timeoutCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); timeoutCts.CancelAfter(TimeSpan.FromMilliseconds(config.TimeoutMilliseconds)); - - await client.ConnectAsync(config.IpAddress, config.Port, timeoutCts.Token); - await using NetworkStream stream = client.GetStream(); + NetworkStream stream = await GetConnectedStreamAsync(config, timeoutCts.Token); ushort transactionId = NextTransactionId(); byte[] request = BuildReadHoldingRegistersRequest(transactionId, config.UnitId, startAddress, numberOfPoints); @@ -293,6 +295,11 @@ public sealed class ModbusTcpPlcCoilService : IPlcCoilService, IPlcRegisterServi return registers; } + catch + { + ResetConnection(); + throw; + } finally { _transportLock.Release(); @@ -304,12 +311,9 @@ public sealed class ModbusTcpPlcCoilService : IPlcCoilService, IPlcRegisterServi await _transportLock.WaitAsync(cancellationToken); try { - using var client = new TcpClient(); using var timeoutCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); timeoutCts.CancelAfter(TimeSpan.FromMilliseconds(config.TimeoutMilliseconds)); - - await client.ConnectAsync(config.IpAddress, config.Port, timeoutCts.Token); - await using NetworkStream stream = client.GetStream(); + NetworkStream stream = await GetConnectedStreamAsync(config, timeoutCts.Token); ushort transactionId = NextTransactionId(); byte[] request = BuildReadCoilsRequest(transactionId, config.UnitId, startAddress, numberOfPoints); @@ -348,6 +352,11 @@ public sealed class ModbusTcpPlcCoilService : IPlcCoilService, IPlcRegisterServi return coils; } + catch + { + ResetConnection(); + throw; + } finally { _transportLock.Release(); @@ -359,12 +368,9 @@ public sealed class ModbusTcpPlcCoilService : IPlcCoilService, IPlcRegisterServi await _transportLock.WaitAsync(cancellationToken); try { - using var client = new TcpClient(); using var timeoutCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); timeoutCts.CancelAfter(TimeSpan.FromMilliseconds(config.TimeoutMilliseconds)); - - await client.ConnectAsync(config.IpAddress, config.Port, timeoutCts.Token); - await using NetworkStream stream = client.GetStream(); + NetworkStream stream = await GetConnectedStreamAsync(config, timeoutCts.Token); ushort transactionId = NextTransactionId(); byte[] request = BuildWriteMultipleRegistersRequest(transactionId, config.UnitId, startAddress, values); @@ -391,6 +397,11 @@ public sealed class ModbusTcpPlcCoilService : IPlcCoilService, IPlcRegisterServi throw new InvalidOperationException("PLC 写寄存器响应地址或数量不匹配。"); } } + catch + { + ResetConnection(); + throw; + } finally { _transportLock.Release(); @@ -409,6 +420,57 @@ public sealed class ModbusTcpPlcCoilService : IPlcCoilService, IPlcRegisterServi return remainingLength; } + private async Task GetConnectedStreamAsync(PlcConnectionConfig config, CancellationToken cancellationToken) + { + string connectionKey = $"{config.IpAddress}:{config.Port}"; + if (_client is not null + && _stream is not null + && _client.Connected + && string.Equals(_connectionKey, connectionKey, StringComparison.OrdinalIgnoreCase)) + { + return _stream; + } + + ResetConnection(); + var client = new TcpClient(); + try + { + await client.ConnectAsync(config.IpAddress, config.Port, cancellationToken); + _client = client; + _stream = client.GetStream(); + _connectionKey = connectionKey; + return _stream; + } + catch + { + client.Dispose(); + throw; + } + } + + private void ResetConnection() + { + try + { + _stream?.Dispose(); + } + catch + { + } + + try + { + _client?.Dispose(); + } + catch + { + } + + _stream = null; + _client = null; + _connectionKey = null; + } + private static void ValidateFixedResponseHeader(byte[] response, byte[] request) { ValidateMbapHeader(response, request); diff --git a/DentistryHandpieces/Models.cs b/DentistryHandpieces/Models.cs index 6842c03..d51530c 100644 --- a/DentistryHandpieces/Models.cs +++ b/DentistryHandpieces/Models.cs @@ -206,6 +206,8 @@ public sealed class TorqueSamplePayload public double SpeedRpm { get; init; } public double TorqueMilliNewtonMeters { get; init; } + + public bool IsStableHold { get; init; } } public sealed class TorqueCurvePayload @@ -214,6 +216,10 @@ public sealed class TorqueCurvePayload public int EvaluationSampleCount { get; init; } + public double? EvaluationStartSeconds { get; init; } + + public double? EvaluationEndSeconds { get; init; } + public double ChangeThresholdMilliNewtonMeters { get; init; } public double SpeedChangeThresholdRpm { get; init; } @@ -306,6 +312,8 @@ public sealed class RealtimeSamplePayload public bool SpeedTorqueDone { get; init; } + public bool SpeedTorqueStable { get; init; } + public bool SpeedTorqueResetEnabled { get; init; } public bool SpeedTorqueResetDone { get; init; }