diff --git a/COFTester/Controls/RealtimeForceChart.cs b/COFTester/Controls/RealtimeForceChart.cs index 3048a7c..126ac65 100644 --- a/COFTester/Controls/RealtimeForceChart.cs +++ b/COFTester/Controls/RealtimeForceChart.cs @@ -57,7 +57,6 @@ public sealed class RealtimeForceChart : FrameworkElement typeof(RealtimeForceChart), new FrameworkPropertyMetadata(0d, FrameworkPropertyMetadataOptions.AffectsRender)); - private const double NewtonToGramForce = 101.9716213; private const double GravityMetersPerSecondSquared = 9.80665; private readonly Pen _axisPen = new(new SolidColorBrush(Color.FromRgb(54, 54, 54)), 1); @@ -220,7 +219,7 @@ public sealed class RealtimeForceChart : FrameworkElement DrawRightAlignedText( drawingContext, - FormatForceGramLabel(yMax * index / yDivisions), + FormatForceNewtonLabel(yMax * index / yDivisions), plot.Left - 10, y - 8, 11, @@ -237,7 +236,7 @@ public sealed class RealtimeForceChart : FrameworkElement var xAxisTitleY = Math.Min(plot.Bottom + 30, ActualHeight - 18); DrawText(drawingContext, "Time [sec]", plot.Right - 58, xAxisTitleY, 12, _titleBrush); - DrawText(drawingContext, "Friction force [gf]", plot.Left, plot.Top - 25, 12, _titleBrush); + DrawText(drawingContext, "Friction force [N]", plot.Left, plot.Top - 25, 12, _titleBrush); DrawText(drawingContext, "Coefficient of friction", plot.Right - 8, plot.Top - 25, 11, _titleBrush); } @@ -373,12 +372,11 @@ public sealed class RealtimeForceChart : FrameworkElement : 0; } - private static string FormatForceGramLabel(double forceN) + private static string FormatForceNewtonLabel(double forceN) { - var forceGf = forceN * NewtonToGramForce; - return forceGf >= 100 - ? forceGf.ToString("0", CultureInfo.InvariantCulture) - : forceGf.ToString("0.#", CultureInfo.InvariantCulture); + return forceN >= 10 + ? forceN.ToString("0.#", CultureInfo.InvariantCulture) + : forceN.ToString("0.###", CultureInfo.InvariantCulture); } private static string FormatCoefficientLabel(double forceN, double normalForceN) diff --git a/COFTester/MainWindow.xaml b/COFTester/MainWindow.xaml index 63e213b..dfa05da 100644 --- a/COFTester/MainWindow.xaml +++ b/COFTester/MainWindow.xaml @@ -591,8 +591,8 @@ - - + + diff --git a/COFTester/Models/PersistedRunData.cs b/COFTester/Models/PersistedRunData.cs index 2966802..eca1bc7 100644 --- a/COFTester/Models/PersistedRunData.cs +++ b/COFTester/Models/PersistedRunData.cs @@ -7,4 +7,6 @@ public sealed class PersistedRunData public required TestRecipeSnapshot Recipe { get; init; } public required IReadOnlyList Samples { get; init; } + + public IReadOnlyList ReciprocatingRecords { get; init; } = Array.Empty(); } diff --git a/COFTester/Services/ModbusProcessDataReader.cs b/COFTester/Services/ModbusProcessDataReader.cs index aa61387..d96dcb1 100644 --- a/COFTester/Services/ModbusProcessDataReader.cs +++ b/COFTester/Services/ModbusProcessDataReader.cs @@ -8,11 +8,10 @@ namespace COFTester.Services; public sealed class ModbusProcessDataReader { - private const ushort ReciprocatingRecordCountAddress = 500; - private const ushort ReciprocatingRecordStartAddress = 502; - private const int ReciprocatingRecordValueCount = 4; - private const int ReciprocatingRecordRegisterCount = ReciprocatingRecordValueCount * PlcRegisterEncoding.FloatRegisterCount; - private const ushort MaxReciprocatingRegistersPerRead = 120; + private const ushort CurrentReciprocatingStaticForceAddress = 1000; + private const ushort CurrentReciprocatingStaticCoefficientAddress = 1002; + private const ushort CurrentReciprocatingKineticForceAddress = 1010; + private const ushort CurrentReciprocatingKineticCoefficientAddress = 1012; private static readonly PlcResultRegisterMap DefaultResultRegisterMap = new( SlaveAddress: 1, @@ -138,60 +137,37 @@ public sealed class ModbusProcessDataReader horizontalPosition); } - public async Task> ReadReciprocatingRecordsAsync( - int requestedCount, + public async Task ReadCurrentReciprocatingRecordAsync( + int recordIndex, CancellationToken cancellationToken = default) { var master = _connectionService.Master ?? throw new InvalidOperationException("Modbus master is not connected."); - var configuredCount = Math.Max(1, requestedCount); - var countRegisters = await master.ReadHoldingRegistersAsync( + var startAddress = Min( + CurrentReciprocatingStaticForceAddress, + CurrentReciprocatingStaticCoefficientAddress, + CurrentReciprocatingKineticForceAddress, + CurrentReciprocatingKineticCoefficientAddress); + var registerCount = GetRegisterCount( + startAddress, + CurrentReciprocatingStaticForceAddress, + CurrentReciprocatingStaticCoefficientAddress, + CurrentReciprocatingKineticForceAddress, + CurrentReciprocatingKineticCoefficientAddress); + var registers = await ReadHoldingRegisterBlockAsync( + master, _resultRegisterMap.SlaveAddress, - ReciprocatingRecordCountAddress, - 1, + startAddress, + registerCount, cancellationToken); - var plcCount = countRegisters.Length == 0 ? configuredCount : countRegisters[0]; - var recordCount = plcCount <= 0 ? configuredCount : Math.Min(plcCount, configuredCount); - if (recordCount == 0) + + return new ReciprocatingFrictionRecord { - return Array.Empty(); - } - - var maxAddressableRecordCount = Math.Max(0, (ushort.MaxValue - ReciprocatingRecordStartAddress + 1) / ReciprocatingRecordRegisterCount); - var recordsToRead = Math.Min(recordCount, maxAddressableRecordCount); - var records = new List(recordsToRead); - var recordsPerRead = Math.Max(1, MaxReciprocatingRegistersPerRead / ReciprocatingRecordRegisterCount); - var nextRecordOffset = 0; - - while (nextRecordOffset < recordsToRead) - { - var chunkRecordCount = Math.Min(recordsPerRead, recordsToRead - nextRecordOffset); - var chunkStartAddress = checked((ushort)(ReciprocatingRecordStartAddress + nextRecordOffset * ReciprocatingRecordRegisterCount)); - var chunkRegisterCount = checked((ushort)(chunkRecordCount * ReciprocatingRecordRegisterCount)); - var registers = await ReadHoldingRegisterBlockAsync( - master, - _resultRegisterMap.SlaveAddress, - chunkStartAddress, - chunkRegisterCount, - cancellationToken); - - for (var index = 0; index < chunkRecordCount; index++) - { - var baseOffset = index * ReciprocatingRecordRegisterCount; - var recordNumber = nextRecordOffset + index + 1; - records.Add(new ReciprocatingFrictionRecord - { - Index = recordNumber, - StaticCoefficient = ReadRecordFloat(registers, chunkStartAddress, baseOffset), - KineticCoefficient = ReadRecordFloat(registers, chunkStartAddress, baseOffset + 2), - StaticForceN = ReadRecordFloat(registers, chunkStartAddress, baseOffset + 4), - KineticForceN = ReadRecordFloat(registers, chunkStartAddress, baseOffset + 6) - }); - } - - nextRecordOffset += chunkRecordCount; - } - - return records; + Index = Math.Max(1, recordIndex), + StaticCoefficient = ReadFloatAt(registers, startAddress, CurrentReciprocatingStaticCoefficientAddress), + KineticCoefficient = ReadFloatAt(registers, startAddress, CurrentReciprocatingKineticCoefficientAddress), + StaticForceN = ReadFloatAt(registers, startAddress, CurrentReciprocatingStaticForceAddress), + KineticForceN = ReadFloatAt(registers, startAddress, CurrentReciprocatingKineticForceAddress) + }; } private static async Task ReadHoldingRegisterBlockAsync( @@ -216,12 +192,6 @@ public sealed class ModbusProcessDataReader return PlcRegisterEncoding.ReadFloat(registers, offset, $"D{address}"); } - private static double ReadRecordFloat(ushort[] registers, ushort chunkStartAddress, int offset) - { - var address = checked((ushort)(chunkStartAddress + offset)); - return PlcRegisterEncoding.ReadFloat(registers, offset, $"D{address}"); - } - private static ushort Min(params ushort[] values) { return values.Min(); diff --git a/COFTester/Services/ModbusTcpConnectionService.cs b/COFTester/Services/ModbusTcpConnectionService.cs index da24ef0..415b650 100644 --- a/COFTester/Services/ModbusTcpConnectionService.cs +++ b/COFTester/Services/ModbusTcpConnectionService.cs @@ -27,6 +27,40 @@ public sealed class ModbusTcpConnectionService : IDisposable public IModbusMaster? Master => _master; + public async Task ReadCoilsAsync( + byte slaveAddress, + ushort startAddress, + ushort numberOfPoints, + CancellationToken cancellationToken = default) + { + var master = _master ?? throw new InvalidOperationException("Modbus master is not connected."); + var method = ResolveReadCoilsMethod(master.GetType()); + var parameters = method.GetParameters().Length switch + { + 4 => new object?[] { slaveAddress, startAddress, numberOfPoints, cancellationToken }, + 3 => new object?[] { slaveAddress, startAddress, numberOfPoints }, + _ => throw new InvalidOperationException("Unsupported ReadCoilsAsync signature.") + }; + + var result = method.Invoke(master, parameters); + if (result is Task boolArrayTask) + { + return await boolArrayTask.ConfigureAwait(false); + } + + if (result is Task task) + { + await task.ConfigureAwait(false); + var resultProperty = task.GetType().GetProperty("Result"); + if (resultProperty?.GetValue(task) is bool[] values) + { + return values; + } + } + + throw new InvalidOperationException("Modbus ReadCoilsAsync invocation did not return a Task."); + } + public async Task WriteSingleCoilAsync( byte slaveAddress, ushort coilAddress, @@ -210,6 +244,17 @@ public sealed class ModbusTcpConnectionService : IDisposable ?? throw new MissingMethodException(masterType.FullName, "WriteSingleCoilAsync"); } + private static MethodInfo ResolveReadCoilsMethod(Type masterType) + { + return masterType.GetMethod( + "ReadCoilsAsync", + [typeof(byte), typeof(ushort), typeof(ushort), typeof(CancellationToken)]) + ?? masterType.GetMethod( + "ReadCoilsAsync", + [typeof(byte), typeof(ushort), typeof(ushort)]) + ?? throw new MissingMethodException(masterType.FullName, "ReadCoilsAsync"); + } + private static MethodInfo ResolveWriteSingleRegisterMethod(Type masterType) { return masterType.GetMethod( diff --git a/COFTester/Services/RunExportService.cs b/COFTester/Services/RunExportService.cs index 611fb0f..22826ec 100644 --- a/COFTester/Services/RunExportService.cs +++ b/COFTester/Services/RunExportService.cs @@ -62,6 +62,7 @@ public sealed class RunExportService { BuildSummarySheet(workbook.Worksheets.Add("报表汇总"), exportRuns, exportStatistics); BuildRawDataSheet(workbook.Worksheets.Add("原始数据"), exportRuns); + BuildReciprocatingDataSheet(workbook.Worksheets.Add("每次数据"), exportRuns); BuildChartSheet(workbook.Worksheets.Add("曲线图"), exportRuns); workbook.SaveAs(outputPath); } @@ -192,14 +193,15 @@ public sealed class RunExportService "产品编号", "测试模式", "方向", - "静摩擦系数 μs", - "动摩擦系数 μk", + "静摩擦系数 COFs", + "动摩擦系数 COFk", "标准差", "标准差1", "标准差2", "峰值力(N)", "稳定段均力(N)", "有效采样点数", + "往复记录数", "原始力最小值(N)", "原始力最大值(N)", "原始力均值(N)", @@ -234,10 +236,11 @@ public sealed class RunExportService worksheet.Cell(row, 16).Value = item.Data.Run.PeakForceN; worksheet.Cell(row, 17).Value = item.Data.Run.AverageForceN; worksheet.Cell(row, 18).Value = item.ValidSamples.Count; - worksheet.Cell(row, 19).Value = forceStats.Min; - worksheet.Cell(row, 20).Value = forceStats.Max; - worksheet.Cell(row, 21).Value = forceStats.Average; - worksheet.Cell(row, 22).Value = forceStats.StandardDeviation; + worksheet.Cell(row, 19).Value = CountReciprocatingRecords(item); + worksheet.Cell(row, 20).Value = forceStats.Min; + worksheet.Cell(row, 21).Value = forceStats.Max; + worksheet.Cell(row, 22).Value = forceStats.Average; + worksheet.Cell(row, 23).Value = forceStats.StandardDeviation; worksheet.Cell(row, 4).Style.Fill.BackgroundColor = ToXlColor(item.CurveColorHex); worksheet.Cell(row, 4).Style.Font.FontColor = XLColor.White; @@ -250,7 +253,7 @@ public sealed class RunExportService ApplySummaryColumnWidths(worksheet); ApplySummaryFormatting(worksheet, dataStartRow, dataLastRow); - ApplyPageLayout(worksheet, dataLastRow, "$A$1:$V$"); + ApplyPageLayout(worksheet, dataLastRow, "$A$1:$W$"); worksheet.SheetView.FreezeRows(1); } @@ -281,8 +284,8 @@ public sealed class RunExportService "产品编号", "测试模式", "方向", - "静摩擦系数 μs", - "动摩擦系数 μk", + "静摩擦系数 COFs", + "动摩擦系数 COFk", "标准差", "数据状态", "采样序号", @@ -345,6 +348,84 @@ public sealed class RunExportService worksheet.SheetView.FreezeRows(1); } + private static void BuildReciprocatingDataSheet(IXLWorksheet worksheet, IReadOnlyList runs) + { + ApplyDefaultWorksheetStyle(worksheet); + + worksheet.Cell("A1").Value = "每次往复数据"; + worksheet.Range("A1:F1").Merge(); + worksheet.Cell("A1").Style.Font.Bold = true; + worksheet.Cell("A1").Style.Font.FontSize = 18; + worksheet.Cell("A1").Style.Font.FontColor = XLColor.FromHtml("#21313D"); + + worksheet.Cell("A3").Value = "记录说明"; + worksheet.Cell("B3").Value = "每一行记录一次往复完成时读取的 COFs、COFk、Fs[N]、Fk[N];旧历史没有往复记录时保留轮次并标记为空。"; + worksheet.Range("B3:H3").Merge(); + + var headerRow = 5; + var headers = new[] + { + "曲线标识", + "曲线标签", + "图例标签", + "曲线颜色", + "试验轮次", + "完成时间", + "批次号", + "产品编号", + "测试模式", + "方向", + "设置往复次数", + "往复序号", + "COFs", + "COFk", + "Fs[N]", + "Fk[N]", + "数据状态" + }; + + WriteHeaderRow(worksheet, headerRow, headers); + + var rowIndex = headerRow + 1; + foreach (var item in runs) + { + var records = item.Data.ReciprocatingRecords + .Where(record => record.Index > 0) + .OrderBy(record => record.Index) + .ToArray(); + + if (records.Length == 0) + { + WriteReciprocatingMetadataCells(worksheet, rowIndex, item); + worksheet.Cell(rowIndex, 17).Value = "无往复记录"; + rowIndex++; + continue; + } + + foreach (var record in records) + { + WriteReciprocatingMetadataCells(worksheet, rowIndex, item); + worksheet.Cell(rowIndex, 12).Value = record.Index; + SetNullableNumber(worksheet.Cell(rowIndex, 13), record.StaticCoefficient); + SetNullableNumber(worksheet.Cell(rowIndex, 14), record.KineticCoefficient); + SetNullableNumber(worksheet.Cell(rowIndex, 15), record.StaticForceN); + SetNullableNumber(worksheet.Cell(rowIndex, 16), record.KineticForceN); + worksheet.Cell(rowIndex, 17).Value = record.HasData ? "有效" : "空记录"; + rowIndex++; + } + } + + var dataLastRow = Math.Max(headerRow + 1, rowIndex - 1); + worksheet.Range(headerRow, 1, dataLastRow, headers.Length) + .CreateTable("ReciprocatingRecords") + .Theme = XLTableTheme.TableStyleMedium4; + + ApplyReciprocatingDataColumnWidths(worksheet); + ApplyReciprocatingDataFormatting(worksheet, headerRow + 1, dataLastRow); + ApplyPageLayout(worksheet, dataLastRow, "$A$1:$Q$"); + worksheet.SheetView.FreezeRows(1); + } + private static WorksheetChartLayout BuildChartSheet(IXLWorksheet worksheet, IReadOnlyList runs) { ApplyDefaultWorksheetStyle(worksheet); @@ -356,27 +437,29 @@ public sealed class RunExportService worksheet.Cell("A1").Style.Font.FontColor = XLColor.FromHtml("#21313D"); worksheet.Cell("A3").Value = "图表说明"; - worksheet.Cell("B3").Value = "每条含至少 2 个有效采样点的历史试验记录对应一个独立曲线图,横轴按真实位移数值比例绘制。"; + worksheet.Cell("B3").Value = "总览曲线叠加本次导出的全部有效次数曲线;每次曲线仅显示对应单次试验,横轴按真实位移数值比例绘制。"; worksheet.Range("B3:H3").Merge(); worksheet.Cell("A5").Value = "曲线清单"; worksheet.Cell("A5").Style.Font.Bold = true; worksheet.Cell("A5").Style.Font.FontSize = 12; - WriteHeaderRow(worksheet, 6, ["曲线标识", "图例标签", "颜色", "有效采样点数", "曲线状态"]); + WriteHeaderRow(worksheet, 6, ["曲线标识", "试验轮次", "图例标签", "颜色", "有效采样点数", "往复记录数", "曲线状态"]); for (var index = 0; index < runs.Count; index++) { var row = 7 + index; worksheet.Cell(row, 1).Value = BuildCurveKey(runs[index]); - worksheet.Cell(row, 2).Value = BuildLegendLabel(runs[index]); - worksheet.Cell(row, 3).Value = runs[index].CurveColorHex; - worksheet.Cell(row, 4).Value = runs[index].ValidSamples.Count; - worksheet.Cell(row, 5).Value = IsChartableRun(runs[index]) - ? "已记录图片曲线与原始数据" - : "采样点不足,保留原始数据"; - worksheet.Cell(row, 3).Style.Fill.BackgroundColor = ToXlColor(runs[index].CurveColorHex); - worksheet.Cell(row, 3).Style.Font.FontColor = XLColor.White; + worksheet.Cell(row, 2).Value = runs[index].Data.Run.RunIndex; + worksheet.Cell(row, 3).Value = BuildLegendLabel(runs[index]); + worksheet.Cell(row, 4).Value = runs[index].CurveColorHex; + worksheet.Cell(row, 5).Value = runs[index].ValidSamples.Count; + worksheet.Cell(row, 6).Value = CountReciprocatingRecords(runs[index]); + worksheet.Cell(row, 7).Value = IsChartableRun(runs[index]) + ? "已记录总览曲线、每次曲线和原始数据" + : "采样点不足,保留原始数据和往复数据"; + worksheet.Cell(row, 4).Style.Fill.BackgroundColor = ToXlColor(runs[index].CurveColorHex); + worksheet.Cell(row, 4).Style.Font.FontColor = XLColor.White; } ApplyChartColumnWidths(worksheet); @@ -406,14 +489,14 @@ public sealed class RunExportService var rows = new (string Label, string Value)[] { - ("平均静摩擦系数 μs", averageStatic.ToString("F4", CultureInfo.InvariantCulture)), - ("平均动摩擦系数 μk", averageKinetic.ToString("F4", CultureInfo.InvariantCulture)), - ("静摩擦最大值 μs", maxStatic.ToString("F4", CultureInfo.InvariantCulture)), - ("静摩擦最小值 μs", minStatic.ToString("F4", CultureInfo.InvariantCulture)), - ("静摩擦标准差 μs", standardDeviationStatic.ToString("F4", CultureInfo.InvariantCulture)), - ("动摩擦最大值 μk", maxKinetic.ToString("F4", CultureInfo.InvariantCulture)), - ("动摩擦最小值 μk", minKinetic.ToString("F4", CultureInfo.InvariantCulture)), - ("动摩擦标准差 μk", standardDeviationKinetic.ToString("F4", CultureInfo.InvariantCulture)), + ("平均静摩擦系数 COFs", averageStatic.ToString("F4", CultureInfo.InvariantCulture)), + ("平均动摩擦系数 COFk", averageKinetic.ToString("F4", CultureInfo.InvariantCulture)), + ("静摩擦最大值 COFs", maxStatic.ToString("F4", CultureInfo.InvariantCulture)), + ("静摩擦最小值 COFs", minStatic.ToString("F4", CultureInfo.InvariantCulture)), + ("静摩擦标准差 COFs", standardDeviationStatic.ToString("F4", CultureInfo.InvariantCulture)), + ("动摩擦最大值 COFk", maxKinetic.ToString("F4", CultureInfo.InvariantCulture)), + ("动摩擦最小值 COFk", minKinetic.ToString("F4", CultureInfo.InvariantCulture)), + ("动摩擦标准差 COFk", standardDeviationKinetic.ToString("F4", CultureInfo.InvariantCulture)), ("最大峰值力(N)", maxPeakForce.ToString("F4", CultureInfo.InvariantCulture)), ("最小峰值力(N)", minPeakForce.ToString("F4", CultureInfo.InvariantCulture)) }; @@ -493,13 +576,13 @@ public sealed class RunExportService if (perRunSeries.Count > 0) { charts.Add(new ChartDefinition( - "历史实时摩擦曲线总览", + "总览曲线(全部次数)", "位移 (mm)", "力值 (N)", true, perRunSeries.Select(item => item.Series).ToArray(), new ChartAnchor(0, overviewStartRow, chartWidth, overviewStartRow + overviewChartHeight), - "历史实时摩擦曲线总览")); + "总览曲线_全部次数")); perRunStartRow = overviewStartRow + overviewChartHeight + rowGap; } @@ -513,13 +596,13 @@ public sealed class RunExportService var runSeries = perRunSeries[index]; charts.Add(new ChartDefinition( - $"{BuildCurveLabel(runSeries.Data)} 力值曲线", + BuildSingleRunCurveTitle(runSeries.Data), "位移 (mm)", "力值 (N)", false, [runSeries.Series], new ChartAnchor(fromColumn, fromRow, toColumn, toRow), - $"单条曲线图_{runSeries.Data.Sequence:D2}")); + $"每次曲线_{runSeries.Data.Sequence:D2}")); } return new WorksheetChartLayout(worksheet.Name, charts); @@ -537,8 +620,8 @@ public sealed class RunExportService InsertCurveChartImage( worksheet, row, - "历史实时摩擦曲线总览(图片记录)", - "历史实时摩擦曲线总览", + "总览曲线(全部次数)", + "总览曲线(全部次数)", "CurveOverview", chartableRuns, showLegend: true); @@ -549,8 +632,8 @@ public sealed class RunExportService InsertCurveChartImage( worksheet, row, - $"{BuildCurveLabel(run)} 实时摩擦曲线(图片记录)", - $"{BuildCurveLabel(run)} 力值曲线", + BuildSingleRunCurveTitle(run), + BuildSingleRunCurveTitle(run), $"Curve{run.Sequence:D2}", [run], showLegend: false); @@ -901,6 +984,7 @@ public sealed class RunExportService worksheet.Column(20).Width = 14; worksheet.Column(21).Width = 14; worksheet.Column(22).Width = 16; + worksheet.Column(23).Width = 16; } private static void ApplyRawDataColumnWidths(IXLWorksheet worksheet) @@ -926,6 +1010,27 @@ public sealed class RunExportService worksheet.Column(19).Width = 14; } + private static void ApplyReciprocatingDataColumnWidths(IXLWorksheet worksheet) + { + worksheet.Column(1).Width = 24; + worksheet.Column(2).Width = 26; + worksheet.Column(3).Width = 28; + worksheet.Column(4).Width = 12; + worksheet.Column(5).Width = 10; + worksheet.Column(6).Width = 20; + worksheet.Column(7).Width = 16; + worksheet.Column(8).Width = 16; + worksheet.Column(9).Width = 16; + worksheet.Column(10).Width = 14; + worksheet.Column(11).Width = 14; + worksheet.Column(12).Width = 10; + worksheet.Column(13).Width = 12; + worksheet.Column(14).Width = 12; + worksheet.Column(15).Width = 12; + worksheet.Column(16).Width = 12; + worksheet.Column(17).Width = 14; + } + private static void ApplyChartColumnWidths(IXLWorksheet worksheet) { for (var column = 1; column <= 20; column++) @@ -936,7 +1041,9 @@ public sealed class RunExportService 2 => 28, 3 => 12, 4 => 12, - 5 => 22, + 5 => 14, + 6 => 14, + 7 => 32, _ => 14 }; } @@ -952,8 +1059,8 @@ public sealed class RunExportService worksheet.Range(dataStartRow, 5, dataLastRow, 5).Style.Alignment.Horizontal = XLAlignmentHorizontalValues.Center; worksheet.Range(dataStartRow, 10, dataLastRow, 10).Style.Alignment.Horizontal = XLAlignmentHorizontalValues.Center; worksheet.Range(dataStartRow, 11, dataLastRow, 17).Style.NumberFormat.Format = "0.0000"; - worksheet.Range(dataStartRow, 18, dataLastRow, 18).Style.NumberFormat.Format = "0"; - worksheet.Range(dataStartRow, 19, dataLastRow, 22).Style.NumberFormat.Format = "0.0000"; + worksheet.Range(dataStartRow, 18, dataLastRow, 19).Style.NumberFormat.Format = "0"; + worksheet.Range(dataStartRow, 20, dataLastRow, 23).Style.NumberFormat.Format = "0.0000"; } private static void ApplyRawDataFormatting(IXLWorksheet worksheet, int dataStartRow, int dataLastRow) @@ -971,6 +1078,18 @@ public sealed class RunExportService worksheet.Range(dataStartRow, 19, dataLastRow, 19).Style.NumberFormat.Format = "0.000"; } + private static void ApplyReciprocatingDataFormatting(IXLWorksheet worksheet, int dataStartRow, int dataLastRow) + { + if (dataLastRow < dataStartRow) + { + return; + } + + worksheet.Range(dataStartRow, 5, dataLastRow, 5).Style.Alignment.Horizontal = XLAlignmentHorizontalValues.Center; + worksheet.Range(dataStartRow, 10, dataLastRow, 12).Style.Alignment.Horizontal = XLAlignmentHorizontalValues.Center; + worksheet.Range(dataStartRow, 13, dataLastRow, 16).Style.NumberFormat.Format = "0.0000"; + } + private static void ApplyPageLayout(IXLWorksheet worksheet, int lastDataRow, string printAreaPrefix) { worksheet.PageSetup.PageOrientation = XLPageOrientation.Landscape; @@ -1224,6 +1343,11 @@ public sealed class RunExportService return $"{BuildCurveSequenceCode(item.Sequence)} | 批次 {item.Data.Recipe.BatchNumber}"; } + private static string BuildSingleRunCurveTitle(ExportCurveData item) + { + return $"第 {item.Data.Run.RunIndex} 次曲线 - {item.Data.Recipe.BatchNumber}"; + } + private static string BuildCurveSequenceCode(int sequence) { return $"曲线{sequence:D2}"; @@ -1285,6 +1409,37 @@ public sealed class RunExportService return Math.Sqrt(variance); } + private static int CountReciprocatingRecords(ExportCurveData item) + { + return item.Data.ReciprocatingRecords.Count(record => record.HasData); + } + + private static void WriteReciprocatingMetadataCells(IXLWorksheet worksheet, int row, ExportCurveData item) + { + worksheet.Cell(row, 1).Value = BuildCurveKey(item); + worksheet.Cell(row, 2).Value = BuildCurveLabel(item); + worksheet.Cell(row, 3).Value = BuildLegendLabel(item); + worksheet.Cell(row, 4).Value = item.CurveColorHex; + worksheet.Cell(row, 5).Value = item.Data.Run.RunIndex; + worksheet.Cell(row, 6).Value = item.Data.Run.CompletedAt; + worksheet.Cell(row, 6).Style.DateFormat.Format = "yyyy-mm-dd hh:mm:ss"; + worksheet.Cell(row, 7).Value = item.Data.Recipe.BatchNumber; + worksheet.Cell(row, 8).Value = item.Data.Recipe.ProductCode; + worksheet.Cell(row, 9).Value = item.Data.Recipe.TestMode; + worksheet.Cell(row, 10).Value = item.Data.Recipe.Direction; + worksheet.Cell(row, 11).Value = item.Data.Recipe.ReciprocatingCount; + worksheet.Cell(row, 4).Style.Fill.BackgroundColor = ToXlColor(item.CurveColorHex); + worksheet.Cell(row, 4).Style.Font.FontColor = XLColor.White; + } + + private static void SetNullableNumber(IXLCell cell, double? value) + { + if (value is { } number && IsFinite(number)) + { + cell.Value = number; + } + } + private static string BuildCellReference(string sheetName, int rowNumber, int columnNumber) { return $"{QuoteSheetName(sheetName)}!${GetColumnName(columnNumber)}${rowNumber}"; diff --git a/COFTester/Services/TestDataRepository.cs b/COFTester/Services/TestDataRepository.cs index 8f52e9c..90d6d4d 100644 --- a/COFTester/Services/TestDataRepository.cs +++ b/COFTester/Services/TestDataRepository.cs @@ -24,6 +24,7 @@ public sealed class TestDataRepository "speed_mm_per_min", "travel_mm", "replicate_count", + "reciprocating_count", "specimen_description", "static_coefficient", "static_coefficient_1", @@ -53,6 +54,17 @@ public sealed class TestDataRepository "speed_mm_per_min" ]; + private static readonly string[] ReciprocatingRecordColumns = + [ + "id", + "run_id", + "record_index", + "static_coefficient", + "kinetic_coefficient", + "static_force_n", + "kinetic_force_n" + ]; + private readonly string _connectionString; public TestDataRepository(string databasePath) @@ -79,11 +91,17 @@ public sealed class TestDataRepository EnsureTestRunsTable(connection, transaction); EnsureRawSamplesTable(connection, transaction); EnsureRawSamplesIndex(connection, transaction); + EnsureReciprocatingRecordsTable(connection, transaction); + EnsureReciprocatingRecordsIndex(connection, transaction); transaction.Commit(); SetForeignKeys(connection, enabled: true); } - public void SaveRun(RunRecord run, TestRecipeSnapshot recipe, IReadOnlyList samples) + public void SaveRun( + RunRecord run, + TestRecipeSnapshot recipe, + IReadOnlyList samples, + IReadOnlyList reciprocatingRecords) { using var connection = new SqliteConnection(_connectionString); connection.Open(); @@ -98,7 +116,7 @@ public sealed class TestDataRepository run_id, run_index, completed_at, batch_number, product_code, test_mode, counterface_material, direction, sled_mass_grams, lift_speed_mm_per_min, lift_displacement_mm, speed_mm_per_min, travel_mm, replicate_count, - specimen_description, static_coefficient, static_coefficient_1, + reciprocating_count, specimen_description, static_coefficient, static_coefficient_1, static_coefficient_2, kinetic_coefficient, kinetic_coefficient_1, kinetic_coefficient_2, standard_deviation, standard_deviation_1, standard_deviation_2, peak_force_n, average_force_n, judgement, csv_export_path, @@ -107,7 +125,7 @@ public sealed class TestDataRepository $runId, $runIndex, $completedAt, $batchNumber, $productCode, $testMode, $counterfaceMaterial, $direction, $sledMassGrams, $liftSpeedMmPerMin, $liftDisplacementMm, $speedMmPerMin, $travelMm, $replicateCount, - $specimenDescription, $staticCoefficient, $staticCoefficient1, + $reciprocatingCount, $specimenDescription, $staticCoefficient, $staticCoefficient1, $staticCoefficient2, $kineticCoefficient, $kineticCoefficient1, $kineticCoefficient2, $standardDeviation, $standardDeviation1, $standardDeviation2, $peakForceN, $averageForceN, $judgement, $csvExportPath, @@ -127,6 +145,14 @@ public sealed class TestDataRepository deleteCommand.ExecuteNonQuery(); } + using (var deleteReciprocatingCommand = connection.CreateCommand()) + { + deleteReciprocatingCommand.Transaction = transaction; + deleteReciprocatingCommand.CommandText = "DELETE FROM reciprocating_records WHERE run_id = $runId;"; + deleteReciprocatingCommand.Parameters.AddWithValue("$runId", run.RunId.ToString("N", CultureInfo.InvariantCulture)); + deleteReciprocatingCommand.ExecuteNonQuery(); + } + foreach (var sample in samples) { using var sampleCommand = connection.CreateCommand(); @@ -149,6 +175,28 @@ public sealed class TestDataRepository sampleCommand.ExecuteNonQuery(); } + foreach (var record in reciprocatingRecords.Where(record => record.HasData)) + { + using var recordCommand = connection.CreateCommand(); + recordCommand.Transaction = transaction; + recordCommand.CommandText = + """ + INSERT INTO reciprocating_records ( + run_id, record_index, static_coefficient, kinetic_coefficient, static_force_n, kinetic_force_n + ) VALUES ( + $runId, $recordIndex, $staticCoefficient, $kineticCoefficient, $staticForceN, $kineticForceN + ); + """; + + recordCommand.Parameters.AddWithValue("$runId", run.RunId.ToString("N", CultureInfo.InvariantCulture)); + recordCommand.Parameters.AddWithValue("$recordIndex", record.Index); + recordCommand.Parameters.AddWithValue("$staticCoefficient", (object?)record.StaticCoefficient ?? DBNull.Value); + recordCommand.Parameters.AddWithValue("$kineticCoefficient", (object?)record.KineticCoefficient ?? DBNull.Value); + recordCommand.Parameters.AddWithValue("$staticForceN", (object?)record.StaticForceN ?? DBNull.Value); + recordCommand.Parameters.AddWithValue("$kineticForceN", (object?)record.KineticForceN ?? DBNull.Value); + recordCommand.ExecuteNonQuery(); + } + transaction.Commit(); } @@ -184,6 +232,13 @@ public sealed class TestDataRepository deleteSamplesCommand.ExecuteNonQuery(); } + using (var deleteReciprocatingCommand = connection.CreateCommand()) + { + deleteReciprocatingCommand.Transaction = transaction; + deleteReciprocatingCommand.CommandText = "DELETE FROM reciprocating_records;"; + deleteReciprocatingCommand.ExecuteNonQuery(); + } + using (var deleteRunsCommand = connection.CreateCommand()) { deleteRunsCommand.Transaction = transaction; @@ -234,7 +289,7 @@ public sealed class TestDataRepository SELECT run_id, run_index, completed_at, batch_number, product_code, test_mode, counterface_material, direction, sled_mass_grams, lift_speed_mm_per_min, lift_displacement_mm, speed_mm_per_min, travel_mm, replicate_count, - specimen_description, static_coefficient, static_coefficient_1, + reciprocating_count, specimen_description, static_coefficient, static_coefficient_1, static_coefficient_2, kinetic_coefficient, kinetic_coefficient_1, kinetic_coefficient_2, standard_deviation, standard_deviation_1, standard_deviation_2, peak_force_n, average_force_n, judgement, csv_export_path, @@ -257,21 +312,21 @@ public sealed class TestDataRepository CompletedAt = DateTime.Parse(reader.GetString(2), CultureInfo.InvariantCulture, DateTimeStyles.RoundtripKind), BatchNumber = reader.GetString(3), TestMode = reader.GetString(5), - StaticCoefficient = reader.GetDouble(15), - StaticCoefficient1 = reader.GetDouble(16), - StaticCoefficient2 = reader.GetDouble(17), - KineticCoefficient = reader.GetDouble(18), - KineticCoefficient1 = reader.GetDouble(19), - KineticCoefficient2 = reader.GetDouble(20), - StandardDeviation = reader.GetDouble(21), - StandardDeviation1 = reader.GetDouble(22), - StandardDeviation2 = reader.GetDouble(23), - PeakForceN = reader.GetDouble(24), - AverageForceN = reader.GetDouble(25), - Judgement = reader.GetString(26), - CsvExportPath = reader.GetString(27), - ReportExportPath = reader.GetString(28), - SampleCount = reader.GetInt32(29) + StaticCoefficient = reader.GetDouble(16), + StaticCoefficient1 = reader.GetDouble(17), + StaticCoefficient2 = reader.GetDouble(18), + KineticCoefficient = reader.GetDouble(19), + KineticCoefficient1 = reader.GetDouble(20), + KineticCoefficient2 = reader.GetDouble(21), + StandardDeviation = reader.GetDouble(22), + StandardDeviation1 = reader.GetDouble(23), + StandardDeviation2 = reader.GetDouble(24), + PeakForceN = reader.GetDouble(25), + AverageForceN = reader.GetDouble(26), + Judgement = reader.GetString(27), + CsvExportPath = reader.GetString(28), + ReportExportPath = reader.GetString(29), + SampleCount = reader.GetInt32(30) }; var recipe = new TestRecipeSnapshot { @@ -286,15 +341,18 @@ public sealed class TestDataRepository SpeedMmPerMin = reader.GetDouble(11), TravelMm = reader.GetDouble(12), ReplicateCount = reader.GetInt32(13), - SpecimenDescription = reader.GetString(14) + ReciprocatingCount = reader.GetInt32(14), + SpecimenDescription = reader.GetString(15) }; var samples = LoadSamples(connection, runId); + var reciprocatingRecords = LoadReciprocatingRecords(connection, runId); return new PersistedRunData { Run = run, Recipe = recipe, - Samples = samples + Samples = samples, + ReciprocatingRecords = reciprocatingRecords }; } @@ -314,6 +372,7 @@ public sealed class TestDataRepository command.Parameters.AddWithValue("$speedMmPerMin", recipe.SpeedMmPerMin); command.Parameters.AddWithValue("$travelMm", recipe.TravelMm); command.Parameters.AddWithValue("$replicateCount", recipe.ReplicateCount); + command.Parameters.AddWithValue("$reciprocatingCount", recipe.ReciprocatingCount); command.Parameters.AddWithValue("$specimenDescription", recipe.SpecimenDescription); command.Parameters.AddWithValue("$staticCoefficient", run.StaticCoefficient); command.Parameters.AddWithValue("$staticCoefficient1", run.StaticCoefficient1); @@ -362,6 +421,35 @@ public sealed class TestDataRepository return samples; } + private static IReadOnlyList LoadReciprocatingRecords(SqliteConnection connection, Guid runId) + { + using var command = connection.CreateCommand(); + command.CommandText = + """ + SELECT record_index, static_coefficient, kinetic_coefficient, static_force_n, kinetic_force_n + FROM reciprocating_records + WHERE run_id = $runId + ORDER BY record_index; + """; + command.Parameters.AddWithValue("$runId", runId.ToString("N", CultureInfo.InvariantCulture)); + + using var reader = command.ExecuteReader(); + var records = new List(); + while (reader.Read()) + { + records.Add(new ReciprocatingFrictionRecord + { + Index = reader.GetInt32(0), + StaticCoefficient = ReadNullableDouble(reader, 1), + KineticCoefficient = ReadNullableDouble(reader, 2), + StaticForceN = ReadNullableDouble(reader, 3), + KineticForceN = ReadNullableDouble(reader, 4) + }); + } + + return records; + } + private static void EnsureTestRunsTable(SqliteConnection connection, SqliteTransaction transaction) { if (!TableExists(connection, transaction, "test_runs")) @@ -386,7 +474,7 @@ public sealed class TestDataRepository run_id, run_index, completed_at, batch_number, product_code, test_mode, counterface_material, direction, sled_mass_grams, lift_speed_mm_per_min, lift_displacement_mm, speed_mm_per_min, travel_mm, replicate_count, - specimen_description, static_coefficient, static_coefficient_1, + reciprocating_count, specimen_description, static_coefficient, static_coefficient_1, static_coefficient_2, kinetic_coefficient, kinetic_coefficient_1, kinetic_coefficient_2, standard_deviation, standard_deviation_1, standard_deviation_2, peak_force_n, average_force_n, judgement, @@ -407,6 +495,7 @@ public sealed class TestDataRepository {SelectColumnOrDefault(existingColumns, "speed_mm_per_min", "0")}, {SelectColumnOrDefault(existingColumns, "travel_mm", "0")}, {SelectColumnOrDefault(existingColumns, "replicate_count", "0")}, + {SelectColumnOrDefault(existingColumns, "reciprocating_count", "50")}, {SelectColumnOrDefault(existingColumns, "specimen_description", "''")}, {SelectColumnOrDefault(existingColumns, "static_coefficient", "0")}, {SelectColumnOrDefault(existingColumns, "static_coefficient_1", SelectColumnOrDefault(existingColumns, "static_coefficient", "0"))}, @@ -482,6 +571,57 @@ public sealed class TestDataRepository command.ExecuteNonQuery(); } + private static void EnsureReciprocatingRecordsTable(SqliteConnection connection, SqliteTransaction transaction) + { + if (!TableExists(connection, transaction, "reciprocating_records")) + { + CreateReciprocatingRecordsTable(connection, transaction, "reciprocating_records"); + return; + } + + var existingColumns = GetColumnNames(connection, transaction, "reciprocating_records"); + if (existingColumns.SetEquals(ReciprocatingRecordColumns)) + { + return; + } + + CreateReciprocatingRecordsTable(connection, transaction, "reciprocating_records_new"); + + using var migrateCommand = connection.CreateCommand(); + migrateCommand.Transaction = transaction; + migrateCommand.CommandText = + $""" + INSERT INTO reciprocating_records_new ( + id, run_id, record_index, static_coefficient, kinetic_coefficient, static_force_n, kinetic_force_n + ) + SELECT + {SelectColumnOrDefault(existingColumns, "id", "NULL")}, + {SelectColumnOrDefault(existingColumns, "run_id", "''")}, + {SelectColumnOrDefault(existingColumns, "record_index", "0")}, + {SelectColumnOrDefault(existingColumns, "static_coefficient", "NULL")}, + {SelectColumnOrDefault(existingColumns, "kinetic_coefficient", "NULL")}, + {SelectColumnOrDefault(existingColumns, "static_force_n", "NULL")}, + {SelectColumnOrDefault(existingColumns, "kinetic_force_n", "NULL")} + FROM reciprocating_records; + """; + migrateCommand.ExecuteNonQuery(); + + DropTable(connection, transaction, "reciprocating_records"); + RenameTable(connection, transaction, "reciprocating_records_new", "reciprocating_records"); + } + + private static void EnsureReciprocatingRecordsIndex(SqliteConnection connection, SqliteTransaction transaction) + { + using var command = connection.CreateCommand(); + command.Transaction = transaction; + command.CommandText = + """ + CREATE INDEX IF NOT EXISTS idx_reciprocating_records_run_id_record_index + ON reciprocating_records(run_id, record_index); + """; + command.ExecuteNonQuery(); + } + private static void CreateTestRunsTable(SqliteConnection connection, SqliteTransaction transaction, string tableName) { using var command = connection.CreateCommand(); @@ -503,6 +643,7 @@ public sealed class TestDataRepository speed_mm_per_min REAL NOT NULL, travel_mm REAL NOT NULL, replicate_count INTEGER NOT NULL, + reciprocating_count INTEGER NOT NULL DEFAULT 50, specimen_description TEXT NOT NULL, static_coefficient REAL NOT NULL, static_coefficient_1 REAL NOT NULL DEFAULT 0, @@ -524,6 +665,26 @@ public sealed class TestDataRepository command.ExecuteNonQuery(); } + private static void CreateReciprocatingRecordsTable(SqliteConnection connection, SqliteTransaction transaction, string tableName) + { + using var command = connection.CreateCommand(); + command.Transaction = transaction; + command.CommandText = + $""" + CREATE TABLE {tableName} ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + run_id TEXT NOT NULL, + record_index INTEGER NOT NULL, + static_coefficient REAL, + kinetic_coefficient REAL, + static_force_n REAL, + kinetic_force_n REAL, + FOREIGN KEY (run_id) REFERENCES test_runs(run_id) ON DELETE CASCADE + ); + """; + command.ExecuteNonQuery(); + } + private static void CreateRawSamplesTable(SqliteConnection connection, SqliteTransaction transaction, string tableName) { using var command = connection.CreateCommand(); @@ -574,6 +735,11 @@ public sealed class TestDataRepository return existingColumns.Contains(columnName) ? columnName : defaultSql; } + private static double? ReadNullableDouble(SqliteDataReader reader, int ordinal) + { + return reader.IsDBNull(ordinal) ? null : reader.GetDouble(ordinal); + } + private static void DropTable(SqliteConnection connection, SqliteTransaction transaction, string tableName) { using var command = connection.CreateCommand(); diff --git a/COFTester/ViewModels/MainViewModel.cs b/COFTester/ViewModels/MainViewModel.cs index a7c0c5d..f9a6cc4 100644 --- a/COFTester/ViewModels/MainViewModel.cs +++ b/COFTester/ViewModels/MainViewModel.cs @@ -44,9 +44,11 @@ public sealed class MainViewModel : ObservableObject, IDisposable private const ushort CoilForward = 3; private const ushort CoilStart = 30; private const ushort CoilStop = 32; + private const ushort CoilReciprocatingCycleComplete = 37; private const ushort CoilReset = 90; private const ushort RegisterSledMassGrams = 400; private const ushort RegisterReplicateCount = 406; + private const ushort RegisterReciprocatingCount = 410; private const ushort RegisterHorizontalSpeedSetpoint = 370; private const ushort RegisterHorizontalTravelSetpoint = 380; private const ushort RegisterLiftSpeed = 310; @@ -58,7 +60,7 @@ public sealed class MainViewModel : ObservableObject, IDisposable private const double EmptyChartYAxisMaxLimit = 0.5; private const double EmptyChartPointForceN = 0.001; private const int ReciprocatingRecordSectionSize = 15; - private static readonly TimeSpan ReciprocatingRecordRefreshInterval = TimeSpan.FromSeconds(1); + private static readonly TimeSpan SingleReciprocatingCompletionGracePeriod = TimeSpan.FromSeconds(2); private readonly DispatcherTimer _timer; private readonly DispatcherTimer _deviceReconnectTimer; @@ -122,6 +124,7 @@ public sealed class MainViewModel : ObservableObject, IDisposable private TestRecipeSnapshot? _displayedRecipeSnapshot; private TestRecipeSnapshot? _lastCompletedRecipeSnapshot; private IReadOnlyList _lastCompletedSamples = Array.Empty(); + private IReadOnlyList _lastCompletedReciprocatingRecords = Array.Empty(); private bool _isShowingHistoricalRun; private bool _deviceConnectionFailureLogged; private bool _isInitializingDeviceConnection; @@ -130,6 +133,8 @@ public sealed class MainViewModel : ObservableObject, IDisposable private bool _isSyncingRecipeFromPlc; private bool _activeRunStartedByPlc; private bool _reciprocatingRecordReadFailureLogged; + private bool _lastReciprocatingCycleCompleteSignal; + private bool _isWaitingForReciprocatingCycleCompleteSignalReset; private ushort? _activeTableMotionCoil; private ushort? _pendingTableMotionStopCoil; private MachineRuntimeState _machineRuntimeState = MachineRuntimeState.Idle; @@ -138,11 +143,12 @@ public sealed class MainViewModel : ObservableObject, IDisposable private double _kineticForceSum; private int _kineticSampleCount; private double _runStartHorizontalPositionMm = double.NaN; + private DateTime? _runTravelCompletedAtUtc; private double _lastRealtimeChartElapsedSeconds = double.NaN; private double _realtimeChartMaxElapsedSeconds; private double _currentRealtimeChartElapsedSeconds; private DateTime _lastRealtimeChartRefreshAt = DateTime.MinValue; - private DateTime _lastReciprocatingRecordRefreshAt = DateTime.MinValue; + private int _nextReciprocatingRecordIndex = 1; private double _lastForceXAxisMaxLimit; private double _lastForceYAxisMaxLimit; @@ -406,7 +412,7 @@ public sealed class MainViewModel : ObservableObject, IDisposable public string DeviceEndpoint => _deviceConnectionService.Endpoint; public string PlcAddressSummary => - $"站号 {ModbusSlaveAddress};参数 D{RegisterSledMassGrams}/D{RegisterReplicateCount}/D{RegisterHorizontalSpeedSetpoint}/D{RegisterHorizontalTravelSetpoint};结果 D460;往复记录占位 D500/D502。"; + $"站号 {ModbusSlaveAddress};参数 D{RegisterSledMassGrams}/D{RegisterReplicateCount}/D{RegisterReciprocatingCount}/D{RegisterHorizontalSpeedSetpoint}/D{RegisterHorizontalTravelSetpoint};结果 D460;往复完成 M{CoilReciprocatingCycleComplete};往复记录 D1000/D1002/D1010/D1012。"; public string DeviceLastConnectedAtLabel => _deviceLastConnectedAt?.ToString("yyyy-MM-dd HH:mm:ss") ?? "尚未连接"; @@ -744,7 +750,8 @@ public sealed class MainViewModel : ObservableObject, IDisposable } } - StartOrResumeCore(); + await SyncStartParametersToPlcAsync(); + await StartOrResumeCoreAsync(); } private bool TryValidateStartOrResume() @@ -766,7 +773,13 @@ public sealed class MainViewModel : ObservableObject, IDisposable return false; } - private void StartOrResumeCore(bool startedByPlc = false) + private async Task StartOrResumeCoreAsync(bool startedByPlc = false) + { + var initialReciprocatingCycleCompleteSignal = await ReadReciprocatingCycleCompleteSignalOrDefaultAsync(); + StartOrResumeCore(startedByPlc, initialReciprocatingCycleCompleteSignal); + } + + private void StartOrResumeCore(bool startedByPlc = false, bool initialReciprocatingCycleCompleteSignal = false) { var isRecoveringActiveRun = _machineRuntimeState == MachineRuntimeState.Interlocked && HasRecoverableActiveRunContext(); @@ -781,6 +794,9 @@ public sealed class MainViewModel : ObservableObject, IDisposable _currentRunSamples.Clear(); ClearRealtimeChartSamples(); ResetRealtimeSamplingMetrics(); + ResetReciprocatingRecordTable(); + _lastReciprocatingCycleCompleteSignal = initialReciprocatingCycleCompleteSignal; + _isWaitingForReciprocatingCycleCompleteSignalReset = initialReciprocatingCycleCompleteSignal; CurrentForceN = 0; CurrentDisplacementMm = 0; CurrentPeakForceN = 0; @@ -806,7 +822,7 @@ public sealed class MainViewModel : ObservableObject, IDisposable SetAxisLimits(emptyChartXMax, EmptyChartYAxisMaxLimit); NotifyRealtimeChartChanged(); _deviceDataReader.Initialize(Recipe); - AddInfoEvent($"批次 {Recipe.BatchNumber} 第 {NextRunIndex} 轮开始。模式={Recipe.TestMode}, 水平速度={Recipe.SpeedMmPerMin:F0} mm/min"); + AddInfoEvent($"批次 {Recipe.BatchNumber} 第 {NextRunIndex} 轮开始。模式={Recipe.TestMode}, 往复次数={Recipe.ReciprocatingCount}, 水平速度={Recipe.SpeedMmPerMin:F0} mm/min"); } else if (isRecoveringActiveRun) { @@ -890,9 +906,10 @@ public sealed class MainViewModel : ObservableObject, IDisposable return; } + await SyncStartParametersToPlcAsync(); if (await ExecutePlcPulseCommandAsync("启动", CoilStart, updateLocalState: false)) { - StartOrResumeCore(startedByPlc: true); + await StartOrResumeCoreAsync(startedByPlc: true); } } @@ -953,14 +970,14 @@ public sealed class MainViewModel : ObservableObject, IDisposable UpdateLiveProcessSnapshot(frame); } - await RefreshReciprocatingRecordsFromPlcIfDueAsync(); - if (_machineRuntimeState != MachineRuntimeState.Running) { RaiseStatusProperties(); return; } + await CaptureReciprocatingRecordIfCycleCompletedAsync(); + if (!AppendRunningSample(frame, out var isCompleted)) { RaiseStatusProperties(); @@ -1030,6 +1047,7 @@ public sealed class MainViewModel : ObservableObject, IDisposable _lastCompletedRun = record; _lastCompletedRecipeSnapshot = _activeRecipeSnapshot ?? TestRecipeSnapshot.FromRecipe(Recipe); _lastCompletedSamples = _currentRunSamples.ToArray(); + _lastCompletedReciprocatingRecords = CloneReciprocatingRecords(ReciprocatingRecords); PersistLastCompletedRun(); ResetActiveRunContext(); SelectedRunRecord = record; @@ -1039,7 +1057,7 @@ public sealed class MainViewModel : ObservableObject, IDisposable _machineRuntimeState = MachineRuntimeState.Idle; MachineState = "待机"; StateBrush = BrushFromHex("#6C8E78"); - InterlockMessage = $"试验完成。μs={record.StaticCoefficient:F3}, μk={record.KineticCoefficient:F3}"; + InterlockMessage = $"试验完成。COFs={record.StaticCoefficient:F3}, COFk={record.KineticCoefficient:F3}"; AddInfoEvent($"第 {record.RunIndex} 轮完成。"); RaiseStatusProperties(); } @@ -1128,8 +1146,21 @@ public sealed class MainViewModel : ObservableObject, IDisposable var activeReplicateCount = GetActiveReplicateCount(); CurrentStaticCoefficient = ResolveRepresentativeValue(activeReplicateCount, StaticCoefficient1, StaticCoefficient2); CurrentKineticCoefficient = ResolveRepresentativeValue(activeReplicateCount, KineticCoefficient1, KineticCoefficient2); - TrialProgressPercent = Math.Min(100, displacementMm / Math.Max(GetActiveTravelMm(), 1) * 100); - isCompleted = displacementMm >= GetActiveTravelMm(); + var configuredReciprocatingCount = GetConfiguredReciprocatingCount(); + TrialProgressPercent = configuredReciprocatingCount > 1 + ? Math.Min(100, ReciprocatingRecords.Count(record => record.HasData) / Math.Max(configuredReciprocatingCount, 1d) * 100) + : Math.Min(100, displacementMm / Math.Max(GetActiveTravelMm(), 1) * 100); + var travelCompleted = displacementMm >= GetActiveTravelMm(); + if (travelCompleted && _runTravelCompletedAtUtc is null) + { + _runTravelCompletedAtUtc = DateTime.UtcNow; + } + else if (!travelCompleted) + { + _runTravelCompletedAtUtc = null; + } + + isCompleted = ShouldCompleteActiveRun(travelCompleted); RefreshRealtimeChartPresentation(force: isFirstChartSample || isCompleted); if (isFirstChartSample) { @@ -1158,6 +1189,25 @@ public sealed class MainViewModel : ObservableObject, IDisposable return IsFinite(displacementMm); } + private bool ShouldCompleteActiveRun(bool travelCompleted) + { + var configuredReciprocatingCount = GetConfiguredReciprocatingCount(); + var capturedReciprocatingCount = ReciprocatingRecords.Count(record => record.HasData); + if (capturedReciprocatingCount >= configuredReciprocatingCount) + { + return true; + } + + if (configuredReciprocatingCount > 1) + { + return false; + } + + return travelCompleted && + _runTravelCompletedAtUtc is { } completedAt && + DateTime.UtcNow - completedAt >= SingleReciprocatingCompletionGracePeriod; + } + private void UpdatePreviewResult(double displacementMm, double forceN) { _runningPeakForceN = Math.Max(_runningPeakForceN, forceN); @@ -1383,6 +1433,7 @@ public sealed class MainViewModel : ObservableObject, IDisposable _kineticForceSum = 0; _kineticSampleCount = 0; _runStartHorizontalPositionMm = double.NaN; + _runTravelCompletedAtUtc = null; _lastRealtimeChartElapsedSeconds = double.NaN; _realtimeChartMaxElapsedSeconds = 0; _currentRealtimeChartElapsedSeconds = 0; @@ -1433,32 +1484,52 @@ public sealed class MainViewModel : ObservableObject, IDisposable OnPropertyChanged(nameof(ExportReportSummary)); } - private async Task RefreshReciprocatingRecordsFromPlcIfDueAsync() + private async Task CaptureReciprocatingRecordIfCycleCompletedAsync() { - if (!_deviceConnectionService.IsConnected) + if (!_deviceConnectionService.IsConnected || + _nextReciprocatingRecordIndex > GetConfiguredReciprocatingCount()) { return; } - var now = DateTime.UtcNow; - if (now - _lastReciprocatingRecordRefreshAt < ReciprocatingRecordRefreshInterval) - { - return; - } - - _lastReciprocatingRecordRefreshAt = now; - try { - var records = await _deviceDataReader.ReadReciprocatingRecordsAsync(GetConfiguredReciprocatingCount()); - UpdateReciprocatingRecordTable(records); + var coils = await _deviceConnectionService.ReadCoilsAsync( + ModbusSlaveAddress, + CoilReciprocatingCycleComplete, + 1); + var isCycleComplete = coils.Length > 0 && coils[0]; + if (!isCycleComplete) + { + _lastReciprocatingCycleCompleteSignal = false; + _isWaitingForReciprocatingCycleCompleteSignalReset = false; + _reciprocatingRecordReadFailureLogged = false; + return; + } + + if (_isWaitingForReciprocatingCycleCompleteSignalReset) + { + return; + } + + if (_lastReciprocatingCycleCompleteSignal) + { + return; + } + + var recordIndex = _nextReciprocatingRecordIndex; + var record = await _deviceDataReader.ReadCurrentReciprocatingRecordAsync(recordIndex); + ApplyReciprocatingRecord(record); + _nextReciprocatingRecordIndex++; + _lastReciprocatingCycleCompleteSignal = true; _reciprocatingRecordReadFailureLogged = false; + AddInfoEvent($"已记录第 {recordIndex} 次往复数据: COFs={record.StaticCoefficientLabel}, COFk={record.KineticCoefficientLabel}, Fs={record.StaticForceLabel}N, Fk={record.KineticForceLabel}N"); } catch (Exception ex) { if (!_reciprocatingRecordReadFailureLogged) { - AddWarningEvent($"往复记录读取失败,已保留占位表: {ex.Message}"); + AddWarningEvent($"往复第 {_nextReciprocatingRecordIndex} 次结果读取失败,等待下一次完成信号: {ex.Message}"); _reciprocatingRecordReadFailureLogged = true; } } @@ -1467,6 +1538,10 @@ public sealed class MainViewModel : ObservableObject, IDisposable private void ResetReciprocatingRecordTable() { ReciprocatingRecords.Clear(); + _nextReciprocatingRecordIndex = 1; + _lastReciprocatingCycleCompleteSignal = false; + _isWaitingForReciprocatingCycleCompleteSignalReset = false; + _reciprocatingRecordReadFailureLogged = false; for (var index = 1; index <= GetConfiguredReciprocatingCount(); index++) { ReciprocatingRecords.Add(ReciprocatingFrictionRecord.Empty(index)); @@ -1476,20 +1551,46 @@ public sealed class MainViewModel : ObservableObject, IDisposable OnPropertyChanged(nameof(ReciprocatingRecordSummary)); } - private void UpdateReciprocatingRecordTable(IReadOnlyList records) + private void LoadReciprocatingRecordsIntoTable( + IReadOnlyList records, + int configuredCount) { var byIndex = records .Where(record => record.Index > 0) .GroupBy(record => record.Index) .ToDictionary(group => group.Key, group => group.First()); + var rowCount = Math.Max(1, Math.Max(configuredCount, byIndex.Keys.DefaultIfEmpty(0).Max())); + ReciprocatingRecords.Clear(); - for (var index = 1; index <= GetConfiguredReciprocatingCount(); index++) + for (var index = 1; index <= rowCount; index++) { ReciprocatingRecords.Add(byIndex.TryGetValue(index, out var record) ? record : ReciprocatingFrictionRecord.Empty(index)); } + _nextReciprocatingRecordIndex = ReciprocatingRecords.Count(item => item.HasData) + 1; + _lastReciprocatingCycleCompleteSignal = false; + _isWaitingForReciprocatingCycleCompleteSignalReset = false; + _reciprocatingRecordReadFailureLogged = false; + RebuildReciprocatingRecordSections(); + OnPropertyChanged(nameof(ReciprocatingRecordSummary)); + } + + private void ApplyReciprocatingRecord(ReciprocatingFrictionRecord record) + { + var configuredCount = GetConfiguredReciprocatingCount(); + if (record.Index < 1 || record.Index > configuredCount) + { + return; + } + + while (ReciprocatingRecords.Count < configuredCount) + { + ReciprocatingRecords.Add(ReciprocatingFrictionRecord.Empty(ReciprocatingRecords.Count + 1)); + } + + ReciprocatingRecords[record.Index - 1] = record; RebuildReciprocatingRecordSections(); OnPropertyChanged(nameof(ReciprocatingRecordSummary)); } @@ -1515,8 +1616,8 @@ public sealed class MainViewModel : ObservableObject, IDisposable Rows = [ BuildReciprocatingRecordGridRow(string.Empty, sectionRecords.Select(record => record.Index.ToString()), isHeader: true), - BuildReciprocatingRecordGridRow("cofs", sectionRecords.Select(record => record.StaticCoefficientLabel)), - BuildReciprocatingRecordGridRow("cofk", sectionRecords.Select(record => record.KineticCoefficientLabel)), + BuildReciprocatingRecordGridRow("COFs", sectionRecords.Select(record => record.StaticCoefficientLabel)), + BuildReciprocatingRecordGridRow("COFk", sectionRecords.Select(record => record.KineticCoefficientLabel)), BuildReciprocatingRecordGridRow("Fs[N]", sectionRecords.Select(record => record.StaticForceLabel)), BuildReciprocatingRecordGridRow("Fk[N]", sectionRecords.Select(record => record.KineticForceLabel)) ] @@ -1524,6 +1625,20 @@ public sealed class MainViewModel : ObservableObject, IDisposable } } + private static IReadOnlyList CloneReciprocatingRecords(IEnumerable records) + { + return records + .Select(record => new ReciprocatingFrictionRecord + { + Index = record.Index, + StaticCoefficient = record.StaticCoefficient, + KineticCoefficient = record.KineticCoefficient, + StaticForceN = record.StaticForceN, + KineticForceN = record.KineticForceN + }) + .ToArray(); + } + private static ReciprocatingRecordGridRow BuildReciprocatingRecordGridRow( string header, IEnumerable values, @@ -1540,7 +1655,10 @@ public sealed class MainViewModel : ObservableObject, IDisposable private int GetConfiguredReciprocatingCount() { - return Math.Max(1, Recipe.ReciprocatingCount); + var count = _isShowingHistoricalRun && _displayedRecipeSnapshot is not null + ? _displayedRecipeSnapshot.ReciprocatingCount + : Recipe.ReciprocatingCount; + return Math.Max(1, count); } private void RaiseStatusProperties() @@ -1570,6 +1688,48 @@ public sealed class MainViewModel : ObservableObject, IDisposable _clearHistoryCommand.RaiseCanExecuteChanged(); } + private async Task ReadReciprocatingCycleCompleteSignalOrDefaultAsync() + { + if (!_deviceConnectionService.IsConnected) + { + return false; + } + + try + { + var coils = await _deviceConnectionService.ReadCoilsAsync( + ModbusSlaveAddress, + CoilReciprocatingCycleComplete, + 1); + return coils.Length > 0 && coils[0]; + } + catch (Exception ex) + { + AddWarningEvent($"启动前读取往复完成信号 M{CoilReciprocatingCycleComplete} 失败,按未完成处理: {ex.Message}"); + return false; + } + } + + private async Task SyncStartParametersToPlcAsync() + { + if (!_deviceConnectionService.IsConnected) + { + return; + } + + try + { + await _deviceConnectionService.WriteSingleRegisterAsync( + ModbusSlaveAddress, + RegisterReciprocatingCount, + (ushort)Math.Clamp(Recipe.ReciprocatingCount, 1, ushort.MaxValue)); + } + catch (Exception ex) + { + AddWarningEvent($"启动前写入往复次数 D{RegisterReciprocatingCount} 失败,将继续按界面设置 {Recipe.ReciprocatingCount} 次记录: {ex.Message}"); + } + } + private bool CanExecutePlcCommand() { return !_isPlcCommandBusy && _activeTableMotionCoil is null; @@ -1739,8 +1899,12 @@ public sealed class MainViewModel : ObservableObject, IDisposable try { - _dataRepository.SaveRun(_lastCompletedRun!, _lastCompletedRecipeSnapshot!, _lastCompletedSamples); - AddInfoEvent($"SQLite 已保存试验 {_lastCompletedRun!.RunIndex} 的元数据和 {_lastCompletedSamples.Count} 条原始采样。"); + _dataRepository.SaveRun( + _lastCompletedRun!, + _lastCompletedRecipeSnapshot!, + _lastCompletedSamples, + _lastCompletedReciprocatingRecords); + AddInfoEvent($"SQLite 已保存试验 {_lastCompletedRun!.RunIndex} 的元数据、{_lastCompletedSamples.Count} 条原始采样和 {_lastCompletedReciprocatingRecords.Count(record => record.HasData)} 条往复记录。"); } catch (Exception ex) { @@ -1795,6 +1959,7 @@ public sealed class MainViewModel : ObservableObject, IDisposable _displayedRecipeSnapshot = data.Recipe; _currentRunSamples.Clear(); _currentRunSamples.AddRange(data.Samples); + LoadReciprocatingRecordsIntoTable(data.ReciprocatingRecords, data.Recipe.ReciprocatingCount); LoadSamplesIntoChart(data.Samples); @@ -1842,7 +2007,8 @@ public sealed class MainViewModel : ObservableObject, IDisposable { Run = _lastCompletedRun, Recipe = _lastCompletedRecipeSnapshot, - Samples = _lastCompletedSamples + Samples = _lastCompletedSamples, + ReciprocatingRecords = _lastCompletedReciprocatingRecords }; } @@ -2083,6 +2249,7 @@ public sealed class MainViewModel : ObservableObject, IDisposable var speedMmPerMin = await ReadFloatHoldingRegisterAsync(master, RegisterHorizontalSpeedSetpoint); var travelMm = await ReadFloatHoldingRegisterAsync(master, RegisterHorizontalTravelSetpoint); var replicateCount = Math.Clamp((int)Math.Round(await ReadFloatHoldingRegisterAsync(master, RegisterReplicateCount)), 0, 1); + var plcReciprocatingCount = await ReadUInt16HoldingRegisterAsync(master, RegisterReciprocatingCount); Recipe.SledMassGrams = sledMassGrams; Recipe.LiftSpeedMmPerMin = liftSpeedMmPerMin; @@ -2090,12 +2257,26 @@ public sealed class MainViewModel : ObservableObject, IDisposable Recipe.SpeedMmPerMin = speedMmPerMin; Recipe.TravelMm = travelMm; Recipe.ReplicateCount = replicateCount; + if (plcReciprocatingCount > 0) + { + Recipe.ReciprocatingCount = plcReciprocatingCount; + } + else + { + AddWarningEvent($"PLC 往复次数 D{RegisterReciprocatingCount}=0,已保留界面设置 {Recipe.ReciprocatingCount} 次。"); + } } finally { _isSyncingRecipeFromPlc = false; } + if (_machineRuntimeState == MachineRuntimeState.Idle && + ReciprocatingRecords.Count != GetConfiguredReciprocatingCount()) + { + ResetReciprocatingRecordTable(); + } + RefreshEmptyRealtimeChartFrameIfVisible(); RaiseStatusProperties(); } @@ -2127,9 +2308,30 @@ public sealed class MainViewModel : ObservableObject, IDisposable case nameof(TestRecipe.ReplicateCount): await _deviceConnectionService.WriteFloatRegisterAsync(ModbusSlaveAddress, RegisterReplicateCount, Math.Clamp(Recipe.ReplicateCount, 0, 1)); break; + case nameof(TestRecipe.ReciprocatingCount): + await _deviceConnectionService.WriteSingleRegisterAsync( + ModbusSlaveAddress, + RegisterReciprocatingCount, + (ushort)Math.Clamp(Recipe.ReciprocatingCount, 1, ushort.MaxValue)); + break; } } + private static async Task ReadUInt16HoldingRegisterAsync(NModbusAsync.IModbusMaster master, ushort address) + { + var registers = await master.ReadHoldingRegistersAsync( + ModbusSlaveAddress, + address, + 1, + default); + if (registers.Length == 0) + { + throw new InvalidOperationException($"PLC 寄存器 D{address} 数据返回不完整。"); + } + + return registers[0]; + } + private static async Task ReadFloatHoldingRegisterAsync(NModbusAsync.IModbusMaster master, ushort address) { var registers = await master.ReadHoldingRegistersAsync( @@ -2304,7 +2506,8 @@ public sealed class MainViewModel : ObservableObject, IDisposable { Run = activeRun, Recipe = activeRecipeSnapshot, - Samples = activeSamples + Samples = activeSamples, + ReciprocatingRecords = CloneReciprocatingRecords(ReciprocatingRecords) }; } @@ -2312,7 +2515,7 @@ public sealed class MainViewModel : ObservableObject, IDisposable { var historyCount = RunHistory.Count; var confirmation = MessageBox.Show( - $"将永久清空 {historyCount} 轮历史试验、原始采样和当前历史回放数据。\n\n会清空数据库与界面中的历史记录、曲线和结果显示,但保留当前测试参数。\n\n是否继续?", + $"将永久清空 {historyCount} 轮历史试验、原始采样、往复记录和当前历史回放数据。\n\n会清空数据库与界面中的历史记录、曲线和结果显示,但保留当前测试参数。\n\n是否继续?", "确认清空历史", MessageBoxButton.YesNo, MessageBoxImage.Warning, @@ -2333,6 +2536,7 @@ public sealed class MainViewModel : ObservableObject, IDisposable _lastCompletedRun = null; _lastCompletedRecipeSnapshot = null; _lastCompletedSamples = Array.Empty(); + _lastCompletedReciprocatingRecords = Array.Empty(); _displayedRecipeSnapshot = TestRecipeSnapshot.FromRecipe(Recipe); _isShowingHistoricalRun = false; ResetActiveRunContext();