更新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), typeof(RealtimeForceChart),
new FrameworkPropertyMetadata(0d, FrameworkPropertyMetadataOptions.AffectsRender)); new FrameworkPropertyMetadata(0d, FrameworkPropertyMetadataOptions.AffectsRender));
private const double NewtonToGramForce = 101.9716213;
private const double GravityMetersPerSecondSquared = 9.80665; private const double GravityMetersPerSecondSquared = 9.80665;
private readonly Pen _axisPen = new(new SolidColorBrush(Color.FromRgb(54, 54, 54)), 1); private readonly Pen _axisPen = new(new SolidColorBrush(Color.FromRgb(54, 54, 54)), 1);
@@ -220,7 +219,7 @@ public sealed class RealtimeForceChart : FrameworkElement
DrawRightAlignedText( DrawRightAlignedText(
drawingContext, drawingContext,
FormatForceGramLabel(yMax * index / yDivisions), FormatForceNewtonLabel(yMax * index / yDivisions),
plot.Left - 10, plot.Left - 10,
y - 8, y - 8,
11, 11,
@@ -237,7 +236,7 @@ public sealed class RealtimeForceChart : FrameworkElement
var xAxisTitleY = Math.Min(plot.Bottom + 30, ActualHeight - 18); var xAxisTitleY = Math.Min(plot.Bottom + 30, ActualHeight - 18);
DrawText(drawingContext, "Time [sec]", plot.Right - 58, xAxisTitleY, 12, _titleBrush); 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); DrawText(drawingContext, "Coefficient of friction", plot.Right - 8, plot.Top - 25, 11, _titleBrush);
} }
@@ -373,12 +372,11 @@ public sealed class RealtimeForceChart : FrameworkElement
: 0; : 0;
} }
private static string FormatForceGramLabel(double forceN) private static string FormatForceNewtonLabel(double forceN)
{ {
var forceGf = forceN * NewtonToGramForce; return forceN >= 10
return forceGf >= 100 ? forceN.ToString("0.#", CultureInfo.InvariantCulture)
? forceGf.ToString("0", CultureInfo.InvariantCulture) : forceN.ToString("0.###", CultureInfo.InvariantCulture);
: forceGf.ToString("0.#", CultureInfo.InvariantCulture);
} }
private static string FormatCoefficientLabel(double forceN, double normalForceN) 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 RunIndex}" Width="0.7*" />
<DataGridTextColumn Header="时间" Binding="{Binding CompletedAtLabel}" Width="1.2*" /> <DataGridTextColumn Header="时间" Binding="{Binding CompletedAtLabel}" Width="1.2*" />
<DataGridTextColumn Header="批次" Binding="{Binding BatchNumber}" 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="COFs" Binding="{Binding StaticCoefficient, StringFormat={}{0:F3}}" Width="0.8*" />
<DataGridTextColumn Header="μk" Binding="{Binding KineticCoefficient, 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*" /> <DataGridTextColumn Header="模式" Binding="{Binding TestMode}" Width="1.1*" />
</DataGrid.Columns> </DataGrid.Columns>
</DataGrid> </DataGrid>

View File

