feat(analysis): 添加Excel报告导出功能

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Scottxjw
2026-08-25 11:29:45 +08:00
parent abaa9cb6d1
commit 820bc1c2bc
5 changed files with 754 additions and 9 deletions

View File

@@ -12,13 +12,171 @@ public class PlossAnalysisService
{ {
private readonly PlossParser _plossParser; private readonly PlossParser _plossParser;
/// <summary>
/// 功率段列表 (与模板一致)
/// </summary>
private static readonly int[] PowerSegments = [350, 500, 750, 1000, 1250, 1500, 1750, 2000, 2250];
/// <summary>
/// 标定偏移值 a按功率段
/// 标定Δp = 理论Δp最大值 + a
/// </summary>
private static readonly Dictionary<int, int> CalibrationOffsetByPower = new()
{
{ 350, 3000 },
{ 500, 1000 },
{ 750, 1200 },
{ 1000, 1500 },
{ 1250, 1500 },
{ 1500, 2000 },
{ 1750, 2500 },
{ 2000, 2500 },
{ 2250, 3000 }
};
public PlossAnalysisService(PlossParser plossParser) public PlossAnalysisService(PlossParser plossParser)
{ {
_plossParser = plossParser; _plossParser = plossParser;
} }
/// <summary> /// <summary>
/// 分析单个 Ploss 日志文件 /// 分析单个 Ploss 日志文件(生成 Excel 报告)
/// </summary>
public async Task<PlossExcelReport> AnalyzeForExcelAsync(string filePath)
{
var fileInfo = new FileInfo(filePath);
if (!fileInfo.Exists)
{
return new PlossExcelReport(
FileName: fileInfo.Name,
StatisticsByPowerSegment: new Dictionary<int, PlossPowerSegmentStatistics>(),
TotalCount: 0,
DroppedCount: 0,
ErrorCount: 0,
ErrorMessage: "文件不存在"
);
}
var lines = await File.ReadAllLinesAsync(filePath);
var records = new List<PlossRecord>();
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
);
}
/// <summary>
/// 计算单个功率段的统计值
/// </summary>
private static PlossPowerSegmentStatistics CalculateStatistics(List<PlossRecord> 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
);
}
/// <summary>
/// 计算中位数
/// </summary>
private static long CalculateMedian(List<long> 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];
}
/// <summary>
/// 分析单个 Ploss 日志文件(原始控制台输出)
/// </summary> /// </summary>
public async Task<PlossAnalysisReport> AnalyzeFileAsync(string filePath) public async Task<PlossAnalysisReport> AnalyzeFileAsync(string filePath)
{ {
@@ -48,7 +206,7 @@ public class PlossAnalysisService
var rows = new List<PlossRowReport>(); var rows = new List<PlossRowReport>();
int errorCount = 0; int errorCount = 0;
// 用于暂存两行格式的 header 行(与 ParseService 相同的合并逻辑) // 用于暂存两行格式的 header 行
string? pendingHeaderLine = null; string? pendingHeaderLine = null;
int pendingHeaderLineNumber = 0; int pendingHeaderLineNumber = 0;
@@ -81,11 +239,7 @@ public class PlossAnalysisService
} }
else else
{ {
// 单行格式(旧格式兼容) // 缺少 header 行,记录错误
var result = _plossParser.Parse(line, Guid.Empty);
if (result.IsSuccess && result.Record != null)
rows.Add(BuildRow(lineNumber, result.Record));
else
errorCount++; errorCount++;
} }
} }

View File

