Compare commits
7 Commits
2b031995e1
...
0667e2e9b8
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0667e2e9b8 | ||
|
|
b88c07356e | ||
|
|
dea7af045b | ||
|
|
7cd08f934a | ||
|
|
430b38b361 | ||
|
|
77d4bfe953 | ||
|
|
f3472d21ee |
@@ -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
|
||||
```
|
||||
|
||||
|
||||
@@ -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 > Field10(ploss 大于阈值)时计数,超过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 状态 | 含义 |
|
||||
|
||||
@@ -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 拆分 | 取第一个分隔符后部分 |
|
||||
| 350mW~2250mW(9列) | PlossRecord.Field7 | 按功率匹配后取平均值 |
|
||||
| 350mW~2250mW(9列) | 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.db,export 阶段仅按 (车厂, 车型, 手机厂商, 型号) 排序后原样写出。
|
||||
> 所有数值列均按同一个分组键聚合;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 / ΣCount;Q基值 = Σ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 带 BOM,RFC 4180 转义。
|
||||
|
||||
---
|
||||
|
||||
## 6. 数据库隔离
|
||||
|
||||
```text
|
||||
local.db release.db
|
||||
|
||||
@@ -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/
|
||||
|
||||
@@ -58,7 +58,6 @@
|
||||
|
||||
| 类 | 职责 |
|
||||
|----|------|
|
||||
| `StatsService` | 本地库统计查询 |
|
||||
| `CleanService` | 软删除管理 |
|
||||
|
||||
---
|
||||
|
||||
@@ -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 > Field10(ploss 大于阈值)时计数,超过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;
|
||||
}
|
||||
```
|
||||
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 数值区间的参考;真实数据中「纯手机」与「手机+异物」分属两个日志文件。
|
||||
@@ -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 数据是孤立的
|
||||
@@ -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);
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -9,7 +9,7 @@ namespace Gpulse.WCT.DataAnalyzer.Core.Application;
|
||||
|
||||
/// <summary>
|
||||
/// 从本地解析库生成独立正式发布库。
|
||||
/// 当前业务规则:功率列使用 Ploss 的 Field7,重复结果取平均值;缺失值保留为空。
|
||||
/// 当前业务规则:功率列按 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);
|
||||
|
||||
@@ -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 > Field10(ploss 大于阈值)时计数,超过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 - Field9(Threshold - Ploss)</summary>
|
||||
public int Margin => Field10 - Field9;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
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");
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user