Files
ConeCalorimeter/ConeCalorimeter/ViewModels/TestPageViewModel.cs
GukSang.Jin 737ef1643e 更新11111
2026-05-28 18:10:42 +08:00

618 lines
20 KiB
C#

using System.Collections.ObjectModel;
using System.Diagnostics;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using ConeCalorimeter.Models;
using ConeCalorimeter.Services;
using OxyPlot;
using OxyPlot.Axes;
using OxyPlot.Legends;
using OxyPlot.Series;
namespace ConeCalorimeter.ViewModels;
public sealed class TestPageViewModel : PageViewModel
{
private const string ResetAction = "复位";
private const string TestAction = "测试控制";
private const string TestStartDisplayText = "测试开始";
private const string TestStopDisplayText = "测试结束";
private const string FanAction = "风机";
private const string IgniterAction = "点火器";
private const ushort ResetCoil = 88;
private const ushort TestStartCoil = 65;
private const ushort TestStopCoil = 67;
private const ushort FanCoil = 54;
private const ushort IgniterCoil = 53;
private const double MinimumHeatReleaseAxisMaximum = 150;
private const double MinimumTotalAxisMaximum = 150;
private const double PlotAxisPaddingFactor = 1.1;
private const int MaximumPlotPoints = 600;
private const int ControlPulseReleaseRetryCount = 3;
private static readonly TimeSpan ControlPulseDuration = TimeSpan.FromMilliseconds(300);
private static readonly TimeSpan ControlPulseReleaseRetryDelay = TimeSpan.FromMilliseconds(100);
private readonly IExperimentDataService _experimentDataService;
private readonly ITcpDeviceConnectionService _tcpDeviceConnectionService;
private readonly LineSeries _heatReleaseSeries;
private readonly LineSeries _totalHeatSeries;
private readonly LineSeries _totalSmokeSeries;
private readonly Dictionary<string, DeviceActionViewModel> _deviceActionsByLabel = [];
private readonly HashSet<string> _pulseActionsInProgress = [];
private DeviceActionViewModel _testAction = null!;
private DeviceActionViewModel _resetAction = null!;
private bool _flameDetected;
private bool _resetInProgress;
private int? _lastPlottedSeconds;
private string _lastAction = "待机";
public TestPageViewModel(
IExperimentDataService experimentDataService,
ITcpDeviceConnectionService tcpDeviceConnectionService) : base("测试界面")
{
_experimentDataService = experimentDataService;
_tcpDeviceConnectionService = tcpDeviceConnectionService;
TopMetrics =
[
new MetricDisplayViewModel("孔板流量", "m³/s"),
new MetricDisplayViewModel("孔板压差", "Pa"),
new MetricDisplayViewModel("孔板温度", "K"),
new MetricDisplayViewModel("辐射锥温度", "°C"),
new MetricDisplayViewModel("辐射照度", "kW/㎡")
];
GasMetrics =
[
new MetricDisplayViewModel("氧浓度", "%"),
new MetricDisplayViewModel("二氧化碳", "%"),
new MetricDisplayViewModel("一氧化碳", "%")
];
HeatMetrics =
[
new MetricDisplayViewModel("热释放速率", "kW/m²"),
new MetricDisplayViewModel("热释放速率180", "MJ/m²"),
new MetricDisplayViewModel("热释放速率300", "MJ/m²"),
new MetricDisplayViewModel("放热总量", "MJ/m²"),
new MetricDisplayViewModel("产烟量", "m³"),
new MetricDisplayViewModel("当前质量", "g"),
new MetricDisplayViewModel("质量损失", "g")
];
TimerMetrics =
[
new MetricDisplayViewModel("点火计时", "s"),
new MetricDisplayViewModel("实验计时", "s")
];
ExecuteDeviceActionCommand = new RelayCommand<string>(ExecuteDeviceAction);
_testAction = new DeviceActionViewModel(TestAction, ExecuteDeviceActionCommand);
_testAction.SetDisplayText(TestStartDisplayText);
_resetAction = new DeviceActionViewModel(ResetAction, ExecuteDeviceActionCommand);
DeviceActions =
[
new DeviceActionViewModel("辐射锥升", ExecuteDeviceActionCommand),
new DeviceActionViewModel("辐射锥降", ExecuteDeviceActionCommand),
new DeviceActionViewModel("称重台升", ExecuteDeviceActionCommand),
new DeviceActionViewModel("称重台降", ExecuteDeviceActionCommand),
_testAction,
new DeviceActionViewModel(FanAction, ExecuteDeviceActionCommand),
new DeviceActionViewModel(IgniterAction, ExecuteDeviceActionCommand),
_resetAction
];
foreach (var action in DeviceActions)
{
_deviceActionsByLabel[action.Label] = action;
}
RefreshToggleActionStates();
HeatReleasePlot = CreatePlotModel(out _heatReleaseSeries, out _totalHeatSeries, out _totalSmokeSeries);
UpdateSnapshot(_experimentDataService.CurrentSnapshot);
_experimentDataService.SnapshotUpdated += (_, snapshot) => UpdateSnapshot(snapshot);
}
public ObservableCollection<MetricDisplayViewModel> TopMetrics { get; }
public ObservableCollection<MetricDisplayViewModel> GasMetrics { get; }
public ObservableCollection<MetricDisplayViewModel> HeatMetrics { get; }
public ObservableCollection<MetricDisplayViewModel> TimerMetrics { get; }
public ObservableCollection<DeviceActionViewModel> DeviceActions { get; }
public IRelayCommand<string> ExecuteDeviceActionCommand { get; }
public PlotModel HeatReleasePlot { get; }
public bool FlameDetected
{
get => _flameDetected;
private set => SetProperty(ref _flameDetected, value);
}
public string FlameStatus => FlameDetected ? "已检测" : "未检测";
public string LastAction
{
get => _lastAction;
private set => SetProperty(ref _lastAction, value);
}
private static PlotModel CreatePlotModel(out LineSeries heatReleaseSeries, out LineSeries totalHeatSeries, out LineSeries totalSmokeSeries)
{
var model = new PlotModel
{
Title = "热释放速率-时间曲线",
PlotAreaBorderColor = OxyColors.DimGray,
TextColor = OxyColors.Black,
TitleColor = OxyColors.Black
};
model.Legends.Add(new Legend
{
LegendPlacement = LegendPlacement.Inside,
LegendPosition = LegendPosition.TopRight,
LegendOrientation = LegendOrientation.Vertical,
LegendBorder = OxyColors.Gray,
LegendBackground = OxyColor.FromAColor(220, OxyColors.White)
});
model.Axes.Add(new LinearAxis
{
Position = AxisPosition.Bottom,
Title = "时间 (s)",
Minimum = 0,
Maximum = 300,
MajorGridlineStyle = LineStyle.Solid,
MinorGridlineStyle = LineStyle.Dot,
MajorGridlineColor = OxyColor.FromRgb(210, 210, 210),
MinorGridlineColor = OxyColor.FromRgb(235, 235, 235)
});
model.Axes.Add(new LinearAxis
{
Position = AxisPosition.Left,
Title = "热释放速率 (kW/m²)",
Minimum = 0,
Maximum = 150,
MajorGridlineStyle = LineStyle.Solid,
MinorGridlineStyle = LineStyle.Dot,
MajorGridlineColor = OxyColor.FromRgb(210, 210, 210),
MinorGridlineColor = OxyColor.FromRgb(235, 235, 235)
});
model.Axes.Add(new LinearAxis
{
Position = AxisPosition.Right,
Title = "总量 / 总烟",
Minimum = 0,
Maximum = 150,
Key = "TotalAxis",
MajorGridlineStyle = LineStyle.None
});
heatReleaseSeries = new LineSeries
{
Title = "热释放速率",
Color = OxyColors.Red,
StrokeThickness = 2
};
totalHeatSeries = new LineSeries
{
Title = "总放热",
Color = OxyColors.LimeGreen,
StrokeThickness = 2,
YAxisKey = "TotalAxis"
};
totalSmokeSeries = new LineSeries
{
Title = "总产烟",
Color = OxyColors.DodgerBlue,
StrokeThickness = 2,
YAxisKey = "TotalAxis"
};
model.Series.Add(heatReleaseSeries);
model.Series.Add(totalHeatSeries);
model.Series.Add(totalSmokeSeries);
return model;
}
private void UpdateSnapshot(RealtimeSnapshot snapshot)
{
TopMetrics[0].SetValue(snapshot.OrificeFlow);
TopMetrics[1].SetValue(snapshot.OrificePressure);
TopMetrics[2].SetValue(snapshot.OrificeTemperature);
TopMetrics[3].SetValue(snapshot.ConeTemperature, "0.0");
TopMetrics[4].SetValue(snapshot.Irradiance);
GasMetrics[0].SetValue(snapshot.Oxygen);
GasMetrics[1].SetValue(snapshot.CarbonDioxide);
GasMetrics[2].SetValue(snapshot.CarbonMonoxide);
HeatMetrics[0].SetValue(snapshot.HeatReleaseRate);
HeatMetrics[1].SetValue(snapshot.Qa180);
HeatMetrics[2].SetValue(snapshot.Qa300);
HeatMetrics[3].SetValue(snapshot.TotalHeatRelease);
HeatMetrics[4].SetValue(snapshot.TotalSmoke);
HeatMetrics[5].SetValue(snapshot.CurrentMass);
HeatMetrics[6].SetValue(snapshot.MassLoss);
TimerMetrics[0].SetValue(snapshot.IgnitionSeconds);
TimerMetrics[1].SetValue(snapshot.TestSeconds);
FlameDetected = snapshot.FlameDetected;
OnPropertyChanged(nameof(FlameStatus));
UpdatePlot(snapshot);
}
private void UpdatePlot(RealtimeSnapshot snapshot)
{
if (!ShouldPlotSnapshot(snapshot))
{
return;
}
var x = snapshot.TestSeconds;
AppendPoint(_heatReleaseSeries, x, snapshot.HeatReleaseRate);
AppendPoint(_totalHeatSeries, x, snapshot.TotalHeatRelease);
AppendPoint(_totalSmokeSeries, x, snapshot.TotalSmoke);
_lastPlottedSeconds = snapshot.TestSeconds;
UpdateTimeAxis(x);
UpdateValueAxes();
HeatReleasePlot.InvalidatePlot(true);
}
private bool ShouldPlotSnapshot(RealtimeSnapshot snapshot)
{
return snapshot.TestSeconds >= 0
&& (!_lastPlottedSeconds.HasValue || snapshot.TestSeconds > _lastPlottedSeconds.Value)
&& double.IsFinite(snapshot.HeatReleaseRate)
&& double.IsFinite(snapshot.TotalHeatRelease)
&& double.IsFinite(snapshot.TotalSmoke);
}
private static void AppendPoint(LineSeries series, double x, double y)
{
if (!double.IsFinite(x) || x < 0 || !double.IsFinite(y))
{
return;
}
series.Points.Add(new DataPoint(x, y));
if (series.Points.Count > MaximumPlotPoints)
{
series.Points.RemoveAt(0);
}
}
private void UpdateTimeAxis(double latestSeconds)
{
var bottomAxis = HeatReleasePlot.Axes.First(axis => axis.Position == AxisPosition.Bottom);
bottomAxis.Minimum = Math.Max(0, latestSeconds - MaximumPlotPoints);
bottomAxis.Maximum = Math.Max(300, latestSeconds + 40);
}
private void UpdateValueAxes()
{
var heatReleaseAxis = HeatReleasePlot.Axes.First(axis => axis.Position == AxisPosition.Left);
heatReleaseAxis.Maximum = CalculateAxisMaximum(MinimumHeatReleaseAxisMaximum, _heatReleaseSeries);
var totalAxis = HeatReleasePlot.Axes.First(axis => axis.Key == "TotalAxis");
totalAxis.Maximum = CalculateAxisMaximum(
MinimumTotalAxisMaximum,
_totalHeatSeries,
_totalSmokeSeries);
}
private static double CalculateAxisMaximum(double minimumMaximum, params LineSeries[] seriesCollection)
{
var maximum = seriesCollection
.SelectMany(series => series.Points)
.Where(point => double.IsFinite(point.Y))
.Select(point => point.Y)
.DefaultIfEmpty(0)
.Max();
return Math.Max(minimumMaximum, Math.Ceiling(maximum * PlotAxisPaddingFactor));
}
private void ExecuteDeviceAction(string? action)
{
if (string.IsNullOrWhiteSpace(action))
{
return;
}
if (action == TestAction)
{
ExecuteTestControlAction();
return;
}
if (action == ResetAction)
{
LastAction = action;
ExecuteResetAction();
return;
}
if (TryGetToggleDeviceActionCoil(action, out var toggleCoilAddress))
{
ToggleDeviceAction(action, toggleCoilAddress);
return;
}
if (TryGetPulseDeviceActionCoil(action, out var coilAddress))
{
ExecutePulseDeviceAction(action, coilAddress);
return;
}
LastAction = action;
}
private async void ExecuteResetAction()
{
if (_resetInProgress)
{
return;
}
if (!_tcpDeviceConnectionService.TryWriteCoil(ResetCoil, true))
{
LastAction = "复位失败";
Debug.WriteLine("Device reset write failed.");
return;
}
SetResetInProgress(true);
LastAction = "复位中";
await Task.Delay(ControlPulseDuration);
if (await ReleaseControlPulseAsync(ResetCoil, ResetAction))
{
LastAction = "复位完成";
}
else
{
LastAction = "复位线圈复位失败";
}
SetResetInProgress(false);
}
private void SetResetInProgress(bool resetInProgress)
{
_resetInProgress = resetInProgress;
_resetAction.SetDisplayText(resetInProgress ? "复位中" : ResetAction);
}
private void ClearPlotSeries()
{
_heatReleaseSeries.Points.Clear();
_totalHeatSeries.Points.Clear();
_totalSmokeSeries.Points.Clear();
var bottomAxis = HeatReleasePlot.Axes.First(axis => axis.Position == AxisPosition.Bottom);
bottomAxis.Minimum = 0;
bottomAxis.Maximum = 300;
UpdateValueAxes();
_lastPlottedSeconds = null;
HeatReleasePlot.InvalidatePlot(true);
}
private async void ExecuteTestControlAction()
{
if (!TryBeginPulseAction(TestAction))
{
return;
}
var isStarting = !_testAction.IsActive;
var actionText = isStarting ? TestStartDisplayText : TestStopDisplayText;
var coilAddress = isStarting ? TestStartCoil : TestStopCoil;
if (!_tcpDeviceConnectionService.TryWriteCoil(coilAddress, true))
{
LastAction = $"{actionText}失败";
Debug.WriteLine($"Device action '{actionText}' write failed.");
EndPulseAction(TestAction);
return;
}
try
{
if (isStarting)
{
ClearPlotSeries();
_experimentDataService.StartTest();
}
else
{
_experimentDataService.StopTest();
}
SetTestRunning(isStarting);
LastAction = actionText;
await Task.Delay(ControlPulseDuration);
if (!await ReleaseControlPulseAsync(coilAddress, actionText))
{
LastAction = $"{actionText}线圈复位失败";
}
}
finally
{
EndPulseAction(TestAction);
}
}
private void SetTestRunning(bool isRunning)
{
_testAction.UpdateStatus(isRunning);
_testAction.SetDisplayText(isRunning ? TestStopDisplayText : TestStartDisplayText);
}
private void RefreshToggleActionStates()
{
UpdateToggleActionStatus(FanAction, FanCoil);
UpdateToggleActionStatus(IgniterAction, IgniterCoil);
}
private bool UpdateToggleActionStatus(string action, ushort coilAddress)
{
if (!_deviceActionsByLabel.TryGetValue(action, out var actionViewModel))
{
return false;
}
if (_tcpDeviceConnectionService.TryReadCoil(coilAddress, out var isActive))
{
actionViewModel.UpdateStatus(isActive);
return true;
}
actionViewModel.UpdateStatus(actionViewModel.IsActive, false);
return false;
}
private void ToggleDeviceAction(string action, ushort coilAddress)
{
if (!_tcpDeviceConnectionService.TryReadCoil(coilAddress, out var isActive))
{
LastAction = $"{action}状态读取失败";
Debug.WriteLine($"Device action '{action}' state read failed.");
UpdateToggleActionStatus(action, coilAddress);
return;
}
var nextValue = !isActive;
if (_tcpDeviceConnectionService.TryWriteCoil(coilAddress, nextValue))
{
LastAction = $"{action}控制完成";
if (!UpdateToggleActionStatus(action, coilAddress)
&& _deviceActionsByLabel.TryGetValue(action, out var actionViewModel))
{
actionViewModel.UpdateStatus(nextValue);
}
return;
}
LastAction = $"{action}控制失败";
Debug.WriteLine($"Device action '{action}' write failed.");
}
private async void ExecutePulseDeviceAction(string action, ushort coilAddress)
{
if (!TryBeginPulseAction(action))
{
return;
}
_deviceActionsByLabel.TryGetValue(action, out var actionViewModel);
if (!_tcpDeviceConnectionService.TryWriteCoil(coilAddress, true))
{
LastAction = $"{action}失败";
Debug.WriteLine($"Device action '{action}' write failed.");
EndPulseAction(action);
return;
}
try
{
actionViewModel?.UpdateStatus(true);
LastAction = action;
await Task.Delay(ControlPulseDuration);
if (await ReleaseControlPulseAsync(coilAddress, action))
{
actionViewModel?.UpdateStatus(false);
return;
}
actionViewModel?.UpdateStatus(true, false);
LastAction = $"{action}线圈复位失败";
}
finally
{
EndPulseAction(action);
}
}
private bool TryBeginPulseAction(string action)
{
return _pulseActionsInProgress.Add(action);
}
private void EndPulseAction(string action)
{
_pulseActionsInProgress.Remove(action);
}
private async Task<bool> ReleaseControlPulseAsync(ushort coilAddress, string actionText)
{
for (var attempt = 1; attempt <= ControlPulseReleaseRetryCount; attempt++)
{
if (_tcpDeviceConnectionService.TryWriteCoil(coilAddress, false))
{
return true;
}
if (attempt < ControlPulseReleaseRetryCount)
{
await Task.Delay(ControlPulseReleaseRetryDelay);
}
}
Debug.WriteLine($"Device action '{actionText}' pulse release failed.");
return false;
}
private static bool TryGetToggleDeviceActionCoil(string action, out ushort coilAddress)
{
switch (action)
{
case FanAction:
coilAddress = FanCoil;
return true;
case IgniterAction:
coilAddress = IgniterCoil;
return true;
default:
coilAddress = 0;
return false;
}
}
private static bool TryGetPulseDeviceActionCoil(string action, out ushort coilAddress)
{
switch (action)
{
case "称重台升":
coilAddress = 93;
return true;
case "称重台降":
coilAddress = 94;
return true;
case "辐射锥升":
coilAddress = 83;
return true;
case "辐射锥降":
coilAddress = 84;
return true;
default:
coilAddress = 0;
return false;
}
}
}