@@ -0,0 +1,44 @@
namespace Gpulse.WCT.DataAnalyzer.Core.Application.Analysis;
/// <summary>
/// Ploss 功率段统计
/// </summary>
/// <param name="PowerSegment">功率档位 (350/500/750/1000/1250/1500/1750/2000/2250)</param>
/// <param name="TheoreticalDeltaPs">理论Δp列表long避免大字段值溢出</param>
/// <param name="Vpas">VPA列表 (Field7),与 TheoreticalDeltaPs 同源同长</param>
/// <param name="Max">理论Δp最大值</param>
/// <param name="Min">理论Δp最小值</param>
/// <param name="Avg">理论Δp平均值</param>
/// <param name="Median">理论Δp中位数</param>
/// <param name="CalibrationValue">标定Δp值 = Max + a按功率段查表</param>
public record PlossPowerSegmentStatistics(
int PowerSegment,
IReadOnlyList<long> TheoreticalDeltaPs,
IReadOnlyList<int> Vpas,
long Max,
long Min,
double Avg,
long Median,
long CalibrationValue
);
/// <summary>
/// Ploss Excel 报告
/// </summary>
/// <param name="FileName">输入文件名</param>
/// <param name="StatisticsByPowerSegment">按功率段分组的统计</param>
/// <param name="TotalCount">总记录数</param>
/// <param name="DroppedCount">不在 9 个标准功率段内的记录数Field10 非档位)</param>
/// <param name="ErrorCount">解析错误数</param>
/// <param name="ErrorMessage">错误信息</param>
public record PlossExcelReport(
string FileName,
IReadOnlyDictionary<int, PlossPowerSegmentStatistics> StatisticsByPowerSegment,
int TotalCount,
int DroppedCount,
int ErrorCount,
string? ErrorMessage
)
{
public bool IsSuccess => ErrorMessage == null;
}

View File

@@ -1,4 +1,5 @@
using Gpulse.WCT.DataAnalyzer.Core.Application.Parsing; using Gpulse.WCT.DataAnalyzer.Core.Application.Parsing;
using Gpulse.WCT.DataAnalyzer.Core.Domain.Local;
namespace Gpulse.WCT.DataAnalyzer.Core.Application.Analysis; namespace Gpulse.WCT.DataAnalyzer.Core.Application.Analysis;
@@ -17,7 +18,140 @@ public class QfodCalibrationService
} }
/// <summary> /// <summary>
/// 计算标定 ΔQ 阈值 /// 计算标定 ΔQ 阈值(生成 Excel 报告)
/// </summary>
/// <param name="pureFile">纯手机充电日志文件</param>
/// <param name="foreignFile">手机+异物充电日志文件</param>
public async Task<QfodExcelReport> 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<byte, QfodCoilStatistics>(),
0, pureRecords.ErrorMessage);
if (foreignRecords.ErrorMessage != null)
return new QfodExcelReport(
Path.GetFileName(pureFile), Path.GetFileName(foreignFile),
new Dictionary<byte, QfodCoilStatistics>(),
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<byte, QfodCoilStatistics>(),
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
);
}
/// <summary>
/// 计算单线圈统计
/// 注意DeltaQ 统计使用绝对值
/// </summary>
private static QfodCoilStatistics CalculateCoilStatistics(List<QfodRecord> 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)
);
}
/// <summary>
/// 计算整数中位数
/// </summary>
private static int CalculateMedian(List<int> 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];
}
/// <summary>
/// 计算浮点数中位数
/// </summary>
private static float CalculateMedianFloat(List<float> 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];
}
/// <summary>
/// 读取日志文件中的所有 Qfod 记录
/// </summary>
private async Task<(List<QfodRecord> Records, string? ErrorMessage)> ReadRecordsAsync(string filePath)
{
var fileInfo = new FileInfo(filePath);
if (!fileInfo.Exists)
{
return (new List<QfodRecord>(), $"文件不存在: {filePath}");
}
var records = new List<QfodRecord>();
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);
}
/// <summary>
/// 计算标定 ΔQ 阈值(原始控制台输出)
/// </summary> /// </summary>
/// <param name="pureFile">纯手机充电日志文件</param> /// <param name="pureFile">纯手机充电日志文件</param>
/// <param name="foreignFile">手机+异物充电日志文件</param> /// <param name="foreignFile">手机+异物充电日志文件</param>

View File

