feat: 实现 FOD 日志数据采集 CLI 应用

This commit is contained in:
ssss
2026-07-02 18:13:56 +08:00
parent c4e5ac0360
commit 3dcd9f7c0b
52 changed files with 3144 additions and 354 deletions

View File

@@ -0,0 +1,65 @@
using System.CommandLine;
using WCTDataMiner.Services;
namespace WCTDataMiner.Commands;
/// <summary>
/// 数据清理命令
/// </summary>
public class CleanCommand : Command
{
public CleanCommand(CleanService cleanService)
: base("clean", "Clean data from database (soft delete)")
{
var scenarioOption = new Option<string?>(
"--scenario",
"Scenario ID to clean"
);
var allOption = new Option<bool>(
"--all",
() => false,
"Clean all data"
);
var confirmOption = new Option<bool>(
"--confirm",
() => false,
"Confirm the clean operation"
);
AddOption(scenarioOption);
AddOption(allOption);
AddOption(confirmOption);
this.SetHandler(async (scenario, all, confirm) =>
{
if (!confirm)
{
Console.WriteLine("Please add --confirm to confirm the clean operation.");
return;
}
if (!string.IsNullOrEmpty(scenario))
{
if (!Guid.TryParse(scenario, out var scenarioId))
{
Console.WriteLine($"Invalid scenario ID: {scenario}");
return;
}
var count = await cleanService.CleanByScenarioAsync(scenarioId);
Console.WriteLine($"Cleaned {count} records for scenario {scenarioId}");
}
else if (all)
{
var count = await cleanService.CleanAllAsync();
Console.WriteLine($"Cleaned {count} records in total");
}
else
{
Console.WriteLine("Please specify --scenario <id> or --all");
}
}, scenarioOption, allOption, confirmOption);
}
}

View File

@@ -0,0 +1,84 @@
using System.CommandLine;
using WCTDataMiner.Services;
namespace WCTDataMiner.Commands;
/// <summary>
/// 数据导出命令
/// </summary>
public class ExportCommand : Command
{
public ExportCommand(ExportService exportService)
: base("export", "Export data to CSV or JSON")
{
var formatOption = new Option<string>(
"--format",
() => "csv",
"Export format: csv or json"
);
var outputOption = new Option<string>(
"--output",
() => "./data/output",
"Output directory path"
);
var typeOption = new Option<string>(
"--type",
() => "all",
"Data type to export: qfod, ploss, or all"
);
AddOption(formatOption);
AddOption(outputOption);
AddOption(typeOption);
this.SetHandler(async (format, output, type) =>
{
format = format.ToLower();
type = type.ToLower();
Console.WriteLine($"Exporting {type} data as {format.ToUpper()} to {output}...");
try
{
if (format == "csv")
{
if (type is "qfod" or "all")
{
var path = await exportService.ExportQfodToCsvAsync(output);
Console.WriteLine($" Qfod CSV exported: {path}");
}
if (type is "ploss" or "all")
{
var path = await exportService.ExportPlossToCsvAsync(output);
Console.WriteLine($" Ploss CSV exported: {path}");
}
}
else if (format == "json")
{
if (type is "qfod" or "all")
{
var path = await exportService.ExportQfodToJsonAsync(output);
Console.WriteLine($" Qfod JSON exported: {path}");
}
if (type is "ploss" or "all")
{
var path = await exportService.ExportPlossToJsonAsync(output);
Console.WriteLine($" Ploss JSON exported: {path}");
}
}
else
{
Console.WriteLine($"Unknown format: {format}. Use 'csv' or 'json'.");
}
}
catch (Exception ex)
{
Console.WriteLine($"Export failed: {ex.Message}");
}
}, formatOption, outputOption, typeOption);
}
}

View File

@@ -0,0 +1,80 @@
using System.CommandLine;
using WCTDataMiner.Services;
namespace WCTDataMiner.Commands;
/// <summary>
/// 解析日志文件命令
/// </summary>
public class ParseCommand : Command
{
public ParseCommand(ParseService parseService)
: base("parse", "Parse log files and store data to database")
{
var fileOption = new Option<string>(
"--file",
"Path to a single log file to parse"
);
var dirOption = new Option<string>(
"--dir",
"Path to directory containing log files"
);
var recursiveOption = new Option<bool>(
"--recursive",
() => false,
"Recursively search subdirectories"
);
var forceOption = new Option<bool>(
"--force",
() => false,
"Force re-parse even if file already parsed"
);
AddOption(fileOption);
AddOption(dirOption);
AddOption(recursiveOption);
AddOption(forceOption);
this.SetHandler(async (file, dir, recursive, force) =>
{
if (!string.IsNullOrEmpty(file))
{
var report = await parseService.ParseFileAsync(file, force);
PrintReport(report);
}
else if (!string.IsNullOrEmpty(dir))
{
var reports = await parseService.ParseDirectoryAsync(dir, recursive, force);
Console.WriteLine($"\n=== Summary: {reports.Count} files processed ===");
foreach (var report in reports)
{
PrintReport(report);
}
var totalQfod = reports.Sum(r => r.QfodCount);
var totalPloss = reports.Sum(r => r.PlossCount);
var totalErrors = reports.Sum(r => r.ErrorCount);
Console.WriteLine($"\nTotal: Qfod={totalQfod}, Ploss={totalPloss}, Errors={totalErrors}");
}
else
{
Console.WriteLine("Please specify --file or --dir");
}
}, fileOption, dirOption, recursiveOption, forceOption);
}
private static void PrintReport(ParseReport report)
{
if (report.IsSuccess)
{
Console.WriteLine($" [{report.FileName}] Qfod: {report.QfodCount}, Ploss: {report.PlossCount}, Errors: {report.ErrorCount}");
}
else
{
Console.WriteLine($" [{report.FileName}] FAILED: {report.ErrorMessage}");
}
}
}

View File

@@ -0,0 +1,112 @@
using System.CommandLine;
using WCTDataMiner.Services;
namespace WCTDataMiner.Commands;
/// <summary>
/// 统计查询命令
/// </summary>
public class StatsCommand : Command
{
public StatsCommand(StatsService statsService)
: base("stats", "Query statistics from database")
{
var summaryOption = new Option<bool>(
"--summary",
() => false,
"Show overall statistics summary"
);
var byFodTypeOption = new Option<bool>(
"--by-fod-type",
() => false,
"Show statistics by FOD type"
);
var byPanelOption = new Option<bool>(
"--by-panel",
() => false,
"Show statistics by TX panel"
);
var byHardwareOption = new Option<bool>(
"--by-hardware",
() => false,
"Show statistics by TX hardware version"
);
var byRxTypeOption = new Option<bool>(
"--by-rx-type",
() => false,
"Show statistics by RX type"
);
AddOption(summaryOption);
AddOption(byFodTypeOption);
AddOption(byPanelOption);
AddOption(byHardwareOption);
AddOption(byRxTypeOption);
this.SetHandler(async (summary, byFodType, byPanel, byHardware, byRxType) =>
{
// 如果没有指定任何选项,默认显示摘要
if (!summary && !byFodType && !byPanel && !byHardware && !byRxType)
{
summary = true;
}
if (summary)
{
var s = await statsService.GetSummaryAsync();
Console.WriteLine("=== Statistics Summary ===");
Console.WriteLine($" Scenarios: {s.ScenarioCount}");
Console.WriteLine($" Qfod Records: {s.QfodCount}");
Console.WriteLine($" Ploss Records: {s.PlossCount}");
Console.WriteLine($" TX Panels: {s.PanelCount}");
Console.WriteLine($" TX Hardware: {s.HardwareCount}");
Console.WriteLine($" TX Software: {s.SoftwareCount}");
Console.WriteLine($" RX Types: {s.RxTypeCount}");
}
if (byFodType)
{
Console.WriteLine("\n=== By FOD Type ===");
var stats = await statsService.GetStatsByFodTypeAsync();
foreach (var s in stats)
{
Console.WriteLine($" FOD Type {s.FodType}: {s.Count}");
}
}
if (byPanel)
{
Console.WriteLine("\n=== By TX Panel ===");
var stats = await statsService.GetStatsByPanelAsync();
foreach (var s in stats)
{
Console.WriteLine($" {s.Name}: {s.Count}");
}
}
if (byHardware)
{
Console.WriteLine("\n=== By TX Hardware ===");
var stats = await statsService.GetStatsByHardwareAsync();
foreach (var s in stats)
{
Console.WriteLine($" {s.Name}: {s.Count}");
}
}
if (byRxType)
{
Console.WriteLine("\n=== By RX Type ===");
var stats = await statsService.GetStatsByRxTypeAsync();
foreach (var s in stats)
{
Console.WriteLine($" {s.Name}: {s.Count}");
}
}
}, summaryOption, byFodTypeOption, byPanelOption, byHardwareOption, byRxTypeOption);
}
}

