feat(analysis): 添加Excel报告导出功能
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -12,13 +12,171 @@ public class PlossAnalysisService
|
||||
{
|
||||
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)
|
||||
{
|
||||
_plossParser = plossParser;
|
||||
}
|
||||
|
||||
/// <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>
|
||||
public async Task<PlossAnalysisReport> AnalyzeFileAsync(string filePath)
|
||||
{
|
||||
@@ -48,7 +206,7 @@ public class PlossAnalysisService
|
||||
var rows = new List<PlossRowReport>();
|
||||
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++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
/// <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>
|
||||
/// <param name="pureFile">纯手机充电日志文件</param>
|
||||
/// <param name="foreignFile">手机+异物充电日志文件</param>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user