feat(cli): 增强Analyze命令支持自动扫描和Excel输出

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Scottxjw
2026-08-25 11:30:00 +08:00
parent 820bc1c2bc
commit ee8ea29265
9 changed files with 467 additions and 188 deletions

View File

@@ -75,6 +75,48 @@ public class DimensionService
); );
} }
/// <summary>
/// 获取或创建车厂维度(并发安全)
/// </summary>
public async Task<Manufacturer> GetOrCreateManufacturerAsync(string name)
{
return await GetOrCreateDimensionAsync(
() => _context.Manufacturers.AsNoTracking().FirstOrDefaultAsync(m => m.Name == name),
() => new Manufacturer { Name = name },
entity => _context.Manufacturers.Add(entity),
name,
"Manufacturer"
);
}
/// <summary>
/// 获取或创建车型维度(并发安全)
/// </summary>
public async Task<CarModel> GetOrCreateCarModelAsync(string name)
{
return await GetOrCreateDimensionAsync(
() => _context.CarModels.AsNoTracking().FirstOrDefaultAsync(c => c.Name == name),
() => new CarModel { Name = name },
entity => _context.CarModels.Add(entity),
name,
"Car Model"
);
}
/// <summary>
/// 获取或创建异物类型维度(并发安全)
/// </summary>
public async Task<ForeignObject> GetOrCreateForeignObjectAsync(string name)
{
return await GetOrCreateDimensionAsync(
() => _context.ForeignObjects.AsNoTracking().FirstOrDefaultAsync(f => f.Name == name),
() => new ForeignObject { Name = name },
entity => _context.ForeignObjects.Add(entity),
name,
"Foreign Object"
);
}
/// <summary> /// <summary>
/// 通用的并发安全 GetOrCreate 模式 /// 通用的并发安全 GetOrCreate 模式
/// </summary> /// </summary>

View File

