600 lines
20 KiB
C#
600 lines
20 KiB
C#
using System.Diagnostics;
|
|
using System.Globalization;
|
|
using System.Windows.Threading;
|
|
using CommunityToolkit.Mvvm.Input;
|
|
using ConeCalorimeter.Models;
|
|
using ConeCalorimeter.Services;
|
|
|
|
namespace ConeCalorimeter.ViewModels;
|
|
|
|
public sealed class ConeRadiationSettingsViewModel : PageViewModel
|
|
{
|
|
private const ushort CurrentHeatFluxRegister = 32;
|
|
private const ushort TargetTemperatureRegister = 400;
|
|
private const ushort HeatTransferRegister = 418;
|
|
private const double TargetTemperatureScale = 10;
|
|
private const double CurrentTemperatureMinimum = 10;
|
|
private const double CurrentTemperatureMaximum = 1200;
|
|
private const double CurrentTemperatureMaximumDropPerRefresh = 80;
|
|
private const double CurrentHeatFluxMinimum = 0;
|
|
private const double CurrentHeatFluxMaximum = 1000;
|
|
private const double CurrentHeatFluxZeroTolerance = 0.005;
|
|
private const double CurrentHeatFluxStableAbsoluteTolerance = 0.25;
|
|
private const double CurrentHeatFluxStableRelativeTolerance = 0.1;
|
|
private const double CurrentHeatFluxStableMaximumTolerance = 5;
|
|
private const int CurrentHeatFluxStableReadCount = 3;
|
|
private const int CurrentHeatFluxZeroStableReadCount = 10;
|
|
private const double HeatTransferInputMinimum = 0;
|
|
private const double HeatTransferReadMinimum = 0.01;
|
|
private const double HeatTransferMaximum = 20000;
|
|
private const int ParameterRefreshReleaseDelayMilliseconds = 500;
|
|
private const ushort AlarmCoil = 91;
|
|
private const ushort CirculatingWaterCoil = 49;
|
|
private const ushort HeatingCoil = 102;
|
|
private const string CirculatingWaterAction = "循环水";
|
|
|
|
private readonly Action _closeAction;
|
|
private readonly Action _helpAction;
|
|
private readonly IExperimentDataService _experimentDataService;
|
|
private readonly ITcpDeviceConnectionService _tcpDeviceConnectionService;
|
|
private readonly DispatcherTimer _refreshTimer;
|
|
private readonly HashSet<ushort> _loggedFloatDiagnostics = [];
|
|
private readonly HashSet<ushort> _loggedInvalidFloatDiagnostics = [];
|
|
private static readonly ModbusFloatByteOrder[] CurrentHeatFluxByteOrders =
|
|
[
|
|
ModbusFloatByteOrder.Abcd,
|
|
ModbusFloatByteOrder.Cdab,
|
|
ModbusFloatByteOrder.Badc,
|
|
ModbusFloatByteOrder.Dcba
|
|
];
|
|
|
|
private string _currentTemperatureText = "";
|
|
private string _currentHeatFluxText = "";
|
|
private string _targetTemperatureText = "";
|
|
private string _heatTransferText = "";
|
|
private string _lastAction = "待机";
|
|
private bool _alarmActive;
|
|
private bool _circulatingWaterActive;
|
|
private bool _heatingActive;
|
|
private bool _isEditingConeParameters;
|
|
private double? _lastStableCurrentTemperature;
|
|
private double? _lastStableCurrentHeatFlux;
|
|
private double? _pendingCurrentHeatFlux;
|
|
private int _pendingCurrentHeatFluxReadCount;
|
|
private int _pendingCurrentHeatFluxZeroReadCount;
|
|
private ModbusFloatByteOrder? _currentHeatFluxByteOrder;
|
|
private DateTime _parameterRefreshBlockedUntil = DateTime.MinValue;
|
|
|
|
public ConeRadiationSettingsViewModel(
|
|
Action closeAction,
|
|
Action helpAction,
|
|
IExperimentDataService experimentDataService,
|
|
ITcpDeviceConnectionService tcpDeviceConnectionService) : base("辐射锥设置")
|
|
{
|
|
_closeAction = closeAction;
|
|
_helpAction = helpAction;
|
|
_experimentDataService = experimentDataService;
|
|
_tcpDeviceConnectionService = tcpDeviceConnectionService;
|
|
CloseCommand = new RelayCommand(_closeAction);
|
|
HelpCommand = new RelayCommand(_helpAction);
|
|
ActionCommand = new RelayCommand<string>(ExecuteAction);
|
|
SaveParametersCommand = new RelayCommand(SaveParameters);
|
|
|
|
UpdateCurrentReadings(_experimentDataService.CurrentSnapshot);
|
|
_experimentDataService.SnapshotUpdated += (_, snapshot) => UpdateCurrentReadings(snapshot);
|
|
RefreshDeviceValues();
|
|
_refreshTimer = new DispatcherTimer
|
|
{
|
|
Interval = TimeSpan.FromSeconds(1)
|
|
};
|
|
_refreshTimer.Tick += (_, _) => RefreshDeviceValues();
|
|
_refreshTimer.Start();
|
|
}
|
|
|
|
public IRelayCommand CloseCommand { get; }
|
|
|
|
public IRelayCommand HelpCommand { get; }
|
|
|
|
public IRelayCommand<string> ActionCommand { get; }
|
|
|
|
public IRelayCommand SaveParametersCommand { get; }
|
|
|
|
public string CurrentTemperatureText
|
|
{
|
|
get => _currentTemperatureText;
|
|
private set => SetProperty(ref _currentTemperatureText, value);
|
|
}
|
|
|
|
public string CurrentHeatFluxText
|
|
{
|
|
get => _currentHeatFluxText;
|
|
private set => SetProperty(ref _currentHeatFluxText, value);
|
|
}
|
|
|
|
public string TargetTemperatureText
|
|
{
|
|
get => _targetTemperatureText;
|
|
set => SetProperty(ref _targetTemperatureText, value);
|
|
}
|
|
|
|
public string HeatTransferText
|
|
{
|
|
get => _heatTransferText;
|
|
set => SetProperty(ref _heatTransferText, value);
|
|
}
|
|
|
|
public string LastAction
|
|
{
|
|
get => _lastAction;
|
|
private set => SetProperty(ref _lastAction, value);
|
|
}
|
|
|
|
public bool AlarmActive
|
|
{
|
|
get => _alarmActive;
|
|
private set => SetProperty(ref _alarmActive, value);
|
|
}
|
|
|
|
public bool CirculatingWaterActive
|
|
{
|
|
get => _circulatingWaterActive;
|
|
private set
|
|
{
|
|
if (SetProperty(ref _circulatingWaterActive, value))
|
|
{
|
|
OnPropertyChanged(nameof(CirculatingWaterButtonText));
|
|
}
|
|
}
|
|
}
|
|
|
|
public string CirculatingWaterButtonText => CirculatingWaterActive ? "循环水:开启" : "循环水:关闭";
|
|
|
|
public bool HeatingActive
|
|
{
|
|
get => _heatingActive;
|
|
private set
|
|
{
|
|
if (SetProperty(ref _heatingActive, value))
|
|
{
|
|
OnPropertyChanged(nameof(StartHeatingButtonText));
|
|
}
|
|
}
|
|
}
|
|
|
|
public string StartHeatingButtonText => HeatingActive ? "加热中" : "开始升温";
|
|
|
|
public void BeginConeParameterEdit()
|
|
{
|
|
_isEditingConeParameters = true;
|
|
}
|
|
|
|
public void EndConeParameterEdit()
|
|
{
|
|
_isEditingConeParameters = false;
|
|
_parameterRefreshBlockedUntil = DateTime.UtcNow.AddMilliseconds(ParameterRefreshReleaseDelayMilliseconds);
|
|
}
|
|
|
|
private void ExecuteAction(string? action)
|
|
{
|
|
if (string.IsNullOrWhiteSpace(action))
|
|
{
|
|
return;
|
|
}
|
|
|
|
LastAction = action;
|
|
|
|
if (action == CirculatingWaterAction)
|
|
{
|
|
ToggleCirculatingWater();
|
|
return;
|
|
}
|
|
|
|
if (action == "开始升温")
|
|
{
|
|
if (WriteTargetTemperature())
|
|
{
|
|
WriteActionCoil(action);
|
|
}
|
|
|
|
return;
|
|
}
|
|
|
|
WriteActionCoil(action);
|
|
}
|
|
|
|
private void RefreshDeviceValues()
|
|
{
|
|
if (TryReadCurrentHeatFlux(out var currentHeatFlux))
|
|
{
|
|
if (TryUpdateStableCurrentHeatFlux(currentHeatFlux, out var stableCurrentHeatFlux))
|
|
{
|
|
CurrentHeatFluxText = stableCurrentHeatFlux.ToString("0.00", CultureInfo.InvariantCulture);
|
|
}
|
|
}
|
|
else if (!_lastStableCurrentHeatFlux.HasValue)
|
|
{
|
|
CurrentHeatFluxText = string.Empty;
|
|
}
|
|
|
|
if (CanRefreshConeParameters())
|
|
{
|
|
TargetTemperatureText = ReadScaledInt16Text(TargetTemperatureRegister, TargetTemperatureScale, "0.#");
|
|
if (TryReadRangedFloatText(
|
|
"ConeHeatTransfer",
|
|
HeatTransferRegister,
|
|
HeatTransferReadMinimum,
|
|
HeatTransferMaximum,
|
|
"0.##",
|
|
out var heatTransferText,
|
|
out _))
|
|
{
|
|
HeatTransferText = heatTransferText;
|
|
}
|
|
}
|
|
|
|
AlarmActive = _tcpDeviceConnectionService.TryReadCoil(AlarmCoil, out var alarmActive) && alarmActive;
|
|
HeatingActive = _tcpDeviceConnectionService.TryReadCoil(HeatingCoil, out var heatingActive) && heatingActive;
|
|
if (_tcpDeviceConnectionService.TryReadCoil(CirculatingWaterCoil, out var circulatingWaterActive))
|
|
{
|
|
CirculatingWaterActive = circulatingWaterActive;
|
|
}
|
|
}
|
|
|
|
private void UpdateCurrentReadings(RealtimeSnapshot snapshot)
|
|
{
|
|
if (IsStableCurrentTemperature(snapshot.ConeTemperature))
|
|
{
|
|
_lastStableCurrentTemperature = snapshot.ConeTemperature;
|
|
CurrentTemperatureText = NormalizeDisplayValue(snapshot.ConeTemperature)
|
|
.ToString("0.0", CultureInfo.InvariantCulture);
|
|
}
|
|
}
|
|
|
|
private bool TryReadCurrentHeatFlux(out double value)
|
|
{
|
|
value = double.NaN;
|
|
|
|
if (!_tcpDeviceConnectionService.TryReadFloatValues(CurrentHeatFluxRegister, out var result))
|
|
{
|
|
return false;
|
|
}
|
|
|
|
var preferredByteOrder = _currentHeatFluxByteOrder;
|
|
if (!ModbusFloatSelector.TrySelectRangedFloat(
|
|
result,
|
|
CurrentHeatFluxMinimum,
|
|
CurrentHeatFluxMaximum,
|
|
out var byteOrder,
|
|
out var selectedValue,
|
|
out var matchCount,
|
|
preferredByteOrder))
|
|
{
|
|
if (!TrySelectSingleNonZeroCurrentHeatFluxCandidate(result, out byteOrder, out selectedValue))
|
|
{
|
|
LogFloatDiagnostic("ConeCurrentHeatFlux", CurrentHeatFluxRegister, result, null, matchCount);
|
|
return false;
|
|
}
|
|
}
|
|
|
|
if (!preferredByteOrder.HasValue
|
|
&& IsZeroLikeCurrentHeatFlux(selectedValue)
|
|
&& TrySelectSingleNonZeroCurrentHeatFluxCandidate(result, out var nonZeroByteOrder, out var nonZeroValue))
|
|
{
|
|
byteOrder = nonZeroByteOrder;
|
|
selectedValue = nonZeroValue;
|
|
}
|
|
|
|
LogFloatDiagnostic("ConeCurrentHeatFlux", CurrentHeatFluxRegister, result, byteOrder, matchCount);
|
|
RememberCurrentHeatFluxByteOrder(byteOrder, selectedValue);
|
|
value = NormalizeDisplayValue(selectedValue);
|
|
return true;
|
|
}
|
|
|
|
private bool TryReadRangedFloatText(
|
|
string label,
|
|
ushort registerAddress,
|
|
double minimum,
|
|
double maximum,
|
|
string format,
|
|
out string text,
|
|
out double value)
|
|
{
|
|
text = string.Empty;
|
|
value = double.NaN;
|
|
|
|
if (!_tcpDeviceConnectionService.TryReadFloatValues(registerAddress, out var result))
|
|
{
|
|
return false;
|
|
}
|
|
|
|
if (!ModbusFloatSelector.TrySelectRangedFloat(result, minimum, maximum, out var byteOrder, out var selectedValue, out var matchCount))
|
|
{
|
|
LogFloatDiagnostic(label, registerAddress, result, null, matchCount);
|
|
return false;
|
|
}
|
|
|
|
value = selectedValue;
|
|
LogFloatDiagnostic(label, registerAddress, result, byteOrder, matchCount);
|
|
text = NormalizeDisplayValue(value).ToString(format, CultureInfo.InvariantCulture);
|
|
return true;
|
|
}
|
|
|
|
private bool IsStableCurrentTemperature(double value)
|
|
{
|
|
if (!ModbusFloatSelector.IsInRange(value, CurrentTemperatureMinimum, CurrentTemperatureMaximum))
|
|
{
|
|
return false;
|
|
}
|
|
|
|
return !_lastStableCurrentTemperature.HasValue
|
|
|| value >= _lastStableCurrentTemperature.Value - CurrentTemperatureMaximumDropPerRefresh;
|
|
}
|
|
|
|
private bool TryUpdateStableCurrentHeatFlux(double value, out double stableValue)
|
|
{
|
|
value = NormalizeDisplayValue(value);
|
|
stableValue = _lastStableCurrentHeatFlux ?? value;
|
|
|
|
if (IsZeroLikeCurrentHeatFlux(value))
|
|
{
|
|
_pendingCurrentHeatFlux = null;
|
|
_pendingCurrentHeatFluxReadCount = 0;
|
|
_pendingCurrentHeatFluxZeroReadCount++;
|
|
|
|
if (_pendingCurrentHeatFluxZeroReadCount < CurrentHeatFluxZeroStableReadCount)
|
|
{
|
|
return false;
|
|
}
|
|
|
|
AcceptStableCurrentHeatFlux(0);
|
|
stableValue = 0;
|
|
return true;
|
|
}
|
|
|
|
_pendingCurrentHeatFluxZeroReadCount = 0;
|
|
|
|
if (_lastStableCurrentHeatFlux.HasValue
|
|
&& IsWithinCurrentHeatFluxStableTolerance(value, _lastStableCurrentHeatFlux.Value))
|
|
{
|
|
AcceptStableCurrentHeatFlux(value);
|
|
stableValue = value;
|
|
return true;
|
|
}
|
|
|
|
if (!_pendingCurrentHeatFlux.HasValue
|
|
|| !IsWithinCurrentHeatFluxStableTolerance(value, _pendingCurrentHeatFlux.Value))
|
|
{
|
|
_pendingCurrentHeatFlux = value;
|
|
_pendingCurrentHeatFluxReadCount = 1;
|
|
return false;
|
|
}
|
|
|
|
_pendingCurrentHeatFluxReadCount++;
|
|
|
|
if (_pendingCurrentHeatFluxReadCount < CurrentHeatFluxStableReadCount)
|
|
{
|
|
return false;
|
|
}
|
|
|
|
AcceptStableCurrentHeatFlux(value);
|
|
stableValue = value;
|
|
return true;
|
|
}
|
|
|
|
private void AcceptStableCurrentHeatFlux(double value)
|
|
{
|
|
_lastStableCurrentHeatFlux = value;
|
|
_pendingCurrentHeatFlux = null;
|
|
_pendingCurrentHeatFluxReadCount = 0;
|
|
_pendingCurrentHeatFluxZeroReadCount = 0;
|
|
}
|
|
|
|
private static bool TrySelectSingleNonZeroCurrentHeatFluxCandidate(
|
|
ModbusFloatReadResult result,
|
|
out ModbusFloatByteOrder byteOrder,
|
|
out double value)
|
|
{
|
|
var matches = CurrentHeatFluxByteOrders
|
|
.Select(candidate => new
|
|
{
|
|
ByteOrder = candidate,
|
|
Value = result.GetValue(candidate)
|
|
})
|
|
.Where(candidate => ModbusFloatSelector.IsInRange(
|
|
candidate.Value,
|
|
CurrentHeatFluxMinimum,
|
|
CurrentHeatFluxMaximum)
|
|
&& !IsZeroLikeCurrentHeatFlux(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 RememberCurrentHeatFluxByteOrder(ModbusFloatByteOrder byteOrder, double value)
|
|
{
|
|
if (!IsZeroLikeCurrentHeatFlux(value))
|
|
{
|
|
_currentHeatFluxByteOrder = byteOrder;
|
|
}
|
|
}
|
|
|
|
private static bool IsWithinCurrentHeatFluxStableTolerance(double value, double referenceValue)
|
|
{
|
|
return Math.Abs(value - referenceValue) <= GetCurrentHeatFluxStableTolerance(referenceValue);
|
|
}
|
|
|
|
private static double GetCurrentHeatFluxStableTolerance(double referenceValue)
|
|
{
|
|
return Math.Min(
|
|
CurrentHeatFluxStableMaximumTolerance,
|
|
Math.Max(CurrentHeatFluxStableAbsoluteTolerance, Math.Abs(referenceValue) * CurrentHeatFluxStableRelativeTolerance));
|
|
}
|
|
|
|
private static bool IsZeroLikeCurrentHeatFlux(double value)
|
|
{
|
|
return double.IsFinite(value) && Math.Abs(value) < CurrentHeatFluxZeroTolerance;
|
|
}
|
|
|
|
private string ReadScaledInt16Text(ushort registerAddress, double scale, string format)
|
|
{
|
|
return _tcpDeviceConnectionService.TryReadInt16(registerAddress, out var rawValue)
|
|
? (rawValue / scale).ToString(format, CultureInfo.InvariantCulture)
|
|
: string.Empty;
|
|
}
|
|
|
|
private bool WriteTargetTemperature()
|
|
{
|
|
if (!double.TryParse(TargetTemperatureText, NumberStyles.Float, CultureInfo.InvariantCulture, out var value)
|
|
|| !double.IsFinite(value))
|
|
{
|
|
LastAction = "辐射温度输入无效";
|
|
Debug.WriteLine($"Invalid cone radiation target temperature: {TargetTemperatureText}");
|
|
return false;
|
|
}
|
|
|
|
var scaledValue = Math.Round(value * TargetTemperatureScale, MidpointRounding.AwayFromZero);
|
|
if (scaledValue is < short.MinValue or > short.MaxValue)
|
|
{
|
|
LastAction = "辐射温度输入超出范围";
|
|
Debug.WriteLine($"Cone radiation target temperature out of range: {TargetTemperatureText}");
|
|
return false;
|
|
}
|
|
|
|
if (_tcpDeviceConnectionService.TryWriteInt16(TargetTemperatureRegister, (short)scaledValue))
|
|
{
|
|
TargetTemperatureText = value.ToString("0.#", CultureInfo.InvariantCulture);
|
|
return true;
|
|
}
|
|
|
|
LastAction = "设置辐射温度失败";
|
|
Debug.WriteLine("Cone radiation target temperature write failed.");
|
|
return false;
|
|
}
|
|
|
|
private void SaveParameters()
|
|
{
|
|
if (!float.TryParse(HeatTransferText, NumberStyles.Float, CultureInfo.InvariantCulture, out var heatTransfer))
|
|
{
|
|
LastAction = "热传递输入无效";
|
|
Debug.WriteLine($"Invalid cone radiation heat transfer: {HeatTransferText}");
|
|
return;
|
|
}
|
|
|
|
if (!ModbusFloatSelector.IsInRange(heatTransfer, HeatTransferInputMinimum, HeatTransferMaximum))
|
|
{
|
|
LastAction = "热传递输入超出范围";
|
|
Debug.WriteLine($"Cone radiation heat transfer out of range: {HeatTransferText}");
|
|
return;
|
|
}
|
|
|
|
if (_tcpDeviceConnectionService.TryWriteFloat(HeatTransferRegister, heatTransfer))
|
|
{
|
|
_parameterRefreshBlockedUntil = DateTime.UtcNow.AddMilliseconds(ParameterRefreshReleaseDelayMilliseconds);
|
|
HeatTransferText = heatTransfer.ToString("0.##", CultureInfo.InvariantCulture);
|
|
LastAction = "热传递保存成功";
|
|
return;
|
|
}
|
|
|
|
LastAction = "热传递保存失败";
|
|
Debug.WriteLine("Cone radiation heat transfer write failed.");
|
|
}
|
|
|
|
private void LogFloatDiagnostic(
|
|
string label,
|
|
ushort registerAddress,
|
|
ModbusFloatReadResult result,
|
|
ModbusFloatByteOrder? selectedByteOrder,
|
|
int matchCount)
|
|
{
|
|
if (selectedByteOrder is null)
|
|
{
|
|
if (!_loggedInvalidFloatDiagnostics.Add(registerAddress))
|
|
{
|
|
return;
|
|
}
|
|
}
|
|
else if (!_loggedFloatDiagnostics.Add(registerAddress))
|
|
{
|
|
return;
|
|
}
|
|
|
|
var selectedText = selectedByteOrder?.ToString() ?? "none";
|
|
|
|
Debug.WriteLine(
|
|
$"Cone radiation 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}.");
|
|
}
|
|
|
|
private static double NormalizeDisplayValue(double value)
|
|
{
|
|
return Math.Abs(value) < 0.005 ? 0 : value;
|
|
}
|
|
|
|
private bool CanRefreshConeParameters()
|
|
{
|
|
return !_isEditingConeParameters && DateTime.UtcNow >= _parameterRefreshBlockedUntil;
|
|
}
|
|
|
|
private void ToggleCirculatingWater()
|
|
{
|
|
if (!_tcpDeviceConnectionService.TryReadCoil(CirculatingWaterCoil, out var isActive))
|
|
{
|
|
LastAction = "循环水状态读取失败";
|
|
Debug.WriteLine("Cone radiation circulating water state read failed.");
|
|
return;
|
|
}
|
|
|
|
var nextValue = !isActive;
|
|
if (_tcpDeviceConnectionService.TryWriteCoil(CirculatingWaterCoil, nextValue))
|
|
{
|
|
CirculatingWaterActive = nextValue;
|
|
LastAction = $"循环水{(nextValue ? "开启" : "关闭")}";
|
|
return;
|
|
}
|
|
|
|
LastAction = $"循环水{(nextValue ? "开启" : "关闭")}失败";
|
|
Debug.WriteLine("Cone radiation circulating water write failed.");
|
|
}
|
|
|
|
private void WriteActionCoil(string action)
|
|
{
|
|
if (!TryGetActionCoil(action, out var coilAddress))
|
|
{
|
|
return;
|
|
}
|
|
|
|
if (!_tcpDeviceConnectionService.TryWriteCoil(coilAddress, true))
|
|
{
|
|
LastAction = $"{action}失败";
|
|
Debug.WriteLine($"Cone radiation action '{action}' write failed.");
|
|
}
|
|
}
|
|
|
|
private static bool TryGetActionCoil(string action, out ushort coilAddress)
|
|
{
|
|
switch (action)
|
|
{
|
|
case "开始升温":
|
|
coilAddress = 100;
|
|
return true;
|
|
case "停止升温":
|
|
coilAddress = 101;
|
|
return true;
|
|
default:
|
|
coilAddress = 0;
|
|
return false;
|
|
}
|
|
}
|
|
}
|