View File

@@ -0,0 +1,44 @@
namespace WCTDataMiner.Configuration;
/// <summary>
/// 应用配置
/// </summary>
public class AppSettings
{
public DatabaseSettings Database { get; set; } = new();
public ParserSettings Parser { get; set; } = new();
public PathSettings Paths { get; set; } = new();
}
/// <summary>
/// 数据库配置
/// </summary>
public class DatabaseSettings
{
public string Type { get; set; } = "sqlite";
public string Path { get; set; } = "./data/database/wctminer.db";
// PostgreSQL 配置(可选)
public string? Host { get; set; }
public int Port { get; set; } = 5432;
public string? Name { get; set; }
public string? User { get; set; }
public string? Password { get; set; }
}
/// <summary>
/// 解析器配置
/// </summary>
public class ParserSettings
{
public int BatchSize { get; set; } = 1000;
}
/// <summary>
/// 路径配置
/// </summary>
public class PathSettings
{
public string LogDir { get; set; } = "./data/logs";
public string OutputDir { get; set; } = "./data/output";
}

View File

@@ -0,0 +1,103 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
using WCTDataMiner.Models;
namespace WCTDataMiner.Data.Configurations;
public class PlossRecordConfiguration : IEntityTypeConfiguration<PlossRecord>
{
public void Configure(EntityTypeBuilder<PlossRecord> builder)
{
builder.ToTable("ploss_record");
builder.HasKey(e => e.Id);
builder.Property(e => e.Id).HasColumnName("id");
builder.Property(e => e.ScenarioId)
.IsRequired()
.HasColumnName("scenario_id");
builder.Property(e => e.RxType)
.IsRequired()
.HasColumnName("rx_type");
builder.Property(e => e.RxPower)
.IsRequired()
.HasColumnName("rx_power");
builder.Property(e => e.TxPower)
.IsRequired()
.HasColumnName("tx_power");
builder.Property(e => e.Vcoil)
.IsRequired()
.HasColumnName("vcoil");
builder.Property(e => e.Vin)
.IsRequired()
.HasColumnName("vin");
builder.Property(e => e.Isns)
.IsRequired()
.HasColumnName("isns");
builder.Property(e => e.Ploss)
.IsRequired()
.HasColumnName("ploss");
builder.Property(e => e.Threshold)
.IsRequired()
.HasColumnName("threshold");
builder.Property(e => e.TriggerCount)
.IsRequired()
.HasDefaultValue(0)
.HasColumnName("trigger_count");
builder.Property(e => e.FodResult)
.IsRequired()
.HasDefaultValue(0)
.HasColumnName("fod_result");
builder.Property(e => e.ProtocolType)
.IsRequired()
.HasColumnName("protocol_type");
builder.Property(e => e.PwmDuty)
.IsRequired()
.HasColumnName("pwm_duty");
builder.Property(e => e.CoilIndex)
.IsRequired()
.HasColumnName("coil_index");
builder.Property(e => e.IsDeleted)
.IsRequired()
.HasDefaultValue(false)
.HasColumnName("is_deleted");
builder.Property(e => e.CreatedAt)
.IsRequired()
.HasDefaultValueSql("datetime('now')")
.HasColumnName("created_at");
// 派生属性不落库
builder.Ignore(e => e.Margin);
// 索引
builder.HasIndex(e => e.ScenarioId).HasDatabaseName("idx_ploss_scenario");
builder.HasIndex(e => e.RxPower).HasDatabaseName("idx_ploss_rx_power");
builder.HasIndex(e => e.Ploss).HasDatabaseName("idx_ploss_ploss");
builder.HasIndex(e => e.Threshold).HasDatabaseName("idx_ploss_threshold");
builder.HasIndex(e => e.FodResult).HasDatabaseName("idx_ploss_fod_result");
// 关系配置
builder.HasOne(e => e.Scenario)
.WithMany(e => e.PlossRecords)
.HasForeignKey(e => e.ScenarioId)
.OnDelete(DeleteBehavior.Cascade);
// 全局查询过滤器
builder.HasQueryFilter(e => !e.IsDeleted);
}
}

View File

@@ -0,0 +1,73 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
using WCTDataMiner.Models;
namespace WCTDataMiner.Data.Configurations;
public class QfodRecordConfiguration : IEntityTypeConfiguration<QfodRecord>
{
public void Configure(EntityTypeBuilder<QfodRecord> builder)
{
builder.ToTable("qfod_record", t =>
{
t.HasCheckConstraint("chk_qfod_charger", "charger_index IN (0, 1)");
t.HasCheckConstraint("chk_qfod_coil", "coil_index IN (0, 1, 2)");
});
builder.HasKey(e => e.Id);
builder.Property(e => e.Id).HasColumnName("id");
builder.Property(e => e.ScenarioId)
.IsRequired()
.HasColumnName("scenario_id");
builder.Property(e => e.ChargerIndex)
.IsRequired()
.HasColumnName("charger_index");
builder.Property(e => e.CoilIndex)
.IsRequired()
.HasColumnName("coil_index");
builder.Property(e => e.DeltaQ)
.IsRequired()
.HasColumnName("delta_q");
builder.Property(e => e.CurrentQ)
.IsRequired()
.HasColumnName("current_q");
builder.Property(e => e.RawQ)
.IsRequired()
.HasColumnName("raw_q");
builder.Property(e => e.FodType)
.IsRequired()
.HasColumnName("fod_type");
builder.Property(e => e.IsDeleted)
.IsRequired()
.HasDefaultValue(false)
.HasColumnName("is_deleted");
builder.Property(e => e.CreatedAt)
.IsRequired()
.HasDefaultValueSql("datetime('now')")
.HasColumnName("created_at");
// 索引
builder.HasIndex(e => e.ScenarioId).HasDatabaseName("idx_qfod_scenario");
builder.HasIndex(e => new { e.ChargerIndex, e.CoilIndex }).HasDatabaseName("idx_qfod_charger_coil");
builder.HasIndex(e => e.FodType).HasDatabaseName("idx_qfod_fod_type");
builder.HasIndex(e => e.DeltaQ).HasDatabaseName("idx_qfod_delta_q");
// 关系配置
builder.HasOne(e => e.Scenario)
.WithMany(e => e.QfodRecords)
.HasForeignKey(e => e.ScenarioId)
.OnDelete(DeleteBehavior.Cascade);
// 全局查询过滤器
builder.HasQueryFilter(e => !e.IsDeleted);
}
}

View File

@@ -0,0 +1,36 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
using WCTDataMiner.Models;
namespace WCTDataMiner.Data.Configurations;
public class RxTypeConfiguration : IEntityTypeConfiguration<RxType>
{
public void Configure(EntityTypeBuilder<RxType> builder)
{
builder.ToTable("rx_type");
builder.HasKey(e => e.Id);
builder.Property(e => e.Id).HasColumnName("id");
builder.Property(e => e.Name)
.IsRequired()
.HasMaxLength(100)
.HasColumnName("name");
builder.Property(e => e.IsDeleted)
.IsRequired()
.HasDefaultValue(false)
.HasColumnName("is_deleted");
builder.Property(e => e.CreatedAt)
.IsRequired()
.HasDefaultValueSql("datetime('now')")
.HasColumnName("created_at");
builder.HasIndex(e => e.Name).IsUnique();
// 全局查询过滤器
builder.HasQueryFilter(e => !e.IsDeleted);
}
}

View File

