Files
COFTester/COFTester/Services/RunExportService.cs
GukSang.Jin ceaf8b3050 更新
2026-05-15 09:47:22 +08:00

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);
}