diff --git a/docs/architecture/CLI命令设计.md b/docs/architecture/CLI命令设计.md index d080ed9..215de9d 100644 --- a/docs/architecture/CLI命令设计.md +++ b/docs/architecture/CLI命令设计.md @@ -12,6 +12,7 @@ | `aggregate` | 从本地库汇总生成发布库 | local.db → release.db | | `export` | 导出发布库到 CSV | release.db → CSV | | `clean` | 清理本地库数据(软删除) | 只写 local.db | +| `analyze` | 独立分析:Ploss 余量/delta_p、Qfod 标定阈值 | log → 控制台报告 | --- @@ -71,6 +72,26 @@ dotnet run clean --all --confirm --- +### 2.5 analyze + +```bash +# Ploss 余量 / delta_p 分析(单文件,直接解析日志,不依赖本地库) +dotnet run analyze --type ploss --file + +# Qfod 标定阈值(两个文件:纯手机 / 手机+异物) +dotnet run analyze --type qfod --pure <纯手机.log> --foreign <手机+异物.log> +``` + +- `analyze` 为**独立分析**,不进入 `parse → aggregate` 发布管线,也不写任何文件(仅控制台报告)。 +- Ploss 口径以实体语义为准(Field9=ploss、Field10=threshold、Field12=FOD 标志): + - FOD 报警 = `Field12 == 1` + - 安全余量 `Margin = Threshold − Ploss > 2000` + - 恒等式校验 `delta_p = Tx − Rx − pow_loss − Ploss`(仅两行格式) + - delta_p 反解(两行格式且 FOD=1):`delta_p' = DeltaP + 2001 + Ploss − Threshold`,控制台输出重写后的 header 行 +- Qfod 标定阈值:`Threshold = (纯手机 ΔQ 最大值 + 手机+异物 ΔQ 最小值) / 2`。因现无法自动区分两个日志文件,需显式传入 `--pure`/`--foreign`。 + +--- + ## 3. 典型工作流 ```bash diff --git a/docs/requirement/Ploss分析.md b/docs/requirement/Ploss分析.md new file mode 100644 index 0000000..02e6713 --- /dev/null +++ b/docs/requirement/Ploss分析.md @@ -0,0 +1,114 @@ +# Ploss 分析(delta_p 与安全余量判定) + +> 本文档单独记录 Ploss 分析的分析口径与判定条件。 +> 内容独立于既有需求文档 `docs/requirement/WCT数据需求分析.md`,不与原文档合并。 + +--- + +## 1. 核心口径 + +Ploss 两行日志格式: + +```text +pow_loss = 2200, delta_p = 2500 +FOD-> (16 个字段) +``` + +- **header 行**直接记录固件当前使用的 `pow_loss` / `delta_p`。 +- **FOD-> 行**记录 16 个字段,其中包含 Ploss(功率损耗计算值)、Threshold(阈值)、触发次数、FOD 结果等。 + +**权威字段语义**(以实体 `PlossRecord` 注释为准): + +| 字段 | 语义 | +|------|------| +| Field9 | ploss - 功率损耗计算值 | +| Field10 | threshold - 阈值 | +| Field11 | 触发次数 | +| Field12 | FOD 结果标志 | + +--- + +## 2. 判定条件 + +### 2.1 FOD 报警判定 + +**直接看 FOD 结果标志 == 1**。固件已完成判定(Ploss>Threshold → 计数累积 → 满限置位),分析无需重建该逻辑。 + +### 2.2 delta_p 恒等式 + +``` +delta_p = Tx − Rx − pow_loss − Ploss +``` + +能量守恒恒等式,固件即用此公式计算 Ploss,故必然满足,可用于校验。 + +### 2.3 安全余量判定 + +``` +Field10 − Field9 = Threshold − Ploss > 2000 +``` + +正常充电时安全余量需大于 2000;余量不足(≤2000)即异物或异常信号。 + +### 2.4 delta_p 调整反解(使余量 > 2000) + +当 FOD=1(余量不足)时,可依据 2.2 恒等式与 2.3 余量判定,反解出使余量严格 `> 2000`(整数即 `≥ 2001`)所需的 delta_p: + +``` +margin = Threshold − Ploss +delta_p' = DeltaP + (2001 − margin) + = DeltaP + 2001 + Ploss − Threshold +``` + +- `DeltaP`:header 行记录的原始 delta_p +- `delta_p'`:调整后的新 delta_p +- 将 header 行重写为 `pow_loss = <原值>, delta_p = ` 后,按恒等式 Ploss 下降 `2001 − margin`,余量达到 2001。 + +> **注意**:此反解仅在两行格式(有 header)下有意义;旧单行格式无 header,PowLoss/DeltaP 为 null,无法调整 delta_p。 + +**测试数据字段位置**(与实体语义错位,以实体语义为准): + +| 文件 | ploss | threshold | FOD 标志 | +|------|-------|-----------|----------| +| 实体语义(真实固件) | Field9 | Field10 | Field12 | +| ploss_test 实测 | Field8 | Field9 | Field11 | +| ploss_legacy 实测 | Field10 | Field11 | Field13 | + +**ploss_test 中 14 条 FOD=1 行的计算结果**(新余量校验 = 2001): + +| 行号 | pow_loss | DeltaP | ploss | threshold | margin | delta_p' | +|------|----------|--------|-------|-----------|--------|----------| +| 247 | 2200 | 2500 | 798 | 1000 | 202 | 4299 | +| 259 | 2000 | 2200 | 760 | 750 | −10 | 4211 | +| 519 | 2200 | 2500 | 1308 | 1000 | −308 | 4809 | +| 527 | 3000 | 3500 | 1355 | 1250 | −105 | 5606 | +| 567 | 2200 | 2500 | 674 | 1000 | 326 | 4175 | +| 957 | 2000 | 2200 | 736 | 750 | 14 | 4187 | +| 1129 | 1000 | 500 | 123 | 350 | 227 | 2274 | +| 1265 | 2000 | 2200 | 497 | 750 | 253 | 3948 | +| 1533 | 3000 | 3500 | 1474 | 1250 | −224 | 5725 | +| 2157 | 2200 | 2500 | 361 | 1000 | 639 | 3862 | +| 2379 | 1000 | 500 | 397 | 350 | −47 | 2548 | +| 2413 | 3000 | 3500 | 955 | 1250 | 295 | 5206 | +| 2477 | 2000 | 2200 | 363 | 750 | 387 | 3814 | +| 2575 | 1000 | 500 | 560 | 350 | −210 | 2711 | + +新 delta_p 范围 **2274 ~ 5725**(原 500 ~ 3500)。 + +> **局限**:示例日志为生成的测试数据,恒等式 `delta_p = Tx − Rx − pow_loss − Ploss` 在数据中不成立(穷举全部字段组合均无法一致满足),故表中 delta_p' 为"若恒等式成立则应写入的目标值",无法用数据本身验证。真实固件数据(恒等式成立)下此公式精确成立。 + +--- + +## 3. 实测佐证 + +示例日志(自动生成的测试数据)中,按语义(threshold − ploss)计算安全余量: + +| 格式 | 正常行 margin>2000 比例 | FOD 行 margin>2000 比例 | +|---|---|---| +| 两行 ploss_test | 917/1486 (61.7%) | 0/14 (0%) | +| 旧版 ploss_legacy | 361/593 (60.9%) | 0/7 (0%) | + +FOD 行安全余量全部 ≤ 2000,可区分正常/异物。 + +> 说明:示例日志为生成的测试数据,其字段位置与实体语义存在错位;**以实体语义为准** +> (Field9=ploss、Field10=threshold)。 diff --git a/docs/requirement/Qfod标定分析.md b/docs/requirement/Qfod标定分析.md new file mode 100644 index 0000000..b858ee4 --- /dev/null +++ b/docs/requirement/Qfod标定分析.md @@ -0,0 +1,79 @@ +# Qfod 标定分析(Q值 → 标定 ΔQ 阈值) + +> 本文档单独记录 Qfod 标定分析的分析口径、阶段区分限制与交付方式。 +> 内容独立于既有需求文档 `docs/requirement/WCT数据需求分析.md`,不与原文档合并。 + +--- + +## 1. 核心口径 + +Q 值由日志直接记录,日志格式 `X#:Q A->B C D E F`: + +| 字段 | 名称 | 说明 | +|------|------|------| +| X | ChargerIndex | 充电器索引(0, 1) | +| A | CoilIndex | 线圈索引(0, 1, 2) | +| B | ΔQ (DeltaQ) | Q 值变化量 | +| C | CurrentQ | 当前 Q 值 | +| D | RawQ | 原始 Q 值 | +| E | FodType | 异物类型编码 | + +**关键点**:「纯手机」与「手机+异物」分别对应**两个日志文件(两个测试场景)**: + +- 日志文件 A:纯手机充电 +- 日志文件 B:手机 + 异物 + +标定分析需**先区分这两个日志文件**,再计算标定 ΔQ 阈值。 + +--- + +## 2. 标定 ΔQ 阈值计算 + +1. 分别从两个日志文件提取 ΔQ: + - 文件 A(纯手机):取该文件 ΔQ **最大值**(示例:25) + - 文件 B(手机+异物):取该文件 ΔQ **最小值**(示例:40) +2. 计算标定阈值:`Threshold = (25 + 40) / 2 = 32` + +--- + +## 3. 分析步骤 + +1. 提取日志中 `#Q` 开头的行 +2. 绘制 ΔQ 时序曲线,叠加 Y=Threshold 水平线 +3. 验证余量:纯手机最高点 vs 阈值、有异物最低点 vs 阈值 + +--- + +## 4. 文件区分方式(现状与后续计划) + +### 4.1 当前限制 + +暂无可自动区分「纯手机」/「手机+异物」两个日志文件的可靠依据,因此**当前版本无法自动计算标定 ΔQ 阈值**。 + +### 4.2 后续计划 + +日志文件将写清楚所对应的场景(文件名 Purpose 段或文件内标记),届时据此区分两个文件再计算标定阈值。 + +**待办**:定义日志文件区分标识,解析时识别并关联配对的两个场景。 + +--- + +## 5. 交付方式 + +- 标定 ΔQ 阈值属**独立分析**,**不进入发布库聚合**(发布库现有 Q值/Q基值 列仍为 CurrentQ/RawQ 全量平均,保持不变)。 +- 计划通过**新建 CLI 分析命令**(`analyze-qfod`)承载/输出,命令**尚未实现**。 + +--- + +## 6. 实测佐证 + +`data/test_input/singleMold-v1.0-hex2_1-iPhone15-qfod_test-20260710-1.log`(示例日志含 Phase 标记): + +| Phase | 含义 | ΔQ 范围 | FOD Type(E) | +|---|---|---|---| +| 1 正常充电 | 纯手机 | 20~30(均值 24.9) | 0 | +| 2 异物检测 | 手机+异物 | 35~55(均值 44.6) | 1/2/3 | +| 3 边界值 | 临界区 | 30~35(均值 32.5) | — | +| 4 负数 | 异常 | -25~0 | — | + +> 说明:示例日志为生成的测试数据,仅作 ΔQ 数值区间的参考;真实数据中「纯手机」与「手机+异物」分属两个日志文件。 diff --git a/src/Gpulse.WCT.DataAnalyzer.Core/Application/Analysis/PlossAnalysisService.cs b/src/Gpulse.WCT.DataAnalyzer.Core/Application/Analysis/PlossAnalysisService.cs new file mode 100644 index 0000000..0724a2d --- /dev/null +++ b/src/Gpulse.WCT.DataAnalyzer.Core/Application/Analysis/PlossAnalysisService.cs @@ -0,0 +1,196 @@ +using Gpulse.WCT.DataAnalyzer.Core.Application.Parsing; +using Gpulse.WCT.DataAnalyzer.Core.Domain.Local; + +namespace Gpulse.WCT.DataAnalyzer.Core.Application.Analysis; + +/// +/// Ploss 分析服务 - 安全余量判定与 delta_p 反解 +/// 口径以实体语义为准:Field9=ploss, Field10=threshold, Field12=FOD结果标志 +/// 直接解析日志文件,不依赖本地库。 +/// +public class PlossAnalysisService +{ + private readonly PlossParser _plossParser; + + public PlossAnalysisService(PlossParser plossParser) + { + _plossParser = plossParser; + } + + /// + /// 分析单个 Ploss 日志文件 + /// + public async Task AnalyzeFileAsync(string filePath) + { + var fileInfo = new FileInfo(filePath); + + if (!fileInfo.Exists) + { + return new PlossAnalysisReport( + FileName: fileInfo.Name, + TotalCount: 0, + TwoLineCount: 0, + LegacyCount: 0, + FodCount: 0, + NormalAboveCount: 0, + NormalCount: 0, + FodAboveCount: 0, + IdentityHeldCount: 0, + IdentityCheckedCount: 0, + FodRows: [], + ErrorCount: 0, + ErrorMessage: "文件不存在" + ); + } + + var lines = await File.ReadAllLinesAsync(filePath); + + var rows = new List(); + int errorCount = 0; + + // 用于暂存两行格式的 header 行(与 ParseService 相同的合并逻辑) + string? pendingHeaderLine = null; + int pendingHeaderLineNumber = 0; + + for (int i = 0; i < lines.Length; i++) + { + var line = lines[i].Trim(); + if (string.IsNullOrEmpty(line)) continue; + + var lineNumber = i + 1; + + if (_plossParser.IsMultiLineStart(line)) + { + pendingHeaderLine = line; + pendingHeaderLineNumber = lineNumber; + } + 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) + rows.Add(BuildRow(lineNumber, result.Record)); + else + errorCount++; + + pendingHeaderLine = null; + } + else + { + // 单行格式(旧格式兼容) + var result = _plossParser.Parse(line, Guid.Empty); + if (result.IsSuccess && result.Record != null) + rows.Add(BuildRow(lineNumber, result.Record)); + else + errorCount++; + } + } + } + + // 处理末尾遗留的 header 行(异常情况) + if (pendingHeaderLine != null) + { + errorCount++; + } + + var fodRows = rows.Where(r => r.IsFod).ToList(); + var normalRows = rows.Where(r => !r.IsFod).ToList(); + + return new PlossAnalysisReport( + FileName: fileInfo.Name, + TotalCount: rows.Count, + TwoLineCount: rows.Count(r => r.IsTwoLine), + LegacyCount: rows.Count(r => !r.IsTwoLine), + FodCount: fodRows.Count, + NormalAboveCount: normalRows.Count(r => r.Margin > 2000), + NormalCount: normalRows.Count, + FodAboveCount: fodRows.Count(r => r.Margin > 2000), + IdentityHeldCount: rows.Count(r => r.IsTwoLine && r.IdentityHeld), + IdentityCheckedCount: rows.Count(r => r.IsTwoLine), + FodRows: fodRows, + ErrorCount: errorCount, + ErrorMessage: null + ); + } + + private static PlossRowReport BuildRow(int lineNumber, PlossRecord record) + { + var isTwoLine = record.PowLoss.HasValue && record.DeltaP.HasValue; + var ploss = record.Field9; + var threshold = record.Field10; + var margin = threshold - ploss; + var isFod = record.Field12 == 1; + + // 恒等式校验(仅两行格式有 header 数据): + // delta_p = Tx - Rx - pow_loss - Ploss + bool identityHeld = isTwoLine + && record.DeltaP == record.Field5 - record.Field4 - record.PowLoss - ploss; + + // delta_p 反解(仅两行格式且 FOD=1): + // delta_p' = DeltaP + 2001 + Ploss - Threshold + int? deltaPPrime = null; + string? rewrittenHeader = null; + if (isTwoLine && isFod) + { + deltaPPrime = record.DeltaP + 2001 + ploss - threshold; + rewrittenHeader = $"pow_loss = {record.PowLoss}, delta_p = {deltaPPrime}"; + } + + return new PlossRowReport( + LineNumber: lineNumber, + IsTwoLine: isTwoLine, + PowLoss: record.PowLoss, + DeltaP: record.DeltaP, + Ploss: ploss, + Threshold: threshold, + Margin: margin, + IsFod: isFod, + IdentityHeld: identityHeld, + DeltaPPrime: deltaPPrime, + RewrittenHeader: rewrittenHeader + ); + } +} + +/// +/// Ploss 单行分析结果 +/// +public record PlossRowReport( + int LineNumber, + bool IsTwoLine, + int? PowLoss, + int? DeltaP, + int Ploss, + int Threshold, + int Margin, + bool IsFod, + bool IdentityHeld, + int? DeltaPPrime, + string? RewrittenHeader +); + +/// +/// Ploss 分析报告 +/// +public record PlossAnalysisReport( + string FileName, + int TotalCount, + int TwoLineCount, + int LegacyCount, + int FodCount, + int NormalAboveCount, + int NormalCount, + int FodAboveCount, + int IdentityHeldCount, + int IdentityCheckedCount, + IReadOnlyList FodRows, + 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 new file mode 100644 index 0000000..cc9700a --- /dev/null +++ b/src/Gpulse.WCT.DataAnalyzer.Core/Application/Analysis/QfodCalibrationService.cs @@ -0,0 +1,123 @@ +using Gpulse.WCT.DataAnalyzer.Core.Application.Parsing; + +namespace Gpulse.WCT.DataAnalyzer.Core.Application.Analysis; + +/// +/// Qfod 标定分析服务 - 由「纯手机」与「手机+异物」两个日志文件计算标定 ΔQ 阈值 +/// Threshold = (纯手机 ΔQ 最大值 + 手机+异物 ΔQ 最小值) / 2 +/// 直接解析日志文件,不依赖本地库,也不进入发布库聚合。 +/// +public class QfodCalibrationService +{ + private readonly QfodParser _qfodParser; + + public QfodCalibrationService(QfodParser qfodParser) + { + _qfodParser = qfodParser; + } + + /// + /// 计算标定 ΔQ 阈值 + /// + /// 纯手机充电日志文件 + /// 手机+异物充电日志文件 + public async Task CalibrateAsync(string pureFile, string foreignFile) + { + var pureSummary = await ReadSummaryAsync(pureFile); + var foreignSummary = await ReadSummaryAsync(foreignFile); + + if (pureSummary.ErrorMessage != null) + return new QfodCalibrationReport(null, null, pureSummary, null, 0, 0, 0, pureSummary.ErrorMessage); + if (foreignSummary.ErrorMessage != null) + return new QfodCalibrationReport(null, null, null, foreignSummary, 0, 0, 0, foreignSummary.ErrorMessage); + + if (pureSummary.Count == 0 || foreignSummary.Count == 0) + { + return new QfodCalibrationReport( + pureSummary.FileName, foreignSummary.FileName, + pureSummary, foreignSummary, 0, 0, 0, + "日志文件中未提取到 Qfod 数据(无 #:Q 行)"); + } + + var threshold = (pureSummary.Max + foreignSummary.Min) / 2; + + return new QfodCalibrationReport( + PureFileName: pureSummary.FileName, + ForeignFileName: foreignSummary.FileName, + Pure: pureSummary, + Foreign: foreignSummary, + Threshold: threshold, + PureMargin: pureSummary.Max - threshold, + ForeignMargin: foreignSummary.Min - threshold, + ErrorMessage: null + ); + } + + private async Task ReadSummaryAsync(string filePath) + { + var fileInfo = new FileInfo(filePath); + + if (!fileInfo.Exists) + { + return new QfodFileSummary(fileInfo.Name, 0, 0, 0, 0, $"文件不存在: {filePath}"); + } + + var deltaQs = 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) + deltaQs.Add(result.Record.DeltaQ); + } + + if (deltaQs.Count == 0) + { + return new QfodFileSummary(fileInfo.Name, 0, 0, 0, 0, null); + } + + return new QfodFileSummary( + FileName: fileInfo.Name, + Count: deltaQs.Count, + Min: deltaQs.Min(), + Max: deltaQs.Max(), + Average: deltaQs.Average(), + ErrorMessage: null + ); + } +} + +/// +/// 单个 Qfod 文件的 ΔQ 统计 +/// +public record QfodFileSummary( + string FileName, + int Count, + int Min, + int Max, + double Average, + string? ErrorMessage +); + +/// +/// Qfod 标定报告 +/// +public record QfodCalibrationReport( + string? PureFileName, + string? ForeignFileName, + QfodFileSummary? Pure, + QfodFileSummary? Foreign, + int Threshold, + int PureMargin, + int ForeignMargin, + string? ErrorMessage +) +{ + public bool IsSuccess => ErrorMessage == null; +} diff --git a/src/Gpulse.WCT.DataAnalyzer.Core/Infrastructure/Extensions/ServiceCollectionExtensions.cs b/src/Gpulse.WCT.DataAnalyzer.Core/Infrastructure/Extensions/ServiceCollectionExtensions.cs index 0a4c4d4..682aeff 100644 --- a/src/Gpulse.WCT.DataAnalyzer.Core/Infrastructure/Extensions/ServiceCollectionExtensions.cs +++ b/src/Gpulse.WCT.DataAnalyzer.Core/Infrastructure/Extensions/ServiceCollectionExtensions.cs @@ -2,6 +2,7 @@ using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; using Gpulse.WCT.DataAnalyzer.Core.Infrastructure.LocalData; using Gpulse.WCT.DataAnalyzer.Core.Application.Parsing; +using Gpulse.WCT.DataAnalyzer.Core.Application.Analysis; using Gpulse.WCT.DataAnalyzer.Core.Application; namespace Gpulse.WCT.DataAnalyzer.Core.Infrastructure.Extensions; @@ -58,6 +59,10 @@ public static class ServiceCollectionExtensions services.AddScoped(); services.AddScoped(); + // Analysis + services.AddScoped(); + services.AddScoped(); + return services; } } diff --git a/src/Gpulse.WCT.DataAnalyzer/Commands/AnalyzeCommand.cs b/src/Gpulse.WCT.DataAnalyzer/Commands/AnalyzeCommand.cs new file mode 100644 index 0000000..65e1d68 --- /dev/null +++ b/src/Gpulse.WCT.DataAnalyzer/Commands/AnalyzeCommand.cs @@ -0,0 +1,144 @@ +using System.CommandLine; +using Gpulse.WCT.DataAnalyzer.Core.Application.Analysis; + +namespace Gpulse.WCT.DataAnalyzer.Commands; + +/// +/// 数据分析命令 - 支持 Ploss 余量/delta_p 分析与 Qfod 标定阈值分析 +/// +public class AnalyzeCommand : Command +{ + public AnalyzeCommand( + PlossAnalysisService plossAnalysisService, + QfodCalibrationService qfodCalibrationService) + : base("analyze", "Analyze Ploss margin or Qfod calibration threshold from log files") + { + var typeOption = new Option( + "--type", + "Analysis type: ploss or qfod" + ) + { + IsRequired = true + }; + + var fileOption = new Option( + "--file", + "Ploss: path to a log file to analyze" + ); + + var pureOption = new Option( + "--pure", + "Qfod: path to the phone-only charging log file" + ); + + var foreignOption = new Option( + "--foreign", + "Qfod: path to the phone + foreign object charging log file" + ); + + AddOption(typeOption); + AddOption(fileOption); + AddOption(pureOption); + AddOption(foreignOption); + + this.SetHandler(async (type, file, pure, foreign) => + { + type = type.ToLower(); + + switch (type) + { + case "ploss": + await HandlePlossAsync(plossAnalysisService, file); + break; + case "qfod": + await HandleQfodAsync(qfodCalibrationService, pure, foreign); + break; + default: + Console.WriteLine($"Unknown type: {type}. Use 'ploss' or 'qfod'."); + break; + } + }, typeOption, fileOption, pureOption, foreignOption); + } + + private static async Task HandlePlossAsync(PlossAnalysisService service, string? file) + { + if (string.IsNullOrEmpty(file)) + { + Console.WriteLine("Please specify --file for Ploss analysis"); + return; + } + + var report = await service.AnalyzeFileAsync(file); + + if (!report.IsSuccess) + { + Console.WriteLine($" [{report.FileName}] FAILED: {report.ErrorMessage}"); + return; + } + + Console.WriteLine($"\n=== Ploss Analysis: {report.FileName} ==="); + Console.WriteLine($"Total: {report.TotalCount} (TwoLine: {report.TwoLineCount}, Legacy: {report.LegacyCount}), FOD: {report.FodCount}, Errors: {report.ErrorCount}"); + + var normalRatio = Percent(report.NormalAboveCount, report.NormalCount); + var fodRatio = Percent(report.FodAboveCount, report.FodCount); + Console.WriteLine($"Normal margin>2000: {report.NormalAboveCount}/{report.NormalCount} ({normalRatio}%)"); + Console.WriteLine($"FOD margin>2000: {report.FodAboveCount}/{report.FodCount} ({fodRatio}%)"); + Console.WriteLine($"Identity (delta_p = Tx - Rx - pow_loss - ploss): {report.IdentityHeldCount}/{report.IdentityCheckedCount}"); + + if (report.FodRows.Count == 0) + { + Console.WriteLine("No FOD rows (Field12 == 1)."); + return; + } + + Console.WriteLine($"\nFOD rows (delta_p back-solve):"); + foreach (var row in report.FodRows) + { + var format = row.IsTwoLine ? "two-line" : "legacy"; + Console.WriteLine($" Line {row.LineNumber,-6} [{format}] pow_loss={row.PowLoss?.ToString() ?? "-"} delta_p={row.DeltaP?.ToString() ?? "-"} ploss={row.Ploss} threshold={row.Threshold} margin={row.Margin}"); + + if (row.IsTwoLine && row.DeltaPPrime.HasValue) + { + Console.WriteLine($" -> delta_p'={row.DeltaPPrime.Value}"); + Console.WriteLine($" rewrite: {row.RewrittenHeader}"); + } + else + { + Console.WriteLine($" (旧单行格式无 header,无法反解 delta_p)"); + } + } + } + + private static async Task HandleQfodAsync(QfodCalibrationService service, string? pure, string? foreign) + { + if (string.IsNullOrEmpty(pure) || string.IsNullOrEmpty(foreign)) + { + Console.WriteLine("Please specify --pure and --foreign for Qfod calibration"); + return; + } + + var report = await service.CalibrateAsync(pure, foreign); + + if (!report.IsSuccess) + { + Console.WriteLine($" FAILED: {report.ErrorMessage}"); + return; + } + + Console.WriteLine("\n=== Qfod Calibration ==="); + PrintQfodFileSummary("Pure phone", report.Pure!); + PrintQfodFileSummary("Foreign object", report.Foreign!); + + Console.WriteLine($"\nThreshold = (pureMax + foreignMin) / 2 = ({report.Pure!.Max} + {report.Foreign!.Min}) / 2 = {report.Threshold}"); + Console.WriteLine($"Margin: pureMax - threshold = {report.PureMargin} (负为安全), foreignMin - threshold = {report.ForeignMargin} (正为安全)"); + } + + private static void PrintQfodFileSummary(string label, QfodFileSummary summary) + { + Console.WriteLine($" {label}: {summary.FileName}"); + Console.WriteLine($" count={summary.Count} min={summary.Min} max={summary.Max} avg={summary.Average:F1}"); + } + + private static string Percent(int part, int total) + => total == 0 ? "0.0" : (part * 100.0 / total).ToString("F1"); +} diff --git a/src/Gpulse.WCT.DataAnalyzer/Program.cs b/src/Gpulse.WCT.DataAnalyzer/Program.cs index fa8f20b..e8e16c8 100644 --- a/src/Gpulse.WCT.DataAnalyzer/Program.cs +++ b/src/Gpulse.WCT.DataAnalyzer/Program.cs @@ -7,6 +7,7 @@ using Gpulse.WCT.DataAnalyzer.Commands; using Gpulse.WCT.DataAnalyzer.Core.Infrastructure.LocalData; using Gpulse.WCT.DataAnalyzer.Core.Infrastructure.Extensions; using Gpulse.WCT.DataAnalyzer.Core.Application; +using Gpulse.WCT.DataAnalyzer.Core.Application.Analysis; namespace Gpulse.WCT.DataAnalyzer; @@ -70,6 +71,9 @@ public class Program rootCommand.AddCommand(new AggregateCommand(provider.GetRequiredService())); rootCommand.AddCommand(new ExportCommand(provider.GetRequiredService())); rootCommand.AddCommand(new CleanCommand(provider.GetRequiredService())); + rootCommand.AddCommand(new AnalyzeCommand( + provider.GetRequiredService(), + provider.GetRequiredService())); return await rootCommand.InvokeAsync(args); }