@@ -0,0 +1,112 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
using WCTDataMiner.Models;
namespace WCTDataMiner.Data.Configurations;
public class TestScenarioConfiguration : IEntityTypeConfiguration<TestScenario>
{
public void Configure(EntityTypeBuilder<TestScenario> builder)
{
builder.ToTable("test_scenario");
builder.HasKey(e => e.Id);
builder.Property(e => e.Id).HasColumnName("id");
builder.Property(e => e.TxPanelId)
.IsRequired()
.HasColumnName("tx_panel_id");
builder.Property(e => e.TxHardwareId)
.IsRequired()
.HasColumnName("tx_hardware_id");
builder.Property(e => e.TxSoftwareId)
.IsRequired()
.HasColumnName("tx_software_id");
builder.Property(e => e.RxTypeId)
.IsRequired()
.HasColumnName("rx_type_id");
builder.Property(e => e.TestPurpose)
.HasMaxLength(200)
.HasColumnName("test_purpose");
builder.Property(e => e.TestDate)
.IsRequired()
.HasColumnName("test_date");
builder.Property(e => e.TestSequence)
.IsRequired()
.HasDefaultValue(1)
.HasColumnName("test_sequence");
builder.Property(e => e.QfodCount)
.IsRequired()
.HasDefaultValue(0)
.HasColumnName("qfod_count");
builder.Property(e => e.PlossCount)
.IsRequired()
.HasDefaultValue(0)
.HasColumnName("ploss_count");
builder.Property(e => e.IsDeleted)
.IsRequired()
.HasDefaultValue(false)
.HasColumnName("is_deleted");
builder.Property(e => e.CreatedAt)
.IsRequired()
.HasDefaultValueSql("datetime('now')")
.HasColumnName("created_at");
// 索引
builder.HasIndex(e => e.TxPanelId).HasDatabaseName("idx_scenario_tx_panel");
builder.HasIndex(e => e.TxHardwareId).HasDatabaseName("idx_scenario_tx_hardware");
builder.HasIndex(e => e.TxSoftwareId).HasDatabaseName("idx_scenario_tx_software");
builder.HasIndex(e => e.RxTypeId).HasDatabaseName("idx_scenario_rx_type");
builder.HasIndex(e => e.TestDate).HasDatabaseName("idx_scenario_test_date");
// 唯一约束
builder.HasIndex(e => new { e.TxPanelId, e.TxHardwareId, e.TxSoftwareId, e.RxTypeId, e.TestPurpose, e.TestDate, e.TestSequence })
.IsUnique()
.HasDatabaseName("uq_scenario");
// 关系配置 - 维度表
builder.HasOne(e => e.TxPanel)
.WithMany(e => e.TestScenarios)
.HasForeignKey(e => e.TxPanelId)
.OnDelete(DeleteBehavior.Restrict);
builder.HasOne(e => e.TxHardware)
.WithMany(e => e.TestScenarios)
.HasForeignKey(e => e.TxHardwareId)
.OnDelete(DeleteBehavior.Restrict);
builder.HasOne(e => e.TxSoftware)
.WithMany(e => e.TestScenarios)
.HasForeignKey(e => e.TxSoftwareId)
.OnDelete(DeleteBehavior.Restrict);
builder.HasOne(e => e.RxType)
.WithMany(e => e.TestScenarios)
.HasForeignKey(e => e.RxTypeId)
.OnDelete(DeleteBehavior.Restrict);
// 关系配置 - 数据记录
builder.HasMany(e => e.QfodRecords)
.WithOne(e => e.Scenario)
.HasForeignKey(e => e.ScenarioId)
.OnDelete(DeleteBehavior.Cascade);
builder.HasMany(e => e.PlossRecords)
.WithOne(e => e.Scenario)
.HasForeignKey(e => e.ScenarioId)
.OnDelete(DeleteBehavior.Cascade);
// 全局查询过滤器
builder.HasQueryFilter(e => !e.IsDeleted);
}
}

View File

@@ -0,0 +1,36 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
using WCTDataMiner.Models;
namespace WCTDataMiner.Data.Configurations;
public class TxHardwareConfiguration : IEntityTypeConfiguration<TxHardware>
{
public void Configure(EntityTypeBuilder<TxHardware> builder)
{
builder.ToTable("tx_hardware");
builder.HasKey(e => e.Id);
builder.Property(e => e.Id).HasColumnName("id");
builder.Property(e => e.Version)
.IsRequired()
.HasMaxLength(50)
.HasColumnName("version");
builder.Property(e => e.IsDeleted)
.IsRequired()
.HasDefaultValue(false)
.HasColumnName("is_deleted");
builder.Property(e => e.CreatedAt)
.IsRequired()
.HasDefaultValueSql("datetime('now')")
.HasColumnName("created_at");
builder.HasIndex(e => e.Version).IsUnique();
// 全局查询过滤器
builder.HasQueryFilter(e => !e.IsDeleted);
}
}

View File

@@ -0,0 +1,36 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
using WCTDataMiner.Models;
namespace WCTDataMiner.Data.Configurations;
public class TxPanelConfiguration : IEntityTypeConfiguration<TxPanel>
{
public void Configure(EntityTypeBuilder<TxPanel> builder)
{
builder.ToTable("tx_panel");
builder.HasKey(e => e.Id);
builder.Property(e => e.Id).HasColumnName("id");
builder.Property(e => e.Name)
.IsRequired()
.HasMaxLength(50)
.HasColumnName("name");
builder.Property(e => e.IsDeleted)
.IsRequired()
.HasDefaultValue(false)
.HasColumnName("is_deleted");
builder.Property(e => e.CreatedAt)
.IsRequired()
.HasDefaultValueSql("datetime('now')")
.HasColumnName("created_at");
builder.HasIndex(e => e.Name).IsUnique();
// 全局查询过滤器
builder.HasQueryFilter(e => !e.IsDeleted);
}
}

View File

@@ -0,0 +1,36 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
using WCTDataMiner.Models;
namespace WCTDataMiner.Data.Configurations;
public class TxSoftwareConfiguration : IEntityTypeConfiguration<TxSoftware>
{
public void Configure(EntityTypeBuilder<TxSoftware> builder)
{
builder.ToTable("tx_software");
builder.HasKey(e => e.Id);
builder.Property(e => e.Id).HasColumnName("id");
builder.Property(e => e.Version)
.IsRequired()
.HasMaxLength(50)
.HasColumnName("version");
builder.Property(e => e.IsDeleted)
.IsRequired()
.HasDefaultValue(false)
.HasColumnName("is_deleted");
builder.Property(e => e.CreatedAt)
.IsRequired()
.HasDefaultValueSql("datetime('now')")
.HasColumnName("created_at");
builder.HasIndex(e => e.Version).IsUnique();
// 全局查询过滤器
builder.HasQueryFilter(e => !e.IsDeleted);
}
}

View File

@@ -0,0 +1,66 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Configuration;
namespace WCTDataMiner.Data;
/// <summary>
/// 数据库上下文工厂实现 - 支持SQLite和PostgreSQL切换
/// </summary>
public class DbContextFactory : IDbContextFactory
{
private readonly IConfiguration _configuration;
public DbContextFactory(IConfiguration configuration)
{
_configuration = configuration;
}
public WctMinerDbContext CreateDbContext()
{
var dbType = _configuration["Database:Type"]?.ToLower() ?? "sqlite";
var optionsBuilder = new DbContextOptionsBuilder<WctMinerDbContext>();
switch (dbType)
{
case "postgresql":
case "postgres":
var pgConnStr = BuildPostgreSqlConnectionString();
optionsBuilder.UseNpgsql(pgConnStr);
break;
case "sqlite":
default:
var dbPath = _configuration["Database:Path"] ?? "./data/database/wctminer.db";
EnsureDirectoryExists(dbPath);
optionsBuilder.UseSqlite($"Data Source={dbPath}");
break;
}
#if DEBUG
optionsBuilder.EnableSensitiveDataLogging();
optionsBuilder.EnableDetailedErrors();
#endif
return new WctMinerDbContext(optionsBuilder.Options);
}
private string BuildPostgreSqlConnectionString()
{
var host = _configuration["Database:Host"] ?? "localhost";
var port = _configuration.GetValue("Database:Port", 5432);
var name = _configuration["Database:Name"] ?? "wctminer";
var user = _configuration["Database:User"] ?? "postgres";
var password = _configuration["Database:Password"] ?? "";
return $"Host={host};Port={port};Database={name};Username={user};Password={password}";
}
private static void EnsureDirectoryExists(string dbPath)
{
var directory = Path.GetDirectoryName(dbPath);
if (!string.IsNullOrEmpty(directory) && !Directory.Exists(directory))
{
Directory.CreateDirectory(directory);
}
}
}

View File

@@ -0,0 +1,9 @@
namespace WCTDataMiner.Data;
/// <summary>
/// 数据库上下文工厂接口
/// </summary>
public interface IDbContextFactory
{
WctMinerDbContext CreateDbContext();
}

View File

@@ -0,0 +1,36 @@
using WCTDataMiner.Models;
namespace WCTDataMiner.Data;
/// <summary>
/// 初始数据种子
/// </summary>
public static class SeedData
{
/// <summary>
/// TX面板类型预设数据
/// </summary>
public static readonly TxPanel[] DefaultTxPanels =
[
new() { Name = "none" },
new() { Name = "single-mold" },
new() { Name = "single-rapid" },
new() { Name = "single-3d" },
new() { Name = "dual-mold" },
new() { Name = "dual-rapid" },
new() { Name = "dual-3d" }
];
/// <summary>
/// 初始化种子数据
/// </summary>
public static void Initialize(WctMinerDbContext context)
{
// 确保 TxPanel 表有预设数据
if (!context.TxPanels.Any())
{
context.TxPanels.AddRange(DefaultTxPanels);
context.SaveChanges();
}
}
}

