Compare commits

...

7 Commits

Author SHA1 Message Date
Scottxjw
0667e2e9b8 feat(analyze): 新增 Ploss/Qfod 分析命令
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-14 15:36:29 +08:00
Scottxjw
b88c07356e refactor(ploss): 统一字段语义 Field9=ploss Field10=threshold
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-14 15:35:48 +08:00
Scottxjw
dea7af045b docs(requirement): 清理遗留的 QA 问题与编号错误
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-14 09:25:47 +08:00
Scottxjw
7cd08f934a feat(parse): 支持解析 txt/dat 扩展名数据文件
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-14 09:25:12 +08:00
Scottxjw
430b38b361 feat(aggregation): 按 Threshold 分桶并优化 Qfod 聚合
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-14 09:24:38 +08:00
Scottxjw
77d4bfe953 feat(cli): 配置相对路径按 exe 目录解析
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-14 09:24:12 +08:00
Scottxjw
f3472d21ee refactor: 移除 stats 统计功能
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-14 09:23:26 +08:00
21 changed files with 1093 additions and 557 deletions

View File

@@ -11,8 +11,8 @@
| `parse` | 解析日志文件到本地库 | log → local.db |
| `aggregate` | 从本地库汇总生成发布库 | local.db → release.db |
| `export` | 导出发布库到 CSV | release.db → CSV |
| `stats` | 查询本地库统计信息 | 只读 local.db |
| `clean` | 清理本地库数据(软删除) | 只写 local.db |
| `analyze` | 独立分析Ploss 余量/delta_p、Qfod 标定阈值 | log → 控制台报告 |
---
@@ -60,26 +60,7 @@ dotnet run export --type qfod --format csv
dotnet run export --format json --type qfod
```
### 2.4 stats
```bash
# 查看整体统计
dotnet run stats --summary
# 按 FOD 类型统计
dotnet run stats --by-fod-type
# 按面板统计
dotnet run stats --by-panel
# 按硬件版本统计
dotnet run stats --by-hardware
# 按 RX 类型统计
dotnet run stats --by-rx-type
```
### 2.5 clean
### 2.4 clean
```bash
# 清理指定场景
@@ -91,19 +72,36 @@ 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. 典型工作流
```bash
# 1. 解析日志
dotnet run parse --dir ./data/test_input --recursive
# 2. 查看统计
dotnet run stats --summary
# 3. 汇总生成发布库
# 2. 汇总生成发布库
dotnet run aggregate
# 4. 导出正式 CSV
# 3. 导出正式 CSV
dotnet run export --output ./data/output
```

View File