@@ -7,7 +7,7 @@ using Gpulse.WCT.DataAnalyzer.Core.Application.Parsing;
namespace Gpulse.WCT.DataAnalyzer.Core.Application; namespace Gpulse.WCT.DataAnalyzer.Core.Application;
/// <summary> /// <summary>
/// 选条件常量 /// 选条件常量
/// </summary> /// </summary>
public static class FilterConstants public static class FilterConstants
{ {
@@ -38,14 +38,17 @@ public class ScenarioService
/// </summary> /// </summary>
public async Task<TestScenario> GetOrCreateScenarioAsync(ScenarioInfo info) public async Task<TestScenario> GetOrCreateScenarioAsync(ScenarioInfo info)
{ {
_logger.LogDebug("查找/创建场景: TxPanel={TxPanel}, TxHardware={TxHardware}, TxSoftware={TxSoftware}, RxType={RxType}", _logger.LogDebug("查找/创建场景: Manufacturer={Manufacturer}, CarModel={CarModel}, TxPanel={TxPanel}, TxHardware={TxHardware}, TxSoftware={TxSoftware}, RxType={RxType}, ForeignObject={ForeignObject}",
info.TxPanel, info.TxHardware, info.TxSoftware, info.RxType); info.Manufacturer, info.CarModel, info.TxPanel, info.TxHardware, info.TxSoftware, info.RxType, info.ForeignObject);
// 1. 获取或创建各维度 // 1. 获取或创建各维度
var manufacturer = await _dimensionService.GetOrCreateManufacturerAsync(info.Manufacturer);
var carModel = await _dimensionService.GetOrCreateCarModelAsync(info.CarModel);
var txPanel = await _dimensionService.GetOrCreateTxPanelAsync(info.TxPanel); var txPanel = await _dimensionService.GetOrCreateTxPanelAsync(info.TxPanel);
var txHardware = await _dimensionService.GetOrCreateTxHardwareAsync(info.TxHardware); var txHardware = await _dimensionService.GetOrCreateTxHardwareAsync(info.TxHardware);
var txSoftware = await _dimensionService.GetOrCreateTxSoftwareAsync(info.TxSoftware); var txSoftware = await _dimensionService.GetOrCreateTxSoftwareAsync(info.TxSoftware);
var rxType = await _dimensionService.GetOrCreateRxTypeAsync(info.RxType); var rxType = await _dimensionService.GetOrCreateRxTypeAsync(info.RxType);
var foreignObject = await _dimensionService.GetOrCreateForeignObjectAsync(info.ForeignObject);
// 2. 并发安全的场景创建 // 2. 并发安全的场景创建
const int maxRetries = 3; const int maxRetries = 3;
@@ -56,10 +59,13 @@ public class ScenarioService
var existing = await _context.TestScenarios var existing = await _context.TestScenarios
.AsNoTracking() .AsNoTracking()
.FirstOrDefaultAsync(s => .FirstOrDefaultAsync(s =>
s.ManufacturerId == manufacturer.Id &&
s.CarModelId == carModel.Id &&
s.TxPanelId == txPanel.Id && s.TxPanelId == txPanel.Id &&
s.TxHardwareId == txHardware.Id && s.TxHardwareId == txHardware.Id &&
s.TxSoftwareId == txSoftware.Id && s.TxSoftwareId == txSoftware.Id &&
s.RxTypeId == rxType.Id && s.RxTypeId == rxType.Id &&
s.ForeignObjectId == foreignObject.Id &&
s.TestPurpose == info.TestPurpose && s.TestPurpose == info.TestPurpose &&
s.TestDate == info.TestDate && s.TestDate == info.TestDate &&
s.TestSequence == info.TestSequence); s.TestSequence == info.TestSequence);
@@ -73,10 +79,13 @@ public class ScenarioService
// 3. 创建新场景 // 3. 创建新场景
var scenario = new TestScenario var scenario = new TestScenario
{ {
ManufacturerId = manufacturer.Id,
CarModelId = carModel.Id,
TxPanelId = txPanel.Id, TxPanelId = txPanel.Id,
TxHardwareId = txHardware.Id, TxHardwareId = txHardware.Id,
TxSoftwareId = txSoftware.Id, TxSoftwareId = txSoftware.Id,
RxTypeId = rxType.Id, RxTypeId = rxType.Id,
ForeignObjectId = foreignObject.Id,
TestPurpose = info.TestPurpose, TestPurpose = info.TestPurpose,
TestDate = info.TestDate, TestDate = info.TestDate,
TestSequence = info.TestSequence TestSequence = info.TestSequence
@@ -88,8 +97,8 @@ public class ScenarioService
{ {
await _context.SaveChangesAsync(); await _context.SaveChangesAsync();
_logger.LogInformation("创建新测试场景: {ScenarioId}, TxPanel={TxPanel}, TxHardware={TxHardware}, RxType={RxType}, TestDate={TestDate}", _logger.LogInformation("创建新测试场景: {ScenarioId}, Manufacturer={Manufacturer}, CarModel={CarModel}, TxPanel={TxPanel}, TxHardware={TxHardware}, RxType={RxType}, ForeignObject={ForeignObject}, TestDate={TestDate}",
scenario.Id, info.TxPanel, info.TxHardware, info.RxType, info.TestDate); scenario.Id, info.Manufacturer, info.CarModel, info.TxPanel, info.TxHardware, info.RxType, info.ForeignObject, info.TestDate);
return scenario; return scenario;
} }
@@ -97,7 +106,7 @@ public class ScenarioService
{ {
// Detach 实体以避免 ChangeTracker 问题 // Detach 实体以避免 ChangeTracker 问题
_context.Entry(scenario).State = EntityState.Detached; _context.Entry(scenario).State = EntityState.Detached;
_logger.LogDebug("场景并发创建冲突,重试: TxPanel={TxPanel}", info.TxPanel); _logger.LogDebug("场景并发创建冲突,重试: Manufacturer={Manufacturer}, CarModel={CarModel}", info.Manufacturer, info.CarModel);
if (attempt < maxRetries - 1) if (attempt < maxRetries - 1)
{ {
@@ -111,17 +120,20 @@ public class ScenarioService
var finalScenario = await _context.TestScenarios var finalScenario = await _context.TestScenarios
.AsNoTracking() .AsNoTracking()
.FirstOrDefaultAsync(s => .FirstOrDefaultAsync(s =>
s.ManufacturerId == manufacturer.Id &&
s.CarModelId == carModel.Id &&
s.TxPanelId == txPanel.Id && s.TxPanelId == txPanel.Id &&
s.TxHardwareId == txHardware.Id && s.TxHardwareId == txHardware.Id &&
s.TxSoftwareId == txSoftware.Id && s.TxSoftwareId == txSoftware.Id &&
s.RxTypeId == rxType.Id && s.RxTypeId == rxType.Id &&
s.ForeignObjectId == foreignObject.Id &&
s.TestPurpose == info.TestPurpose && s.TestPurpose == info.TestPurpose &&
s.TestDate == info.TestDate && s.TestDate == info.TestDate &&
s.TestSequence == info.TestSequence); s.TestSequence == info.TestSequence);
if (finalScenario == null) if (finalScenario == null)
{ {
throw new InvalidOperationException($"无法获取或创建场景: {info.TxPanel}/{info.TxHardware}/{info.RxType}"); throw new InvalidOperationException($"无法获取或创建场景: {info.Manufacturer}/{info.CarModel}/{info.TxPanel}/{info.TxHardware}/{info.RxType}/{info.ForeignObject}");
} }
return finalScenario; return finalScenario;
@@ -133,10 +145,13 @@ public class ScenarioService
public async Task<TestScenario?> GetScenarioWithDimensionsAsync(Guid scenarioId) public async Task<TestScenario?> GetScenarioWithDimensionsAsync(Guid scenarioId)
{ {
return await _context.TestScenarios return await _context.TestScenarios
.Include(s => s.Manufacturer)
.Include(s => s.CarModel)
.Include(s => s.TxPanel) .Include(s => s.TxPanel)
.Include(s => s.TxHardware) .Include(s => s.TxHardware)
.Include(s => s.TxSoftware) .Include(s => s.TxSoftware)
.Include(s => s.RxType) .Include(s => s.RxType)
.Include(s => s.ForeignObject)
.FirstOrDefaultAsync(s => s.Id == scenarioId); .FirstOrDefaultAsync(s => s.Id == scenarioId);
} }
@@ -148,10 +163,13 @@ public class ScenarioService
public async Task<List<TestScenario>> GetAllScenariosAsync(int pageSize = 50, int pageIndex = 0) public async Task<List<TestScenario>> GetAllScenariosAsync(int pageSize = 50, int pageIndex = 0)
{ {
var query = _context.TestScenarios var query = _context.TestScenarios
.Include(s => s.Manufacturer)
.Include(s => s.CarModel)
.Include(s => s.TxPanel) .Include(s => s.TxPanel)
.Include(s => s.TxHardware) .Include(s => s.TxHardware)
.Include(s => s.TxSoftware) .Include(s => s.TxSoftware)
.Include(s => s.RxType) .Include(s => s.RxType)
.Include(s => s.ForeignObject)
.OrderByDescending(s => s.TestDate) .OrderByDescending(s => s.TestDate)
.ThenByDescending(s => s.TestSequence) .ThenByDescending(s => s.TestSequence)
.AsNoTracking(); .AsNoTracking();
@@ -177,6 +195,18 @@ public class ScenarioService
/// </summary> /// </summary>
public async Task<FilterOptions> GetFilterOptionsAsync() public async Task<FilterOptions> GetFilterOptionsAsync()
{ {
var manufacturers = await _context.Manufacturers
.Select(m => m.Name)
.Distinct()
.OrderBy(n => n)
.ToListAsync();
var carModels = await _context.CarModels
.Select(c => c.Name)
.Distinct()
.OrderBy(n => n)
.ToListAsync();
var txPanels = await _context.TxPanels var txPanels = await _context.TxPanels
.Select(p => p.Name) .Select(p => p.Name)
.Distinct() .Distinct()
@@ -201,37 +231,56 @@ public class ScenarioService
.OrderBy(n => n) .OrderBy(n => n)
.ToListAsync(); .ToListAsync();
var foreignObjects = await _context.ForeignObjects
.Select(f => f.Name)
.Distinct()
.OrderBy(n => n)
.ToListAsync();
return new FilterOptions( return new FilterOptions(
manufacturers.Prepend(FilterConstants.AllOption).ToList(),
carModels.Prepend(FilterConstants.AllOption).ToList(),
txPanels.Prepend(FilterConstants.AllOption).ToList(), txPanels.Prepend(FilterConstants.AllOption).ToList(),
txHardwares.Prepend(FilterConstants.AllOption).ToList(), txHardwares.Prepend(FilterConstants.AllOption).ToList(),
txSoftwares.Prepend(FilterConstants.AllOption).ToList(), txSoftwares.Prepend(FilterConstants.AllOption).ToList(),
rxTypes.Prepend(FilterConstants.AllOption).ToList()); rxTypes.Prepend(FilterConstants.AllOption).ToList(),
foreignObjects.Prepend(FilterConstants.AllOption).ToList());
} }
/// <summary> /// <summary>
/// 根据筛选条件获取测试场景 /// 根据筛选条件获取测试场景
/// </summary> /// </summary>
/// <param name="txPanel">TxPanel 名称null 或 "全部" 表示不过滤</param>
/// <param name="txHardware">TxHardware 版本null 或 "全部" 表示不过滤</param>
/// <param name="txSoftware">TxSoftware 版本null 或 "全部" 表示不过滤</param>
/// <param name="rxType">RxType 名称null 或 "全部" 表示不过滤</param>
/// <param name="pageSize">每页数量默认50传0表示不分页</param>
/// <param name="pageIndex">页码从0开始</param>
public async Task<List<TestScenario>> GetScenariosByFilterAsync( public async Task<List<TestScenario>> GetScenariosByFilterAsync(
string? manufacturer = null,
string? carModel = null,
string? txPanel = null, string? txPanel = null,
string? txHardware = null, string? txHardware = null,
string? txSoftware = null, string? txSoftware = null,
string? rxType = null, string? rxType = null,
string? foreignObject = null,
int pageSize = 50, int pageSize = 50,
int pageIndex = 0) int pageIndex = 0)
{ {
var query = _context.TestScenarios var query = _context.TestScenarios
.Include(s => s.Manufacturer)
.Include(s => s.CarModel)
.Include(s => s.TxPanel) .Include(s => s.TxPanel)
.Include(s => s.TxHardware) .Include(s => s.TxHardware)
.Include(s => s.TxSoftware) .Include(s => s.TxSoftware)
.Include(s => s.RxType) .Include(s => s.RxType)
.Include(s => s.ForeignObject)
.AsNoTracking(); .AsNoTracking();
if (!string.IsNullOrEmpty(manufacturer) && manufacturer != FilterConstants.AllOption)
{
query = query.Where(s => s.Manufacturer.Name == manufacturer);
}
if (!string.IsNullOrEmpty(carModel) && carModel != FilterConstants.AllOption)
{
query = query.Where(s => s.CarModel.Name == carModel);
}
if (!string.IsNullOrEmpty(txPanel) && txPanel != FilterConstants.AllOption) if (!string.IsNullOrEmpty(txPanel) && txPanel != FilterConstants.AllOption)
{ {
query = query.Where(s => s.TxPanel.Name == txPanel); query = query.Where(s => s.TxPanel.Name == txPanel);
@@ -252,6 +301,11 @@ public class ScenarioService
query = query.Where(s => s.RxType.Name == rxType); query = query.Where(s => s.RxType.Name == rxType);
} }
if (!string.IsNullOrEmpty(foreignObject) && foreignObject != FilterConstants.AllOption)
{
query = query.Where(s => s.ForeignObject.Name == foreignObject);
}
query = query query = query
.OrderByDescending(s => s.TestDate) .OrderByDescending(s => s.TestDate)
.ThenByDescending(s => s.TestSequence); .ThenByDescending(s => s.TestSequence);
@@ -307,7 +361,10 @@ public class ScenarioService
/// 筛选选项数据 /// 筛选选项数据
/// </summary> /// </summary>
public record FilterOptions( public record FilterOptions(
List<string> Manufacturers,
List<string> CarModels,
List<string> TxPanels, List<string> TxPanels,
List<string> TxHardwares, List<string> TxHardwares,
List<string> TxSoftwares, List<string> TxSoftwares,
List<string> RxTypes); List<string> RxTypes,
List<string> ForeignObjects);

View File

@@ -4,9 +4,9 @@ namespace Gpulse.WCT.DataAnalyzer.Core.Application.Parsing;
/// <summary> /// <summary>
/// 文件名解析器 - 从文件名提取场景信息 /// 文件名解析器 - 从文件名提取场景信息
/// 格式: TX面板-TX硬件-TX软件-RX类型-测试目的-年月日-测试次数.log /// 格式: 车厂-车型-TX面板类型-TX硬件-TX软件-RX类型-异物类型-测试目的-年月日测试次数.dat
/// 分隔符: 使用 '-' 作为字段分隔符,字段内部可包含 '_' 或 '.' 等符号 /// 分隔符: 使用 '-' 作为字段分隔符,字段内部可包含 '_' 或 '.' 等符号
/// 示例: singleMold-v1.0-hex2_1-iPhone15-compatibility-20260702-1.log /// 示例: 奇瑞-E5-singleMold-A00-V001-伏达小蓝板-无-compatibility-2026081701.dat
/// </summary> /// </summary>
public class FileNameParser public class FileNameParser
{ {
@@ -19,39 +19,60 @@ public class FileNameParser
/// <returns>场景信息,若格式不匹配返回 null</returns> /// <returns>场景信息,若格式不匹配返回 null</returns>
public ScenarioInfo? Parse(string fileName) public ScenarioInfo? Parse(string fileName)
{ {
// 移除 .log 扩展名 // 移除扩展名
var nameWithoutExt = Path.GetFileNameWithoutExtension(fileName); var nameWithoutExt = Path.GetFileNameWithoutExtension(fileName);
if (string.IsNullOrEmpty(nameWithoutExt)) if (string.IsNullOrEmpty(nameWithoutExt))
return null; return null;
var parts = nameWithoutExt.Split(Separators); var parts = nameWithoutExt.Split(Separators);
if (parts.Length < 7) if (parts.Length < 9)
return null; return null;
// 解析日期 // 解析日期+序号最后一个字段格式yyyyMMddNN
if (!TryParseDate(parts[5], out var testDate)) var dateSeqStr = parts[8];
if (!TryParseDateSequence(dateSeqStr, out var testDate, out var testSequence))
return null; return null;
// 解析测试序号
if (!int.TryParse(parts[6], out var testSequence))
testSequence = 1;
return new ScenarioInfo( return new ScenarioInfo(
TxPanel: parts[0], Manufacturer: parts[0],
TxHardware: parts[1], CarModel: parts[1],
TxSoftware: parts[2], TxPanel: parts[2],
RxType: parts[3], TxHardware: parts[3],
TestPurpose: parts.Length > 4 ? parts[4] : null, TxSoftware: parts[4],
RxType: parts[5],
ForeignObject: parts[6],
TestPurpose: parts[7],
TestDate: testDate, TestDate: testDate,
TestSequence: testSequence TestSequence: testSequence
); );
} }
private static bool TryParseDate(string dateStr, out DateOnly date) /// <summary>
/// 解析日期+序号组合字段
/// 格式: yyyyMMddNN (如 2026081701 表示 2026年8月17日 第01次测试)
/// </summary>
private static bool TryParseDateSequence(string dateSeqStr, out DateOnly date, out int sequence)
{ {
// 支持多种日期格式 date = default;
var formats = new[] { "yyyyMMdd", "yyyy-MM-dd", "yyMMdd" }; sequence = 1;
return DateOnly.TryParseExact(dateStr, formats, CultureInfo.InvariantCulture, DateTimeStyles.None, out date);
// 最小长度8位日期 + 1位序号 = 9位
// 标准长度8位日期 + 2位序号 = 10位
if (dateSeqStr.Length < 9)
return false;
// 提取日期部分前8位
var dateStr = dateSeqStr.Substring(0, 8);
var formats = new[] { "yyyyMMdd" };
if (!DateOnly.TryParseExact(dateStr, formats, CultureInfo.InvariantCulture, DateTimeStyles.None, out date))
return false;
// 提取序号部分(剩余位数)
var seqStr = dateSeqStr.Substring(8);
if (!int.TryParse(seqStr, out sequence))
return false;
return true;
} }
} }
@@ -59,11 +80,14 @@ public class FileNameParser
/// 场景信息 - 从文件名解析出的测试场景数据 /// 场景信息 - 从文件名解析出的测试场景数据
/// </summary> /// </summary>
public record ScenarioInfo( public record ScenarioInfo(
string Manufacturer,
string CarModel,
string TxPanel, string TxPanel,
string TxHardware, string TxHardware,
string TxSoftware, string TxSoftware,
string RxType, string RxType,
string ForeignObject,
string? TestPurpose, string? TestPurpose,
DateOnly TestDate, DateOnly TestDate,
int TestSequence int TestSequence
); );

View File

@@ -5,10 +5,9 @@ using Gpulse.WCT.DataAnalyzer.Core.Domain.Local;
namespace Gpulse.WCT.DataAnalyzer.Core.Application.Parsing; namespace Gpulse.WCT.DataAnalyzer.Core.Application.Parsing;
/// <summary> /// <summary>
/// Ploss FOD日志解析器 - 支持两行格式 /// Ploss FOD日志解析器 - 两行格式
/// 第一行: pow_loss = 3053, delta_p = 1950 /// 第一行: pow_loss = 3053, delta_p = 1950
/// 第二行: FOD-> (1) (2) ... (16) /// 第二行: FOD-> (1) (2) ... (16)
/// 同时兼容旧的单行格式
/// </summary> /// </summary>
public class PlossParser : IParser<PlossRecord> public class PlossParser : IParser<PlossRecord>
{ {
@@ -27,17 +26,10 @@ public class PlossParser : IParser<PlossRecord>
RegexOptions.Compiled RegexOptions.Compiled
); );
// 旧格式正则兼容前3个字段被忽略所有捕获组支持负数Field1 兼容十六进制
// 共 14 个捕获组Field1 文本 + 13 个 (-?\d+)),对应旧格式 14 个原始字段含前3个占位字段
private static readonly Regex LegacyPattern = new(
@"^FOD->\s+(0[xX][0-9A-Fa-f]+|0[0-9A-Fa-f]+|-?\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
);
/// <summary> /// <summary>
/// 匹配类型枚举 /// 匹配类型枚举
/// </summary> /// </summary>
public enum MatchType { None, Header, Fod, Legacy } public enum MatchType { None, Header, Fod }
/// <summary> /// <summary>
/// 单次匹配结果(避免重复正则匹配) /// 单次匹配结果(避免重复正则匹配)
@@ -52,10 +44,6 @@ public class PlossParser : IParser<PlossRecord>
if (fodMatch.Success) if (fodMatch.Success)
return (MatchType.Fod, fodMatch); return (MatchType.Fod, fodMatch);
var legacyMatch = LegacyPattern.Match(line);
if (legacyMatch.Success)
return (MatchType.Legacy, legacyMatch);
return (MatchType.None, Match.Empty); return (MatchType.None, Match.Empty);
} }
@@ -70,7 +58,7 @@ public class PlossParser : IParser<PlossRecord>
public bool IsMultiLineStart(string line) => TryMatch(line).Type == MatchType.Header; public bool IsMultiLineStart(string line) => TryMatch(line).Type == MatchType.Header;
/// <summary> /// <summary>
/// 单行解析(兼容旧格式 /// 单行解析(仅支持FOD行用于错误提示
/// </summary> /// </summary>
public ParseResult<PlossRecord> Parse(string line, Guid scenarioId) public ParseResult<PlossRecord> Parse(string line, Guid scenarioId)
{ {
@@ -78,15 +66,17 @@ public class PlossParser : IParser<PlossRecord>
return type switch return type switch
{ {
MatchType.Legacy => ParseLegacyFormat(match, scenarioId),
MatchType.Fod => ParseFodLine(match, scenarioId), MatchType.Fod => ParseFodLine(match, scenarioId),
MatchType.Header => ParseHeaderLine(match, scenarioId), MatchType.Header => ParseResult<PlossRecord>.Failure(
"INCOMPLETE_RECORD",
"Header line requires FOD line. Use ParseMultiLine instead."
),
_ => ParseResult<PlossRecord>.Failure("FORMAT_MISMATCH", "Line does not match Ploss pattern") _ => ParseResult<PlossRecord>.Failure("FORMAT_MISMATCH", "Line does not match Ploss pattern")
}; };
} }
/// <summary> /// <summary>
/// 多行解析(两行格式) /// 多行解析(两行格式)
/// </summary> /// </summary>
public ParseResult<PlossRecord>? ParseMultiLine(string[] lines, Guid scenarioId) public ParseResult<PlossRecord>? ParseMultiLine(string[] lines, Guid scenarioId)
{ {
@@ -137,73 +127,9 @@ public class PlossParser : IParser<PlossRecord>
} }
} }
private ParseResult<PlossRecord> ParseLegacyFormat(Match match, Guid scenarioId)
{
// 保持原有逻辑不变,映射到新字段名
try
{
var record = new PlossRecord
{
ScenarioId = scenarioId,
// 旧格式没有第一行字段
PowLoss = null,
DeltaP = null,
// 映射到新字段名
Field1 = ParseFieldValue(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,
Field16 = 0
};
return ParseResult<PlossRecord>.Success(record);
}
catch (Exception ex)
{
return ParseResult<PlossRecord>.Failure(
"TYPE_CONVERSION",
$"Failed to convert field: {ex.Message}"
);
}
}
private ParseResult<PlossRecord> ParseHeaderLine(Match match, Guid scenarioId)
{
// 单独解析header行返回部分记录
// 实际使用时应通过ParseMultiLine合并
try
{
var record = new PlossRecord
{
ScenarioId = scenarioId,
PowLoss = int.Parse(match.Groups[1].Value),
DeltaP = int.Parse(match.Groups[2].Value)
};
return ParseResult<PlossRecord>.Success(record);
}
catch (Exception ex)
{
return ParseResult<PlossRecord>.Failure(
"TYPE_CONVERSION",
$"Failed to convert header field: {ex.Message}"
);
}
}
private ParseResult<PlossRecord> ParseFodLine(Match match, Guid scenarioId) private ParseResult<PlossRecord> ParseFodLine(Match match, Guid scenarioId)
{ {
// 单独解析FOD行新格式,无header // 单独解析FOD行无header,返回部分记录
try try
{ {
var record = new PlossRecord var record = new PlossRecord

View File

@@ -69,9 +69,9 @@ public class AggregationService
.AsSplitQuery() .AsSplitQuery()
.Where(r => !r.IsDeleted) .Where(r => !r.IsDeleted)
.Include(r => r.Scenario) .Include(r => r.Scenario)
.ThenInclude(s => s!.TxPanel) .ThenInclude(s => s!.Manufacturer)
.Include(r => r.Scenario) .Include(r => r.Scenario)
.ThenInclude(s => s!.TxHardware) .ThenInclude(s => s!.CarModel)
.Include(r => r.Scenario) .Include(r => r.Scenario)
.ThenInclude(s => s!.RxType) .ThenInclude(s => s!.RxType)
.ToListAsync(cancellationToken); .ToListAsync(cancellationToken);
@@ -79,8 +79,8 @@ public class AggregationService
return records.Select(r => new PlossRow( return records.Select(r => new PlossRow(
r, r,
r.Scenario, r.Scenario,
r.Scenario.TxPanel.Name, r.Scenario.Manufacturer.Name,
r.Scenario.TxHardware.Version, r.Scenario.CarModel.Name,
r.Scenario.RxType.Name)).ToList(); r.Scenario.RxType.Name)).ToList();
} }
@@ -109,11 +109,9 @@ public class AggregationService
private ChargingParameterKey BuildKey(TestScenario scenario) private ChargingParameterKey BuildKey(TestScenario scenario)
{ {
var panelName = scenario.TxPanel.Name; // 直接使用 Manufacturer 和 CarModel 维度
var mapping = _configuration.GetSection("Aggregation:TxPanelMappings")[panelName]; var carFactory = scenario.Manufacturer.Name;
var carFactory = mapping ?? panelName; var carModel = scenario.CarModel.Name;
var carModel = _configuration[$"Aggregation:CarModelMappings:{scenario.TxHardware.Version}"]
?? scenario.TxHardware.Version;
var (phoneBrand, phoneModel) = SplitPhoneName(scenario.RxType.Name); var (phoneBrand, phoneModel) = SplitPhoneName(scenario.RxType.Name);
return new ChargingParameterKey(carFactory, carModel, phoneBrand, phoneModel); return new ChargingParameterKey(carFactory, carModel, phoneBrand, phoneModel);
} }

View File

@@ -1,5 +1,6 @@
using System.CommandLine; using System.CommandLine;
using Gpulse.WCT.DataAnalyzer.Core.Application.Analysis; using Gpulse.WCT.DataAnalyzer.Core.Application.Analysis;
using Gpulse.WCT.DataAnalyzer.Core.Application.Exporting;
namespace Gpulse.WCT.DataAnalyzer.Commands; namespace Gpulse.WCT.DataAnalyzer.Commands;
@@ -8,9 +9,12 @@ namespace Gpulse.WCT.DataAnalyzer.Commands;
/// </summary> /// </summary>
public class AnalyzeCommand : Command public class AnalyzeCommand : Command
{ {
private static readonly string[] LogExtensions = [".DAT", ".dat", ".LOG", ".log"];
public AnalyzeCommand( public AnalyzeCommand(
PlossAnalysisService plossAnalysisService, PlossAnalysisService plossAnalysisService,
QfodCalibrationService qfodCalibrationService) QfodCalibrationService qfodCalibrationService,
AnalyzeExcelExporter excelExporter)
: base("analyze", "Analyze Ploss margin or Qfod calibration threshold from log files") : base("analyze", "Analyze Ploss margin or Qfod calibration threshold from log files")
{ {
var typeOption = new Option<string>( var typeOption = new Option<string>(
@@ -36,31 +40,246 @@ public class AnalyzeCommand : Command
"Qfod: path to the phone + foreign object charging log file" "Qfod: path to the phone + foreign object charging log file"
); );
var outputOption = new Option<string?>(
new[] { "--output", "-o" },
"Output Excel file path (default: same directory as input with .xlsx extension)"
);
var autoOption = new Option<bool>(
"--auto",
"Auto scan Datas folder and analyze all files"
);
AddOption(typeOption); AddOption(typeOption);
AddOption(fileOption); AddOption(fileOption);
AddOption(pureOption); AddOption(pureOption);
AddOption(foreignOption); AddOption(foreignOption);
AddOption(outputOption);
AddOption(autoOption);
this.SetHandler(async (type, file, pure, foreign) => this.SetHandler(async (type, file, pure, foreign, output, auto) =>
{ {
type = type.ToLower(); type = type.ToLower();
// 自动扫描模式
if (auto || (string.IsNullOrEmpty(file) && string.IsNullOrEmpty(pure) && string.IsNullOrEmpty(foreign)))
{
await HandleAutoScanAsync(plossAnalysisService, qfodCalibrationService, excelExporter, type);
return;
}
switch (type) switch (type)
{ {
case "ploss": case "ploss":
await HandlePlossAsync(plossAnalysisService, file); await HandlePlossAsync(plossAnalysisService, excelExporter, file, output);
break; break;
case "qfod": case "qfod":
await HandleQfodAsync(qfodCalibrationService, pure, foreign); await HandleQfodAsync(qfodCalibrationService, excelExporter, pure, foreign, output);
break; break;
default: default:
Console.WriteLine($"Unknown type: {type}. Use 'ploss' or 'qfod'."); Console.WriteLine($"Unknown type: {type}. Use 'ploss' or 'qfod'.");
break; break;
} }
}, typeOption, fileOption, pureOption, foreignOption); }, typeOption, fileOption, pureOption, foreignOption, outputOption, autoOption);
} }
private static async Task HandlePlossAsync(PlossAnalysisService service, string? file) /// <summary>
/// 自动扫描 Datas 文件夹并分析
/// </summary>
private static async Task HandleAutoScanAsync(
PlossAnalysisService plossService,
QfodCalibrationService qfodService,
AnalyzeExcelExporter exporter,
string type)
{
var dataDir = Path.Combine(AppContext.BaseDirectory, "Datas");
if (!Directory.Exists(dataDir))
{
Console.WriteLine($"[错误] 未找到 Datas 文件夹: {dataDir}");
Console.WriteLine("请创建 Datas 文件夹并放入日志文件,或手动指定文件路径。");
return;
}
// 扫描日志文件
var allFiles = LogExtensions
.SelectMany(ext => Directory.GetFiles(dataDir, $"*{ext}"))
.ToList();
if (allFiles.Count == 0)
{
Console.WriteLine($"[错误] Datas 文件夹中未找到日志文件");
return;
}
// 分类文件
var pureFiles = new List<string>();
var foreignFiles = new List<string>();
foreach (var filePath in allFiles)
{
var foreignType = GetForeignObjectType(Path.GetFileNameWithoutExtension(filePath));
if (foreignType == "无")
pureFiles.Add(filePath);
else
foreignFiles.Add(filePath);
}
Console.WriteLine($"\n扫描 Datas 文件夹:");
Console.WriteLine($" 总计: {allFiles.Count} 个日志文件");
Console.WriteLine($" 纯手机: {pureFiles.Count} 个");
Console.WriteLine($" 异物: {foreignFiles.Count} 个");
Console.WriteLine();
switch (type)
{
case "ploss":
Console.WriteLine("=== Ploss 分析 ===");
int plossOk = 0, plossFailed = 0;
foreach (var filePath in allFiles)
{
try
{
await HandlePlossAsync(plossService, exporter, filePath, null);
plossOk++;
}
catch (Exception ex)
{
plossFailed++;
Console.WriteLine($"[FAILED] {Path.GetFileName(filePath)}: {ex.Message}");
}
}
Console.WriteLine($"\n[完成] 已分析 {plossOk} 个文件{(plossFailed > 0 ? $" {plossFailed} " : "")}");
break;
case "qfod":
if (pureFiles.Count == 0)
{
Console.WriteLine("[错误] 未找到纯手机日志文件名第7段为\"无\"");
return;
}
if (foreignFiles.Count == 0)
{
Console.WriteLine("[错误] 未找到异物日志文件名第7段不为\"无\"");
return;
}
Console.WriteLine("=== Qfod 标定 ===");
if (pureFiles.Count == 1 && foreignFiles.Count == 1)
{
try
{
await HandleQfodAsync(qfodService, exporter, pureFiles[0], foreignFiles[0], null);
}
catch (Exception ex)
{
Console.WriteLine($"[FAILED] {Path.GetFileName(pureFiles[0])}: {ex.Message}");
}
Console.WriteLine("\n[完成] 已分析 1 个配对");
}
else
{
int pairCount = 0, qfodOk = 0, qfodFailed = 0;
foreach (var pure in pureFiles)
{
foreach (var foreign in foreignFiles)
{
pairCount++;
var outputPath = GetOutputPath(pure, null, $"qfod_{pairCount}");
try
{
await HandleQfodAsync(qfodService, exporter, pure, foreign, outputPath);
qfodOk++;
}
catch (Exception ex)
{
qfodFailed++;
Console.WriteLine($"[FAILED] {Path.GetFileName(pure)} + {Path.GetFileName(foreign)}: {ex.Message}");
}
}
}
Console.WriteLine($"\n[完成] 已分析 {qfodOk} 个配对{(qfodFailed > 0 ? $" {qfodFailed} " : "")}");
}
break;
default:
Console.WriteLine($"Unknown type: {type}. Use 'ploss' or 'qfod'.");
break;
}
}
/// <summary>
/// 从文件名提取异物类型第7段
/// 格式: 车厂-车型-TX面板-TX硬件-TX软件-RX类型-异物类型-测试目的-年月日测试次数
/// </summary>
private static string GetForeignObjectType(string fileNameWithoutExt)
{
var parts = fileNameWithoutExt.Split('-');
return parts.Length >= 7 ? parts[6] : "";
}
/// <summary>
/// 获取输出目录exe 同目录下的 Output 文件夹)
/// </summary>
private static string GetOutputDirectory()
{
var outputDir = Path.Combine(AppContext.BaseDirectory, "Output");
if (!Directory.Exists(outputDir))
Directory.CreateDirectory(outputDir);
return outputDir;
}
/// <summary>
/// 净化来自文件名的路径段:剔除路径分隔符、遍历段、非法字符,防止 Datas/ 下的文件名
/// 经 Path.Combine 写到 Output/ 之外(路径遍历)。不可信输入必须经此处理。
/// </summary>
private static string SafeSegment(string segment)
{
if (string.IsNullOrWhiteSpace(segment))
return "Unknown";
var invalid = Path.GetInvalidFileNameChars()
.Concat(['/', '\\', ':', '*', '?', '"', '<', '>', '|']);
var cleaned = new string(segment.Where(c => !invalid.Contains(c)).ToArray());
return string.IsNullOrWhiteSpace(cleaned) ? "Unknown" : cleaned;
}
/// <summary>
/// 根据文件名解析出目录结构Output/{车厂}/{车型}/{手机名}/
/// 文件名格式: 车厂-车型-TX面板-TX硬件-TX软件-RX类型-异物类型-测试目的-年月日测试次数
/// </summary>
private static string GetOutputPath(string inputFile, string? customOutput = null, string? suffix = null)
{
if (customOutput != null)
return customOutput;
var fileName = Path.GetFileNameWithoutExtension(inputFile);
var parts = fileName.Split('-');
// 解析车厂、车型、手机名(RX类型),经 SafeSegment 净化防止路径遍历
var manufacturer = parts.Length >= 1 ? SafeSegment(parts[0]) : "Unknown";
var carModel = parts.Length >= 2 ? SafeSegment(parts[1]) : "Unknown";
var phoneName = parts.Length >= 6 ? SafeSegment(parts[5]) : "Unknown";
// 构建输出目录
var outputDir = Path.Combine(GetOutputDirectory(), manufacturer, carModel, phoneName);
if (!Directory.Exists(outputDir))
Directory.CreateDirectory(outputDir);
// 构建输出文件名
var outputFileName = suffix != null
? $"{fileName}_{suffix}.xlsx"
: $"{fileName}.xlsx";
return Path.Combine(outputDir, outputFileName);
}
private static async Task HandlePlossAsync(
PlossAnalysisService service,
AnalyzeExcelExporter exporter,
string? file,
string? output)
{ {
if (string.IsNullOrEmpty(file)) if (string.IsNullOrEmpty(file))
{ {
@@ -68,48 +287,27 @@ public class AnalyzeCommand : Command
return; return;
} }
var report = await service.AnalyzeFileAsync(file); var excelReport = await service.AnalyzeForExcelAsync(file);
if (!report.IsSuccess) if (!excelReport.IsSuccess)
{ {
Console.WriteLine($" [{report.FileName}] FAILED: {report.ErrorMessage}"); Console.WriteLine($"[FAILED] {excelReport.FileName}: {excelReport.ErrorMessage}");
return; return;
} }
Console.WriteLine($"\n=== Ploss Analysis: {report.FileName} ==="); var outputPath = GetOutputPath(file, output);
Console.WriteLine($"Total: {report.TotalCount} (TwoLine: {report.TwoLineCount}, Legacy: {report.LegacyCount}), FOD: {report.FodCount}, Errors: {report.ErrorCount}");
var normalRatio = Percent(report.NormalAboveCount, report.NormalCount); await exporter.ExportPlossReportAsync(excelReport, outputPath);
var fodRatio = Percent(report.FodAboveCount, report.FodCount); Console.WriteLine($"[OK] {outputPath}");
Console.WriteLine($"Normal margin>2000: {report.NormalAboveCount}/{report.NormalCount} ({normalRatio}%)"); Console.WriteLine($"Total: {excelReport.TotalCount}, Dropped: {excelReport.DroppedCount}, Errors: {excelReport.ErrorCount}");
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) private static async Task HandleQfodAsync(
QfodCalibrationService service,
AnalyzeExcelExporter exporter,
string? pure,
string? foreign,
string? output)
{ {
if (string.IsNullOrEmpty(pure) || string.IsNullOrEmpty(foreign)) if (string.IsNullOrEmpty(pure) || string.IsNullOrEmpty(foreign))
{ {
@@ -117,28 +315,17 @@ public class AnalyzeCommand : Command
return; return;
} }
var report = await service.CalibrateAsync(pure, foreign); var excelReport = await service.CalibrateForExcelAsync(pure, foreign);
if (!report.IsSuccess) if (!excelReport.IsSuccess)
{ {
Console.WriteLine($" FAILED: {report.ErrorMessage}"); Console.WriteLine($"[FAILED] {excelReport.ErrorMessage}");
return; return;
} }
Console.WriteLine("\n=== Qfod Calibration ==="); var outputPath = output ?? GetOutputPath(pure, output, "qfod");
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}"); await exporter.ExportQfodReportAsync(excelReport, outputPath);
Console.WriteLine($"Margin: pureMax - threshold = {report.PureMargin} (负为安全), foreignMin - threshold = {report.ForeignMargin} (正为安全)"); Console.WriteLine($"[OK] {outputPath}");
} }
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

@@ -43,4 +43,8 @@
<EmbeddedResource Include="appsettings.json" /> <EmbeddedResource Include="appsettings.json" />
</ItemGroup> </ItemGroup>
<ItemGroup>
<None Include="analyze.bat" CopyToOutputDirectory="PreserveNewest" />
</ItemGroup>
</Project> </Project>

View File

@@ -1,6 +1,7 @@
using System.CommandLine; using System.CommandLine;
using System.Reflection; using System.Reflection;
using System.Text.Json; using System.Text.Json;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Configuration; using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Hosting;
@@ -10,6 +11,7 @@ using Gpulse.WCT.DataAnalyzer.Core.Infrastructure.LocalData;
using Gpulse.WCT.DataAnalyzer.Core.Infrastructure.Extensions; using Gpulse.WCT.DataAnalyzer.Core.Infrastructure.Extensions;
using Gpulse.WCT.DataAnalyzer.Core.Application; using Gpulse.WCT.DataAnalyzer.Core.Application;
using Gpulse.WCT.DataAnalyzer.Core.Application.Analysis; using Gpulse.WCT.DataAnalyzer.Core.Application.Analysis;
using Gpulse.WCT.DataAnalyzer.Core.Application.Exporting;
namespace Gpulse.WCT.DataAnalyzer; namespace Gpulse.WCT.DataAnalyzer;
@@ -50,14 +52,14 @@ public class Program
var app = builder.Build(); var app = builder.Build();
// 初始化数据库 // 初始化数据库(经 EF 迁移建表/升级,替代 EnsureCreated 以支持既有库升级)
using (var scope = app.Services.CreateScope()) using (var scope = app.Services.CreateScope())
{ {
var dbContext = scope.ServiceProvider.GetRequiredService<WctMinerDbContext>(); var dbContext = scope.ServiceProvider.GetRequiredService<WctMinerDbContext>();
await dbContext.Database.EnsureCreatedAsync(); await DatabaseMigrationInitializer.MigrateOrUpgradeAsync(dbContext);
var releaseContext = scope.ServiceProvider.GetRequiredService<ReleaseDbContext>(); var releaseContext = scope.ServiceProvider.GetRequiredService<ReleaseDbContext>();
await releaseContext.Database.EnsureCreatedAsync(); await DatabaseMigrationInitializer.MigrateOrUpgradeAsync(releaseContext);
// 种子数据 // 种子数据
SeedData.Initialize(dbContext); SeedData.Initialize(dbContext);
@@ -75,7 +77,8 @@ public class Program
rootCommand.AddCommand(new CleanCommand(provider.GetRequiredService<CleanService>())); rootCommand.AddCommand(new CleanCommand(provider.GetRequiredService<CleanService>()));
rootCommand.AddCommand(new AnalyzeCommand( rootCommand.AddCommand(new AnalyzeCommand(
provider.GetRequiredService<PlossAnalysisService>(), provider.GetRequiredService<PlossAnalysisService>(),
provider.GetRequiredService<QfodCalibrationService>())); provider.GetRequiredService<QfodCalibrationService>(),
provider.GetRequiredService<AnalyzeExcelExporter>()));
return await rootCommand.InvokeAsync(args); return await rootCommand.InvokeAsync(args);
} }

View File

@@ -0,0 +1,38 @@
@echo off
chcp 65001 >nul
:: WCT DataAnalyzer 分析工具
:: 自动扫描 Datas 文件夹,执行 Ploss 和 Qfod 分析
set "SCRIPT_DIR=%~dp0"
set "EXE_PATH=%SCRIPT_DIR%Gpulse.WCT.DataAnalyzer.exe"
if not exist "%EXE_PATH%" (
echo [错误] 找不到程序: %EXE_PATH%
echo 请确保此脚本与 Gpulse.WCT.DataAnalyzer.exe 在同一目录
pause
exit /b 1
)
echo ========================================
echo WCT DataAnalyzer 分析工具
echo ========================================
echo.
:: 执行 Ploss 分析
echo [1/2] 执行 Ploss 分析...
"%EXE_PATH%" analyze --type ploss --auto
echo.
:: 执行 Qfod 标定
echo [2/2] 执行 Qfod 标定...
"%EXE_PATH%" analyze --type qfod --auto
echo.
echo ========================================
echo 分析完成
echo ========================================
echo.
echo 输出目录: %SCRIPT_DIR%Output
echo.
pause