更新20260514
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>();
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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}";
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user