feat(cli): 增强Analyze命令支持自动扫描和Excel输出
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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>
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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,10 +80,13 @@ 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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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):");
|
private static async Task HandleQfodAsync(
|
||||||
foreach (var row in report.FodRows)
|
QfodCalibrationService service,
|
||||||
{
|
AnalyzeExcelExporter exporter,
|
||||||
var format = row.IsTwoLine ? "two-line" : "legacy";
|
string? pure,
|
||||||
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}");
|
string? foreign,
|
||||||
|
string? output)
|
||||||
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))
|
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");
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -43,4 +43,8 @@
|
|||||||
<EmbeddedResource Include="appsettings.json" />
|
<EmbeddedResource Include="appsettings.json" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<None Include="analyze.bat" CopyToOutputDirectory="PreserveNewest" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
</Project>
|
</Project>
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
38
src/Gpulse.WCT.DataAnalyzer/analyze.bat
Normal file
38
src/Gpulse.WCT.DataAnalyzer/analyze.bat
Normal 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
|
||||||
Reference in New Issue
Block a user