@@ -28,15 +28,15 @@ FOD-> (1) (2) (3) (4) (5) (6) (7) (8) (9) (10) (11) (12) (13) (14) (15) (16)
| (1) | Field1 | int | 待定义 |
| (2) | Field2 | int | 待定义 |
| (3) | Field3 | int | 待定义 |
| (4) | Field4 | int | 待定义 |
| (5) | Field5 | int | 待定义 |
| (4) | Field4 | int | RX power - 接收端功率 |
| (5) | Field5 | int | TX power - 发射端功率 |
| (6) | Field6 | int | 待定义 |
| (7) | Field7 | int | 待定义 |
| (8) | Field8 | int | 待定义 |
| (9) | Field9 | int | 待定义 |
| (10) | Field10 | int | 待定义 |
| (11) | Field11 | int | 待定义 |
| (12) | Field12 | int | 待定义 |
| (9) | Field9 | int | ploss - 功率损耗计算值 |
| (10) | Field10 | int | threshold - 阈值 |
| (11) | Field11 | int | 触发次数 - 当 Field9 > Field10ploss 大于阈值时计数超过5次触发FOD保护 |
| (12) | Field12 | int | PFOD result 标志 - 触发时置位 |
| (13) | Field13 | int | 待定义 |
| (14) | Field14 | int | 待定义 |
| (15) | Field15 | int | 待定义 |
@@ -99,127 +99,78 @@ private static readonly Regex FodPattern = new(
## 4. 解析器实现
> 当前实现位于 `src/Gpulse.WCT.DataAnalyzer.Core/Application/Parsing/PlossParser.cs`,实现 `IParser<PlossRecord>` 接口。
解析分两步:
1. **单行识别**`TryMatch(line)` 依次尝试 `HeaderPattern``FodPattern``LegacyPattern`,返回匹配类型(`None/Header/Fod/Legacy`),避免重复正则匹配。
2. **多行合并**`ParseService` 检测到 header 行后暂存,下一行为 Ploss 行时调用 `ParseMultiLine([header, fod], scenarioId)` 合并为一条完整记录;文件末尾残留的 header 行记为解析错误。
```csharp
// Parsers/PlossParser.cs
using System.Text.RegularExpressions;
using Gpulse.WCT.DataAnalyzer.Models;
public enum MatchType { None, Header, Fod, Legacy }
namespace Gpulse.WCT.DataAnalyzer.Parsers;
public class PlossParser : IParser<PlossRecord>
public (MatchType Type, Match Match) TryMatch(string line)
{
private static readonly Regex HeaderPattern = new(
@"^pow_loss\s*=\s*(\d+),\s*delta_p\s*=\s*(\d+)$",
RegexOptions.Compiled
);
private static readonly Regex FodPattern = new(
@"^FOD->\s+(-?\d+)\s+(-?\d+)\s+(-?\d+)\s+(-?\d+)\s+(-?\d+)\s+(-?\d+)\s+(-?\d+)\s+(-?\d+)\s+(-?\d+)\s+(-?\d+)\s+(-?\d+)\s+(-?\d+)\s+(-?\d+)\s+(-?\d+)\s+(-?\d+)\s+(-?\d+)$",
RegexOptions.Compiled
);
public bool CanParse(string line) => HeaderPattern.IsMatch(line) || FodPattern.IsMatch(line);
public ParseResult<PlossRecord> Parse(string line, Guid scenarioId)
{
// 尝试匹配第一行
var headerMatch = HeaderPattern.Match(line);
if (headerMatch.Success)
{
return ParseHeaderLine(headerMatch, scenarioId);
}
if (headerMatch.Success) return (MatchType.Header, headerMatch);
// 尝试匹配第二行
var fodMatch = FodPattern.Match(line);
if (fodMatch.Success)
{
return ParseFodLine(fodMatch, scenarioId);
}
if (fodMatch.Success) return (MatchType.Fod, fodMatch);
return ParseResult<PlossRecord>.Failure(
"FORMAT_MISMATCH",
"Line does not match Ploss pattern"
);
}
var legacyMatch = LegacyPattern.Match(line);
if (legacyMatch.Success) return (MatchType.Legacy, legacyMatch);
private ParseResult<PlossRecord> ParseHeaderLine(Match match, Guid scenarioId)
{
try
{
var record = new PlossRecord
{
ScenarioId = scenarioId,
PowLoss = int.Parse(match.Groups[1].Value),
DeltaP = int.Parse(match.Groups[2].Value),
IsHeaderLine = true
};
return ParseResult<PlossRecord>.Success(record);
}
catch (Exception ex)
{
return ParseResult<PlossRecord>.Failure(
"TYPE_CONVERSION",
$"Failed to convert header field: {ex.Message}"
);
}
}
return (MatchType.None, Match.Empty);
}
private ParseResult<PlossRecord> ParseFodLine(Match match, Guid scenarioId)
{
try
{
var record = new PlossRecord
{
ScenarioId = scenarioId,
Field1 = int.Parse(match.Groups[1].Value),
Field2 = int.Parse(match.Groups[2].Value),
Field3 = int.Parse(match.Groups[3].Value),
Field4 = int.Parse(match.Groups[4].Value),
Field5 = int.Parse(match.Groups[5].Value),
Field6 = int.Parse(match.Groups[6].Value),
Field7 = int.Parse(match.Groups[7].Value),
Field8 = int.Parse(match.Groups[8].Value),
Field9 = int.Parse(match.Groups[9].Value),
Field10 = int.Parse(match.Groups[10].Value),
Field11 = int.Parse(match.Groups[11].Value),
Field12 = int.Parse(match.Groups[12].Value),
Field13 = int.Parse(match.Groups[13].Value),
Field14 = int.Parse(match.Groups[14].Value),
Field15 = int.Parse(match.Groups[15].Value),
Field16 = int.Parse(match.Groups[16].Value),
IsHeaderLine = false
};
return ParseResult<PlossRecord>.Success(record);
}
catch (Exception ex)
{
return ParseResult<PlossRecord>.Failure(
"TYPE_CONVERSION",
$"Failed to convert FOD field: {ex.Message}"
);
}
}
public bool CanParse(string line) => TryMatch(line).Type != MatchType.None;
public bool IsMultiLineStart(string line) => TryMatch(line).Type == MatchType.Header;
public ParseResult<PlossRecord>? ParseMultiLine(string[] lines, Guid scenarioId)
{
if (lines.Length < 2 || lines[0] == null || lines[1] == null)
return null;
var headerMatch = HeaderPattern.Match(lines[0].Trim());
var fodMatch = FodPattern.Match(lines[1].Trim());
if (!headerMatch.Success || !fodMatch.Success)
return null;
// 第一行 header 组1/组2 → PowLoss/DeltaP
// 第二行 FOD 组1~16 → Field1~Field16
return ParseResult<PlossRecord>.Success(new PlossRecord { /* ... */ });
}
```
> **注意**:实际实现中,两行日志需要合并为一条完整记录。可以使用 `PlossRecordMerger` 或在业务逻辑层处理合并。
**多行合并ParseMultiLine捕获组映射**
| 来源 | 捕获组 | 字段 |
|------|--------|------|
| 第一行 header | Group 1 / Group 2 | PowLoss / DeltaP |
| 第二行 FOD | Group 1 ~ Group 16 | Field1 ~ Field16 |
> **旧单行格式**:无 header 行,`Field1~13` 直接来自 `FOD->` 后 13 个数字,`Field14~16` 补默认值 0`PowLoss/DeltaP` 为 null。字段语义以 `PlossRecord` 实体注释为准。
---
## 5. 核心判定逻辑
> 字段语义以 `PlossRecord` 实体注释为准:`Field9` = ploss功率损耗计算值、`Field10` = threshold阈值、`Field11` = 触发次数。
### 5.1 FOD 触发条件
```
if (Ploss > Threshold) → 计数累积
if (Count > Limit) → 触发 FOD 报警,停止充电
if (Field9 > Field10) → Field11 计数累积
if (Field11 > 5) → 触发 FOD 报警,停止充电
```
> **说明**:触发条件统一为 `Field9 > Field10`ploss > threshold实体 `Field11` 注释已同步此口径。
### 5.2 安全余量计算
```csharp
// 派生属性(不落库)
Margin = Threshold - Ploss
// 派生属性(不落库) = Field10 - Field9
Margin = Field10 - Field9
```
| Margin 状态 | 含义 |

View File

@@ -75,7 +75,7 @@ sequenceDiagram
CLI->>AS: AggregateAsync()
AS->>LDB: 读取所有 PlossRecord + 关联维度
AS->>AS: 按 (车厂, 车型, 手机厂商, 型号) 分组
AS->>AS: 计算 9 个功率列平均值
AS->>AS: 按 Threshold(Field9) 分桶计算 9 个功率列
AS->>AS: 计算 Q值/Q基值/P-Q系数
AS->>RDB: DELETE + INSERT (事务)
AS-->>CLI: AggregationReport
@@ -89,12 +89,14 @@ sequenceDiagram
| 车型 (CarModel) | 配置映射 `Aggregation:CarModelMappings` | fallback 为 TxHardware.Version |
| 手机厂商 (PhoneBrand) | RxType.Name 拆分 | 取第一个分隔符前部分 |
| 型号 (PhoneModel) | RxType.Name 拆分 | 取第一个分隔符后部分 |
| 350mW2250mW9列 | PlossRecord.Field7 | 按功率匹配后取平均值 |
| 350mW2250mW9列 | PlossRecord.Field7 | 按 Threshold(Field9) 分桶(记录阈值=档位),取 Field7 平均值 |
| Q值 (QValue) | QfodRecord.CurrentQ | 平均值 |
| Q基值 (QBaseValue) | QfodRecord.RawQ | 平均值 |
| P-Q值系数 (PqCoefficient) | PlossRecord.DeltaP | 平均值 |
| 谐振频率 (ResonanceFrequency) | 暂无 | 允许为空 |
> 各列的详细计算规则(含公式、功率匹配与空值逻辑)见 [§5 输出 CSV 各列计算详解](#5-输出-csv-各列计算详解)。
**替代配置:**
配置文件 `appsettings.json` 中可添加映射:
@@ -140,7 +142,64 @@ sequenceDiagram
---
## 5. 数据库隔离
## 5. 输出 CSV 各列计算详解
> 最终 `WCT-ChargingParameterDatabase.csv` 的每一列都在 aggregate 阶段由 `AggregationService`
> 计算写入 release.dbexport 阶段仅按 (车厂, 车型, 手机厂商, 型号) 排序后原样写出。
> 所有数值列均按同一个分组键聚合release.db 中每条记录对应 CSV 中的一行。
### 5.1 分组键(一行 = 一个分组)
| CSV 列 | 计算方式 |
|--------|----------|
| 车厂 | `Aggregation:TxPanelMappings` 映射 TxPanel.Name未配置映射时 fallback 为 TxPanel.Name |
| 车型 | `Aggregation:CarModelMappings` 映射 TxHardware.Version未配置映射时 fallback 为 TxHardware.Version |
| 手机厂商 | RxType.Name 按第一个分隔符(`/``_``:`)拆分的**前半部分** |
| 型号 | 分隔符的**后半部分** |
- RxType.Name 无分隔符时:手机厂商固定为 `Unknown`,型号取整个 RxType.Name。
- **分组基于 Ploss 记录**:只有 Qfod 记录、没有任何 Ploss 记录的场景不会产生发布记录,
因而不会出现在 CSV 中。
### 5.2 功率列350mW / 500mW / 750mW / 1000mW / 1250mW / 1500mW / 1750mW / 2000mW / 2250mW
- **语义**:列头即"允许的最大损耗限值"(安全红线)档位,对应需求文档 Table 4
0~5W→350、5~10W→500、10~15W→750、15~30W→1000、30~50W→1250
- **来源字段**:列值取 Ploss 记录的 `Field7`(实际损耗)。
- **分桶规则**:一条记录按其 `Field9`Threshold的值落入对应档位列
`Field9 == power` 的记录,其 `Field7` 计入 `power` 列的平均值;`Field9` 不在列头集合中的记录不进任何列。
- **聚合**:取分组内落入该档位的 `Field7` 平均值(有效值取平均,全部为空则留空)。
- **字段编号说明**:需求文档称阈值为逻辑编号 (8),但当前解析数据中阈值实际落在 `field_9`
`field_8` 为负数、非阈值)。解析器按原始位置逐字段存储,字段语义以实体注释与实测数据为准。
### 5.3 Q值 / Q基值
- **来源字段**Qfod 记录的 `CurrentQ` / `RawQ`
- **分场景预聚合**:每个测试场景先对 Qfod 记录求 `ΣCurrentQ``ΣRawQ``Count`(数据库端完成)。
- **组内加权累加**:分组内每一条 Ploss 记录,按其所属场景把该场景的预聚合值累加一次。
同一场景出现 k 条 Ploss 记录时,其 Sum 与 Count 同倍放大,商保持不变,
等效于将该场景全部 Qfod 明细拉平后求平均。
- **结果**Q值 = ΣCurrentQ / ΣCountQ基值 = ΣRawQ / ΣCount。
- **空值**:分组内没有任何 Qfod 记录时,两列均留空。
### 5.4 P-Q值系数
- **来源字段**Ploss 两行格式第一行的 `delta_p`(旧单行格式无此字段,为 null
- **聚合**:分组内所有 Ploss 记录 `DeltaP` 的平均值(忽略空值)。
- **空值**:分组内 `DeltaP` 全部为空时留空。
### 5.5 谐振频率
- 当前版本固定为 `null`**未实现计算**,导出时该列恒为空。
### 5.6 数值输出格式
- 数字使用 invariant culture 输出 double 全精度(如 `2531.5737704918033`),不四舍五入。
- CSV 为 UTF-8 带 BOMRFC 4180 转义。
---
## 6. 数据库隔离
```text
local.db release.db

View File

@@ -45,7 +45,7 @@ log 文件
│ Application Layer │
│ ┌───────────────┐ ┌──────────────┐ ┌────────────┐ ┌──────────────┐ │
│ │ LocalIngestion│ │ Publishing │ │ Exporting │ │Administration│ │
│ │ ParseService │ │AggregationSvc│ │ExportSvc │ │ StatsService │ │
│ │ ParseService │ │AggregationSvc│ │ExportSvc │ │ CleanService │ │
│ │ ScenarioSvc │ │ThresholdCalc │ │ │ │ CleanService │ │
│ │ DimensionSvc │ │ │ │ │ │ │ │
│ └───────────────┘ └──────────────┘ └────────────┘ └──────────────┘ │
@@ -92,7 +92,6 @@ src/
│ ├── Commands/ # 命令实现
│ │ ├── ParseCommand.cs
│ │ ├── AggregateCommand.cs
│ │ ├── StatsCommand.cs
│ │ ├── ExportCommand.cs
│ │ └── CleanCommand.cs
│ ├── Program.cs
@@ -115,7 +114,6 @@ src/
│ ├── Exporting/ # 导出
│ │ └── ExportService.cs
│ └── Administration/ # 管理
│ ├── StatsService.cs
│ └── CleanService.cs
├── Domain/ # 领域实体
│ └── Models/

View File

@@ -58,7 +58,6 @@
| 类 | 职责 |
|----|------|
| `StatsService` | 本地库统计查询 |
| `CleanService` | 软删除管理 |
---

View File

@@ -8,34 +8,44 @@
**表名**`ploss_record`
**描述**:存储 Ploss FOD 格式解析数据,对应日志格式 `FOD-> (1)...(13)`
**描述**:存储 Ploss FOD 格式解析数据,对应两行日志格式
```
pow_loss = X, delta_p = Y
FOD-> (1) (2) ... (16)
```
---
## 2. 字段定义
> **类型修正说明**:需求文档中 rx_power, tx_power, vcoil, vin, isns, pwm_duty 为 `uint16`0-65535但 SQLite 不支持无符号类型。实际数据如 vcoil=35377 超出了 SMALLINT-32768~32767范围因此这些字段使用 INT 类型存储。
| 列名 | 类型 | 约束 | 说明 |
|------|------|------|------|
| id | UUID | PK, NOT NULL | 主键 |
| scenario_id | UUID | FK, NOT NULL | 关联测试场景 |
| rx_type | TINYINT | NOT NULL | 接收端类型标识 |
| rx_power | INT | NOT NULL | 接收端功率mW原 uint16 |
| tx_power | INT | NOT NULL | 发射端功率mW原 uint16 |
| vcoil | INT | NOT NULL | 发射线圈电压,原 uint16 |
| vin | INT | NOT NULL | 输入电压,原 uint16 |
| isns | INT | NOT NULL | 输入电流采样值,原 uint16 |
| ploss | INT | NOT NULL | **核心值**:计算损耗(负数=安全) |
| threshold | INT | NOT NULL | **安全红线**:当前功率段阈值 |
| trigger_count | INT | NOT NULL, DEFAULT 0 | 触发计数器 |
| fod_result | INT | NOT NULL, DEFAULT 0 | FOD判定结果 |
| protocol_type | TINYINT | NOT NULL | 充电协议类型 |
| pwm_duty | INT | NOT NULL | PWM占空比原 uint16 |
| coil_index | TINYINT | NOT NULL | 充电线圈索引 |
| pow_loss | INT | NULL | 第一行:功率损耗值 |
| delta_p | INT | NULL | 第一行:功率差值 |
| field_1 | INT | NOT NULL | Field1语义待定义 |
| field_2 | INT | NOT NULL | Field2语义待定义 |
| field_3 | INT | NOT NULL | Field3语义待定义 |
| field_4 | INT | NOT NULL | Field4: RX power - 接收端功率 |
| field_5 | INT | NOT NULL | Field5: TX power - 发射端功率 |
| field_6 | INT | NOT NULL | Field6语义待定义 |
| field_7 | INT | NOT NULL | Field7语义待定义 |
| field_8 | INT | NOT NULL | Field8语义待定义 |
| field_9 | INT | NOT NULL, DEFAULT 0 | Field9: ploss - 功率损耗计算值 |
| field_10 | INT | NOT NULL, DEFAULT 0 | Field10: threshold - 阈值 |
| field_11 | INT | NOT NULL | Field11: 触发次数 - 当 Field9 > Field10 时计数超过5次触发FOD保护 |
| field_12 | INT | NOT NULL | Field12: PFOD result 标志 - 触发时置位 |
| field_13 | INT | NOT NULL, DEFAULT 0 | Field13语义待定义 |
| field_14 | INT | NOT NULL, DEFAULT 0 | Field14语义待定义 |
| field_15 | INT | NOT NULL, DEFAULT 0 | Field15语义待定义 |
| field_16 | INT | NOT NULL, DEFAULT 0 | Field16语义待定义 |
| is_deleted | BOOLEAN | NOT NULL, DEFAULT FALSE | 软删除标记 |
| created_at | TIMESTAMP | NOT NULL, DEFAULT NOW() | 创建时间 |
> **字段语义**以 `PlossRecord` 实体注释为准。
---
## 3. 外键
@@ -52,9 +62,9 @@
| 字段 | 计算公式 | 说明 |
|------|----------|------|
| margin | threshold - ploss | 安全余量(>0 安全,<0 危险) |
| margin | field_10 - field_9 | 安全余量(>0 安全,<0 危险) |
> **使用限制**`Margin` 属性通过 EF Core `Ignore()` 排除映射,仅用于内存计算。不可在数据库查询中直接使用(如 `WHERE Margin > 0`),需改用 `WHERE (threshold - ploss) > 0` 或先加载到内存再计算。
> **使用限制**`Margin` 属性通过 EF Core `Ignore()` 排除映射,仅用于内存计算。不可在数据库查询中直接使用(如 `WHERE Margin > 0`),需改用 `WHERE (field_10 - field_9) > 0` 或先加载到内存再计算。
---
@@ -63,10 +73,12 @@
| 索引名 | 字段 | 用途 |
|--------|------|------|
| idx_ploss_scenario | scenario_id | 按测试场景查询 |
| idx_ploss_rx_power | rx_power | 阈值表匹配 |
| idx_ploss_ploss | ploss | 损耗值查询 |
| idx_ploss_threshold | threshold | 阈值查询 |
| idx_ploss_fod_result | fod_result | FOD结果筛选 |
| idx_ploss_field_2 | field_2 | 字段查询 |
| idx_ploss_field_7 | field_7 | 字段查询 |
| idx_ploss_field_8 | field_8 | 字段查询 |
| idx_ploss_field_10 | field_10 | 字段查询 |
| idx_ploss_pow_loss | pow_loss | 第一行损耗值查询 |
| idx_ploss_delta_p | delta_p | 第一行功率差值查询 |
---
@@ -75,51 +87,73 @@
### 实体类
```csharp
// Models/PlossRecord.cs
namespace Gpulse.WCT.DataAnalyzer.Models;
// Domain/Local/PlossRecord.cs
namespace Gpulse.WCT.DataAnalyzer.Core.Domain.Local;
public class PlossRecord
{
public Guid Id { get; set; } = Guid.NewGuid();
public Guid ScenarioId { get; set; }
public byte RxType { get; set; }
public short RxPower { get; set; }
public short TxPower { get; set; }
public short Vcoil { get; set; }
public short Vin { get; set; }
public short Isns { get; set; }
/// <summary>核心判定值:负数表示安全</summary>
public int Ploss { get; set; }
// ===== 第一行字段nullable兼容旧数据 =====
/// <summary>功率损耗值(来自第一行)</summary>
public int? PowLoss { get; set; }
/// <summary>安全红线:当前功率段阈值</summary>
public int Threshold { get; set; }
/// <summary>功率差值(来自第一行)</summary>
public int? DeltaP { get; set; }
// ===== 第二行字段16个通用字段均支持负数 =====
public int Field1 { get; set; }
public int Field2 { get; set; }
public int Field3 { get; set; }
/// <summary>Field4: RX power - 接收端功率</summary>
public int Field4 { get; set; }
/// <summary>Field5: TX power - 发射端功率</summary>
public int Field5 { get; set; }
public int Field6 { get; set; }
public int Field7 { get; set; }
public int Field8 { get; set; }
/// <summary>Field9: ploss - 功率损耗计算值</summary>
public int Field9 { get; set; }
/// <summary>Field10: threshold - 阈值</summary>
public int Field10 { get; set; }
/// <summary>Field11: 触发次数 - 当 Field9 > Field10ploss 大于阈值时计数超过5次触发FOD保护</summary>
public int Field11 { get; set; }
/// <summary>Field12: PFOD result 标志 - 触发时置位</summary>
public int Field12 { get; set; }
public int Field13 { get; set; }
public int Field14 { get; set; }
public int Field15 { get; set; }
public int Field16 { get; set; }
public short TriggerCount { get; set; } = 0;
public short FodResult { get; set; } = 0;
public byte ProtocolType { get; set; }
public short PwmDuty { get; set; }
public byte CoilIndex { get; set; }
public bool IsDeleted { get; set; } = false;
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
// Navigation Properties
public TestScenario Scenario { get; set; } = null!;
/// <summary>派生属性:安全余量(不落库)</summary>
public int Margin => Threshold - Ploss;
/// <summary>派生属性:安全余量(不落库) = Field10 - Field9</summary>
public int Margin => Field10 - Field9;
}
```
### 配置类
```csharp
// Data/Configurations/PlossRecordConfiguration.cs
// Infrastructure/LocalData/Configurations/PlossRecordConfiguration.cs
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
using Gpulse.WCT.DataAnalyzer.Models;
using Gpulse.WCT.DataAnalyzer.Core.Domain.Local;
namespace Gpulse.WCT.DataAnalyzer.Data.Configurations;
namespace Gpulse.WCT.DataAnalyzer.Core.Infrastructure.LocalData.Configurations;
public class PlossRecordConfiguration : IEntityTypeConfiguration<PlossRecord>
{
@@ -134,59 +168,27 @@ public class PlossRecordConfiguration : IEntityTypeConfiguration<PlossRecord>
.IsRequired()
.HasColumnName("scenario_id");
builder.Property(e => e.RxType)
.IsRequired()
.HasColumnName("rx_type");
// ===== 第一行字段nullable =====
builder.Property(e => e.PowLoss).IsRequired(false).HasColumnName("pow_loss");
builder.Property(e => e.DeltaP).IsRequired(false).HasColumnName("delta_p");
builder.Property(e => e.RxPower)
.IsRequired()
.HasColumnName("rx_power");
builder.Property(e => e.TxPower)
.IsRequired()
.HasColumnName("tx_power");
builder.Property(e => e.Vcoil)
.IsRequired()
.HasColumnName("vcoil");
builder.Property(e => e.Vin)
.IsRequired()
.HasColumnName("vin");
builder.Property(e => e.Isns)
.IsRequired()
.HasColumnName("isns");
builder.Property(e => e.Ploss)
.IsRequired()
.HasColumnName("ploss");
builder.Property(e => e.Threshold)
.IsRequired()
.HasColumnName("threshold");
builder.Property(e => e.TriggerCount)
.IsRequired()
.HasDefaultValue((short)0)
.HasColumnName("trigger_count");
builder.Property(e => e.FodResult)
.IsRequired()
.HasDefaultValue((short)0)
.HasColumnName("fod_result");
builder.Property(e => e.ProtocolType)
.IsRequired()
.HasColumnName("protocol_type");
builder.Property(e => e.PwmDuty)
.IsRequired()
.HasColumnName("pwm_duty");
builder.Property(e => e.CoilIndex)
.IsRequired()
.HasColumnName("coil_index");
// ===== 第二行字段(通用命名) =====
builder.Property(e => e.Field1).IsRequired().HasColumnName("field_1");
builder.Property(e => e.Field2).IsRequired().HasColumnName("field_2");
builder.Property(e => e.Field3).IsRequired().HasColumnName("field_3");
builder.Property(e => e.Field4).IsRequired().HasColumnName("field_4");
builder.Property(e => e.Field5).IsRequired().HasColumnName("field_5");
builder.Property(e => e.Field6).IsRequired().HasColumnName("field_6");
builder.Property(e => e.Field7).IsRequired().HasColumnName("field_7");
builder.Property(e => e.Field8).IsRequired().HasColumnName("field_8");
builder.Property(e => e.Field9).IsRequired().HasDefaultValue(0).HasColumnName("field_9");
builder.Property(e => e.Field10).IsRequired().HasDefaultValue(0).HasColumnName("field_10");
builder.Property(e => e.Field11).IsRequired().HasColumnName("field_11");
builder.Property(e => e.Field12).IsRequired().HasColumnName("field_12");
builder.Property(e => e.Field13).IsRequired().HasDefaultValue(0).HasColumnName("field_13");
builder.Property(e => e.Field14).IsRequired().HasDefaultValue(0).HasColumnName("field_14");
builder.Property(e => e.Field15).IsRequired().HasDefaultValue(0).HasColumnName("field_15");
builder.Property(e => e.Field16).IsRequired().HasDefaultValue(0).HasColumnName("field_16");
builder.Property(e => e.IsDeleted)
.IsRequired()
@@ -195,7 +197,7 @@ public class PlossRecordConfiguration : IEntityTypeConfiguration<PlossRecord>
builder.Property(e => e.CreatedAt)
.IsRequired()
.HasDefaultValueSql("NOW()")
.HasDefaultValueSql("datetime('now')")
.HasColumnName("created_at");
// 派生属性不落库
@@ -203,16 +205,21 @@ public class PlossRecordConfiguration : IEntityTypeConfiguration<PlossRecord>
// 索引
builder.HasIndex(e => e.ScenarioId).HasDatabaseName("idx_ploss_scenario");
builder.HasIndex(e => e.RxPower).HasDatabaseName("idx_ploss_rx_power");
builder.HasIndex(e => e.Ploss).HasDatabaseName("idx_ploss_ploss");
builder.HasIndex(e => e.Threshold).HasDatabaseName("idx_ploss_threshold");
builder.HasIndex(e => e.FodResult).HasDatabaseName("idx_ploss_fod_result");
builder.HasIndex(e => e.Field2).HasDatabaseName("idx_ploss_field_2");
builder.HasIndex(e => e.Field7).HasDatabaseName("idx_ploss_field_7");
builder.HasIndex(e => e.Field8).HasDatabaseName("idx_ploss_field_8");
builder.HasIndex(e => e.Field10).HasDatabaseName("idx_ploss_field_10");
builder.HasIndex(e => e.PowLoss).HasDatabaseName("idx_ploss_pow_loss");
builder.HasIndex(e => e.DeltaP).HasDatabaseName("idx_ploss_delta_p");
// 关系配置
builder.HasOne(e => e.Scenario)
.WithMany(e => e.PlossRecords)
.HasForeignKey(e => e.ScenarioId)
.IsRequired();
.OnDelete(DeleteBehavior.Cascade);
// 全局查询过滤器
builder.HasQueryFilter(e => !e.IsDeleted);
}
}
```
@@ -221,7 +228,7 @@ public class PlossRecordConfiguration : IEntityTypeConfiguration<PlossRecord>
## 7. 动态阈值表
> 阈值根据 RxPower 动态查表(不落库)
> 阈值根据 RxPower 动态查表(不落库,业务规则
| Rx Power 区间 | Threshold | 备注 |
|---------------|-----------|------|
@@ -230,15 +237,3 @@ public class PlossRecordConfiguration : IEntityTypeConfiguration<PlossRecord>
| 10000-15000 mW | 750 mW | |
| 15000-30000 mW | 1000 mW | |
| 30000-50000 mW | 1250 mW | 高功率 |
```csharp
// Services/ThresholdCalculator.cs
public static int GetThresholdByPower(int rxPower)
{
if (rxPower <= 5000) return 350;
if (rxPower <= 10000) return 500;
if (rxPower <= 15000) return 750;
if (rxPower <= 30000) return 1000;
return 1250;
}
```

View 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下有意义旧单行格式无 headerPowLoss/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

View 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 数值区间的参考;真实数据中「纯手机」与「手机+异物」分属两个日志文件。

View File

@@ -107,7 +107,7 @@
1. 从 Log 提取两种场景的 ΔQ
- 场景 A纯手机ΔQ = 25最大值
- 场景 B手机+异物ΔQ = 40 (最小值)
1. 计算阈值Threshold = (25 + 40) / 2 = 32
2. 计算阈值Threshold = (25 + 40) / 2 = 32
### 2.1.2 分析步骤
@@ -197,10 +197,3 @@ Margin = Threshold - Ploss
- 调整 delta_p 使曲线回归安全区(负值区)
# 3 QA
## 3.1 导入之后会有脏数据污染吗? 人工删除?
## 3.2 FOD Type 具体的是什么,对数据分析有影响吗?和文件名定义的有什么关系
## 3.3 Ploss fod 日志格式和 实际日志中的对应不上
## 3.4 数据是孤立的

View File

@@ -1,142 +0,0 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
using Gpulse.WCT.DataAnalyzer.Core.Infrastructure.LocalData;
using Gpulse.WCT.DataAnalyzer.Core.Domain.Local;
namespace Gpulse.WCT.DataAnalyzer.Core.Application;
/// <summary>
/// 统计查询服务 - 使用数据库端聚合优化内存效率
/// </summary>
public class StatsService
{
private readonly WctMinerDbContext _context;
private readonly ILogger<StatsService> _logger;
public StatsService(WctMinerDbContext context, ILogger<StatsService> logger)
{
_context = context;
_logger = logger;
}
/// <summary>
/// 获取整体统计摘要(并行查询优化)
/// </summary>
public async Task<StatsSummary> GetSummaryAsync()
{
// 使用 Task.WhenAll 并行执行多个查询
var tasks = new[]
{
_context.TestScenarios.CountAsync(),
_context.QfodRecords.CountAsync(),
_context.PlossRecords.CountAsync(),
_context.TxPanels.CountAsync(),
_context.TxHardwares.CountAsync(),
_context.TxSoftwares.CountAsync(),
_context.RxTypes.CountAsync()
};
var results = await Task.WhenAll(tasks);
var summary = new StatsSummary(
results[0], results[1], results[2],
results[3], results[4], results[5], results[6]
);
_logger.LogDebug("统计摘要: Scenarios={ScenarioCount}, Qfod={QfodCount}, Ploss={PlossCount}",
summary.ScenarioCount, summary.QfodCount, summary.PlossCount);
return summary;
}
/// <summary>
/// 按FOD类型统计Qfod记录数据库端聚合
/// </summary>
public async Task<List<FodTypeStats>> GetStatsByFodTypeAsync()
{
// 使用数据库端 GroupBy
var stats = await _context.QfodRecords
.GroupBy(r => r.FodType)
.Select(g => new FodTypeStats(g.Key, g.Count()))
.OrderBy(s => s.FodType)
.ToListAsync();
_logger.LogDebug("按 FOD 类型统计完成: {Count} 种类型", stats.Count);
return stats;
}
/// <summary>
/// 按面板类型统计(数据库端聚合 - 使用维度表反向统计)
/// </summary>
public async Task<List<DimensionStats>> GetStatsByPanelAsync()
{
// 方式: 使用维度表反向统计(更高效)
var stats = await _context.TxPanels
.Select(p => new DimensionStats(
p.Name,
p.TestScenarios.Count()
))
.OrderByDescending(s => s.Count)
.ToListAsync();
_logger.LogDebug("按面板类型统计完成: {Count} 种面板", stats.Count);
return stats;
}
/// <summary>
/// 按硬件版本统计(数据库端聚合)
/// </summary>
public async Task<List<DimensionStats>> GetStatsByHardwareAsync()
{
var stats = await _context.TxHardwares
.Select(h => new DimensionStats(
h.Version,
h.TestScenarios.Count()
))
.OrderByDescending(s => s.Count)
.ToListAsync();
_logger.LogDebug("按硬件版本统计完成: {Count} 种版本", stats.Count);
return stats;
}
/// <summary>
/// 按RX类型统计数据库端聚合
/// </summary>
public async Task<List<DimensionStats>> GetStatsByRxTypeAsync()
{
var stats = await _context.RxTypes
.Select(r => new DimensionStats(
r.Name,
r.TestScenarios.Count()
))
.OrderByDescending(s => s.Count)
.ToListAsync();
_logger.LogDebug("按 RX 类型统计完成: {Count} 种类型", stats.Count);
return stats;
}
}
/// <summary>
/// 整体统计摘要
/// </summary>
public record StatsSummary(
int ScenarioCount,
int QfodCount,
int PlossCount,
int PanelCount,
int HardwareCount,
int SoftwareCount,
int RxTypeCount
);
/// <summary>
/// FOD类型统计
/// </summary>
public record FodTypeStats(byte FodType, int Count);
/// <summary>
/// 维度统计
/// </summary>
public record DimensionStats(string Name, int Count);

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -20,6 +20,11 @@ public class ParseService
private readonly ILogger<ParseService> _logger;
private readonly int _batchSize;
/// <summary>
/// 支持的输入数据文件扩展名(大小写不敏感)
/// </summary>
private static readonly string[] SupportedExtensions = [".log", ".txt", ".dat"];
public ParseService(
WctMinerDbContext context,
ScenarioService scenarioService,
@@ -169,12 +174,16 @@ public class ParseService
}
var searchOption = recursive ? SearchOption.AllDirectories : SearchOption.TopDirectoryOnly;
var logFiles = dirInfo.GetFiles("*.log", searchOption);
var dataFiles = dirInfo.EnumerateFiles("*", searchOption)
.Where(f => SupportedExtensions.Contains(f.Extension, StringComparer.OrdinalIgnoreCase))
.OrderBy(f => f.Name)
.ToArray();
_logger.LogInformation("发现 {FileCount} 个日志文件", logFiles.Length);
_logger.LogInformation("发现 {FileCount} 个数据文件({Extensions}",
dataFiles.Length, string.Join("/", SupportedExtensions));
var reports = new List<ParseReport>();
foreach (var file in logFiles)
foreach (var file in dataFiles)
{
var report = await ParseFileAsync(file.FullName, force);
reports.Add(report);

View File

@@ -144,19 +144,19 @@ public class PlossParser : IParser<PlossRecord>
PowLoss = null,
DeltaP = null,
// 映射到新字段名
Field1 = int.Parse(match.Groups[1].Value), // RxType
Field2 = int.Parse(match.Groups[2].Value), // RxPower
Field3 = int.Parse(match.Groups[3].Value), // TxPower
Field4 = int.Parse(match.Groups[4].Value), // Vcoil
Field5 = int.Parse(match.Groups[5].Value), // Vin
Field6 = int.Parse(match.Groups[6].Value), // Isns
Field7 = int.Parse(match.Groups[7].Value), // Ploss
Field8 = int.Parse(match.Groups[8].Value), // Threshold
Field9 = int.Parse(match.Groups[9].Value), // TriggerCount
Field10 = int.Parse(match.Groups[10].Value), // FodResult
Field11 = int.Parse(match.Groups[11].Value), // ProtocolType
Field12 = int.Parse(match.Groups[12].Value), // PwmDuty
Field13 = int.Parse(match.Groups[13].Value), // CoilIndex
Field1 = int.Parse(match.Groups[1].Value),
Field2 = int.Parse(match.Groups[2].Value),
Field3 = int.Parse(match.Groups[3].Value),
Field4 = int.Parse(match.Groups[4].Value), // RX power
Field5 = int.Parse(match.Groups[5].Value), // TX power
Field6 = int.Parse(match.Groups[6].Value),
Field7 = int.Parse(match.Groups[7].Value),
Field8 = int.Parse(match.Groups[8].Value),
Field9 = int.Parse(match.Groups[9].Value), // ploss
Field10 = int.Parse(match.Groups[10].Value), // threshold
Field11 = int.Parse(match.Groups[11].Value), // 触发次数
Field12 = int.Parse(match.Groups[12].Value), // PFOD result
Field13 = int.Parse(match.Groups[13].Value),
// 旧格式没有Field14-16使用默认值
Field14 = 0,
Field15 = 0,

View File

@@ -9,7 +9,7 @@ namespace Gpulse.WCT.DataAnalyzer.Core.Application;
/// <summary>
/// 从本地解析库生成独立正式发布库。
/// 当前业务规则:功率列使用 PlossField7重复结果取平均值缺失值保留为空。
/// 当前业务规则:功率列按 Threshold(Field9) 分桶、取值 Ploss(Field7),重复结果取平均值;缺失值保留为空。
/// </summary>
public class AggregationService
{
@@ -34,9 +34,10 @@ public class AggregationService
public async Task<AggregationReport> AggregateAsync(bool rebuild = true, CancellationToken cancellationToken = default)
{
var rows = await LoadRowsAsync(cancellationToken);
var qfodAggregates = await LoadQfodAggregatesAsync(cancellationToken);
var records = rows
.GroupBy(r => BuildKey(r.Scenario))
.Select(group => CreateRecord(group.Key, group.ToList()))
.Select(group => CreateRecord(group.Key, group.ToList(), qfodAggregates))
.ToList();
await using var transaction = await _releaseContext.Database.BeginTransactionAsync(cancellationToken);
@@ -61,8 +62,11 @@ public class AggregationService
private async Task<List<PlossRow>> LoadRowsAsync(CancellationToken cancellationToken)
{
// 只加载 Ploss 及其引用维度。Qfod 明细不在此加载(避免集合 Include 产生笛卡尔积),
// 仅在 LoadQfodAggregatesAsync 中按场景聚合出 Sum/Count。
var records = await _localContext.PlossRecords
.AsNoTracking()
.AsSplitQuery()
.Where(r => !r.IsDeleted)
.Include(r => r.Scenario)
.ThenInclude(s => s!.TxPanel)
@@ -70,8 +74,6 @@ public class AggregationService
.ThenInclude(s => s!.TxHardware)
.Include(r => r.Scenario)
.ThenInclude(s => s!.RxType)
.Include(r => r.Scenario)
.ThenInclude(s => s!.QfodRecords)
.ToListAsync(cancellationToken);
return records.Select(r => new PlossRow(
@@ -82,6 +84,29 @@ public class AggregationService
r.Scenario.RxType.Name)).ToList();
}
/// <summary>
/// 按场景聚合 Qfod 记录的 CurrentQ/RawQ 求和与计数(在数据库侧完成,只返回每场景 3 个数字)。
/// </summary>
private async Task<Dictionary<Guid, QfodAggregate>> LoadQfodAggregatesAsync(CancellationToken cancellationToken)
{
var groups = await _localContext.QfodRecords
.AsNoTracking()
.Where(r => !r.IsDeleted)
.GroupBy(r => r.ScenarioId)
.Select(g => new
{
ScenarioId = g.Key,
QValueSum = g.Sum(r => (double)r.CurrentQ),
QBaseSum = g.Sum(r => (double)r.RawQ),
Count = g.Count()
})
.ToListAsync(cancellationToken);
return groups.ToDictionary(
g => g.ScenarioId,
g => new QfodAggregate(g.QValueSum, g.QBaseSum, g.Count));
}
private ChargingParameterKey BuildKey(TestScenario scenario)
{
var panelName = scenario.TxPanel.Name;
@@ -102,12 +127,17 @@ public class AggregationService
return ("Unknown", value);
}
private static ChargingParameterRecord CreateRecord(ChargingParameterKey key, List<PlossRow> rows)
private static ChargingParameterRecord CreateRecord(
ChargingParameterKey key,
List<PlossRow> rows,
IReadOnlyDictionary<Guid, QfodAggregate> qfodAggregates)
{
var values = PowerLevels.Select(power => Average(rows
.Where(row => IsPowerMatch(row.Record, power))
.Select(row => (double?)row.Record.Field7))).ToArray();
var (qValueSum, qBaseSum, qCount) = AggregateQfod(rows, qfodAggregates);
return new ChargingParameterRecord
{
CarFactory = key.CarFactory,
@@ -123,16 +153,43 @@ public class AggregationService
Power1750mW = values[6],
Power2000mW = values[7],
Power2250mW = values[8],
QValue = Average(rows.SelectMany(row => row.Scenario.QfodRecords).Select(q => (double?)q.CurrentQ)),
QBaseValue = Average(rows.SelectMany(row => row.Scenario.QfodRecords).Select(q => (double?)q.RawQ)),
QValue = qCount > 0 ? qValueSum / qCount : null,
QBaseValue = qCount > 0 ? qBaseSum / qCount : null,
PqCoefficient = Average(rows.Select(row => row.Record.DeltaP.HasValue ? (double?)row.Record.DeltaP.Value : null)),
ResonanceFrequency = null
};
}
/// <summary>
/// 累加分组内各行的场景 Qfod 聚合值。
/// 等价于原实现的 SelectMany(row => row.Scenario.QfodRecords):同一场景出现 k 次,其 Sum 与 Count 同倍放大,
/// 商保持不变,因此多场景分组时加权语义与原来完全一致,但无需加载 Qfod 明细。
/// </summary>
private static (double Sum, double BaseSum, int Count) AggregateQfod(
List<PlossRow> rows,
IReadOnlyDictionary<Guid, QfodAggregate> qfodAggregates)
{
double sum = 0, baseSum = 0;
int count = 0;
foreach (var row in rows)
{
if (qfodAggregates.TryGetValue(row.Scenario.Id, out var agg))
{
sum += agg.QValueSum;
baseSum += agg.QBaseSum;
count += agg.Count;
}
}
return (sum, baseSum, count);
}
private static bool IsPowerMatch(PlossRecord record, int power)
{
return record.Field4 == power || record.Field5 == power || record.PowLoss == power;
// 功率列按 Threshold(Field9) 分桶:记录的阈值等于哪个档位,其 Ploss 就进哪一列。
// 注:需求文档中 Threshold 为逻辑编号 (8),但当前解析数据里阈值实际落在 field_9。
return record.Field9 == power;
}
private static double? Average(IEnumerable<double?> values)
@@ -143,6 +200,7 @@ public class AggregationService
private record PlossRow(PlossRecord Record, TestScenario Scenario, string Panel, string Hardware, string RxType);
private record ChargingParameterKey(string CarFactory, string CarModel, string PhoneBrand, string PhoneModel);
private readonly record struct QfodAggregate(double QValueSum, double QBaseSum, int Count);
}
public record AggregationReport(int ReleaseRecordCount, int LocalRecordCount);

View File

@@ -19,7 +19,6 @@ public class PlossRecord
public int? DeltaP { get; set; }
// ===== 第二行字段16个通用字段均支持负数 =====
/// <summary>Field1: RX_TYPE - 接收端类型</summary>
public int Field1 { get; set; }
public int Field2 { get; set; }
@@ -33,10 +32,9 @@ public class PlossRecord
public int Field6 { get; set; }
/// <summary>核心判定值Field7 (原Ploss) - 负数表示安全</summary>
public int Field7 { get; set; }
/// <summary>安全红线Field8 (原Threshold) - 当前功率段阈值</summary>
public int Field8 { get; set; }
/// <summary>Field9: ploss - 功率损耗计算值</summary>
@@ -45,7 +43,7 @@ public class PlossRecord
/// <summary>Field10: threshold - 阈值</summary>
public int Field10 { get; set; }
/// <summary>Field11: 触发次数 - 当 Field7 > Field8损耗大于阈值时计数超过5次后触发FOD保护</summary>
/// <summary>Field11: 触发次数 - 当 Field9 > Field10ploss 大于阈值时计数超过5次后触发FOD保护</summary>
public int Field11 { get; set; }
/// <summary>Field12: PFOD result 标志 - 触发时置位</summary>
@@ -62,6 +60,6 @@ public class PlossRecord
// Navigation Properties
public TestScenario Scenario { get; set; } = null!;
/// <summary>派生属性:安全余量(不落库) = Field8 - Field7</summary>
public int Margin => Field8 - Field7;
/// <summary>派生属性:安全余量(不落库) = Field10 - Field9Threshold - Ploss</summary>
public int Margin => Field10 - Field9;
}

View File

@@ -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;
@@ -54,11 +55,14 @@ public static class ServiceCollectionExtensions
services.AddScoped<DimensionService>();
services.AddScoped<ScenarioService>();
services.AddScoped<ParseService>();
services.AddScoped<StatsService>();
services.AddScoped<ExportService>();
services.AddScoped<CleanService>();
services.AddScoped<AggregationService>();
// Analysis
services.AddScoped<PlossAnalysisService>();
services.AddScoped<QfodCalibrationService>();
return services;
}
}

View File

@@ -111,12 +111,12 @@ public class PlossRecordConfiguration : IEntityTypeConfiguration<PlossRecord>
// 索引
builder.HasIndex(e => e.ScenarioId).HasDatabaseName("idx_ploss_scenario");
builder.HasIndex(e => e.Field2).HasDatabaseName("idx_ploss_field_2"); // 原 rx_power
builder.HasIndex(e => e.Field7).HasDatabaseName("idx_ploss_field_7"); // 原 ploss
builder.HasIndex(e => e.Field8).HasDatabaseName("idx_ploss_field_8"); // 原 threshold
builder.HasIndex(e => e.Field10).HasDatabaseName("idx_ploss_field_10"); // 原 fod_result
builder.HasIndex(e => e.PowLoss).HasDatabaseName("idx_ploss_pow_loss"); // 新增
builder.HasIndex(e => e.DeltaP).HasDatabaseName("idx_ploss_delta_p"); // 新增
builder.HasIndex(e => e.Field2).HasDatabaseName("idx_ploss_field_2");
builder.HasIndex(e => e.Field7).HasDatabaseName("idx_ploss_field_7");
builder.HasIndex(e => e.Field8).HasDatabaseName("idx_ploss_field_8");
builder.HasIndex(e => e.Field10).HasDatabaseName("idx_ploss_field_10");
builder.HasIndex(e => e.PowLoss).HasDatabaseName("idx_ploss_pow_loss");
builder.HasIndex(e => e.DeltaP).HasDatabaseName("idx_ploss_delta_p");
// 关系配置
builder.HasOne(e => e.Scenario)

View 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");
}

View File

@@ -1,112 +0,0 @@
using System.CommandLine;
using Gpulse.WCT.DataAnalyzer.Core.Application;
namespace Gpulse.WCT.DataAnalyzer.Commands;
/// <summary>
/// 统计查询命令
/// </summary>
public class StatsCommand : Command
{
public StatsCommand(StatsService statsService)
: base("stats", "Query statistics from database")
{
var summaryOption = new Option<bool>(
"--summary",
() => false,
"Show overall statistics summary"
);
var byFodTypeOption = new Option<bool>(
"--by-fod-type",
() => false,
"Show statistics by FOD type"
);
var byPanelOption = new Option<bool>(
"--by-panel",
() => false,
"Show statistics by TX panel"
);
var byHardwareOption = new Option<bool>(
"--by-hardware",
() => false,
"Show statistics by TX hardware version"
);
var byRxTypeOption = new Option<bool>(
"--by-rx-type",
() => false,
"Show statistics by RX type"
);
AddOption(summaryOption);
AddOption(byFodTypeOption);
AddOption(byPanelOption);
AddOption(byHardwareOption);
AddOption(byRxTypeOption);
this.SetHandler(async (summary, byFodType, byPanel, byHardware, byRxType) =>
{
// 如果没有指定任何选项,默认显示摘要
if (!summary && !byFodType && !byPanel && !byHardware && !byRxType)
{
summary = true;
}
if (summary)
{
var s = await statsService.GetSummaryAsync();
Console.WriteLine("=== Statistics Summary ===");
Console.WriteLine($" Scenarios: {s.ScenarioCount}");
Console.WriteLine($" Qfod Records: {s.QfodCount}");
Console.WriteLine($" Ploss Records: {s.PlossCount}");
Console.WriteLine($" TX Panels: {s.PanelCount}");
Console.WriteLine($" TX Hardware: {s.HardwareCount}");
Console.WriteLine($" TX Software: {s.SoftwareCount}");
Console.WriteLine($" RX Types: {s.RxTypeCount}");
}
if (byFodType)
{
Console.WriteLine("\n=== By FOD Type ===");
var stats = await statsService.GetStatsByFodTypeAsync();
foreach (var s in stats)
{
Console.WriteLine($" FOD Type {s.FodType}: {s.Count}");
}
}
if (byPanel)
{
Console.WriteLine("\n=== By TX Panel ===");
var stats = await statsService.GetStatsByPanelAsync();
foreach (var s in stats)
{
Console.WriteLine($" {s.Name}: {s.Count}");
}
}
if (byHardware)
{
Console.WriteLine("\n=== By TX Hardware ===");
var stats = await statsService.GetStatsByHardwareAsync();
foreach (var s in stats)
{
Console.WriteLine($" {s.Name}: {s.Count}");
}
}
if (byRxType)
{
Console.WriteLine("\n=== By RX Type ===");
var stats = await statsService.GetStatsByRxTypeAsync();
foreach (var s in stats)
{
Console.WriteLine($" {s.Name}: {s.Count}");
}
}
}, summaryOption, byFodTypeOption, byPanelOption, byHardwareOption, byRxTypeOption);
}
}

View File

@@ -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;
@@ -17,14 +18,8 @@ public class Program
{
public static async Task<int> Main(string[] args)
{
// 1. 构建引导配置(用于 Serilog 初始化)
var bootstrapConfig = new ConfigurationBuilder()
.SetBasePath(AppContext.BaseDirectory)
.AddJsonFile("appsettings.json", optional: false)
.AddJsonFile($"appsettings.{Environment.GetEnvironmentVariable("DOTNET_ENVIRONMENT") ?? "Production"}.json", optional: true)
.AddEnvironmentVariables()
.AddCommandLine(args)
.Build();
// 1. 构建引导配置(用于 Serilog 初始化),并将数据库/日志等相对路径解析到 exe 所在目录
var bootstrapConfig = BuildConfiguration(args);
// 2. 创建引导日志器
Log.Logger = new LoggerConfiguration()
@@ -74,9 +69,11 @@ public class Program
rootCommand.AddCommand(new ParseCommand(provider.GetRequiredService<ParseService>()));
rootCommand.AddCommand(new AggregateCommand(provider.GetRequiredService<AggregationService>()));
rootCommand.AddCommand(new StatsCommand(provider.GetRequiredService<StatsService>()));
rootCommand.AddCommand(new ExportCommand(provider.GetRequiredService<ExportService>()));
rootCommand.AddCommand(new CleanCommand(provider.GetRequiredService<CleanService>()));
rootCommand.AddCommand(new AnalyzeCommand(
provider.GetRequiredService<PlossAnalysisService>(),
provider.GetRequiredService<QfodCalibrationService>()));
return await rootCommand.InvokeAsync(args);
}
@@ -90,4 +87,79 @@ public class Program
await Log.CloseAndFlushAsync();
}
}
/// <summary>
/// 构建配置。除支持 json/环境变量/命令行外,
/// 将 appsettings.json 中数据库、日志、输出等相对路径统一解析为基于 exe 所在目录的绝对路径,
/// 保证应用从任意工作目录启动时,配置文件、数据库、日志都落在 exe 旁边。
/// </summary>
private static IConfiguration BuildConfiguration(string[] args)
{
var builder = new ConfigurationBuilder()
.SetBasePath(AppContext.BaseDirectory)
.AddJsonFile("appsettings.json", optional: false)
.AddJsonFile($"appsettings.{Environment.GetEnvironmentVariable("DOTNET_ENVIRONMENT") ?? "Production"}.json", optional: true)
.AddEnvironmentVariables()
.AddCommandLine(args);
var config = builder.Build();
// 收集需要解析的相对路径,通过内存配置覆盖层注入(优先级高于 json/环境变量/命令行)
var overrides = ResolveRelativePaths(config);
if (overrides.Count == 0)
return config;
return new ConfigurationBuilder()
.AddConfiguration(config)
.AddInMemoryCollection(overrides)
.Build();
}
/// <summary>
/// 将配置中的相对路径解析为基于 exe 目录的绝对路径。
/// </summary>
private static Dictionary<string, string?> ResolveRelativePaths(IConfiguration config)
{
var baseDirectory = AppContext.BaseDirectory;
var overrides = new Dictionary<string, string?>(StringComparer.OrdinalIgnoreCase);
// 数据库文件
foreach (var key in new[] { "Database:Path", "Database:LocalPath", "Database:ReleasePath" })
{
AddResolved(overrides, config, key, baseDirectory);
}
// 日志与输出目录
foreach (var key in new[] { "Paths:LogDir", "Paths:OutputDir" })
{
AddResolved(overrides, config, key, baseDirectory);
}
// Serilog 文件日志路径(遍历 WriteTo 数组,避免硬编码索引)
foreach (var sink in config.GetSection("Serilog:WriteTo").GetChildren())
{
if (!string.Equals(sink["Name"], "File", StringComparison.OrdinalIgnoreCase))
continue;
AddResolved(overrides, config, $"{sink.Path}:Args:path", baseDirectory);
}
return overrides;
}
private static void AddResolved(
Dictionary<string, string?> overrides,
IConfiguration config,
string key,
string baseDirectory)
{
var value = config[key];
if (string.IsNullOrWhiteSpace(value))
return;
overrides[key] = Path.IsPathRooted(value)
? value
: Path.GetFullPath(Path.Combine(baseDirectory, value));
}
}