feat(aggregation): 按 Threshold 分桶并优化 Qfod 聚合
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -75,7 +75,7 @@ sequenceDiagram
|
|||||||
CLI->>AS: AggregateAsync()
|
CLI->>AS: AggregateAsync()
|
||||||
AS->>LDB: 读取所有 PlossRecord + 关联维度
|
AS->>LDB: 读取所有 PlossRecord + 关联维度
|
||||||
AS->>AS: 按 (车厂, 车型, 手机厂商, 型号) 分组
|
AS->>AS: 按 (车厂, 车型, 手机厂商, 型号) 分组
|
||||||
AS->>AS: 计算 9 个功率列平均值
|
AS->>AS: 按 Threshold(Field9) 分桶计算 9 个功率列
|
||||||
AS->>AS: 计算 Q值/Q基值/P-Q系数
|
AS->>AS: 计算 Q值/Q基值/P-Q系数
|
||||||
AS->>RDB: DELETE + INSERT (事务)
|
AS->>RDB: DELETE + INSERT (事务)
|
||||||
AS-->>CLI: AggregationReport
|
AS-->>CLI: AggregationReport
|
||||||
@@ -89,12 +89,14 @@ sequenceDiagram
|
|||||||
| 车型 (CarModel) | 配置映射 `Aggregation:CarModelMappings` | fallback 为 TxHardware.Version |
|
| 车型 (CarModel) | 配置映射 `Aggregation:CarModelMappings` | fallback 为 TxHardware.Version |
|
||||||
| 手机厂商 (PhoneBrand) | RxType.Name 拆分 | 取第一个分隔符前部分 |
|
| 手机厂商 (PhoneBrand) | RxType.Name 拆分 | 取第一个分隔符前部分 |
|
||||||
| 型号 (PhoneModel) | RxType.Name 拆分 | 取第一个分隔符后部分 |
|
| 型号 (PhoneModel) | RxType.Name 拆分 | 取第一个分隔符后部分 |
|
||||||
| 350mW~2250mW(9列) | PlossRecord.Field7 | 按功率匹配后取平均值 |
|
| 350mW~2250mW(9列) | PlossRecord.Field7 | 按 Threshold(Field9) 分桶(记录阈值=档位),取 Field7 平均值 |
|
||||||
| Q值 (QValue) | QfodRecord.CurrentQ | 平均值 |
|
| Q值 (QValue) | QfodRecord.CurrentQ | 平均值 |
|
||||||
| Q基值 (QBaseValue) | QfodRecord.RawQ | 平均值 |
|
| Q基值 (QBaseValue) | QfodRecord.RawQ | 平均值 |
|
||||||
| P-Q值系数 (PqCoefficient) | PlossRecord.DeltaP | 平均值 |
|
| P-Q值系数 (PqCoefficient) | PlossRecord.DeltaP | 平均值 |
|
||||||
| 谐振频率 (ResonanceFrequency) | 暂无 | 允许为空 |
|
| 谐振频率 (ResonanceFrequency) | 暂无 | 允许为空 |
|
||||||
|
|
||||||
|
> 各列的详细计算规则(含公式、功率匹配与空值逻辑)见 [§5 输出 CSV 各列计算详解](#5-输出-csv-各列计算详解)。
|
||||||
|
|
||||||
**替代配置:**
|
**替代配置:**
|
||||||
|
|
||||||
配置文件 `appsettings.json` 中可添加映射:
|
配置文件 `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
|
```text
|
||||||
local.db release.db
|
local.db release.db
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ namespace Gpulse.WCT.DataAnalyzer.Core.Application;
|
|||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// 从本地解析库生成独立正式发布库。
|
/// 从本地解析库生成独立正式发布库。
|
||||||
/// 当前业务规则:功率列使用 Ploss 的 Field7,重复结果取平均值;缺失值保留为空。
|
/// 当前业务规则:功率列按 Threshold(Field9) 分桶、取值 Ploss(Field7),重复结果取平均值;缺失值保留为空。
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public class AggregationService
|
public class AggregationService
|
||||||
{
|
{
|
||||||
@@ -34,9 +34,10 @@ public class AggregationService
|
|||||||
public async Task<AggregationReport> AggregateAsync(bool rebuild = true, CancellationToken cancellationToken = default)
|
public async Task<AggregationReport> AggregateAsync(bool rebuild = true, CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
var rows = await LoadRowsAsync(cancellationToken);
|
var rows = await LoadRowsAsync(cancellationToken);
|
||||||
|
var qfodAggregates = await LoadQfodAggregatesAsync(cancellationToken);
|
||||||
var records = rows
|
var records = rows
|
||||||
.GroupBy(r => BuildKey(r.Scenario))
|
.GroupBy(r => BuildKey(r.Scenario))
|
||||||
.Select(group => CreateRecord(group.Key, group.ToList()))
|
.Select(group => CreateRecord(group.Key, group.ToList(), qfodAggregates))
|
||||||
.ToList();
|
.ToList();
|
||||||
|
|
||||||
await using var transaction = await _releaseContext.Database.BeginTransactionAsync(cancellationToken);
|
await using var transaction = await _releaseContext.Database.BeginTransactionAsync(cancellationToken);
|
||||||
@@ -61,8 +62,11 @@ public class AggregationService
|
|||||||
|
|
||||||
private async Task<List<PlossRow>> LoadRowsAsync(CancellationToken cancellationToken)
|
private async Task<List<PlossRow>> LoadRowsAsync(CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
|
// 只加载 Ploss 及其引用维度。Qfod 明细不在此加载(避免集合 Include 产生笛卡尔积),
|
||||||
|
// 仅在 LoadQfodAggregatesAsync 中按场景聚合出 Sum/Count。
|
||||||
var records = await _localContext.PlossRecords
|
var records = await _localContext.PlossRecords
|
||||||
.AsNoTracking()
|
.AsNoTracking()
|
||||||
|
.AsSplitQuery()
|
||||||
.Where(r => !r.IsDeleted)
|
.Where(r => !r.IsDeleted)
|
||||||
.Include(r => r.Scenario)
|
.Include(r => r.Scenario)
|
||||||
.ThenInclude(s => s!.TxPanel)
|
.ThenInclude(s => s!.TxPanel)
|
||||||
@@ -70,8 +74,6 @@ public class AggregationService
|
|||||||
.ThenInclude(s => s!.TxHardware)
|
.ThenInclude(s => s!.TxHardware)
|
||||||
.Include(r => r.Scenario)
|
.Include(r => r.Scenario)
|
||||||
.ThenInclude(s => s!.RxType)
|
.ThenInclude(s => s!.RxType)
|
||||||
.Include(r => r.Scenario)
|
|
||||||
.ThenInclude(s => s!.QfodRecords)
|
|
||||||
.ToListAsync(cancellationToken);
|
.ToListAsync(cancellationToken);
|
||||||
|
|
||||||
return records.Select(r => new PlossRow(
|
return records.Select(r => new PlossRow(
|
||||||
@@ -82,6 +84,29 @@ public class AggregationService
|
|||||||
r.Scenario.RxType.Name)).ToList();
|
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)
|
private ChargingParameterKey BuildKey(TestScenario scenario)
|
||||||
{
|
{
|
||||||
var panelName = scenario.TxPanel.Name;
|
var panelName = scenario.TxPanel.Name;
|
||||||
@@ -102,12 +127,17 @@ public class AggregationService
|
|||||||
return ("Unknown", value);
|
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
|
var values = PowerLevels.Select(power => Average(rows
|
||||||
.Where(row => IsPowerMatch(row.Record, power))
|
.Where(row => IsPowerMatch(row.Record, power))
|
||||||
.Select(row => (double?)row.Record.Field7))).ToArray();
|
.Select(row => (double?)row.Record.Field7))).ToArray();
|
||||||
|
|
||||||
|
var (qValueSum, qBaseSum, qCount) = AggregateQfod(rows, qfodAggregates);
|
||||||
|
|
||||||
return new ChargingParameterRecord
|
return new ChargingParameterRecord
|
||||||
{
|
{
|
||||||
CarFactory = key.CarFactory,
|
CarFactory = key.CarFactory,
|
||||||
@@ -123,16 +153,43 @@ public class AggregationService
|
|||||||
Power1750mW = values[6],
|
Power1750mW = values[6],
|
||||||
Power2000mW = values[7],
|
Power2000mW = values[7],
|
||||||
Power2250mW = values[8],
|
Power2250mW = values[8],
|
||||||
QValue = Average(rows.SelectMany(row => row.Scenario.QfodRecords).Select(q => (double?)q.CurrentQ)),
|
QValue = qCount > 0 ? qValueSum / qCount : null,
|
||||||
QBaseValue = Average(rows.SelectMany(row => row.Scenario.QfodRecords).Select(q => (double?)q.RawQ)),
|
QBaseValue = qCount > 0 ? qBaseSum / qCount : null,
|
||||||
PqCoefficient = Average(rows.Select(row => row.Record.DeltaP.HasValue ? (double?)row.Record.DeltaP.Value : null)),
|
PqCoefficient = Average(rows.Select(row => row.Record.DeltaP.HasValue ? (double?)row.Record.DeltaP.Value : null)),
|
||||||
ResonanceFrequency = 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)
|
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)
|
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 PlossRow(PlossRecord Record, TestScenario Scenario, string Panel, string Hardware, string RxType);
|
||||||
private record ChargingParameterKey(string CarFactory, string CarModel, string PhoneBrand, string PhoneModel);
|
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);
|
public record AggregationReport(int ReleaseRecordCount, int LocalRecordCount);
|
||||||
|
|||||||
Reference in New Issue
Block a user