View File

@@ -0,0 +1,32 @@
using Microsoft.EntityFrameworkCore;
using WCTDataMiner.Models;
namespace WCTDataMiner.Data;
/// <summary>
/// WCT数据采集数据库上下文
/// </summary>
public class WctMinerDbContext : DbContext
{
public WctMinerDbContext(DbContextOptions<WctMinerDbContext> options)
: base(options) { }
// 维度表
public DbSet<TxPanel> TxPanels => Set<TxPanel>();
public DbSet<TxHardware> TxHardwares => Set<TxHardware>();
public DbSet<TxSoftware> TxSoftwares => Set<TxSoftware>();
public DbSet<RxType> RxTypes => Set<RxType>();
// 事实表
public DbSet<TestScenario> TestScenarios => Set<TestScenario>();
public DbSet<QfodRecord> QfodRecords => Set<QfodRecord>();
public DbSet<PlossRecord> PlossRecords => Set<PlossRecord>();
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
base.OnModelCreating(modelBuilder);
// 自动应用所有 IEntityTypeConfiguration<T> 配置
modelBuilder.ApplyConfigurationsFromAssembly(typeof(WctMinerDbContext).Assembly);
}
}

View File

@@ -0,0 +1,54 @@
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using WCTDataMiner.Data;
using WCTDataMiner.Parsers;
using WCTDataMiner.Services;
namespace WCTDataMiner.Extensions;
/// <summary>
/// 服务注册扩展
/// </summary>
public static class ServiceCollectionExtensions
{
/// <summary>
/// 注册数据库服务
/// </summary>
public static IServiceCollection AddDatabaseServices(
this IServiceCollection services,
IConfiguration configuration)
{
// 注册 DbContext 工厂
services.AddScoped<IDbContextFactory, DbContextFactory>();
// 注册 DbContext通过工厂创建
services.AddScoped<WctMinerDbContext>(sp =>
{
var factory = sp.GetRequiredService<IDbContextFactory>();
return factory.CreateDbContext();
});
return services;
}
/// <summary>
/// 注册应用服务
/// </summary>
public static IServiceCollection AddApplicationServices(this IServiceCollection services)
{
// Parsers
services.AddScoped<QfodParser>();
services.AddScoped<PlossParser>();
services.AddSingleton<FileNameParser>();
// Services
services.AddScoped<DimensionService>();
services.AddScoped<ScenarioService>();
services.AddScoped<ParseService>();
services.AddScoped<StatsService>();
services.AddScoped<ExportService>();
services.AddScoped<CleanService>();
return services;
}
}

View File

@@ -0,0 +1,36 @@
namespace WCTDataMiner.Models;
/// <summary>
/// Ploss记录实体 - 存储Ploss FOD格式解析数据
/// </summary>
public class PlossRecord
{
public Guid Id { get; set; } = Guid.NewGuid();
public Guid ScenarioId { get; set; }
public byte RxType { get; set; }
public int RxPower { get; set; } // uint16 范围 0-65535使用 int 存储
public int TxPower { get; set; } // uint16 范围 0-65535使用 int 存储
public int Vcoil { get; set; } // uint16 范围 0-65535使用 int 存储
public int Vin { get; set; } // uint16 范围 0-65535使用 int 存储
public int Isns { get; set; } // uint16 范围 0-65535使用 int 存储
/// <summary>核心判定值:负数表示安全</summary>
public int Ploss { get; set; }
/// <summary>安全红线:当前功率段阈值</summary>
public int Threshold { get; set; }
public int TriggerCount { get; set; } = 0;
public int FodResult { get; set; } = 0;
public byte ProtocolType { get; set; }
public int PwmDuty { get; set; } // uint16 范围 0-65535使用 int 存储
public byte CoilIndex { get; set; }
public bool IsDeleted { get; set; } = false;
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
// Navigation Properties
public TestScenario Scenario { get; set; } = null!;
/// <summary>派生属性:安全余量(不落库)</summary>
public int Margin => Threshold - Ploss;
}

View File

@@ -0,0 +1,21 @@
namespace WCTDataMiner.Models;
/// <summary>
/// Qfod记录实体 - 存储Qfod格式解析数据
/// </summary>
public class QfodRecord
{
public Guid Id { get; set; } = Guid.NewGuid();
public Guid ScenarioId { get; set; }
public byte ChargerIndex { get; set; }
public byte CoilIndex { get; set; }
public int DeltaQ { get; set; }
public float CurrentQ { get; set; }
public float RawQ { get; set; }
public byte FodType { get; set; }
public bool IsDeleted { get; set; } = false;
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
// Navigation Properties
public TestScenario Scenario { get; set; } = null!;
}

View File

@@ -0,0 +1,15 @@
namespace WCTDataMiner.Models;
/// <summary>
/// RX类型维度表
/// </summary>
public class RxType
{
public Guid Id { get; set; } = Guid.NewGuid();
public string Name { get; set; } = null!;
public bool IsDeleted { get; set; } = false;
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
// Navigation Properties
public ICollection<TestScenario> TestScenarios { get; set; } = [];
}

View File

@@ -0,0 +1,30 @@
namespace WCTDataMiner.Models;
/// <summary>
/// 测试场景实体 - 通过外键关联各维度表
/// </summary>
public class TestScenario
{
public Guid Id { get; set; } = Guid.NewGuid();
public Guid TxPanelId { get; set; }
public Guid TxHardwareId { get; set; }
public Guid TxSoftwareId { get; set; }
public Guid RxTypeId { get; set; }
public string? TestPurpose { get; set; }
public DateOnly TestDate { get; set; }
public int TestSequence { get; set; } = 1;
public int QfodCount { get; set; } = 0;
public int PlossCount { get; set; } = 0;
public bool IsDeleted { get; set; } = false;
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
// Navigation Properties - 维度表
public TxPanel TxPanel { get; set; } = null!;
public TxHardware TxHardware { get; set; } = null!;
public TxSoftware TxSoftware { get; set; } = null!;
public RxType RxType { get; set; } = null!;
// Navigation Properties - 数据记录
public ICollection<QfodRecord> QfodRecords { get; set; } = [];
public ICollection<PlossRecord> PlossRecords { get; set; } = [];
}

View File

@@ -0,0 +1,15 @@
namespace WCTDataMiner.Models;
/// <summary>
/// TX硬件版本维度表
/// </summary>
public class TxHardware
{
public Guid Id { get; set; } = Guid.NewGuid();
public string Version { get; set; } = null!;
public bool IsDeleted { get; set; } = false;
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
// Navigation Properties
public ICollection<TestScenario> TestScenarios { get; set; } = [];
}

View File

@@ -0,0 +1,15 @@
namespace WCTDataMiner.Models;
/// <summary>
/// TX面板类型维度表
/// </summary>
public class TxPanel
{
public Guid Id { get; set; } = Guid.NewGuid();
public string Name { get; set; } = null!;
public bool IsDeleted { get; set; } = false;
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
// Navigation Properties
public ICollection<TestScenario> TestScenarios { get; set; } = [];
}

View File

@@ -0,0 +1,15 @@
namespace WCTDataMiner.Models;
/// <summary>
/// TX软件版本维度表
/// </summary>
public class TxSoftware
{
public Guid Id { get; set; } = Guid.NewGuid();
public string Version { get; set; } = null!;
public bool IsDeleted { get; set; } = false;
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
// Navigation Properties
public ICollection<TestScenario> TestScenarios { get; set; } = [];
}

View File

@@ -0,0 +1,68 @@
using System.Globalization;
namespace WCTDataMiner.Parsers;
/// <summary>
/// 文件名解析器 - 从文件名提取场景信息
/// 格式: TX面板-TX硬件-TX软件-RX类型-测试目的-年月日-测试次数.log
/// 示例: singleMold-v1.0-hex2_1-iPhone15-compatibility-20260702-1.log
/// </summary>
public class FileNameParser
{
private static readonly char[] Separators = { '-', '_' };
/// <summary>
/// 解析日志文件名,提取场景信息
/// </summary>
/// <param name="fileName">文件名(不含路径)</param>
/// <returns>场景信息,若格式不匹配返回 null</returns>
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)
return null;
// 解析日期
if (!TryParseDate(parts[5], out var testDate))
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,
TestDate: testDate,
TestSequence: testSequence
);
}
private static bool TryParseDate(string dateStr, out DateOnly date)
{
// 支持多种日期格式
var formats = new[] { "yyyyMMdd", "yyyy-MM-dd", "yyMMdd" };
return DateOnly.TryParseExact(dateStr, formats, CultureInfo.InvariantCulture, DateTimeStyles.None, out date);
}
}
/// <summary>
/// 场景信息 - 从文件名解析出的测试场景数据
/// </summary>
public record ScenarioInfo(
string TxPanel,
string TxHardware,
string TxSoftware,
string RxType,
string? TestPurpose,
DateOnly TestDate,
int TestSequence
);

