From 820bc1c2bcef49c922bed9fe044e04a4eda28fc6 Mon Sep 17 00:00:00 2001 From: Scottxjw <13374147+scottxjw@user.noreply.gitee.com> Date: Tue, 25 Aug 2026 11:29:45 +0800 Subject: [PATCH] =?UTF-8?q?feat(analysis):=20=E6=B7=BB=E5=8A=A0Excel?= =?UTF-8?q?=E6=8A=A5=E5=91=8A=E5=AF=BC=E5=87=BA=E5=8A=9F=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.8 (1M context) --- .../Analysis/PlossAnalysisService.cs | 170 +++++++- .../Application/Analysis/PlossExcelReport.cs | 44 +++ .../Analysis/QfodCalibrationService.cs | 136 ++++++- .../Application/Analysis/QfodExcelReport.cs | 50 +++ .../Exporting/AnalyzeExcelExporter.cs | 363 ++++++++++++++++++ 5 files changed, 754 insertions(+), 9 deletions(-) create mode 100644 src/Gpulse.WCT.DataAnalyzer.Core/Application/Analysis/PlossExcelReport.cs create mode 100644 src/Gpulse.WCT.DataAnalyzer.Core/Application/Analysis/QfodExcelReport.cs create mode 100644 src/Gpulse.WCT.DataAnalyzer.Core/Application/Exporting/AnalyzeExcelExporter.cs diff --git a/src/Gpulse.WCT.DataAnalyzer.Core/Application/Analysis/PlossAnalysisService.cs b/src/Gpulse.WCT.DataAnalyzer.Core/Application/Analysis/PlossAnalysisService.cs index 0724a2d..0092ec4 100644 --- a/src/Gpulse.WCT.DataAnalyzer.Core/Application/Analysis/PlossAnalysisService.cs +++ b/src/Gpulse.WCT.DataAnalyzer.Core/Application/Analysis/PlossAnalysisService.cs @@ -12,13 +12,171 @@ public class PlossAnalysisService { private readonly PlossParser _plossParser; + /// + /// 功率段列表 (与模板一致) + /// + private static readonly int[] PowerSegments = [350, 500, 750, 1000, 1250, 1500, 1750, 2000, 2250]; + + /// + /// 标定偏移值 a(按功率段) + /// 标定Δp = 理论Δp最大值 + a + /// + private static readonly Dictionary CalibrationOffsetByPower = new() + { + { 350, 3000 }, + { 500, 1000 }, + { 750, 1200 }, + { 1000, 1500 }, + { 1250, 1500 }, + { 1500, 2000 }, + { 1750, 2500 }, + { 2000, 2500 }, + { 2250, 3000 } + }; + public PlossAnalysisService(PlossParser plossParser) { _plossParser = plossParser; } /// - /// 分析单个 Ploss 日志文件 + /// 分析单个 Ploss 日志文件(生成 Excel 报告) + /// + public async Task AnalyzeForExcelAsync(string filePath) + { + var fileInfo = new FileInfo(filePath); + + if (!fileInfo.Exists) + { + return new PlossExcelReport( + FileName: fileInfo.Name, + StatisticsByPowerSegment: new Dictionary(), + TotalCount: 0, + DroppedCount: 0, + ErrorCount: 0, + ErrorMessage: "文件不存在" + ); + } + + var lines = await File.ReadAllLinesAsync(filePath); + var records = new List(); + int errorCount = 0; + + // 用于暂存两行格式的 header 行 + string? pendingHeaderLine = null; + + for (int i = 0; i < lines.Length; i++) + { + var line = lines[i].Trim(); + if (string.IsNullOrEmpty(line)) continue; + + if (_plossParser.IsMultiLineStart(line)) + { + pendingHeaderLine = line; + } + else if (_plossParser.CanParse(line)) + { + if (pendingHeaderLine != null) + { + // 两行格式:合并解析 + var result = _plossParser.ParseMultiLine( + new[] { pendingHeaderLine, line }, Guid.Empty); + + if (result is { IsSuccess: true } && result.Record != null) + records.Add(result.Record); + else + errorCount++; + + pendingHeaderLine = null; + } + else + { + // 缺少 header 行,记录错误 + errorCount++; + } + } + } + + // 处理末尾遗留的 header 行(异常情况) + if (pendingHeaderLine != null) + { + errorCount++; + } + + // 按 Field10(功率段)分组并计算统计 + // 口径:实体语义 Field9=ploss、Field10=threshold;真实日志中 threshold 值恰为功率档位 (350/500/…),故按 Field10 分桶。 + var matched = records.Where(r => PowerSegments.Contains(r.Field10)).ToList(); + int droppedCount = records.Count - matched.Count; + + var statisticsByPowerSegment = matched + .GroupBy(r => r.Field10) + .ToDictionary( + g => g.Key, + g => CalculateStatistics(g.ToList()) + ); + + return new PlossExcelReport( + FileName: fileInfo.Name, + StatisticsByPowerSegment: statisticsByPowerSegment, + TotalCount: records.Count, + DroppedCount: droppedCount, + ErrorCount: errorCount, + ErrorMessage: null + ); + } + + /// + /// 计算单个功率段的统计值 + /// + private static PlossPowerSegmentStatistics CalculateStatistics(List records) + { + // 理论Δp = Field5 - (Field4 + PowLoss + Field9)(恒等式 delta_p = Tx − Rx − pow_loss − Ploss) + // 用 long 计算,避免大字段值 int 溢出;PowLoss 仅两行格式有。 + // TheoreticalDeltaPs 与 Vpas 必须同源同长,导出器按行配对写,否则行错位。 + var usable = records.Where(r => r.PowLoss.HasValue).ToList(); + var theoreticalDeltaPs = usable + .Select(r => (long)r.Field5 - ((long)r.Field4 + r.PowLoss!.Value + r.Field9)) + .ToList(); + var vpas = usable.Select(r => r.Field7).ToList(); + + int powerSegment = records[0].Field10; + long max = theoreticalDeltaPs.Count > 0 ? theoreticalDeltaPs.Max() : 0; + long min = theoreticalDeltaPs.Count > 0 ? theoreticalDeltaPs.Min() : 0; + double avg = theoreticalDeltaPs.Count > 0 ? theoreticalDeltaPs.Average() : 0; + long median = CalculateMedian(theoreticalDeltaPs); + + // 标定值 = 理论Δp最大值 + a + int calibrationOffset = CalibrationOffsetByPower.GetValueOrDefault(powerSegment, 0); + long calibrationValue = max + calibrationOffset; + + return new PlossPowerSegmentStatistics( + PowerSegment: powerSegment, + TheoreticalDeltaPs: theoreticalDeltaPs, + Vpas: vpas, + Max: max, + Min: min, + Avg: avg, + Median: median, + CalibrationValue: calibrationValue + ); + } + + /// + /// 计算中位数 + /// + private static long CalculateMedian(List values) + { + if (values.Count == 0) return 0; + + var sorted = values.OrderBy(v => v).ToList(); + int mid = sorted.Count / 2; + return sorted.Count % 2 == 0 + ? (sorted[mid - 1] + sorted[mid]) / 2 + : sorted[mid]; + } + + /// + /// 分析单个 Ploss 日志文件(原始控制台输出) /// public async Task AnalyzeFileAsync(string filePath) { @@ -48,7 +206,7 @@ public class PlossAnalysisService var rows = new List(); int errorCount = 0; - // 用于暂存两行格式的 header 行(与 ParseService 相同的合并逻辑) + // 用于暂存两行格式的 header 行 string? pendingHeaderLine = null; int pendingHeaderLineNumber = 0; @@ -81,12 +239,8 @@ public class PlossAnalysisService } else { - // 单行格式(旧格式兼容) - var result = _plossParser.Parse(line, Guid.Empty); - if (result.IsSuccess && result.Record != null) - rows.Add(BuildRow(lineNumber, result.Record)); - else - errorCount++; + // 缺少 header 行,记录错误 + errorCount++; } } } diff --git a/src/Gpulse.WCT.DataAnalyzer.Core/Application/Analysis/PlossExcelReport.cs b/src/Gpulse.WCT.DataAnalyzer.Core/Application/Analysis/PlossExcelReport.cs new file mode 100644 index 0000000..40fa2f2 --- /dev/null +++ b/src/Gpulse.WCT.DataAnalyzer.Core/Application/Analysis/PlossExcelReport.cs @@ -0,0 +1,44 @@ +namespace Gpulse.WCT.DataAnalyzer.Core.Application.Analysis; + +/// +/// Ploss 功率段统计 +/// +/// 功率档位 (350/500/750/1000/1250/1500/1750/2000/2250) +/// 理论Δp列表(long,避免大字段值溢出) +/// VPA列表 (Field7),与 TheoreticalDeltaPs 同源同长 +/// 理论Δp最大值 +/// 理论Δp最小值 +/// 理论Δp平均值 +/// 理论Δp中位数 +/// 标定Δp值 = Max + a(按功率段查表) +public record PlossPowerSegmentStatistics( + int PowerSegment, + IReadOnlyList TheoreticalDeltaPs, + IReadOnlyList Vpas, + long Max, + long Min, + double Avg, + long Median, + long CalibrationValue +); + +/// +/// Ploss Excel 报告 +/// +/// 输入文件名 +/// 按功率段分组的统计 +/// 总记录数 +/// 不在 9 个标准功率段内的记录数(Field10 非档位) +/// 解析错误数 +/// 错误信息 +public record PlossExcelReport( + string FileName, + IReadOnlyDictionary StatisticsByPowerSegment, + int TotalCount, + int DroppedCount, + int ErrorCount, + string? ErrorMessage +) +{ + public bool IsSuccess => ErrorMessage == null; +} diff --git a/src/Gpulse.WCT.DataAnalyzer.Core/Application/Analysis/QfodCalibrationService.cs b/src/Gpulse.WCT.DataAnalyzer.Core/Application/Analysis/QfodCalibrationService.cs index cc9700a..487dfb5 100644 --- a/src/Gpulse.WCT.DataAnalyzer.Core/Application/Analysis/QfodCalibrationService.cs +++ b/src/Gpulse.WCT.DataAnalyzer.Core/Application/Analysis/QfodCalibrationService.cs @@ -1,4 +1,5 @@ using Gpulse.WCT.DataAnalyzer.Core.Application.Parsing; +using Gpulse.WCT.DataAnalyzer.Core.Domain.Local; namespace Gpulse.WCT.DataAnalyzer.Core.Application.Analysis; @@ -17,7 +18,140 @@ public class QfodCalibrationService } /// - /// 计算标定 ΔQ 阈值 + /// 计算标定 ΔQ 阈值(生成 Excel 报告) + /// + /// 纯手机充电日志文件 + /// 手机+异物充电日志文件 + public async Task CalibrateForExcelAsync(string pureFile, string foreignFile) + { + var pureRecords = await ReadRecordsAsync(pureFile); + var foreignRecords = await ReadRecordsAsync(foreignFile); + + if (pureRecords.ErrorMessage != null) + return new QfodExcelReport( + Path.GetFileName(pureFile), Path.GetFileName(foreignFile), + new Dictionary(), + 0, pureRecords.ErrorMessage); + + if (foreignRecords.ErrorMessage != null) + return new QfodExcelReport( + Path.GetFileName(pureFile), Path.GetFileName(foreignFile), + new Dictionary(), + 0, foreignRecords.ErrorMessage); + + // 空记录守卫:日志文件存在但未提取到 Qfod 行时,不能从 0 起算阈值却标记成功 + if (pureRecords.Records.Count == 0 || foreignRecords.Records.Count == 0) + return new QfodExcelReport( + Path.GetFileName(pureFile), Path.GetFileName(foreignFile), + new Dictionary(), + 0, "日志文件中未提取到 Qfod 数据"); + + // 按线圈分组统计(仅纯手机;Qfod 输出模板无异物块,异物文件仅用于阈值计算) + var pureByCoil = pureRecords.Records + .GroupBy(r => r.CoilIndex) + .ToDictionary(g => g.Key, g => CalculateCoilStatistics(g.ToList())); + + // 计算阈值(使用所有纯手机和异物的 DeltaQ;有符号 ΔQ,见使用指南示例 Threshold=-22) + int pureMax = pureRecords.Records.Max(r => r.DeltaQ); + int foreignMin = foreignRecords.Records.Min(r => r.DeltaQ); + int threshold = (pureMax + foreignMin) / 2; + + return new QfodExcelReport( + PureFileName: Path.GetFileName(pureFile), + ForeignFileName: Path.GetFileName(foreignFile), + PureStatisticsByCoil: pureByCoil, + Threshold: threshold, + ErrorMessage: null + ); + } + + /// + /// 计算单线圈统计 + /// 注意:DeltaQ 统计使用绝对值 + /// + private static QfodCoilStatistics CalculateCoilStatistics(List records) + { + // DeltaQ 取绝对值统计 + var deltaQs = records.Select(r => Math.Abs(r.DeltaQ)).ToList(); + var currentQs = records.Select(r => r.CurrentQ).ToList(); + var calibratedQs = records.Select(r => r.RawQ).ToList(); + + return new QfodCoilStatistics( + CoilIndex: records[0].CoilIndex, + DeltaQs: deltaQs, + CurrentQs: currentQs, + CalibratedQs: calibratedQs, + MaxDeltaQ: deltaQs.Count > 0 ? deltaQs.Max() : 0, + MinDeltaQ: deltaQs.Count > 0 ? deltaQs.Min() : 0, + AvgDeltaQ: deltaQs.Count > 0 ? deltaQs.Average() : 0, + MedianDeltaQ: CalculateMedian(deltaQs), + MaxCurrentQ: currentQs.Count > 0 ? currentQs.Max() : 0, + MinCurrentQ: currentQs.Count > 0 ? currentQs.Min() : 0, + AvgCurrentQ: currentQs.Count > 0 ? currentQs.Average() : 0, + MedianCurrentQ: CalculateMedianFloat(currentQs) + ); + } + + /// + /// 计算整数中位数 + /// + private static int CalculateMedian(List values) + { + if (values.Count == 0) return 0; + + var sorted = values.OrderBy(v => v).ToList(); + int mid = sorted.Count / 2; + return sorted.Count % 2 == 0 + ? (sorted[mid - 1] + sorted[mid]) / 2 + : sorted[mid]; + } + + /// + /// 计算浮点数中位数 + /// + private static float CalculateMedianFloat(List values) + { + if (values.Count == 0) return 0; + + var sorted = values.OrderBy(v => v).ToList(); + int mid = sorted.Count / 2; + return sorted.Count % 2 == 0 + ? (sorted[mid - 1] + sorted[mid]) / 2f + : sorted[mid]; + } + + /// + /// 读取日志文件中的所有 Qfod 记录 + /// + private async Task<(List Records, string? ErrorMessage)> ReadRecordsAsync(string filePath) + { + var fileInfo = new FileInfo(filePath); + + if (!fileInfo.Exists) + { + return (new List(), $"文件不存在: {filePath}"); + } + + var records = new List(); + var lines = await File.ReadAllLinesAsync(filePath); + + foreach (var line in lines) + { + var trimmed = line.Trim(); + if (string.IsNullOrEmpty(trimmed)) continue; + + if (!_qfodParser.CanParse(trimmed)) continue; + + var result = _qfodParser.Parse(trimmed, Guid.Empty); + if (result.IsSuccess && result.Record != null) + records.Add(result.Record); + } + + return (records, null); + } + + /// + /// 计算标定 ΔQ 阈值(原始控制台输出) /// /// 纯手机充电日志文件 /// 手机+异物充电日志文件 diff --git a/src/Gpulse.WCT.DataAnalyzer.Core/Application/Analysis/QfodExcelReport.cs b/src/Gpulse.WCT.DataAnalyzer.Core/Application/Analysis/QfodExcelReport.cs new file mode 100644 index 0000000..e206311 --- /dev/null +++ b/src/Gpulse.WCT.DataAnalyzer.Core/Application/Analysis/QfodExcelReport.cs @@ -0,0 +1,50 @@ +namespace Gpulse.WCT.DataAnalyzer.Core.Application.Analysis; + +/// +/// 单线圈 Qfod 统计 +/// +/// 线圈索引 (0/1/2 对应 Q0/Q1/Q2) +/// ΔQ绝对值列表 +/// Q实际值列表 (CurrentQ) +/// Q校准值列表 (RawQ) +/// ΔQ绝对值最大值 +/// ΔQ绝对值最小值 +/// ΔQ绝对值平均值 +/// ΔQ绝对值中位数 +/// Q实际值最大值 +/// Q实际值最小值 +/// Q实际值平均值 +/// Q实际值中位数 +public record QfodCoilStatistics( + byte CoilIndex, + IReadOnlyList DeltaQs, + IReadOnlyList CurrentQs, + IReadOnlyList CalibratedQs, + int MaxDeltaQ, + int MinDeltaQ, + double AvgDeltaQ, + int MedianDeltaQ, + float MaxCurrentQ, + float MinCurrentQ, + double AvgCurrentQ, + float MedianCurrentQ +); + +/// +/// Qfod Excel 报告 +/// +/// 纯手机日志文件名 +/// 异物日志文件名 +/// 纯手机按线圈分组的统计 +/// 标定阈值 = (纯手机ΔQ最大值 + 异物ΔQ最小值) / 2(有符号 ΔQ) +/// 错误信息 +public record QfodExcelReport( + string PureFileName, + string ForeignFileName, + IReadOnlyDictionary PureStatisticsByCoil, + int Threshold, + string? ErrorMessage +) +{ + public bool IsSuccess => ErrorMessage == null; +} diff --git a/src/Gpulse.WCT.DataAnalyzer.Core/Application/Exporting/AnalyzeExcelExporter.cs b/src/Gpulse.WCT.DataAnalyzer.Core/Application/Exporting/AnalyzeExcelExporter.cs new file mode 100644 index 0000000..7a98d48 --- /dev/null +++ b/src/Gpulse.WCT.DataAnalyzer.Core/Application/Exporting/AnalyzeExcelExporter.cs @@ -0,0 +1,363 @@ +using System.Data; +using ClosedXML.Excel; +using Gpulse.WCT.DataAnalyzer.Core.Application.Analysis; + +namespace Gpulse.WCT.DataAnalyzer.Core.Application.Exporting; + +/// +/// 分析结果 Excel 导出器 +/// +public class AnalyzeExcelExporter +{ + /// + /// 功率段列表 (与模板一致) + /// + private static readonly int[] PowerSegments = [350, 500, 750, 1000, 1250, 1500, 1750, 2000, 2250]; + + /// + /// 导出 Ploss 分析报告到 Excel + /// + /// Ploss 分析报告 + /// 输出文件路径 + /// 取消令牌 + /// 输出文件路径 + public async Task ExportPlossReportAsync( + PlossExcelReport report, + string outputPath, + CancellationToken cancellationToken = default) + { + using var workbook = new XLWorkbook(); + var worksheet = workbook.Worksheets.Add("Sheet1"); + + // 动态数据行:找出最大数据量(无数据时为0) + var statsValues = report.StatisticsByPowerSegment.Values.ToList(); + int maxDataRows = statsValues.Count > 0 + ? statsValues.Max(s => Math.Max(s.TheoreticalDeltaPs.Count, s.Vpas.Count)) + : 0; + + // 计算总行数:标题行(1) + 统计行(5) + 数据行子标题(1) + 数据行标题(1) + 数据行(maxDataRows) + int totalRows = 8 + maxDataRows; + + // 每个功率段占两列,总共 1 + 9*2 = 19 列 + int lastCol = 1 + PowerSegments.Length * 2; + + // 行1: 功率段标题行(每个功率段合并两列,背景色加深) + worksheet.Cell(1, 1).Value = "功率段"; + var headerRowRange = worksheet.Range(1, 1, 1, lastCol); + headerRowRange.Style.Fill.BackgroundColor = XLColor.LightGray; // 设置背景色 + + for (int i = 0; i < PowerSegments.Length; i++) + { + int col = 2 + i * 2; + var range = worksheet.Range(1, col, 1, col + 1); + range.Merge(); + range.FirstCell().Value = PowerSegments[i]; + range.Style.Alignment.Horizontal = XLAlignmentHorizontalValues.Center; + range.Style.Alignment.Vertical = XLAlignmentVerticalValues.Center; + } + + // 行2-6: 统计行(每个功率段合并两列) + var statLabels = new[] { "理论Δp最大值", "理论Δp最小值", "理论Δp平均值", "理论Δp中位数", "标定Δp值" }; + for (int statRow = 0; statRow < 5; statRow++) + { + int excelRow = 2 + statRow; + worksheet.Cell(excelRow, 1).Value = statLabels[statRow]; + + for (int i = 0; i < PowerSegments.Length; i++) + { + var power = PowerSegments[i]; + int col = 2 + i * 2; + var range = worksheet.Range(excelRow, col, excelRow, col + 1); + range.Merge(); + range.Style.Alignment.Horizontal = XLAlignmentHorizontalValues.Center; + range.Style.Alignment.Vertical = XLAlignmentVerticalValues.Center; + + if (report.StatisticsByPowerSegment.TryGetValue(power, out var stats)) + { + var value = statRow switch + { + 0 => stats.Max, + 1 => stats.Min, + 2 => stats.Avg, + 3 => stats.Median, + 4 => stats.CalibrationValue, + _ => 0 + }; + range.FirstCell().Value = value; + } + } + } + + // 行7: 数据行子标题(理论Δp / VPA),第一列开始合并"数据行" + int subHeaderRow = 7; + int dataStartRow = 8; + + if (maxDataRows > 0) + { + worksheet.Cell(subHeaderRow, 1).Value = "数据行"; + // 从子标题行开始合并到最后一行数据 + int mergeEndRow = dataStartRow + maxDataRows - 1; + if (mergeEndRow > subHeaderRow) + { + var mergeRange = worksheet.Range(subHeaderRow, 1, mergeEndRow, 1); + mergeRange.Merge(); + } + // 第一列居中 + worksheet.Range(subHeaderRow, 1, Math.Max(subHeaderRow, dataStartRow + maxDataRows - 1), 1).Style.Alignment.Horizontal = XLAlignmentHorizontalValues.Center; + worksheet.Range(subHeaderRow, 1, Math.Max(subHeaderRow, dataStartRow + maxDataRows - 1), 1).Style.Alignment.Vertical = XLAlignmentVerticalValues.Center; + } + + for (int i = 0; i < PowerSegments.Length; i++) + { + int col = 2 + i * 2; + worksheet.Cell(subHeaderRow, col).Value = "理论Δp"; + worksheet.Cell(subHeaderRow, col + 1).Value = "VPA"; + worksheet.Cell(subHeaderRow, col).Style.Alignment.Horizontal = XLAlignmentHorizontalValues.Center; + worksheet.Cell(subHeaderRow, col).Style.Alignment.Vertical = XLAlignmentVerticalValues.Center; + worksheet.Cell(subHeaderRow, col + 1).Style.Alignment.Horizontal = XLAlignmentHorizontalValues.Center; + worksheet.Cell(subHeaderRow, col + 1).Style.Alignment.Vertical = XLAlignmentVerticalValues.Center; + } + + // 填充数据行(每个功率段两列:理论Δp 和 VPA) + for (int row = 0; row < maxDataRows; row++) + { + int excelRow = dataStartRow + row; + + for (int i = 0; i < PowerSegments.Length; i++) + { + var power = PowerSegments[i]; + int col = 2 + i * 2; + if (report.StatisticsByPowerSegment.TryGetValue(power, out var stats)) + { + // 理论Δp(第一列) + if (row < stats.TheoreticalDeltaPs.Count) + { + var cell = worksheet.Cell(excelRow, col); + cell.Value = stats.TheoreticalDeltaPs[row]; + cell.Style.Alignment.Horizontal = XLAlignmentHorizontalValues.Center; + cell.Style.Alignment.Vertical = XLAlignmentVerticalValues.Center; + } + + // VPA(第二列) + if (row < stats.Vpas.Count) + { + var cell = worksheet.Cell(excelRow, col + 1); + cell.Value = stats.Vpas[row]; + cell.Style.Alignment.Horizontal = XLAlignmentHorizontalValues.Center; + cell.Style.Alignment.Vertical = XLAlignmentVerticalValues.Center; + } + } + } + } + + // 设置第一列居中 + worksheet.Range(1, 1, totalRows, 1).Style.Alignment.Horizontal = XLAlignmentHorizontalValues.Center; + worksheet.Range(1, 1, totalRows, 1).Style.Alignment.Vertical = XLAlignmentVerticalValues.Center; + + // 添加表格边框线 + if (totalRows > 0) + { + var tableRange = worksheet.Range(1, 1, totalRows, lastCol); + tableRange.Style.Border.TopBorder = XLBorderStyleValues.Thin; + tableRange.Style.Border.BottomBorder = XLBorderStyleValues.Thin; + tableRange.Style.Border.LeftBorder = XLBorderStyleValues.Thin; + tableRange.Style.Border.RightBorder = XLBorderStyleValues.Thin; + } + + // 设置列宽 + worksheet.Column(1).Width = 15; + for (int c = 2; c <= lastCol; c++) + worksheet.Column(c).Width = 10; + + // 保存文件 + EnsureParentDirectory(outputPath); + await Task.Run(() => workbook.SaveAs(outputPath), cancellationToken); + + return outputPath; + } + + /// + /// 导出 Qfod 标定报告到 Excel + /// + /// Qfod 标定报告 + /// 输出文件路径 + /// 取消令牌 + /// 输出文件路径 + public async Task ExportQfodReportAsync( + QfodExcelReport report, + string outputPath, + CancellationToken cancellationToken = default) + { + using var workbook = new XLWorkbook(); + var worksheet = workbook.Worksheets.Add("Sheet1"); + + // 动态数据行:找出最大数据量 + int maxDataRows = 0; + foreach (var stats in report.PureStatisticsByCoil.Values) + { + maxDataRows = Math.Max(maxDataRows, stats.DeltaQs.Count); + } + + // 每个线圈三列,总共 1 + 3*3 = 10 列 + int lastCol = 10; + + // 计算总行数:标题行(1) + 统计行(5) + 数据行子标题(1) + 数据行(maxDataRows) + int totalRows = 7 + maxDataRows; + + // 行1: 线圈编号标题行(背景色加深) + var headerRange = worksheet.Range(1, 1, 1, lastCol); + headerRange.Style.Fill.BackgroundColor = XLColor.LightGray; + + worksheet.Cell(1, 1).Value = "线圈编号"; + worksheet.Range(1, 1, 1, lastCol).Style.Alignment.Horizontal = XLAlignmentHorizontalValues.Center; + worksheet.Range(1, 1, 1, lastCol).Style.Alignment.Vertical = XLAlignmentVerticalValues.Center; + + // Q0、Q1、Q2 各合并三列 + for (int coil = 0; coil < 3; coil++) + { + int col = 2 + coil * 3; + var range = worksheet.Range(1, col, 1, col + 2); + range.Merge(); + range.FirstCell().Value = $"Q{coil}"; + range.Style.Alignment.Horizontal = XLAlignmentHorizontalValues.Center; + range.Style.Alignment.Vertical = XLAlignmentVerticalValues.Center; + } + + // 行2-6: 统计行(每个线圈合并三列) + var statLabels = new[] { "Q最大值", "Q最小值", "Q平均值", "Q中位数", "标定Q值" }; + for (int statRow = 0; statRow < 5; statRow++) + { + int excelRow = 2 + statRow; + worksheet.Cell(excelRow, 1).Value = statLabels[statRow]; + worksheet.Cell(excelRow, 1).Style.Alignment.Horizontal = XLAlignmentHorizontalValues.Center; + worksheet.Cell(excelRow, 1).Style.Alignment.Vertical = XLAlignmentVerticalValues.Center; + + for (int coil = 0; coil < 3; coil++) + { + int col = 2 + coil * 3; + var range = worksheet.Range(excelRow, col, excelRow, col + 2); + range.Merge(); + range.Style.Alignment.Horizontal = XLAlignmentHorizontalValues.Center; + range.Style.Alignment.Vertical = XLAlignmentVerticalValues.Center; + + if (report.PureStatisticsByCoil.TryGetValue((byte)coil, out var stats)) + { + var value = statRow switch + { + 0 => stats.MaxDeltaQ, + 1 => stats.MinDeltaQ, + 2 => stats.AvgDeltaQ, + 3 => stats.MedianDeltaQ, + 4 => report.Threshold, + _ => 0 + }; + range.FirstCell().Value = value; + } + } + } + + // 行7: 数据行子标题(ΔQ / Q实际值 / Q校准值)+ 数据行标题开始合并 + int subHeaderRow = 7; + int dataStartRow = 8; + + if (maxDataRows > 0) + { + worksheet.Cell(subHeaderRow, 1).Value = "数据行"; + int mergeEndRow = dataStartRow + maxDataRows - 1; + if (mergeEndRow > subHeaderRow) + { + var mergeRange = worksheet.Range(subHeaderRow, 1, mergeEndRow, 1); + mergeRange.Merge(); + } + worksheet.Range(subHeaderRow, 1, mergeEndRow, 1).Style.Alignment.Horizontal = XLAlignmentHorizontalValues.Center; + worksheet.Range(subHeaderRow, 1, mergeEndRow, 1).Style.Alignment.Vertical = XLAlignmentVerticalValues.Center; + } + + // 每个线圈三列子标题 + for (int coil = 0; coil < 3; coil++) + { + int col = 2 + coil * 3; + worksheet.Cell(subHeaderRow, col).Value = "ΔQ"; + worksheet.Cell(subHeaderRow, col + 1).Value = "Q实际值"; + worksheet.Cell(subHeaderRow, col + 2).Value = "Q校准值"; + + for (int c = col; c <= col + 2; c++) + { + worksheet.Cell(subHeaderRow, c).Style.Alignment.Horizontal = XLAlignmentHorizontalValues.Center; + worksheet.Cell(subHeaderRow, c).Style.Alignment.Vertical = XLAlignmentVerticalValues.Center; + } + } + + // 填充数据行 + for (int row = 0; row < maxDataRows; row++) + { + int excelRow = dataStartRow + row; + + for (int coil = 0; coil < 3; coil++) + { + if (report.PureStatisticsByCoil.TryGetValue((byte)coil, out var stats)) + { + int col = 2 + coil * 3; + + // ΔQ + if (row < stats.DeltaQs.Count) + { + var cell = worksheet.Cell(excelRow, col); + cell.Value = stats.DeltaQs[row]; + cell.Style.Alignment.Horizontal = XLAlignmentHorizontalValues.Center; + cell.Style.Alignment.Vertical = XLAlignmentVerticalValues.Center; + } + + // Q实际值 + if (row < stats.CurrentQs.Count) + { + var cell = worksheet.Cell(excelRow, col + 1); + cell.Value = stats.CurrentQs[row]; + cell.Style.Alignment.Horizontal = XLAlignmentHorizontalValues.Center; + cell.Style.Alignment.Vertical = XLAlignmentVerticalValues.Center; + } + + // Q校准值 + if (row < stats.CalibratedQs.Count) + { + var cell = worksheet.Cell(excelRow, col + 2); + cell.Value = stats.CalibratedQs[row]; + cell.Style.Alignment.Horizontal = XLAlignmentHorizontalValues.Center; + cell.Style.Alignment.Vertical = XLAlignmentVerticalValues.Center; + } + } + } + } + + // 添加表格边框线 + if (totalRows > 0) + { + var tableRange = worksheet.Range(1, 1, totalRows, lastCol); + tableRange.Style.Border.TopBorder = XLBorderStyleValues.Thin; + tableRange.Style.Border.BottomBorder = XLBorderStyleValues.Thin; + tableRange.Style.Border.LeftBorder = XLBorderStyleValues.Thin; + tableRange.Style.Border.RightBorder = XLBorderStyleValues.Thin; + } + + // 设置列宽 + worksheet.Column(1).Width = 12; + for (int c = 2; c <= lastCol; c++) + worksheet.Column(c).Width = 10; + + // 保存文件 + EnsureParentDirectory(outputPath); + await Task.Run(() => workbook.SaveAs(outputPath), cancellationToken); + + return outputPath; + } + + /// + /// 确保 outputPath 的父目录存在(用户 --output 指向不存在目录时自动创建,避免 SaveAs 崩溃) + /// + private static void EnsureParentDirectory(string outputPath) + { + var dir = Path.GetDirectoryName(outputPath); + if (!string.IsNullOrEmpty(dir)) + Directory.CreateDirectory(dir); + } +}