618 lines
20 KiB
C#
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;
|
|
}
|
|
}
|
|
}
|