This commit is contained in:
GukSang.Jin
2026-05-26 19:53:14 +08:00
parent ededa3a80c
commit 0c4393bebc
3 changed files with 379 additions and 39 deletions

View File

@@ -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;

View File

@@ -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<ushort> _loggedFloatDiagnostics = [];
private readonly HashSet<ushort> _loggedInvalidFloatDiagnostics = [];
private readonly Dictionary<int, double> _stableRealtimeValues = [];
private readonly Dictionary<int, int> _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;
}

View File

@@ -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<string, PairedActionWaitState> _pairedActionWaitStates = [];
private readonly HashSet<ushort> _loggedFloatDiagnostics = [];
private readonly HashSet<ushort> _loggedInvalidFloatDiagnostics = [];
private readonly Dictionary<ushort, string> _lastFileDiagnosticByRegister = [];
private readonly Dictionary<ushort, DateTime> _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<string>(3);
AddFailure(failures, PressureDifference);
AddFailure(failures, CalibrationOxygen);
AddFailure(failures, CValue);
return string.Join("", failures);
}
}
private static void AddFailure(List<string> failures, DisplayValueReadResult result)
{
if (!result.HasValue)
{
failures.Add($"{result.DisplayLabel}{result.FailureDescription}");
}
}
}
private sealed class PairedActionWaitState(
ushort completionCoilAddress,
string waitingText,