更新20260514

This commit is contained in:
GukSang.Jin
2026-05-14 16:52:14 +08:00
parent 2b8d918499
commit 23cefe1378
8 changed files with 704 additions and 164 deletions

View File

@@ -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)

View File

@@ -591,8 +591,8 @@
<DataGridTextColumn Header="轮次" Binding="{Binding RunIndex}" Width="0.7*" />
<DataGridTextColumn Header="时间" Binding="{Binding CompletedAtLabel}" Width="1.2*" />
<DataGridTextColumn Header="批次" Binding="{Binding BatchNumber}" Width="1.2*" />
<DataGridTextColumn Header="μs" Binding="{Binding StaticCoefficient, StringFormat={}{0:F3}}" Width="0.8*" />
<DataGridTextColumn Header="μk" Binding="{Binding KineticCoefficient, StringFormat={}{0:F3}}" Width="0.8*" />
<DataGridTextColumn Header="COFs" Binding="{Binding StaticCoefficient, StringFormat={}{0:F3}}" Width="0.8*" />
<DataGridTextColumn Header="COFk" Binding="{Binding KineticCoefficient, StringFormat={}{0:F3}}" Width="0.8*" />
<DataGridTextColumn Header="模式" Binding="{Binding TestMode}" Width="1.1*" />
</DataGrid.Columns>
</DataGrid>

View File

@@ -7,4 +7,6 @@ public sealed class PersistedRunData
public required TestRecipeSnapshot Recipe { get; init; }
public required IReadOnlyList<RawSampleRecord> Samples { get; init; }
public IReadOnlyList<ReciprocatingFrictionRecord> ReciprocatingRecords { get; init; } = Array.Empty<ReciprocatingFrictionRecord>();
}

View File

@@ -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<IReadOnlyList<ReciprocatingFrictionRecord>> ReadReciprocatingRecordsAsync(
int requestedCount,
public async Task<ReciprocatingFrictionRecord> 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<ReciprocatingFrictionRecord>();
}
var maxAddressableRecordCount = Math.Max(0, (ushort.MaxValue - ReciprocatingRecordStartAddress + 1) / ReciprocatingRecordRegisterCount);
var recordsToRead = Math.Min(recordCount, maxAddressableRecordCount);
var records = new List<ReciprocatingFrictionRecord>(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<ushort[]> 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();

View File

@@ -27,6 +27,40 @@ public sealed class ModbusTcpConnectionService : IDisposable
public IModbusMaster? Master => _master;
public async Task<bool[]> 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<bool[]> 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<bool[]>.");
}
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(

View File

@@ -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<ExportCurveData> 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<ExportCurveData> 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}";

View File

@@ -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<RawSampleRecord> samples)
public void SaveRun(
RunRecord run,
TestRecipeSnapshot recipe,
IReadOnlyList<RawSampleRecord> samples,
IReadOnlyList<ReciprocatingFrictionRecord> 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<ReciprocatingFrictionRecord> 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<ReciprocatingFrictionRecord>();
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();

View File

@@ -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<RawSampleRecord> _lastCompletedSamples = Array.Empty<RawSampleRecord>();
private IReadOnlyList<ReciprocatingFrictionRecord> _lastCompletedReciprocatingRecords = Array.Empty<ReciprocatingFrictionRecord>();
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<ReciprocatingFrictionRecord> records)
private void LoadReciprocatingRecordsIntoTable(
IReadOnlyList<ReciprocatingFrictionRecord> 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<ReciprocatingFrictionRecord> CloneReciprocatingRecords(IEnumerable<ReciprocatingFrictionRecord> 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<string> 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<bool> 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<ushort> 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<double> 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<RawSampleRecord>();
_lastCompletedReciprocatingRecords = Array.Empty<ReciprocatingFrictionRecord>();
_displayedRecipeSnapshot = TestRecipeSnapshot.FromRecipe(Recipe);
_isShowingHistoricalRun = false;
ResetActiveRunContext();