feat(analyze): 新增 Ploss/Qfod 分析命令
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -12,6 +12,7 @@
|
|||||||
| `aggregate` | 从本地库汇总生成发布库 | local.db → release.db |
|
| `aggregate` | 从本地库汇总生成发布库 | local.db → release.db |
|
||||||
| `export` | 导出发布库到 CSV | release.db → CSV |
|
| `export` | 导出发布库到 CSV | release.db → CSV |
|
||||||
| `clean` | 清理本地库数据(软删除) | 只写 local.db |
|
| `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 <log>
|
||||||
|
|
||||||
|
# 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. 典型工作流
|
## 3. 典型工作流
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
|||||||
114
docs/requirement/Ploss分析.md
Normal file
114
docs/requirement/Ploss分析.md
Normal file
@@ -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 = <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)。
|
||||||
79
docs/requirement/Qfod标定分析.md
Normal file
79
docs/requirement/Qfod标定分析.md
Normal file
@@ -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 数值区间的参考;真实数据中「纯手机」与「手机+异物」分属两个日志文件。
|
||||||
@@ -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;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Ploss 分析服务 - 安全余量判定与 delta_p 反解
|
||||||
|
/// 口径以实体语义为准:Field9=ploss, Field10=threshold, Field12=FOD结果标志
|
||||||
|
/// 直接解析日志文件,不依赖本地库。
|
||||||
|
/// </summary>
|
||||||
|
public class PlossAnalysisService
|
||||||
|
{
|
||||||
|
private readonly PlossParser _plossParser;
|
||||||
|
|
||||||
|
public PlossAnalysisService(PlossParser plossParser)
|
||||||
|
{
|
||||||
|
_plossParser = plossParser;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 分析单个 Ploss 日志文件
|
||||||
|
/// </summary>
|
||||||
|
public async Task<PlossAnalysisReport> 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<PlossRowReport>();
|
||||||
|
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
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Ploss 单行分析结果
|
||||||
|
/// </summary>
|
||||||
|
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
|
||||||
|
);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Ploss 分析报告
|
||||||
|
/// </summary>
|
||||||
|
public record PlossAnalysisReport(
|
||||||
|
string FileName,
|
||||||
|
int TotalCount,
|
||||||
|
int TwoLineCount,
|
||||||
|
int LegacyCount,
|
||||||
|
int FodCount,
|
||||||
|
int NormalAboveCount,
|
||||||
|
int NormalCount,
|
||||||
|
int FodAboveCount,
|
||||||
|
int IdentityHeldCount,
|
||||||
|
int IdentityCheckedCount,
|
||||||
|
IReadOnlyList<PlossRowReport> FodRows,
|
||||||
|
int ErrorCount,
|
||||||
|
string? ErrorMessage
|
||||||
|
)
|
||||||
|
{
|
||||||
|
public bool IsSuccess => ErrorMessage == null;
|
||||||
|
}
|
||||||
@@ -0,0 +1,123 @@
|
|||||||
|
using Gpulse.WCT.DataAnalyzer.Core.Application.Parsing;
|
||||||
|
|
||||||
|
namespace Gpulse.WCT.DataAnalyzer.Core.Application.Analysis;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Qfod 标定分析服务 - 由「纯手机」与「手机+异物」两个日志文件计算标定 ΔQ 阈值
|
||||||
|
/// Threshold = (纯手机 ΔQ 最大值 + 手机+异物 ΔQ 最小值) / 2
|
||||||
|
/// 直接解析日志文件,不依赖本地库,也不进入发布库聚合。
|
||||||
|
/// </summary>
|
||||||
|
public class QfodCalibrationService
|
||||||
|
{
|
||||||
|
private readonly QfodParser _qfodParser;
|
||||||
|
|
||||||
|
public QfodCalibrationService(QfodParser qfodParser)
|
||||||
|
{
|
||||||
|
_qfodParser = qfodParser;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 计算标定 ΔQ 阈值
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="pureFile">纯手机充电日志文件</param>
|
||||||
|
/// <param name="foreignFile">手机+异物充电日志文件</param>
|
||||||
|
public async Task<QfodCalibrationReport> 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<QfodFileSummary> 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<int>();
|
||||||
|
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
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 单个 Qfod 文件的 ΔQ 统计
|
||||||
|
/// </summary>
|
||||||
|
public record QfodFileSummary(
|
||||||
|
string FileName,
|
||||||
|
int Count,
|
||||||
|
int Min,
|
||||||
|
int Max,
|
||||||
|
double Average,
|
||||||
|
string? ErrorMessage
|
||||||
|
);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Qfod 标定报告
|
||||||
|
/// </summary>
|
||||||
|
public record QfodCalibrationReport(
|
||||||
|
string? PureFileName,
|
||||||
|
string? ForeignFileName,
|
||||||
|
QfodFileSummary? Pure,
|
||||||
|
QfodFileSummary? Foreign,
|
||||||
|
int Threshold,
|
||||||
|
int PureMargin,
|
||||||
|
int ForeignMargin,
|
||||||
|
string? ErrorMessage
|
||||||
|
)
|
||||||
|
{
|
||||||
|
public bool IsSuccess => ErrorMessage == null;
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@ using Microsoft.Extensions.Configuration;
|
|||||||
using Microsoft.Extensions.DependencyInjection;
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
using Gpulse.WCT.DataAnalyzer.Core.Infrastructure.LocalData;
|
using Gpulse.WCT.DataAnalyzer.Core.Infrastructure.LocalData;
|
||||||
using Gpulse.WCT.DataAnalyzer.Core.Application.Parsing;
|
using Gpulse.WCT.DataAnalyzer.Core.Application.Parsing;
|
||||||
|
using Gpulse.WCT.DataAnalyzer.Core.Application.Analysis;
|
||||||
using Gpulse.WCT.DataAnalyzer.Core.Application;
|
using Gpulse.WCT.DataAnalyzer.Core.Application;
|
||||||
|
|
||||||
namespace Gpulse.WCT.DataAnalyzer.Core.Infrastructure.Extensions;
|
namespace Gpulse.WCT.DataAnalyzer.Core.Infrastructure.Extensions;
|
||||||
@@ -58,6 +59,10 @@ public static class ServiceCollectionExtensions
|
|||||||
services.AddScoped<CleanService>();
|
services.AddScoped<CleanService>();
|
||||||
services.AddScoped<AggregationService>();
|
services.AddScoped<AggregationService>();
|
||||||
|
|
||||||
|
// Analysis
|
||||||
|
services.AddScoped<PlossAnalysisService>();
|
||||||
|
services.AddScoped<QfodCalibrationService>();
|
||||||
|
|
||||||
return services;
|
return services;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
144
src/Gpulse.WCT.DataAnalyzer/Commands/AnalyzeCommand.cs
Normal file
144
src/Gpulse.WCT.DataAnalyzer/Commands/AnalyzeCommand.cs
Normal file
@@ -0,0 +1,144 @@
|
|||||||
|
using System.CommandLine;
|
||||||
|
using Gpulse.WCT.DataAnalyzer.Core.Application.Analysis;
|
||||||
|
|
||||||
|
namespace Gpulse.WCT.DataAnalyzer.Commands;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 数据分析命令 - 支持 Ploss 余量/delta_p 分析与 Qfod 标定阈值分析
|
||||||
|
/// </summary>
|
||||||
|
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<string>(
|
||||||
|
"--type",
|
||||||
|
"Analysis type: ploss or qfod"
|
||||||
|
)
|
||||||
|
{
|
||||||
|
IsRequired = true
|
||||||
|
};
|
||||||
|
|
||||||
|
var fileOption = new Option<string?>(
|
||||||
|
"--file",
|
||||||
|
"Ploss: path to a log file to analyze"
|
||||||
|
);
|
||||||
|
|
||||||
|
var pureOption = new Option<string?>(
|
||||||
|
"--pure",
|
||||||
|
"Qfod: path to the phone-only charging log file"
|
||||||
|
);
|
||||||
|
|
||||||
|
var foreignOption = new Option<string?>(
|
||||||
|
"--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");
|
||||||
|
}
|
||||||
@@ -7,6 +7,7 @@ using Gpulse.WCT.DataAnalyzer.Commands;
|
|||||||
using Gpulse.WCT.DataAnalyzer.Core.Infrastructure.LocalData;
|
using Gpulse.WCT.DataAnalyzer.Core.Infrastructure.LocalData;
|
||||||
using Gpulse.WCT.DataAnalyzer.Core.Infrastructure.Extensions;
|
using Gpulse.WCT.DataAnalyzer.Core.Infrastructure.Extensions;
|
||||||
using Gpulse.WCT.DataAnalyzer.Core.Application;
|
using Gpulse.WCT.DataAnalyzer.Core.Application;
|
||||||
|
using Gpulse.WCT.DataAnalyzer.Core.Application.Analysis;
|
||||||
|
|
||||||
namespace Gpulse.WCT.DataAnalyzer;
|
namespace Gpulse.WCT.DataAnalyzer;
|
||||||
|
|
||||||
@@ -70,6 +71,9 @@ public class Program
|
|||||||
rootCommand.AddCommand(new AggregateCommand(provider.GetRequiredService<AggregationService>()));
|
rootCommand.AddCommand(new AggregateCommand(provider.GetRequiredService<AggregationService>()));
|
||||||
rootCommand.AddCommand(new ExportCommand(provider.GetRequiredService<ExportService>()));
|
rootCommand.AddCommand(new ExportCommand(provider.GetRequiredService<ExportService>()));
|
||||||
rootCommand.AddCommand(new CleanCommand(provider.GetRequiredService<CleanService>()));
|
rootCommand.AddCommand(new CleanCommand(provider.GetRequiredService<CleanService>()));
|
||||||
|
rootCommand.AddCommand(new AnalyzeCommand(
|
||||||
|
provider.GetRequiredService<PlossAnalysisService>(),
|
||||||
|
provider.GetRequiredService<QfodCalibrationService>()));
|
||||||
|
|
||||||
return await rootCommand.InvokeAsync(args);
|
return await rootCommand.InvokeAsync(args);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user