View File

@@ -0,0 +1,36 @@
namespace WCTDataMiner.Parsers;
/// <summary>
/// 通用解析器接口
/// </summary>
public interface IParser<TRecord>
{
/// <summary>
/// 判断是否可以解析该行
/// </summary>
bool CanParse(string line);
/// <summary>
/// 解析日志行
/// </summary>
/// <param name="line">日志行内容</param>
/// <param name="scenarioId">关联的测试场景ID</param>
ParseResult<TRecord> Parse(string line, Guid scenarioId);
}
/// <summary>
/// 解析结果
/// </summary>
public record ParseResult<TRecord>
{
public bool IsSuccess { get; init; }
public TRecord? Record { get; init; }
public string? ErrorType { get; init; }
public string? ErrorMessage { get; init; }
public static ParseResult<TRecord> Success(TRecord record) =>
new() { IsSuccess = true, Record = record };
public static ParseResult<TRecord> Failure(string errorType, string errorMessage) =>
new() { IsSuccess = false, ErrorType = errorType, ErrorMessage = errorMessage };
}

View File

@@ -0,0 +1,62 @@
using System.Text.RegularExpressions;
using WCTDataMiner.Models;
namespace WCTDataMiner.Parsers;
/// <summary>
/// Ploss FOD日志解析器
/// 格式: FOD-> (1) X X X (2) (3) (4) (5) (6) (7) (8) (9) (10) (11) (12) (13)
/// 示例: FOD-> 4 2 1 127 10690 12013 35377 9904 1213 -2882 750 0 0 1 500 1
/// </summary>
public class PlossParser : IParser<PlossRecord>
{
// 格式说明有3个X忽略字段正则需要3个 \d+ 来匹配
private static readonly Regex PlossPattern = new(
@"^FOD->\s+(\d+)\s+\d+\s+\d+\s+\d+\s+(\d+)\s+(\d+)\s+(\d+)\s+(\d+)\s+(\d+)\s+(-?\d+)\s+(\d+)\s+(\d+)\s+(\d+)\s+(\d+)\s+(\d+)\s+(\d+)$",
RegexOptions.Compiled
);
public bool CanParse(string line) => PlossPattern.IsMatch(line);
public ParseResult<PlossRecord> Parse(string line, Guid scenarioId)
{
var match = PlossPattern.Match(line);
if (!match.Success)
{
return ParseResult<PlossRecord>.Failure(
"FORMAT_MISMATCH",
"Line does not match Ploss pattern"
);
}
try
{
var record = new PlossRecord
{
ScenarioId = scenarioId,
RxType = byte.Parse(match.Groups[1].Value),
RxPower = int.Parse(match.Groups[2].Value),
TxPower = int.Parse(match.Groups[3].Value),
Vcoil = int.Parse(match.Groups[4].Value),
Vin = int.Parse(match.Groups[5].Value),
Isns = int.Parse(match.Groups[6].Value),
Ploss = int.Parse(match.Groups[7].Value),
Threshold = int.Parse(match.Groups[8].Value),
TriggerCount = int.Parse(match.Groups[9].Value),
FodResult = int.Parse(match.Groups[10].Value),
ProtocolType = byte.Parse(match.Groups[11].Value),
PwmDuty = int.Parse(match.Groups[12].Value),
CoilIndex = byte.Parse(match.Groups[13].Value)
};
return ParseResult<PlossRecord>.Success(record);
}
catch (Exception ex)
{
return ParseResult<PlossRecord>.Failure(
"TYPE_CONVERSION",
$"Failed to convert field: {ex.Message}"
);
}
}
}

View File

@@ -0,0 +1,54 @@
using System.Text.RegularExpressions;
using WCTDataMiner.Models;
namespace WCTDataMiner.Parsers;
/// <summary>
/// Qfod日志解析器
/// 格式: X#:Q A->B C D E
/// 示例: 0#:Q 1->25 45.5 20.5 2
/// </summary>
public class QfodParser : IParser<QfodRecord>
{
private static readonly Regex QfodPattern = new(
@"^(\d)#:Q\s+(\d+)->(-?\d+)\s+([-\d.]+)\s+([-\d.]+)\s+(\d+)$",
RegexOptions.Compiled
);
public bool CanParse(string line) => QfodPattern.IsMatch(line);
public ParseResult<QfodRecord> Parse(string line, Guid scenarioId)
{
var match = QfodPattern.Match(line);
if (!match.Success)
{
return ParseResult<QfodRecord>.Failure(
"FORMAT_MISMATCH",
"Line does not match Qfod pattern"
);
}
try
{
var record = new QfodRecord
{
ScenarioId = scenarioId,
ChargerIndex = byte.Parse(match.Groups[1].Value),
CoilIndex = byte.Parse(match.Groups[2].Value),
DeltaQ = int.Parse(match.Groups[3].Value),
CurrentQ = float.Parse(match.Groups[4].Value),
RawQ = float.Parse(match.Groups[5].Value),
FodType = byte.Parse(match.Groups[6].Value)
};
return ParseResult<QfodRecord>.Success(record);
}
catch (Exception ex)
{
return ParseResult<QfodRecord>.Failure(
"TYPE_CONVERSION",
$"Failed to convert field: {ex.Message}"
);
}
}
}

View File

@@ -0,0 +1,83 @@
using System.CommandLine;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Serilog;
using WCTDataMiner.Commands;
using WCTDataMiner.Data;
using WCTDataMiner.Extensions;
using WCTDataMiner.Services;
namespace WCTDataMiner;
/// <summary>
/// WCTDataMiner - 无线充电FOD日志数据采集系统
/// </summary>
public class Program
{
public static async Task<int> Main(string[] args)
{
// 配置 Serilog
Log.Logger = new LoggerConfiguration()
.MinimumLevel.Information()
.MinimumLevel.Override("WCTDataMiner", Serilog.Events.LogEventLevel.Debug)
.WriteTo.Console()
.WriteTo.File(
"./data/logs/wctminer-.log",
rollingInterval: RollingInterval.Day,
outputTemplate: "{Timestamp:yyyy-MM-dd HH:mm:ss.fff zzz} [{Level:u3}] {Message:lj}{NewLine}{Exception}")
.CreateLogger();
try
{
var builder = Host.CreateApplicationBuilder(args);
// 配置
builder.Configuration.AddJsonFile("appsettings.json", optional: false);
builder.Configuration.AddJsonFile($"appsettings.{builder.Environment.EnvironmentName}.json", optional: true);
builder.Configuration.AddEnvironmentVariables();
builder.Configuration.AddCommandLine(args);
// Serilog
builder.Services.AddSerilog();
// 注册服务
builder.Services.AddDatabaseServices(builder.Configuration);
builder.Services.AddApplicationServices();
var app = builder.Build();
// 初始化数据库
using (var scope = app.Services.CreateScope())
{
var dbContext = scope.ServiceProvider.GetRequiredService<WctMinerDbContext>();
await dbContext.Database.EnsureCreatedAsync();
// 种子数据
SeedData.Initialize(dbContext);
}
// 创建 CLI 命令
using var commandScope = app.Services.CreateScope();
var provider = commandScope.ServiceProvider;
var rootCommand = new RootCommand("WCTDataMiner - FOD Log Data Parser");
rootCommand.AddCommand(new ParseCommand(provider.GetRequiredService<ParseService>()));
rootCommand.AddCommand(new StatsCommand(provider.GetRequiredService<StatsService>()));
rootCommand.AddCommand(new ExportCommand(provider.GetRequiredService<ExportService>()));
rootCommand.AddCommand(new CleanCommand(provider.GetRequiredService<CleanService>()));
return await rootCommand.InvokeAsync(args);
}
catch (Exception ex)
{
Log.Fatal(ex, "Application terminated unexpectedly");
return 1;
}
finally
{
await Log.CloseAndFlushAsync();
}
}
}

View File

