1724 lines
68 KiB
C#
1724 lines
68 KiB
C#
using System.Globalization;
|
|
using System.IO;
|
|
using System.Windows;
|
|
using System.Windows.Media;
|
|
using System.Windows.Media.Imaging;
|
|
using ClosedXML.Excel;
|
|
using ClosedXML.Excel.Drawings;
|
|
using COFTester.Models;
|
|
using DocumentFormat.OpenXml;
|
|
using DocumentFormat.OpenXml.Packaging;
|
|
using A = DocumentFormat.OpenXml.Drawing;
|
|
using C = DocumentFormat.OpenXml.Drawing.Charts;
|
|
using Xdr = DocumentFormat.OpenXml.Drawing.Spreadsheet;
|
|
using S = DocumentFormat.OpenXml.Spreadsheet;
|
|
|
|
namespace COFTester.Services;
|
|
|
|
public sealed class RunExportService
|
|
{
|
|
private static readonly string[] CurvePaletteHex =
|
|
[
|
|
"#0069B4",
|
|
"#00839A",
|
|
"#C46B58",
|
|
"#C49645",
|
|
"#5E7DA0",
|
|
"#7194A3",
|
|
"#7A8C5B",
|
|
"#915F8F"
|
|
];
|
|
|
|
private readonly string _reportDirectory;
|
|
|
|
public RunExportService(string exportRootDirectory)
|
|
{
|
|
_reportDirectory = ResolveDefaultReportDirectory(exportRootDirectory);
|
|
Directory.CreateDirectory(_reportDirectory);
|
|
}
|
|
|
|
public string ExportHistoricalComparisonReport(IReadOnlyList<PersistedRunData> runs)
|
|
{
|
|
var path = Path.Combine(_reportDirectory, BuildHistoricalComparisonReportFileName(runs));
|
|
return ExportHistoricalComparisonReport(runs, path);
|
|
}
|
|
|
|
public string ExportHistoricalComparisonReport(IReadOnlyList<PersistedRunData> runs, string outputPath)
|
|
{
|
|
var exportRuns = BuildExportRuns(runs);
|
|
if (exportRuns.Length == 0)
|
|
{
|
|
throw new InvalidOperationException("未找到可导出的有效历史数据。");
|
|
}
|
|
|
|
var exportStatistics = BuildExportStatistics(runs.Count, exportRuns);
|
|
var outputDirectory = Path.GetDirectoryName(outputPath);
|
|
if (!string.IsNullOrWhiteSpace(outputDirectory))
|
|
{
|
|
Directory.CreateDirectory(outputDirectory);
|
|
}
|
|
|
|
using (var workbook = new XLWorkbook())
|
|
{
|
|
BuildSummarySheet(workbook.Worksheets.Add("报表汇总"), exportRuns, exportStatistics);
|
|
BuildRawDataSheet(workbook.Worksheets.Add("原始数据"), exportRuns);
|
|
BuildReciprocatingDataSheet(workbook.Worksheets.Add("每次数据"), exportRuns);
|
|
BuildChartSheet(workbook.Worksheets.Add("曲线图"), exportRuns);
|
|
workbook.SaveAs(outputPath);
|
|
}
|
|
|
|
ValidateWorkbook(outputPath);
|
|
return outputPath;
|
|
}
|
|
|
|
public string GetDefaultHistoricalComparisonReportDirectory()
|
|
{
|
|
return _reportDirectory;
|
|
}
|
|
|
|
private static string ResolveDefaultReportDirectory(string exportRootDirectory)
|
|
{
|
|
var desktopDirectory = Environment.GetFolderPath(Environment.SpecialFolder.DesktopDirectory);
|
|
if (!string.IsNullOrWhiteSpace(desktopDirectory))
|
|
{
|
|
return desktopDirectory;
|
|
}
|
|
|
|
return Path.Combine(exportRootDirectory, "reports");
|
|
}
|
|
|
|
private static void ValidateWorkbook(string workbookPath)
|
|
{
|
|
using var document = SpreadsheetDocument.Open(workbookPath, false);
|
|
if (document.WorkbookPart?.Workbook.Sheets is null)
|
|
{
|
|
throw new InvalidOperationException("导出的 Excel 文件缺少工作表。");
|
|
}
|
|
}
|
|
|
|
public string BuildHistoricalComparisonReportFileName(IReadOnlyList<PersistedRunData> runs)
|
|
{
|
|
var exportRuns = BuildExportRuns(runs);
|
|
var historyCount = exportRuns.Length;
|
|
var batchSummary = BuildBatchSummary(exportRuns);
|
|
return $"摩擦系数历史对比_{historyCount}轮_{batchSummary}_{DateTime.Now:yyyyMMdd-HHmmss}.xlsx";
|
|
}
|
|
|
|
private static ExportCurveData[] BuildExportRuns(IReadOnlyList<PersistedRunData> runs)
|
|
{
|
|
return runs
|
|
.OrderBy(data => data.Run.CompletedAt)
|
|
.Select(data => new
|
|
{
|
|
Data = data,
|
|
ValidSamples = data.Samples
|
|
.Where(IsValidSample)
|
|
.OrderBy(sample => sample.SampleIndex)
|
|
.ToArray()
|
|
})
|
|
.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,
|
|
chartImageCount,
|
|
Math.Max(0, sourceRunCount - exportRuns.Count),
|
|
exportRuns.Sum(item => item.ValidSamples.Count));
|
|
}
|
|
|
|
internal static bool IsValidSample(RawSampleRecord sample)
|
|
{
|
|
return sample.SampleIndex > 0
|
|
&& IsFinite(sample.DisplacementMm)
|
|
&& IsFinite(sample.ForceN);
|
|
}
|
|
|
|
private static bool IsFinite(double value)
|
|
{
|
|
return !double.IsNaN(value) && !double.IsInfinity(value);
|
|
}
|
|
|
|
private static void BuildSummarySheet(
|
|
IXLWorksheet worksheet,
|
|
IReadOnlyList<ExportCurveData> runs,
|
|
ExportReportStatistics exportStatistics)
|
|
{
|
|
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 = DateTime.Now;
|
|
worksheet.Cell("B3").Style.DateFormat.Format = "yyyy-mm-dd hh:mm:ss";
|
|
worksheet.Cell("A4").Value = "历史记录总数";
|
|
worksheet.Cell("B4").Value = exportStatistics.SourceRunCount;
|
|
worksheet.Cell("A5").Value = "有效导出记录数";
|
|
worksheet.Cell("B5").Value = exportStatistics.ExportRunCount;
|
|
worksheet.Cell("A6").Value = "曲线图数量";
|
|
worksheet.Cell("B6").Value = exportStatistics.ChartRunCount;
|
|
worksheet.Cell("A7").Value = "排除记录数";
|
|
worksheet.Cell("B7").Value = exportStatistics.ExcludedRunCount;
|
|
worksheet.Cell("A8").Value = "有效采样点总数";
|
|
worksheet.Cell("B8").Value = exportStatistics.ValidSampleCount;
|
|
worksheet.Cell("A9").Value = "汇总说明";
|
|
worksheet.Cell("B9").Value = "本表仅统计含有效采样点的试验记录;单点记录保留数据但不生成曲线图。";
|
|
worksheet.Range("B9:F9").Merge();
|
|
|
|
BuildStatisticsPanel(worksheet, runs);
|
|
|
|
var titleRow = 18;
|
|
worksheet.Cell(titleRow, 1).Value = "有效试验结果汇总";
|
|
worksheet.Cell(titleRow, 1).Style.Font.Bold = true;
|
|
worksheet.Cell(titleRow, 1).Style.Font.FontSize = 12;
|
|
|
|
var headerRow = titleRow + 1;
|
|
var headers = new[]
|
|
{
|
|
"曲线标识",
|
|
"曲线标签",
|
|
"图例标签",
|
|
"曲线颜色",
|
|
"试验轮次",
|
|
"完成时间",
|
|
"批次号",
|
|
"产品编号",
|
|
"测试模式",
|
|
"方向",
|
|
"静摩擦系数 COFs",
|
|
"动摩擦系数 COFk",
|
|
"标准差",
|
|
"标准差1",
|
|
"标准差2",
|
|
"峰值力(N)",
|
|
"稳定段均力(N)",
|
|
"有效采样点数",
|
|
"往复记录数",
|
|
"原始力最小值(N)",
|
|
"原始力最大值(N)",
|
|
"原始力均值(N)",
|
|
"原始力标准差(N)"
|
|
};
|
|
|
|
WriteHeaderRow(worksheet, headerRow, headers);
|
|
|
|
var dataStartRow = headerRow + 1;
|
|
for (var index = 0; index < runs.Count; index++)
|
|
{
|
|
var row = dataStartRow + index;
|
|
var item = runs[index];
|
|
var metrics = BuildRunReportMetrics(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 = 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 = 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;
|
|
}
|
|
|
|
var dataLastRow = Math.Max(dataStartRow, dataStartRow + runs.Count - 1);
|
|
worksheet.Range(headerRow, 1, dataLastRow, headers.Length)
|
|
.CreateTable("HistorySummary")
|
|
.Theme = XLTableTheme.TableStyleMedium2;
|
|
|
|
ApplySummaryColumnWidths(worksheet);
|
|
ApplySummaryFormatting(worksheet, dataStartRow, dataLastRow);
|
|
ApplyPageLayout(worksheet, dataLastRow, "$A$1:$W$");
|
|
worksheet.SheetView.FreezeRows(1);
|
|
}
|
|
|
|
private static void BuildRawDataSheet(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 = "每一行均为有效采样点,支持按曲线标识、轮次和批次号筛选。";
|
|
worksheet.Range("B3:G3").Merge();
|
|
|
|
var headerRow = 5;
|
|
var headers = new[]
|
|
{
|
|
"曲线标识",
|
|
"曲线标签",
|
|
"图例标签",
|
|
"曲线颜色",
|
|
"试验轮次",
|
|
"完成时间",
|
|
"批次号",
|
|
"产品编号",
|
|
"测试模式",
|
|
"方向",
|
|
"设置往复次数",
|
|
"当前往复序号",
|
|
"静摩擦系数 COFs",
|
|
"动摩擦系数 COFk",
|
|
"标准差",
|
|
"数据状态",
|
|
"采样序号",
|
|
"采样时间",
|
|
"时间(s)",
|
|
"位移(mm)",
|
|
"力值(N)",
|
|
"速度(mm/min)"
|
|
};
|
|
|
|
WriteHeaderRow(worksheet, headerRow, headers);
|
|
|
|
var rowIndex = headerRow + 1;
|
|
foreach (var item in runs)
|
|
{
|
|
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;
|
|
worksheet.Cell(rowIndex, 4).Value = item.CurveColorHex;
|
|
worksheet.Cell(rowIndex, 5).Value = item.Data.Run.RunIndex;
|
|
worksheet.Cell(rowIndex, 6).Value = item.Data.Run.CompletedAt;
|
|
worksheet.Cell(rowIndex, 6).Style.DateFormat.Format = "yyyy-mm-dd hh:mm:ss";
|
|
worksheet.Cell(rowIndex, 7).Value = item.Data.Recipe.BatchNumber;
|
|
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.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, 22).Value = sample.SpeedMmPerMin;
|
|
}
|
|
|
|
worksheet.Cell(rowIndex, 4).Style.Fill.BackgroundColor = ToXlColor(item.CurveColorHex);
|
|
worksheet.Cell(rowIndex, 4).Style.Font.FontColor = XLColor.White;
|
|
rowIndex++;
|
|
}
|
|
}
|
|
|
|
var dataLastRow = Math.Max(headerRow + 1, rowIndex - 1);
|
|
worksheet.Range(headerRow, 1, dataLastRow, headers.Length)
|
|
.CreateTable("HistorySamples")
|
|
.Theme = XLTableTheme.TableStyleMedium9;
|
|
|
|
ApplyRawDataColumnWidths(worksheet);
|
|
ApplyRawDataFormatting(worksheet, headerRow + 1, dataLastRow);
|
|
ApplyPageLayout(worksheet, dataLastRow, "$A$1:$V$");
|
|
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);
|
|
|
|
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 = "总览曲线按测试过程时间轴连续显示全部往复;每次曲线按往复序号拆分为独立图表,避免多次往复叠加在同一图中。";
|
|
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, ["曲线标识", "试验轮次", "图例标签", "颜色", "有效采样点数", "往复记录数", "曲线状态"]);
|
|
|
|
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 = 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])
|
|
? $"已记录总览曲线和 {CountReciprocatingCurveSegments(runs[index])} 张每次曲线"
|
|
: "采样点不足,保留原始数据和往复数据";
|
|
worksheet.Cell(row, 4).Style.Fill.BackgroundColor = ToXlColor(runs[index].CurveColorHex);
|
|
worksheet.Cell(row, 4).Style.Font.FontColor = XLColor.White;
|
|
}
|
|
|
|
ApplyChartColumnWidths(worksheet);
|
|
BuildChartDataArea(worksheet, runs);
|
|
InsertCurveChartImages(worksheet, runs);
|
|
ApplyPageLayout(worksheet, CalculateChartSheetLastRow(runs), "$A$1:$T$");
|
|
worksheet.SheetView.FreezeRows(1);
|
|
return new WorksheetChartLayout(worksheet.Name, []);
|
|
}
|
|
|
|
private static void BuildStatisticsPanel(IXLWorksheet worksheet, IReadOnlyList<ExportCurveData> runs)
|
|
{
|
|
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;
|
|
worksheet.Cell("H3").Style.Font.FontSize = 12;
|
|
|
|
var rows = new (string Label, string Value)[]
|
|
{
|
|
("平均静摩擦系数 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))
|
|
};
|
|
|
|
for (var index = 0; index < rows.Length; index++)
|
|
{
|
|
var row = 4 + index;
|
|
worksheet.Cell(row, 8).Value = rows[index].Label;
|
|
worksheet.Cell(row, 8).Style.Fill.BackgroundColor = XLColor.FromHtml("#2F4A5C");
|
|
worksheet.Cell(row, 8).Style.Font.Bold = true;
|
|
worksheet.Cell(row, 8).Style.Font.FontColor = XLColor.White;
|
|
worksheet.Cell(row, 9).Value = rows[index].Value;
|
|
worksheet.Cell(row, 9).Style.Border.BottomBorder = XLBorderStyleValues.Thin;
|
|
worksheet.Cell(row, 9).Style.Border.BottomBorderColor = XLColor.FromHtml("#D3DDE5");
|
|
}
|
|
}
|
|
|
|
private static WorksheetChartLayout BuildChartDataArea(IXLWorksheet worksheet, IReadOnlyList<ExportCurveData> runs)
|
|
{
|
|
const int startColumn = 30;
|
|
const int startRow = 1;
|
|
|
|
var perRunSeries = new List<PerRunSeriesLayout>();
|
|
var currentColumn = startColumn;
|
|
var sheetName = worksheet.Name;
|
|
|
|
for (var index = 0; index < runs.Count; index++)
|
|
{
|
|
var item = runs[index];
|
|
var xColumn = currentColumn;
|
|
var yColumn = xColumn + 1;
|
|
currentColumn += 3;
|
|
|
|
worksheet.Cell(startRow, xColumn).Value = BuildCurveLabel(item);
|
|
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 = CalculateElapsedSeconds(firstCapturedAt, sample.CapturedAt);
|
|
worksheet.Cell(currentRow, yColumn).Value = sample.ForceN;
|
|
currentRow++;
|
|
}
|
|
|
|
worksheet.Column(xColumn).Hide();
|
|
worksheet.Column(yColumn).Hide();
|
|
|
|
var titleRef = BuildCellReference(sheetName, startRow, xColumn);
|
|
var startDataRow = startRow + 2;
|
|
var endDataRow = Math.Max(startDataRow, currentRow - 1);
|
|
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,
|
|
BuildRangeReference(sheetName, startDataRow, xColumn, endDataRow, xColumn),
|
|
BuildRangeReference(sheetName, startDataRow, yColumn, endDataRow, yColumn),
|
|
item.CurveColorHex,
|
|
BuildCurveLabel(item),
|
|
xValues,
|
|
yValues);
|
|
|
|
if (IsChartableRun(item))
|
|
{
|
|
perRunSeries.Add(new PerRunSeriesLayout(item, layout));
|
|
}
|
|
}
|
|
|
|
var charts = new List<ChartDefinition>();
|
|
var overviewStartRow = Math.Max(10, runs.Count + 8);
|
|
const int overviewChartHeight = 13;
|
|
const int chartHeight = 11;
|
|
const int chartWidth = 19;
|
|
const int rowGap = 2;
|
|
var perRunStartRow = overviewStartRow;
|
|
|
|
if (perRunSeries.Count > 0)
|
|
{
|
|
charts.Add(new ChartDefinition(
|
|
"总览曲线(全部次数)",
|
|
"时间 (s)",
|
|
"力值 (N)",
|
|
true,
|
|
perRunSeries.Select(item => item.Series).ToArray(),
|
|
new ChartAnchor(0, overviewStartRow, chartWidth, overviewStartRow + overviewChartHeight),
|
|
"总览曲线_全部次数"));
|
|
|
|
perRunStartRow = overviewStartRow + overviewChartHeight + rowGap;
|
|
}
|
|
|
|
for (var index = 0; index < perRunSeries.Count; index++)
|
|
{
|
|
var fromColumn = 0;
|
|
var fromRow = perRunStartRow + index * (chartHeight + rowGap);
|
|
var toColumn = fromColumn + chartWidth;
|
|
var toRow = fromRow + chartHeight;
|
|
var runSeries = perRunSeries[index];
|
|
|
|
charts.Add(new ChartDefinition(
|
|
BuildSingleRunCurveTitle(runSeries.Data),
|
|
"时间 (s)",
|
|
"力值 (N)",
|
|
false,
|
|
[runSeries.Series],
|
|
new ChartAnchor(fromColumn, fromRow, toColumn, toRow),
|
|
$"每次曲线_{runSeries.Data.Sequence:D2}"));
|
|
}
|
|
|
|
return new WorksheetChartLayout(worksheet.Name, charts);
|
|
}
|
|
|
|
private static void InsertCurveChartImages(IXLWorksheet worksheet, IReadOnlyList<ExportCurveData> runs)
|
|
{
|
|
var chartableRuns = runs.Where(IsChartableRun).ToArray();
|
|
if (chartableRuns.Length == 0)
|
|
{
|
|
return;
|
|
}
|
|
|
|
var row = Math.Max(10, runs.Count + 8);
|
|
InsertCurveChartImage(
|
|
worksheet,
|
|
row,
|
|
"总览曲线(全部次数)",
|
|
"总览曲线(全部次数)",
|
|
"CurveOverview",
|
|
chartableRuns,
|
|
showLegend: true);
|
|
|
|
row += 38;
|
|
foreach (var run in chartableRuns)
|
|
{
|
|
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;
|
|
}
|
|
}
|
|
}
|
|
|
|
private static void InsertCurveChartImage(
|
|
IXLWorksheet worksheet,
|
|
int titleRow,
|
|
string sheetTitle,
|
|
string chartTitle,
|
|
string pictureName,
|
|
IReadOnlyList<ExportCurveData> runs,
|
|
bool showLegend)
|
|
{
|
|
worksheet.Cell(titleRow, 1).Value = sheetTitle;
|
|
worksheet.Cell(titleRow, 1).Style.Font.Bold = true;
|
|
worksheet.Cell(titleRow, 1).Style.Font.FontSize = 12;
|
|
worksheet.Cell(titleRow, 1).Style.Font.FontColor = XLColor.FromHtml("#21313D");
|
|
|
|
var pngBytes = RenderCurveChartPng(chartTitle, runs, showLegend);
|
|
var imageStream = new MemoryStream(pngBytes);
|
|
worksheet.AddPicture(imageStream, XLPictureFormat.Png, pictureName)
|
|
.MoveTo(worksheet.Cell(titleRow + 1, 1))
|
|
.WithSize(1060, 548);
|
|
}
|
|
|
|
private static byte[] RenderCurveChartPng(string title, IReadOnlyList<ExportCurveData> runs, bool showLegend)
|
|
{
|
|
const int width = 1280;
|
|
const int height = 660;
|
|
var visual = new DrawingVisual();
|
|
|
|
using (var drawingContext = visual.RenderOpen())
|
|
{
|
|
var backgroundBrush = CreateBrush("#F8FBFD");
|
|
var titleBrush = CreateBrush("#21313D");
|
|
var labelBrush = CreateBrush("#4A5C6C");
|
|
var axisTitleBrush = CreateBrush("#21313D");
|
|
var gridPen = CreatePen("#D1DBE3", 1);
|
|
var minorGridPen = CreatePen("#E5ECF2", 1);
|
|
var axisPen = CreatePen("#4A5C6C", 1.4);
|
|
var pointBrush = CreateBrush("#0069B4");
|
|
var pointStrokePen = CreatePen("#F7F9FB", 2.5);
|
|
|
|
drawingContext.DrawRectangle(backgroundBrush, null, new Rect(0, 0, width, height));
|
|
|
|
var plot = new Rect(98, 82, width - 140, height - 170);
|
|
var xMax = ResolveExportChartXMax(runs);
|
|
var yMax = ResolveExportChartYMax(runs);
|
|
|
|
DrawCenteredExportText(drawingContext, title, width / 2d, 24, 28, titleBrush, bold: true);
|
|
DrawExportGridAndAxes(drawingContext, plot, xMax, yMax, labelBrush, axisTitleBrush, gridPen, minorGridPen, axisPen);
|
|
|
|
for (var index = 0; index < runs.Count; index++)
|
|
{
|
|
var run = runs[index];
|
|
DrawExportCurve(drawingContext, plot, xMax, yMax, run, pointBrush, pointStrokePen);
|
|
}
|
|
|
|
if (showLegend)
|
|
{
|
|
DrawExportLegend(drawingContext, runs, labelBrush);
|
|
}
|
|
}
|
|
|
|
var target = new RenderTargetBitmap(width, height, 96, 96, PixelFormats.Pbgra32);
|
|
target.Render(visual);
|
|
|
|
var encoder = new PngBitmapEncoder();
|
|
encoder.Frames.Add(BitmapFrame.Create(target));
|
|
using var stream = new MemoryStream();
|
|
encoder.Save(stream);
|
|
return stream.ToArray();
|
|
}
|
|
|
|
private static void DrawExportGridAndAxes(
|
|
DrawingContext drawingContext,
|
|
Rect plot,
|
|
double xMax,
|
|
double yMax,
|
|
Brush labelBrush,
|
|
Brush axisTitleBrush,
|
|
Pen gridPen,
|
|
Pen minorGridPen,
|
|
Pen axisPen)
|
|
{
|
|
drawingContext.DrawRectangle(null, axisPen, plot);
|
|
|
|
const int xDivisions = 5;
|
|
for (var index = 0; index <= xDivisions; index++)
|
|
{
|
|
var x = plot.Left + plot.Width * index / xDivisions;
|
|
drawingContext.DrawLine(index == 0 ? axisPen : minorGridPen, new Point(x, plot.Top), new Point(x, plot.Bottom));
|
|
DrawCenteredExportText(
|
|
drawingContext,
|
|
(xMax * index / xDivisions).ToString("0.#", CultureInfo.InvariantCulture),
|
|
x,
|
|
plot.Bottom + 28,
|
|
17,
|
|
labelBrush);
|
|
}
|
|
|
|
const int yDivisions = 5;
|
|
for (var index = 0; index <= yDivisions; index++)
|
|
{
|
|
var y = plot.Bottom - plot.Height * index / yDivisions;
|
|
drawingContext.DrawLine(index == 0 ? axisPen : gridPen, new Point(plot.Left, y), new Point(plot.Right, y));
|
|
DrawExportText(
|
|
drawingContext,
|
|
(yMax * index / yDivisions).ToString("0.000", CultureInfo.InvariantCulture),
|
|
18,
|
|
y - 10,
|
|
17,
|
|
labelBrush);
|
|
}
|
|
|
|
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);
|
|
}
|
|
|
|
private static void DrawExportKineticBand(
|
|
DrawingContext drawingContext,
|
|
Rect plot,
|
|
double xMax,
|
|
double travelMm,
|
|
Brush fillBrush,
|
|
Pen strokePen)
|
|
{
|
|
if (!IsFinite(travelMm) || travelMm <= 0)
|
|
{
|
|
return;
|
|
}
|
|
|
|
var xStart = Math.Clamp(travelMm * 0.35, 0, xMax);
|
|
var xEnd = Math.Clamp(travelMm, 0, xMax);
|
|
if (xEnd <= xStart)
|
|
{
|
|
return;
|
|
}
|
|
|
|
var left = MapX(xStart, plot, xMax);
|
|
var right = MapX(xEnd, plot, xMax);
|
|
var rect = new Rect(left, plot.Top, right - left, plot.Height);
|
|
drawingContext.DrawRectangle(fillBrush, strokePen, rect);
|
|
}
|
|
|
|
private static void DrawExportCurve(
|
|
DrawingContext drawingContext,
|
|
Rect plot,
|
|
double xMax,
|
|
double yMax,
|
|
ExportCurveData run,
|
|
Brush pointBrush,
|
|
Pen pointStrokePen)
|
|
{
|
|
var firstCapturedAt = run.ValidSamples.Count == 0 ? DateTime.MinValue : run.ValidSamples[0].CapturedAt;
|
|
var points = run.ValidSamples
|
|
.Where(sample => IsFinite(sample.ForceN))
|
|
.Select(sample => new Point(
|
|
MapX(CalculateElapsedSeconds(firstCapturedAt, sample.CapturedAt), plot, xMax),
|
|
MapY(sample.ForceN, plot, yMax)))
|
|
.ToArray();
|
|
|
|
if (points.Length == 0)
|
|
{
|
|
return;
|
|
}
|
|
|
|
var curvePen = new Pen(CreateBrush(run.CurveColorHex), 3.2);
|
|
if (points.Length == 1)
|
|
{
|
|
drawingContext.DrawEllipse(pointBrush, pointStrokePen, points[0], 5, 5);
|
|
return;
|
|
}
|
|
|
|
var geometry = new StreamGeometry();
|
|
using (var context = geometry.Open())
|
|
{
|
|
context.BeginFigure(points[0], false, false);
|
|
for (var index = 1; index < points.Length; index++)
|
|
{
|
|
context.LineTo(points[index], true, false);
|
|
}
|
|
}
|
|
|
|
geometry.Freeze();
|
|
drawingContext.DrawGeometry(null, curvePen, geometry);
|
|
drawingContext.DrawEllipse(pointBrush, pointStrokePen, points[^1], 5, 5);
|
|
}
|
|
|
|
private static void DrawExportLegend(DrawingContext drawingContext, IReadOnlyList<ExportCurveData> runs, Brush labelBrush)
|
|
{
|
|
const double startX = 760;
|
|
var y = 31d;
|
|
for (var index = 0; index < runs.Count && index < 8; index++)
|
|
{
|
|
var x = index < 4 ? startX : startX + 250;
|
|
var rowY = y + (index % 4) * 24;
|
|
drawingContext.DrawLine(new Pen(CreateBrush(runs[index].CurveColorHex), 3), new Point(x, rowY - 6), new Point(x + 32, rowY - 6));
|
|
DrawExportText(drawingContext, BuildLegendLabel(runs[index]), x + 40, rowY - 18, 17, labelBrush);
|
|
}
|
|
}
|
|
|
|
private static double ResolveExportChartXMax(IReadOnlyList<ExportCurveData> runs)
|
|
{
|
|
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)
|
|
{
|
|
var forceMax = runs.SelectMany(item => item.ValidSamples).Select(sample => sample.ForceN).Where(IsFinite).DefaultIfEmpty(0).Max();
|
|
return Math.Max(forceMax * 1.15, 0.5);
|
|
}
|
|
|
|
private static double MapX(double x, Rect plot, double xMax)
|
|
{
|
|
return plot.Left + plot.Width * Math.Clamp(x / xMax, 0, 1);
|
|
}
|
|
|
|
private static double MapY(double y, Rect plot, double yMax)
|
|
{
|
|
return plot.Bottom - plot.Height * Math.Clamp(y / yMax, 0, 1);
|
|
}
|
|
|
|
private static void DrawCenteredExportText(
|
|
DrawingContext drawingContext,
|
|
string text,
|
|
double centerX,
|
|
double y,
|
|
double fontSize,
|
|
Brush brush,
|
|
bool bold = false)
|
|
{
|
|
var formattedText = CreateFormattedText(text, fontSize, brush, bold);
|
|
drawingContext.DrawText(formattedText, new Point(centerX - formattedText.Width / 2, y));
|
|
}
|
|
|
|
private static void DrawExportText(
|
|
DrawingContext drawingContext,
|
|
string text,
|
|
double x,
|
|
double y,
|
|
double fontSize,
|
|
Brush brush,
|
|
bool bold = false)
|
|
{
|
|
drawingContext.DrawText(CreateFormattedText(text, fontSize, brush, bold), new Point(x, y));
|
|
}
|
|
|
|
private static FormattedText CreateFormattedText(string text, double fontSize, Brush brush, bool bold)
|
|
{
|
|
return new FormattedText(
|
|
text,
|
|
CultureInfo.CurrentCulture,
|
|
FlowDirection.LeftToRight,
|
|
new Typeface(
|
|
new FontFamily("Microsoft YaHei UI"),
|
|
FontStyles.Normal,
|
|
bold ? FontWeights.Bold : FontWeights.Normal,
|
|
FontStretches.Normal),
|
|
fontSize,
|
|
brush,
|
|
1);
|
|
}
|
|
|
|
private static Brush CreateBrush(string hex)
|
|
{
|
|
var brush = new SolidColorBrush(ToWpfColor(hex));
|
|
brush.Freeze();
|
|
return brush;
|
|
}
|
|
|
|
private static Pen CreatePen(string hex, double thickness)
|
|
{
|
|
var pen = new Pen(CreateBrush(hex), thickness);
|
|
pen.Freeze();
|
|
return pen;
|
|
}
|
|
|
|
private static Color ToWpfColor(string hex)
|
|
{
|
|
return ColorConverter.ConvertFromString(hex) is Color color
|
|
? color
|
|
: Color.FromRgb(0, 105, 180);
|
|
}
|
|
|
|
private static bool IsChartableRun(ExportCurveData item)
|
|
{
|
|
return item.ValidSamples.Count >= 2;
|
|
}
|
|
|
|
private static int CalculateChartSheetLastRow(IReadOnlyList<ExportCurveData> runs)
|
|
{
|
|
var chartCount = runs.Where(IsChartableRun).Sum(CountReciprocatingCurveSegments);
|
|
var renderedImageCount = chartCount == 0 ? 0 : chartCount + 1;
|
|
return Math.Max(40, runs.Count + 12 + renderedImageCount * 38);
|
|
}
|
|
|
|
private static void ApplyDefaultWorksheetStyle(IXLWorksheet worksheet)
|
|
{
|
|
worksheet.Style.Font.FontName = "Microsoft YaHei UI";
|
|
worksheet.Style.Font.FontSize = 10.5;
|
|
worksheet.Style.Alignment.Vertical = XLAlignmentVerticalValues.Center;
|
|
worksheet.SheetView.ZoomScale = 90;
|
|
}
|
|
|
|
private static void WriteHeaderRow(IXLWorksheet worksheet, int row, IReadOnlyList<string> headers)
|
|
{
|
|
for (var index = 0; index < headers.Count; index++)
|
|
{
|
|
var cell = worksheet.Cell(row, index + 1);
|
|
cell.Value = headers[index];
|
|
cell.Style.Font.Bold = true;
|
|
cell.Style.Font.FontColor = XLColor.White;
|
|
cell.Style.Fill.BackgroundColor = XLColor.FromHtml("#2F4A5C");
|
|
cell.Style.Border.BottomBorder = XLBorderStyleValues.Thin;
|
|
cell.Style.Border.BottomBorderColor = XLColor.FromHtml("#1F3342");
|
|
}
|
|
}
|
|
|
|
private static void ApplySummaryColumnWidths(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 = 12;
|
|
worksheet.Column(12).Width = 12;
|
|
worksheet.Column(13).Width = 12;
|
|
worksheet.Column(14).Width = 12;
|
|
worksheet.Column(15).Width = 12;
|
|
worksheet.Column(16).Width = 14;
|
|
worksheet.Column(17).Width = 14;
|
|
worksheet.Column(18).Width = 12;
|
|
worksheet.Column(19).Width = 14;
|
|
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)
|
|
{
|
|
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 = 14;
|
|
worksheet.Column(13).Width = 12;
|
|
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)
|
|
{
|
|
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++)
|
|
{
|
|
worksheet.Column(column).Width = column switch
|
|
{
|
|
1 => 24,
|
|
2 => 28,
|
|
3 => 12,
|
|
4 => 12,
|
|
5 => 14,
|
|
6 => 14,
|
|
7 => 32,
|
|
_ => 14
|
|
};
|
|
}
|
|
}
|
|
|
|
private static void ApplySummaryFormatting(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, 10).Style.Alignment.Horizontal = XLAlignmentHorizontalValues.Center;
|
|
worksheet.Range(dataStartRow, 11, dataLastRow, 17).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)
|
|
{
|
|
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, 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)
|
|
{
|
|
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;
|
|
worksheet.PageSetup.PaperSize = XLPaperSize.A4Paper;
|
|
worksheet.PageSetup.Margins.Top = 0.35;
|
|
worksheet.PageSetup.Margins.Bottom = 0.35;
|
|
worksheet.PageSetup.Margins.Left = 0.28;
|
|
worksheet.PageSetup.Margins.Right = 0.28;
|
|
worksheet.PageSetup.CenterHorizontally = true;
|
|
worksheet.PageSetup.SetRowsToRepeatAtTop(1, 2);
|
|
worksheet.PageSetup.PagesWide = 1;
|
|
worksheet.PageSetup.PagesTall = 0;
|
|
worksheet.PageSetup.PrintAreas.Clear();
|
|
worksheet.PageSetup.PrintAreas.Add($"{printAreaPrefix}{Math.Max(lastDataRow, 40)}");
|
|
}
|
|
|
|
private static void InsertNativeCharts(string workbookPath, WorksheetChartLayout layout)
|
|
{
|
|
using var document = SpreadsheetDocument.Open(workbookPath, true);
|
|
var workbookPart = document.WorkbookPart ?? throw new InvalidOperationException("WorkbookPart not found.");
|
|
var sheet = workbookPart.Workbook.Sheets?.Elements<S.Sheet>()
|
|
.FirstOrDefault(item => string.Equals(item.Name?.Value, layout.WorksheetName, StringComparison.Ordinal));
|
|
if (sheet is null)
|
|
{
|
|
throw new InvalidOperationException($"Worksheet {layout.WorksheetName} not found.");
|
|
}
|
|
|
|
var worksheetPart = (WorksheetPart)workbookPart.GetPartById(sheet.Id!);
|
|
var drawingsPart = worksheetPart.DrawingsPart ?? worksheetPart.AddNewPart<DrawingsPart>();
|
|
|
|
if (worksheetPart.Worksheet.Elements<S.Drawing>().FirstOrDefault() is null)
|
|
{
|
|
var drawingId = worksheetPart.GetIdOfPart(drawingsPart);
|
|
worksheetPart.Worksheet.Append(new S.Drawing { Id = drawingId });
|
|
worksheetPart.Worksheet.Save();
|
|
}
|
|
|
|
drawingsPart.WorksheetDrawing = new Xdr.WorksheetDrawing();
|
|
|
|
foreach (var chartDefinition in layout.Charts)
|
|
{
|
|
var chartPart = drawingsPart.AddNewPart<ChartPart>();
|
|
BuildChartPart(chartPart, chartDefinition);
|
|
var chartRelationshipId = drawingsPart.GetIdOfPart(chartPart);
|
|
AppendChartAnchor(drawingsPart.WorksheetDrawing, chartRelationshipId, chartDefinition.Anchor, chartDefinition.ShapeName);
|
|
}
|
|
|
|
drawingsPart.WorksheetDrawing.Save();
|
|
}
|
|
|
|
private static void BuildChartPart(ChartPart chartPart, ChartDefinition chartDefinition)
|
|
{
|
|
const uint xAxisId = 48650112U;
|
|
const uint valueAxisId = 48672768U;
|
|
|
|
var chartSpace = new C.ChartSpace();
|
|
chartSpace.Append(new C.EditingLanguage { Val = "zh-CN" });
|
|
|
|
var chart = new C.Chart();
|
|
chart.Append(CreateChartTitle(chartDefinition.Title));
|
|
chart.Append(new C.AutoTitleDeleted { Val = false });
|
|
|
|
var plotArea = new C.PlotArea();
|
|
plotArea.Append(new C.Layout());
|
|
|
|
var scatterChart = new C.ScatterChart(
|
|
new C.ScatterStyle { Val = C.ScatterStyleValues.Line },
|
|
new C.VaryColors { Val = false });
|
|
|
|
for (var index = 0; index < chartDefinition.Series.Count; index++)
|
|
{
|
|
var series = chartDefinition.Series[index];
|
|
var colorHex = series.ColorHex.TrimStart('#');
|
|
var chartShape = new C.ChartShapeProperties(
|
|
new A.Outline(
|
|
new A.SolidFill(new A.RgbColorModelHex { Val = colorHex }),
|
|
new A.PresetDash { Val = A.PresetLineDashValues.Solid })
|
|
{
|
|
Width = 19050
|
|
});
|
|
|
|
var scatterSeries = new C.ScatterChartSeries(
|
|
new C.Index { Val = (uint)index },
|
|
new C.Order { Val = (uint)index },
|
|
new C.SeriesText(CreateStringReference(series.TitleReference, series.TitleText)),
|
|
chartShape,
|
|
new C.Marker(new C.Symbol { Val = C.MarkerStyleValues.None }),
|
|
new C.XValues(CreateNumberReference(series.XValuesReference, series.XValues)),
|
|
new C.YValues(CreateNumberReference(series.YValuesReference, series.YValues)),
|
|
new C.Smooth { Val = false });
|
|
|
|
scatterChart.Append(scatterSeries);
|
|
}
|
|
|
|
scatterChart.Append(new C.AxisId { Val = xAxisId });
|
|
scatterChart.Append(new C.AxisId { Val = valueAxisId });
|
|
plotArea.Append(scatterChart);
|
|
|
|
var xAxis = new C.ValueAxis(
|
|
new C.AxisId { Val = xAxisId },
|
|
new C.Scaling(new C.Orientation { Val = C.OrientationValues.MinMax }),
|
|
new C.Delete { Val = false },
|
|
new C.AxisPosition { Val = C.AxisPositionValues.Bottom },
|
|
new C.NumberingFormat { FormatCode = "0.0", SourceLinked = true },
|
|
CreateAxisTitle(chartDefinition.XAxisTitle),
|
|
new C.TickLabelPosition { Val = C.TickLabelPositionValues.NextTo },
|
|
new C.CrossingAxis { Val = valueAxisId },
|
|
new C.Crosses { Val = C.CrossesValues.AutoZero },
|
|
new C.CrossBetween { Val = C.CrossBetweenValues.Between });
|
|
|
|
var valueAxis = new C.ValueAxis(
|
|
new C.AxisId { Val = valueAxisId },
|
|
new C.Scaling(new C.Orientation { Val = C.OrientationValues.MinMax }),
|
|
new C.Delete { Val = false },
|
|
new C.AxisPosition { Val = C.AxisPositionValues.Left },
|
|
new C.MajorGridlines(),
|
|
new C.NumberingFormat { FormatCode = "0.000", SourceLinked = true },
|
|
CreateAxisTitle(chartDefinition.YAxisTitle),
|
|
new C.TickLabelPosition { Val = C.TickLabelPositionValues.NextTo },
|
|
new C.CrossingAxis { Val = xAxisId },
|
|
new C.Crosses { Val = C.CrossesValues.AutoZero },
|
|
new C.CrossBetween { Val = C.CrossBetweenValues.Between });
|
|
|
|
plotArea.Append(xAxis);
|
|
plotArea.Append(valueAxis);
|
|
|
|
chart.Append(plotArea);
|
|
if (chartDefinition.ShowLegend)
|
|
{
|
|
chart.Append(new C.Legend(
|
|
new C.LegendPosition { Val = C.LegendPositionValues.Bottom },
|
|
new C.Layout()));
|
|
}
|
|
|
|
chart.Append(new C.PlotVisibleOnly { Val = true });
|
|
chart.Append(new C.DisplayBlanksAs { Val = C.DisplayBlanksAsValues.Gap });
|
|
chart.Append(new C.ShowDataLabelsOverMaximum { Val = false });
|
|
|
|
chartSpace.Append(chart);
|
|
chartPart.ChartSpace = chartSpace;
|
|
chartPart.ChartSpace.Save();
|
|
}
|
|
|
|
private static C.StringReference CreateStringReference(string formula, string value)
|
|
{
|
|
var stringCache = new C.StringCache(new C.PointCount { Val = 1U });
|
|
stringCache.Append(new C.StringPoint(new C.NumericValue(value)) { Index = 0U });
|
|
return new C.StringReference(new C.Formula(formula), stringCache);
|
|
}
|
|
|
|
private static C.NumberReference CreateNumberReference(string formula, IReadOnlyList<double> values)
|
|
{
|
|
var numberingCache = new C.NumberingCache(new C.FormatCode("General"), new C.PointCount { Val = (uint)values.Count });
|
|
for (var index = 0; index < values.Count; index++)
|
|
{
|
|
numberingCache.Append(new C.NumericPoint(
|
|
new C.NumericValue(values[index].ToString("G17", CultureInfo.InvariantCulture)))
|
|
{
|
|
Index = (uint)index
|
|
});
|
|
}
|
|
|
|
return new C.NumberReference(new C.Formula(formula), numberingCache);
|
|
}
|
|
|
|
private static void AppendChartAnchor(Xdr.WorksheetDrawing worksheetDrawing, string chartRelationshipId, ChartAnchor anchor, string shapeName)
|
|
{
|
|
var graphicFrameId = worksheetDrawing.Descendants<Xdr.NonVisualDrawingProperties>()
|
|
.Select(item => item.Id?.Value ?? 0U)
|
|
.DefaultIfEmpty(1U)
|
|
.Max() + 1U;
|
|
|
|
var twoCellAnchor = new Xdr.TwoCellAnchor(
|
|
new Xdr.FromMarker(
|
|
new Xdr.ColumnId(anchor.FromColumn.ToString(CultureInfo.InvariantCulture)),
|
|
new Xdr.ColumnOffset("0"),
|
|
new Xdr.RowId(anchor.FromRow.ToString(CultureInfo.InvariantCulture)),
|
|
new Xdr.RowOffset("0")),
|
|
new Xdr.ToMarker(
|
|
new Xdr.ColumnId(anchor.ToColumn.ToString(CultureInfo.InvariantCulture)),
|
|
new Xdr.ColumnOffset("0"),
|
|
new Xdr.RowId(anchor.ToRow.ToString(CultureInfo.InvariantCulture)),
|
|
new Xdr.RowOffset("0")),
|
|
new Xdr.GraphicFrame(
|
|
new Xdr.NonVisualGraphicFrameProperties(
|
|
new Xdr.NonVisualDrawingProperties { Id = graphicFrameId, Name = shapeName },
|
|
new Xdr.NonVisualGraphicFrameDrawingProperties()),
|
|
new Xdr.Transform(
|
|
new A.Offset { X = 0L, Y = 0L },
|
|
new A.Extents { Cx = 0L, Cy = 0L }),
|
|
new A.Graphic(
|
|
new A.GraphicData(
|
|
new C.ChartReference { Id = chartRelationshipId })
|
|
{
|
|
Uri = "http://schemas.openxmlformats.org/drawingml/2006/chart"
|
|
}))
|
|
{
|
|
Macro = string.Empty
|
|
},
|
|
new Xdr.ClientData());
|
|
|
|
worksheetDrawing.Append(twoCellAnchor);
|
|
}
|
|
|
|
private static C.Title CreateChartTitle(string text)
|
|
{
|
|
var richText = new C.RichText(
|
|
new A.BodyProperties(),
|
|
new A.ListStyle(),
|
|
new A.Paragraph(
|
|
new A.Run(
|
|
new A.RunProperties { Language = "zh-CN" },
|
|
new A.Text(text)),
|
|
new A.EndParagraphRunProperties { Language = "zh-CN" }));
|
|
|
|
return new C.Title(
|
|
new C.ChartText(richText),
|
|
new C.Layout(),
|
|
new C.Overlay { Val = false });
|
|
}
|
|
|
|
private static C.Title CreateAxisTitle(string text)
|
|
{
|
|
var richText = new C.RichText(
|
|
new A.BodyProperties(),
|
|
new A.ListStyle(),
|
|
new A.Paragraph(
|
|
new A.Run(
|
|
new A.RunProperties { Language = "zh-CN" },
|
|
new A.Text(text)),
|
|
new A.EndParagraphRunProperties { Language = "zh-CN" }));
|
|
|
|
return new C.Title(
|
|
new C.ChartText(richText),
|
|
new C.Layout(),
|
|
new C.Overlay { Val = false });
|
|
}
|
|
|
|
private static string BuildCurveKey(ExportCurveData item)
|
|
{
|
|
return $"{BuildCurveSequenceCode(item.Sequence)}_{item.Data.Run.CompletedAt:yyyyMMddHHmmss}_R{item.Data.Run.RunIndex:D2}_{SanitizeFileName(item.Data.Recipe.BatchNumber)}";
|
|
}
|
|
|
|
private static string BuildCurveLabel(ExportCurveData item)
|
|
{
|
|
return $"{BuildCurveSequenceCode(item.Sequence)} | R{item.Data.Run.RunIndex:D2} | {item.Data.Recipe.BatchNumber}";
|
|
}
|
|
|
|
private static string BuildLegendLabel(ExportCurveData item)
|
|
{
|
|
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 BuildReciprocatingCurveTitle(ExportCurveData item, int reciprocatingIndex)
|
|
{
|
|
return $"第 {item.Data.Run.RunIndex} 轮 - 第 {reciprocatingIndex} 次曲线 - {item.Data.Recipe.BatchNumber}";
|
|
}
|
|
|
|
private static string BuildCurveSequenceCode(int sequence)
|
|
{
|
|
return $"曲线{sequence:D2}";
|
|
}
|
|
|
|
private static string BuildBatchSummary(IReadOnlyList<ExportCurveData> runs)
|
|
{
|
|
if (runs.Count == 0)
|
|
{
|
|
return "无有效批次";
|
|
}
|
|
|
|
var distinctBatches = runs
|
|
.Select(item => SanitizeFileName(item.Data.Recipe.BatchNumber))
|
|
.Where(batch => !string.IsNullOrWhiteSpace(batch))
|
|
.Distinct(StringComparer.Ordinal)
|
|
.Take(3)
|
|
.ToArray();
|
|
|
|
return distinctBatches.Length == 0 ? "未标记批次" : string.Join("-", distinctBatches);
|
|
}
|
|
|
|
private static string GetCurveColorHex(int index)
|
|
{
|
|
return CurvePaletteHex[index % CurvePaletteHex.Length];
|
|
}
|
|
|
|
private static XLColor ToXlColor(string hex)
|
|
{
|
|
return XLColor.FromHtml(hex);
|
|
}
|
|
|
|
private static ForceStatistics CalculateForceStatistics(IReadOnlyList<RawSampleRecord> samples)
|
|
{
|
|
if (samples.Count == 0)
|
|
{
|
|
return new ForceStatistics(0, 0, 0, 0);
|
|
}
|
|
|
|
var forces = samples.Select(sample => sample.ForceN).ToArray();
|
|
var average = forces.Average();
|
|
return new ForceStatistics(
|
|
forces.Min(),
|
|
forces.Max(),
|
|
average,
|
|
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();
|
|
if (samples.Length <= 1)
|
|
{
|
|
return 0;
|
|
}
|
|
|
|
var mean = samples.Average();
|
|
var variance = samples.Sum(value => Math.Pow(value - mean, 2)) / samples.Length;
|
|
return Math.Sqrt(variance);
|
|
}
|
|
|
|
private static int CountReciprocatingRecords(ExportCurveData item)
|
|
{
|
|
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);
|
|
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}";
|
|
}
|
|
|
|
private static string BuildRangeReference(string sheetName, int startRow, int startColumn, int endRow, int endColumn)
|
|
{
|
|
return $"{QuoteSheetName(sheetName)}!${GetColumnName(startColumn)}${startRow}:${GetColumnName(endColumn)}${endRow}";
|
|
}
|
|
|
|
private static string QuoteSheetName(string sheetName)
|
|
{
|
|
return $"'{sheetName.Replace("'", "''", StringComparison.Ordinal)}'";
|
|
}
|
|
|
|
private static string GetColumnName(int columnNumber)
|
|
{
|
|
var dividend = columnNumber;
|
|
var columnName = string.Empty;
|
|
|
|
while (dividend > 0)
|
|
{
|
|
var modulo = (dividend - 1) % 26;
|
|
columnName = Convert.ToChar(65 + modulo) + columnName;
|
|
dividend = (dividend - modulo) / 26;
|
|
}
|
|
|
|
return columnName;
|
|
}
|
|
|
|
private static string SanitizeFileName(string value)
|
|
{
|
|
foreach (var character in Path.GetInvalidFileNameChars())
|
|
{
|
|
value = value.Replace(character, '_');
|
|
}
|
|
|
|
return value;
|
|
}
|
|
|
|
private sealed record ExportCurveData(PersistedRunData Data, IReadOnlyList<RawSampleRecord> ValidSamples, int Sequence, string CurveColorHex);
|
|
|
|
private sealed record ExportReportStatistics(
|
|
int SourceRunCount,
|
|
int ExportRunCount,
|
|
int ChartRunCount,
|
|
int ExcludedRunCount,
|
|
int ValidSampleCount);
|
|
|
|
private sealed record SeriesLayout(
|
|
string TitleReference,
|
|
string XValuesReference,
|
|
string YValuesReference,
|
|
string ColorHex,
|
|
string TitleText,
|
|
IReadOnlyList<double> XValues,
|
|
IReadOnlyList<double> YValues);
|
|
|
|
private sealed record PerRunSeriesLayout(ExportCurveData Data, SeriesLayout Series);
|
|
|
|
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(
|
|
string Title,
|
|
string XAxisTitle,
|
|
string YAxisTitle,
|
|
bool ShowLegend,
|
|
IReadOnlyList<SeriesLayout> Series,
|
|
ChartAnchor Anchor,
|
|
string ShapeName);
|
|
|
|
private sealed record WorksheetChartLayout(string WorksheetName, IReadOnlyList<ChartDefinition> Charts);
|
|
}
|