diff --git a/ConeCalorimeter/Services/ModbusFloatSelector.cs b/ConeCalorimeter/Services/ModbusFloatSelector.cs index 25fc2ae..fe66030 100644 --- a/ConeCalorimeter/Services/ModbusFloatSelector.cs +++ b/ConeCalorimeter/Services/ModbusFloatSelector.cs @@ -2,6 +2,14 @@ namespace ConeCalorimeter.Services; internal static class ModbusFloatSelector { + private static readonly ModbusFloatByteOrder[] FloatByteOrders = + [ + ModbusFloatByteOrder.Abcd, + ModbusFloatByteOrder.Cdab, + ModbusFloatByteOrder.Badc, + ModbusFloatByteOrder.Dcba + ]; + private static readonly ModbusFloatByteOrder[] AlternateFloatByteOrders = [ ModbusFloatByteOrder.Cdab, @@ -15,22 +23,33 @@ internal static class ModbusFloatSelector double maximum, out ModbusFloatByteOrder byteOrder, out double value, - out int matchCount) + out int matchCount, + ModbusFloatByteOrder? preferredByteOrder = null) { - var alternateMatches = AlternateFloatByteOrders + var matches = FloatByteOrders .Where(candidate => IsInRange(result.GetValue(candidate), minimum, maximum)) .ToArray(); - var isAbcdInRange = IsInRange(result.Abcd, minimum, maximum); - matchCount = alternateMatches.Length + (isAbcdInRange ? 1 : 0); + matchCount = matches.Length; - if (isAbcdInRange) + if (preferredByteOrder is { } preferred && matches.Contains(preferred)) + { + byteOrder = preferred; + value = result.GetValue(byteOrder); + return true; + } + + if (matches.Contains(ModbusFloatByteOrder.Abcd)) { byteOrder = ModbusFloatByteOrder.Abcd; value = result.Abcd; return true; } + var alternateMatches = matches + .Where(candidate => candidate != ModbusFloatByteOrder.Abcd) + .ToArray(); + if (alternateMatches.Length != 1) { byteOrder = default; diff --git a/ConeCalorimeter/Services/ModbusRealtimeDataService.cs b/ConeCalorimeter/Services/ModbusRealtimeDataService.cs index dfb3742..7cc91ec 100644 --- a/ConeCalorimeter/Services/ModbusRealtimeDataService.cs +++ b/ConeCalorimeter/Services/ModbusRealtimeDataService.cs @@ -5,6 +5,9 @@ namespace ConeCalorimeter.Services; public sealed class ModbusRealtimeDataService : IRealtimeDataService { + private const int ScaleCurrentMassStabilityKey = -1; + private const double RealtimeZeroTolerance = 0.005; + private const int ZeroValueStableReadCount = 2; private const ushort OxygenRegister = 10; private const ushort OrificeFlowRegister = 14; private const ushort OrificePressureRegister = 16; @@ -23,10 +26,21 @@ public sealed class ModbusRealtimeDataService : IRealtimeDataService private const ushort IgnitionSecondsRegister = 1014; private const ushort TestSecondsRegister = 1015; private const ushort M3FlameMonitorBit = 3; + private static readonly ModbusFloatByteOrder[] RealtimeFloatByteOrders = + [ + ModbusFloatByteOrder.Abcd, + ModbusFloatByteOrder.Cdab, + ModbusFloatByteOrder.Badc, + ModbusFloatByteOrder.Dcba + ]; + private readonly ITcpDeviceConnectionService _tcpDeviceConnectionService; private readonly IScaleService _scaleService; private readonly HashSet _loggedFloatDiagnostics = []; private readonly HashSet _loggedInvalidFloatDiagnostics = []; + private readonly Dictionary _stableRealtimeValues = []; + private readonly Dictionary _pendingZeroReadCounts = []; + private ModbusFloatByteOrder? _preferredFloatByteOrder; public ModbusRealtimeDataService( ITcpDeviceConnectionService tcpDeviceConnectionService, @@ -38,12 +52,18 @@ public sealed class ModbusRealtimeDataService : IRealtimeDataService public RealtimeSnapshot GetCurrentSnapshot(TimeSpan elapsed) { + // Read a non-zero temperature early so the shared realtime path can lock the PLC float word order + // before zero-capable fields such as gas concentrations and flow rates are evaluated. + var orificeTemperature = ReadRangedFloatOrEmpty("OrificeTemperature", OrificeTemperatureRegister, 200, 700); + var coneTemperature = ReadRangedFloatOrEmpty("ConeTemperature", ConeTemperatureRegister, 0, 1200); + var sampleTemperature = ReadRangedFloatOrEmpty("SampleTemperature", SampleTemperatureRegister, -50, 1200); + return new RealtimeSnapshot( OrificeFlow: ReadRangedFloatOrEmpty("OrificeFlow", OrificeFlowRegister, 0, 10), OrificePressure: ReadRangedFloatOrEmpty("OrificePressure", OrificePressureRegister, -5000, 5000), - OrificeTemperature: ReadRangedFloatOrEmpty("OrificeTemperature", OrificeTemperatureRegister, 200, 700), - ConeTemperature: ReadRangedFloatOrEmpty("ConeTemperature", ConeTemperatureRegister, 0, 1200), - SampleTemperature: ReadRangedFloatOrEmpty("SampleTemperature", SampleTemperatureRegister, -50, 1200), + OrificeTemperature: orificeTemperature, + ConeTemperature: coneTemperature, + SampleTemperature: sampleTemperature, Irradiance: ReadRangedFloatOrEmpty("Irradiance", IrradianceRegister, 0, 100), FlameDetected: ReadCoilOrFalse(M3FlameMonitorBit), Oxygen: ReadRangedFloatOrEmpty("O2", OxygenRegister, 0, 30), @@ -72,14 +92,84 @@ public sealed class ModbusRealtimeDataService : IRealtimeDataService return double.NaN; } - if (!ModbusFloatSelector.TrySelectRangedFloat(result, minimum, maximum, out var byteOrder, out var value, out var matchCount)) + if (!TrySelectRealtimeFloat(result, minimum, maximum, out var byteOrder, out var value, out var matchCount)) { LogFloatDiagnostic(label, registerAddress, result, null, matchCount); return double.NaN; } LogFloatDiagnostic(label, registerAddress, result, byteOrder, matchCount); - return NormalizeRealtimeValue(value); + return StabilizeRealtimeValue(registerAddress, value); + } + + private bool TrySelectRealtimeFloat( + ModbusFloatReadResult result, + double minimum, + double maximum, + out ModbusFloatByteOrder byteOrder, + out double value, + out int matchCount) + { + var preferredByteOrder = _preferredFloatByteOrder; + if (!ModbusFloatSelector.TrySelectRangedFloat( + result, + minimum, + maximum, + out byteOrder, + out value, + out matchCount, + preferredByteOrder)) + { + return false; + } + + if (!preferredByteOrder.HasValue + && IsZeroLike(value) + && TrySelectSingleNonZeroCandidate(result, minimum, maximum, out var nonZeroByteOrder, out var nonZeroValue)) + { + byteOrder = nonZeroByteOrder; + value = nonZeroValue; + } + + RememberPreferredFloatByteOrder(byteOrder, value); + return true; + } + + private static bool TrySelectSingleNonZeroCandidate( + ModbusFloatReadResult result, + double minimum, + double maximum, + out ModbusFloatByteOrder byteOrder, + out double value) + { + var matches = RealtimeFloatByteOrders + .Select(candidate => new + { + ByteOrder = candidate, + Value = result.GetValue(candidate) + }) + .Where(candidate => ModbusFloatSelector.IsInRange(candidate.Value, minimum, maximum) + && !IsZeroLike(candidate.Value)) + .ToArray(); + + if (matches.Length != 1) + { + byteOrder = default; + value = double.NaN; + return false; + } + + byteOrder = matches[0].ByteOrder; + value = matches[0].Value; + return true; + } + + private void RememberPreferredFloatByteOrder(ModbusFloatByteOrder byteOrder, double value) + { + if (!IsZeroLike(value)) + { + _preferredFloatByteOrder = byteOrder; + } } private void LogFloatDiagnostic( @@ -112,7 +202,43 @@ public sealed class ModbusRealtimeDataService : IRealtimeDataService private static double NormalizeRealtimeValue(double value) { - return Math.Abs(value) < 0.005 ? 0 : value; + return IsZeroLike(value) ? 0 : value; + } + + private static bool IsZeroLike(double value) + { + return double.IsFinite(value) && Math.Abs(value) < RealtimeZeroTolerance; + } + + private double StabilizeRealtimeValue(int key, double value) + { + var normalizedValue = NormalizeRealtimeValue(value); + + if (!_stableRealtimeValues.TryGetValue(key, out var stableValue)) + { + AcceptStableRealtimeValue(key, normalizedValue); + return normalizedValue; + } + + if (IsZeroLike(normalizedValue) && !IsZeroLike(stableValue)) + { + var pendingCount = _pendingZeroReadCounts.GetValueOrDefault(key) + 1; + _pendingZeroReadCounts[key] = pendingCount; + + if (pendingCount < ZeroValueStableReadCount) + { + return stableValue; + } + } + + AcceptStableRealtimeValue(key, normalizedValue); + return normalizedValue; + } + + private void AcceptStableRealtimeValue(int key, double value) + { + _stableRealtimeValues[key] = value; + _pendingZeroReadCounts.Remove(key); } private int ReadInt16OrEmpty(ushort registerAddress) @@ -125,7 +251,7 @@ public sealed class ModbusRealtimeDataService : IRealtimeDataService private double ReadScaleCurrentMassOrEmpty() { return _scaleService.TryReadCurrentMass(out var value) - ? NormalizeRealtimeValue(value) + ? StabilizeRealtimeValue(ScaleCurrentMassStabilityKey, value) : double.NaN; } diff --git a/ConeCalorimeter/ViewModels/CValueCalibrationViewModel.cs b/ConeCalorimeter/ViewModels/CValueCalibrationViewModel.cs index bd6fc95..af57a61 100644 --- a/ConeCalorimeter/ViewModels/CValueCalibrationViewModel.cs +++ b/ConeCalorimeter/ViewModels/CValueCalibrationViewModel.cs @@ -1,6 +1,7 @@ using System.Collections.ObjectModel; using System.Diagnostics; using System.Globalization; +using System.IO; using System.Windows.Threading; using CommunityToolkit.Mvvm.ComponentModel; using CommunityToolkit.Mvvm.Input; @@ -42,6 +43,12 @@ public sealed class CValueCalibrationViewModel : PageViewModel private const string MethaneValveAction = "甲烷阀"; private const string BaselineCollectionAction = "基线采集"; private const string CalibrationStartAction = "标定开始"; + private static readonly TimeSpan DiagnosticLogInterval = TimeSpan.FromSeconds(5); + private static readonly string DiagnosticLogFilePath = Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), + "ConeCalorimeter", + "Logs", + "cvalue-calibration-diagnostics.log"); private static readonly ModbusFloatByteOrder[] FloatByteOrders = [ ModbusFloatByteOrder.Abcd, @@ -61,6 +68,9 @@ public sealed class CValueCalibrationViewModel : PageViewModel private readonly Dictionary _pairedActionWaitStates = []; private readonly HashSet _loggedFloatDiagnostics = []; private readonly HashSet _loggedInvalidFloatDiagnostics = []; + private readonly Dictionary _lastFileDiagnosticByRegister = []; + private readonly Dictionary _lastFileDiagnosticAtByRegister = []; + private ModbusFloatByteOrder? _calibrationFloatByteOrder; private string _baselineOxygenText = ""; private string _temperatureText = ""; private string _pressureDifferenceText = ""; @@ -191,37 +201,51 @@ public sealed class CValueCalibrationViewModel : PageViewModel ToggleHoldAction(action); } - private void RefreshDeviceValues() + private CalibrationDataReadStatus RefreshDeviceValues() { BaselineOxygenText = ReadOxygenPercentText( "BaselineOxygen", BaselineOxygenRegister, "0.00"); - TemperatureText = ReadRangedFloatText( + var temperature = ReadRangedFloatDisplay( "Temperature", + "Te", TemperatureRegister, TemperatureMinimum, TemperatureMaximum, "0.00"); - PressureDifferenceText = ReadRangedFloatText( + TemperatureText = temperature.Text; + RememberCalibrationFloatByteOrder(temperature); + + var pressureDifference = ReadRangedFloatDisplay( "PressureDifference", + "Δp", PressureDifferenceRegister, PressureDifferenceMinimum, PressureDifferenceMaximum, "0.00"); - CalibrationOxygenText = ReadRangedFloatText( + PressureDifferenceText = pressureDifference.Text; + + var calibrationOxygen = ReadOxygenPercentDisplay( "CalibrationOxygen", + "XO2", CalibrationOxygenRegister, - OxygenMinimum, - OxygenMaximum, "0.00"); - CValueText = ReadRangedFloatText( + + CalibrationOxygenText = calibrationOxygen.Text; + RememberCalibrationFloatByteOrder(calibrationOxygen); + + var cValue = ReadRangedFloatDisplay( "CValue", + "C", CValueRegister, CValueMinimum, CValueMaximum, "0.00"); + CValueText = cValue.Text; + RefreshHoldActionStates(); + return new CalibrationDataReadStatus(pressureDifference, calibrationOxygen, cValue); } private string ReadRangedFloatText( @@ -230,44 +254,82 @@ public sealed class CValueCalibrationViewModel : PageViewModel double minimum, double maximum, string format) + { + return ReadRangedFloatDisplay(label, label, registerAddress, minimum, maximum, format).Text; + } + + private DisplayValueReadResult ReadRangedFloatDisplay( + string label, + string displayLabel, + ushort registerAddress, + double minimum, + double maximum, + string format) { if (!_tcpDeviceConnectionService.TryReadFloatValues(registerAddress, out var result)) { - return string.Empty; + LogFloatReadFailure(label, registerAddress, "读取失败"); + return DisplayValueReadResult.Failed(displayLabel, "读取失败"); } - if (!ModbusFloatSelector.TrySelectRangedFloat(result, minimum, maximum, out var byteOrder, out var value, out var matchCount)) + var preferredByteOrder = _calibrationFloatByteOrder; + if (!ModbusFloatSelector.TrySelectRangedFloat( + result, + minimum, + maximum, + out var byteOrder, + out var value, + out var matchCount, + preferredByteOrder)) { - LogFloatDiagnostic(label, registerAddress, result, null, matchCount); - return string.Empty; + LogFloatDiagnostic(label, registerAddress, result, null, matchCount, preferredByteOrder); + return DisplayValueReadResult.Failed(displayLabel, "无有效值"); } - LogFloatDiagnostic(label, registerAddress, result, byteOrder, matchCount); - return NormalizeDisplayValue(value).ToString(format, CultureInfo.InvariantCulture); + LogFloatDiagnostic(label, registerAddress, result, byteOrder, matchCount, preferredByteOrder); + return DisplayValueReadResult.Valid( + displayLabel, + NormalizeDisplayValue(value).ToString(format, CultureInfo.InvariantCulture), + byteOrder); } private string ReadOxygenPercentText( string label, ushort registerAddress, string format) + { + return ReadOxygenPercentDisplay(label, label, registerAddress, format).Text; + } + + private DisplayValueReadResult ReadOxygenPercentDisplay( + string label, + string displayLabel, + ushort registerAddress, + string format) { if (!_tcpDeviceConnectionService.TryReadFloatValues(registerAddress, out var result)) { - return string.Empty; + LogFloatReadFailure(label, registerAddress, "读取失败"); + return DisplayValueReadResult.Failed(displayLabel, "读取失败"); } - if (!TrySelectOxygenPercent(result, out var byteOrder, out var value, out var matchCount)) + var preferredByteOrder = _calibrationFloatByteOrder; + if (!TrySelectOxygenPercent(result, preferredByteOrder, out var byteOrder, out var value, out var matchCount)) { - LogFloatDiagnostic(label, registerAddress, result, null, matchCount); - return string.Empty; + LogFloatDiagnostic(label, registerAddress, result, null, matchCount, preferredByteOrder); + return DisplayValueReadResult.Failed(displayLabel, "无有效值"); } - LogFloatDiagnostic(label, registerAddress, result, byteOrder, matchCount); - return NormalizeDisplayValue(value).ToString(format, CultureInfo.InvariantCulture); + LogFloatDiagnostic(label, registerAddress, result, byteOrder, matchCount, preferredByteOrder); + return DisplayValueReadResult.Valid( + displayLabel, + NormalizeDisplayValue(value).ToString(format, CultureInfo.InvariantCulture), + byteOrder); } private static bool TrySelectOxygenPercent( ModbusFloatReadResult result, + ModbusFloatByteOrder? preferredByteOrder, out ModbusFloatByteOrder byteOrder, out double value, out int matchCount) @@ -279,8 +341,15 @@ public sealed class CValueCalibrationViewModel : PageViewModel matchCount = candidates.Length; - var selected = candidates.FirstOrDefault( - candidate => candidate.ByteOrder == ModbusFloatByteOrder.Abcd && candidate.IsPlausible); + var selected = preferredByteOrder is { } plausiblePreferred + ? candidates.FirstOrDefault(candidate => candidate.ByteOrder == plausiblePreferred && candidate.IsPlausible) + : default; + + if (!selected.IsValid) + { + selected = candidates.FirstOrDefault( + candidate => candidate.ByteOrder == ModbusFloatByteOrder.Abcd && candidate.IsPlausible); + } if (!selected.IsValid) { @@ -294,6 +363,13 @@ public sealed class CValueCalibrationViewModel : PageViewModel } } + if (!selected.IsValid) + { + selected = preferredByteOrder is { } validPreferred + ? candidates.FirstOrDefault(candidate => candidate.ByteOrder == validPreferred) + : default; + } + if (!selected.IsValid) { selected = candidates.FirstOrDefault(candidate => candidate.ByteOrder == ModbusFloatByteOrder.Abcd); @@ -351,8 +427,21 @@ public sealed class CValueCalibrationViewModel : PageViewModel ushort registerAddress, ModbusFloatReadResult result, ModbusFloatByteOrder? selectedByteOrder, - int matchCount) + int matchCount, + ModbusFloatByteOrder? preferredByteOrder) { + var selectedText = selectedByteOrder?.ToString() ?? "none"; + var preferredText = preferredByteOrder?.ToString() ?? "none"; + var statusText = selectedByteOrder is null ? "无有效值" : "读取成功"; + var diagnosticMessage = + $"CValueCalibration label={label} register=D{registerAddress} endpoint={_tcpDeviceConnectionService.Endpoint} " + + $"status={statusText} raw=[{result.RawHex}] " + + $"ABCD={FormatDiagnosticFloat(result.Abcd)} CDAB={FormatDiagnosticFloat(result.Cdab)} " + + $"BADC={FormatDiagnosticFloat(result.Badc)} DCBA={FormatDiagnosticFloat(result.Dcba)} " + + $"preferred={preferredText} selected={selectedText} matches={matchCount}"; + + WriteFloatDiagnosticToFile(registerAddress, diagnosticMessage); + if (selectedByteOrder is null) { if (!_loggedInvalidFloatDiagnostics.Add(registerAddress)) @@ -365,13 +454,66 @@ public sealed class CValueCalibrationViewModel : PageViewModel return; } - var selectedText = selectedByteOrder?.ToString() ?? "none"; - Debug.WriteLine( $"C value calibration float {label} register {registerAddress} raw [{result.RawHex}], " + $"ABCD={result.Abcd:G9}, CDAB={result.Cdab:G9}, " + $"BADC={result.Badc:G9}, DCBA={result.Dcba:G9}, " - + $"selected={selectedText}, matches={matchCount}."); + + $"preferred={preferredText}, selected={selectedText}, matches={matchCount}."); + } + + private void LogFloatReadFailure(string label, ushort registerAddress, string statusText) + { + var diagnosticMessage = + $"CValueCalibration label={label} register=D{registerAddress} endpoint={_tcpDeviceConnectionService.Endpoint} " + + $"status={statusText} connection=\"{_tcpDeviceConnectionService.StatusText}\""; + + WriteFloatDiagnosticToFile(registerAddress, diagnosticMessage); + Debug.WriteLine($"C value calibration float {label} register {registerAddress} {statusText}."); + } + + private void WriteFloatDiagnosticToFile(ushort registerAddress, string message) + { + var now = DateTime.Now; + if (_lastFileDiagnosticByRegister.TryGetValue(registerAddress, out var lastMessage) + && string.Equals(lastMessage, message, StringComparison.Ordinal) + && _lastFileDiagnosticAtByRegister.TryGetValue(registerAddress, out var lastLoggedAt) + && now - lastLoggedAt < DiagnosticLogInterval) + { + return; + } + + _lastFileDiagnosticByRegister[registerAddress] = message; + _lastFileDiagnosticAtByRegister[registerAddress] = now; + + try + { + var directory = Path.GetDirectoryName(DiagnosticLogFilePath); + if (!string.IsNullOrWhiteSpace(directory)) + { + Directory.CreateDirectory(directory); + } + + File.AppendAllText( + DiagnosticLogFilePath, + $"{now:yyyy-MM-dd HH:mm:ss.fff} {message}{Environment.NewLine}"); + } + catch (Exception ex) + { + Debug.WriteLine($"C value calibration diagnostic log write failed: {ex.Message}"); + } + } + + private void RememberCalibrationFloatByteOrder(DisplayValueReadResult result) + { + if (result.ByteOrder is { } byteOrder) + { + _calibrationFloatByteOrder = byteOrder; + } + } + + private static string FormatDiagnosticFloat(double value) + { + return value.ToString("G9", CultureInfo.InvariantCulture); } private static double NormalizeDisplayValue(double value) @@ -525,7 +667,7 @@ public sealed class CValueCalibrationViewModel : PageViewModel continue; } - CompletePairedAction(waitState, actionViewModel); + CompletePairedAction(action, waitState, actionViewModel); } if (!hasWaitingAction) @@ -535,10 +677,14 @@ public sealed class CValueCalibrationViewModel : PageViewModel } private void CompletePairedAction( + string action, PairedActionWaitState waitState, CValueCalibrationActionViewModel actionViewModel) { - LastAction = waitState.CompletedText; + var calibrationDataStatus = RefreshDeviceValues(); + LastAction = action == CalibrationStartAction && !calibrationDataStatus.HasAllValues + ? $"{waitState.CompletedText},{calibrationDataStatus.MissingSummary}" + : waitState.CompletedText; waitState.End(); try @@ -633,6 +779,55 @@ public sealed class CValueCalibrationViewModel : PageViewModel bool IsPlausible, bool IsValid); + private readonly record struct DisplayValueReadResult( + string DisplayLabel, + string Text, + bool HasValue, + string FailureDescription, + ModbusFloatByteOrder? ByteOrder) + { + public static DisplayValueReadResult Valid( + string displayLabel, + string text, + ModbusFloatByteOrder byteOrder) + { + return new DisplayValueReadResult(displayLabel, text, true, string.Empty, byteOrder); + } + + public static DisplayValueReadResult Failed(string displayLabel, string failureDescription) + { + return new DisplayValueReadResult(displayLabel, string.Empty, false, failureDescription, null); + } + } + + private readonly record struct CalibrationDataReadStatus( + DisplayValueReadResult PressureDifference, + DisplayValueReadResult CalibrationOxygen, + DisplayValueReadResult CValue) + { + public bool HasAllValues => PressureDifference.HasValue && CalibrationOxygen.HasValue && CValue.HasValue; + + public string MissingSummary + { + get + { + var failures = new List(3); + AddFailure(failures, PressureDifference); + AddFailure(failures, CalibrationOxygen); + AddFailure(failures, CValue); + return string.Join(";", failures); + } + } + + private static void AddFailure(List failures, DisplayValueReadResult result) + { + if (!result.HasValue) + { + failures.Add($"{result.DisplayLabel}{result.FailureDescription}"); + } + } + } + private sealed class PairedActionWaitState( ushort completionCoilAddress, string waitingText,