@@ -0,0 +1,87 @@
using Microsoft.EntityFrameworkCore;
using WCTDataMiner.Data;
using WCTDataMiner.Models;
namespace WCTDataMiner.Services;
/// <summary>
/// 数据清理服务 - 支持软删除
/// </summary>
public class CleanService
{
private readonly WctMinerDbContext _context;
public CleanService(WctMinerDbContext context)
{
_context = context;
}
/// <summary>
/// 软删除指定场景及其关联记录
/// </summary>
public async Task<int> CleanByScenarioAsync(Guid scenarioId)
{
var scenario = await _context.TestScenarios.FindAsync(scenarioId);
if (scenario == null) return 0;
// 软删除关联的Qfod记录
var qfodRecords = await _context.QfodRecords
.Where(r => r.ScenarioId == scenarioId)
.ToListAsync();
foreach (var record in qfodRecords)
{
record.IsDeleted = true;
}
// 软删除关联的Ploss记录
var plossRecords = await _context.PlossRecords
.Where(r => r.ScenarioId == scenarioId)
.ToListAsync();
foreach (var record in plossRecords)
{
record.IsDeleted = true;
}
// 软删除场景
scenario.IsDeleted = true;
await _context.SaveChangesAsync();
return qfodRecords.Count + plossRecords.Count + 1;
}
/// <summary>
/// 软删除所有数据
/// </summary>
public async Task<int> CleanAllAsync()
{
int count = 0;
// 软删除所有Qfod记录
var qfodRecords = await _context.QfodRecords.IgnoreQueryFilters().ToListAsync();
foreach (var record in qfodRecords.Where(r => !r.IsDeleted))
{
record.IsDeleted = true;
count++;
}
// 软删除所有Ploss记录
var plossRecords = await _context.PlossRecords.IgnoreQueryFilters().ToListAsync();
foreach (var record in plossRecords.Where(r => !r.IsDeleted))
{
record.IsDeleted = true;
count++;
}
// 软删除所有场景
var scenarios = await _context.TestScenarios.IgnoreQueryFilters().ToListAsync();
foreach (var scenario in scenarios.Where(s => !s.IsDeleted))
{
scenario.IsDeleted = true;
count++;
}
await _context.SaveChangesAsync();
return count;
}
}

View File

@@ -0,0 +1,82 @@
using Microsoft.EntityFrameworkCore;
using WCTDataMiner.Data;
using WCTDataMiner.Models;
namespace WCTDataMiner.Services;
/// <summary>
/// 维度表管理服务 - 负责维度表的CRUD操作
/// </summary>
public class DimensionService
{
private readonly WctMinerDbContext _context;
public DimensionService(WctMinerDbContext context)
{
_context = context;
}
/// <summary>
/// 获取或创建TX面板维度
/// </summary>
public async Task<TxPanel> GetOrCreateTxPanelAsync(string name)
{
var existing = await _context.TxPanels
.FirstOrDefaultAsync(p => p.Name == name);
if (existing != null) return existing;
var newPanel = new TxPanel { Name = name };
_context.TxPanels.Add(newPanel);
await _context.SaveChangesAsync();
return newPanel;
}
/// <summary>
/// 获取或创建TX硬件版本维度
/// </summary>
public async Task<TxHardware> GetOrCreateTxHardwareAsync(string version)
{
var existing = await _context.TxHardwares
.FirstOrDefaultAsync(h => h.Version == version);
if (existing != null) return existing;
var newHardware = new TxHardware { Version = version };
_context.TxHardwares.Add(newHardware);
await _context.SaveChangesAsync();
return newHardware;
}
/// <summary>
/// 获取或创建TX软件版本维度
/// </summary>
public async Task<TxSoftware> GetOrCreateTxSoftwareAsync(string version)
{
var existing = await _context.TxSoftwares
.FirstOrDefaultAsync(s => s.Version == version);
if (existing != null) return existing;
var newSoftware = new TxSoftware { Version = version };
_context.TxSoftwares.Add(newSoftware);
await _context.SaveChangesAsync();
return newSoftware;
}
/// <summary>
/// 获取或创建RX类型维度
/// </summary>
public async Task<RxType> GetOrCreateRxTypeAsync(string name)
{
var existing = await _context.RxTypes
.FirstOrDefaultAsync(r => r.Name == name);
if (existing != null) return existing;
var newRxType = new RxType { Name = name };
_context.RxTypes.Add(newRxType);
await _context.SaveChangesAsync();
return newRxType;
}
}

View File

@@ -0,0 +1,177 @@
using System.Globalization;
using System.Text;
using System.Text.Json;
using Microsoft.EntityFrameworkCore;
using WCTDataMiner.Data;
using WCTDataMiner.Models;
namespace WCTDataMiner.Services;
/// <summary>
/// 数据导出服务
/// </summary>
public class ExportService
{
private readonly WctMinerDbContext _context;
public ExportService(WctMinerDbContext context)
{
_context = context;
}
/// <summary>
/// 导出Qfod数据为CSV
/// </summary>
public async Task<string> ExportQfodToCsvAsync(string outputDir)
{
Directory.CreateDirectory(outputDir);
var filePath = Path.Combine(outputDir, $"qfod_export_{DateTime.Now:yyyyMMdd_HHmmss}.csv");
var records = await _context.QfodRecords
.Include(r => r.Scenario)
.ThenInclude(s => s!.TxPanel)
.Include(r => r.Scenario)
.ThenInclude(s => s!.TxHardware)
.Include(r => r.Scenario)
.ThenInclude(s => s!.TxSoftware)
.Include(r => r.Scenario)
.ThenInclude(s => s!.RxType)
.ToListAsync();
var sb = new StringBuilder();
// CSV Header
sb.AppendLine("id,scenario_id,tx_panel,tx_hardware,tx_software,rx_type,test_purpose,test_date,test_sequence,charger_index,coil_index,delta_q,current_q,raw_q,fod_type");
foreach (var r in records)
{
sb.AppendLine($"{r.Id},{r.ScenarioId},{r.Scenario?.TxPanel?.Name},{r.Scenario?.TxHardware?.Version},{r.Scenario?.TxSoftware?.Version},{r.Scenario?.RxType?.Name},{r.Scenario?.TestPurpose},{r.Scenario?.TestDate:yyyy-MM-dd},{r.Scenario?.TestSequence},{r.ChargerIndex},{r.CoilIndex},{r.DeltaQ},{r.CurrentQ},{r.RawQ},{r.FodType}");
}
await File.WriteAllTextAsync(filePath, sb.ToString());
return filePath;
}
/// <summary>
/// 导出Ploss数据为CSV
/// </summary>
public async Task<string> ExportPlossToCsvAsync(string outputDir)
{
Directory.CreateDirectory(outputDir);
var filePath = Path.Combine(outputDir, $"ploss_export_{DateTime.Now:yyyyMMdd_HHmmss}.csv");
var records = await _context.PlossRecords
.Include(r => r.Scenario)
.ThenInclude(s => s!.TxPanel)
.Include(r => r.Scenario)
.ThenInclude(s => s!.TxHardware)
.Include(r => r.Scenario)
.ThenInclude(s => s!.TxSoftware)
.Include(r => r.Scenario)
.ThenInclude(s => s!.RxType)
.ToListAsync();
var sb = new StringBuilder();
// CSV Header
sb.AppendLine("id,scenario_id,tx_panel,tx_hardware,tx_software,rx_type,test_purpose,test_date,test_sequence,rx_type_field,rx_power,tx_power,vcoil,vin,isns,ploss,threshold,trigger_count,fod_result,protocol_type,pwm_duty,coil_index,margin");
foreach (var r in records)
{
sb.AppendLine($"{r.Id},{r.ScenarioId},{r.Scenario?.TxPanel?.Name},{r.Scenario?.TxHardware?.Version},{r.Scenario?.TxSoftware?.Version},{r.Scenario?.RxType?.Name},{r.Scenario?.TestPurpose},{r.Scenario?.TestDate:yyyy-MM-dd},{r.Scenario?.TestSequence},{r.RxType},{r.RxPower},{r.TxPower},{r.Vcoil},{r.Vin},{r.Isns},{r.Ploss},{r.Threshold},{r.TriggerCount},{r.FodResult},{r.ProtocolType},{r.PwmDuty},{r.CoilIndex},{r.Margin}");
}
await File.WriteAllTextAsync(filePath, sb.ToString());
return filePath;
}
/// <summary>
/// 导出Qfod数据为JSON
/// </summary>
public async Task<string> ExportQfodToJsonAsync(string outputDir)
{
Directory.CreateDirectory(outputDir);
var filePath = Path.Combine(outputDir, $"qfod_export_{DateTime.Now:yyyyMMdd_HHmmss}.json");
var records = await _context.QfodRecords
.Include(r => r.Scenario)
.ThenInclude(s => s!.TxPanel)
.Include(r => r.Scenario)
.ThenInclude(s => s!.TxHardware)
.Include(r => r.Scenario)
.ThenInclude(s => s!.TxSoftware)
.Include(r => r.Scenario)
.ThenInclude(s => s!.RxType)
.Select(r => new
{
r.Id,
r.ScenarioId,
TxPanel = r.Scenario!.TxPanel.Name,
TxHardware = r.Scenario.TxHardware.Version,
TxSoftware = r.Scenario.TxSoftware.Version,
RxType = r.Scenario.RxType.Name,
r.Scenario.TestPurpose,
TestDate = r.Scenario.TestDate.ToString("yyyy-MM-dd"),
r.Scenario.TestSequence,
r.ChargerIndex,
r.CoilIndex,
r.DeltaQ,
r.CurrentQ,
r.RawQ,
r.FodType
})
.ToListAsync();
var json = JsonSerializer.Serialize(records, new JsonSerializerOptions { WriteIndented = true });
await File.WriteAllTextAsync(filePath, json);
return filePath;
}
/// <summary>
/// 导出Ploss数据为JSON
/// </summary>
public async Task<string> ExportPlossToJsonAsync(string outputDir)
{
Directory.CreateDirectory(outputDir);
var filePath = Path.Combine(outputDir, $"ploss_export_{DateTime.Now:yyyyMMdd_HHmmss}.json");
var records = await _context.PlossRecords
.Include(r => r.Scenario)
.ThenInclude(s => s!.TxPanel)
.Include(r => r.Scenario)
.ThenInclude(s => s!.TxHardware)
.Include(r => r.Scenario)
.ThenInclude(s => s!.TxSoftware)
.Include(r => r.Scenario)
.ThenInclude(s => s!.RxType)
.Select(r => new
{
r.Id,
r.ScenarioId,
TxPanel = r.Scenario!.TxPanel.Name,
TxHardware = r.Scenario.TxHardware.Version,
TxSoftware = r.Scenario.TxSoftware.Version,
ScenarioRxType = r.Scenario.RxType.Name,
r.Scenario.TestPurpose,
TestDate = r.Scenario.TestDate.ToString("yyyy-MM-dd"),
r.Scenario.TestSequence,
RecordRxType = r.RxType,
r.RxPower,
r.TxPower,
r.Vcoil,
r.Vin,
r.Isns,
r.Ploss,
r.Threshold,
r.TriggerCount,
r.FodResult,
r.ProtocolType,
r.PwmDuty,
r.CoilIndex,
Margin = r.Threshold - r.Ploss
})
.ToListAsync();
var json = JsonSerializer.Serialize(records, new JsonSerializerOptions { WriteIndented = true });
await File.WriteAllTextAsync(filePath, json);
return filePath;
}
}