@@ -0,0 +1,50 @@
namespace Gpulse.WCT.DataAnalyzer.Core.Application.Analysis;
/// <summary>
/// 单线圈 Qfod 统计
/// </summary>
/// <param name="CoilIndex">线圈索引 (0/1/2 对应 Q0/Q1/Q2)</param>
/// <param name="DeltaQs">ΔQ绝对值列表</param>
/// <param name="CurrentQs">Q实际值列表 (CurrentQ)</param>
/// <param name="CalibratedQs">Q校准值列表 (RawQ)</param>
/// <param name="MaxDeltaQ">ΔQ绝对值最大值</param>
/// <param name="MinDeltaQ">ΔQ绝对值最小值</param>
/// <param name="AvgDeltaQ">ΔQ绝对值平均值</param>
/// <param name="MedianDeltaQ">ΔQ绝对值中位数</param>
/// <param name="MaxCurrentQ">Q实际值最大值</param>
/// <param name="MinCurrentQ">Q实际值最小值</param>
/// <param name="AvgCurrentQ">Q实际值平均值</param>
/// <param name="MedianCurrentQ">Q实际值中位数</param>
public record QfodCoilStatistics(
byte CoilIndex,
IReadOnlyList<int> DeltaQs,
IReadOnlyList<float> CurrentQs,
IReadOnlyList<float> CalibratedQs,
int MaxDeltaQ,
int MinDeltaQ,
double AvgDeltaQ,
int MedianDeltaQ,
float MaxCurrentQ,
float MinCurrentQ,
double AvgCurrentQ,
float MedianCurrentQ
);
/// <summary>
/// Qfod Excel 报告
/// </summary>
/// <param name="PureFileName">纯手机日志文件名</param>
/// <param name="ForeignFileName">异物日志文件名</param>
/// <param name="PureStatisticsByCoil">纯手机按线圈分组的统计</param>
/// <param name="Threshold">标定阈值 = (纯手机ΔQ最大值 + 异物ΔQ最小值) / 2有符号 ΔQ</param>
/// <param name="ErrorMessage">错误信息</param>
public record QfodExcelReport(
string PureFileName,
string ForeignFileName,
IReadOnlyDictionary<byte, QfodCoilStatistics> PureStatisticsByCoil,
int Threshold,
string? ErrorMessage
)
{
public bool IsSuccess => ErrorMessage == null;
}

View File

@@ -0,0 +1,363 @@
using System.Data;
using ClosedXML.Excel;
using Gpulse.WCT.DataAnalyzer.Core.Application.Analysis;
namespace Gpulse.WCT.DataAnalyzer.Core.Application.Exporting;
/// <summary>
/// 分析结果 Excel 导出器
/// </summary>
public class AnalyzeExcelExporter
{
/// <summary>
/// 功率段列表 (与模板一致)
/// </summary>
private static readonly int[] PowerSegments = [350, 500, 750, 1000, 1250, 1500, 1750, 2000, 2250];
/// <summary>
/// 导出 Ploss 分析报告到 Excel
/// </summary>
/// <param name="report">Ploss 分析报告</param>
/// <param name="outputPath">输出文件路径</param>
/// <param name="cancellationToken">取消令牌</param>
/// <returns>输出文件路径</returns>
public async Task<string> 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;
}
/// <summary>
/// 导出 Qfod 标定报告到 Excel
/// </summary>
/// <param name="report">Qfod 标定报告</param>
/// <param name="outputPath">输出文件路径</param>
/// <param name="cancellationToken">取消令牌</param>
/// <returns>输出文件路径</returns>
public async Task<string> 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;
}
/// <summary>
/// 确保 outputPath 的父目录存在(用户 --output 指向不存在目录时自动创建,避免 SaveAs 崩溃)
/// </summary>
private static void EnsureParentDirectory(string outputPath)
{
var dir = Path.GetDirectoryName(outputPath);
if (!string.IsNullOrEmpty(dir))
Directory.CreateDirectory(dir);
}
}