diff --git a/src/Gpulse.WCT.DataAnalyzer.Core/Application/LocalIngestion/DimensionService.cs b/src/Gpulse.WCT.DataAnalyzer.Core/Application/LocalIngestion/DimensionService.cs index 911267f..6bd5361 100644 --- a/src/Gpulse.WCT.DataAnalyzer.Core/Application/LocalIngestion/DimensionService.cs +++ b/src/Gpulse.WCT.DataAnalyzer.Core/Application/LocalIngestion/DimensionService.cs @@ -75,6 +75,48 @@ public class DimensionService ); } + /// + /// 获取或创建车厂维度(并发安全) + /// + public async Task 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" + ); + } + + /// + /// 获取或创建车型维度(并发安全) + /// + public async Task 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" + ); + } + + /// + /// 获取或创建异物类型维度(并发安全) + /// + public async Task 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" + ); + } + /// /// 通用的并发安全 GetOrCreate 模式 /// diff --git a/src/Gpulse.WCT.DataAnalyzer.Core/Application/LocalIngestion/ScenarioService.cs b/src/Gpulse.WCT.DataAnalyzer.Core/Application/LocalIngestion/ScenarioService.cs index b75e9e2..f53822e 100644 --- a/src/Gpulse.WCT.DataAnalyzer.Core/Application/LocalIngestion/ScenarioService.cs +++ b/src/Gpulse.WCT.DataAnalyzer.Core/Application/LocalIngestion/ScenarioService.cs @@ -7,7 +7,7 @@ using Gpulse.WCT.DataAnalyzer.Core.Application.Parsing; namespace Gpulse.WCT.DataAnalyzer.Core.Application; /// -/// 篮选条件常量 +/// 筛选条件常量 /// public static class FilterConstants { @@ -38,14 +38,17 @@ public class ScenarioService /// public async Task GetOrCreateScenarioAsync(ScenarioInfo info) { - _logger.LogDebug("查找/创建场景: TxPanel={TxPanel}, TxHardware={TxHardware}, TxSoftware={TxSoftware}, RxType={RxType}", - info.TxPanel, info.TxHardware, info.TxSoftware, info.RxType); + _logger.LogDebug("查找/创建场景: Manufacturer={Manufacturer}, CarModel={CarModel}, TxPanel={TxPanel}, TxHardware={TxHardware}, TxSoftware={TxSoftware}, RxType={RxType}, ForeignObject={ForeignObject}", + info.Manufacturer, info.CarModel, info.TxPanel, info.TxHardware, info.TxSoftware, info.RxType, info.ForeignObject); // 1. 获取或创建各维度 + var manufacturer = await _dimensionService.GetOrCreateManufacturerAsync(info.Manufacturer); + var carModel = await _dimensionService.GetOrCreateCarModelAsync(info.CarModel); var txPanel = await _dimensionService.GetOrCreateTxPanelAsync(info.TxPanel); var txHardware = await _dimensionService.GetOrCreateTxHardwareAsync(info.TxHardware); var txSoftware = await _dimensionService.GetOrCreateTxSoftwareAsync(info.TxSoftware); var rxType = await _dimensionService.GetOrCreateRxTypeAsync(info.RxType); + var foreignObject = await _dimensionService.GetOrCreateForeignObjectAsync(info.ForeignObject); // 2. 并发安全的场景创建 const int maxRetries = 3; @@ -56,10 +59,13 @@ public class ScenarioService var existing = await _context.TestScenarios .AsNoTracking() .FirstOrDefaultAsync(s => + s.ManufacturerId == manufacturer.Id && + s.CarModelId == carModel.Id && s.TxPanelId == txPanel.Id && s.TxHardwareId == txHardware.Id && s.TxSoftwareId == txSoftware.Id && s.RxTypeId == rxType.Id && + s.ForeignObjectId == foreignObject.Id && s.TestPurpose == info.TestPurpose && s.TestDate == info.TestDate && s.TestSequence == info.TestSequence); @@ -73,10 +79,13 @@ public class ScenarioService // 3. 创建新场景 var scenario = new TestScenario { + ManufacturerId = manufacturer.Id, + CarModelId = carModel.Id, TxPanelId = txPanel.Id, TxHardwareId = txHardware.Id, TxSoftwareId = txSoftware.Id, RxTypeId = rxType.Id, + ForeignObjectId = foreignObject.Id, TestPurpose = info.TestPurpose, TestDate = info.TestDate, TestSequence = info.TestSequence @@ -88,8 +97,8 @@ public class ScenarioService { await _context.SaveChangesAsync(); - _logger.LogInformation("创建新测试场景: {ScenarioId}, TxPanel={TxPanel}, TxHardware={TxHardware}, RxType={RxType}, TestDate={TestDate}", - scenario.Id, info.TxPanel, info.TxHardware, info.RxType, info.TestDate); + _logger.LogInformation("创建新测试场景: {ScenarioId}, Manufacturer={Manufacturer}, CarModel={CarModel}, TxPanel={TxPanel}, TxHardware={TxHardware}, RxType={RxType}, ForeignObject={ForeignObject}, TestDate={TestDate}", + scenario.Id, info.Manufacturer, info.CarModel, info.TxPanel, info.TxHardware, info.RxType, info.ForeignObject, info.TestDate); return scenario; } @@ -97,7 +106,7 @@ public class ScenarioService { // Detach 实体以避免 ChangeTracker 问题 _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) { @@ -111,17 +120,20 @@ public class ScenarioService var finalScenario = await _context.TestScenarios .AsNoTracking() .FirstOrDefaultAsync(s => + s.ManufacturerId == manufacturer.Id && + s.CarModelId == carModel.Id && s.TxPanelId == txPanel.Id && s.TxHardwareId == txHardware.Id && s.TxSoftwareId == txSoftware.Id && s.RxTypeId == rxType.Id && + s.ForeignObjectId == foreignObject.Id && s.TestPurpose == info.TestPurpose && s.TestDate == info.TestDate && s.TestSequence == info.TestSequence); 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; @@ -133,10 +145,13 @@ public class ScenarioService public async Task GetScenarioWithDimensionsAsync(Guid scenarioId) { return await _context.TestScenarios + .Include(s => s.Manufacturer) + .Include(s => s.CarModel) .Include(s => s.TxPanel) .Include(s => s.TxHardware) .Include(s => s.TxSoftware) .Include(s => s.RxType) + .Include(s => s.ForeignObject) .FirstOrDefaultAsync(s => s.Id == scenarioId); } @@ -148,10 +163,13 @@ public class ScenarioService public async Task> GetAllScenariosAsync(int pageSize = 50, int pageIndex = 0) { var query = _context.TestScenarios + .Include(s => s.Manufacturer) + .Include(s => s.CarModel) .Include(s => s.TxPanel) .Include(s => s.TxHardware) .Include(s => s.TxSoftware) .Include(s => s.RxType) + .Include(s => s.ForeignObject) .OrderByDescending(s => s.TestDate) .ThenByDescending(s => s.TestSequence) .AsNoTracking(); @@ -177,6 +195,18 @@ public class ScenarioService /// public async Task 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 .Select(p => p.Name) .Distinct() @@ -201,37 +231,56 @@ public class ScenarioService .OrderBy(n => n) .ToListAsync(); + var foreignObjects = await _context.ForeignObjects + .Select(f => f.Name) + .Distinct() + .OrderBy(n => n) + .ToListAsync(); + return new FilterOptions( + manufacturers.Prepend(FilterConstants.AllOption).ToList(), + carModels.Prepend(FilterConstants.AllOption).ToList(), txPanels.Prepend(FilterConstants.AllOption).ToList(), txHardwares.Prepend(FilterConstants.AllOption).ToList(), txSoftwares.Prepend(FilterConstants.AllOption).ToList(), - rxTypes.Prepend(FilterConstants.AllOption).ToList()); + rxTypes.Prepend(FilterConstants.AllOption).ToList(), + foreignObjects.Prepend(FilterConstants.AllOption).ToList()); } /// /// 根据筛选条件获取测试场景 /// - /// TxPanel 名称,null 或 "全部" 表示不过滤 - /// TxHardware 版本,null 或 "全部" 表示不过滤 - /// TxSoftware 版本,null 或 "全部" 表示不过滤 - /// RxType 名称,null 或 "全部" 表示不过滤 - /// 每页数量,默认50,传0表示不分页 - /// 页码,从0开始 public async Task> GetScenariosByFilterAsync( + string? manufacturer = null, + string? carModel = null, string? txPanel = null, string? txHardware = null, string? txSoftware = null, string? rxType = null, + string? foreignObject = null, int pageSize = 50, int pageIndex = 0) { var query = _context.TestScenarios + .Include(s => s.Manufacturer) + .Include(s => s.CarModel) .Include(s => s.TxPanel) .Include(s => s.TxHardware) .Include(s => s.TxSoftware) .Include(s => s.RxType) + .Include(s => s.ForeignObject) .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) { query = query.Where(s => s.TxPanel.Name == txPanel); @@ -252,6 +301,11 @@ public class ScenarioService 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 .OrderByDescending(s => s.TestDate) .ThenByDescending(s => s.TestSequence); @@ -307,7 +361,10 @@ public class ScenarioService /// 筛选选项数据 /// public record FilterOptions( + List Manufacturers, + List CarModels, List TxPanels, List TxHardwares, List TxSoftwares, - List RxTypes); \ No newline at end of file + List RxTypes, + List ForeignObjects); diff --git a/src/Gpulse.WCT.DataAnalyzer.Core/Application/Parsing/FileNameParser.cs b/src/Gpulse.WCT.DataAnalyzer.Core/Application/Parsing/FileNameParser.cs index fd834c0..f0cec92 100644 --- a/src/Gpulse.WCT.DataAnalyzer.Core/Application/Parsing/FileNameParser.cs +++ b/src/Gpulse.WCT.DataAnalyzer.Core/Application/Parsing/FileNameParser.cs @@ -4,9 +4,9 @@ namespace Gpulse.WCT.DataAnalyzer.Core.Application.Parsing; /// /// 文件名解析器 - 从文件名提取场景信息 -/// 格式: 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 /// public class FileNameParser { @@ -19,39 +19,60 @@ public class FileNameParser /// 场景信息,若格式不匹配返回 null public ScenarioInfo? Parse(string fileName) { - // 移除 .log 扩展名 + // 移除扩展名 var nameWithoutExt = Path.GetFileNameWithoutExtension(fileName); if (string.IsNullOrEmpty(nameWithoutExt)) return null; var parts = nameWithoutExt.Split(Separators); - if (parts.Length < 7) + if (parts.Length < 9) return null; - // 解析日期 - if (!TryParseDate(parts[5], out var testDate)) + // 解析日期+序号(最后一个字段,格式:yyyyMMddNN) + var dateSeqStr = parts[8]; + if (!TryParseDateSequence(dateSeqStr, out var testDate, out var testSequence)) return null; - // 解析测试序号 - if (!int.TryParse(parts[6], out var testSequence)) - testSequence = 1; - return new ScenarioInfo( - TxPanel: parts[0], - TxHardware: parts[1], - TxSoftware: parts[2], - RxType: parts[3], - TestPurpose: parts.Length > 4 ? parts[4] : null, + Manufacturer: parts[0], + CarModel: parts[1], + TxPanel: parts[2], + TxHardware: parts[3], + TxSoftware: parts[4], + RxType: parts[5], + ForeignObject: parts[6], + TestPurpose: parts[7], TestDate: testDate, TestSequence: testSequence ); } - private static bool TryParseDate(string dateStr, out DateOnly date) + /// + /// 解析日期+序号组合字段 + /// 格式: yyyyMMddNN (如 2026081701 表示 2026年8月17日 第01次测试) + /// + private static bool TryParseDateSequence(string dateSeqStr, out DateOnly date, out int sequence) { - // 支持多种日期格式 - var formats = new[] { "yyyyMMdd", "yyyy-MM-dd", "yyMMdd" }; - return DateOnly.TryParseExact(dateStr, formats, CultureInfo.InvariantCulture, DateTimeStyles.None, out date); + date = default; + sequence = 1; + + // 最小长度: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 /// 场景信息 - 从文件名解析出的测试场景数据 /// public record ScenarioInfo( + string Manufacturer, + string CarModel, string TxPanel, string TxHardware, string TxSoftware, string RxType, + string ForeignObject, string? TestPurpose, DateOnly TestDate, int TestSequence -); \ No newline at end of file +); diff --git a/src/Gpulse.WCT.DataAnalyzer.Core/Application/Parsing/PlossParser.cs b/src/Gpulse.WCT.DataAnalyzer.Core/Application/Parsing/PlossParser.cs index 9c2de79..a31a306 100644 --- a/src/Gpulse.WCT.DataAnalyzer.Core/Application/Parsing/PlossParser.cs +++ b/src/Gpulse.WCT.DataAnalyzer.Core/Application/Parsing/PlossParser.cs @@ -5,10 +5,9 @@ using Gpulse.WCT.DataAnalyzer.Core.Domain.Local; namespace Gpulse.WCT.DataAnalyzer.Core.Application.Parsing; /// -/// Ploss FOD日志解析器 - 支持两行格式 +/// Ploss FOD日志解析器 - 两行格式 /// 第一行: pow_loss = 3053, delta_p = 1950 /// 第二行: FOD-> (1) (2) ... (16) -/// 同时兼容旧的单行格式 /// public class PlossParser : IParser { @@ -27,17 +26,10 @@ public class PlossParser : IParser 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 - ); - /// /// 匹配类型枚举 /// - public enum MatchType { None, Header, Fod, Legacy } + public enum MatchType { None, Header, Fod } /// /// 单次匹配结果(避免重复正则匹配) @@ -52,10 +44,6 @@ public class PlossParser : IParser if (fodMatch.Success) return (MatchType.Fod, fodMatch); - var legacyMatch = LegacyPattern.Match(line); - if (legacyMatch.Success) - return (MatchType.Legacy, legacyMatch); - return (MatchType.None, Match.Empty); } @@ -70,7 +58,7 @@ public class PlossParser : IParser public bool IsMultiLineStart(string line) => TryMatch(line).Type == MatchType.Header; /// - /// 单行解析(兼容旧格式) + /// 单行解析(仅支持FOD行,用于错误提示) /// public ParseResult Parse(string line, Guid scenarioId) { @@ -78,15 +66,17 @@ public class PlossParser : IParser return type switch { - MatchType.Legacy => ParseLegacyFormat(match, scenarioId), MatchType.Fod => ParseFodLine(match, scenarioId), - MatchType.Header => ParseHeaderLine(match, scenarioId), + MatchType.Header => ParseResult.Failure( + "INCOMPLETE_RECORD", + "Header line requires FOD line. Use ParseMultiLine instead." + ), _ => ParseResult.Failure("FORMAT_MISMATCH", "Line does not match Ploss pattern") }; } /// - /// 多行解析(新两行格式) + /// 多行解析(两行格式) /// public ParseResult? ParseMultiLine(string[] lines, Guid scenarioId) { @@ -137,73 +127,9 @@ public class PlossParser : IParser } } - private ParseResult 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.Success(record); - } - catch (Exception ex) - { - return ParseResult.Failure( - "TYPE_CONVERSION", - $"Failed to convert field: {ex.Message}" - ); - } - } - - private ParseResult 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.Success(record); - } - catch (Exception ex) - { - return ParseResult.Failure( - "TYPE_CONVERSION", - $"Failed to convert header field: {ex.Message}" - ); - } - } - private ParseResult ParseFodLine(Match match, Guid scenarioId) { - // 单独解析FOD行(新格式,无header) + // 单独解析FOD行(无header),返回部分记录 try { var record = new PlossRecord diff --git a/src/Gpulse.WCT.DataAnalyzer.Core/Application/Publishing/AggregationService.cs b/src/Gpulse.WCT.DataAnalyzer.Core/Application/Publishing/AggregationService.cs index 0dd7059..b258214 100644 --- a/src/Gpulse.WCT.DataAnalyzer.Core/Application/Publishing/AggregationService.cs +++ b/src/Gpulse.WCT.DataAnalyzer.Core/Application/Publishing/AggregationService.cs @@ -69,9 +69,9 @@ public class AggregationService .AsSplitQuery() .Where(r => !r.IsDeleted) .Include(r => r.Scenario) - .ThenInclude(s => s!.TxPanel) + .ThenInclude(s => s!.Manufacturer) .Include(r => r.Scenario) - .ThenInclude(s => s!.TxHardware) + .ThenInclude(s => s!.CarModel) .Include(r => r.Scenario) .ThenInclude(s => s!.RxType) .ToListAsync(cancellationToken); @@ -79,8 +79,8 @@ public class AggregationService return records.Select(r => new PlossRow( r, r.Scenario, - r.Scenario.TxPanel.Name, - r.Scenario.TxHardware.Version, + r.Scenario.Manufacturer.Name, + r.Scenario.CarModel.Name, r.Scenario.RxType.Name)).ToList(); } @@ -109,11 +109,9 @@ public class AggregationService private ChargingParameterKey BuildKey(TestScenario scenario) { - var panelName = scenario.TxPanel.Name; - var mapping = _configuration.GetSection("Aggregation:TxPanelMappings")[panelName]; - var carFactory = mapping ?? panelName; - var carModel = _configuration[$"Aggregation:CarModelMappings:{scenario.TxHardware.Version}"] - ?? scenario.TxHardware.Version; + // 直接使用 Manufacturer 和 CarModel 维度 + var carFactory = scenario.Manufacturer.Name; + var carModel = scenario.CarModel.Name; var (phoneBrand, phoneModel) = SplitPhoneName(scenario.RxType.Name); return new ChargingParameterKey(carFactory, carModel, phoneBrand, phoneModel); } diff --git a/src/Gpulse.WCT.DataAnalyzer/Commands/AnalyzeCommand.cs b/src/Gpulse.WCT.DataAnalyzer/Commands/AnalyzeCommand.cs index 65e1d68..20cbb70 100644 --- a/src/Gpulse.WCT.DataAnalyzer/Commands/AnalyzeCommand.cs +++ b/src/Gpulse.WCT.DataAnalyzer/Commands/AnalyzeCommand.cs @@ -1,5 +1,6 @@ using System.CommandLine; using Gpulse.WCT.DataAnalyzer.Core.Application.Analysis; +using Gpulse.WCT.DataAnalyzer.Core.Application.Exporting; namespace Gpulse.WCT.DataAnalyzer.Commands; @@ -8,9 +9,12 @@ namespace Gpulse.WCT.DataAnalyzer.Commands; /// public class AnalyzeCommand : Command { + private static readonly string[] LogExtensions = [".DAT", ".dat", ".LOG", ".log"]; + public AnalyzeCommand( PlossAnalysisService plossAnalysisService, - QfodCalibrationService qfodCalibrationService) + QfodCalibrationService qfodCalibrationService, + AnalyzeExcelExporter excelExporter) : base("analyze", "Analyze Ploss margin or Qfod calibration threshold from log files") { var typeOption = new Option( @@ -36,31 +40,246 @@ public class AnalyzeCommand : Command "Qfod: path to the phone + foreign object charging log file" ); + var outputOption = new Option( + new[] { "--output", "-o" }, + "Output Excel file path (default: same directory as input with .xlsx extension)" + ); + + var autoOption = new Option( + "--auto", + "Auto scan Datas folder and analyze all files" + ); + AddOption(typeOption); AddOption(fileOption); AddOption(pureOption); 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(); + // 自动扫描模式 + if (auto || (string.IsNullOrEmpty(file) && string.IsNullOrEmpty(pure) && string.IsNullOrEmpty(foreign))) + { + await HandleAutoScanAsync(plossAnalysisService, qfodCalibrationService, excelExporter, type); + return; + } + switch (type) { case "ploss": - await HandlePlossAsync(plossAnalysisService, file); + await HandlePlossAsync(plossAnalysisService, excelExporter, file, output); break; case "qfod": - await HandleQfodAsync(qfodCalibrationService, pure, foreign); + await HandleQfodAsync(qfodCalibrationService, excelExporter, pure, foreign, output); break; default: Console.WriteLine($"Unknown type: {type}. Use 'ploss' or 'qfod'."); break; } - }, typeOption, fileOption, pureOption, foreignOption); + }, typeOption, fileOption, pureOption, foreignOption, outputOption, autoOption); } - private static async Task HandlePlossAsync(PlossAnalysisService service, string? file) + /// + /// 自动扫描 Datas 文件夹并分析 + /// + 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(); + var foreignFiles = new List(); + + 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; + } + } + + /// + /// 从文件名提取异物类型(第7段) + /// 格式: 车厂-车型-TX面板-TX硬件-TX软件-RX类型-异物类型-测试目的-年月日测试次数 + /// + private static string GetForeignObjectType(string fileNameWithoutExt) + { + var parts = fileNameWithoutExt.Split('-'); + return parts.Length >= 7 ? parts[6] : ""; + } + + /// + /// 获取输出目录(exe 同目录下的 Output 文件夹) + /// + private static string GetOutputDirectory() + { + var outputDir = Path.Combine(AppContext.BaseDirectory, "Output"); + if (!Directory.Exists(outputDir)) + Directory.CreateDirectory(outputDir); + return outputDir; + } + + /// + /// 净化来自文件名的路径段:剔除路径分隔符、遍历段、非法字符,防止 Datas/ 下的文件名 + /// 经 Path.Combine 写到 Output/ 之外(路径遍历)。不可信输入必须经此处理。 + /// + 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; + } + + /// + /// 根据文件名解析出目录结构:Output/{车厂}/{车型}/{手机名}/ + /// 文件名格式: 车厂-车型-TX面板-TX硬件-TX软件-RX类型-异物类型-测试目的-年月日测试次数 + /// + 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)) { @@ -68,48 +287,27 @@ public class AnalyzeCommand : Command 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; } - 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 outputPath = GetOutputPath(file, output); - 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)"); - } - } + await exporter.ExportPlossReportAsync(excelReport, outputPath); + Console.WriteLine($"[OK] {outputPath}"); + Console.WriteLine($"Total: {excelReport.TotalCount}, Dropped: {excelReport.DroppedCount}, Errors: {excelReport.ErrorCount}"); } - 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)) { @@ -117,28 +315,17 @@ public class AnalyzeCommand : Command 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; } - Console.WriteLine("\n=== Qfod Calibration ==="); - PrintQfodFileSummary("Pure phone", report.Pure!); - PrintQfodFileSummary("Foreign object", report.Foreign!); + var outputPath = output ?? GetOutputPath(pure, output, "qfod"); - 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} (正为安全)"); + await exporter.ExportQfodReportAsync(excelReport, outputPath); + 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"); } diff --git a/src/Gpulse.WCT.DataAnalyzer/Gpulse.WCT.DataAnalyzer.csproj b/src/Gpulse.WCT.DataAnalyzer/Gpulse.WCT.DataAnalyzer.csproj index 891844e..cf61b92 100644 --- a/src/Gpulse.WCT.DataAnalyzer/Gpulse.WCT.DataAnalyzer.csproj +++ b/src/Gpulse.WCT.DataAnalyzer/Gpulse.WCT.DataAnalyzer.csproj @@ -43,4 +43,8 @@ + + + + diff --git a/src/Gpulse.WCT.DataAnalyzer/Program.cs b/src/Gpulse.WCT.DataAnalyzer/Program.cs index 39d37e3..9071025 100644 --- a/src/Gpulse.WCT.DataAnalyzer/Program.cs +++ b/src/Gpulse.WCT.DataAnalyzer/Program.cs @@ -1,6 +1,7 @@ using System.CommandLine; using System.Reflection; using System.Text.Json; +using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; 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.Application; using Gpulse.WCT.DataAnalyzer.Core.Application.Analysis; +using Gpulse.WCT.DataAnalyzer.Core.Application.Exporting; namespace Gpulse.WCT.DataAnalyzer; @@ -50,14 +52,14 @@ public class Program var app = builder.Build(); - // 初始化数据库 + // 初始化数据库(经 EF 迁移建表/升级,替代 EnsureCreated 以支持既有库升级) using (var scope = app.Services.CreateScope()) { var dbContext = scope.ServiceProvider.GetRequiredService(); - await dbContext.Database.EnsureCreatedAsync(); + await DatabaseMigrationInitializer.MigrateOrUpgradeAsync(dbContext); var releaseContext = scope.ServiceProvider.GetRequiredService(); - await releaseContext.Database.EnsureCreatedAsync(); + await DatabaseMigrationInitializer.MigrateOrUpgradeAsync(releaseContext); // 种子数据 SeedData.Initialize(dbContext); @@ -75,7 +77,8 @@ public class Program rootCommand.AddCommand(new CleanCommand(provider.GetRequiredService())); rootCommand.AddCommand(new AnalyzeCommand( provider.GetRequiredService(), - provider.GetRequiredService())); + provider.GetRequiredService(), + provider.GetRequiredService())); return await rootCommand.InvokeAsync(args); } diff --git a/src/Gpulse.WCT.DataAnalyzer/analyze.bat b/src/Gpulse.WCT.DataAnalyzer/analyze.bat new file mode 100644 index 0000000..8d09b78 --- /dev/null +++ b/src/Gpulse.WCT.DataAnalyzer/analyze.bat @@ -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