diff --git a/COFTester/Models/RawSampleRecord.cs b/COFTester/Models/RawSampleRecord.cs index 635270f..c662092 100644 --- a/COFTester/Models/RawSampleRecord.cs +++ b/COFTester/Models/RawSampleRecord.cs @@ -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; } diff --git a/COFTester/Services/RunExportService.cs b/COFTester/Services/RunExportService.cs index 22826ec..ac9f97d 100644 --- a/COFTester/Services/RunExportService.cs +++ b/COFTester/Services/RunExportService.cs @@ -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 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 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 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 runs) @@ -933,7 +960,7 @@ public sealed class RunExportService private static int CalculateChartSheetLastRow(IReadOnlyList 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 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 values) + { + return values + .Where(value => value is { } number && IsFinite(number)) + .Select(value => value!.Value) + .ToArray(); + } + private static double CalculateStandardDeviation(IEnumerable 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 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(); + 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 NormalizeReciprocatingGroups(IReadOnlyList 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 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 Samples); + private sealed record ChartAnchor(int FromColumn, int FromRow, int ToColumn, int ToRow); private sealed record ChartDefinition( diff --git a/COFTester/Services/TestDataRepository.cs b/COFTester/Services/TestDataRepository.cs index 90d6d4d..9458b7b 100644 --- a/COFTester/Services/TestDataRepository.cs +++ b/COFTester/Services/TestDataRepository.cs @@ -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, diff --git a/COFTester/ViewModels/MainViewModel.cs b/COFTester/ViewModels/MainViewModel.cs index a6095b8..fc97c82 100644 --- a/COFTester/ViewModels/MainViewModel.cs +++ b/COFTester/ViewModels/MainViewModel.cs @@ -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,