@@ -7,4 +7,6 @@ public sealed class PersistedRunData
public required TestRecipeSnapshot Recipe { get; init; } public required TestRecipeSnapshot Recipe { get; init; }
public required IReadOnlyList<RawSampleRecord> Samples { 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 public sealed class ModbusProcessDataReader
{ {
private const ushort ReciprocatingRecordCountAddress = 500; private const ushort CurrentReciprocatingStaticForceAddress = 1000;
private const ushort ReciprocatingRecordStartAddress = 502; private const ushort CurrentReciprocatingStaticCoefficientAddress = 1002;
private const int ReciprocatingRecordValueCount = 4; private const ushort CurrentReciprocatingKineticForceAddress = 1010;
private const int ReciprocatingRecordRegisterCount = ReciprocatingRecordValueCount * PlcRegisterEncoding.FloatRegisterCount; private const ushort CurrentReciprocatingKineticCoefficientAddress = 1012;
private const ushort MaxReciprocatingRegistersPerRead = 120;
private static readonly PlcResultRegisterMap DefaultResultRegisterMap = new( private static readonly PlcResultRegisterMap DefaultResultRegisterMap = new(
SlaveAddress: 1, SlaveAddress: 1,
@@ -138,60 +137,37 @@ public sealed class ModbusProcessDataReader
horizontalPosition); horizontalPosition);
} }
public async Task<IReadOnlyList<ReciprocatingFrictionRecord>> ReadReciprocatingRecordsAsync( public async Task<ReciprocatingFrictionRecord> ReadCurrentReciprocatingRecordAsync(
int requestedCount, int recordIndex,
CancellationToken cancellationToken = default) CancellationToken cancellationToken = default)
{ {
var master = _connectionService.Master ?? throw new InvalidOperationException("Modbus master is not connected."); var master = _connectionService.Master ?? throw new InvalidOperationException("Modbus master is not connected.");
var configuredCount = Math.Max(1, requestedCount); var startAddress = Min(
var countRegisters = await master.ReadHoldingRegistersAsync( CurrentReciprocatingStaticForceAddress,
_resultRegisterMap.SlaveAddress, CurrentReciprocatingStaticCoefficientAddress,
ReciprocatingRecordCountAddress, CurrentReciprocatingKineticForceAddress,
1, CurrentReciprocatingKineticCoefficientAddress);
cancellationToken); var registerCount = GetRegisterCount(
var plcCount = countRegisters.Length == 0 ? configuredCount : countRegisters[0]; startAddress,
var recordCount = plcCount <= 0 ? configuredCount : Math.Min(plcCount, configuredCount); CurrentReciprocatingStaticForceAddress,
if (recordCount == 0) CurrentReciprocatingStaticCoefficientAddress,
{ CurrentReciprocatingKineticForceAddress,
return Array.Empty<ReciprocatingFrictionRecord>(); CurrentReciprocatingKineticCoefficientAddress);
}
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( var registers = await ReadHoldingRegisterBlockAsync(
master, master,
_resultRegisterMap.SlaveAddress, _resultRegisterMap.SlaveAddress,
chunkStartAddress, startAddress,
chunkRegisterCount, registerCount,
cancellationToken); cancellationToken);
for (var index = 0; index < chunkRecordCount; index++) return new ReciprocatingFrictionRecord
{ {
var baseOffset = index * ReciprocatingRecordRegisterCount; Index = Math.Max(1, recordIndex),
var recordNumber = nextRecordOffset + index + 1; StaticCoefficient = ReadFloatAt(registers, startAddress, CurrentReciprocatingStaticCoefficientAddress),
records.Add(new ReciprocatingFrictionRecord KineticCoefficient = ReadFloatAt(registers, startAddress, CurrentReciprocatingKineticCoefficientAddress),
{ StaticForceN = ReadFloatAt(registers, startAddress, CurrentReciprocatingStaticForceAddress),
Index = recordNumber, KineticForceN = ReadFloatAt(registers, startAddress, CurrentReciprocatingKineticForceAddress)
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;
} }
private static async Task<ushort[]> ReadHoldingRegisterBlockAsync( private static async Task<ushort[]> ReadHoldingRegisterBlockAsync(
@@ -216,12 +192,6 @@ public sealed class ModbusProcessDataReader
return PlcRegisterEncoding.ReadFloat(registers, offset, $"D{address}"); 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) private static ushort Min(params ushort[] values)
{ {
return values.Min(); return values.Min();

View File

@@ -27,6 +27,40 @@ public sealed class ModbusTcpConnectionService : IDisposable
public IModbusMaster? Master => _master; 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( public async Task WriteSingleCoilAsync(
byte slaveAddress, byte slaveAddress,
ushort coilAddress, ushort coilAddress,
@@ -210,6 +244,17 @@ public sealed class ModbusTcpConnectionService : IDisposable
?? throw new MissingMethodException(masterType.FullName, "WriteSingleCoilAsync"); ?? 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) private static MethodInfo ResolveWriteSingleRegisterMethod(Type masterType)
{ {
return masterType.GetMethod( return masterType.GetMethod(

View File

@@ -62,6 +62,7 @@ public sealed class RunExportService
{ {
BuildSummarySheet(workbook.Worksheets.Add("报表汇总"), exportRuns, exportStatistics); BuildSummarySheet(workbook.Worksheets.Add("报表汇总"), exportRuns, exportStatistics);
BuildRawDataSheet(workbook.Worksheets.Add("原始数据"), exportRuns); BuildRawDataSheet(workbook.Worksheets.Add("原始数据"), exportRuns);
BuildReciprocatingDataSheet(workbook.Worksheets.Add("每次数据"), exportRuns);
BuildChartSheet(workbook.Worksheets.Add("曲线图"), exportRuns); BuildChartSheet(workbook.Worksheets.Add("曲线图"), exportRuns);
workbook.SaveAs(outputPath); workbook.SaveAs(outputPath);
} }
@@ -192,14 +193,15 @@ public sealed class RunExportService
"产品编号", "产品编号",
"测试模式", "测试模式",
"方向", "方向",
"静摩擦系数 μs", "静摩擦系数 COFs",
"动摩擦系数 μk", "动摩擦系数 COFk",
"标准差", "标准差",
"标准差1", "标准差1",
"标准差2", "标准差2",
"峰值力(N)", "峰值力(N)",
"稳定段均力(N)", "稳定段均力(N)",
"有效采样点数", "有效采样点数",
"往复记录数",
"原始力最小值(N)", "原始力最小值(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, 16).Value = item.Data.Run.PeakForceN;
worksheet.Cell(row, 17).Value = item.Data.Run.AverageForceN; worksheet.Cell(row, 17).Value = item.Data.Run.AverageForceN;
worksheet.Cell(row, 18).Value = item.ValidSamples.Count; worksheet.Cell(row, 18).Value = item.ValidSamples.Count;
worksheet.Cell(row, 19).Value = forceStats.Min; worksheet.Cell(row, 19).Value = CountReciprocatingRecords(item);
worksheet.Cell(row, 20).Value = forceStats.Max; worksheet.Cell(row, 20).Value = forceStats.Min;
worksheet.Cell(row, 21).Value = forceStats.Average; worksheet.Cell(row, 21).Value = forceStats.Max;
worksheet.Cell(row, 22).Value = forceStats.StandardDeviation; 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.Fill.BackgroundColor = ToXlColor(item.CurveColorHex);
worksheet.Cell(row, 4).Style.Font.FontColor = XLColor.White; worksheet.Cell(row, 4).Style.Font.FontColor = XLColor.White;
@@ -250,7 +253,7 @@ public sealed class RunExportService
ApplySummaryColumnWidths(worksheet); ApplySummaryColumnWidths(worksheet);
ApplySummaryFormatting(worksheet, dataStartRow, dataLastRow); ApplySummaryFormatting(worksheet, dataStartRow, dataLastRow);
ApplyPageLayout(worksheet, dataLastRow, "$A$1:$V$"); ApplyPageLayout(worksheet, dataLastRow, "$A$1:$W$");
worksheet.SheetView.FreezeRows(1); worksheet.SheetView.FreezeRows(1);
} }
@@ -281,8 +284,8 @@ public sealed class RunExportService
"产品编号", "产品编号",
"测试模式", "测试模式",
"方向", "方向",
"静摩擦系数 μs", "静摩擦系数 COFs",
"动摩擦系数 μk", "动摩擦系数 COFk",
"标准差", "标准差",
"数据状态", "数据状态",
"采样序号", "采样序号",
@@ -345,6 +348,84 @@ public sealed class RunExportService
worksheet.SheetView.FreezeRows(1); 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) private static WorksheetChartLayout BuildChartSheet(IXLWorksheet worksheet, IReadOnlyList<ExportCurveData> runs)
{ {
ApplyDefaultWorksheetStyle(worksheet); ApplyDefaultWorksheetStyle(worksheet);
@@ -356,27 +437,29 @@ public sealed class RunExportService
worksheet.Cell("A1").Style.Font.FontColor = XLColor.FromHtml("#21313D"); worksheet.Cell("A1").Style.Font.FontColor = XLColor.FromHtml("#21313D");
worksheet.Cell("A3").Value = "图表说明"; worksheet.Cell("A3").Value = "图表说明";
worksheet.Cell("B3").Value = "每条含至少 2 个有效采样点的历史试验记录对应一个独立曲线图,横轴按真实位移数值比例绘制。"; worksheet.Cell("B3").Value = "总览曲线叠加本次导出的全部有效次数曲线;每次曲线仅显示对应单次试验,横轴按真实位移数值比例绘制。";
worksheet.Range("B3:H3").Merge(); worksheet.Range("B3:H3").Merge();
worksheet.Cell("A5").Value = "曲线清单"; worksheet.Cell("A5").Value = "曲线清单";
worksheet.Cell("A5").Style.Font.Bold = true; worksheet.Cell("A5").Style.Font.Bold = true;
worksheet.Cell("A5").Style.Font.FontSize = 12; worksheet.Cell("A5").Style.Font.FontSize = 12;
WriteHeaderRow(worksheet, 6, ["曲线标识", "图例标签", "颜色", "有效采样点数", "曲线状态"]); WriteHeaderRow(worksheet, 6, ["曲线标识", "试验轮次", "图例标签", "颜色", "有效采样点数", "往复记录数", "曲线状态"]);
for (var index = 0; index < runs.Count; index++) for (var index = 0; index < runs.Count; index++)
{ {
var row = 7 + index; var row = 7 + index;
worksheet.Cell(row, 1).Value = BuildCurveKey(runs[index]); worksheet.Cell(row, 1).Value = BuildCurveKey(runs[index]);
worksheet.Cell(row, 2).Value = BuildLegendLabel(runs[index]); worksheet.Cell(row, 2).Value = runs[index].Data.Run.RunIndex;
worksheet.Cell(row, 3).Value = runs[index].CurveColorHex; worksheet.Cell(row, 3).Value = BuildLegendLabel(runs[index]);
worksheet.Cell(row, 4).Value = runs[index].ValidSamples.Count; worksheet.Cell(row, 4).Value = runs[index].CurveColorHex;
worksheet.Cell(row, 5).Value = IsChartableRun(runs[index]) 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, 3).Style.Fill.BackgroundColor = ToXlColor(runs[index].CurveColorHex); ? "已记录总览曲线、每次曲线和原始数据"
worksheet.Cell(row, 3).Style.Font.FontColor = XLColor.White; : "采样点不足,保留原始数据和往复数据";
worksheet.Cell(row, 4).Style.Fill.BackgroundColor = ToXlColor(runs[index].CurveColorHex);
worksheet.Cell(row, 4).Style.Font.FontColor = XLColor.White;
} }
ApplyChartColumnWidths(worksheet); ApplyChartColumnWidths(worksheet);
@@ -406,14 +489,14 @@ public sealed class RunExportService
var rows = new (string Label, string Value)[] var rows = new (string Label, string Value)[]
{ {
("平均静摩擦系数 μs", averageStatic.ToString("F4", CultureInfo.InvariantCulture)), ("平均静摩擦系数 COFs", averageStatic.ToString("F4", CultureInfo.InvariantCulture)),
("平均动摩擦系数 μk", averageKinetic.ToString("F4", CultureInfo.InvariantCulture)), ("平均动摩擦系数 COFk", averageKinetic.ToString("F4", CultureInfo.InvariantCulture)),
("静摩擦最大值 μs", maxStatic.ToString("F4", CultureInfo.InvariantCulture)), ("静摩擦最大值 COFs", maxStatic.ToString("F4", CultureInfo.InvariantCulture)),
("静摩擦最小值 μs", minStatic.ToString("F4", CultureInfo.InvariantCulture)), ("静摩擦最小值 COFs", minStatic.ToString("F4", CultureInfo.InvariantCulture)),
("静摩擦标准差 μs", standardDeviationStatic.ToString("F4", CultureInfo.InvariantCulture)), ("静摩擦标准差 COFs", standardDeviationStatic.ToString("F4", CultureInfo.InvariantCulture)),
("动摩擦最大值 μk", maxKinetic.ToString("F4", CultureInfo.InvariantCulture)), ("动摩擦最大值 COFk", maxKinetic.ToString("F4", CultureInfo.InvariantCulture)),
("动摩擦最小值 μk", minKinetic.ToString("F4", CultureInfo.InvariantCulture)), ("动摩擦最小值 COFk", minKinetic.ToString("F4", CultureInfo.InvariantCulture)),
("动摩擦标准差 μk", standardDeviationKinetic.ToString("F4", CultureInfo.InvariantCulture)), ("动摩擦标准差 COFk", standardDeviationKinetic.ToString("F4", CultureInfo.InvariantCulture)),
("最大峰值力(N)", maxPeakForce.ToString("F4", CultureInfo.InvariantCulture)), ("最大峰值力(N)", maxPeakForce.ToString("F4", CultureInfo.InvariantCulture)),
("最小峰值力(N)", minPeakForce.ToString("F4", CultureInfo.InvariantCulture)) ("最小峰值力(N)", minPeakForce.ToString("F4", CultureInfo.InvariantCulture))
}; };
@@ -493,13 +576,13 @@ public sealed class RunExportService
if (perRunSeries.Count > 0) if (perRunSeries.Count > 0)
{ {
charts.Add(new ChartDefinition( charts.Add(new ChartDefinition(
"历史实时摩擦曲线总览", "总览曲线(全部次数)",
"位移 (mm)", "位移 (mm)",
"力值 (N)", "力值 (N)",
true, true,
perRunSeries.Select(item => item.Series).ToArray(), perRunSeries.Select(item => item.Series).ToArray(),
new ChartAnchor(0, overviewStartRow, chartWidth, overviewStartRow + overviewChartHeight), new ChartAnchor(0, overviewStartRow, chartWidth, overviewStartRow + overviewChartHeight),
"历史实时摩擦曲线总览")); "总览曲线_全部次数"));
perRunStartRow = overviewStartRow + overviewChartHeight + rowGap; perRunStartRow = overviewStartRow + overviewChartHeight + rowGap;
} }
@@ -513,13 +596,13 @@ public sealed class RunExportService
var runSeries = perRunSeries[index]; var runSeries = perRunSeries[index];
charts.Add(new ChartDefinition( charts.Add(new ChartDefinition(
$"{BuildCurveLabel(runSeries.Data)} 力值曲线", BuildSingleRunCurveTitle(runSeries.Data),
"位移 (mm)", "位移 (mm)",
"力值 (N)", "力值 (N)",
false, false,
[runSeries.Series], [runSeries.Series],
new ChartAnchor(fromColumn, fromRow, toColumn, toRow), new ChartAnchor(fromColumn, fromRow, toColumn, toRow),
$"单条曲线_{runSeries.Data.Sequence:D2}")); $"每次曲线_{runSeries.Data.Sequence:D2}"));
} }
return new WorksheetChartLayout(worksheet.Name, charts); return new WorksheetChartLayout(worksheet.Name, charts);
@@ -537,8 +620,8 @@ public sealed class RunExportService
InsertCurveChartImage( InsertCurveChartImage(
worksheet, worksheet,
row, row,
"历史实时摩擦曲线总览(图片记录", "总览曲线(全部次数",
"历史实时摩擦曲线总览", "总览曲线(全部次数)",
"CurveOverview", "CurveOverview",
chartableRuns, chartableRuns,
showLegend: true); showLegend: true);
@@ -549,8 +632,8 @@ public sealed class RunExportService
InsertCurveChartImage( InsertCurveChartImage(
worksheet, worksheet,
row, row,
$"{BuildCurveLabel(run)} 实时摩擦曲线(图片记录)", BuildSingleRunCurveTitle(run),
$"{BuildCurveLabel(run)} 力值曲线", BuildSingleRunCurveTitle(run),
$"Curve{run.Sequence:D2}", $"Curve{run.Sequence:D2}",
[run], [run],
showLegend: false); showLegend: false);
@@ -901,6 +984,7 @@ public sealed class RunExportService
worksheet.Column(20).Width = 14; worksheet.Column(20).Width = 14;
worksheet.Column(21).Width = 14; worksheet.Column(21).Width = 14;
worksheet.Column(22).Width = 16; worksheet.Column(22).Width = 16;
worksheet.Column(23).Width = 16;
} }
private static void ApplyRawDataColumnWidths(IXLWorksheet worksheet) private static void ApplyRawDataColumnWidths(IXLWorksheet worksheet)
@@ -926,6 +1010,27 @@ public sealed class RunExportService
worksheet.Column(19).Width = 14; 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) private static void ApplyChartColumnWidths(IXLWorksheet worksheet)
{ {
for (var column = 1; column <= 20; column++) for (var column = 1; column <= 20; column++)
@@ -936,7 +1041,9 @@ public sealed class RunExportService
2 => 28, 2 => 28,
3 => 12, 3 => 12,
4 => 12, 4 => 12,
5 => 22, 5 => 14,
6 => 14,
7 => 32,
_ => 14 _ => 14
}; };
} }
@@ -952,8 +1059,8 @@ public sealed class RunExportService
worksheet.Range(dataStartRow, 5, dataLastRow, 5).Style.Alignment.Horizontal = XLAlignmentHorizontalValues.Center; 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, 10, dataLastRow, 10).Style.Alignment.Horizontal = XLAlignmentHorizontalValues.Center;
worksheet.Range(dataStartRow, 11, dataLastRow, 17).Style.NumberFormat.Format = "0.0000"; worksheet.Range(dataStartRow, 11, dataLastRow, 17).Style.NumberFormat.Format = "0.0000";
worksheet.Range(dataStartRow, 18, dataLastRow, 18).Style.NumberFormat.Format = "0"; worksheet.Range(dataStartRow, 18, dataLastRow, 19).Style.NumberFormat.Format = "0";
worksheet.Range(dataStartRow, 19, dataLastRow, 22).Style.NumberFormat.Format = "0.0000"; worksheet.Range(dataStartRow, 20, dataLastRow, 23).Style.NumberFormat.Format = "0.0000";
} }
private static void ApplyRawDataFormatting(IXLWorksheet worksheet, int dataStartRow, int dataLastRow) 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"; 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) private static void ApplyPageLayout(IXLWorksheet worksheet, int lastDataRow, string printAreaPrefix)
{ {
worksheet.PageSetup.PageOrientation = XLPageOrientation.Landscape; worksheet.PageSetup.PageOrientation = XLPageOrientation.Landscape;
@@ -1224,6 +1343,11 @@ public sealed class RunExportService
return $"{BuildCurveSequenceCode(item.Sequence)} | 批次 {item.Data.Recipe.BatchNumber}"; 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) private static string BuildCurveSequenceCode(int sequence)
{ {
return $"曲线{sequence:D2}"; return $"曲线{sequence:D2}";
@@ -1285,6 +1409,37 @@ public sealed class RunExportService
return Math.Sqrt(variance); 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) private static string BuildCellReference(string sheetName, int rowNumber, int columnNumber)
{ {
return $"{QuoteSheetName(sheetName)}!${GetColumnName(columnNumber)}${rowNumber}"; return $"{QuoteSheetName(sheetName)}!${GetColumnName(columnNumber)}${rowNumber}";

View File

@@ -24,6 +24,7 @@ public sealed class TestDataRepository
"speed_mm_per_min", "speed_mm_per_min",
"travel_mm", "travel_mm",
"replicate_count", "replicate_count",
"reciprocating_count",
"specimen_description", "specimen_description",
"static_coefficient", "static_coefficient",
"static_coefficient_1", "static_coefficient_1",
@@ -53,6 +54,17 @@ public sealed class TestDataRepository
"speed_mm_per_min" "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; private readonly string _connectionString;
public TestDataRepository(string databasePath) public TestDataRepository(string databasePath)
@@ -79,11 +91,17 @@ public sealed class TestDataRepository
EnsureTestRunsTable(connection, transaction); EnsureTestRunsTable(connection, transaction);
EnsureRawSamplesTable(connection, transaction); EnsureRawSamplesTable(connection, transaction);
EnsureRawSamplesIndex(connection, transaction); EnsureRawSamplesIndex(connection, transaction);
EnsureReciprocatingRecordsTable(connection, transaction);
EnsureReciprocatingRecordsIndex(connection, transaction);
transaction.Commit(); transaction.Commit();
SetForeignKeys(connection, enabled: true); 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); using var connection = new SqliteConnection(_connectionString);
connection.Open(); connection.Open();
@@ -98,7 +116,7 @@ public sealed class TestDataRepository
run_id, run_index, completed_at, batch_number, product_code, test_mode, run_id, run_index, completed_at, batch_number, product_code, test_mode,
counterface_material, direction, sled_mass_grams, lift_speed_mm_per_min, counterface_material, direction, sled_mass_grams, lift_speed_mm_per_min,
lift_displacement_mm, speed_mm_per_min, travel_mm, replicate_count, 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, static_coefficient_2, kinetic_coefficient, kinetic_coefficient_1,
kinetic_coefficient_2, standard_deviation, standard_deviation_1, standard_deviation_2, kinetic_coefficient_2, standard_deviation, standard_deviation_1, standard_deviation_2,
peak_force_n, average_force_n, judgement, csv_export_path, peak_force_n, average_force_n, judgement, csv_export_path,
@@ -107,7 +125,7 @@ public sealed class TestDataRepository
$runId, $runIndex, $completedAt, $batchNumber, $productCode, $testMode, $runId, $runIndex, $completedAt, $batchNumber, $productCode, $testMode,
$counterfaceMaterial, $direction, $sledMassGrams, $liftSpeedMmPerMin, $counterfaceMaterial, $direction, $sledMassGrams, $liftSpeedMmPerMin,
$liftDisplacementMm, $speedMmPerMin, $travelMm, $replicateCount, $liftDisplacementMm, $speedMmPerMin, $travelMm, $replicateCount,
$specimenDescription, $staticCoefficient, $staticCoefficient1, $reciprocatingCount, $specimenDescription, $staticCoefficient, $staticCoefficient1,
$staticCoefficient2, $kineticCoefficient, $kineticCoefficient1, $staticCoefficient2, $kineticCoefficient, $kineticCoefficient1,
$kineticCoefficient2, $standardDeviation, $standardDeviation1, $standardDeviation2, $kineticCoefficient2, $standardDeviation, $standardDeviation1, $standardDeviation2,
$peakForceN, $averageForceN, $judgement, $csvExportPath, $peakForceN, $averageForceN, $judgement, $csvExportPath,
@@ -127,6 +145,14 @@ public sealed class TestDataRepository
deleteCommand.ExecuteNonQuery(); 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) foreach (var sample in samples)
{ {
using var sampleCommand = connection.CreateCommand(); using var sampleCommand = connection.CreateCommand();
@@ -149,6 +175,28 @@ public sealed class TestDataRepository
sampleCommand.ExecuteNonQuery(); 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(); transaction.Commit();
} }
@@ -184,6 +232,13 @@ public sealed class TestDataRepository
deleteSamplesCommand.ExecuteNonQuery(); deleteSamplesCommand.ExecuteNonQuery();
} }
using (var deleteReciprocatingCommand = connection.CreateCommand())
{
deleteReciprocatingCommand.Transaction = transaction;
deleteReciprocatingCommand.CommandText = "DELETE FROM reciprocating_records;";
deleteReciprocatingCommand.ExecuteNonQuery();
}
using (var deleteRunsCommand = connection.CreateCommand()) using (var deleteRunsCommand = connection.CreateCommand())
{ {
deleteRunsCommand.Transaction = transaction; deleteRunsCommand.Transaction = transaction;
@@ -234,7 +289,7 @@ public sealed class TestDataRepository
SELECT run_id, run_index, completed_at, batch_number, product_code, test_mode, SELECT run_id, run_index, completed_at, batch_number, product_code, test_mode,
counterface_material, direction, sled_mass_grams, lift_speed_mm_per_min, counterface_material, direction, sled_mass_grams, lift_speed_mm_per_min,
lift_displacement_mm, speed_mm_per_min, travel_mm, replicate_count, 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, static_coefficient_2, kinetic_coefficient, kinetic_coefficient_1,
kinetic_coefficient_2, standard_deviation, standard_deviation_1, standard_deviation_2, kinetic_coefficient_2, standard_deviation, standard_deviation_1, standard_deviation_2,
peak_force_n, average_force_n, judgement, csv_export_path, 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), CompletedAt = DateTime.Parse(reader.GetString(2), CultureInfo.InvariantCulture, DateTimeStyles.RoundtripKind),
BatchNumber = reader.GetString(3), BatchNumber = reader.GetString(3),
TestMode = reader.GetString(5), TestMode = reader.GetString(5),
StaticCoefficient = reader.GetDouble(15), StaticCoefficient = reader.GetDouble(16),
StaticCoefficient1 = reader.GetDouble(16), StaticCoefficient1 = reader.GetDouble(17),
StaticCoefficient2 = reader.GetDouble(17), StaticCoefficient2 = reader.GetDouble(18),
KineticCoefficient = reader.GetDouble(18), KineticCoefficient = reader.GetDouble(19),
KineticCoefficient1 = reader.GetDouble(19), KineticCoefficient1 = reader.GetDouble(20),
KineticCoefficient2 = reader.GetDouble(20), KineticCoefficient2 = reader.GetDouble(21),
StandardDeviation = reader.GetDouble(21), StandardDeviation = reader.GetDouble(22),
StandardDeviation1 = reader.GetDouble(22), StandardDeviation1 = reader.GetDouble(23),
StandardDeviation2 = reader.GetDouble(23), StandardDeviation2 = reader.GetDouble(24),
PeakForceN = reader.GetDouble(24), PeakForceN = reader.GetDouble(25),
AverageForceN = reader.GetDouble(25), AverageForceN = reader.GetDouble(26),
Judgement = reader.GetString(26), Judgement = reader.GetString(27),
CsvExportPath = reader.GetString(27), CsvExportPath = reader.GetString(28),
ReportExportPath = reader.GetString(28), ReportExportPath = reader.GetString(29),
SampleCount = reader.GetInt32(29) SampleCount = reader.GetInt32(30)
}; };
var recipe = new TestRecipeSnapshot var recipe = new TestRecipeSnapshot
{ {
@@ -286,15 +341,18 @@ public sealed class TestDataRepository
SpeedMmPerMin = reader.GetDouble(11), SpeedMmPerMin = reader.GetDouble(11),
TravelMm = reader.GetDouble(12), TravelMm = reader.GetDouble(12),
ReplicateCount = reader.GetInt32(13), ReplicateCount = reader.GetInt32(13),
SpecimenDescription = reader.GetString(14) ReciprocatingCount = reader.GetInt32(14),
SpecimenDescription = reader.GetString(15)
}; };
var samples = LoadSamples(connection, runId); var samples = LoadSamples(connection, runId);
var reciprocatingRecords = LoadReciprocatingRecords(connection, runId);
return new PersistedRunData return new PersistedRunData
{ {
Run = run, Run = run,
Recipe = recipe, 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("$speedMmPerMin", recipe.SpeedMmPerMin);
command.Parameters.AddWithValue("$travelMm", recipe.TravelMm); command.Parameters.AddWithValue("$travelMm", recipe.TravelMm);
command.Parameters.AddWithValue("$replicateCount", recipe.ReplicateCount); command.Parameters.AddWithValue("$replicateCount", recipe.ReplicateCount);
command.Parameters.AddWithValue("$reciprocatingCount", recipe.ReciprocatingCount);
command.Parameters.AddWithValue("$specimenDescription", recipe.SpecimenDescription); command.Parameters.AddWithValue("$specimenDescription", recipe.SpecimenDescription);
command.Parameters.AddWithValue("$staticCoefficient", run.StaticCoefficient); command.Parameters.AddWithValue("$staticCoefficient", run.StaticCoefficient);
command.Parameters.AddWithValue("$staticCoefficient1", run.StaticCoefficient1); command.Parameters.AddWithValue("$staticCoefficient1", run.StaticCoefficient1);
@@ -362,6 +421,35 @@ public sealed class TestDataRepository
return samples; 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) private static void EnsureTestRunsTable(SqliteConnection connection, SqliteTransaction transaction)
{ {
if (!TableExists(connection, transaction, "test_runs")) 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, run_id, run_index, completed_at, batch_number, product_code, test_mode,
counterface_material, direction, sled_mass_grams, lift_speed_mm_per_min, counterface_material, direction, sled_mass_grams, lift_speed_mm_per_min,
lift_displacement_mm, speed_mm_per_min, travel_mm, replicate_count, 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, static_coefficient_2, kinetic_coefficient, kinetic_coefficient_1,
kinetic_coefficient_2, standard_deviation, standard_deviation_1, kinetic_coefficient_2, standard_deviation, standard_deviation_1,
standard_deviation_2, peak_force_n, average_force_n, judgement, 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, "speed_mm_per_min", "0")},
{SelectColumnOrDefault(existingColumns, "travel_mm", "0")}, {SelectColumnOrDefault(existingColumns, "travel_mm", "0")},
{SelectColumnOrDefault(existingColumns, "replicate_count", "0")}, {SelectColumnOrDefault(existingColumns, "replicate_count", "0")},
{SelectColumnOrDefault(existingColumns, "reciprocating_count", "50")},
{SelectColumnOrDefault(existingColumns, "specimen_description", "''")}, {SelectColumnOrDefault(existingColumns, "specimen_description", "''")},
{SelectColumnOrDefault(existingColumns, "static_coefficient", "0")}, {SelectColumnOrDefault(existingColumns, "static_coefficient", "0")},
{SelectColumnOrDefault(existingColumns, "static_coefficient_1", 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(); 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) private static void CreateTestRunsTable(SqliteConnection connection, SqliteTransaction transaction, string tableName)
{ {
using var command = connection.CreateCommand(); using var command = connection.CreateCommand();
@@ -503,6 +643,7 @@ public sealed class TestDataRepository
speed_mm_per_min REAL NOT NULL, speed_mm_per_min REAL NOT NULL,
travel_mm REAL NOT NULL, travel_mm REAL NOT NULL,
replicate_count INTEGER NOT NULL, replicate_count INTEGER NOT NULL,
reciprocating_count INTEGER NOT NULL DEFAULT 50,
specimen_description TEXT NOT NULL, specimen_description TEXT NOT NULL,
static_coefficient REAL NOT NULL, static_coefficient REAL NOT NULL,
static_coefficient_1 REAL NOT NULL DEFAULT 0, static_coefficient_1 REAL NOT NULL DEFAULT 0,
@@ -524,6 +665,26 @@ public sealed class TestDataRepository
command.ExecuteNonQuery(); 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) private static void CreateRawSamplesTable(SqliteConnection connection, SqliteTransaction transaction, string tableName)
{ {
using var command = connection.CreateCommand(); using var command = connection.CreateCommand();
@@ -574,6 +735,11 @@ public sealed class TestDataRepository
return existingColumns.Contains(columnName) ? columnName : defaultSql; 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) private static void DropTable(SqliteConnection connection, SqliteTransaction transaction, string tableName)
{ {
using var command = connection.CreateCommand(); 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 CoilForward = 3;
private const ushort CoilStart = 30; private const ushort CoilStart = 30;
private const ushort CoilStop = 32; private const ushort CoilStop = 32;
private const ushort CoilReciprocatingCycleComplete = 37;
private const ushort CoilReset = 90; private const ushort CoilReset = 90;
private const ushort RegisterSledMassGrams = 400; private const ushort RegisterSledMassGrams = 400;
private const ushort RegisterReplicateCount = 406; private const ushort RegisterReplicateCount = 406;
private const ushort RegisterReciprocatingCount = 410;
private const ushort RegisterHorizontalSpeedSetpoint = 370; private const ushort RegisterHorizontalSpeedSetpoint = 370;
private const ushort RegisterHorizontalTravelSetpoint = 380; private const ushort RegisterHorizontalTravelSetpoint = 380;
private const ushort RegisterLiftSpeed = 310; private const ushort RegisterLiftSpeed = 310;
@@ -58,7 +60,7 @@ public sealed class MainViewModel : ObservableObject, IDisposable
private const double EmptyChartYAxisMaxLimit = 0.5; private const double EmptyChartYAxisMaxLimit = 0.5;
private const double EmptyChartPointForceN = 0.001; private const double EmptyChartPointForceN = 0.001;
private const int ReciprocatingRecordSectionSize = 15; 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 _timer;
private readonly DispatcherTimer _deviceReconnectTimer; private readonly DispatcherTimer _deviceReconnectTimer;
@@ -122,6 +124,7 @@ public sealed class MainViewModel : ObservableObject, IDisposable
private TestRecipeSnapshot? _displayedRecipeSnapshot; private TestRecipeSnapshot? _displayedRecipeSnapshot;
private TestRecipeSnapshot? _lastCompletedRecipeSnapshot; private TestRecipeSnapshot? _lastCompletedRecipeSnapshot;
private IReadOnlyList<RawSampleRecord> _lastCompletedSamples = Array.Empty<RawSampleRecord>(); private IReadOnlyList<RawSampleRecord> _lastCompletedSamples = Array.Empty<RawSampleRecord>();
private IReadOnlyList<ReciprocatingFrictionRecord> _lastCompletedReciprocatingRecords = Array.Empty<ReciprocatingFrictionRecord>();
private bool _isShowingHistoricalRun; private bool _isShowingHistoricalRun;
private bool _deviceConnectionFailureLogged; private bool _deviceConnectionFailureLogged;
private bool _isInitializingDeviceConnection; private bool _isInitializingDeviceConnection;
@@ -130,6 +133,8 @@ public sealed class MainViewModel : ObservableObject, IDisposable
private bool _isSyncingRecipeFromPlc; private bool _isSyncingRecipeFromPlc;
private bool _activeRunStartedByPlc; private bool _activeRunStartedByPlc;
private bool _reciprocatingRecordReadFailureLogged; private bool _reciprocatingRecordReadFailureLogged;
private bool _lastReciprocatingCycleCompleteSignal;
private bool _isWaitingForReciprocatingCycleCompleteSignalReset;
private ushort? _activeTableMotionCoil; private ushort? _activeTableMotionCoil;
private ushort? _pendingTableMotionStopCoil; private ushort? _pendingTableMotionStopCoil;
private MachineRuntimeState _machineRuntimeState = MachineRuntimeState.Idle; private MachineRuntimeState _machineRuntimeState = MachineRuntimeState.Idle;
@@ -138,11 +143,12 @@ public sealed class MainViewModel : ObservableObject, IDisposable
private double _kineticForceSum; private double _kineticForceSum;
private int _kineticSampleCount; private int _kineticSampleCount;
private double _runStartHorizontalPositionMm = double.NaN; private double _runStartHorizontalPositionMm = double.NaN;
private DateTime? _runTravelCompletedAtUtc;
private double _lastRealtimeChartElapsedSeconds = double.NaN; private double _lastRealtimeChartElapsedSeconds = double.NaN;
private double _realtimeChartMaxElapsedSeconds; private double _realtimeChartMaxElapsedSeconds;
private double _currentRealtimeChartElapsedSeconds; private double _currentRealtimeChartElapsedSeconds;
private DateTime _lastRealtimeChartRefreshAt = DateTime.MinValue; private DateTime _lastRealtimeChartRefreshAt = DateTime.MinValue;
private DateTime _lastReciprocatingRecordRefreshAt = DateTime.MinValue; private int _nextReciprocatingRecordIndex = 1;
private double _lastForceXAxisMaxLimit; private double _lastForceXAxisMaxLimit;
private double _lastForceYAxisMaxLimit; private double _lastForceYAxisMaxLimit;
@@ -406,7 +412,7 @@ public sealed class MainViewModel : ObservableObject, IDisposable
public string DeviceEndpoint => _deviceConnectionService.Endpoint; public string DeviceEndpoint => _deviceConnectionService.Endpoint;
public string PlcAddressSummary => 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 => public string DeviceLastConnectedAtLabel =>
_deviceLastConnectedAt?.ToString("yyyy-MM-dd HH:mm:ss") ?? "尚未连接"; _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() private bool TryValidateStartOrResume()
@@ -766,7 +773,13 @@ public sealed class MainViewModel : ObservableObject, IDisposable
return false; 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(); var isRecoveringActiveRun = _machineRuntimeState == MachineRuntimeState.Interlocked && HasRecoverableActiveRunContext();
@@ -781,6 +794,9 @@ public sealed class MainViewModel : ObservableObject, IDisposable
_currentRunSamples.Clear(); _currentRunSamples.Clear();
ClearRealtimeChartSamples(); ClearRealtimeChartSamples();
ResetRealtimeSamplingMetrics(); ResetRealtimeSamplingMetrics();
ResetReciprocatingRecordTable();
_lastReciprocatingCycleCompleteSignal = initialReciprocatingCycleCompleteSignal;
_isWaitingForReciprocatingCycleCompleteSignalReset = initialReciprocatingCycleCompleteSignal;
CurrentForceN = 0; CurrentForceN = 0;
CurrentDisplacementMm = 0; CurrentDisplacementMm = 0;
CurrentPeakForceN = 0; CurrentPeakForceN = 0;
@@ -806,7 +822,7 @@ public sealed class MainViewModel : ObservableObject, IDisposable
SetAxisLimits(emptyChartXMax, EmptyChartYAxisMaxLimit); SetAxisLimits(emptyChartXMax, EmptyChartYAxisMaxLimit);
NotifyRealtimeChartChanged(); NotifyRealtimeChartChanged();
_deviceDataReader.Initialize(Recipe); _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) else if (isRecoveringActiveRun)
{ {
@@ -890,9 +906,10 @@ public sealed class MainViewModel : ObservableObject, IDisposable
return; return;
} }
await SyncStartParametersToPlcAsync();
if (await ExecutePlcPulseCommandAsync("启动", CoilStart, updateLocalState: false)) 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); UpdateLiveProcessSnapshot(frame);
} }
await RefreshReciprocatingRecordsFromPlcIfDueAsync();
if (_machineRuntimeState != MachineRuntimeState.Running) if (_machineRuntimeState != MachineRuntimeState.Running)
{ {
RaiseStatusProperties(); RaiseStatusProperties();
return; return;
} }
await CaptureReciprocatingRecordIfCycleCompletedAsync();
if (!AppendRunningSample(frame, out var isCompleted)) if (!AppendRunningSample(frame, out var isCompleted))
{ {
RaiseStatusProperties(); RaiseStatusProperties();
@@ -1030,6 +1047,7 @@ public sealed class MainViewModel : ObservableObject, IDisposable
_lastCompletedRun = record; _lastCompletedRun = record;
_lastCompletedRecipeSnapshot = _activeRecipeSnapshot ?? TestRecipeSnapshot.FromRecipe(Recipe); _lastCompletedRecipeSnapshot = _activeRecipeSnapshot ?? TestRecipeSnapshot.FromRecipe(Recipe);
_lastCompletedSamples = _currentRunSamples.ToArray(); _lastCompletedSamples = _currentRunSamples.ToArray();
_lastCompletedReciprocatingRecords = CloneReciprocatingRecords(ReciprocatingRecords);
PersistLastCompletedRun(); PersistLastCompletedRun();
ResetActiveRunContext(); ResetActiveRunContext();
SelectedRunRecord = record; SelectedRunRecord = record;
@@ -1039,7 +1057,7 @@ public sealed class MainViewModel : ObservableObject, IDisposable
_machineRuntimeState = MachineRuntimeState.Idle; _machineRuntimeState = MachineRuntimeState.Idle;
MachineState = "待机"; MachineState = "待机";
StateBrush = BrushFromHex("#6C8E78"); StateBrush = BrushFromHex("#6C8E78");
InterlockMessage = $"试验完成。μs={record.StaticCoefficient:F3}, μk={record.KineticCoefficient:F3}"; InterlockMessage = $"试验完成。COFs={record.StaticCoefficient:F3}, COFk={record.KineticCoefficient:F3}";
AddInfoEvent($"第 {record.RunIndex} 轮完成。"); AddInfoEvent($"第 {record.RunIndex} 轮完成。");
RaiseStatusProperties(); RaiseStatusProperties();
} }
@@ -1128,8 +1146,21 @@ public sealed class MainViewModel : ObservableObject, IDisposable
var activeReplicateCount = GetActiveReplicateCount(); var activeReplicateCount = GetActiveReplicateCount();
CurrentStaticCoefficient = ResolveRepresentativeValue(activeReplicateCount, StaticCoefficient1, StaticCoefficient2); CurrentStaticCoefficient = ResolveRepresentativeValue(activeReplicateCount, StaticCoefficient1, StaticCoefficient2);
CurrentKineticCoefficient = ResolveRepresentativeValue(activeReplicateCount, KineticCoefficient1, KineticCoefficient2); CurrentKineticCoefficient = ResolveRepresentativeValue(activeReplicateCount, KineticCoefficient1, KineticCoefficient2);
TrialProgressPercent = Math.Min(100, displacementMm / Math.Max(GetActiveTravelMm(), 1) * 100); var configuredReciprocatingCount = GetConfiguredReciprocatingCount();
isCompleted = displacementMm >= GetActiveTravelMm(); 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); RefreshRealtimeChartPresentation(force: isFirstChartSample || isCompleted);
if (isFirstChartSample) if (isFirstChartSample)
{ {
@@ -1158,6 +1189,25 @@ public sealed class MainViewModel : ObservableObject, IDisposable
return IsFinite(displacementMm); 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) private void UpdatePreviewResult(double displacementMm, double forceN)
{ {
_runningPeakForceN = Math.Max(_runningPeakForceN, forceN); _runningPeakForceN = Math.Max(_runningPeakForceN, forceN);
@@ -1383,6 +1433,7 @@ public sealed class MainViewModel : ObservableObject, IDisposable
_kineticForceSum = 0; _kineticForceSum = 0;
_kineticSampleCount = 0; _kineticSampleCount = 0;
_runStartHorizontalPositionMm = double.NaN; _runStartHorizontalPositionMm = double.NaN;
_runTravelCompletedAtUtc = null;
_lastRealtimeChartElapsedSeconds = double.NaN; _lastRealtimeChartElapsedSeconds = double.NaN;
_realtimeChartMaxElapsedSeconds = 0; _realtimeChartMaxElapsedSeconds = 0;
_currentRealtimeChartElapsedSeconds = 0; _currentRealtimeChartElapsedSeconds = 0;
@@ -1433,32 +1484,52 @@ public sealed class MainViewModel : ObservableObject, IDisposable
OnPropertyChanged(nameof(ExportReportSummary)); OnPropertyChanged(nameof(ExportReportSummary));
} }
private async Task RefreshReciprocatingRecordsFromPlcIfDueAsync() private async Task CaptureReciprocatingRecordIfCycleCompletedAsync()
{ {
if (!_deviceConnectionService.IsConnected) if (!_deviceConnectionService.IsConnected ||
_nextReciprocatingRecordIndex > GetConfiguredReciprocatingCount())
{ {
return; return;
} }
var now = DateTime.UtcNow;
if (now - _lastReciprocatingRecordRefreshAt < ReciprocatingRecordRefreshInterval)
{
return;
}
_lastReciprocatingRecordRefreshAt = now;
try try
{ {
var records = await _deviceDataReader.ReadReciprocatingRecordsAsync(GetConfiguredReciprocatingCount()); var coils = await _deviceConnectionService.ReadCoilsAsync(
UpdateReciprocatingRecordTable(records); ModbusSlaveAddress,
CoilReciprocatingCycleComplete,
1);
var isCycleComplete = coils.Length > 0 && coils[0];
if (!isCycleComplete)
{
_lastReciprocatingCycleCompleteSignal = false;
_isWaitingForReciprocatingCycleCompleteSignalReset = false;
_reciprocatingRecordReadFailureLogged = 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) catch (Exception ex)
{ {
if (!_reciprocatingRecordReadFailureLogged) if (!_reciprocatingRecordReadFailureLogged)
{ {
AddWarningEvent($"往复记录读取失败,已保留占位表: {ex.Message}"); AddWarningEvent($"往复第 {_nextReciprocatingRecordIndex} 次结果读取失败,等待下一次完成信号: {ex.Message}");
_reciprocatingRecordReadFailureLogged = true; _reciprocatingRecordReadFailureLogged = true;
} }
} }
@@ -1467,6 +1538,10 @@ public sealed class MainViewModel : ObservableObject, IDisposable
private void ResetReciprocatingRecordTable() private void ResetReciprocatingRecordTable()
{ {
ReciprocatingRecords.Clear(); ReciprocatingRecords.Clear();
_nextReciprocatingRecordIndex = 1;
_lastReciprocatingCycleCompleteSignal = false;
_isWaitingForReciprocatingCycleCompleteSignalReset = false;
_reciprocatingRecordReadFailureLogged = false;
for (var index = 1; index <= GetConfiguredReciprocatingCount(); index++) for (var index = 1; index <= GetConfiguredReciprocatingCount(); index++)
{ {
ReciprocatingRecords.Add(ReciprocatingFrictionRecord.Empty(index)); ReciprocatingRecords.Add(ReciprocatingFrictionRecord.Empty(index));
@@ -1476,20 +1551,46 @@ public sealed class MainViewModel : ObservableObject, IDisposable
OnPropertyChanged(nameof(ReciprocatingRecordSummary)); OnPropertyChanged(nameof(ReciprocatingRecordSummary));
} }
private void UpdateReciprocatingRecordTable(IReadOnlyList<ReciprocatingFrictionRecord> records) private void LoadReciprocatingRecordsIntoTable(
IReadOnlyList<ReciprocatingFrictionRecord> records,
int configuredCount)
{ {
var byIndex = records var byIndex = records
.Where(record => record.Index > 0) .Where(record => record.Index > 0)
.GroupBy(record => record.Index) .GroupBy(record => record.Index)
.ToDictionary(group => group.Key, group => group.First()); .ToDictionary(group => group.Key, group => group.First());
var rowCount = Math.Max(1, Math.Max(configuredCount, byIndex.Keys.DefaultIfEmpty(0).Max()));
ReciprocatingRecords.Clear(); ReciprocatingRecords.Clear();
for (var index = 1; index <= GetConfiguredReciprocatingCount(); index++) for (var index = 1; index <= rowCount; index++)
{ {
ReciprocatingRecords.Add(byIndex.TryGetValue(index, out var record) ReciprocatingRecords.Add(byIndex.TryGetValue(index, out var record)
? record ? record
: ReciprocatingFrictionRecord.Empty(index)); : 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(); RebuildReciprocatingRecordSections();
OnPropertyChanged(nameof(ReciprocatingRecordSummary)); OnPropertyChanged(nameof(ReciprocatingRecordSummary));
} }
@@ -1515,8 +1616,8 @@ public sealed class MainViewModel : ObservableObject, IDisposable
Rows = Rows =
[ [
BuildReciprocatingRecordGridRow(string.Empty, sectionRecords.Select(record => record.Index.ToString()), isHeader: true), BuildReciprocatingRecordGridRow(string.Empty, sectionRecords.Select(record => record.Index.ToString()), isHeader: true),
BuildReciprocatingRecordGridRow("cofs", sectionRecords.Select(record => record.StaticCoefficientLabel)), BuildReciprocatingRecordGridRow("COFs", sectionRecords.Select(record => record.StaticCoefficientLabel)),
BuildReciprocatingRecordGridRow("cofk", sectionRecords.Select(record => record.KineticCoefficientLabel)), BuildReciprocatingRecordGridRow("COFk", sectionRecords.Select(record => record.KineticCoefficientLabel)),
BuildReciprocatingRecordGridRow("Fs[N]", sectionRecords.Select(record => record.StaticForceLabel)), BuildReciprocatingRecordGridRow("Fs[N]", sectionRecords.Select(record => record.StaticForceLabel)),
BuildReciprocatingRecordGridRow("Fk[N]", sectionRecords.Select(record => record.KineticForceLabel)) 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( private static ReciprocatingRecordGridRow BuildReciprocatingRecordGridRow(
string header, string header,
IEnumerable<string> values, IEnumerable<string> values,
@@ -1540,7 +1655,10 @@ public sealed class MainViewModel : ObservableObject, IDisposable
private int GetConfiguredReciprocatingCount() 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() private void RaiseStatusProperties()
@@ -1570,6 +1688,48 @@ public sealed class MainViewModel : ObservableObject, IDisposable
_clearHistoryCommand.RaiseCanExecuteChanged(); _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() private bool CanExecutePlcCommand()
{ {
return !_isPlcCommandBusy && _activeTableMotionCoil is null; return !_isPlcCommandBusy && _activeTableMotionCoil is null;
@@ -1739,8 +1899,12 @@ public sealed class MainViewModel : ObservableObject, IDisposable
try try
{ {
_dataRepository.SaveRun(_lastCompletedRun!, _lastCompletedRecipeSnapshot!, _lastCompletedSamples); _dataRepository.SaveRun(
AddInfoEvent($"SQLite 已保存试验 {_lastCompletedRun!.RunIndex} 的元数据和 {_lastCompletedSamples.Count} 条原始采样。"); _lastCompletedRun!,
_lastCompletedRecipeSnapshot!,
_lastCompletedSamples,
_lastCompletedReciprocatingRecords);
AddInfoEvent($"SQLite 已保存试验 {_lastCompletedRun!.RunIndex} 的元数据、{_lastCompletedSamples.Count} 条原始采样和 {_lastCompletedReciprocatingRecords.Count(record => record.HasData)} 条往复记录。");
} }
catch (Exception ex) catch (Exception ex)
{ {
@@ -1795,6 +1959,7 @@ public sealed class MainViewModel : ObservableObject, IDisposable
_displayedRecipeSnapshot = data.Recipe; _displayedRecipeSnapshot = data.Recipe;
_currentRunSamples.Clear(); _currentRunSamples.Clear();
_currentRunSamples.AddRange(data.Samples); _currentRunSamples.AddRange(data.Samples);
LoadReciprocatingRecordsIntoTable(data.ReciprocatingRecords, data.Recipe.ReciprocatingCount);
LoadSamplesIntoChart(data.Samples); LoadSamplesIntoChart(data.Samples);
@@ -1842,7 +2007,8 @@ public sealed class MainViewModel : ObservableObject, IDisposable
{ {
Run = _lastCompletedRun, Run = _lastCompletedRun,
Recipe = _lastCompletedRecipeSnapshot, 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 speedMmPerMin = await ReadFloatHoldingRegisterAsync(master, RegisterHorizontalSpeedSetpoint);
var travelMm = await ReadFloatHoldingRegisterAsync(master, RegisterHorizontalTravelSetpoint); var travelMm = await ReadFloatHoldingRegisterAsync(master, RegisterHorizontalTravelSetpoint);
var replicateCount = Math.Clamp((int)Math.Round(await ReadFloatHoldingRegisterAsync(master, RegisterReplicateCount)), 0, 1); var replicateCount = Math.Clamp((int)Math.Round(await ReadFloatHoldingRegisterAsync(master, RegisterReplicateCount)), 0, 1);
var plcReciprocatingCount = await ReadUInt16HoldingRegisterAsync(master, RegisterReciprocatingCount);
Recipe.SledMassGrams = sledMassGrams; Recipe.SledMassGrams = sledMassGrams;
Recipe.LiftSpeedMmPerMin = liftSpeedMmPerMin; Recipe.LiftSpeedMmPerMin = liftSpeedMmPerMin;
@@ -2090,12 +2257,26 @@ public sealed class MainViewModel : ObservableObject, IDisposable
Recipe.SpeedMmPerMin = speedMmPerMin; Recipe.SpeedMmPerMin = speedMmPerMin;
Recipe.TravelMm = travelMm; Recipe.TravelMm = travelMm;
Recipe.ReplicateCount = replicateCount; Recipe.ReplicateCount = replicateCount;
if (plcReciprocatingCount > 0)
{
Recipe.ReciprocatingCount = plcReciprocatingCount;
}
else
{
AddWarningEvent($"PLC 往复次数 D{RegisterReciprocatingCount}=0已保留界面设置 {Recipe.ReciprocatingCount} 次。");
}
} }
finally finally
{ {
_isSyncingRecipeFromPlc = false; _isSyncingRecipeFromPlc = false;
} }
if (_machineRuntimeState == MachineRuntimeState.Idle &&
ReciprocatingRecords.Count != GetConfiguredReciprocatingCount())
{
ResetReciprocatingRecordTable();
}
RefreshEmptyRealtimeChartFrameIfVisible(); RefreshEmptyRealtimeChartFrameIfVisible();
RaiseStatusProperties(); RaiseStatusProperties();
} }
@@ -2127,9 +2308,30 @@ public sealed class MainViewModel : ObservableObject, IDisposable
case nameof(TestRecipe.ReplicateCount): case nameof(TestRecipe.ReplicateCount):
await _deviceConnectionService.WriteFloatRegisterAsync(ModbusSlaveAddress, RegisterReplicateCount, Math.Clamp(Recipe.ReplicateCount, 0, 1)); await _deviceConnectionService.WriteFloatRegisterAsync(ModbusSlaveAddress, RegisterReplicateCount, Math.Clamp(Recipe.ReplicateCount, 0, 1));
break; 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) private static async Task<double> ReadFloatHoldingRegisterAsync(NModbusAsync.IModbusMaster master, ushort address)
{ {
var registers = await master.ReadHoldingRegistersAsync( var registers = await master.ReadHoldingRegistersAsync(
@@ -2304,7 +2506,8 @@ public sealed class MainViewModel : ObservableObject, IDisposable
{ {
Run = activeRun, Run = activeRun,
Recipe = activeRecipeSnapshot, Recipe = activeRecipeSnapshot,
Samples = activeSamples Samples = activeSamples,
ReciprocatingRecords = CloneReciprocatingRecords(ReciprocatingRecords)
}; };
} }
@@ -2312,7 +2515,7 @@ public sealed class MainViewModel : ObservableObject, IDisposable
{ {
var historyCount = RunHistory.Count; var historyCount = RunHistory.Count;
var confirmation = MessageBox.Show( var confirmation = MessageBox.Show(
$"将永久清空 {historyCount} 轮历史试验、原始采样和当前历史回放数据。\n\n会清空数据库与界面中的历史记录、曲线和结果显示但保留当前测试参数。\n\n是否继续", $"将永久清空 {historyCount} 轮历史试验、原始采样、往复记录和当前历史回放数据。\n\n会清空数据库与界面中的历史记录、曲线和结果显示但保留当前测试参数。\n\n是否继续",
"确认清空历史", "确认清空历史",
MessageBoxButton.YesNo, MessageBoxButton.YesNo,
MessageBoxImage.Warning, MessageBoxImage.Warning,
@@ -2333,6 +2536,7 @@ public sealed class MainViewModel : ObservableObject, IDisposable
_lastCompletedRun = null; _lastCompletedRun = null;
_lastCompletedRecipeSnapshot = null; _lastCompletedRecipeSnapshot = null;
_lastCompletedSamples = Array.Empty<RawSampleRecord>(); _lastCompletedSamples = Array.Empty<RawSampleRecord>();
_lastCompletedReciprocatingRecords = Array.Empty<ReciprocatingFrictionRecord>();
_displayedRecipeSnapshot = TestRecipeSnapshot.FromRecipe(Recipe); _displayedRecipeSnapshot = TestRecipeSnapshot.FromRecipe(Recipe);
_isShowingHistoricalRun = false; _isShowingHistoricalRun = false;
ResetActiveRunContext(); ResetActiveRunContext();