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