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();