This commit is contained in:
GukSang.Jin
2026-05-14 17:30:26 +08:00
parent a8859bfc3e
commit 1ecb76549c
4 changed files with 297 additions and 82 deletions

View File

@@ -6,6 +6,8 @@ public sealed class RawSampleRecord
public int SampleIndex { get; init; }
public int ReciprocatingIndex { get; init; }
public DateTime CapturedAt { get; init; }
public double DisplacementMm { get; init; }

View File

@@ -116,17 +116,22 @@ public sealed class RunExportService
.OrderBy(sample => sample.SampleIndex)
.ToArray()
})
.Where(item => item.ValidSamples.Length > 0)
.Where(item => item.ValidSamples.Length > 0 || item.Data.ReciprocatingRecords.Any(record => record.HasData))
.Select((item, index) => new ExportCurveData(item.Data, item.ValidSamples, index + 1, GetCurveColorHex(index)))
.ToArray();
}
private static ExportReportStatistics BuildExportStatistics(int sourceRunCount, IReadOnlyList<ExportCurveData> exportRuns)
{
var chartableRuns = exportRuns.Where(IsChartableRun).ToArray();
var chartImageCount = chartableRuns.Length == 0
? 0
: 1 + chartableRuns.Sum(CountReciprocatingCurveSegments);
return new ExportReportStatistics(
sourceRunCount,
exportRuns.Count,
exportRuns.Count(IsChartableRun),
chartImageCount,
Math.Max(0, sourceRunCount - exportRuns.Count),
exportRuns.Sum(item => item.ValidSamples.Count));
}
@@ -215,7 +220,7 @@ public sealed class RunExportService
{
var row = dataStartRow + index;
var item = runs[index];
var forceStats = CalculateForceStatistics(item.ValidSamples);
var metrics = BuildRunReportMetrics(item);
worksheet.Cell(row, 1).Value = BuildCurveKey(item);
worksheet.Cell(row, 2).Value = BuildCurveLabel(item);
@@ -228,19 +233,19 @@ public sealed class RunExportService
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.Run.StaticCoefficient;
worksheet.Cell(row, 12).Value = item.Data.Run.KineticCoefficient;
worksheet.Cell(row, 13).Value = item.Data.Run.StandardDeviation;
worksheet.Cell(row, 14).Value = item.Data.Run.StandardDeviation1;
worksheet.Cell(row, 15).Value = item.Data.Run.StandardDeviation2;
worksheet.Cell(row, 16).Value = item.Data.Run.PeakForceN;
worksheet.Cell(row, 17).Value = item.Data.Run.AverageForceN;
worksheet.Cell(row, 11).Value = metrics.StaticCoefficient;
worksheet.Cell(row, 12).Value = metrics.KineticCoefficient;
worksheet.Cell(row, 13).Value = metrics.StandardDeviation;
worksheet.Cell(row, 14).Value = ResolveReportValue(item.Data.Run.StandardDeviation1, [metrics.StandardDeviation]);
worksheet.Cell(row, 15).Value = ResolveReportValue(item.Data.Run.StandardDeviation2, [metrics.StandardDeviation]);
worksheet.Cell(row, 16).Value = metrics.PeakForceN;
worksheet.Cell(row, 17).Value = metrics.AverageForceN;
worksheet.Cell(row, 18).Value = item.ValidSamples.Count;
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, 20).Value = metrics.ForceStats.Min;
worksheet.Cell(row, 21).Value = metrics.ForceStats.Max;
worksheet.Cell(row, 22).Value = metrics.ForceStats.Average;
worksheet.Cell(row, 23).Value = metrics.ForceStats.StandardDeviation;
worksheet.Cell(row, 4).Style.Fill.BackgroundColor = ToXlColor(item.CurveColorHex);
worksheet.Cell(row, 4).Style.Font.FontColor = XLColor.White;
@@ -284,12 +289,15 @@ public sealed class RunExportService
"产品编号",
"测试模式",
"方向",
"设置往复次数",
"当前往复序号",
"静摩擦系数 COFs",
"动摩擦系数 COFk",
"标准差",
"数据状态",
"采样序号",
"采样时间",
"时间(s)",
"位移(mm)",
"力值(N)",
"速度(mm/min)"
@@ -303,9 +311,17 @@ public sealed class RunExportService
var curveKey = BuildCurveKey(item);
var curveLabel = BuildCurveLabel(item);
var legendLabel = BuildLegendLabel(item);
var metrics = BuildRunReportMetrics(item);
var firstCapturedAt = item.ValidSamples.Count == 0 ? DateTime.MinValue : item.ValidSamples[0].CapturedAt;
var reciprocatingRecordByIndex = item.Data.ReciprocatingRecords
.Where(record => record.HasData)
.GroupBy(record => record.Index)
.ToDictionary(group => group.Key, group => group.Last());
foreach (var sample in item.ValidSamples)
{
var sampleReciprocatingIndex = ResolveSampleReciprocatingIndex(item, sample);
reciprocatingRecordByIndex.TryGetValue(sampleReciprocatingIndex, out var sampleReciprocatingRecord);
worksheet.Cell(rowIndex, 1).Value = curveKey;
worksheet.Cell(rowIndex, 2).Value = curveLabel;
worksheet.Cell(rowIndex, 3).Value = legendLabel;
@@ -317,18 +333,21 @@ public sealed class RunExportService
worksheet.Cell(rowIndex, 8).Value = item.Data.Recipe.ProductCode;
worksheet.Cell(rowIndex, 9).Value = item.Data.Recipe.TestMode;
worksheet.Cell(rowIndex, 10).Value = item.Data.Recipe.Direction;
worksheet.Cell(rowIndex, 11).Value = item.Data.Run.StaticCoefficient;
worksheet.Cell(rowIndex, 12).Value = item.Data.Run.KineticCoefficient;
worksheet.Cell(rowIndex, 13).Value = item.Data.Run.StandardDeviation;
worksheet.Cell(rowIndex, 14).Value = "有效";
worksheet.Cell(rowIndex, 15).Value = sample.SampleIndex;
worksheet.Cell(rowIndex, 16).Value = sample.CapturedAt;
worksheet.Cell(rowIndex, 16).Style.DateFormat.Format = "yyyy-mm-dd hh:mm:ss.000";
worksheet.Cell(rowIndex, 17).Value = sample.DisplacementMm;
worksheet.Cell(rowIndex, 18).Value = sample.ForceN;
worksheet.Cell(rowIndex, 11).Value = item.Data.Recipe.ReciprocatingCount;
worksheet.Cell(rowIndex, 12).Value = sampleReciprocatingIndex;
worksheet.Cell(rowIndex, 13).Value = ResolveNullableReportValue(sampleReciprocatingRecord?.StaticCoefficient, metrics.StaticCoefficient);
worksheet.Cell(rowIndex, 14).Value = ResolveNullableReportValue(sampleReciprocatingRecord?.KineticCoefficient, metrics.KineticCoefficient);
worksheet.Cell(rowIndex, 15).Value = metrics.StandardDeviation;
worksheet.Cell(rowIndex, 16).Value = "有效";
worksheet.Cell(rowIndex, 17).Value = sample.SampleIndex;
worksheet.Cell(rowIndex, 18).Value = sample.CapturedAt;
worksheet.Cell(rowIndex, 18).Style.DateFormat.Format = "yyyy-mm-dd hh:mm:ss.000";
worksheet.Cell(rowIndex, 19).Value = CalculateElapsedSeconds(firstCapturedAt, sample.CapturedAt);
worksheet.Cell(rowIndex, 20).Value = sample.DisplacementMm;
worksheet.Cell(rowIndex, 21).Value = sample.ForceN;
if (IsFinite(sample.SpeedMmPerMin))
{
worksheet.Cell(rowIndex, 19).Value = sample.SpeedMmPerMin;
worksheet.Cell(rowIndex, 22).Value = sample.SpeedMmPerMin;
}
worksheet.Cell(rowIndex, 4).Style.Fill.BackgroundColor = ToXlColor(item.CurveColorHex);
@@ -344,7 +363,7 @@ public sealed class RunExportService
ApplyRawDataColumnWidths(worksheet);
ApplyRawDataFormatting(worksheet, headerRow + 1, dataLastRow);
ApplyPageLayout(worksheet, dataLastRow, "$A$1:$S$");
ApplyPageLayout(worksheet, dataLastRow, "$A$1:$V$");
worksheet.SheetView.FreezeRows(1);
}
@@ -437,7 +456,7 @@ public sealed class RunExportService
worksheet.Cell("A1").Style.Font.FontColor = XLColor.FromHtml("#21313D");
worksheet.Cell("A3").Value = "图表说明";
worksheet.Cell("B3").Value = "总览曲线叠加本次导出的全部有效次数曲线;每次曲线仅显示对应单次试验,横轴按真实位移数值比例绘制。";
worksheet.Cell("B3").Value = "总览曲线按测试过程时间轴连续显示全部往复;每次曲线按往复序号拆分为独立图表,避免多次往复叠加在同一图中。";
worksheet.Range("B3:H3").Merge();
worksheet.Cell("A5").Value = "曲线清单";
@@ -456,7 +475,7 @@ public sealed class RunExportService
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])
? "已记录总览曲线、每次曲线和原始数据"
? $"已记录总览曲线和 {CountReciprocatingCurveSegments(runs[index])} 张每次曲线"
: "采样点不足,保留原始数据和往复数据";
worksheet.Cell(row, 4).Style.Fill.BackgroundColor = ToXlColor(runs[index].CurveColorHex);
worksheet.Cell(row, 4).Style.Font.FontColor = XLColor.White;
@@ -472,16 +491,17 @@ public sealed class RunExportService
private static void BuildStatisticsPanel(IXLWorksheet worksheet, IReadOnlyList<ExportCurveData> runs)
{
var averageStatic = runs.Count == 0 ? 0 : runs.Average(item => item.Data.Run.StaticCoefficient);
var averageKinetic = runs.Count == 0 ? 0 : runs.Average(item => item.Data.Run.KineticCoefficient);
var maxStatic = runs.Count == 0 ? 0 : runs.Max(item => item.Data.Run.StaticCoefficient);
var minStatic = runs.Count == 0 ? 0 : runs.Min(item => item.Data.Run.StaticCoefficient);
var maxKinetic = runs.Count == 0 ? 0 : runs.Max(item => item.Data.Run.KineticCoefficient);
var minKinetic = runs.Count == 0 ? 0 : runs.Min(item => item.Data.Run.KineticCoefficient);
var standardDeviationStatic = CalculateStandardDeviation(runs.Select(item => item.Data.Run.StaticCoefficient));
var standardDeviationKinetic = CalculateStandardDeviation(runs.Select(item => item.Data.Run.KineticCoefficient));
var maxPeakForce = runs.Count == 0 ? 0 : runs.Max(item => item.Data.Run.PeakForceN);
var minPeakForce = runs.Count == 0 ? 0 : runs.Min(item => item.Data.Run.PeakForceN);
var metrics = runs.Select(BuildRunReportMetrics).ToArray();
var averageStatic = metrics.Length == 0 ? 0 : metrics.Average(item => item.StaticCoefficient);
var averageKinetic = metrics.Length == 0 ? 0 : metrics.Average(item => item.KineticCoefficient);
var maxStatic = metrics.Length == 0 ? 0 : metrics.Max(item => item.StaticCoefficient);
var minStatic = metrics.Length == 0 ? 0 : metrics.Min(item => item.StaticCoefficient);
var maxKinetic = metrics.Length == 0 ? 0 : metrics.Max(item => item.KineticCoefficient);
var minKinetic = metrics.Length == 0 ? 0 : metrics.Min(item => item.KineticCoefficient);
var standardDeviationStatic = CalculateStandardDeviation(metrics.Select(item => item.StaticCoefficient));
var standardDeviationKinetic = CalculateStandardDeviation(metrics.Select(item => item.KineticCoefficient));
var maxPeakForce = metrics.Length == 0 ? 0 : metrics.Max(item => item.PeakForceN);
var minPeakForce = metrics.Length == 0 ? 0 : metrics.Min(item => item.PeakForceN);
worksheet.Cell("H3").Value = "汇总统计";
worksheet.Cell("H3").Style.Font.Bold = true;
@@ -531,13 +551,14 @@ public sealed class RunExportService
currentColumn += 3;
worksheet.Cell(startRow, xColumn).Value = BuildCurveLabel(item);
worksheet.Cell(startRow + 1, xColumn).Value = "位移(mm)";
worksheet.Cell(startRow + 1, xColumn).Value = "时间(s)";
worksheet.Cell(startRow + 1, yColumn).Value = "力值(N)";
var currentRow = startRow + 2;
var firstCapturedAt = item.ValidSamples.Count == 0 ? DateTime.MinValue : item.ValidSamples[0].CapturedAt;
foreach (var sample in item.ValidSamples)
{
worksheet.Cell(currentRow, xColumn).Value = sample.DisplacementMm;
worksheet.Cell(currentRow, xColumn).Value = CalculateElapsedSeconds(firstCapturedAt, sample.CapturedAt);
worksheet.Cell(currentRow, yColumn).Value = sample.ForceN;
currentRow++;
}
@@ -548,7 +569,7 @@ public sealed class RunExportService
var titleRef = BuildCellReference(sheetName, startRow, xColumn);
var startDataRow = startRow + 2;
var endDataRow = Math.Max(startDataRow, currentRow - 1);
var xValues = item.ValidSamples.Select(sample => sample.DisplacementMm).ToArray();
var xValues = item.ValidSamples.Select(sample => CalculateElapsedSeconds(firstCapturedAt, sample.CapturedAt)).ToArray();
var yValues = item.ValidSamples.Select(sample => sample.ForceN).ToArray();
var layout = new SeriesLayout(
titleRef,
@@ -577,7 +598,7 @@ public sealed class RunExportService
{
charts.Add(new ChartDefinition(
"总览曲线(全部次数)",
"位移 (mm)",
"时间 (s)",
"力值 (N)",
true,
perRunSeries.Select(item => item.Series).ToArray(),
@@ -597,7 +618,7 @@ public sealed class RunExportService
charts.Add(new ChartDefinition(
BuildSingleRunCurveTitle(runSeries.Data),
"位移 (mm)",
"时间 (s)",
"力值 (N)",
false,
[runSeries.Series],
@@ -629,15 +650,24 @@ public sealed class RunExportService
row += 38;
foreach (var run in chartableRuns)
{
InsertCurveChartImage(
worksheet,
row,
BuildSingleRunCurveTitle(run),
BuildSingleRunCurveTitle(run),
$"Curve{run.Sequence:D2}",
[run],
showLegend: false);
row += 36;
foreach (var group in BuildReciprocatingSampleGroups(run).Where(group => group.Samples.Count >= 2))
{
var cycleRun = new ExportCurveData(
run.Data,
group.Samples,
run.Sequence,
GetCurveColorHex(group.Index - 1));
var title = BuildReciprocatingCurveTitle(run, group.Index);
InsertCurveChartImage(
worksheet,
row,
title,
title,
$"Curve{run.Sequence:D2}_{group.Index:D3}",
[cycleRun],
showLegend: false);
row += 36;
}
}
}
@@ -677,8 +707,6 @@ public sealed class RunExportService
var gridPen = CreatePen("#D1DBE3", 1);
var minorGridPen = CreatePen("#E5ECF2", 1);
var axisPen = CreatePen("#4A5C6C", 1.4);
var bandBrush = new SolidColorBrush(Color.FromArgb(20, 0, 105, 180));
var bandPen = new Pen(new SolidColorBrush(Color.FromArgb(80, 0, 105, 180)), 1);
var pointBrush = CreateBrush("#0069B4");
var pointStrokePen = CreatePen("#F7F9FB", 2.5);
@@ -690,7 +718,6 @@ public sealed class RunExportService
DrawExportText(drawingContext, title, 32, 42, 28, titleBrush, bold: true);
DrawExportGridAndAxes(drawingContext, plot, xMax, yMax, labelBrush, axisTitleBrush, gridPen, minorGridPen, axisPen);
DrawExportKineticBand(drawingContext, plot, xMax, runs.Max(item => item.Data.Recipe.TravelMm), bandBrush, bandPen);
for (var index = 0; index < runs.Count; index++)
{
@@ -755,7 +782,7 @@ public sealed class RunExportService
labelBrush);
}
DrawCenteredExportText(drawingContext, "位移 / mm", plot.Left + plot.Width / 2, plot.Bottom + 58, 18, axisTitleBrush, bold: true);
DrawCenteredExportText(drawingContext, "时间 / s", plot.Left + plot.Width / 2, plot.Bottom + 58, 18, axisTitleBrush, bold: true);
DrawExportText(drawingContext, "力值 / N", 18, plot.Top - 30, 18, axisTitleBrush, bold: true);
}
@@ -794,10 +821,11 @@ public sealed class RunExportService
Brush pointBrush,
Pen pointStrokePen)
{
var firstCapturedAt = run.ValidSamples.Count == 0 ? DateTime.MinValue : run.ValidSamples[0].CapturedAt;
var points = run.ValidSamples
.Where(sample => IsFinite(sample.DisplacementMm) && IsFinite(sample.ForceN))
.Where(sample => IsFinite(sample.ForceN))
.Select(sample => new Point(
MapX(sample.DisplacementMm, plot, xMax),
MapX(CalculateElapsedSeconds(firstCapturedAt, sample.CapturedAt), plot, xMax),
MapY(sample.ForceN, plot, yMax)))
.ToArray();
@@ -843,9 +871,8 @@ public sealed class RunExportService
private static double ResolveExportChartXMax(IReadOnlyList<ExportCurveData> runs)
{
var travelMax = runs.Select(item => item.Data.Recipe.TravelMm).Where(IsFinite).DefaultIfEmpty(0).Max();
var sampleMax = runs.SelectMany(item => item.ValidSamples).Select(sample => sample.DisplacementMm).Where(IsFinite).DefaultIfEmpty(0).Max();
return Math.Max(Math.Max(travelMax, sampleMax) + 5, 1);
var sampleMax = runs.Select(item => ResolveSamplesDurationSeconds(item.ValidSamples)).Where(IsFinite).DefaultIfEmpty(0).Max();
return Math.Max(sampleMax + 1, 1);
}
private static double ResolveExportChartYMax(IReadOnlyList<ExportCurveData> runs)
@@ -933,7 +960,7 @@ public sealed class RunExportService
private static int CalculateChartSheetLastRow(IReadOnlyList<ExportCurveData> runs)
{
var chartCount = runs.Count(IsChartableRun);
var chartCount = runs.Where(IsChartableRun).Sum(CountReciprocatingCurveSegments);
var renderedImageCount = chartCount == 0 ? 0 : chartCount + 1;
return Math.Max(40, runs.Count + 12 + renderedImageCount * 38);
}
@@ -999,15 +1026,18 @@ public sealed class RunExportService
worksheet.Column(8).Width = 16;
worksheet.Column(9).Width = 16;
worksheet.Column(10).Width = 14;
worksheet.Column(11).Width = 12;
worksheet.Column(12).Width = 12;
worksheet.Column(11).Width = 14;
worksheet.Column(12).Width = 14;
worksheet.Column(13).Width = 12;
worksheet.Column(14).Width = 10;
worksheet.Column(15).Width = 10;
worksheet.Column(16).Width = 24;
worksheet.Column(17).Width = 14;
worksheet.Column(18).Width = 14;
worksheet.Column(19).Width = 14;
worksheet.Column(14).Width = 12;
worksheet.Column(15).Width = 12;
worksheet.Column(16).Width = 10;
worksheet.Column(17).Width = 10;
worksheet.Column(18).Width = 24;
worksheet.Column(19).Width = 12;
worksheet.Column(20).Width = 14;
worksheet.Column(21).Width = 14;
worksheet.Column(22).Width = 14;
}
private static void ApplyReciprocatingDataColumnWidths(IXLWorksheet worksheet)
@@ -1071,11 +1101,11 @@ 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, 13).Style.NumberFormat.Format = "0.0000";
worksheet.Range(dataStartRow, 15, dataLastRow, 15).Style.NumberFormat.Format = "0";
worksheet.Range(dataStartRow, 17, dataLastRow, 18).Style.NumberFormat.Format = "0.0000";
worksheet.Range(dataStartRow, 19, dataLastRow, 19).Style.NumberFormat.Format = "0.000";
worksheet.Range(dataStartRow, 10, dataLastRow, 12).Style.Alignment.Horizontal = XLAlignmentHorizontalValues.Center;
worksheet.Range(dataStartRow, 13, dataLastRow, 15).Style.NumberFormat.Format = "0.0000";
worksheet.Range(dataStartRow, 17, dataLastRow, 17).Style.NumberFormat.Format = "0";
worksheet.Range(dataStartRow, 19, dataLastRow, 21).Style.NumberFormat.Format = "0.0000";
worksheet.Range(dataStartRow, 22, dataLastRow, 22).Style.NumberFormat.Format = "0.000";
}
private static void ApplyReciprocatingDataFormatting(IXLWorksheet worksheet, int dataStartRow, int dataLastRow)
@@ -1348,6 +1378,11 @@ public sealed class RunExportService
return $"第 {item.Data.Run.RunIndex} 次曲线 - {item.Data.Recipe.BatchNumber}";
}
private static string BuildReciprocatingCurveTitle(ExportCurveData item, int reciprocatingIndex)
{
return $"第 {item.Data.Run.RunIndex} 轮 - 第 {reciprocatingIndex} 次曲线 - {item.Data.Recipe.BatchNumber}";
}
private static string BuildCurveSequenceCode(int sequence)
{
return $"曲线{sequence:D2}";
@@ -1396,6 +1431,58 @@ public sealed class RunExportService
CalculateStandardDeviation(forces));
}
private static RunReportMetrics BuildRunReportMetrics(ExportCurveData item)
{
var forceStats = CalculateForceStatistics(item.ValidSamples);
var staticCoefficientValues = GetFiniteNullableValues(item.Data.ReciprocatingRecords.Select(record => record.StaticCoefficient));
var kineticCoefficientValues = GetFiniteNullableValues(item.Data.ReciprocatingRecords.Select(record => record.KineticCoefficient));
var staticCoefficient = ResolveReportValue(item.Data.Run.StaticCoefficient, staticCoefficientValues);
var kineticCoefficient = ResolveReportValue(item.Data.Run.KineticCoefficient, kineticCoefficientValues);
double[] standardDeviationFallback = staticCoefficientValues.Length > 1
? [CalculateStandardDeviation(staticCoefficientValues)]
: [];
return new RunReportMetrics(
staticCoefficient,
kineticCoefficient,
ResolveReportValue(item.Data.Run.StandardDeviation, standardDeviationFallback),
ResolveReportValue(item.Data.Run.PeakForceN, [forceStats.Max]),
ResolveReportValue(item.Data.Run.AverageForceN, [forceStats.Average]),
forceStats);
}
private static double ResolveReportValue(double runValue, IEnumerable<double> fallbackValues)
{
if (IsUsableReportValue(runValue))
{
return runValue;
}
var values = fallbackValues.Where(IsUsableReportValue).ToArray();
return values.Length == 0 ? 0 : values.Average();
}
private static double ResolveNullableReportValue(double? value, double fallback)
{
return value is { } number && IsFinite(number)
? number
: fallback;
}
private static bool IsUsableReportValue(double value)
{
return IsFinite(value) && Math.Abs(value) > 0.0000001;
}
private static double[] GetFiniteNullableValues(IEnumerable<double?> values)
{
return values
.Where(value => value is { } number && IsFinite(number))
.Select(value => value!.Value)
.ToArray();
}
private static double CalculateStandardDeviation(IEnumerable<double> values)
{
var samples = values.ToArray();
@@ -1414,6 +1501,115 @@ public sealed class RunExportService
return item.Data.ReciprocatingRecords.Count(record => record.HasData);
}
private static int CountReciprocatingCurveSegments(ExportCurveData item)
{
return BuildReciprocatingSampleGroups(item).Count(group => group.Samples.Count >= 2);
}
private static IReadOnlyList<ReciprocatingSampleGroup> BuildReciprocatingSampleGroups(ExportCurveData item)
{
var samples = item.ValidSamples
.OrderBy(sample => sample.SampleIndex)
.ToArray();
if (samples.Length == 0)
{
return [];
}
if (samples.Any(sample => sample.ReciprocatingIndex > 0))
{
return NormalizeReciprocatingGroups(samples
.GroupBy(sample => Math.Max(1, sample.ReciprocatingIndex))
.OrderBy(group => group.Key)
.Select(group => new ReciprocatingSampleGroup(group.Key, group.ToArray()))
.ToArray());
}
var expectedCount = ResolveExpectedReciprocatingCount(item);
if (expectedCount <= 1)
{
return [new ReciprocatingSampleGroup(1, samples)];
}
var groups = new List<ReciprocatingSampleGroup>();
for (var index = 1; index <= expectedCount; index++)
{
var start = (int)Math.Floor((index - 1) * samples.Length / (double)expectedCount);
var end = (int)Math.Floor(index * samples.Length / (double)expectedCount);
if (index == expectedCount)
{
end = samples.Length;
}
if (end <= start)
{
continue;
}
groups.Add(new ReciprocatingSampleGroup(index, samples[start..end]));
}
return NormalizeReciprocatingGroups(groups);
}
private static IReadOnlyList<ReciprocatingSampleGroup> NormalizeReciprocatingGroups(IReadOnlyList<ReciprocatingSampleGroup> groups)
{
if (groups.Any(group => group.Samples.Count >= 2))
{
return groups;
}
var samples = groups.SelectMany(group => group.Samples).OrderBy(sample => sample.SampleIndex).ToArray();
return samples.Length >= 2
? [new ReciprocatingSampleGroup(1, samples)]
: groups;
}
private static int ResolveExpectedReciprocatingCount(ExportCurveData item)
{
var recordMax = item.Data.ReciprocatingRecords
.Where(record => record.HasData)
.Select(record => record.Index)
.DefaultIfEmpty(0)
.Max();
return Math.Max(1, Math.Max(item.Data.Recipe.ReciprocatingCount, recordMax));
}
private static int ResolveSampleReciprocatingIndex(ExportCurveData item, RawSampleRecord sample)
{
if (sample.ReciprocatingIndex > 0)
{
return sample.ReciprocatingIndex;
}
var expectedCount = ResolveExpectedReciprocatingCount(item);
if (expectedCount <= 1 || item.ValidSamples.Count == 0)
{
return 1;
}
var firstSampleIndex = item.ValidSamples[0].SampleIndex;
var samplePosition = Math.Clamp(sample.SampleIndex - firstSampleIndex, 0, item.ValidSamples.Count - 1);
return Math.Clamp((int)Math.Floor(samplePosition * expectedCount / (double)item.ValidSamples.Count) + 1, 1, expectedCount);
}
private static double ResolveSamplesDurationSeconds(IReadOnlyList<RawSampleRecord> samples)
{
return samples.Count == 0
? 0
: CalculateElapsedSeconds(samples[0].CapturedAt, samples[^1].CapturedAt);
}
private static double CalculateElapsedSeconds(DateTime firstCapturedAt, DateTime capturedAt)
{
if (firstCapturedAt == DateTime.MinValue)
{
return 0;
}
return Math.Max(0, (capturedAt - firstCapturedAt).TotalSeconds);
}
private static void WriteReciprocatingMetadataCells(IXLWorksheet worksheet, int row, ExportCurveData item)
{
worksheet.Cell(row, 1).Value = BuildCurveKey(item);
@@ -1502,6 +1698,16 @@ public sealed class RunExportService
private sealed record ForceStatistics(double Min, double Max, double Average, double StandardDeviation);
private sealed record RunReportMetrics(
double StaticCoefficient,
double KineticCoefficient,
double StandardDeviation,
double PeakForceN,
double AverageForceN,
ForceStatistics ForceStats);
private sealed record ReciprocatingSampleGroup(int Index, IReadOnlyList<RawSampleRecord> Samples);
private sealed record ChartAnchor(int FromColumn, int FromRow, int ToColumn, int ToRow);
private sealed record ChartDefinition(

View File

@@ -48,6 +48,7 @@ public sealed class TestDataRepository
"id",
"run_id",
"sample_index",
"reciprocating_index",
"captured_at",
"displacement_mm",
"force_n",
@@ -160,14 +161,15 @@ public sealed class TestDataRepository
sampleCommand.CommandText =
"""
INSERT INTO raw_samples (
run_id, sample_index, captured_at, displacement_mm, force_n, speed_mm_per_min
run_id, sample_index, reciprocating_index, captured_at, displacement_mm, force_n, speed_mm_per_min
) VALUES (
$runId, $sampleIndex, $capturedAt, $displacementMm, $forceN, $speedMmPerMin
$runId, $sampleIndex, $reciprocatingIndex, $capturedAt, $displacementMm, $forceN, $speedMmPerMin
);
""";
sampleCommand.Parameters.AddWithValue("$runId", sample.RunId.ToString("N", CultureInfo.InvariantCulture));
sampleCommand.Parameters.AddWithValue("$sampleIndex", sample.SampleIndex);
sampleCommand.Parameters.AddWithValue("$reciprocatingIndex", sample.ReciprocatingIndex);
sampleCommand.Parameters.AddWithValue("$capturedAt", sample.CapturedAt.ToString("O", CultureInfo.InvariantCulture));
sampleCommand.Parameters.AddWithValue("$displacementMm", sample.DisplacementMm);
sampleCommand.Parameters.AddWithValue("$forceN", sample.ForceN);
@@ -396,7 +398,7 @@ public sealed class TestDataRepository
using var command = connection.CreateCommand();
command.CommandText =
"""
SELECT run_id, sample_index, captured_at, displacement_mm, force_n, speed_mm_per_min
SELECT run_id, sample_index, reciprocating_index, captured_at, displacement_mm, force_n, speed_mm_per_min
FROM raw_samples
WHERE run_id = $runId
ORDER BY sample_index;
@@ -411,10 +413,11 @@ public sealed class TestDataRepository
{
RunId = Guid.ParseExact(reader.GetString(0), "N"),
SampleIndex = reader.GetInt32(1),
CapturedAt = DateTime.Parse(reader.GetString(2), CultureInfo.InvariantCulture, DateTimeStyles.RoundtripKind),
DisplacementMm = reader.GetDouble(3),
ForceN = reader.GetDouble(4),
SpeedMmPerMin = reader.GetDouble(5)
ReciprocatingIndex = reader.GetInt32(2),
CapturedAt = DateTime.Parse(reader.GetString(3), CultureInfo.InvariantCulture, DateTimeStyles.RoundtripKind),
DisplacementMm = reader.GetDouble(4),
ForceN = reader.GetDouble(5),
SpeedMmPerMin = reader.GetDouble(6)
});
}
@@ -541,12 +544,13 @@ public sealed class TestDataRepository
migrateCommand.CommandText =
$"""
INSERT INTO raw_samples_new (
id, run_id, sample_index, captured_at, displacement_mm, force_n, speed_mm_per_min
id, run_id, sample_index, reciprocating_index, captured_at, displacement_mm, force_n, speed_mm_per_min
)
SELECT
{SelectColumnOrDefault(existingColumns, "id", "NULL")},
{SelectColumnOrDefault(existingColumns, "run_id", "''")},
{SelectColumnOrDefault(existingColumns, "sample_index", "0")},
{SelectColumnOrDefault(existingColumns, "reciprocating_index", "0")},
{SelectColumnOrDefault(existingColumns, "captured_at", "''")},
{SelectColumnOrDefault(existingColumns, "displacement_mm", "0")},
{SelectColumnOrDefault(existingColumns, "force_n", "0")},
@@ -695,6 +699,7 @@ public sealed class TestDataRepository
id INTEGER PRIMARY KEY AUTOINCREMENT,
run_id TEXT NOT NULL,
sample_index INTEGER NOT NULL,
reciprocating_index INTEGER NOT NULL DEFAULT 0,
captured_at TEXT NOT NULL,
displacement_mm REAL NOT NULL,
force_n REAL NOT NULL,

View File

@@ -1131,6 +1131,7 @@ public sealed class MainViewModel : ObservableObject, IDisposable
{
RunId = _activeRunId,
SampleIndex = _currentRunSamples.Count + 1,
ReciprocatingIndex = Math.Clamp(_nextReciprocatingRecordIndex, 1, Math.Max(GetConfiguredReciprocatingCount(), 1)),
CapturedAt = capturedAt,
DisplacementMm = displacementMm,
ForceN = forceN,
@@ -2474,6 +2475,7 @@ public sealed class MainViewModel : ObservableObject, IDisposable
{
RunId = sample.RunId,
SampleIndex = sample.SampleIndex,
ReciprocatingIndex = sample.ReciprocatingIndex,
CapturedAt = sample.CapturedAt,
DisplacementMm = sample.DisplacementMm,
ForceN = sample.ForceN,