diff --git a/DentistryHandpieces/App.xaml.cs b/DentistryHandpieces/App.xaml.cs
index be08ffb..2e617d3 100644
--- a/DentistryHandpieces/App.xaml.cs
+++ b/DentistryHandpieces/App.xaml.cs
@@ -1,14 +1,96 @@
-using System.Configuration;
-using System.Data;
+using System.IO;
using System.Windows;
+using System.Windows.Threading;
+using Serilog;
+using Serilog.Events;
-namespace DentistryHandpieces
+namespace DentistryHandpieces;
+
+public partial class App : Application
{
- ///
- /// Interaction logic for App.xaml
- ///
- public partial class App : Application
+ protected override void OnStartup(StartupEventArgs e)
{
+ ConfigureLogging();
+ DispatcherUnhandledException += OnDispatcherUnhandledException;
+ AppDomain.CurrentDomain.UnhandledException += OnUnhandledException;
+ TaskScheduler.UnobservedTaskException += OnUnobservedTaskException;
+
+ Log.Information(
+ "应用启动,版本 {Version},系统 {OperatingSystem}",
+ typeof(App).Assembly.GetName().Version?.ToString() ?? "unknown",
+ Environment.OSVersion.VersionString);
+
+ base.OnStartup(e);
}
+ protected override void OnExit(ExitEventArgs e)
+ {
+ Log.Information("应用退出,退出代码 {ExitCode}", e.ApplicationExitCode);
+ Log.CloseAndFlush();
+ base.OnExit(e);
+ }
+
+ private static void ConfigureLogging()
+ {
+ string logDirectory = Path.Combine(
+ Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
+ "DentistryHandpieces",
+ "Logs");
+
+ try
+ {
+ CreateLogger(logDirectory);
+ Log.Information("日志系统初始化完成,目录 {LogDirectory}", logDirectory);
+ }
+ catch (Exception ex)
+ {
+ string fallbackDirectory = Path.Combine(Path.GetTempPath(), "DentistryHandpieces", "Logs");
+ try
+ {
+ CreateLogger(fallbackDirectory);
+ Log.Warning(ex, "主日志目录初始化失败,已切换到临时目录 {LogDirectory}", fallbackDirectory);
+ }
+ catch (Exception fallbackException)
+ {
+ Log.Logger = new LoggerConfiguration().CreateLogger();
+ System.Diagnostics.Debug.WriteLine($"Serilog initialization failed: {ex}; fallback failed: {fallbackException}");
+ }
+ }
+ }
+
+ private static void CreateLogger(string logDirectory)
+ {
+ Directory.CreateDirectory(logDirectory);
+ Log.Logger = new LoggerConfiguration()
+ .MinimumLevel.Information()
+ .MinimumLevel.Override("Microsoft", LogEventLevel.Warning)
+ .Enrich.FromLogContext()
+ .Enrich.WithProperty("Application", "DentistryHandpieces")
+ .WriteTo.File(
+ Path.Combine(logDirectory, "app-.log"),
+ rollingInterval: RollingInterval.Day,
+ retainedFileCountLimit: 30,
+ fileSizeLimitBytes: 20 * 1024 * 1024,
+ rollOnFileSizeLimit: true,
+ shared: true,
+ flushToDiskInterval: TimeSpan.FromSeconds(1),
+ outputTemplate: "{Timestamp:yyyy-MM-dd HH:mm:ss.fff zzz} [{Level:u3}] {Message:lj}{NewLine}{Exception}")
+ .CreateLogger();
+ }
+
+ private static void OnDispatcherUnhandledException(object sender, DispatcherUnhandledExceptionEventArgs e)
+ {
+ Log.Fatal(e.Exception, "UI线程发生未处理异常");
+ }
+
+ private static void OnUnhandledException(object? sender, UnhandledExceptionEventArgs e)
+ {
+ Log.Fatal(e.ExceptionObject as Exception, "应用域发生未处理异常,IsTerminating={IsTerminating}", e.IsTerminating);
+ }
+
+ private static void OnUnobservedTaskException(object? sender, UnobservedTaskExceptionEventArgs e)
+ {
+ Log.Error(e.Exception, "后台任务发生未观察异常");
+ e.SetObserved();
+ }
}
diff --git a/DentistryHandpieces/DentistryHandpieces.csproj b/DentistryHandpieces/DentistryHandpieces.csproj
index a3df4df..5302ae0 100644
--- a/DentistryHandpieces/DentistryHandpieces.csproj
+++ b/DentistryHandpieces/DentistryHandpieces.csproj
@@ -11,8 +11,9 @@
-
+
+
diff --git a/DentistryHandpieces/FileDialogService.cs b/DentistryHandpieces/FileDialogService.cs
index 25b7945..56319be 100644
--- a/DentistryHandpieces/FileDialogService.cs
+++ b/DentistryHandpieces/FileDialogService.cs
@@ -13,8 +13,8 @@ public sealed class WpfFileDialogService : IFileDialogService
{
var dialog = new SaveFileDialog
{
- Title = "导出 Excel",
- FileName = $"牙科手机验收记录_{createdAt:yyyyMMddHHmmss}.xlsx",
+ Title = "导出报表",
+ FileName = $"牙科手机测试报表_{createdAt:yyyyMMddHHmmss}.xlsx",
Filter = "Excel 工作簿 (*.xlsx)|*.xlsx",
DefaultExt = ".xlsx",
AddExtension = true,
diff --git a/DentistryHandpieces/MainWindow.xaml b/DentistryHandpieces/MainWindow.xaml
index 2664a14..f44a4c2 100644
--- a/DentistryHandpieces/MainWindow.xaml
+++ b/DentistryHandpieces/MainWindow.xaml
@@ -301,10 +301,60 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
@@ -1168,56 +1218,6 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
diff --git a/DentistryHandpieces/MainWindow.xaml.cs b/DentistryHandpieces/MainWindow.xaml.cs
index 971ef72..040eb4c 100644
--- a/DentistryHandpieces/MainWindow.xaml.cs
+++ b/DentistryHandpieces/MainWindow.xaml.cs
@@ -2,6 +2,7 @@ using System.Windows;
using System.Windows.Controls;
using System.Windows.Input;
using System.Windows.Threading;
+using Serilog;
namespace DentistryHandpieces;
@@ -24,6 +25,7 @@ public partial class MainWindow : Window
};
_hiddenSettingsPressTimer.Tick += HiddenSettingsPressTimer_Tick;
Deactivated += (_, _) => ReleaseAllManualMotionTargetsAsync();
+ Log.Information("主窗口创建完成");
}
private void HiddenSettingsHotspot_PreviewMouseLeftButtonDown(object sender, MouseButtonEventArgs e)
@@ -107,6 +109,7 @@ public partial class MainWindow : Window
if (DataContext is MainWindowViewModel viewModel)
{
+ Log.Information("打开隐藏速度设置窗口");
var window = new HiddenSpeedSettingsWindow(_plcService, viewModel.GetPlcConnectionConfig())
{
Owner = this
diff --git a/DentistryHandpieces/MainWindowViewModel.cs b/DentistryHandpieces/MainWindowViewModel.cs
index ca3d908..4628d9c 100644
--- a/DentistryHandpieces/MainWindowViewModel.cs
+++ b/DentistryHandpieces/MainWindowViewModel.cs
@@ -1,5 +1,4 @@
using System.Collections.ObjectModel;
-using System.Data;
using System.Globalization;
using System.IO;
using System.Text.Json;
@@ -7,7 +6,7 @@ using System.Text.Json.Serialization;
using ClosedXML.Excel;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
-using Microsoft.Data.Sqlite;
+using Serilog;
using System.Windows;
using System.Windows.Media;
using System.Windows.Media.Imaging;
@@ -82,6 +81,8 @@ public sealed class MainWindowViewModel : ObservableObject
private const int MaxTorqueSampleCount = 120;
private const double MinimumTorqueChangeThreshold = 0.05;
private const string TorqueUnit = "mN.m";
+ private static readonly TimeSpan SnapshotInterval = TimeSpan.FromSeconds(5);
+ private static readonly TimeSpan NoLoadCaptureDuration = TimeSpan.FromSeconds(3);
private static readonly ushort[] RealtimeRegisterAddresses =
[
@@ -136,9 +137,25 @@ public sealed class MainWindowViewModel : ObservableObject
private readonly IFileDialogService _fileDialogService;
private readonly DispatcherTimer _realtimeTimer;
private readonly DispatcherTimer _parameterRetryTimer;
- private readonly string _databasePath;
+ private readonly object _snapshotWriteLock = new();
+ private readonly string _applicationDataDirectory;
+ private readonly string _recordsDirectory;
private readonly string _parameterConfigPath;
private TestParameterConfig _parameterConfig = TestParameterConfig.CreateDefault();
+ private readonly List _completedRuns = [];
+ private TestRunPayload? _activeDisplacementRun;
+ private TestRunPayload? _activeSpeedTorqueRun;
+ private TestRunPayload? _activeNoLoadSpeedRun;
+ private DateTime? _noLoadCaptureDeadline;
+ private RealtimeSamplePayload? _latestRealtimeSample;
+ private TorqueCurvePayload? _cachedTorqueCurve;
+ private string _sessionId = CreateSessionId();
+ private DateTime _sessionCreatedAt = DateTime.Now;
+ private DateTime _lastSnapshotPersistedAt = DateTime.MinValue;
+ private DateTime _latestPersistedPayloadAt = DateTime.MinValue;
+ private long _realtimeSampleSequence;
+ private int _snapshotWriteInProgress;
+ private bool _sessionExported;
private double _dialZero;
private double _relativeDisplacement;
private double _axialAxisPosition;
@@ -179,6 +196,7 @@ public sealed class MainWindowViewModel : ObservableObject
private bool _isAxialResetDone;
private bool _isSpeedTorqueResetEnabled;
private bool _isSpeedTorqueResetDone;
+ private DateTime _lastParameterReadFailureLogAt = DateTime.MinValue;
private string _relativeDisplacementText = "0.000 mm";
private string _axialAxisPositionText = "0.000 mm";
private string _axialSamplePairText = "-- / --";
@@ -205,7 +223,8 @@ public sealed class MainWindowViewModel : ObservableObject
private string _torqueCurveStatusText = "保持时间曲线:未启动";
private string _axialConfigSummaryText = "--";
private string _speedTorqueConfigSummaryText = "--";
- private string _statusText = "当前整体验收:记录";
+ private string _statusText = "完成测试后可导出报表。";
+ private string _dataCaptureStatusText = "完整采样:等待测试启动;自动备份:已启用;导出校验:已启用";
private string _parameterStatusText = "参数配置将保存到本地文件。";
private string _axialForceSetpointInput = "0";
private string _axialForceSetpointModeButtonText = "轴向跳动力设置";
@@ -217,10 +236,11 @@ public sealed class MainWindowViewModel : ObservableObject
_plcCoilService = plcCoilService;
_plcRegisterService = plcRegisterService;
_fileDialogService = fileDialogService;
- _databasePath = Path.Combine(AppContext.BaseDirectory, "acceptance_records.db");
- _parameterConfigPath = Path.Combine(AppContext.BaseDirectory, "parameter_config.json");
+ _applicationDataDirectory = ResolveApplicationDataDirectory();
+ _recordsDirectory = Path.Combine(_applicationDataDirectory, "Records");
+ _parameterConfigPath = Path.Combine(_applicationDataDirectory, "parameter_config.json");
+ Directory.CreateDirectory(_recordsDirectory);
- SaveRecordCommand = new RelayCommand(SaveRecord);
ExportCommand = new RelayCommand(Export);
SaveParameterConfigCommand = new AsyncRelayCommand(SaveParameterConfigFromInputsAsync);
ForwardDisplacementCommand = new AsyncRelayCommand(ForwardDisplacementAsync);
@@ -241,11 +261,10 @@ public sealed class MainWindowViewModel : ObservableObject
_parameterConfig = LoadParameterConfig();
ApplyParameterConfigToInputs();
- InitializeDatabase();
- LoadRecords();
UpdateDisplacementDisplay("状态:待复位");
UpdateSpeedTorqueDisplay("状态:待启动");
RefreshOverallResult();
+ UpdateDataCaptureStatus();
_realtimeTimer = new DispatcherTimer
{
@@ -261,14 +280,11 @@ public sealed class MainWindowViewModel : ObservableObject
_parameterRetryTimer.Tick += ParameterRetryTimer_Tick;
_parameterRetryTimer.Start();
_ = ReadParameterConfigFromPlcAsync();
+ Log.Information("主视图模型初始化完成,参数文件 {ParameterConfigPath}", _parameterConfigPath);
}
- public ObservableCollection Records { get; } = [];
-
public ObservableCollection TorqueSamples { get; } = [];
- public IRelayCommand SaveRecordCommand { get; }
-
public IRelayCommand ExportCommand { get; }
public IAsyncRelayCommand SaveParameterConfigCommand { get; }
@@ -568,6 +584,12 @@ public sealed class MainWindowViewModel : ObservableObject
private set => SetProperty(ref _statusText, value);
}
+ public string DataCaptureStatusText
+ {
+ get => _dataCaptureStatusText;
+ private set => SetProperty(ref _dataCaptureStatusText, value);
+ }
+
public string ParameterStatusText
{
get => _parameterStatusText;
@@ -631,6 +653,9 @@ public sealed class MainWindowViewModel : ObservableObject
_realtimeSpeed = realtimeSpeed;
AppendTorqueSample(GetScaledTorque(), DateTime.Now);
ApplyResetCoilValues(coilValues);
+ CaptureRealtimeSample(dialIndicator, coilValues);
+ FinalizeNoLoadSpeedRunIfDue();
+ QueueSnapshotIfDue();
if (_isDisplacementRunning)
{
@@ -653,6 +678,7 @@ public sealed class MainWindowViewModel : ObservableObject
StatusText = "实时数据已恢复";
NoLoadSpeedStatusText = "状态:实时数据已恢复";
_lastRealtimeReadFailed = false;
+ Log.Information("PLC实时数据读取已恢复");
}
}
catch (Exception ex)
@@ -662,6 +688,7 @@ public sealed class MainWindowViewModel : ObservableObject
StatusText = $"实时数据读取失败:{ex.Message}";
NoLoadSpeedStatusText = "状态:实时数据读取失败";
_lastRealtimeReadFailed = true;
+ Log.Warning(ex, "PLC实时数据读取失败,后续相同故障将等待恢复后再记录");
}
}
finally
@@ -738,6 +765,7 @@ public sealed class MainWindowViewModel : ObservableObject
SpeedCoefficient = speedCoefficient,
SpeedStopThreshold = speedStopThreshold,
PressureCoefficient = pressureCoefficient,
+ NoLoadSpeedSetting = noLoadSpeedSetting,
PlcIpAddress = _parameterConfig.PlcIpAddress,
PlcPort = _parameterConfig.PlcPort,
PlcUnitId = _parameterConfig.PlcUnitId,
@@ -755,10 +783,20 @@ public sealed class MainWindowViewModel : ObservableObject
_hasLoadedParameterConfigFromPlc = true;
_parameterRetryTimer.Stop();
ParameterStatusText = "通信读取成功";
+ Log.Information(
+ "PLC参数读取成功,地址 {IpAddress}:{Port},站号 {UnitId}",
+ config.IpAddress,
+ config.Port,
+ config.UnitId);
}
catch (Exception ex)
{
ParameterStatusText = $"通信读取失败,正在重试:{ex.Message}";
+ if (DateTime.UtcNow - _lastParameterReadFailureLogAt >= TimeSpan.FromMinutes(1))
+ {
+ _lastParameterReadFailureLogAt = DateTime.UtcNow;
+ Log.Warning(ex, "PLC参数读取失败,正在后台重试");
+ }
}
finally
{
@@ -770,23 +808,50 @@ public sealed class MainWindowViewModel : ObservableObject
{
if (!File.Exists(_parameterConfigPath))
{
- return TestParameterConfig.CreateDefault();
+ string legacyPath = Path.Combine(AppContext.BaseDirectory, "parameter_config.json");
+ if (File.Exists(legacyPath))
+ {
+ try
+ {
+ Directory.CreateDirectory(_applicationDataDirectory);
+ File.Copy(legacyPath, _parameterConfigPath, overwrite: false);
+ Log.Information("已迁移旧参数文件到用户数据目录,来源 {LegacyPath},目标 {ParameterConfigPath}", legacyPath, _parameterConfigPath);
+ }
+ catch (Exception ex)
+ {
+ Log.Warning(ex, "旧参数文件迁移失败,将直接读取旧文件 {LegacyPath}", legacyPath);
+ return LoadParameterConfigFile(legacyPath);
+ }
+ }
+ else
+ {
+ Log.Information("本地参数文件不存在,使用默认参数,路径 {ParameterConfigPath}", _parameterConfigPath);
+ return TestParameterConfig.CreateDefault();
+ }
}
+ return LoadParameterConfigFile(_parameterConfigPath);
+ }
+
+ private static TestParameterConfig LoadParameterConfigFile(string path)
+ {
try
{
- string json = File.ReadAllText(_parameterConfigPath);
+ string json = File.ReadAllText(path);
return JsonSerializer.Deserialize(json, CreateParameterJsonOptions()) ?? TestParameterConfig.CreateDefault();
}
- catch
+ catch (Exception ex)
{
+ Log.Warning(ex, "本地参数文件读取失败,使用默认参数,路径 {ParameterConfigPath}", path);
return TestParameterConfig.CreateDefault();
}
}
private void SaveParameterConfig()
{
+ Directory.CreateDirectory(_applicationDataDirectory);
File.WriteAllText(_parameterConfigPath, JsonSerializer.Serialize(_parameterConfig, CreateParameterJsonOptions()));
+ Log.Information("本地参数文件保存成功,路径 {ParameterConfigPath}", _parameterConfigPath);
}
private static JsonSerializerOptions CreateParameterJsonOptions()
@@ -821,6 +886,7 @@ public sealed class MainWindowViewModel : ObservableObject
SpeedCoefficient = _parameterConfig.SpeedCoefficient,
SpeedStopThreshold = _parameterConfig.SpeedStopThreshold,
PressureCoefficient = _parameterConfig.PressureCoefficient,
+ NoLoadSpeedSetting = _parameterConfig.NoLoadSpeedSetting,
PlcIpAddress = _parameterConfig.PlcIpAddress,
PlcPort = _parameterConfig.PlcPort,
PlcUnitId = _parameterConfig.PlcUnitId,
@@ -855,6 +921,7 @@ public sealed class MainWindowViewModel : ObservableObject
SpeedCoefficient = _parameterConfig.SpeedCoefficient,
SpeedStopThreshold = _parameterConfig.SpeedStopThreshold,
PressureCoefficient = _parameterConfig.PressureCoefficient,
+ NoLoadSpeedSetting = _parameterConfig.NoLoadSpeedSetting,
PlcIpAddress = _parameterConfig.PlcIpAddress,
PlcPort = _parameterConfig.PlcPort,
PlcUnitId = _parameterConfig.PlcUnitId,
@@ -864,8 +931,45 @@ public sealed class MainWindowViewModel : ObservableObject
};
}
+ private TestParameterConfig WithNoLoadSpeedSetting(double noLoadSpeedSetting)
+ {
+ TestParameterConfig config = CloneParameterConfig(_parameterConfig);
+ return new TestParameterConfig
+ {
+ AxialDisplacementLimit = config.AxialDisplacementLimit,
+ AxialSpeed = config.AxialSpeed,
+ AxialManualDisplacement = config.AxialManualDisplacement,
+ AxialForceLowerLimit = config.AxialForceLowerLimit,
+ AxialForceUpperLimit = config.AxialForceUpperLimit,
+ AxialForceCoefficient = config.AxialForceCoefficient,
+ AxialForceSetpoint = config.AxialForceSetpoint,
+ AxialJumpForceSetpoint = config.AxialJumpForceSetpoint,
+ UseAxialPullForceSetpoint = config.UseAxialPullForceSetpoint,
+ AxialForceProtection = config.AxialForceProtection,
+ AxialForceHoldTime = config.AxialForceHoldTime,
+ SpeedTorqueDisplacementLimit = config.SpeedTorqueDisplacementLimit,
+ SpeedTorqueSpeed = config.SpeedTorqueSpeed,
+ SpeedTorqueManualDisplacement = config.SpeedTorqueManualDisplacement,
+ TorqueCoefficient = config.TorqueCoefficient,
+ TorqueProtection = config.TorqueProtection,
+ HoldTorque = config.HoldTorque,
+ TorqueHoldTime = config.TorqueHoldTime,
+ SpeedCoefficient = config.SpeedCoefficient,
+ SpeedStopThreshold = config.SpeedStopThreshold,
+ PressureCoefficient = config.PressureCoefficient,
+ NoLoadSpeedSetting = noLoadSpeedSetting,
+ PlcIpAddress = config.PlcIpAddress,
+ PlcPort = config.PlcPort,
+ PlcUnitId = config.PlcUnitId,
+ PlcPulseMilliseconds = config.PlcPulseMilliseconds,
+ PlcTimeoutMilliseconds = config.PlcTimeoutMilliseconds,
+ FloatWordOrder = config.FloatWordOrder
+ };
+ }
+
private void ApplyParameterConfigToInputs()
{
+ _cachedTorqueCurve = null;
_isApplyingParameterConfigToInputs = true;
try
{
@@ -888,6 +992,7 @@ public sealed class MainWindowViewModel : ObservableObject
SpeedCoefficientInput = FormatConfigNumber(_parameterConfig.SpeedCoefficient);
SpeedStopThresholdInput = FormatConfigNumber(_parameterConfig.SpeedStopThreshold);
PressureCoefficientInput = FormatConfigNumber(_parameterConfig.PressureCoefficient);
+ NoLoadSpeedSettingInput = FormatConfigNumber(_parameterConfig.NoLoadSpeedSetting);
PlcIpAddressInput = string.IsNullOrWhiteSpace(_parameterConfig.PlcIpAddress) ? "192.168.1.10" : _parameterConfig.PlcIpAddress;
PlcPortInput = _parameterConfig.PlcPort > 0 ? _parameterConfig.PlcPort.ToString(CultureInfo.InvariantCulture) : "502";
PlcUnitIdInput = _parameterConfig.PlcUnitId.ToString(CultureInfo.InvariantCulture);
@@ -973,87 +1078,19 @@ public sealed class MainWindowViewModel : ObservableObject
$"1号轴:位移极限 {FormatDisplacement(_parameterConfig.SpeedTorqueDisplacementLimit)} mm;速度 {FormatSpeedSetting(_parameterConfig.SpeedTorqueSpeed)} mm/min;手动位移 {FormatDisplacement(_parameterConfig.SpeedTorqueManualDisplacement)} mm;低速停止 {FormatSpeed(_parameterConfig.SpeedStopThreshold)} r/min";
}
- private void InitializeDatabase()
+ private void Export()
{
- using var connection = OpenConnection();
- using var command = connection.CreateCommand();
- command.CommandText =
- """
- CREATE TABLE IF NOT EXISTS AcceptanceRecords (
- Id INTEGER PRIMARY KEY AUTOINCREMENT,
- CreatedAt TEXT NOT NULL,
- SampleCode TEXT NOT NULL,
- OperatorName TEXT NOT NULL,
- OverallResult TEXT NOT NULL,
- PayloadJson TEXT NOT NULL,
- ExportPath TEXT NOT NULL DEFAULT ''
- );
- """;
- command.ExecuteNonQuery();
- }
-
- private void LoadRecords()
- {
- Records.Clear();
-
- using var connection = OpenConnection();
- using var command = connection.CreateCommand();
- command.CommandText =
- """
- SELECT Id, CreatedAt, SampleCode, OperatorName, OverallResult, ExportPath, PayloadJson
- FROM AcceptanceRecords
- ORDER BY Id DESC
- LIMIT 200;
- """;
-
- using var reader = command.ExecuteReader();
- while (reader.Read())
+ if (HasActiveRuns())
{
- string exportPath = reader.GetString(5);
- string payloadJson = reader.GetString(6);
- if (IsBlankStoredRecord(exportPath, payloadJson))
- {
- continue;
- }
-
- Records.Add(new TestRecordRow
- {
- Id = reader.GetInt32(0),
- CreatedAt = DateTime.Parse(reader.GetString(1), CultureInfo.InvariantCulture, DateTimeStyles.RoundtripKind),
- SampleCode = reader.GetString(2),
- OperatorName = reader.GetString(3),
- OverallResult = reader.GetString(4),
- ExportPath = exportPath
- });
- }
- }
-
- private SqliteConnection OpenConnection()
- {
- var connection = new SqliteConnection($"Data Source={_databasePath}");
- connection.Open();
- return connection;
- }
-
- private void SaveRecord()
- {
- if (!HasCompletedTestResult())
- {
- StatusText = "没有已完成测试,不保存空白记录。请先停止测试生成最终数据。";
+ StatusText = "测试数据仍在采集中,请停止测试或等待空载转速记录完成后再导出。";
+ Log.Warning("导出报表被阻止:当前仍有测试运行正在采集");
return;
}
- var payload = CreatePayload();
- int id = SavePayload(payload, string.Empty);
- StatusText = $"已保存记录 #{id}:{payload.OverallResult}";
- LoadRecords();
- }
-
- private void Export()
- {
if (!HasCompletedTestResult())
{
- StatusText = "没有已完成测试,不导出空白记录。请先停止测试生成最终数据。";
+ StatusText = "没有已完成测试,不导出空白报表。请先停止测试生成最终数据。";
+ Log.Warning("导出报表被阻止:当前没有已完成测试结果");
return;
}
@@ -1062,85 +1099,82 @@ public sealed class MainWindowViewModel : ObservableObject
if (exportPath is null)
{
StatusText = "已取消导出";
+ Log.Information("用户取消导出报表");
return;
}
try
{
- ExportPayload(exportPath, payload);
- SavePayload(payload, exportPath);
- StatusText = $"已导出 Excel:{exportPath}";
- LoadRecords();
+ Log.Information("开始导出报表到 {ExportPath}", exportPath);
+ PersistPayloadSnapshot(payload, "导出前快照");
+ ExportPayloadAtomically(exportPath, payload);
+ _sessionExported = true;
+ StatusText = $"报表已完整导出并校验:{exportPath}";
+ UpdateDataCaptureStatus("导出校验:通过");
+ Log.Information(
+ "报表导出并校验成功,路径 {ExportPath},测试运行 {RunCount},完整采样 {SampleCount},轴向最终位移 {FinalDisplacement},最终轴向力 {FinalAxialForce},最终转速 {FinalSpeed},最终扭矩 {FinalTorque}",
+ exportPath,
+ payload.Runs.Count,
+ payload.Runs.Sum(static run => run.Samples.Count),
+ _finalDisplacement,
+ _finalAxialForce,
+ _finalSpeed,
+ _finalTorque);
}
catch (Exception ex)
{
StatusText = $"导出失败:{ex.Message}";
+ UpdateDataCaptureStatus("导出校验:失败");
+ Log.Error(ex, "报表导出失败,路径 {ExportPath}", exportPath);
}
}
private bool HasCompletedTestResult()
{
- return _finalDisplacement.HasValue
+ return _completedRuns.Count > 0
+ || _finalDisplacement.HasValue
|| _finalAxialForce.HasValue
|| _finalSpeedTorqueDisplacement.HasValue
|| _finalSpeed.HasValue
|| _finalTorque.HasValue;
}
- private static bool IsBlankStoredRecord(string exportPath, string payloadJson)
- {
- if (!string.IsNullOrWhiteSpace(exportPath))
- {
- return false;
- }
-
- try
- {
- AcceptancePayload? payload = JsonSerializer.Deserialize(payloadJson);
- return payload is null || !HasCompletedPayloadResult(payload);
- }
- catch
- {
- return false;
- }
- }
-
- private static bool HasCompletedPayloadResult(AcceptancePayload payload)
- {
- return payload.Projects
- .SelectMany(static project => project.Points)
- .Any(static point => IsCompletedResultPoint(point.TargetText, point.MeasuredText, point.Result));
- }
-
- private static bool IsCompletedResultPoint(string targetText, string measuredText, string result)
- {
- return result != "待停止"
- && measuredText != "--"
- && (targetText == "最终位移"
- || targetText == "最终轴向力"
- || targetText == "最终转速"
- || targetText == "最终扭矩");
- }
-
private AcceptancePayload CreatePayload()
{
return new AcceptancePayload
{
- CreatedAt = DateTime.Now,
+ SessionId = _sessionId,
+ CreatedAt = _sessionCreatedAt,
+ LastUpdatedAt = DateTime.Now,
SampleCode = string.Empty,
OperatorName = string.Empty,
OverallResult = CalculateOverallResult(),
+ ParameterSnapshot = CloneParameterConfig(_parameterConfig),
Projects =
[
CreateDisplacementProject(),
- CreateSpeedTorqueRealtimeProject()
- ]
+ CreateSpeedTorqueRealtimeProject(),
+ CreateNoLoadSpeedProject()
+ ],
+ Runs = CreateRunSnapshots()
};
}
private ProjectPayload CreateSpeedTorqueRealtimeProject()
{
- TorqueCurvePayload curve = CreateTorqueCurvePayload();
+ TestRunPayload? run = GetLatestCompletedRun("转速/扭矩测试");
+ TestParameterConfig parameters = run?.ParameterSnapshot ?? _parameterConfig;
+ RealtimeSamplePayload? lastSample = run?.Samples.LastOrDefault();
+ TorqueCurvePayload curve = run is null ? CreateTorqueCurvePayload() : CreateTorqueCurvePayload(run);
+ double maxDisplacement = run?.Samples.Count > 0
+ ? run.Samples.Max(static sample => Math.Abs(sample.SpeedTorqueDisplacementMm))
+ : _maxSpeedTorqueDisplacement;
+ double peakTorque = run?.Samples.Count > 0
+ ? run.Samples.Max(static sample => sample.SpeedTorquePeakTorqueMilliNewtonMeters)
+ : _speedTorquePeakTorque;
+ double? finalDisplacement = run?.FinalDisplacementMm ?? _finalSpeedTorqueDisplacement;
+ double? finalSpeed = run?.FinalSpeedRpm ?? _finalSpeed;
+ double? finalTorque = run?.FinalTorqueMilliNewtonMeters ?? _finalTorque;
return new ProjectPayload
{
Name = "转速/扭矩实时测试",
@@ -1149,22 +1183,24 @@ public sealed class MainWindowViewModel : ObservableObject
TorqueCurve = curve,
Points =
[
- CreateRecordPoint("转速/扭矩实时测试", "位移极限", $"{FormatDisplacement(_parameterConfig.SpeedTorqueDisplacementLimit)} mm", "mm"),
- CreateRecordPoint("转速/扭矩实时测试", "手/自动速度", $"{FormatSpeedSetting(_parameterConfig.SpeedTorqueSpeed)} mm/min", "mm/min"),
- CreateRecordPoint("转速/扭矩实时测试", "手动位移", $"{FormatDisplacement(_parameterConfig.SpeedTorqueManualDisplacement)} mm", "mm"),
- CreateRecordPoint("转速/扭矩实时测试", "扭矩系数", FormatConfigNumber(_parameterConfig.TorqueCoefficient), string.Empty),
- CreateRecordPoint("转速/扭矩实时测试", "扭矩保护", $"{FormatTorque(_parameterConfig.TorqueProtection)} {TorqueUnit}", TorqueUnit),
- CreateRecordPoint("转速/扭矩实时测试", "保持扭矩设置", $"{FormatTorque(_parameterConfig.HoldTorque)} {TorqueUnit}", TorqueUnit),
- CreateRecordPoint("转速/扭矩实时测试", "扭矩保持时间设置", $"{FormatConfigNumber(_parameterConfig.TorqueHoldTime)} s", "s"),
- CreateRecordPoint("转速/扭矩实时测试", "转速系数", FormatConfigNumber(_parameterConfig.SpeedCoefficient), string.Empty),
- CreateRecordPoint("转速/扭矩实时测试", "低速停止设置", $"{FormatSpeed(_parameterConfig.SpeedStopThreshold)} r/min", "r/min"),
- CreateRecordPoint("转速/扭矩实时测试", "压力系数", FormatConfigNumber(_parameterConfig.PressureCoefficient), string.Empty),
- CreateRecordPoint("转速/扭矩实时测试", "实时压力", $"{FormatPressure(_realtimePressure)} MPa", "MPa"),
- CreateRecordPoint("转速/扭矩实时测试", "最大扭矩采集", $"{FormatTorque(_speedTorquePeakTorque)} {TorqueUnit}", TorqueUnit),
- CreateRecordPoint("转速/扭矩实时测试", "最大位移", $"{FormatDisplacement(_maxSpeedTorqueDisplacement)} mm", "mm"),
- CreateRecordPoint("转速/扭矩实时测试", "最终位移", _finalSpeedTorqueDisplacement.HasValue ? $"{FormatDisplacement(_finalSpeedTorqueDisplacement.Value)} mm" : "--", "mm", _finalSpeedTorqueDisplacement.HasValue ? "记录" : "待停止"),
- CreateRecordPoint("转速/扭矩实时测试", "最终转速", _finalSpeed.HasValue ? $"{FormatSpeed(_finalSpeed.Value)} r/min" : "--", "r/min", _finalSpeed.HasValue ? "记录" : "待停止"),
- CreateRecordPoint("转速/扭矩实时测试", "最终扭矩", _finalTorque.HasValue ? $"{FormatTorque(_finalTorque.Value)} {TorqueUnit}" : "--", TorqueUnit, _finalTorque.HasValue ? "记录" : "待停止"),
+ CreateRecordPoint("转速/扭矩实时测试", "位移极限", $"{FormatDisplacement(parameters.SpeedTorqueDisplacementLimit)} mm", "mm"),
+ CreateRecordPoint("转速/扭矩实时测试", "手/自动速度", $"{FormatSpeedSetting(parameters.SpeedTorqueSpeed)} mm/min", "mm/min"),
+ CreateRecordPoint("转速/扭矩实时测试", "手动位移", $"{FormatDisplacement(parameters.SpeedTorqueManualDisplacement)} mm", "mm"),
+ CreateRecordPoint("转速/扭矩实时测试", "扭矩系数", FormatConfigNumber(parameters.TorqueCoefficient), string.Empty),
+ CreateRecordPoint("转速/扭矩实时测试", "扭矩保护", $"{FormatTorque(parameters.TorqueProtection)} {TorqueUnit}", TorqueUnit),
+ CreateRecordPoint("转速/扭矩实时测试", "保持扭矩设置", $"{FormatTorque(parameters.HoldTorque)} {TorqueUnit}", TorqueUnit),
+ CreateRecordPoint("转速/扭矩实时测试", "扭矩保持时间设置", $"{FormatConfigNumber(parameters.TorqueHoldTime)} s", "s"),
+ CreateRecordPoint("转速/扭矩实时测试", "转速系数", FormatConfigNumber(parameters.SpeedCoefficient), string.Empty),
+ CreateRecordPoint("转速/扭矩实时测试", "低速停止设置", $"{FormatSpeed(parameters.SpeedStopThreshold)} r/min", "r/min"),
+ CreateRecordPoint("转速/扭矩实时测试", "压力系数", FormatConfigNumber(parameters.PressureCoefficient), string.Empty),
+ CreateRecordPoint("转速/扭矩实时测试", "末次采样转速", $"{FormatSpeed(lastSample?.RealtimeSpeedRpm ?? _realtimeSpeed)} r/min", "r/min"),
+ CreateRecordPoint("转速/扭矩实时测试", "末次采样扭矩", $"{FormatTorque(lastSample?.RealtimeTorqueMilliNewtonMeters ?? GetScaledTorque())} {TorqueUnit}", TorqueUnit),
+ CreateRecordPoint("转速/扭矩实时测试", "末次采样压力", $"{FormatPressure(lastSample?.RealtimePressureMpa ?? _realtimePressure)} MPa", "MPa"),
+ CreateRecordPoint("转速/扭矩实时测试", "最大扭矩采集", $"{FormatTorque(peakTorque)} {TorqueUnit}", TorqueUnit),
+ CreateRecordPoint("转速/扭矩实时测试", "最大位移", $"{FormatDisplacement(maxDisplacement)} mm", "mm"),
+ CreateRecordPoint("转速/扭矩实时测试", "最终位移", finalDisplacement.HasValue ? $"{FormatDisplacement(finalDisplacement.Value)} mm" : "--", "mm", finalDisplacement.HasValue ? "记录" : "待停止"),
+ CreateRecordPoint("转速/扭矩实时测试", "最终转速", finalSpeed.HasValue ? $"{FormatSpeed(finalSpeed.Value)} r/min" : "--", "r/min", finalSpeed.HasValue ? "记录" : "待停止"),
+ CreateRecordPoint("转速/扭矩实时测试", "最终扭矩", finalTorque.HasValue ? $"{FormatTorque(finalTorque.Value)} {TorqueUnit}" : "--", TorqueUnit, finalTorque.HasValue ? "记录" : "待停止"),
CreateRecordPoint("转速/扭矩实时测试", "保持时间曲线判定", curve.Result, string.Empty, "记录"),
CreateRecordPoint("转速/扭矩实时测试", "保持时间内最小扭矩", curve.Samples.Count >= 2 ? $"{FormatTorque(curve.MinTorqueMilliNewtonMeters)} {TorqueUnit}" : "--", TorqueUnit),
CreateRecordPoint("转速/扭矩实时测试", "保持时间内最大扭矩", curve.Samples.Count >= 2 ? $"{FormatTorque(curve.MaxTorqueMilliNewtonMeters)} {TorqueUnit}" : "--", TorqueUnit),
@@ -1174,8 +1210,35 @@ public sealed class MainWindowViewModel : ObservableObject
};
}
+ private ProjectPayload CreateNoLoadSpeedProject()
+ {
+ TestRunPayload? run = GetLatestCompletedRun("空载转速测试")
+ ?? _activeNoLoadSpeedRun;
+ TestParameterConfig parameters = run?.ParameterSnapshot ?? _parameterConfig;
+ return new ProjectPayload
+ {
+ Name = "空载转速测试",
+ Requirement = "记录 PLC 空载转速及转速误差率",
+ Result = run is null ? "待记录" : "记录",
+ Points =
+ [
+ CreateRecordPoint("空载转速测试", "空载转速设置", $"{FormatSpeedSetting(parameters.NoLoadSpeedSetting)} r/min", "r/min"),
+ CreateRecordPoint("空载转速测试", "空载转速记录", run?.NoLoadSpeedRpm is double speed ? $"{FormatSpeed(speed)} r/min" : "--", "r/min", run is null ? "待记录" : "记录"),
+ CreateRecordPoint("空载转速测试", "转速误差率", run?.NoLoadSpeedErrorRatePercent is double errorRate ? $"{FormatErrorRate(errorRate)} %" : "--", "%", run is null ? "待记录" : "记录")
+ ]
+ };
+ }
+
private ProjectPayload CreateDisplacementProject()
{
+ TestRunPayload? run = GetLatestCompletedRun("轴向位移动量测试");
+ TestParameterConfig parameters = run?.ParameterSnapshot ?? _parameterConfig;
+ RealtimeSamplePayload? lastSample = run?.Samples.LastOrDefault();
+ double maxDisplacement = run?.Samples.Count > 0
+ ? run.Samples.Max(static sample => Math.Abs(sample.RelativeDisplacementMm))
+ : _maxDisplacement;
+ double? finalDisplacement = run?.FinalDisplacementMm ?? _finalDisplacement;
+ double? finalAxialForce = run?.FinalAxialForceN ?? _finalAxialForce;
return new ProjectPayload
{
Name = "轴向位移动量测试",
@@ -1183,28 +1246,33 @@ public sealed class MainWindowViewModel : ObservableObject
Result = "记录",
Points =
[
- CreateRecordPoint("轴向位移动量测试", "零点读数", $"{FormatDisplacement(_dialZero)} mm", "mm"),
- CreateRecordPoint("轴向位移动量测试", "2号当前位置", $"{FormatDisplacement(_axialAxisPosition)} mm", "mm"),
- CreateRecordPoint("轴向位移动量测试", "采集数据1-1", $"{FormatDisplacement(_axialSampleStart)} mm", "mm"),
- CreateRecordPoint("轴向位移动量测试", "采集数据1-2", $"{FormatDisplacement(_axialSampleEnd)} mm", "mm"),
- CreateRecordPoint("轴向位移动量测试", "数据差值1", $"{FormatDisplacement(_axialSampleDifference)} mm", "mm"),
- CreateRecordPoint("轴向位移动量测试", "最大位移", $"{FormatDisplacement(_maxDisplacement)} mm", "mm"),
- CreateRecordPoint("轴向位移动量测试", "最终位移", _finalDisplacement.HasValue ? $"{FormatDisplacement(_finalDisplacement.Value)} mm" : "--", "mm", _finalDisplacement.HasValue ? "记录" : "待停止"),
- CreateRecordPoint("轴向位移动量测试", "位移极限", $"{FormatDisplacement(_parameterConfig.AxialDisplacementLimit)} mm", "mm"),
- CreateRecordPoint("轴向位移动量测试", "手/自动速度", $"{FormatSpeedSetting(_parameterConfig.AxialSpeed)} mm/min", "mm/min"),
- CreateRecordPoint("轴向位移动量测试", "手动位移", $"{FormatDisplacement(_parameterConfig.AxialManualDisplacement)} mm", "mm"),
- CreateRecordPoint("轴向位移动量测试", "轴向力下限", $"{FormatForce(_parameterConfig.AxialForceLowerLimit)} N", "N"),
- CreateRecordPoint("轴向位移动量测试", "轴向力上限", $"{FormatForce(_parameterConfig.AxialForceUpperLimit)} N", "N"),
- CreateRecordPoint("轴向位移动量测试", "轴向力系数", FormatConfigNumber(_parameterConfig.AxialForceCoefficient), string.Empty),
- CreateRecordPoint("轴向位移动量测试", "轴向跳动力设置", $"{FormatForce(_parameterConfig.AxialJumpForceSetpoint)} N", "N", _parameterConfig.UseAxialPullForceSetpoint ? "备用" : "当前"),
- CreateRecordPoint("轴向位移动量测试", "轴向拉力设置", $"{FormatForce(_parameterConfig.AxialForceSetpoint)} N", "N", _parameterConfig.UseAxialPullForceSetpoint ? "当前" : "备用"),
- CreateRecordPoint("轴向位移动量测试", "轴向力保护", $"{FormatForce(_parameterConfig.AxialForceProtection)} N", "N"),
- CreateRecordPoint("轴向位移动量测试", "轴向力保持时间设置", $"{FormatConfigNumber(_parameterConfig.AxialForceHoldTime)} s", "s"),
- CreateRecordPoint("轴向位移动量测试", "最终轴向力", _finalAxialForce.HasValue ? $"{FormatForce(_finalAxialForce.Value)} N" : "--", "N", _finalAxialForce.HasValue ? "记录" : "待停止")
+ CreateRecordPoint("轴向位移动量测试", "零点读数", $"{FormatDisplacement((lastSample?.DialIndicatorMm ?? _dialZero + _relativeDisplacement) - (lastSample?.RelativeDisplacementMm ?? _relativeDisplacement))} mm", "mm"),
+ CreateRecordPoint("轴向位移动量测试", "2号当前位置", $"{FormatDisplacement(lastSample?.AxialAxisPositionMm ?? _axialAxisPosition)} mm", "mm"),
+ CreateRecordPoint("轴向位移动量测试", "采集数据1-1", $"{FormatDisplacement(lastSample?.AxialSampleStartMm ?? _axialSampleStart)} mm", "mm"),
+ CreateRecordPoint("轴向位移动量测试", "采集数据1-2", $"{FormatDisplacement(lastSample?.AxialSampleEndMm ?? _axialSampleEnd)} mm", "mm"),
+ CreateRecordPoint("轴向位移动量测试", "数据差值1", $"{FormatDisplacement(lastSample?.AxialSampleDifferenceMm ?? _axialSampleDifference)} mm", "mm"),
+ CreateRecordPoint("轴向位移动量测试", "最大位移", $"{FormatDisplacement(maxDisplacement)} mm", "mm"),
+ CreateRecordPoint("轴向位移动量测试", "最终位移", finalDisplacement.HasValue ? $"{FormatDisplacement(finalDisplacement.Value)} mm" : "--", "mm", finalDisplacement.HasValue ? "记录" : "待停止"),
+ CreateRecordPoint("轴向位移动量测试", "位移极限", $"{FormatDisplacement(parameters.AxialDisplacementLimit)} mm", "mm"),
+ CreateRecordPoint("轴向位移动量测试", "手/自动速度", $"{FormatSpeedSetting(parameters.AxialSpeed)} mm/min", "mm/min"),
+ CreateRecordPoint("轴向位移动量测试", "手动位移", $"{FormatDisplacement(parameters.AxialManualDisplacement)} mm", "mm"),
+ CreateRecordPoint("轴向位移动量测试", "轴向力下限", $"{FormatForce(parameters.AxialForceLowerLimit)} N", "N"),
+ CreateRecordPoint("轴向位移动量测试", "轴向力上限", $"{FormatForce(parameters.AxialForceUpperLimit)} N", "N"),
+ CreateRecordPoint("轴向位移动量测试", "轴向力系数", FormatConfigNumber(parameters.AxialForceCoefficient), string.Empty),
+ CreateRecordPoint("轴向位移动量测试", "轴向跳动力设置", $"{FormatForce(parameters.AxialJumpForceSetpoint)} N", "N", parameters.UseAxialPullForceSetpoint ? "备用" : "当前"),
+ CreateRecordPoint("轴向位移动量测试", "轴向拉力设置", $"{FormatForce(parameters.AxialForceSetpoint)} N", "N", parameters.UseAxialPullForceSetpoint ? "当前" : "备用"),
+ CreateRecordPoint("轴向位移动量测试", "轴向力保护", $"{FormatForce(parameters.AxialForceProtection)} N", "N"),
+ CreateRecordPoint("轴向位移动量测试", "轴向力保持时间设置", $"{FormatConfigNumber(parameters.AxialForceHoldTime)} s", "s"),
+ CreateRecordPoint("轴向位移动量测试", "最终轴向力", finalAxialForce.HasValue ? $"{FormatForce(finalAxialForce.Value)} N" : "--", "N", finalAxialForce.HasValue ? "记录" : "待停止")
]
};
}
+ private TestRunPayload? GetLatestCompletedRun(string testType)
+ {
+ return _completedRuns.LastOrDefault(run => run.TestType == testType);
+ }
+
private static PointPayload CreateRecordPoint(string projectName, string targetText, string measuredText, string unit, string result = "记录")
{
return new PointPayload
@@ -1219,34 +1287,28 @@ public sealed class MainWindowViewModel : ObservableObject
};
}
- private int SavePayload(AcceptancePayload payload, string exportPath)
+ private static void ExportPayloadAtomically(string exportPath, AcceptancePayload payload)
{
- using var connection = OpenConnection();
- using var command = connection.CreateCommand();
- command.CommandText =
- """
- INSERT INTO AcceptanceRecords (CreatedAt, SampleCode, OperatorName, OverallResult, PayloadJson, ExportPath)
- VALUES ($createdAt, $sampleCode, $operatorName, $overallResult, $payloadJson, $exportPath);
- SELECT last_insert_rowid();
- """;
- command.Parameters.AddWithValue("$createdAt", payload.CreatedAt.ToString("O", CultureInfo.InvariantCulture));
- command.Parameters.AddWithValue("$sampleCode", payload.SampleCode);
- command.Parameters.AddWithValue("$operatorName", payload.OperatorName);
- command.Parameters.AddWithValue("$overallResult", payload.OverallResult);
- command.Parameters.AddWithValue("$payloadJson", JsonSerializer.Serialize(payload));
- command.Parameters.AddWithValue("$exportPath", exportPath);
+ string exportDirectory = Path.GetDirectoryName(exportPath) ?? AppContext.BaseDirectory;
+ Directory.CreateDirectory(exportDirectory);
- return Convert.ToInt32(command.ExecuteScalar(), CultureInfo.InvariantCulture);
- }
+ string tempPath = Path.Combine(
+ exportDirectory,
+ $".{Path.GetFileNameWithoutExtension(exportPath)}.{Guid.NewGuid():N}.tmp.xlsx");
- private void UpdateExportPath(int id, string exportPath)
- {
- using var connection = OpenConnection();
- using var command = connection.CreateCommand();
- command.CommandText = "UPDATE AcceptanceRecords SET ExportPath = $exportPath WHERE Id = $id;";
- command.Parameters.AddWithValue("$exportPath", exportPath);
- command.Parameters.AddWithValue("$id", id);
- command.ExecuteNonQuery();
+ try
+ {
+ ExportPayload(tempPath, payload);
+ ValidateExportWorkbook(tempPath, payload);
+ File.Move(tempPath, exportPath, overwrite: true);
+ }
+ finally
+ {
+ if (File.Exists(tempPath))
+ {
+ File.Delete(tempPath);
+ }
+ }
}
private static void ExportPayload(string exportPath, AcceptancePayload payload)
@@ -1268,6 +1330,12 @@ public sealed class MainWindowViewModel : ObservableObject
sheet.Cell(3, 2).Value = payload.CreatedAt.ToString("yyyy-MM-dd HH:mm:ss", CultureInfo.InvariantCulture);
sheet.Cell(4, 1).Value = "整体验收";
sheet.Cell(4, 2).Value = payload.OverallResult;
+ sheet.Cell(2, 4).Value = "会话编号";
+ sheet.Cell(2, 5).Value = payload.SessionId;
+ sheet.Cell(3, 4).Value = "最后更新";
+ sheet.Cell(3, 5).Value = payload.LastUpdatedAt.ToString("yyyy-MM-dd HH:mm:ss", CultureInfo.InvariantCulture);
+ sheet.Cell(4, 4).Value = "完整采样数";
+ sheet.Cell(4, 5).Value = payload.Runs.Sum(static run => run.Samples.Count);
int row = 6;
foreach (var project in payload.Projects)
@@ -1304,10 +1372,247 @@ public sealed class MainWindowViewModel : ObservableObject
}
sheet.Columns().AdjustToContents();
+ AddTestRunsSheet(workbook, payload);
+ AddRealtimeDataSheet(workbook, payload);
+ AddParameterSnapshotSheet(workbook, payload);
AddTorqueCurveSheet(workbook, payload);
workbook.SaveAs(exportPath);
}
+ private static void AddTestRunsSheet(XLWorkbook workbook, AcceptancePayload payload)
+ {
+ var sheet = workbook.Worksheets.Add("测试运行记录");
+ sheet.Cell(1, 1).Value = "测试运行记录";
+ sheet.Range(1, 1, 1, 14).Merge().Style.Font.SetBold().Font.SetFontSize(16);
+
+ string[] headers =
+ [
+ "运行编号", "测试类型", "开始时间", "完成时间", "完成状态", "采样数",
+ "最终位移(mm)", "最终轴向力(N)", "最终转速(r/min)", $"最终扭矩({TorqueUnit})",
+ "空载转速(r/min)", "转速误差率(%)", "参数快照时间", "数据来源"
+ ];
+ WriteHeaderRow(sheet, 2, headers);
+
+ for (int i = 0; i < payload.Runs.Count; i++)
+ {
+ TestRunPayload run = payload.Runs[i];
+ int row = 3 + i;
+ sheet.Cell(row, 1).Value = run.RunId;
+ sheet.Cell(row, 2).Value = run.TestType;
+ sheet.Cell(row, 3).Value = run.StartedAt.ToString("yyyy-MM-dd HH:mm:ss.fff", CultureInfo.InvariantCulture);
+ sheet.Cell(row, 4).Value = run.CompletedAt?.ToString("yyyy-MM-dd HH:mm:ss.fff", CultureInfo.InvariantCulture) ?? string.Empty;
+ sheet.Cell(row, 5).Value = run.CompletionStatus;
+ sheet.Cell(row, 6).Value = run.Samples.Count;
+ SetOptionalNumber(sheet.Cell(row, 7), run.FinalDisplacementMm);
+ SetOptionalNumber(sheet.Cell(row, 8), run.FinalAxialForceN);
+ SetOptionalNumber(sheet.Cell(row, 9), run.FinalSpeedRpm);
+ SetOptionalNumber(sheet.Cell(row, 10), run.FinalTorqueMilliNewtonMeters);
+ SetOptionalNumber(sheet.Cell(row, 11), run.NoLoadSpeedRpm);
+ SetOptionalNumber(sheet.Cell(row, 12), run.NoLoadSpeedErrorRatePercent);
+ sheet.Cell(row, 13).Value = run.StartedAt.ToString("yyyy-MM-dd HH:mm:ss.fff", CultureInfo.InvariantCulture);
+ sheet.Cell(row, 14).Value = "PLC实时采样 + 测试停止最终值";
+ }
+
+ sheet.SheetView.FreezeRows(2);
+ sheet.Columns().AdjustToContents();
+ }
+
+ private static void AddRealtimeDataSheet(XLWorkbook workbook, AcceptancePayload payload)
+ {
+ var sheet = workbook.Worksheets.Add("完整实时数据");
+ sheet.Cell(1, 1).Value = "完整实时数据(测试运行期间每次有效 PLC 轮询均记录)";
+ sheet.Range(1, 1, 1, 25).Merge().Style.Font.SetBold().Font.SetFontSize(16);
+
+ string[] headers =
+ [
+ "运行编号", "测试类型", "采样序号", "采样时间",
+ "千分表显示(mm)", "相对位移(mm)", "2号当前位置(mm)",
+ "采集数据1-1(mm)", "采集数据1-2(mm)", "数据差值1(mm)",
+ "轴向力显示(N)", "1号当前位置(mm)", "1号相对位移(mm)",
+ $"最大扭矩采集({TorqueUnit})", $"扭矩显示({TorqueUnit})",
+ "转速显示(r/min)", "压力显示(MPa)", "空载转速记录(r/min)",
+ "转速误差率(%)", "扭矩完成", "复位使能1号", "复位完成1号",
+ "复位使能2号", "复位完成2号", "参数快照"
+ ];
+ WriteHeaderRow(sheet, 2, headers);
+
+ int row = 3;
+ foreach (TestRunPayload run in payload.Runs)
+ {
+ foreach (RealtimeSamplePayload sample in run.Samples)
+ {
+ sheet.Cell(row, 1).Value = run.RunId;
+ sheet.Cell(row, 2).Value = run.TestType;
+ sheet.Cell(row, 3).Value = sample.Sequence;
+ sheet.Cell(row, 4).Value = sample.SampledAt.ToString("yyyy-MM-dd HH:mm:ss.fff", CultureInfo.InvariantCulture);
+ sheet.Cell(row, 5).Value = sample.DialIndicatorMm;
+ sheet.Cell(row, 6).Value = sample.RelativeDisplacementMm;
+ sheet.Cell(row, 7).Value = sample.AxialAxisPositionMm;
+ sheet.Cell(row, 8).Value = sample.AxialSampleStartMm;
+ sheet.Cell(row, 9).Value = sample.AxialSampleEndMm;
+ sheet.Cell(row, 10).Value = sample.AxialSampleDifferenceMm;
+ sheet.Cell(row, 11).Value = sample.AxialForceN;
+ sheet.Cell(row, 12).Value = sample.SpeedTorqueAxisPositionMm;
+ sheet.Cell(row, 13).Value = sample.SpeedTorqueDisplacementMm;
+ sheet.Cell(row, 14).Value = sample.SpeedTorquePeakTorqueMilliNewtonMeters;
+ sheet.Cell(row, 15).Value = sample.RealtimeTorqueMilliNewtonMeters;
+ sheet.Cell(row, 16).Value = sample.RealtimeSpeedRpm;
+ sheet.Cell(row, 17).Value = sample.RealtimePressureMpa;
+ sheet.Cell(row, 18).Value = sample.NoLoadSpeedRpm;
+ sheet.Cell(row, 19).Value = sample.NoLoadSpeedErrorRatePercent;
+ sheet.Cell(row, 20).Value = sample.SpeedTorqueDone ? 1 : 0;
+ sheet.Cell(row, 21).Value = sample.SpeedTorqueResetEnabled ? 1 : 0;
+ sheet.Cell(row, 22).Value = sample.SpeedTorqueResetDone ? 1 : 0;
+ sheet.Cell(row, 23).Value = sample.AxialResetEnabled ? 1 : 0;
+ sheet.Cell(row, 24).Value = sample.AxialResetDone ? 1 : 0;
+ sheet.Cell(row, 25).Value = run.StartedAt.ToString("yyyy-MM-dd HH:mm:ss.fff", CultureInfo.InvariantCulture);
+ row++;
+ }
+ }
+
+ sheet.SheetView.FreezeRows(2);
+ sheet.SheetView.FreezeColumns(4);
+ sheet.Columns().AdjustToContents();
+ }
+
+ private static void AddParameterSnapshotSheet(XLWorkbook workbook, AcceptancePayload payload)
+ {
+ var sheet = workbook.Worksheets.Add("参数快照");
+ sheet.Cell(1, 1).Value = "参数快照(含导出时参数及每次测试运行参数)";
+ sheet.Range(1, 1, 1, 4).Merge().Style.Font.SetBold().Font.SetFontSize(16);
+ WriteHeaderRow(sheet, 2, ["分类", "参数", "值", "单位"]);
+
+ int row = 3;
+ sheet.Cell(row, 1).Value = "导出时参数";
+ sheet.Range(row, 1, row, 4).Merge().Style.Font.SetBold().Fill.SetBackgroundColor(XLColor.FromHtml("#ECFDF5"));
+ row = WriteParameterRows(sheet, row + 1, payload.ParameterSnapshot);
+
+ foreach (TestRunPayload run in payload.Runs)
+ {
+ row++;
+ sheet.Cell(row, 1).Value = $"运行参数:{run.TestType} / {run.RunId} / {run.StartedAt:yyyy-MM-dd HH:mm:ss.fff}";
+ sheet.Range(row, 1, row, 4).Merge().Style.Font.SetBold().Fill.SetBackgroundColor(XLColor.FromHtml("#F1F5F9"));
+ row = WriteParameterRows(sheet, row + 1, run.ParameterSnapshot);
+ }
+
+ sheet.SheetView.FreezeRows(2);
+ sheet.Columns().AdjustToContents();
+ }
+
+ private static int WriteParameterRows(IXLWorksheet sheet, int startRow, TestParameterConfig p)
+ {
+ (string Category, string Name, object Value, string Unit)[] rows =
+ [
+ ("轴向", "位移极限", p.AxialDisplacementLimit, "mm"),
+ ("轴向", "手/自动速度", p.AxialSpeed, "mm/min"),
+ ("轴向", "手动位移", p.AxialManualDisplacement, "mm"),
+ ("轴向", "轴向力下限", p.AxialForceLowerLimit, "N"),
+ ("轴向", "轴向力上限", p.AxialForceUpperLimit, "N"),
+ ("轴向", "轴向力系数", p.AxialForceCoefficient, string.Empty),
+ ("轴向", "轴向拉力设置", p.AxialForceSetpoint, "N"),
+ ("轴向", "轴向跳动力设置", p.AxialJumpForceSetpoint, "N"),
+ ("轴向", "当前轴向力模式", p.UseAxialPullForceSetpoint ? "轴向拉力" : "轴向跳动力", string.Empty),
+ ("轴向", "轴向力保护", p.AxialForceProtection, "N"),
+ ("轴向", "轴向力保持时间", p.AxialForceHoldTime, "s"),
+ ("转速/扭矩", "位移极限", p.SpeedTorqueDisplacementLimit, "mm"),
+ ("转速/扭矩", "手/自动速度", p.SpeedTorqueSpeed, "mm/min"),
+ ("转速/扭矩", "手动位移", p.SpeedTorqueManualDisplacement, "mm"),
+ ("转速/扭矩", "扭矩系数", p.TorqueCoefficient, string.Empty),
+ ("转速/扭矩", "扭矩保护", p.TorqueProtection, TorqueUnit),
+ ("转速/扭矩", "保持扭矩", p.HoldTorque, TorqueUnit),
+ ("转速/扭矩", "扭矩保持时间", p.TorqueHoldTime, "s"),
+ ("转速/扭矩", "转速系数", p.SpeedCoefficient, string.Empty),
+ ("转速/扭矩", "低速停止", p.SpeedStopThreshold, "r/min"),
+ ("转速/扭矩", "压力系数", p.PressureCoefficient, string.Empty),
+ ("空载转速", "空载转速设置", p.NoLoadSpeedSetting, "r/min"),
+ ("通信", "PLC IP", p.PlcIpAddress, string.Empty),
+ ("通信", "PLC端口", p.PlcPort, string.Empty),
+ ("通信", "PLC站号", p.PlcUnitId, string.Empty),
+ ("通信", "PLC脉冲时间", p.PlcPulseMilliseconds, "ms"),
+ ("通信", "PLC超时时间", p.PlcTimeoutMilliseconds, "ms"),
+ ("通信", "浮点字序", p.FloatWordOrder.ToString(), string.Empty)
+ ];
+
+ for (int i = 0; i < rows.Length; i++)
+ {
+ int row = startRow + i;
+ sheet.Cell(row, 1).Value = rows[i].Category;
+ sheet.Cell(row, 2).Value = rows[i].Name;
+ SetObjectValue(sheet.Cell(row, 3), rows[i].Value);
+ sheet.Cell(row, 4).Value = rows[i].Unit;
+ }
+
+ return startRow + rows.Length;
+ }
+
+ private static void ValidateExportWorkbook(string path, AcceptancePayload payload)
+ {
+ if (!File.Exists(path) || new FileInfo(path).Length == 0)
+ {
+ throw new InvalidOperationException("报表文件未成功写入。");
+ }
+
+ using var workbook = new XLWorkbook(path);
+ string[] requiredSheets = ["验收记录", "测试运行记录", "完整实时数据", "参数快照"];
+ foreach (string sheetName in requiredSheets)
+ {
+ if (!workbook.TryGetWorksheet(sheetName, out _))
+ {
+ throw new InvalidOperationException($"报表校验失败:缺少工作表“{sheetName}”。");
+ }
+ }
+
+ int expectedRunCount = payload.Runs.Count;
+ int expectedSampleCount = payload.Runs.Sum(static run => run.Samples.Count);
+ IXLWorksheet runsSheet = workbook.Worksheet("测试运行记录");
+ IXLWorksheet samplesSheet = workbook.Worksheet("完整实时数据");
+ int actualRunCount = Math.Max(0, (runsSheet.LastRowUsed()?.RowNumber() ?? 2) - 2);
+ int actualSampleCount = Math.Max(0, (samplesSheet.LastRowUsed()?.RowNumber() ?? 2) - 2);
+ if (actualRunCount != expectedRunCount || actualSampleCount != expectedSampleCount)
+ {
+ throw new InvalidOperationException(
+ $"报表校验失败:测试运行 {actualRunCount}/{expectedRunCount},完整采样 {actualSampleCount}/{expectedSampleCount}。");
+ }
+ }
+
+ private static void WriteHeaderRow(IXLWorksheet sheet, int row, IReadOnlyList headers)
+ {
+ for (int i = 0; i < headers.Count; i++)
+ {
+ sheet.Cell(row, i + 1).Value = headers[i];
+ }
+
+ sheet.Range(row, 1, row, headers.Count).Style.Fill.SetBackgroundColor(XLColor.FromHtml("#D9EAF7"));
+ sheet.Range(row, 1, row, headers.Count).Style.Font.SetBold();
+ }
+
+ private static void SetOptionalNumber(IXLCell cell, double? value)
+ {
+ if (value.HasValue)
+ {
+ cell.Value = value.Value;
+ }
+ }
+
+ private static void SetObjectValue(IXLCell cell, object value)
+ {
+ switch (value)
+ {
+ case double doubleValue:
+ cell.Value = doubleValue;
+ break;
+ case int intValue:
+ cell.Value = intValue;
+ break;
+ case string stringValue:
+ cell.Value = stringValue;
+ break;
+ default:
+ cell.Value = Convert.ToString(value, CultureInfo.InvariantCulture) ?? string.Empty;
+ break;
+ }
+ }
+
private static void AddTorqueCurveSheet(XLWorkbook workbook, AcceptancePayload payload)
{
TorqueCurvePayload? curve = payload.Projects
@@ -1353,11 +1658,19 @@ public sealed class MainWindowViewModel : ObservableObject
if (curve.Samples.Count > 0)
{
- byte[] imageBytes = CreateTorqueCurveImage(curve, 760, 360);
- var imageStream = new MemoryStream(imageBytes);
- var picture = sheet.AddPicture(imageStream, "TorqueCurve").MoveTo(sheet.Cell(3, 4));
- picture.Width = 760;
- picture.Height = 360;
+ try
+ {
+ byte[] imageBytes = CreateTorqueCurveImage(curve, 760, 360);
+ var imageStream = new MemoryStream(imageBytes);
+ var picture = sheet.AddPicture(imageStream, "TorqueCurve").MoveTo(sheet.Cell(3, 4));
+ picture.Width = 760;
+ picture.Height = 360;
+ }
+ catch (Exception ex)
+ {
+ sheet.Cell(3, 4).Value = "曲线图生成失败,原始曲线数据已完整保留";
+ Log.Warning(ex, "扭矩曲线图片生成失败,报表继续保留原始曲线数据");
+ }
}
else
{
@@ -1488,6 +1801,7 @@ public sealed class MainWindowViewModel : ObservableObject
if (!TryReadParameterConfig(out TestParameterConfig config, out string error))
{
ParameterStatusText = error;
+ Log.Warning("参数保存被阻止:{ValidationError}", error);
return;
}
@@ -1504,10 +1818,17 @@ public sealed class MainWindowViewModel : ObservableObject
_hasLoadedParameterConfigFromPlc = true;
_parameterRetryTimer.Stop();
ParameterStatusText = "PLC配置写入成功";
+ Log.Information(
+ "PLC参数配置写入成功,地址 {IpAddress}:{Port},站号 {UnitId},轴向力模式 {AxialForceMode}",
+ config.PlcIpAddress,
+ config.PlcPort,
+ config.PlcUnitId,
+ config.UseAxialPullForceSetpoint ? "轴向拉力" : "轴向跳动力");
}
catch (Exception ex)
{
ParameterStatusText = $"PLC配置写入失败:{ex.Message}";
+ Log.Error(ex, "PLC参数配置写入失败");
}
}
@@ -1611,6 +1932,7 @@ public sealed class MainWindowViewModel : ObservableObject
SpeedCoefficient = speedCoefficient,
SpeedStopThreshold = speedStopThreshold,
PressureCoefficient = pressureCoefficient,
+ NoLoadSpeedSetting = _parameterConfig.NoLoadSpeedSetting,
PlcIpAddress = PlcIpAddressInput.Trim(),
PlcPort = plcPort,
PlcUnitId = plcUnitId,
@@ -1659,11 +1981,13 @@ public sealed class MainWindowViewModel : ObservableObject
ApplyActiveAxialForceSetpointInput();
UpdateParameterSummaries();
ParameterStatusText = $"已切换到{GetActiveAxialForceSetpointName()},M30={(usePullForceSetpoint ? 1 : 0)}";
+ Log.Information("轴向力模式切换成功,M{CoilAddress}={Value},当前模式 {Mode}", AxialForceModeCoil, usePullForceSetpoint ? 1 : 0, GetActiveAxialForceSetpointName());
await AutoStopIfSetpointReachedAsync();
}
catch (Exception ex)
{
ParameterStatusText = $"轴向力切换失败:{ex.Message}";
+ Log.Error(ex, "轴向力模式切换失败,M{CoilAddress}={Value}", AxialForceModeCoil, usePullForceSetpoint ? 1 : 0);
}
}
@@ -1692,6 +2016,7 @@ public sealed class MainWindowViewModel : ObservableObject
if (!TryReadNonNegative(NoLoadSpeedSettingInput, "空载转速设置", out double setting, out string error))
{
NoLoadSpeedStatusText = $"状态:{error}";
+ Log.Warning("空载转速设置被阻止:{ValidationError}", error);
return;
}
@@ -1700,28 +2025,48 @@ public sealed class MainWindowViewModel : ObservableObject
PlcConnectionConfig config = _parameterConfig.ToPlcConnectionConfig();
await _plcRegisterService.WriteFloatAsync(config, NoLoadSpeedSettingRegister, (float)setting);
float confirmedSetting = await _plcRegisterService.ReadFloatAsync(config, NoLoadSpeedSettingRegister);
+ _parameterConfig = WithNoLoadSpeedSetting(confirmedSetting);
+ SaveParameterConfig();
NoLoadSpeedSettingInput = FormatConfigNumber(confirmedSetting);
NoLoadSpeedStatusText = $"状态:空载转速设置已写入 D{NoLoadSpeedSettingRegister}";
+ Log.Information("空载转速设置写入并回读成功,D{RegisterAddress}={Value}", NoLoadSpeedSettingRegister, confirmedSetting);
}
catch (Exception ex)
{
NoLoadSpeedStatusText = $"状态:空载转速设置写入失败:{ex.Message}";
+ Log.Error(ex, "空载转速设置写入失败,D{RegisterAddress}={Value}", NoLoadSpeedSettingRegister, setting);
}
}
private async Task RecordNoLoadSpeedAsync()
{
+ if (_activeNoLoadSpeedRun is not null)
+ {
+ NoLoadSpeedStatusText = "状态:空载转速正在记录,请稍候";
+ return;
+ }
+
if (!await PulsePlcAsync(NoLoadSpeedRecordCoil, "记录空载转速"))
{
NoLoadSpeedStatusText = "状态:记录指令发送失败";
return;
}
- NoLoadSpeedStatusText = $"状态:已触发 M{NoLoadSpeedRecordCoil},等待 PLC 更新记录";
+ PrepareSessionForNewRun();
+ _activeNoLoadSpeedRun = CreateTestRun("空载转速测试");
+ _noLoadCaptureDeadline = DateTime.Now.Add(NoLoadCaptureDuration);
+ NoLoadSpeedStatusText = $"状态:已触发 M{NoLoadSpeedRecordCoil},正在完整记录 PLC 更新";
+ UpdateDataCaptureStatus();
}
private async Task StartSpeedTorqueAsync()
{
+ if (_isSpeedTorqueRunning)
+ {
+ UpdateSpeedTorqueDisplay("状态:测试已在运行");
+ return;
+ }
+
if (!await PulsePlcAsync(SpeedTorqueStartCoil, "转速/扭矩启动"))
{
return;
@@ -1733,16 +2078,19 @@ public sealed class MainWindowViewModel : ObservableObject
return;
}
+ PrepareSessionForNewRun();
_isSpeedTorqueRunning = true;
_hasShownSpeedTorqueEndWarnings = false;
_finalSpeed = null;
_finalTorque = null;
_finalSpeedTorqueDisplacement = null;
_speedTorqueStartedAt = DateTime.Now;
+ _activeSpeedTorqueRun = CreateTestRun("转速/扭矩测试", _speedTorqueStartedAt.Value);
ClearTorqueSamples();
AppendTorqueSample(GetScaledTorque(), _speedTorqueStartedAt.Value);
_maxSpeedTorqueDisplacement = Math.Max(_maxSpeedTorqueDisplacement, Math.Abs(_speedTorqueDisplacement));
UpdateSpeedTorqueDisplay("状态:测试中");
+ Log.Information("转速/扭矩测试已启动,起始转速 {Speed},起始扭矩 {Torque},起始位移 {Displacement}", _realtimeSpeed, GetScaledTorque(), _speedTorqueDisplacement);
await AutoStopIfSpeedTorqueProtectionReachedAsync();
}
@@ -1779,6 +2127,8 @@ public sealed class MainWindowViewModel : ObservableObject
return;
}
+ FinalizeSpeedTorqueRun("复位前中止");
+ PersistCurrentPayloadSnapshot("转速/扭矩复位前");
_isSpeedTorqueRunning = false;
_realtimeSpeed = 0;
_realtimeTorque = 0;
@@ -1792,6 +2142,7 @@ public sealed class MainWindowViewModel : ObservableObject
_hasShownSpeedTorqueEndWarnings = false;
ClearTorqueSamples();
UpdateSpeedTorqueDisplay("状态:已复位");
+ Log.Information("转速/扭矩复位完成,零点 {ZeroPosition}", _speedTorqueZero);
}
finally
{
@@ -1822,6 +2173,12 @@ public sealed class MainWindowViewModel : ObservableObject
private async Task StartDisplacementAsync()
{
+ if (_isDisplacementRunning)
+ {
+ UpdateDisplacementDisplay("状态:测试已在运行");
+ return;
+ }
+
if (!await PulsePlcAsync(AxialStartCoil, "轴向启动"))
{
return;
@@ -1833,11 +2190,14 @@ public sealed class MainWindowViewModel : ObservableObject
return;
}
+ PrepareSessionForNewRun();
_isDisplacementRunning = true;
+ _activeDisplacementRun = CreateTestRun("轴向位移动量测试");
_finalDisplacement = null;
_finalAxialForce = null;
_maxDisplacement = Math.Abs(_relativeDisplacement);
UpdateDisplacementDisplay("状态:测试中");
+ Log.Information("轴向测试已启动,起始位移 {Displacement},起始轴向力 {AxialForce}", _relativeDisplacement, GetScaledAxialForce());
await AutoStopIfSetpointReachedAsync();
}
@@ -1874,6 +2234,8 @@ public sealed class MainWindowViewModel : ObservableObject
return;
}
+ FinalizeDisplacementRun("复位前中止");
+ PersistCurrentPayloadSnapshot("轴向复位前");
if (!TryGetDialValue(out double currentDial))
{
UpdateDisplacementDisplay("状态:读数无效");
@@ -1888,6 +2250,7 @@ public sealed class MainWindowViewModel : ObservableObject
await UpdateAxialForceFromInputAsync();
_isDisplacementRunning = false;
UpdateDisplacementDisplay("状态:已复位");
+ Log.Information("轴向复位完成,千分表零点 {DialZero}", _dialZero);
}
finally
{
@@ -1899,6 +2262,7 @@ public sealed class MainWindowViewModel : ObservableObject
private async Task WaitForResetDoneAsync(ushort enabledCoil, ushort doneCoil, string actionName, Action updateState)
{
DateTime deadline = DateTime.Now.AddSeconds(30);
+ bool hasLoggedReadFailure = false;
while (DateTime.Now < deadline)
{
try
@@ -1912,18 +2276,25 @@ public sealed class MainWindowViewModel : ObservableObject
updateState(enabled, done);
if (done)
{
+ Log.Information("PLC {ActionName}完成状态已确认,M{EnabledCoil}={Enabled},M{DoneCoil}=1", actionName, enabledCoil, enabled, doneCoil);
return true;
}
}
catch (Exception ex)
{
StatusText = $"PLC {actionName}状态读取失败:M{enabledCoil}/M{doneCoil},{ex.Message}";
+ if (!hasLoggedReadFailure)
+ {
+ hasLoggedReadFailure = true;
+ Log.Warning(ex, "PLC {ActionName}状态读取失败,M{EnabledCoil}/M{DoneCoil}", actionName, enabledCoil, doneCoil);
+ }
}
await Task.Delay(200);
}
StatusText = $"PLC {actionName}完成状态未确认:M{doneCoil} 未置位。";
+ Log.Warning("PLC {ActionName}完成状态超时未确认,M{DoneCoil}未置位", actionName, doneCoil);
return false;
}
@@ -1932,11 +2303,13 @@ public sealed class MainWindowViewModel : ObservableObject
try
{
await _plcCoilService.PulseCoilAsync(_parameterConfig.ToPlcConnectionConfig(), coilAddress);
+ Log.Information("PLC动作成功:{ActionName},M{CoilAddress}脉冲完成", actionName, coilAddress);
return true;
}
catch (Exception ex)
{
StatusText = $"PLC {actionName}失败:M{coilAddress},{ex.Message}";
+ Log.Error(ex, "PLC动作失败:{ActionName},M{CoilAddress}", actionName, coilAddress);
return false;
}
}
@@ -1968,11 +2341,13 @@ public sealed class MainWindowViewModel : ObservableObject
try
{
await _plcCoilService.WriteCoilAsync(_parameterConfig.ToPlcConnectionConfig(), coilAddress, value);
+ Log.Information("PLC手动动作成功:{ActionName},M{CoilAddress}={Value}", actionName, coilAddress, value ? 1 : 0);
return true;
}
catch (Exception ex)
{
StatusText = $"PLC {actionName}失败:M{coilAddress}={(value ? 1 : 0)},{ex.Message}";
+ Log.Error(ex, "PLC手动动作失败:{ActionName},M{CoilAddress}={Value}", actionName, coilAddress, value ? 1 : 0);
return false;
}
}
@@ -2060,6 +2435,7 @@ public sealed class MainWindowViewModel : ObservableObject
_isAutoStoppingDisplacement = true;
try
{
+ Log.Warning("轴向测试触发自动停止:{Status},位移 {Displacement},轴向力 {AxialForce}", status, _relativeDisplacement, GetScaledAxialForce());
if (await PulsePlcAsync(AxialStopCoil, "轴向保护停止"))
{
StopDisplacementTest(status);
@@ -2082,7 +2458,10 @@ public sealed class MainWindowViewModel : ObservableObject
_isDisplacementRunning = false;
_finalDisplacement = Math.Abs(_axialSampleDifference) > 0.000001 ? _axialSampleDifference : _relativeDisplacement;
_finalAxialForce = GetScaledAxialForce();
+ FinalizeDisplacementRun(status);
+ PersistCurrentPayloadSnapshot("轴向测试停止");
UpdateDisplacementDisplay(status);
+ Log.Information("轴向测试停止:{Status},最终位移 {FinalDisplacement},最终轴向力 {FinalAxialForce}", status, _finalDisplacement, _finalAxialForce);
}
private double GetScaledAxialForce()
@@ -2151,6 +2530,7 @@ public sealed class MainWindowViewModel : ObservableObject
_isAutoStoppingSpeedTorque = true;
try
{
+ Log.Warning("转速/扭矩测试触发自动停止:{Status},位移 {Displacement},转速 {Speed},扭矩 {Torque}", status, _speedTorqueDisplacement, _realtimeSpeed, GetScaledTorque());
if (await PulsePlcAsync(SpeedTorqueStopCoil, "转速/扭矩保护停止"))
{
await StopSpeedTorqueTestAsync(status);
@@ -2176,7 +2556,10 @@ public sealed class MainWindowViewModel : ObservableObject
_finalSpeed = speed;
_finalTorque = GetScaledTorque();
_finalSpeedTorqueDisplacement = _speedTorqueDisplacement;
+ FinalizeSpeedTorqueRun(status);
+ PersistCurrentPayloadSnapshot("转速/扭矩测试停止");
UpdateSpeedTorqueDisplay(status);
+ Log.Information("转速/扭矩测试停止:{Status},最终位移 {FinalDisplacement},最终转速 {FinalSpeed},最终扭矩 {FinalTorque}", status, _finalSpeedTorqueDisplacement, _finalSpeed, _finalTorque);
await ShowSpeedTorqueEndWarningsAsync();
}
@@ -2198,6 +2581,7 @@ public sealed class MainWindowViewModel : ObservableObject
if (!limitReached && !wearPlateWorn)
{
_hasShownSpeedTorqueEndWarnings = true;
+ Log.Information("转速/扭矩结束状态正常,M{LimitCoil}=0,M{WearCoil}=0", SpeedTorqueLimitWarningCoil, SpeedTorqueWearPlateWarningCoil);
return;
}
@@ -2213,6 +2597,7 @@ public sealed class MainWindowViewModel : ObservableObject
}
_hasShownSpeedTorqueEndWarnings = true;
+ Log.Warning("转速/扭矩结束提示:M{LimitCoil}={LimitReached},M{WearCoil}={WearPlateWorn}", SpeedTorqueLimitWarningCoil, limitReached ? 1 : 0, SpeedTorqueWearPlateWarningCoil, wearPlateWorn ? 1 : 0);
MessageBox.Show(
string.Join(Environment.NewLine, messages),
"转速/扭矩测试提示",
@@ -2222,6 +2607,7 @@ public sealed class MainWindowViewModel : ObservableObject
catch (Exception ex)
{
StatusText = $"转速/扭矩结束提示读取失败:M{SpeedTorqueLimitWarningCoil}/M{SpeedTorqueWearPlateWarningCoil},{ex.Message}";
+ Log.Error(ex, "转速/扭矩结束提示读取失败,M{LimitCoil}/M{WearCoil}", SpeedTorqueLimitWarningCoil, SpeedTorqueWearPlateWarningCoil);
}
}
@@ -2373,6 +2759,7 @@ public sealed class MainWindowViewModel : ObservableObject
ElapsedSeconds = elapsedSeconds,
TorqueMilliNewtonMeters = value
});
+ _cachedTorqueCurve = null;
int sampleLimit = GetTorqueSampleLimit();
while (TorqueSamples.Count > sampleLimit)
@@ -2386,12 +2773,12 @@ public sealed class MainWindowViewModel : ObservableObject
private void ClearTorqueSamples()
{
TorqueSamples.Clear();
+ _cachedTorqueCurve = null;
UpdateTorqueCurveStatus();
}
private void UpdateTorqueCurveStatus()
{
- TorqueCurvePayload curve = CreateTorqueCurvePayload();
if (_isSpeedTorqueRunning && _parameterConfig.TorqueHoldTime > 0 && TorqueSamples.Count > 0)
{
double elapsed = TorqueSamples[^1].ElapsedSeconds;
@@ -2402,11 +2789,17 @@ public sealed class MainWindowViewModel : ObservableObject
}
}
+ TorqueCurvePayload curve = CreateTorqueCurvePayload();
TorqueCurveStatusText = $"保持时间曲线:{curve.Result}";
}
private TorqueCurvePayload CreateTorqueCurvePayload()
{
+ if (_cachedTorqueCurve is not null)
+ {
+ return _cachedTorqueCurve;
+ }
+
List samples = TorqueSamples
.Where(sample => sample.ElapsedSeconds <= _parameterConfig.TorqueHoldTime)
.Select(sample => new TorqueSamplePayload
@@ -2419,40 +2812,93 @@ public sealed class MainWindowViewModel : ObservableObject
double threshold = GetTorqueChangeThreshold();
if (_parameterConfig.TorqueHoldTime <= 0)
{
- return new TorqueCurvePayload
+ _cachedTorqueCurve = new TorqueCurvePayload
{
HoldTimeSeconds = _parameterConfig.TorqueHoldTime,
ChangeThresholdMilliNewtonMeters = threshold,
Result = "未设置保持时间,未判定",
Samples = samples
};
+ return _cachedTorqueCurve;
+ }
+
+ if (samples.Count < 2)
+ {
+ _cachedTorqueCurve = new TorqueCurvePayload
+ {
+ HoldTimeSeconds = _parameterConfig.TorqueHoldTime,
+ ChangeThresholdMilliNewtonMeters = threshold,
+ Result = "采样不足,未判定",
+ Samples = samples
+ };
+ return _cachedTorqueCurve;
+ }
+
+ double min = samples.Min(sample => sample.TorqueMilliNewtonMeters);
+ double max = samples.Max(sample => sample.TorqueMilliNewtonMeters);
+ double average = samples.Average(sample => sample.TorqueMilliNewtonMeters);
+ double fluctuation = max - min;
+
+ _cachedTorqueCurve = new TorqueCurvePayload
+ {
+ HoldTimeSeconds = _parameterConfig.TorqueHoldTime,
+ ChangeThresholdMilliNewtonMeters = threshold,
+ MinTorqueMilliNewtonMeters = min,
+ MaxTorqueMilliNewtonMeters = max,
+ AverageTorqueMilliNewtonMeters = average,
+ FluctuationMilliNewtonMeters = fluctuation,
+ Result = fluctuation > threshold ? "有变化" : "无明显变化",
+ Samples = samples
+ };
+ return _cachedTorqueCurve;
+ }
+
+ private static TorqueCurvePayload CreateTorqueCurvePayload(TestRunPayload run)
+ {
+ double holdTime = run.ParameterSnapshot.TorqueHoldTime;
+ double threshold = Math.Max(MinimumTorqueChangeThreshold, Math.Abs(run.ParameterSnapshot.HoldTorque) * 0.01);
+ List samples = run.Samples
+ .Select(sample => new TorqueSamplePayload
+ {
+ ElapsedSeconds = Math.Max(0, (sample.SampledAt - run.StartedAt).TotalSeconds),
+ TorqueMilliNewtonMeters = sample.RealtimeTorqueMilliNewtonMeters
+ })
+ .Where(sample => sample.ElapsedSeconds <= holdTime)
+ .ToList();
+
+ if (holdTime <= 0)
+ {
+ return new TorqueCurvePayload
+ {
+ HoldTimeSeconds = holdTime,
+ ChangeThresholdMilliNewtonMeters = threshold,
+ Result = "未设置保持时间,未判定",
+ Samples = samples
+ };
}
if (samples.Count < 2)
{
return new TorqueCurvePayload
{
- HoldTimeSeconds = _parameterConfig.TorqueHoldTime,
+ HoldTimeSeconds = holdTime,
ChangeThresholdMilliNewtonMeters = threshold,
Result = "采样不足,未判定",
Samples = samples
};
}
- double min = samples.Min(sample => sample.TorqueMilliNewtonMeters);
- double max = samples.Max(sample => sample.TorqueMilliNewtonMeters);
- double average = samples.Average(sample => sample.TorqueMilliNewtonMeters);
- double fluctuation = max - min;
-
+ double min = samples.Min(static sample => sample.TorqueMilliNewtonMeters);
+ double max = samples.Max(static sample => sample.TorqueMilliNewtonMeters);
return new TorqueCurvePayload
{
- HoldTimeSeconds = _parameterConfig.TorqueHoldTime,
+ HoldTimeSeconds = holdTime,
ChangeThresholdMilliNewtonMeters = threshold,
MinTorqueMilliNewtonMeters = min,
MaxTorqueMilliNewtonMeters = max,
- AverageTorqueMilliNewtonMeters = average,
- FluctuationMilliNewtonMeters = fluctuation,
- Result = fluctuation > threshold ? "有变化" : "无明显变化",
+ AverageTorqueMilliNewtonMeters = samples.Average(static sample => sample.TorqueMilliNewtonMeters),
+ FluctuationMilliNewtonMeters = max - min,
+ Result = max - min > threshold ? "有变化" : "无明显变化",
Samples = samples
};
}
@@ -2472,9 +2918,365 @@ public sealed class MainWindowViewModel : ObservableObject
return Math.Max(MaxTorqueSampleCount, (int)Math.Ceiling(_parameterConfig.TorqueHoldTime * 2.5) + 4);
}
+ private void CaptureRealtimeSample(double dialIndicator, IReadOnlyDictionary coilValues)
+ {
+ var sample = new RealtimeSamplePayload
+ {
+ Sequence = ++_realtimeSampleSequence,
+ SampledAt = DateTime.Now,
+ DialIndicatorMm = dialIndicator,
+ RelativeDisplacementMm = _relativeDisplacement,
+ AxialAxisPositionMm = _axialAxisPosition,
+ AxialSampleStartMm = _axialSampleStart,
+ AxialSampleEndMm = _axialSampleEnd,
+ AxialSampleDifferenceMm = _axialSampleDifference,
+ AxialForceN = GetScaledAxialForce(),
+ SpeedTorqueAxisPositionMm = _speedTorqueAxisPosition,
+ SpeedTorqueDisplacementMm = _speedTorqueDisplacement,
+ SpeedTorquePeakTorqueMilliNewtonMeters = _speedTorquePeakTorque,
+ RealtimeTorqueMilliNewtonMeters = GetScaledTorque(),
+ RealtimeSpeedRpm = _realtimeSpeed,
+ RealtimePressureMpa = _realtimePressure,
+ NoLoadSpeedRpm = _noLoadSpeedRecord,
+ NoLoadSpeedErrorRatePercent = _noLoadSpeedErrorRate,
+ SpeedTorqueDone = ReadCoilValue(coilValues, SpeedTorqueDoneCoil),
+ SpeedTorqueResetEnabled = ReadCoilValue(coilValues, SpeedTorqueResetEnabledCoil),
+ SpeedTorqueResetDone = ReadCoilValue(coilValues, SpeedTorqueResetDoneCoil),
+ AxialResetEnabled = ReadCoilValue(coilValues, AxialResetEnabledCoil),
+ AxialResetDone = ReadCoilValue(coilValues, AxialResetDoneCoil)
+ };
+
+ _latestRealtimeSample = sample;
+ AppendSample(_activeDisplacementRun, sample);
+ AppendSample(_activeSpeedTorqueRun, sample);
+ AppendSample(_activeNoLoadSpeedRun, sample);
+ UpdateDataCaptureStatus();
+ }
+
+ private static void AppendSample(TestRunPayload? run, RealtimeSamplePayload sample)
+ {
+ if (run is null || run.Samples.LastOrDefault()?.Sequence == sample.Sequence)
+ {
+ return;
+ }
+
+ run.Samples.Add(sample);
+ }
+
+ private void AppendLatestSample(TestRunPayload run)
+ {
+ if (_latestRealtimeSample is not null)
+ {
+ AppendSample(run, _latestRealtimeSample);
+ }
+ }
+
+ private TestRunPayload CreateTestRun(string testType, DateTime? startedAt = null)
+ {
+ return new TestRunPayload
+ {
+ RunId = $"{DateTime.Now:yyyyMMddHHmmssfff}-{Guid.NewGuid():N}"[..30],
+ TestType = testType,
+ StartedAt = startedAt ?? DateTime.Now,
+ ParameterSnapshot = CloneParameterConfig(_parameterConfig)
+ };
+ }
+
+ private void FinalizeDisplacementRun(string status)
+ {
+ if (_activeDisplacementRun is null)
+ {
+ return;
+ }
+
+ AppendLatestSample(_activeDisplacementRun);
+ _activeDisplacementRun.CompletedAt = DateTime.Now;
+ _activeDisplacementRun.CompletionStatus = status;
+ _activeDisplacementRun.FinalDisplacementMm = _finalDisplacement ?? _relativeDisplacement;
+ _activeDisplacementRun.FinalAxialForceN = _finalAxialForce ?? GetScaledAxialForce();
+ _completedRuns.Add(_activeDisplacementRun);
+ _activeDisplacementRun = null;
+ UpdateDataCaptureStatus();
+ }
+
+ private void FinalizeSpeedTorqueRun(string status)
+ {
+ if (_activeSpeedTorqueRun is null)
+ {
+ return;
+ }
+
+ AppendLatestSample(_activeSpeedTorqueRun);
+ _activeSpeedTorqueRun.CompletedAt = DateTime.Now;
+ _activeSpeedTorqueRun.CompletionStatus = status;
+ _activeSpeedTorqueRun.FinalDisplacementMm = _finalSpeedTorqueDisplacement ?? _speedTorqueDisplacement;
+ _activeSpeedTorqueRun.FinalSpeedRpm = _finalSpeed ?? _realtimeSpeed;
+ _activeSpeedTorqueRun.FinalTorqueMilliNewtonMeters = _finalTorque ?? GetScaledTorque();
+ _completedRuns.Add(_activeSpeedTorqueRun);
+ _activeSpeedTorqueRun = null;
+ UpdateDataCaptureStatus();
+ }
+
+ private void FinalizeNoLoadSpeedRunIfDue()
+ {
+ if (_activeNoLoadSpeedRun is null
+ || !_noLoadCaptureDeadline.HasValue
+ || DateTime.Now < _noLoadCaptureDeadline.Value)
+ {
+ return;
+ }
+
+ AppendLatestSample(_activeNoLoadSpeedRun);
+ _activeNoLoadSpeedRun.CompletedAt = DateTime.Now;
+ _activeNoLoadSpeedRun.CompletionStatus = "记录完成";
+ _activeNoLoadSpeedRun.NoLoadSpeedRpm = _noLoadSpeedRecord;
+ _activeNoLoadSpeedRun.NoLoadSpeedErrorRatePercent = _noLoadSpeedErrorRate;
+ _completedRuns.Add(_activeNoLoadSpeedRun);
+ _activeNoLoadSpeedRun = null;
+ _noLoadCaptureDeadline = null;
+ NoLoadSpeedStatusText = "状态:空载转速完整记录已保存";
+ PersistCurrentPayloadSnapshot("空载转速记录完成");
+ UpdateDataCaptureStatus();
+ }
+
+ private void PrepareSessionForNewRun()
+ {
+ if (!_sessionExported || HasActiveRuns())
+ {
+ return;
+ }
+
+ _completedRuns.Clear();
+ _sessionId = CreateSessionId();
+ _sessionCreatedAt = DateTime.Now;
+ _realtimeSampleSequence = 0;
+ _sessionExported = false;
+ _finalDisplacement = null;
+ _finalAxialForce = null;
+ _finalSpeedTorqueDisplacement = null;
+ _finalSpeed = null;
+ _finalTorque = null;
+ _maxDisplacement = 0;
+ _maxSpeedTorqueDisplacement = 0;
+ ClearTorqueSamples();
+ UpdateDataCaptureStatus("新测试会话已建立");
+ }
+
+ private bool HasActiveRuns()
+ {
+ return _activeDisplacementRun is not null
+ || _activeSpeedTorqueRun is not null
+ || _activeNoLoadSpeedRun is not null;
+ }
+
+ private List CreateRunSnapshots()
+ {
+ var runs = _completedRuns.Select(CloneRun).ToList();
+ if (_activeDisplacementRun is not null)
+ {
+ runs.Add(CloneRun(_activeDisplacementRun));
+ }
+
+ if (_activeSpeedTorqueRun is not null)
+ {
+ runs.Add(CloneRun(_activeSpeedTorqueRun));
+ }
+
+ if (_activeNoLoadSpeedRun is not null)
+ {
+ runs.Add(CloneRun(_activeNoLoadSpeedRun));
+ }
+
+ return runs.OrderBy(static run => run.StartedAt).ToList();
+ }
+
+ private static TestRunPayload CloneRun(TestRunPayload run)
+ {
+ return new TestRunPayload
+ {
+ RunId = run.RunId,
+ TestType = run.TestType,
+ StartedAt = run.StartedAt,
+ CompletedAt = run.CompletedAt,
+ CompletionStatus = run.CompletionStatus,
+ ParameterSnapshot = CloneParameterConfig(run.ParameterSnapshot),
+ FinalDisplacementMm = run.FinalDisplacementMm,
+ FinalAxialForceN = run.FinalAxialForceN,
+ FinalSpeedRpm = run.FinalSpeedRpm,
+ FinalTorqueMilliNewtonMeters = run.FinalTorqueMilliNewtonMeters,
+ NoLoadSpeedRpm = run.NoLoadSpeedRpm,
+ NoLoadSpeedErrorRatePercent = run.NoLoadSpeedErrorRatePercent,
+ Samples = run.Samples.ToList()
+ };
+ }
+
+ private static TestParameterConfig CloneParameterConfig(TestParameterConfig config)
+ {
+ return new TestParameterConfig
+ {
+ AxialDisplacementLimit = config.AxialDisplacementLimit,
+ AxialSpeed = config.AxialSpeed,
+ AxialManualDisplacement = config.AxialManualDisplacement,
+ AxialForceLowerLimit = config.AxialForceLowerLimit,
+ AxialForceUpperLimit = config.AxialForceUpperLimit,
+ AxialForceCoefficient = config.AxialForceCoefficient,
+ AxialForceSetpoint = config.AxialForceSetpoint,
+ AxialJumpForceSetpoint = config.AxialJumpForceSetpoint,
+ UseAxialPullForceSetpoint = config.UseAxialPullForceSetpoint,
+ AxialForceProtection = config.AxialForceProtection,
+ AxialForceHoldTime = config.AxialForceHoldTime,
+ SpeedTorqueDisplacementLimit = config.SpeedTorqueDisplacementLimit,
+ SpeedTorqueSpeed = config.SpeedTorqueSpeed,
+ SpeedTorqueManualDisplacement = config.SpeedTorqueManualDisplacement,
+ TorqueCoefficient = config.TorqueCoefficient,
+ TorqueProtection = config.TorqueProtection,
+ HoldTorque = config.HoldTorque,
+ TorqueHoldTime = config.TorqueHoldTime,
+ SpeedCoefficient = config.SpeedCoefficient,
+ SpeedStopThreshold = config.SpeedStopThreshold,
+ PressureCoefficient = config.PressureCoefficient,
+ NoLoadSpeedSetting = config.NoLoadSpeedSetting,
+ PlcIpAddress = config.PlcIpAddress,
+ PlcPort = config.PlcPort,
+ PlcUnitId = config.PlcUnitId,
+ PlcPulseMilliseconds = config.PlcPulseMilliseconds,
+ PlcTimeoutMilliseconds = config.PlcTimeoutMilliseconds,
+ FloatWordOrder = config.FloatWordOrder
+ };
+ }
+
+ private void QueueSnapshotIfDue()
+ {
+ if (!HasActiveRuns()
+ || DateTime.UtcNow - _lastSnapshotPersistedAt < SnapshotInterval
+ || Interlocked.Exchange(ref _snapshotWriteInProgress, 1) != 0)
+ {
+ return;
+ }
+
+ _lastSnapshotPersistedAt = DateTime.UtcNow;
+ AcceptancePayload payload = CreatePayload();
+ _ = Task.Run(() =>
+ {
+ try
+ {
+ PersistPayloadSnapshot(payload, "周期自动备份");
+ }
+ catch (Exception ex)
+ {
+ Log.Error(ex, "测试数据周期自动备份失败,会话 {SessionId}", payload.SessionId);
+ }
+ finally
+ {
+ Interlocked.Exchange(ref _snapshotWriteInProgress, 0);
+ }
+ });
+ }
+
+ private void PersistCurrentPayloadSnapshot(string reason)
+ {
+ if (_completedRuns.Count == 0 && !HasActiveRuns())
+ {
+ return;
+ }
+
+ try
+ {
+ PersistPayloadSnapshot(CreatePayload(), reason);
+ UpdateDataCaptureStatus("自动备份:已保存");
+ }
+ catch (Exception ex)
+ {
+ UpdateDataCaptureStatus("自动备份:失败");
+ Log.Error(ex, "测试数据自动备份失败,原因 {Reason},会话 {SessionId}", reason, _sessionId);
+ }
+ }
+
+ private void PersistPayloadSnapshot(AcceptancePayload payload, string reason)
+ {
+ lock (_snapshotWriteLock)
+ {
+ if (payload.LastUpdatedAt < _latestPersistedPayloadAt)
+ {
+ return;
+ }
+
+ Directory.CreateDirectory(_recordsDirectory);
+ string path = Path.Combine(_recordsDirectory, $"{payload.SessionId}.json");
+ string tempPath = $"{path}.{Guid.NewGuid():N}.tmp";
+
+ try
+ {
+ File.WriteAllText(tempPath, JsonSerializer.Serialize(payload, CreateRecordJsonOptions()));
+ File.Move(tempPath, path, overwrite: true);
+ _latestPersistedPayloadAt = payload.LastUpdatedAt;
+ Log.Information(
+ "测试数据快照保存成功,原因 {Reason},会话 {SessionId},测试运行 {RunCount},完整采样 {SampleCount},路径 {Path}",
+ reason,
+ payload.SessionId,
+ payload.Runs.Count,
+ payload.Runs.Sum(static run => run.Samples.Count),
+ path);
+ }
+ finally
+ {
+ if (File.Exists(tempPath))
+ {
+ File.Delete(tempPath);
+ }
+ }
+ }
+ }
+
+ private static JsonSerializerOptions CreateRecordJsonOptions()
+ {
+ var options = new JsonSerializerOptions();
+ options.Converters.Add(new JsonStringEnumConverter());
+ return options;
+ }
+
+ private void UpdateDataCaptureStatus(string? overrideStatus = null)
+ {
+ int runCount = _completedRuns.Count
+ + (_activeDisplacementRun is null ? 0 : 1)
+ + (_activeSpeedTorqueRun is null ? 0 : 1)
+ + (_activeNoLoadSpeedRun is null ? 0 : 1);
+ int sampleCount = _completedRuns.Sum(static run => run.Samples.Count)
+ + (_activeDisplacementRun?.Samples.Count ?? 0)
+ + (_activeSpeedTorqueRun?.Samples.Count ?? 0)
+ + (_activeNoLoadSpeedRun?.Samples.Count ?? 0);
+ string activeText = HasActiveRuns() ? "采集中" : "待机";
+ DataCaptureStatusText = overrideStatus is null
+ ? $"完整采样:{sampleCount} 条;测试运行:{runCount} 次;{activeText};自动备份与导出校验已启用"
+ : $"完整采样:{sampleCount} 条;测试运行:{runCount} 次;{overrideStatus}";
+ }
+
+ private static string CreateSessionId()
+ {
+ return $"{DateTime.Now:yyyyMMddHHmmssfff}-{Guid.NewGuid():N}"[..34];
+ }
+
+ private static string ResolveApplicationDataDirectory()
+ {
+ string primary = Path.Combine(
+ Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
+ "DentistryHandpieces");
+ try
+ {
+ Directory.CreateDirectory(primary);
+ return primary;
+ }
+ catch
+ {
+ string fallback = Path.Combine(Path.GetTempPath(), "DentistryHandpieces");
+ Directory.CreateDirectory(fallback);
+ return fallback;
+ }
+ }
+
private void RefreshOverallResult()
{
- StatusText = $"当前整体验收:{CalculateOverallResult()}。完成测试后可保存记录并导出 Excel。";
+ StatusText = $"当前整体验收:{CalculateOverallResult()}。完成测试后可导出报表。";
}
private static string CalculateOverallResult()
diff --git a/DentistryHandpieces/ModbusTcpPlcCoilService.cs b/DentistryHandpieces/ModbusTcpPlcCoilService.cs
index fd96c4e..4a54920 100644
--- a/DentistryHandpieces/ModbusTcpPlcCoilService.cs
+++ b/DentistryHandpieces/ModbusTcpPlcCoilService.cs
@@ -1,6 +1,7 @@
using System.Net.Sockets;
using System.IO;
using System.Buffers.Binary;
+using Serilog;
namespace DentistryHandpieces;
@@ -37,14 +38,25 @@ public sealed class ModbusTcpPlcCoilService : IPlcCoilService, IPlcRegisterServi
public async Task PulseCoilAsync(PlcConnectionConfig config, ushort coilAddress, CancellationToken cancellationToken = default)
{
- await WriteSingleCoilAsync(config, coilAddress, true, cancellationToken);
- await Task.Delay(config.PulseMilliseconds, cancellationToken);
- await WriteSingleCoilAsync(config, coilAddress, false, cancellationToken);
+ await ExecuteWriteAsync(
+ config,
+ $"M{coilAddress}",
+ $"脉冲 {config.PulseMilliseconds} ms",
+ async () =>
+ {
+ await WriteSingleCoilAsync(config, coilAddress, true, cancellationToken);
+ await Task.Delay(config.PulseMilliseconds, cancellationToken);
+ await WriteSingleCoilAsync(config, coilAddress, false, cancellationToken);
+ });
}
public async Task WriteCoilAsync(PlcConnectionConfig config, ushort coilAddress, bool value, CancellationToken cancellationToken = default)
{
- await WriteSingleCoilAsync(config, coilAddress, value, cancellationToken);
+ await ExecuteWriteAsync(
+ config,
+ $"M{coilAddress}",
+ value ? 1 : 0,
+ () => WriteSingleCoilAsync(config, coilAddress, value, cancellationToken));
}
public async Task> ReadCoilValuesAsync(PlcConnectionConfig config, IReadOnlyCollection coilAddresses, CancellationToken cancellationToken = default)
@@ -118,12 +130,20 @@ public sealed class ModbusTcpPlcCoilService : IPlcCoilService, IPlcRegisterServi
? [highWord, lowWord]
: [lowWord, highWord];
- await WriteMultipleRegistersAsync(config, registerAddress, registers, cancellationToken);
+ await ExecuteWriteAsync(
+ config,
+ $"D{registerAddress}",
+ value,
+ () => WriteMultipleRegistersAsync(config, registerAddress, registers, cancellationToken));
}
public async Task WriteUInt16Async(PlcConnectionConfig config, ushort registerAddress, ushort value, CancellationToken cancellationToken = default)
{
- await WriteMultipleRegistersAsync(config, registerAddress, [value], cancellationToken);
+ await ExecuteWriteAsync(
+ config,
+ $"D{registerAddress}",
+ value,
+ () => WriteMultipleRegistersAsync(config, registerAddress, [value], cancellationToken));
}
public async Task WriteInt32Async(PlcConnectionConfig config, ushort registerAddress, int value, CancellationToken cancellationToken = default)
@@ -136,7 +156,38 @@ public sealed class ModbusTcpPlcCoilService : IPlcCoilService, IPlcRegisterServi
? [highWord, lowWord]
: [lowWord, highWord];
- await WriteMultipleRegistersAsync(config, registerAddress, registers, cancellationToken);
+ await ExecuteWriteAsync(
+ config,
+ $"D{registerAddress}",
+ value,
+ () => WriteMultipleRegistersAsync(config, registerAddress, registers, cancellationToken));
+ }
+
+ private static async Task ExecuteWriteAsync(PlcConnectionConfig config, string target, object value, Func writeAction)
+ {
+ try
+ {
+ await writeAction();
+ Log.Information(
+ "PLC写入成功,地址 {IpAddress}:{Port},站号 {UnitId},目标 {Target},值 {Value}",
+ config.IpAddress,
+ config.Port,
+ config.UnitId,
+ target,
+ value);
+ }
+ catch (Exception ex)
+ {
+ Log.Error(
+ ex,
+ "PLC写入失败,地址 {IpAddress}:{Port},站号 {UnitId},目标 {Target},值 {Value}",
+ config.IpAddress,
+ config.Port,
+ config.UnitId,
+ target,
+ value);
+ throw;
+ }
}
private async Task WriteSingleCoilAsync(PlcConnectionConfig config, ushort coilAddress, bool value, CancellationToken cancellationToken)
diff --git a/DentistryHandpieces/Models.cs b/DentistryHandpieces/Models.cs
index 9ac61be..56854f3 100644
--- a/DentistryHandpieces/Models.cs
+++ b/DentistryHandpieces/Models.cs
@@ -142,36 +142,27 @@ public sealed class TestPointRow : INotifyPropertyChanged
}
}
-public sealed class TestRecordRow
-{
- public int Id { get; init; }
-
- public DateTime CreatedAt { get; init; }
-
- public string CreatedAtText => CreatedAt.ToString("yyyy-MM-dd HH:mm:ss", CultureInfo.InvariantCulture);
-
- public string SampleCode { get; init; } = string.Empty;
-
- public string OperatorName { get; init; } = string.Empty;
-
- public string OverallResult { get; init; } = string.Empty;
-
- public string ExportPath { get; init; } = string.Empty;
-
- public string ExportPathText => string.IsNullOrWhiteSpace(ExportPath) ? "未导出" : ExportPath;
-}
-
public sealed class AcceptancePayload
{
+ public int SchemaVersion { get; init; } = 2;
+
+ public string SessionId { get; init; } = string.Empty;
+
public DateTime CreatedAt { get; init; }
+ public DateTime LastUpdatedAt { get; init; }
+
public string SampleCode { get; init; } = string.Empty;
public string OperatorName { get; init; } = string.Empty;
public string OverallResult { get; init; } = string.Empty;
+ public TestParameterConfig ParameterSnapshot { get; init; } = TestParameterConfig.CreateDefault();
+
public List Projects { get; init; } = [];
+
+ public List Runs { get; init; } = [];
}
public sealed class ProjectPayload
@@ -234,6 +225,82 @@ public sealed class TorqueCurvePayload
public List Samples { get; init; } = [];
}
+public sealed class TestRunPayload
+{
+ public string RunId { get; init; } = string.Empty;
+
+ public string TestType { get; init; } = string.Empty;
+
+ public DateTime StartedAt { get; init; }
+
+ public DateTime? CompletedAt { get; set; }
+
+ public string CompletionStatus { get; set; } = "测试中";
+
+ public TestParameterConfig ParameterSnapshot { get; init; } = TestParameterConfig.CreateDefault();
+
+ public double? FinalDisplacementMm { get; set; }
+
+ public double? FinalAxialForceN { get; set; }
+
+ public double? FinalSpeedRpm { get; set; }
+
+ public double? FinalTorqueMilliNewtonMeters { get; set; }
+
+ public double? NoLoadSpeedRpm { get; set; }
+
+ public double? NoLoadSpeedErrorRatePercent { get; set; }
+
+ public List Samples { get; init; } = [];
+}
+
+public sealed class RealtimeSamplePayload
+{
+ public long Sequence { get; init; }
+
+ public DateTime SampledAt { get; init; }
+
+ public double DialIndicatorMm { get; init; }
+
+ public double RelativeDisplacementMm { get; init; }
+
+ public double AxialAxisPositionMm { get; init; }
+
+ public double AxialSampleStartMm { get; init; }
+
+ public double AxialSampleEndMm { get; init; }
+
+ public double AxialSampleDifferenceMm { get; init; }
+
+ public double AxialForceN { get; init; }
+
+ public double SpeedTorqueAxisPositionMm { get; init; }
+
+ public double SpeedTorqueDisplacementMm { get; init; }
+
+ public double SpeedTorquePeakTorqueMilliNewtonMeters { get; init; }
+
+ public double RealtimeTorqueMilliNewtonMeters { get; init; }
+
+ public double RealtimeSpeedRpm { get; init; }
+
+ public double RealtimePressureMpa { get; init; }
+
+ public double NoLoadSpeedRpm { get; init; }
+
+ public double NoLoadSpeedErrorRatePercent { get; init; }
+
+ public bool SpeedTorqueDone { get; init; }
+
+ public bool SpeedTorqueResetEnabled { get; init; }
+
+ public bool SpeedTorqueResetDone { get; init; }
+
+ public bool AxialResetEnabled { get; init; }
+
+ public bool AxialResetDone { get; init; }
+}
+
public sealed class TestParameterConfig
{
public double AxialDisplacementLimit { get; init; }
@@ -278,6 +345,8 @@ public sealed class TestParameterConfig
public double PressureCoefficient { get; init; }
+ public double NoLoadSpeedSetting { get; init; }
+
public string PlcIpAddress { get; init; } = "192.168.1.10";
public int PlcPort { get; init; } = 502;
@@ -328,6 +397,7 @@ public sealed class TestParameterConfig
SpeedCoefficient = 1,
SpeedStopThreshold = 0,
PressureCoefficient = 1,
+ NoLoadSpeedSetting = 0,
PlcIpAddress = "192.168.1.10",
PlcPort = 502,
PlcUnitId = 1,