diff --git a/ConeCalorimeter/App.xaml.cs b/ConeCalorimeter/App.xaml.cs index 00acf76..507d5ae 100644 --- a/ConeCalorimeter/App.xaml.cs +++ b/ConeCalorimeter/App.xaml.cs @@ -1,6 +1,7 @@ -using System.Configuration; -using System.Data; +using System.IO; using System.Windows; +using System.Windows.Threading; +using Serilog; namespace ConeCalorimeter { @@ -9,6 +10,74 @@ namespace ConeCalorimeter /// public partial class App : Application { - } + protected override void OnStartup(StartupEventArgs e) + { + ConfigureLogging(); + DispatcherUnhandledException += OnDispatcherUnhandledException; + AppDomain.CurrentDomain.UnhandledException += OnUnhandledException; + TaskScheduler.UnobservedTaskException += OnUnobservedTaskException; + Log.Information("ConeCalorimeter application starting."); + base.OnStartup(e); + } + + protected override void OnExit(ExitEventArgs e) + { + try + { + Log.Information("ConeCalorimeter application exiting with code {ExitCode}.", e.ApplicationExitCode); + } + finally + { + Log.CloseAndFlush(); + } + + base.OnExit(e); + } + + private static void ConfigureLogging() + { + var logDirectory = Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), + "ConeCalorimeter", + "Logs"); + Directory.CreateDirectory(logDirectory); + + Log.Logger = new LoggerConfiguration() + .MinimumLevel.Debug() + .Enrich.WithProperty("Application", "ConeCalorimeter") + .WriteTo.File( + Path.Combine(logDirectory, "app-.log"), + rollingInterval: RollingInterval.Day, + retainedFileCountLimit: 30, + 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, "Unhandled WPF dispatcher exception."); + } + + private static void OnUnhandledException(object sender, UnhandledExceptionEventArgs e) + { + if (e.ExceptionObject is Exception exception) + { + Log.Fatal(exception, "Unhandled AppDomain exception. IsTerminating={IsTerminating}", e.IsTerminating); + return; + } + + Log.Fatal( + "Unhandled AppDomain exception object: {ExceptionObject}. IsTerminating={IsTerminating}", + e.ExceptionObject, + e.IsTerminating); + } + + private static void OnUnobservedTaskException(object? sender, UnobservedTaskExceptionEventArgs e) + { + Log.Error(e.Exception, "Unobserved task exception."); + } + } } diff --git a/ConeCalorimeter/ConeCalorimeter.csproj b/ConeCalorimeter/ConeCalorimeter.csproj index e576896..9344091 100644 --- a/ConeCalorimeter/ConeCalorimeter.csproj +++ b/ConeCalorimeter/ConeCalorimeter.csproj @@ -13,6 +13,7 @@ + diff --git a/ConeCalorimeter/MainWindow.xaml.cs b/ConeCalorimeter/MainWindow.xaml.cs index f57158f..c6ae0d4 100644 --- a/ConeCalorimeter/MainWindow.xaml.cs +++ b/ConeCalorimeter/MainWindow.xaml.cs @@ -1,6 +1,7 @@ using System.Windows; using ConeCalorimeter.Services; using ConeCalorimeter.ViewModels; +using Serilog; namespace ConeCalorimeter { @@ -13,10 +14,15 @@ namespace ConeCalorimeter { InitializeComponent(); - _tcpDeviceConnectionService = new TcpDeviceConnectionService( - TcpDeviceConnectionOptions.FromEnvironment()); - _scaleService = new ModbusRtuScaleService( - SerialScaleOptions.FromEnvironment()); + var tcpOptions = TcpDeviceConnectionOptions.FromEnvironment(); + var scaleOptions = SerialScaleOptions.FromEnvironment(); + + _tcpDeviceConnectionService = new TcpDeviceConnectionService(tcpOptions); + _scaleService = new ModbusRtuScaleService(scaleOptions); + Log.Information( + "Main window initialized. TCP endpoint={Endpoint}, scale port={ScalePort}.", + _tcpDeviceConnectionService.Endpoint, + scaleOptions.PortName); _ = _tcpDeviceConnectionService.StartAsync(); var experimentDataService = new ExperimentDataService( @@ -31,6 +37,7 @@ namespace ConeCalorimeter protected override async void OnClosed(EventArgs e) { + Log.Information("Main window closing; disposing device services."); _scaleService.Dispose(); await _tcpDeviceConnectionService.DisposeAsync(); base.OnClosed(e); diff --git a/ConeCalorimeter/Services/ExperimentDataService.cs b/ConeCalorimeter/Services/ExperimentDataService.cs index ea471fc..7329e28 100644 --- a/ConeCalorimeter/Services/ExperimentDataService.cs +++ b/ConeCalorimeter/Services/ExperimentDataService.cs @@ -2,6 +2,7 @@ using System.Collections.ObjectModel; using System.Diagnostics; using System.Windows.Threading; using ConeCalorimeter.Models; +using Serilog; namespace ConeCalorimeter.Services; @@ -52,11 +53,20 @@ public sealed class ExperimentDataService : IExperimentDataService { _initialMass = CurrentSnapshot.CurrentMass; } + + Log.Information( + "Experiment test started. InitialMass={InitialMass}, SnapshotSeconds={TestSeconds}.", + _initialMass, + CurrentSnapshot.TestSeconds); } public void StopTest() { _isTestRunning = false; + Log.Information( + "Experiment test stopped. Records={RecordCount}, LastRecordedSeconds={LastRecordedSeconds}.", + Records.Count, + _lastRecordedSeconds); } public void ClearRecords() @@ -87,6 +97,7 @@ public sealed class ExperimentDataService : IExperimentDataService catch (Exception ex) { Debug.WriteLine($"Realtime snapshot polling failed: {ex.Message}"); + Log.Warning(ex, "Realtime snapshot polling failed."); } finally { diff --git a/ConeCalorimeter/Services/ModbusRtuScaleService.cs b/ConeCalorimeter/Services/ModbusRtuScaleService.cs index 0ff97e5..5e09758 100644 --- a/ConeCalorimeter/Services/ModbusRtuScaleService.cs +++ b/ConeCalorimeter/Services/ModbusRtuScaleService.cs @@ -1,6 +1,7 @@ using System.Diagnostics; using System.IO; using System.IO.Ports; +using Serilog; namespace ConeCalorimeter.Services; @@ -20,6 +21,12 @@ public sealed class ModbusRtuScaleService : IScaleService public ModbusRtuScaleService(SerialScaleOptions options) { _options = options; + Log.Information( + "Scale service created. Port={PortName}, BaudRate={BaudRate}, UnitId={UnitId}, Register=D{RegisterAddress}.", + _options.PortName, + _options.BaudRate, + _options.UnitId, + _options.RegisterAddress); } public bool TryReadCurrentMass(out double value) @@ -51,6 +58,12 @@ public sealed class ModbusRtuScaleService : IScaleService or ArgumentException) { Debug.WriteLine($"Scale read failed on {_options.PortName}: {ex.Message}"); + Log.Warning( + ex, + "Scale read failed. Port={PortName}, UnitId={UnitId}, Register=D{RegisterAddress}.", + _options.PortName, + _options.UnitId, + _options.RegisterAddress); CloseCurrentPort(); return false; } @@ -87,6 +100,7 @@ public sealed class ModbusRtuScaleService : IScaleService port.Open(); _serialPort = port; + Log.Information("Scale serial port opened. Port={PortName}.", _options.PortName); return port; } @@ -110,6 +124,7 @@ public sealed class ModbusRtuScaleService : IScaleService finally { port.Dispose(); + Log.Information("Scale serial port closed. Port={PortName}.", _options.PortName); } } @@ -198,6 +213,15 @@ public sealed class ModbusRtuScaleService : IScaleService Debug.WriteLine( $"Scale mass out of range raw [{result.RawHex}], ABCD={result.Abcd:G9}, " + $"CDAB={result.Cdab:G9}, BADC={result.Badc:G9}, DCBA={result.Dcba:G9}."); + Log.Warning( + "Scale mass out of range. Raw={RawHex}, ABCD={Abcd}, CDAB={Cdab}, BADC={Badc}, DCBA={Dcba}, Range={MinimumMass}..{MaximumMass}.", + result.RawHex, + result.Abcd, + result.Cdab, + result.Badc, + result.Dcba, + MinimumMass, + MaximumMass); return false; } diff --git a/ConeCalorimeter/Services/TcpDeviceConnectionService.cs b/ConeCalorimeter/Services/TcpDeviceConnectionService.cs index d146486..7115f65 100644 --- a/ConeCalorimeter/Services/TcpDeviceConnectionService.cs +++ b/ConeCalorimeter/Services/TcpDeviceConnectionService.cs @@ -2,6 +2,7 @@ using System.Diagnostics; using System.Buffers.Binary; using System.IO; using System.Net.Sockets; +using Serilog; namespace ConeCalorimeter.Services; @@ -34,6 +35,10 @@ public sealed class TcpDeviceConnectionService : ITcpDeviceConnectionService { _options = options; _statusText = $"未连接设备 {_options.Host}:{_options.Port}"; + Log.Information( + "TCP device service created. Endpoint={Endpoint}, UnitId={UnitId}.", + Endpoint, + _options.UnitId); } public bool IsConnected @@ -112,6 +117,7 @@ public sealed class TcpDeviceConnectionService : ITcpDeviceConnectionService cancellation?.Dispose(); Debug.WriteLine("TCP device connection service stopped."); + Log.Information("TCP device connection service stopped. Endpoint={Endpoint}.", Endpoint); } public async ValueTask DisposeAsync() @@ -139,6 +145,11 @@ public sealed class TcpDeviceConnectionService : ITcpDeviceConnectionService catch (Exception ex) when (ex is IOException or SocketException or InvalidDataException or ObjectDisposedException) { Debug.WriteLine($"TCP device register {registerAddress} read failed: {ex.Message}"); + Log.Warning( + ex, + "TCP float register read failed. Endpoint={Endpoint}, Register=D{RegisterAddress}.", + Endpoint, + registerAddress); SetConnectionState(false, $"读取寄存器 {registerAddress} 失败:{ex.Message}"); CloseCurrentClientCore(); return false; @@ -166,6 +177,11 @@ public sealed class TcpDeviceConnectionService : ITcpDeviceConnectionService catch (Exception ex) when (ex is IOException or SocketException or InvalidDataException or ObjectDisposedException) { Debug.WriteLine($"TCP device register {registerAddress} float values read failed: {ex.Message}"); + Log.Warning( + ex, + "TCP float raw register read failed. Endpoint={Endpoint}, Register=D{RegisterAddress}.", + Endpoint, + registerAddress); SetConnectionState(false, $"读取寄存器 {registerAddress} 失败:{ex.Message}"); CloseCurrentClientCore(); return false; @@ -193,6 +209,11 @@ public sealed class TcpDeviceConnectionService : ITcpDeviceConnectionService catch (Exception ex) when (ex is IOException or SocketException or InvalidDataException or ObjectDisposedException) { Debug.WriteLine($"TCP device register {registerAddress} read failed: {ex.Message}"); + Log.Warning( + ex, + "TCP int16 register read failed. Endpoint={Endpoint}, Register=D{RegisterAddress}.", + Endpoint, + registerAddress); SetConnectionState(false, $"读取寄存器 {registerAddress} 失败:{ex.Message}"); CloseCurrentClientCore(); return false; @@ -213,11 +234,22 @@ public sealed class TcpDeviceConnectionService : ITcpDeviceConnectionService try { WriteInt16(_client, registerAddress, value); + Log.Information( + "TCP int16 register write succeeded. Endpoint={Endpoint}, Register=D{RegisterAddress}, Value={Value}.", + Endpoint, + registerAddress, + value); return true; } catch (Exception ex) when (ex is IOException or SocketException or InvalidDataException or ObjectDisposedException) { Debug.WriteLine($"TCP device register {registerAddress} write failed: {ex.Message}"); + Log.Warning( + ex, + "TCP int16 register write failed. Endpoint={Endpoint}, Register=D{RegisterAddress}, Value={Value}.", + Endpoint, + registerAddress, + value); SetConnectionState(false, $"写入寄存器 {registerAddress} 失败:{ex.Message}"); CloseCurrentClientCore(); return false; @@ -238,11 +270,22 @@ public sealed class TcpDeviceConnectionService : ITcpDeviceConnectionService try { WriteFloat(_client, registerAddress, value); + Log.Information( + "TCP float register write succeeded. Endpoint={Endpoint}, Register=D{RegisterAddress}, Value={Value}.", + Endpoint, + registerAddress, + value); return true; } catch (Exception ex) when (ex is IOException or SocketException or InvalidDataException or ObjectDisposedException) { Debug.WriteLine($"TCP device register {registerAddress} float write failed: {ex.Message}"); + Log.Warning( + ex, + "TCP float register write failed. Endpoint={Endpoint}, Register=D{RegisterAddress}, Value={Value}.", + Endpoint, + registerAddress, + value); SetConnectionState(false, $"写入寄存器 {registerAddress} 失败:{ex.Message}"); CloseCurrentClientCore(); return false; @@ -270,6 +313,11 @@ public sealed class TcpDeviceConnectionService : ITcpDeviceConnectionService catch (Exception ex) when (ex is IOException or SocketException or InvalidDataException or ObjectDisposedException) { Debug.WriteLine($"TCP device coil {coilAddress} read failed: {ex.Message}"); + Log.Warning( + ex, + "TCP coil read failed. Endpoint={Endpoint}, Coil=M{CoilAddress}.", + Endpoint, + coilAddress); SetConnectionState(false, $"读取线圈 {coilAddress} 失败:{ex.Message}"); CloseCurrentClientCore(); return false; @@ -290,11 +338,22 @@ public sealed class TcpDeviceConnectionService : ITcpDeviceConnectionService try { WriteCoil(_client, coilAddress, value); + Log.Information( + "TCP coil write succeeded. Endpoint={Endpoint}, Coil=M{CoilAddress}, Value={Value}.", + Endpoint, + coilAddress, + value); return true; } catch (Exception ex) when (ex is IOException or SocketException or InvalidDataException or ObjectDisposedException) { Debug.WriteLine($"TCP device coil {coilAddress} write failed: {ex.Message}"); + Log.Warning( + ex, + "TCP coil write failed. Endpoint={Endpoint}, Coil=M{CoilAddress}, Value={Value}.", + Endpoint, + coilAddress, + value); SetConnectionState(false, $"写入线圈 {coilAddress} 失败:{ex.Message}"); CloseCurrentClientCore(); return false; @@ -318,6 +377,7 @@ public sealed class TcpDeviceConnectionService : ITcpDeviceConnectionService catch (Exception ex) { Debug.WriteLine($"TCP device connection failed: {ex.Message}"); + Log.Warning(ex, "TCP device connection loop failed. Endpoint={Endpoint}.", Endpoint); SetConnectionState(false, $"连接设备失败:{ex.Message}"); } finally @@ -340,6 +400,7 @@ public sealed class TcpDeviceConnectionService : ITcpDeviceConnectionService { SetConnectionState(false, $"正在连接设备 {_options.Host}:{_options.Port}..."); Debug.WriteLine($"Connecting TCP device at {_options.Host}:{_options.Port}."); + Log.Information("Connecting TCP device. Endpoint={Endpoint}, UnitId={UnitId}.", Endpoint, _options.UnitId); var client = new TcpClient(); var connectTask = client.ConnectAsync(_options.Host, _options.Port, cancellationToken).AsTask(); @@ -363,6 +424,7 @@ public sealed class TcpDeviceConnectionService : ITcpDeviceConnectionService SetConnectionState(true, $"已连接设备 {_options.Host}:{_options.Port}"); Debug.WriteLine($"TCP device connected at {_options.Host}:{_options.Port}."); + Log.Information("TCP device connected. Endpoint={Endpoint}.", Endpoint); } private async Task MonitorConnectionAsync(CancellationToken cancellationToken) @@ -378,6 +440,7 @@ public sealed class TcpDeviceConnectionService : ITcpDeviceConnectionService if (client is null || !IsTcpClientConnected(client)) { Debug.WriteLine("TCP device connection lost."); + Log.Warning("TCP device connection lost. Endpoint={Endpoint}.", Endpoint); SetConnectionState(false, "设备连接已断开"); return; } @@ -598,6 +661,11 @@ public sealed class TcpDeviceConnectionService : ITcpDeviceConnectionService if (changed) { + Log.Information( + "TCP device state changed. Endpoint={Endpoint}, IsConnected={IsConnected}, Status={StatusText}.", + Endpoint, + isConnected, + statusText); ConnectionStatusChanged?.Invoke(this, EventArgs.Empty); } } diff --git a/ConeCalorimeter/ViewModels/CValueCalibrationViewModel.cs b/ConeCalorimeter/ViewModels/CValueCalibrationViewModel.cs index 1b16d35..951b053 100644 --- a/ConeCalorimeter/ViewModels/CValueCalibrationViewModel.cs +++ b/ConeCalorimeter/ViewModels/CValueCalibrationViewModel.cs @@ -6,6 +6,7 @@ using System.Windows.Threading; using CommunityToolkit.Mvvm.ComponentModel; using CommunityToolkit.Mvvm.Input; using ConeCalorimeter.Services; +using Serilog; namespace ConeCalorimeter.ViewModels; @@ -461,6 +462,34 @@ public sealed class CValueCalibrationViewModel : PageViewModel + $"ABCD={result.Abcd:G9}, CDAB={result.Cdab:G9}, " + $"BADC={result.Badc:G9}, DCBA={result.Dcba:G9}, " + $"preferred={preferredText}, selected={selectedText}, matches={matchCount}."); + if (selectedByteOrder is null) + { + Log.Warning( + "C value calibration float has no valid decoded value. Label={Label}, Register=D{RegisterAddress}, Raw={RawHex}, ABCD={Abcd}, CDAB={Cdab}, BADC={Badc}, DCBA={Dcba}, Preferred={PreferredByteOrder}, Matches={MatchCount}.", + label, + registerAddress, + result.RawHex, + result.Abcd, + result.Cdab, + result.Badc, + result.Dcba, + preferredText, + matchCount); + return; + } + + Log.Information( + "C value calibration float decoded. Label={Label}, Register=D{RegisterAddress}, Raw={RawHex}, ABCD={Abcd}, CDAB={Cdab}, BADC={Badc}, DCBA={Dcba}, Preferred={PreferredByteOrder}, Selected={SelectedByteOrder}, Matches={MatchCount}.", + label, + registerAddress, + result.RawHex, + result.Abcd, + result.Cdab, + result.Badc, + result.Dcba, + preferredText, + selectedText, + matchCount); } private void LogFloatReadFailure(string label, ushort registerAddress, string statusText) @@ -469,11 +498,21 @@ public sealed class CValueCalibrationViewModel : PageViewModel $"CValueCalibration label={label} register=D{registerAddress} endpoint={_tcpDeviceConnectionService.Endpoint} " + $"status={statusText} connection=\"{_tcpDeviceConnectionService.StatusText}\""; - WriteFloatDiagnosticToFile(registerAddress, diagnosticMessage); + var shouldWriteApplicationLog = WriteFloatDiagnosticToFile(registerAddress, diagnosticMessage); Debug.WriteLine($"C value calibration float {label} register {registerAddress} {statusText}."); + if (shouldWriteApplicationLog) + { + Log.Warning( + "C value calibration float read failed. Label={Label}, Register=D{RegisterAddress}, Endpoint={Endpoint}, Status={Status}, ConnectionStatus={ConnectionStatus}.", + label, + registerAddress, + _tcpDeviceConnectionService.Endpoint, + statusText, + _tcpDeviceConnectionService.StatusText); + } } - private void WriteFloatDiagnosticToFile(ushort registerAddress, string message) + private bool WriteFloatDiagnosticToFile(ushort registerAddress, string message) { var now = DateTime.Now; if (_lastFileDiagnosticByRegister.TryGetValue(registerAddress, out var lastMessage) @@ -481,7 +520,7 @@ public sealed class CValueCalibrationViewModel : PageViewModel && _lastFileDiagnosticAtByRegister.TryGetValue(registerAddress, out var lastLoggedAt) && now - lastLoggedAt < DiagnosticLogInterval) { - return; + return false; } _lastFileDiagnosticByRegister[registerAddress] = message; @@ -498,10 +537,13 @@ public sealed class CValueCalibrationViewModel : PageViewModel File.AppendAllText( DiagnosticLogFilePath, $"{now:yyyy-MM-dd HH:mm:ss.fff} {message}{Environment.NewLine}"); + return true; } catch (Exception ex) { Debug.WriteLine($"C value calibration diagnostic log write failed: {ex.Message}"); + Log.Warning(ex, "C value calibration diagnostic log write failed. Path={DiagnosticLogFilePath}.", DiagnosticLogFilePath); + return false; } } diff --git a/ConeCalorimeter/ViewModels/ConeRadiationSettingsViewModel.cs b/ConeCalorimeter/ViewModels/ConeRadiationSettingsViewModel.cs index eaed351..369ce8a 100644 --- a/ConeCalorimeter/ViewModels/ConeRadiationSettingsViewModel.cs +++ b/ConeCalorimeter/ViewModels/ConeRadiationSettingsViewModel.cs @@ -5,6 +5,7 @@ using System.Windows.Threading; using CommunityToolkit.Mvvm.Input; using ConeCalorimeter.Models; using ConeCalorimeter.Services; +using Serilog; namespace ConeCalorimeter.ViewModels; @@ -31,10 +32,20 @@ public sealed class ConeRadiationSettingsViewModel : PageViewModel private const int TargetTemperatureWriteConfirmationAttempts = 3; private const int TargetTemperatureWriteConfirmationDelayMilliseconds = 80; private const int ParameterRefreshReleaseDelayMilliseconds = 500; + private const int HeatingDiagnosticLogIntervalSeconds = 5; + private const int HeatingDiagnosticMaximumDurationMinutes = 30; + private const int HeatingNoRiseWarningDelaySeconds = 60; + private const double HeatingNoRiseTolerance = 1; + private const double HeatingAtTargetTolerance = 2; + private const double TargetTemperatureMismatchTolerance = 0.05; private const ushort AlarmCoil = 91; private const ushort CirculatingWaterCoil = 49; private const ushort HeatingCoil = 102; + private const ushort StartHeatingCoil = 100; + private const ushort StopHeatingCoil = 101; private const string CirculatingWaterAction = "循环水"; + private const string StartHeatingAction = "开始升温"; + private const string StopHeatingAction = "停止升温"; private readonly Action _closeAction; private readonly Action _helpAction; @@ -67,6 +78,11 @@ public sealed class ConeRadiationSettingsViewModel : PageViewModel private int _pendingCurrentHeatFluxZeroReadCount; private ModbusFloatByteOrder? _currentHeatFluxByteOrder; private DateTime _parameterRefreshBlockedUntil = DateTime.MinValue; + private double? _lastConfirmedTargetTemperature; + private short? _lastConfirmedTargetTemperatureRaw; + private DateTime? _heatingDiagnosticStartedAtUtc; + private DateTime _lastHeatingDiagnosticLoggedAtUtc = DateTime.MinValue; + private double? _heatingDiagnosticStartTemperature; public ConeRadiationSettingsViewModel( Action closeAction, @@ -192,17 +208,29 @@ public sealed class ConeRadiationSettingsViewModel : PageViewModel return; } - if (action == "开始升温") + if (action == StartHeatingAction) { + Log.Information( + "Cone radiation start heating requested. TargetInput={TargetInput}, CurrentTemperatureText={CurrentTemperatureText}, HeatingActive={HeatingActive}, Endpoint={Endpoint}.", + TargetTemperatureText, + CurrentTemperatureText, + HeatingActive, + _tcpDeviceConnectionService.Endpoint); if (WriteTargetTemperature()) { - WriteActionCoil(action); + if (WriteActionCoil(action)) + { + BeginHeatingDiagnostic(); + } } return; } - WriteActionCoil(action); + if (WriteActionCoil(action) && action == StopHeatingAction) + { + EndHeatingDiagnostic("operator stopped heating"); + } } private void RefreshDeviceValues() @@ -237,6 +265,7 @@ public sealed class ConeRadiationSettingsViewModel : PageViewModel AlarmActive = _tcpDeviceConnectionService.TryReadCoil(AlarmCoil, out var alarmActive) && alarmActive; HeatingActive = _tcpDeviceConnectionService.TryReadCoil(HeatingCoil, out var heatingActive) && heatingActive; + LogHeatingDiagnosticIfNeeded(HeatingActive); if (_tcpDeviceConnectionService.TryReadCoil(CirculatingWaterCoil, out var circulatingWaterActive)) { CirculatingWaterActive = circulatingWaterActive; @@ -461,6 +490,7 @@ public sealed class ConeRadiationSettingsViewModel : PageViewModel { LastAction = "辐射温度输入无效"; Debug.WriteLine($"Invalid cone radiation target temperature: {TargetTemperatureText}"); + Log.Warning("Invalid cone radiation target temperature input. Input={Input}.", TargetTemperatureText); return false; } @@ -469,6 +499,10 @@ public sealed class ConeRadiationSettingsViewModel : PageViewModel { LastAction = "辐射温度输入超出范围"; Debug.WriteLine($"Cone radiation target temperature out of range: {TargetTemperatureText}"); + Log.Warning( + "Cone radiation target temperature out of int16 range. Input={Input}, ScaledValue={ScaledValue}.", + TargetTemperatureText, + scaledValue); return false; } @@ -476,13 +510,25 @@ public sealed class ConeRadiationSettingsViewModel : PageViewModel if (TryWriteAndConfirmTargetTemperature(registerValue)) { _parameterRefreshBlockedUntil = DateTime.UtcNow.AddMilliseconds(ParameterRefreshReleaseDelayMilliseconds); + _lastConfirmedTargetTemperature = value; + _lastConfirmedTargetTemperatureRaw = registerValue; TargetTemperatureText = value.ToString("0.#", CultureInfo.InvariantCulture); LastAction = "辐射温度设置成功"; + Log.Information( + "Cone radiation target temperature confirmed. DisplayValue={DisplayValue}, RawRegisterValue={RawRegisterValue}, Register=D{RegisterAddress}.", + value, + registerValue, + TargetTemperatureRegister); return true; } LastAction = "辐射温度写入未生效"; Debug.WriteLine($"Cone radiation target temperature write was not confirmed. Expected raw D{TargetTemperatureRegister}={registerValue}."); + Log.Warning( + "Cone radiation target temperature write was not confirmed. DisplayValue={DisplayValue}, ExpectedRawValue={ExpectedRawValue}, Register=D{RegisterAddress}.", + value, + registerValue, + TargetTemperatureRegister); return false; } @@ -491,10 +537,24 @@ public sealed class ConeRadiationSettingsViewModel : PageViewModel for (var attempt = 1; attempt <= TargetTemperatureWriteConfirmationAttempts; attempt++) { if (_tcpDeviceConnectionService.TryWriteInt16(TargetTemperatureRegister, registerValue) - && _tcpDeviceConnectionService.TryReadInt16(TargetTemperatureRegister, out var readBackValue) - && readBackValue == registerValue) + && _tcpDeviceConnectionService.TryReadInt16(TargetTemperatureRegister, out var readBackValue)) { - return true; + if (readBackValue == registerValue) + { + Log.Information( + "Cone radiation target temperature write confirmed. Attempt={Attempt}, Register=D{RegisterAddress}, RawRegisterValue={RawRegisterValue}.", + attempt, + TargetTemperatureRegister, + registerValue); + return true; + } + + Log.Warning( + "Cone radiation target temperature readback mismatch. Attempt={Attempt}, Register=D{RegisterAddress}, ExpectedRawValue={ExpectedRawValue}, ActualRawValue={ActualRawValue}.", + attempt, + TargetTemperatureRegister, + registerValue, + readBackValue); } if (attempt < TargetTemperatureWriteConfirmationAttempts) @@ -506,6 +566,125 @@ public sealed class ConeRadiationSettingsViewModel : PageViewModel return false; } + private void BeginHeatingDiagnostic() + { + _heatingDiagnosticStartedAtUtc = DateTime.UtcNow; + _lastHeatingDiagnosticLoggedAtUtc = DateTime.MinValue; + _heatingDiagnosticStartTemperature = _lastStableCurrentTemperature; + + Log.Information( + "Cone radiation heating diagnostic started. TargetTemperature={TargetTemperature}, RawRegisterValue={RawRegisterValue}, StartTemperature={StartTemperature}, StartCoil=M{StartCoil}, HeatingStateCoil=M{HeatingStateCoil}.", + _lastConfirmedTargetTemperature, + _lastConfirmedTargetTemperatureRaw, + _heatingDiagnosticStartTemperature, + StartHeatingCoil, + HeatingCoil); + } + + private void EndHeatingDiagnostic(string reason) + { + if (!_heatingDiagnosticStartedAtUtc.HasValue) + { + return; + } + + Log.Information( + "Cone radiation heating diagnostic ended. Reason={Reason}, TargetTemperature={TargetTemperature}, CurrentTemperature={CurrentTemperature}, HeatingActive={HeatingActive}.", + reason, + _lastConfirmedTargetTemperature, + _lastStableCurrentTemperature, + HeatingActive); + _heatingDiagnosticStartedAtUtc = null; + _heatingDiagnosticStartTemperature = null; + _lastHeatingDiagnosticLoggedAtUtc = DateTime.MinValue; + } + + private void LogHeatingDiagnosticIfNeeded(bool heatingActive) + { + if (!_heatingDiagnosticStartedAtUtc.HasValue) + { + return; + } + + var now = DateTime.UtcNow; + var elapsed = now - _heatingDiagnosticStartedAtUtc.Value; + if (elapsed > TimeSpan.FromMinutes(HeatingDiagnosticMaximumDurationMinutes)) + { + EndHeatingDiagnostic("diagnostic time window expired"); + return; + } + + if (now - _lastHeatingDiagnosticLoggedAtUtc < TimeSpan.FromSeconds(HeatingDiagnosticLogIntervalSeconds)) + { + return; + } + + _lastHeatingDiagnosticLoggedAtUtc = now; + + var targetReadSucceeded = _tcpDeviceConnectionService.TryReadInt16(TargetTemperatureRegister, out var rawTargetReadback); + double? targetReadback = targetReadSucceeded ? rawTargetReadback / TargetTemperatureScale : null; + var currentTemperature = _lastStableCurrentTemperature; + var targetTemperature = _lastConfirmedTargetTemperature; + var deviation = currentTemperature.HasValue && targetTemperature.HasValue + ? currentTemperature.Value - targetTemperature.Value + : (double?)null; + var targetMismatch = targetReadSucceeded + && targetTemperature.HasValue + && Math.Abs(targetReadback!.Value - targetTemperature.Value) > TargetTemperatureMismatchTolerance; + var hasNotRisen = elapsed >= TimeSpan.FromSeconds(HeatingNoRiseWarningDelaySeconds) + && targetTemperature.HasValue + && _heatingDiagnosticStartTemperature.HasValue + && currentTemperature.HasValue + && targetTemperature.Value > _heatingDiagnosticStartTemperature.Value + HeatingNoRiseTolerance + && currentTemperature.Value < _heatingDiagnosticStartTemperature.Value + HeatingNoRiseTolerance; + var reachedTarget = currentTemperature.HasValue + && targetTemperature.HasValue + && Math.Abs(currentTemperature.Value - targetTemperature.Value) <= HeatingAtTargetTolerance; + + if (!targetReadSucceeded) + { + Log.Warning( + "Cone radiation heating diagnostic: target readback failed. ElapsedSeconds={ElapsedSeconds}, TargetTemperature={TargetTemperature}, CurrentTemperature={CurrentTemperature}, HeatingActive={HeatingActive}.", + elapsed.TotalSeconds, + targetTemperature, + currentTemperature, + heatingActive); + return; + } + + if (!heatingActive || targetMismatch || hasNotRisen) + { + Log.Warning( + "Cone radiation heating diagnostic warning. ElapsedSeconds={ElapsedSeconds}, TargetTemperature={TargetTemperature}, RawTargetReadback={RawTargetReadback}, TargetReadback={TargetReadback}, CurrentTemperature={CurrentTemperature}, Deviation={Deviation}, HeatingActive={HeatingActive}, TargetMismatch={TargetMismatch}, HasNotRisen={HasNotRisen}.", + elapsed.TotalSeconds, + targetTemperature, + rawTargetReadback, + targetReadback, + currentTemperature, + deviation, + heatingActive, + targetMismatch, + hasNotRisen); + } + else + { + Log.Information( + "Cone radiation heating diagnostic sample. ElapsedSeconds={ElapsedSeconds}, TargetTemperature={TargetTemperature}, RawTargetReadback={RawTargetReadback}, TargetReadback={TargetReadback}, CurrentTemperature={CurrentTemperature}, Deviation={Deviation}, HeatingActive={HeatingActive}.", + elapsed.TotalSeconds, + targetTemperature, + rawTargetReadback, + targetReadback, + currentTemperature, + deviation, + heatingActive); + } + + if (reachedTarget) + { + EndHeatingDiagnostic("target temperature reached"); + } + } + private void SaveParameters() { if (!float.TryParse(HeatTransferText, NumberStyles.Float, CultureInfo.InvariantCulture, out var heatTransfer)) @@ -593,29 +772,40 @@ public sealed class ConeRadiationSettingsViewModel : PageViewModel Debug.WriteLine("Cone radiation circulating water write failed."); } - private void WriteActionCoil(string action) + private bool WriteActionCoil(string action) { if (!TryGetActionCoil(action, out var coilAddress)) { - return; + return false; } if (!_tcpDeviceConnectionService.TryWriteCoil(coilAddress, true)) { LastAction = $"{action}失败"; Debug.WriteLine($"Cone radiation action '{action}' write failed."); + Log.Warning( + "Cone radiation action coil write failed. Action={Action}, Coil=M{CoilAddress}.", + action, + coilAddress); + return false; } + + Log.Information( + "Cone radiation action coil write succeeded. Action={Action}, Coil=M{CoilAddress}.", + action, + coilAddress); + return true; } private static bool TryGetActionCoil(string action, out ushort coilAddress) { switch (action) { - case "开始升温": - coilAddress = 100; + case StartHeatingAction: + coilAddress = StartHeatingCoil; return true; - case "停止升温": - coilAddress = 101; + case StopHeatingAction: + coilAddress = StopHeatingCoil; return true; default: coilAddress = 0; diff --git a/ConeCalorimeter/ViewModels/RealtimeDataViewModel.cs b/ConeCalorimeter/ViewModels/RealtimeDataViewModel.cs index ec28c07..4ee0263 100644 --- a/ConeCalorimeter/ViewModels/RealtimeDataViewModel.cs +++ b/ConeCalorimeter/ViewModels/RealtimeDataViewModel.cs @@ -5,6 +5,7 @@ using CommunityToolkit.Mvvm.ComponentModel; using CommunityToolkit.Mvvm.Input; using ConeCalorimeter.Services; using Microsoft.Win32; +using Serilog; namespace ConeCalorimeter.ViewModels; @@ -88,6 +89,7 @@ public sealed class RealtimeDataViewModel : PageViewModel if (records.Count == 0) { StatusText = "没有可导出的数据"; + Log.Warning("Realtime data export blocked because there are no records."); return; } @@ -111,10 +113,12 @@ public sealed class RealtimeDataViewModel : PageViewModel { _realtimeDataExportService.Export(dialog.FileName, records); StatusText = $"已导出:{dialog.FileName}"; + Log.Information("Realtime data export succeeded. Path={Path}, Records={RecordCount}.", dialog.FileName, records.Count); } catch (Exception ex) { StatusText = $"导出失败:{ex.Message}"; + Log.Error(ex, "Realtime data export failed. Path={Path}, Records={RecordCount}.", dialog.FileName, records.Count); MessageBox.Show(StatusText, "导出实时数据", MessageBoxButton.OK, MessageBoxImage.Error); } } diff --git a/ConeCalorimeter/ViewModels/ReportPageViewModel.cs b/ConeCalorimeter/ViewModels/ReportPageViewModel.cs index 2b539e9..f77d865 100644 --- a/ConeCalorimeter/ViewModels/ReportPageViewModel.cs +++ b/ConeCalorimeter/ViewModels/ReportPageViewModel.cs @@ -8,6 +8,7 @@ using CommunityToolkit.Mvvm.Input; using ConeCalorimeter.Models; using ConeCalorimeter.Services; using Microsoft.Win32; +using Serilog; namespace ConeCalorimeter.ViewModels; @@ -155,6 +156,7 @@ public sealed class ReportPageViewModel : PageViewModel if (!records.Any(IsValidExperimentRecord)) { StatusText = "请先点击“测试开始”,产生有效实验数据后再导出报表"; + Log.Warning("Report export blocked because no valid experiment records were available. Records={RecordCount}.", records.Count); return; } @@ -180,10 +182,16 @@ public sealed class ReportPageViewModel : PageViewModel { _reportExportService.Export(dialog.FileName, input, records); StatusText = $"已导出:{dialog.FileName}"; + Log.Information( + "Report export succeeded. Path={Path}, Records={RecordCount}, SampleName={SampleName}.", + dialog.FileName, + records.Count, + input.SampleName); } catch (Exception ex) { StatusText = $"导出失败:{ex.Message}"; + Log.Error(ex, "Report export failed. Path={Path}, Records={RecordCount}.", dialog.FileName, records.Count); MessageBox.Show(StatusText, "导出报表", MessageBoxButton.OK, MessageBoxImage.Error); } }