View File

@@ -0,0 +1,201 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Configuration;
using Serilog;
using WCTDataMiner.Data;
using WCTDataMiner.Models;
using WCTDataMiner.Parsers;
namespace WCTDataMiner.Services;
/// <summary>
/// 解析流程协调服务 - 协调文件名解析、行解析、批量写入
/// </summary>
public class ParseService
{
private readonly WctMinerDbContext _context;
private readonly ScenarioService _scenarioService;
private readonly QfodParser _qfodParser;
private readonly PlossParser _plossParser;
private readonly FileNameParser _fileNameParser;
private readonly int _batchSize;
public ParseService(
WctMinerDbContext context,
ScenarioService scenarioService,
QfodParser qfodParser,
PlossParser plossParser,
FileNameParser fileNameParser,
IConfiguration config)
{
_context = context;
_scenarioService = scenarioService;
_qfodParser = qfodParser;
_plossParser = plossParser;
_fileNameParser = fileNameParser;
_batchSize = config.GetValue("Parser:BatchSize", 1000);
}
/// <summary>
/// 解析单个日志文件
/// </summary>
public async Task<ParseReport> ParseFileAsync(string filePath, bool force = false)
{
var fileInfo = new FileInfo(filePath);
if (!fileInfo.Exists)
{
Log.Error("文件不存在: {FilePath}", filePath);
return new ParseReport(fileInfo.Name, 0, 0, 0, "文件不存在");
}
// 解析文件名获取场景信息
var scenarioInfo = _fileNameParser.Parse(fileInfo.Name);
if (scenarioInfo == null)
{
Log.Error("无法解析文件名: {FileName}", fileInfo.Name);
return new ParseReport(fileInfo.Name, 0, 0, 1, "文件名格式不匹配");
}
Log.Information("解析文件: {FileName} => 场景: {TxPanel}/{TxHardware}/{TxSoftware}/{RxType}/{TestDate}",
fileInfo.Name, scenarioInfo.TxPanel, scenarioInfo.TxHardware,
scenarioInfo.TxSoftware, scenarioInfo.RxType, scenarioInfo.TestDate);
// 获取或创建测试场景
var scenario = await _scenarioService.GetOrCreateScenarioAsync(scenarioInfo);
var lines = await File.ReadAllLinesAsync(filePath);
var qfodRecords = new List<QfodRecord>();
var plossRecords = new List<PlossRecord>();
int errorCount = 0;
for (int i = 0; i < lines.Length; i++)
{
var line = lines[i].Trim();
if (string.IsNullOrEmpty(line)) continue;
var lineNumber = i + 1;
if (_qfodParser.CanParse(line))
{
var result = _qfodParser.Parse(line, scenario.Id);
if (result.IsSuccess)
qfodRecords.Add(result.Record!);
else
{
Log.Error("Qfod解析失败 [{Line}] {Error}: {Message}",
lineNumber, result.ErrorType, result.ErrorMessage);
errorCount++;
}
}
else if (_plossParser.CanParse(line))
{
var result = _plossParser.Parse(line, scenario.Id);
if (result.IsSuccess)
plossRecords.Add(result.Record!);
else
{
Log.Error("Ploss解析失败 [{Line}] {Error}: {Message}",
lineNumber, result.ErrorType, result.ErrorMessage);
errorCount++;
}
}
}
// 批量保存
await BatchInsertAsync(qfodRecords, plossRecords);
// 更新场景统计
await UpdateScenarioStatsAsync(scenario.Id, qfodRecords.Count, plossRecords.Count);
Log.Information("解析完成: {FileName} => Qfod: {QfodCount}, Ploss: {PlossCount}, Errors: {ErrorCount}",
fileInfo.Name, qfodRecords.Count, plossRecords.Count, errorCount);
return new ParseReport(fileInfo.Name, qfodRecords.Count, plossRecords.Count, errorCount, null);
}
/// <summary>
/// 解析目录下所有日志文件
/// </summary>
public async Task<List<ParseReport>> ParseDirectoryAsync(string dirPath, bool recursive = false, bool force = false)
{
var dirInfo = new DirectoryInfo(dirPath);
if (!dirInfo.Exists)
{
Log.Error("目录不存在: {DirPath}", dirPath);
return [new ParseReport(dirPath, 0, 0, 0, "目录不存在")];
}
var searchOption = recursive ? SearchOption.AllDirectories : SearchOption.TopDirectoryOnly;
var logFiles = dirInfo.GetFiles("*.log", searchOption);
Log.Information("发现 {FileCount} 个日志文件", logFiles.Length);
var reports = new List<ParseReport>();
foreach (var file in logFiles)
{
var report = await ParseFileAsync(file.FullName, force);
reports.Add(report);
}
return reports;
}
private async Task BatchInsertAsync(
List<QfodRecord> qfodRecords,
List<PlossRecord> plossRecords)
{
if (qfodRecords.Count == 0 && plossRecords.Count == 0)
return;
await using var transaction = await _context.Database.BeginTransactionAsync();
try
{
// 批量插入 QfodRecord
foreach (var batch in qfodRecords.Chunk(_batchSize))
{
_context.QfodRecords.AddRange(batch);
await _context.SaveChangesAsync();
}
// 批量插入 PlossRecord
foreach (var batch in plossRecords.Chunk(_batchSize))
{
_context.PlossRecords.AddRange(batch);
await _context.SaveChangesAsync();
}
await transaction.CommitAsync();
}
catch
{
await transaction.RollbackAsync();
throw;
}
}
private async Task UpdateScenarioStatsAsync(Guid scenarioId, int qfodCount, int plossCount)
{
var scenario = await _context.TestScenarios.FindAsync(scenarioId);
if (scenario != null)
{
scenario.QfodCount += qfodCount;
scenario.PlossCount += plossCount;
await _context.SaveChangesAsync();
}
}
}
/// <summary>
/// 解析报告
/// </summary>
public record ParseReport(
string FileName,
int QfodCount,
int PlossCount,
int ErrorCount,
string? ErrorMessage
)
{
public bool IsSuccess => ErrorMessage == null;
}

View File

@@ -0,0 +1,76 @@
using Microsoft.EntityFrameworkCore;
using WCTDataMiner.Data;
using WCTDataMiner.Models;
using WCTDataMiner.Parsers;
namespace WCTDataMiner.Services;
/// <summary>
/// 测试场景服务 - 负责测试场景的创建和查询
/// </summary>
public class ScenarioService
{
private readonly WctMinerDbContext _context;
private readonly DimensionService _dimensionService;
public ScenarioService(WctMinerDbContext context, DimensionService dimensionService)
{
_context = context;
_dimensionService = dimensionService;
}
/// <summary>
/// 根据场景信息获取或创建测试场景
/// </summary>
public async Task<TestScenario> GetOrCreateScenarioAsync(ScenarioInfo info)
{
// 1. 获取或创建各维度
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);
// 2. 检查场景是否已存在(利用唯一约束)
var existing = await _context.TestScenarios
.FirstOrDefaultAsync(s =>
s.TxPanelId == txPanel.Id &&
s.TxHardwareId == txHardware.Id &&
s.TxSoftwareId == txSoftware.Id &&
s.RxTypeId == rxType.Id &&
s.TestPurpose == info.TestPurpose &&
s.TestDate == info.TestDate &&
s.TestSequence == info.TestSequence);
if (existing != null) return existing;
// 3. 创建新场景
var scenario = new TestScenario
{
TxPanelId = txPanel.Id,
TxHardwareId = txHardware.Id,
TxSoftwareId = txSoftware.Id,
RxTypeId = rxType.Id,
TestPurpose = info.TestPurpose,
TestDate = info.TestDate,
TestSequence = info.TestSequence
};
_context.TestScenarios.Add(scenario);
await _context.SaveChangesAsync();
return scenario;
}
/// <summary>
/// 获取场景详情(含维度属性)
/// </summary>
public async Task<TestScenario?> GetScenarioWithDimensionsAsync(Guid scenarioId)
{
return await _context.TestScenarios
.Include(s => s.TxPanel)
.Include(s => s.TxHardware)
.Include(s => s.TxSoftware)
.Include(s => s.RxType)
.FirstOrDefaultAsync(s => s.Id == scenarioId);
}
}

View File

@@ -0,0 +1,128 @@
using Microsoft.EntityFrameworkCore;
using WCTDataMiner.Data;
using WCTDataMiner.Models;
namespace WCTDataMiner.Services;
/// <summary>
/// 统计查询服务
/// </summary>
public class StatsService
{
private readonly WctMinerDbContext _context;
public StatsService(WctMinerDbContext context)
{
_context = context;
}
/// <summary>
/// 获取整体统计摘要
/// </summary>
public async Task<StatsSummary> GetSummaryAsync()
{
var scenarioCount = await _context.TestScenarios.CountAsync();
var qfodCount = await _context.QfodRecords.CountAsync();
var plossCount = await _context.PlossRecords.CountAsync();
var panelCount = await _context.TxPanels.CountAsync();
var hardwareCount = await _context.TxHardwares.CountAsync();
var softwareCount = await _context.TxSoftwares.CountAsync();
var rxTypeCount = await _context.RxTypes.CountAsync();
return new StatsSummary(
scenarioCount, qfodCount, plossCount,
panelCount, hardwareCount, softwareCount, rxTypeCount
);
}
/// <summary>
/// 按FOD类型统计Qfod记录
/// </summary>
public async Task<List<FodTypeStats>> GetStatsByFodTypeAsync()
{
// 先加载到内存再分组EF Core 对 GroupBy + 自定义类型有限制)
var data = await _context.QfodRecords
.Select(r => r.FodType)
.ToListAsync();
return data
.GroupBy(f => f)
.Select(g => new FodTypeStats(g.Key, g.Count()))
.OrderBy(s => s.FodType)
.ToList();
}
/// <summary>
/// 按面板类型统计
/// </summary>
public async Task<List<DimensionStats>> GetStatsByPanelAsync()
{
var data = await _context.TestScenarios
.Include(s => s.TxPanel)
.Select(s => s.TxPanel.Name)
.ToListAsync();
return data
.GroupBy(n => n)
.Select(g => new DimensionStats(g.Key, g.Count()))
.OrderByDescending(s => s.Count)
.ToList();
}
/// <summary>
/// 按硬件版本统计
/// </summary>
public async Task<List<DimensionStats>> GetStatsByHardwareAsync()
{
var data = await _context.TestScenarios
.Include(s => s.TxHardware)
.Select(s => s.TxHardware.Version)
.ToListAsync();
return data
.GroupBy(v => v)
.Select(g => new DimensionStats(g.Key, g.Count()))
.OrderByDescending(s => s.Count)
.ToList();
}
/// <summary>
/// 按RX类型统计
/// </summary>
public async Task<List<DimensionStats>> GetStatsByRxTypeAsync()
{
var data = await _context.TestScenarios
.Include(s => s.RxType)
.Select(s => s.RxType.Name)
.ToListAsync();
return data
.GroupBy(n => n)
.Select(g => new DimensionStats(g.Key, g.Count()))
.OrderByDescending(s => s.Count)
.ToList();
}
}
/// <summary>
/// 整体统计摘要
/// </summary>
public record StatsSummary(
int ScenarioCount,
int QfodCount,
int PlossCount,
int PanelCount,
int HardwareCount,
int SoftwareCount,
int RxTypeCount
);
/// <summary>
/// FOD类型统计
/// </summary>
public record FodTypeStats(byte FodType, int Count);
/// <summary>
/// 维度统计
/// </summary>
public record DimensionStats(string Name, int Count);

View File

@@ -0,0 +1,43 @@
namespace WCTDataMiner.Services;
/// <summary>
/// 阈值计算器 - 根据接收功率查表获取动态阈值
/// </summary>
public static class ThresholdCalculator
{
/// <summary>
/// 根据RxPower获取对应的动态阈值
/// </summary>
/// <param name="rxPower">接收端功率mW</param>
/// <returns>当前功率段的阈值</returns>
public static int GetThresholdByPower(int rxPower)
{
if (rxPower <= 5000) return 350;
if (rxPower <= 10000) return 500;
if (rxPower <= 15000) return 750;
if (rxPower <= 30000) return 1000;
return 1250;
}
/// <summary>
/// 计算安全余量
/// </summary>
/// <param name="threshold">阈值</param>
/// <param name="ploss">计算损耗值</param>
/// <returns>安全余量(正值安全,负值危险)</returns>
public static int CalculateMargin(int threshold, int ploss)
{
return threshold - ploss;
}
/// <summary>
/// 判断是否触发FOD
/// </summary>
/// <param name="ploss">计算损耗值</param>
/// <param name="threshold">阈值</param>
/// <returns>是否触发FOD</returns>
public static bool IsFodTriggered(int ploss, int threshold)
{
return ploss > threshold;
}
}

View File

@@ -0,0 +1,43 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net8.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<LangVersion>12.0</LangVersion>
</PropertyGroup>
<ItemGroup>
<!-- ORM -->
<PackageReference Include="Microsoft.EntityFrameworkCore" Version="8.0.0" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="8.0.0" />
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="8.0.0" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="8.0.0">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets>
</PackageReference>
<!-- CLI -->
<PackageReference Include="System.CommandLine" Version="2.0.0-beta4.22272.1" />
<!-- Configuration -->
<PackageReference Include="Microsoft.Extensions.Configuration" Version="8.0.0" />
<PackageReference Include="Microsoft.Extensions.Configuration.Json" Version="8.0.0" />
<PackageReference Include="Microsoft.Extensions.Configuration.EnvironmentVariables" Version="8.0.0" />
<PackageReference Include="Microsoft.Extensions.Hosting" Version="8.0.0" />
<!-- Logging -->
<PackageReference Include="Serilog" Version="4.0.0" />
<PackageReference Include="Serilog.Extensions.Hosting" Version="8.0.0" />
<PackageReference Include="Serilog.Sinks.Console" Version="5.0.0" />
<PackageReference Include="Serilog.Sinks.File" Version="5.0.0" />
</ItemGroup>
<ItemGroup>
<None Update="appsettings.json">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</None>
</ItemGroup>
</Project>