更新点位

This commit is contained in:
GukSang.Jin
2026-05-05 14:55:46 +08:00
parent 5e6d87a01d
commit 8d485ddc76
15 changed files with 722 additions and 95 deletions

View File

@@ -15,9 +15,11 @@ namespace ConeCalorimeter
_tcpDeviceConnectionService = new TcpDeviceConnectionService(); _tcpDeviceConnectionService = new TcpDeviceConnectionService();
_ = _tcpDeviceConnectionService.StartAsync(); _ = _tcpDeviceConnectionService.StartAsync();
var experimentDataService = new ExperimentDataService(new DemoRealtimeDataService()); var experimentDataService = new ExperimentDataService(
new ModbusRealtimeDataService(_tcpDeviceConnectionService));
DataContext = new MainViewModel( DataContext = new MainViewModel(
experimentDataService, experimentDataService,
_tcpDeviceConnectionService,
new NpoiReportExportService(), new NpoiReportExportService(),
new HelpDialogService()); new HelpDialogService());
} }

View File

@@ -6,6 +6,7 @@ public sealed record RealtimeDataRecord(
double OrificePressure, double OrificePressure,
double OrificeTemperature, double OrificeTemperature,
double ConeTemperature, double ConeTemperature,
double SampleTemperature,
double Irradiance, double Irradiance,
bool FlameDetected, bool FlameDetected,
double Oxygen, double Oxygen,
@@ -30,6 +31,7 @@ public sealed record RealtimeDataRecord(
snapshot.OrificePressure, snapshot.OrificePressure,
snapshot.OrificeTemperature, snapshot.OrificeTemperature,
snapshot.ConeTemperature, snapshot.ConeTemperature,
snapshot.SampleTemperature,
snapshot.Irradiance, snapshot.Irradiance,
snapshot.FlameDetected, snapshot.FlameDetected,
snapshot.Oxygen, snapshot.Oxygen,

View File

@@ -5,6 +5,7 @@ public sealed record RealtimeSnapshot(
double OrificePressure, double OrificePressure,
double OrificeTemperature, double OrificeTemperature,
double ConeTemperature, double ConeTemperature,
double SampleTemperature,
double Irradiance, double Irradiance,
bool FlameDetected, bool FlameDetected,
double Oxygen, double Oxygen,

View File

@@ -1,38 +0,0 @@
using ConeCalorimeter.Models;
namespace ConeCalorimeter.Services;
public sealed class DemoRealtimeDataService : IRealtimeDataService
{
private readonly Random _random = new(20260504);
public RealtimeSnapshot GetCurrentSnapshot(TimeSpan elapsed)
{
var seconds = elapsed.TotalSeconds;
var heatBase = 28 + Math.Sin(seconds / 8) * 11 + Math.Sin(seconds / 2.7) * 4;
var heatReleaseRate = Math.Max(0, heatBase + _random.NextDouble() * 2.5);
var totalHeat = Math.Min(150, seconds * 0.42 + Math.Sin(seconds / 10) * 2);
var totalSmoke = Math.Min(150, seconds * 0.34 + Math.Cos(seconds / 9) * 1.8);
return new RealtimeSnapshot(
OrificeFlow: 2.35 + Math.Sin(seconds / 11) * 0.08,
OrificePressure: 18.4 + Math.Cos(seconds / 7) * 0.35,
OrificeTemperature: 296.2 + Math.Sin(seconds / 15) * 0.5,
ConeTemperature: 751 + Math.Sin(seconds / 13) * 4,
Irradiance: 50.0 + Math.Cos(seconds / 18) * 0.25,
FlameDetected: seconds % 24 > 5,
Oxygen: 20.95 - Math.Min(2.4, seconds * 0.006),
CarbonDioxide: 0.04 + Math.Min(7.5, seconds * 0.018),
CarbonMonoxide: 0.01 + Math.Min(1.3, seconds * 0.004),
HeatReleaseRate: heatReleaseRate,
Qa180: Math.Min(120, seconds * 0.21),
Qa300: Math.Min(180, seconds * 0.18),
TotalHeatRelease: totalHeat,
SmokeProduction: 0.3 + Math.Min(45, seconds * 0.09),
CurrentMass: Math.Max(0, 35.0 - seconds * 0.015),
MassLoss: Math.Min(35.0, seconds * 0.015),
IgnitionSeconds: (int)Math.Min(seconds, 999),
TestSeconds: (int)Math.Min(seconds, 9999),
TotalSmoke: totalSmoke);
}
}

View File

@@ -7,4 +7,14 @@ public interface ITcpDeviceConnectionService : IAsyncDisposable
Task StartAsync(); Task StartAsync();
Task StopAsync(); Task StopAsync();
bool TryReadFloat(ushort registerAddress, out double value);
bool TryReadInt16(ushort registerAddress, out int value);
bool TryWriteInt16(ushort registerAddress, short value);
bool TryReadCoil(ushort coilAddress, out bool value);
bool TryWriteCoil(ushort coilAddress, bool value);
} }

View File

@@ -0,0 +1,74 @@
using ConeCalorimeter.Models;
namespace ConeCalorimeter.Services;
public sealed class ModbusRealtimeDataService : IRealtimeDataService
{
private const ushort OxygenRegister = 10;
private const ushort OrificeFlowRegister = 14;
private const ushort OrificePressureRegister = 16;
private const ushort CarbonMonoxideRegister = 18;
private const ushort CarbonDioxideRegister = 20;
private const ushort ConeTemperatureRegister = 26;
private const ushort OrificeTemperatureRegister = 30;
private const ushort SampleTemperatureRegister = 36;
private const ushort HeatReleaseRateRegister = 354;
private const ushort Qa180Register = 366;
private const ushort Qa300Register = 370;
private const ushort TotalHeatReleaseRegister = 372;
private const ushort SmokeProductionRegister = 390;
private const ushort IgnitionSecondsRegister = 1014;
private const ushort TestSecondsRegister = 1015;
private const ushort FlameDetectedCoil = 3;
private readonly ITcpDeviceConnectionService _tcpDeviceConnectionService;
public ModbusRealtimeDataService(ITcpDeviceConnectionService tcpDeviceConnectionService)
{
_tcpDeviceConnectionService = tcpDeviceConnectionService;
}
public RealtimeSnapshot GetCurrentSnapshot(TimeSpan elapsed)
{
return new RealtimeSnapshot(
OrificeFlow: ReadFloatOrEmpty(OrificeFlowRegister),
OrificePressure: ReadFloatOrEmpty(OrificePressureRegister),
OrificeTemperature: ReadFloatOrEmpty(OrificeTemperatureRegister),
ConeTemperature: ReadFloatOrEmpty(ConeTemperatureRegister),
SampleTemperature: ReadFloatOrEmpty(SampleTemperatureRegister),
Irradiance: double.NaN,
FlameDetected: ReadCoilOrFalse(FlameDetectedCoil),
Oxygen: ReadFloatOrEmpty(OxygenRegister),
CarbonDioxide: ReadFloatOrEmpty(CarbonDioxideRegister),
CarbonMonoxide: ReadFloatOrEmpty(CarbonMonoxideRegister),
HeatReleaseRate: ReadFloatOrEmpty(HeatReleaseRateRegister),
Qa180: ReadFloatOrEmpty(Qa180Register),
Qa300: ReadFloatOrEmpty(Qa300Register),
TotalHeatRelease: ReadFloatOrEmpty(TotalHeatReleaseRegister),
SmokeProduction: ReadFloatOrEmpty(SmokeProductionRegister),
CurrentMass: double.NaN,
MassLoss: double.NaN,
IgnitionSeconds: ReadInt16OrEmpty(IgnitionSecondsRegister),
TestSeconds: ReadInt16OrEmpty(TestSecondsRegister),
TotalSmoke: ReadFloatOrEmpty(SmokeProductionRegister));
}
private double ReadFloatOrEmpty(ushort registerAddress)
{
return _tcpDeviceConnectionService.TryReadFloat(registerAddress, out var value)
? value
: double.NaN;
}
private int ReadInt16OrEmpty(ushort registerAddress)
{
return _tcpDeviceConnectionService.TryReadInt16(registerAddress, out var value)
? value
: -1;
}
private bool ReadCoilOrFalse(ushort coilAddress)
{
return _tcpDeviceConnectionService.TryReadCoil(coilAddress, out var value) && value;
}
}

View File

@@ -1,4 +1,6 @@
using System.Diagnostics; using System.Diagnostics;
using System.Buffers.Binary;
using System.IO;
using System.Net.Sockets; using System.Net.Sockets;
namespace ConeCalorimeter.Services; namespace ConeCalorimeter.Services;
@@ -7,13 +9,20 @@ public sealed class TcpDeviceConnectionService : ITcpDeviceConnectionService
{ {
private const string Host = "192.168.1.10"; private const string Host = "192.168.1.10";
private const int Port = 502; private const int Port = 502;
private const byte UnitId = 1;
private const byte ReadCoilsFunction = 0x01;
private const byte ReadHoldingRegistersFunction = 0x03;
private const byte WriteSingleCoilFunction = 0x05;
private const byte WriteSingleRegisterFunction = 0x06;
private static readonly TimeSpan RetryDelay = TimeSpan.FromSeconds(3); private static readonly TimeSpan RetryDelay = TimeSpan.FromSeconds(3);
private static readonly TimeSpan ConnectTimeout = TimeSpan.FromSeconds(5); private static readonly TimeSpan ConnectTimeout = TimeSpan.FromSeconds(5);
private static readonly TimeSpan ReadWriteTimeout = TimeSpan.FromSeconds(2);
private readonly object _syncRoot = new(); private readonly object _syncRoot = new();
private CancellationTokenSource? _connectionLoopCancellation; private CancellationTokenSource? _connectionLoopCancellation;
private Task? _connectionLoopTask; private Task? _connectionLoopTask;
private TcpClient? _client; private TcpClient? _client;
private ushort _transactionId;
private bool _isConnected; private bool _isConnected;
public bool IsConnected public bool IsConnected
@@ -90,6 +99,132 @@ public sealed class TcpDeviceConnectionService : ITcpDeviceConnectionService
await StopAsync(); await StopAsync();
} }
public bool TryReadFloat(ushort registerAddress, out double value)
{
value = double.NaN;
lock (_syncRoot)
{
if (_client is null || !IsTcpClientConnected(_client))
{
CloseCurrentClientCore();
return false;
}
try
{
value = ReadFloat(_client, registerAddress);
return true;
}
catch (Exception ex) when (ex is IOException or SocketException or InvalidDataException or ObjectDisposedException)
{
Debug.WriteLine($"TCP device register {registerAddress} read failed: {ex.Message}");
CloseCurrentClientCore();
return false;
}
}
}
public bool TryReadInt16(ushort registerAddress, out int value)
{
value = 0;
lock (_syncRoot)
{
if (_client is null || !IsTcpClientConnected(_client))
{
CloseCurrentClientCore();
return false;
}
try
{
value = ReadInt16(_client, registerAddress);
return true;
}
catch (Exception ex) when (ex is IOException or SocketException or InvalidDataException or ObjectDisposedException)
{
Debug.WriteLine($"TCP device register {registerAddress} read failed: {ex.Message}");
CloseCurrentClientCore();
return false;
}
}
}
public bool TryWriteInt16(ushort registerAddress, short value)
{
lock (_syncRoot)
{
if (_client is null || !IsTcpClientConnected(_client))
{
CloseCurrentClientCore();
return false;
}
try
{
WriteInt16(_client, registerAddress, value);
return true;
}
catch (Exception ex) when (ex is IOException or SocketException or InvalidDataException or ObjectDisposedException)
{
Debug.WriteLine($"TCP device register {registerAddress} write failed: {ex.Message}");
CloseCurrentClientCore();
return false;
}
}
}
public bool TryReadCoil(ushort coilAddress, out bool value)
{
value = false;
lock (_syncRoot)
{
if (_client is null || !IsTcpClientConnected(_client))
{
CloseCurrentClientCore();
return false;
}
try
{
value = ReadCoil(_client, coilAddress);
return true;
}
catch (Exception ex) when (ex is IOException or SocketException or InvalidDataException or ObjectDisposedException)
{
Debug.WriteLine($"TCP device coil {coilAddress} read failed: {ex.Message}");
CloseCurrentClientCore();
return false;
}
}
}
public bool TryWriteCoil(ushort coilAddress, bool value)
{
lock (_syncRoot)
{
if (_client is null || !IsTcpClientConnected(_client))
{
CloseCurrentClientCore();
return false;
}
try
{
WriteCoil(_client, coilAddress, value);
return true;
}
catch (Exception ex) when (ex is IOException or SocketException or InvalidDataException or ObjectDisposedException)
{
Debug.WriteLine($"TCP device coil {coilAddress} write failed: {ex.Message}");
CloseCurrentClientCore();
return false;
}
}
}
private async Task RunConnectionLoopAsync(CancellationToken cancellationToken) private async Task RunConnectionLoopAsync(CancellationToken cancellationToken)
{ {
while (!cancellationToken.IsCancellationRequested) while (!cancellationToken.IsCancellationRequested)
@@ -138,9 +273,12 @@ public sealed class TcpDeviceConnectionService : ITcpDeviceConnectionService
} }
await connectTask; await connectTask;
client.ReceiveTimeout = (int)ReadWriteTimeout.TotalMilliseconds;
client.SendTimeout = (int)ReadWriteTimeout.TotalMilliseconds;
lock (_syncRoot) lock (_syncRoot)
{ {
_client?.Dispose();
_client = client; _client = client;
_isConnected = true; _isConnected = true;
} }
@@ -187,15 +325,159 @@ public sealed class TcpDeviceConnectionService : ITcpDeviceConnectionService
private void CloseCurrentClient() private void CloseCurrentClient()
{ {
TcpClient? client;
lock (_syncRoot) lock (_syncRoot)
{ {
client = _client; CloseCurrentClientCore();
_client = null;
_isConnected = false;
} }
}
private void CloseCurrentClientCore()
{
var client = _client;
_client = null;
_isConnected = false;
client?.Dispose(); client?.Dispose();
} }
private double ReadFloat(TcpClient client, ushort registerAddress)
{
var pdu = ReadHoldingRegisters(client, registerAddress, 2);
if (pdu.Length != 6 || pdu[1] != 4)
{
throw new InvalidDataException("Invalid Modbus TCP float response.");
}
var rawValue = BinaryPrimitives.ReadInt32BigEndian(pdu[2..6]);
return BitConverter.Int32BitsToSingle(rawValue);
}
private int ReadInt16(TcpClient client, ushort registerAddress)
{
var pdu = ReadHoldingRegisters(client, registerAddress, 1);
if (pdu.Length != 4 || pdu[1] != 2)
{
throw new InvalidDataException("Invalid Modbus TCP int16 response.");
}
return BinaryPrimitives.ReadInt16BigEndian(pdu[2..4]);
}
private void WriteInt16(TcpClient client, ushort registerAddress, short value)
{
Span<byte> payload = stackalloc byte[4];
BinaryPrimitives.WriteUInt16BigEndian(payload[0..2], registerAddress);
BinaryPrimitives.WriteInt16BigEndian(payload[2..4], value);
var pdu = SendModbusRequest(client, WriteSingleRegisterFunction, payload);
if (pdu.Length != 5
|| BinaryPrimitives.ReadUInt16BigEndian(pdu[1..3]) != registerAddress
|| BinaryPrimitives.ReadInt16BigEndian(pdu[3..5]) != value)
{
throw new InvalidDataException("Invalid Modbus TCP register write response.");
}
}
private bool ReadCoil(TcpClient client, ushort coilAddress)
{
Span<byte> payload = stackalloc byte[4];
BinaryPrimitives.WriteUInt16BigEndian(payload[0..2], coilAddress);
BinaryPrimitives.WriteUInt16BigEndian(payload[2..4], 1);
var pdu = SendModbusRequest(client, ReadCoilsFunction, payload);
if (pdu.Length != 3 || pdu[1] != 1)
{
throw new InvalidDataException("Invalid Modbus TCP coil response.");
}
return (pdu[2] & 0x01) == 0x01;
}
private void WriteCoil(TcpClient client, ushort coilAddress, bool value)
{
Span<byte> payload = stackalloc byte[4];
BinaryPrimitives.WriteUInt16BigEndian(payload[0..2], coilAddress);
BinaryPrimitives.WriteUInt16BigEndian(payload[2..4], value ? (ushort)0xFF00 : (ushort)0x0000);
var pdu = SendModbusRequest(client, WriteSingleCoilFunction, payload);
if (pdu.Length != 5
|| BinaryPrimitives.ReadUInt16BigEndian(pdu[1..3]) != coilAddress
|| BinaryPrimitives.ReadUInt16BigEndian(pdu[3..5]) != (value ? (ushort)0xFF00 : (ushort)0x0000))
{
throw new InvalidDataException("Invalid Modbus TCP coil write response.");
}
}
private byte[] ReadHoldingRegisters(TcpClient client, ushort registerAddress, ushort registerCount)
{
Span<byte> payload = stackalloc byte[4];
BinaryPrimitives.WriteUInt16BigEndian(payload[0..2], registerAddress);
BinaryPrimitives.WriteUInt16BigEndian(payload[2..4], registerCount);
return SendModbusRequest(client, ReadHoldingRegistersFunction, payload);
}
private byte[] SendModbusRequest(TcpClient client, byte functionCode, ReadOnlySpan<byte> payload)
{
var transactionId = ++_transactionId;
var pduLength = 1 + payload.Length;
var request = new byte[7 + pduLength];
BinaryPrimitives.WriteUInt16BigEndian(request.AsSpan(0, 2), transactionId);
BinaryPrimitives.WriteUInt16BigEndian(request.AsSpan(2, 2), 0);
BinaryPrimitives.WriteUInt16BigEndian(request.AsSpan(4, 2), (ushort)(1 + pduLength));
request[6] = UnitId;
request[7] = functionCode;
payload.CopyTo(request.AsSpan(8));
var stream = client.GetStream();
stream.Write(request);
Span<byte> header = stackalloc byte[7];
ReadExactly(stream, header);
var responseTransactionId = BinaryPrimitives.ReadUInt16BigEndian(header[0..2]);
var protocolId = BinaryPrimitives.ReadUInt16BigEndian(header[2..4]);
var length = BinaryPrimitives.ReadUInt16BigEndian(header[4..6]);
var unitId = header[6];
if (responseTransactionId != transactionId || protocolId != 0 || unitId != UnitId || length < 2)
{
throw new InvalidDataException("Invalid Modbus TCP response header.");
}
var pdu = new byte[length - 1];
ReadExactly(stream, pdu);
if (pdu[0] == (functionCode | 0x80))
{
throw new InvalidDataException($"Modbus exception code {pdu[1]}.");
}
if (pdu[0] != functionCode)
{
throw new InvalidDataException("Unexpected Modbus TCP function code.");
}
return pdu;
}
private static void ReadExactly(NetworkStream stream, Span<byte> buffer)
{
var totalRead = 0;
while (totalRead < buffer.Length)
{
var read = stream.Read(buffer[totalRead..]);
if (read == 0)
{
throw new IOException("TCP device closed the connection.");
}
totalRead += read;
}
}
} }

View File

@@ -1,13 +1,24 @@
using System.Collections.ObjectModel; using System.Collections.ObjectModel;
using System.Diagnostics;
using System.Globalization;
using System.Windows.Threading;
using CommunityToolkit.Mvvm.ComponentModel; using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input; using CommunityToolkit.Mvvm.Input;
using ConeCalorimeter.Services;
namespace ConeCalorimeter.ViewModels; namespace ConeCalorimeter.ViewModels;
public sealed class CValueCalibrationViewModel : PageViewModel public sealed class CValueCalibrationViewModel : PageViewModel
{ {
private const ushort TemperatureRegister = 282;
private const ushort PressureDifferenceRegister = 284;
private const ushort OxygenRegister = 286;
private const ushort CValueRegister = 308;
private readonly Action _closeAction; private readonly Action _closeAction;
private readonly Action _helpAction; private readonly Action _helpAction;
private readonly ITcpDeviceConnectionService _tcpDeviceConnectionService;
private readonly DispatcherTimer _refreshTimer;
private string _baselineOxygenText = ""; private string _baselineOxygenText = "";
private string _temperatureText = ""; private string _temperatureText = "";
private string _pressureDifferenceText = ""; private string _pressureDifferenceText = "";
@@ -15,10 +26,14 @@ public sealed class CValueCalibrationViewModel : PageViewModel
private string _cValueText = ""; private string _cValueText = "";
private string _lastAction = "待机"; private string _lastAction = "待机";
public CValueCalibrationViewModel(Action closeAction, Action helpAction) : base("C值标定") public CValueCalibrationViewModel(
Action closeAction,
Action helpAction,
ITcpDeviceConnectionService tcpDeviceConnectionService) : base("C值标定")
{ {
_closeAction = closeAction; _closeAction = closeAction;
_helpAction = helpAction; _helpAction = helpAction;
_tcpDeviceConnectionService = tcpDeviceConnectionService;
CloseCommand = new RelayCommand(_closeAction); CloseCommand = new RelayCommand(_closeAction);
HelpCommand = new RelayCommand(_helpAction); HelpCommand = new RelayCommand(_helpAction);
ActionCommand = new RelayCommand<string>(ExecuteAction); ActionCommand = new RelayCommand<string>(ExecuteAction);
@@ -36,6 +51,14 @@ public sealed class CValueCalibrationViewModel : PageViewModel
new CValueCalibrationActionViewModel("标定开始", ActionCommand), new CValueCalibrationActionViewModel("标定开始", ActionCommand),
new CValueCalibrationActionViewModel("基线采集", ActionCommand) new CValueCalibrationActionViewModel("基线采集", ActionCommand)
]; ];
RefreshDeviceValues();
_refreshTimer = new DispatcherTimer
{
Interval = TimeSpan.FromSeconds(1)
};
_refreshTimer.Tick += (_, _) => RefreshDeviceValues();
_refreshTimer.Start();
} }
public ObservableCollection<CValueCalibrationActionViewModel> TopActions { get; } public ObservableCollection<CValueCalibrationActionViewModel> TopActions { get; }
@@ -94,19 +117,65 @@ public sealed class CValueCalibrationViewModel : PageViewModel
} }
LastAction = action; LastAction = action;
WriteActionCoil(action);
}
if (action == "基线采集") private void RefreshDeviceValues()
{
var oxygenText = ReadFloatText(OxygenRegister);
BaselineOxygenText = oxygenText;
TemperatureText = ReadFloatText(TemperatureRegister);
PressureDifferenceText = ReadFloatText(PressureDifferenceRegister);
CalibrationOxygenText = oxygenText;
CValueText = ReadFloatText(CValueRegister);
}
private string ReadFloatText(ushort registerAddress)
{
return _tcpDeviceConnectionService.TryReadFloat(registerAddress, out var value)
? value.ToString("0.00", CultureInfo.InvariantCulture)
: string.Empty;
}
private void WriteActionCoil(string action)
{
if (!TryGetActionCoil(action, out var coilAddress))
{ {
BaselineOxygenText = "20.95";
return; return;
} }
if (action == "标定开始") if (!_tcpDeviceConnectionService.TryWriteCoil(coilAddress, true))
{ {
TemperatureText = "298.15"; Debug.WriteLine($"C value calibration action '{action}' write failed.");
PressureDifferenceText = "12.40"; }
CalibrationOxygenText = "20.10"; }
CValueText = "0.045";
private static bool TryGetActionCoil(string action, out ushort coilAddress)
{
switch (action)
{
case "甲烷阀":
coilAddress = 55;
return true;
case "风机":
coilAddress = 54;
return true;
case "点火器":
coilAddress = 53;
return true;
case "取样泵":
coilAddress = 50;
return true;
case "基线采集":
coilAddress = 60;
return true;
case "标定开始":
coilAddress = 70;
return true;
default:
coilAddress = 0;
return false;
} }
} }
} }

View File

@@ -1,6 +1,10 @@
using System.Collections.ObjectModel; using System.Collections.ObjectModel;
using System.Diagnostics;
using System.Globalization;
using System.Windows.Threading;
using CommunityToolkit.Mvvm.ComponentModel; using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input; using CommunityToolkit.Mvvm.Input;
using ConeCalorimeter.Services;
using OxyPlot; using OxyPlot;
using OxyPlot.Axes; using OxyPlot.Axes;
using OxyPlot.Legends; using OxyPlot.Legends;
@@ -10,21 +14,32 @@ namespace ConeCalorimeter.ViewModels;
public sealed class ConeRadiationSettingsViewModel : PageViewModel public sealed class ConeRadiationSettingsViewModel : PageViewModel
{ {
private const ushort TargetTemperatureRegister = 400;
private const ushort CurrentHeatFluxRegister = 410;
private const ushort SlopeRegister = 420;
private const ushort InterceptRegister = 422;
private readonly Action _closeAction; private readonly Action _closeAction;
private readonly Action _helpAction; private readonly Action _helpAction;
private readonly ITcpDeviceConnectionService _tcpDeviceConnectionService;
private readonly DispatcherTimer _refreshTimer;
private string _currentTemperatureText = ""; private string _currentTemperatureText = "";
private string _currentHeatFluxText = ""; private string _currentHeatFluxText = "";
private string _targetTemperatureText = "355"; private string _targetTemperatureText = "";
private string _heatTransferText = ""; private string _heatTransferText = "";
private string _slopeText = ""; private string _slopeText = "";
private string _interceptText = ""; private string _interceptText = "";
private string _lastAction = "待机"; private string _lastAction = "待机";
private bool _alarmActive; private bool _alarmActive;
public ConeRadiationSettingsViewModel(Action closeAction, Action helpAction) : base("辐射锥设置") public ConeRadiationSettingsViewModel(
Action closeAction,
Action helpAction,
ITcpDeviceConnectionService tcpDeviceConnectionService) : base("辐射锥设置")
{ {
_closeAction = closeAction; _closeAction = closeAction;
_helpAction = helpAction; _helpAction = helpAction;
_tcpDeviceConnectionService = tcpDeviceConnectionService;
CloseCommand = new RelayCommand(_closeAction); CloseCommand = new RelayCommand(_closeAction);
HelpCommand = new RelayCommand(_helpAction); HelpCommand = new RelayCommand(_helpAction);
ActionCommand = new RelayCommand<string>(ExecuteAction); ActionCommand = new RelayCommand<string>(ExecuteAction);
@@ -40,6 +55,14 @@ public sealed class ConeRadiationSettingsViewModel : PageViewModel
]; ];
HeatFluxPlot = CreatePlotModel(); HeatFluxPlot = CreatePlotModel();
RefreshDeviceValues();
_refreshTimer = new DispatcherTimer
{
Interval = TimeSpan.FromSeconds(1)
};
_refreshTimer.Tick += (_, _) => RefreshDeviceValues();
_refreshTimer.Start();
} }
public ObservableCollection<ConeRadiationActionViewModel> CalibrationActions { get; } public ObservableCollection<ConeRadiationActionViewModel> CalibrationActions { get; }
@@ -158,18 +181,89 @@ public sealed class ConeRadiationSettingsViewModel : PageViewModel
if (action == "开始升温") if (action == "开始升温")
{ {
CurrentTemperatureText = TargetTemperatureText; WriteTargetTemperature();
CurrentHeatFluxText = "25.00"; WriteActionCoil(action);
HeatTransferText = "0.42";
SlopeText = "0.08";
InterceptText = "1.20";
return; return;
} }
if (action.EndsWith("标定", StringComparison.Ordinal)) WriteActionCoil(action);
}
private void RefreshDeviceValues()
{
CurrentHeatFluxText = ReadFloatText(CurrentHeatFluxRegister);
SlopeText = ReadFloatText(SlopeRegister);
InterceptText = ReadFloatText(InterceptRegister);
}
private string ReadFloatText(ushort registerAddress)
{
return _tcpDeviceConnectionService.TryReadFloat(registerAddress, out var value)
? value.ToString("0.00", CultureInfo.InvariantCulture)
: string.Empty;
}
private void WriteTargetTemperature()
{
if (!short.TryParse(TargetTemperatureText, NumberStyles.Integer, CultureInfo.InvariantCulture, out var value))
{ {
CurrentHeatFluxText = action.Replace("KW标定", ".00", StringComparison.Ordinal); Debug.WriteLine($"Invalid cone radiation target temperature: {TargetTemperatureText}");
return; return;
} }
if (!_tcpDeviceConnectionService.TryWriteInt16(TargetTemperatureRegister, value))
{
Debug.WriteLine("Cone radiation target temperature write failed.");
}
}
private void WriteActionCoil(string action)
{
if (!TryGetActionCoil(action, out var coilAddress))
{
return;
}
if (!_tcpDeviceConnectionService.TryWriteCoil(coilAddress, true))
{
Debug.WriteLine($"Cone radiation action '{action}' write failed.");
}
}
private static bool TryGetActionCoil(string action, out ushort coilAddress)
{
switch (action)
{
case "10KW标定":
coilAddress = 130;
return true;
case "25KW标定":
coilAddress = 131;
return true;
case "35KW标定":
coilAddress = 132;
return true;
case "50KW标定":
coilAddress = 133;
return true;
case "65KW标定":
coilAddress = 134;
return true;
case "75KW标定":
coilAddress = 135;
return true;
case "循环水":
coilAddress = 49;
return true;
case "开始升温":
coilAddress = 100;
return true;
case "停止升温":
coilAddress = 101;
return true;
default:
coilAddress = 0;
return false;
}
} }
} }

View File

@@ -12,11 +12,12 @@ public sealed class MainViewModel : ObservableObject
public MainViewModel( public MainViewModel(
IExperimentDataService experimentDataService, IExperimentDataService experimentDataService,
ITcpDeviceConnectionService tcpDeviceConnectionService,
IReportExportService reportExportService, IReportExportService reportExportService,
IHelpDialogService helpDialogService) IHelpDialogService helpDialogService)
{ {
_helpDialogService = helpDialogService; _helpDialogService = helpDialogService;
var testPage = new TestPageViewModel(experimentDataService); var testPage = new TestPageViewModel(experimentDataService, tcpDeviceConnectionService);
var reportPage = new ReportPageViewModel(experimentDataService, reportExportService); var reportPage = new ReportPageViewModel(experimentDataService, reportExportService);
NavigationItems = []; NavigationItems = [];
@@ -25,13 +26,17 @@ public sealed class MainViewModel : ObservableObject
"C值标定", "C值标定",
new CValueCalibrationViewModel( new CValueCalibrationViewModel(
() => SelectPage(testItem), () => SelectPage(testItem),
ShowCValueCalibrationHelp)); ShowCValueCalibrationHelp,
tcpDeviceConnectionService));
var coneRadiationItem = new NavigationItemViewModel( var coneRadiationItem = new NavigationItemViewModel(
"辐射锥设置", "辐射锥设置",
new ConeRadiationSettingsViewModel(() => SelectPage(testItem), ShowConeRadiationHelp)); new ConeRadiationSettingsViewModel(
() => SelectPage(testItem),
ShowConeRadiationHelp,
tcpDeviceConnectionService));
var smokeDensityItem = new NavigationItemViewModel( var smokeDensityItem = new NavigationItemViewModel(
"烟密度设置", "烟密度设置",
new SmokeDensitySettingsViewModel(() => SelectPage(testItem))); new SmokeDensitySettingsViewModel(() => SelectPage(testItem), tcpDeviceConnectionService));
var realtimeDataItem = new NavigationItemViewModel( var realtimeDataItem = new NavigationItemViewModel(
"实时数据", "实时数据",
new RealtimeDataViewModel(experimentDataService, () => SelectPage(testItem))); new RealtimeDataViewModel(experimentDataService, () => SelectPage(testItem)));

View File

@@ -24,11 +24,23 @@ public sealed class MetricDisplayViewModel : ObservableObject
public void SetValue(double value, string format = "0.00") public void SetValue(double value, string format = "0.00")
{ {
if (!double.IsFinite(value))
{
ValueText = string.Empty;
return;
}
ValueText = value.ToString(format); ValueText = value.ToString(format);
} }
public void SetValue(int value) public void SetValue(int value)
{ {
if (value < 0)
{
ValueText = string.Empty;
return;
}
ValueText = value.ToString(); ValueText = value.ToString();
} }
} }

View File

@@ -11,7 +11,7 @@ public sealed class RealtimeDataRowViewModel
public RealtimeDataRowViewModel(RealtimeDataRecord record) public RealtimeDataRowViewModel(RealtimeDataRecord record)
{ {
TimeText = record.TestSeconds.ToString(CultureInfo.InvariantCulture); TimeText = record.TestSeconds < 0 ? string.Empty : record.TestSeconds.ToString(CultureInfo.InvariantCulture);
OxygenText = Format(record.Oxygen); OxygenText = Format(record.Oxygen);
CarbonDioxideText = Format(record.CarbonDioxide); CarbonDioxideText = Format(record.CarbonDioxide);
CarbonMonoxideText = Format(record.CarbonMonoxide); CarbonMonoxideText = Format(record.CarbonMonoxide);
@@ -25,7 +25,7 @@ public sealed class RealtimeDataRowViewModel
HeatReleaseText = Format(record.HeatReleaseRate); HeatReleaseText = Format(record.HeatReleaseRate);
EhcText = Format(record.EffectiveHeatOfCombustion); EhcText = Format(record.EffectiveHeatOfCombustion);
MassLossText = Format(record.MassLoss); MassLossText = Format(record.MassLoss);
SampleTemperatureText = Format(record.ConeTemperature, "0.0"); SampleTemperatureText = Format(record.SampleTemperature, "0.0");
} }
public string TimeText { get; init; } = string.Empty; public string TimeText { get; init; } = string.Empty;

View File

@@ -1,18 +1,24 @@
using System.Collections.ObjectModel; using System.Collections.ObjectModel;
using System.Diagnostics;
using CommunityToolkit.Mvvm.ComponentModel; using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input; using CommunityToolkit.Mvvm.Input;
using ConeCalorimeter.Services;
namespace ConeCalorimeter.ViewModels; namespace ConeCalorimeter.ViewModels;
public sealed class SmokeDensitySettingsViewModel : PageViewModel public sealed class SmokeDensitySettingsViewModel : PageViewModel
{ {
private readonly Action _closeAction; private readonly Action _closeAction;
private readonly ITcpDeviceConnectionService _tcpDeviceConnectionService;
private string _absorbanceText = ""; private string _absorbanceText = "";
private string _lastCalibration = "待校准"; private string _lastCalibration = "待校准";
public SmokeDensitySettingsViewModel(Action closeAction) : base("烟密度设置") public SmokeDensitySettingsViewModel(
Action closeAction,
ITcpDeviceConnectionService tcpDeviceConnectionService) : base("烟密度设置")
{ {
_closeAction = closeAction; _closeAction = closeAction;
_tcpDeviceConnectionService = tcpDeviceConnectionService;
CloseCommand = new RelayCommand(_closeAction); CloseCommand = new RelayCommand(_closeAction);
CalibrationCommand = new RelayCommand<string>(Calibrate); CalibrationCommand = new RelayCommand<string>(Calibrate);
@@ -52,6 +58,40 @@ public sealed class SmokeDensitySettingsViewModel : PageViewModel
} }
LastCalibration = label; LastCalibration = label;
AbsorbanceText = label.StartsWith("100%", StringComparison.Ordinal) ? "100.00" : "0.00";
if (!TryGetCalibrationCoil(label, out var coilAddress))
{
return;
}
if (!_tcpDeviceConnectionService.TryWriteCoil(coilAddress, true))
{
Debug.WriteLine($"Smoke density calibration '{label}' write failed.");
}
}
private static bool TryGetCalibrationCoil(string label, out ushort coilAddress)
{
switch (label)
{
case "0%校准":
coilAddress = 28;
return true;
case "25%校准":
coilAddress = 26;
return true;
case "50%校准":
coilAddress = 24;
return true;
case "75%校准":
coilAddress = 22;
return true;
case "100%校准":
coilAddress = 20;
return true;
default:
coilAddress = 0;
return false;
}
} }
} }

View File

@@ -1,4 +1,5 @@
using System.Collections.ObjectModel; using System.Collections.ObjectModel;
using System.Diagnostics;
using CommunityToolkit.Mvvm.ComponentModel; using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input; using CommunityToolkit.Mvvm.Input;
using ConeCalorimeter.Models; using ConeCalorimeter.Models;
@@ -13,15 +14,19 @@ namespace ConeCalorimeter.ViewModels;
public sealed class TestPageViewModel : PageViewModel public sealed class TestPageViewModel : PageViewModel
{ {
private readonly IExperimentDataService _experimentDataService; private readonly IExperimentDataService _experimentDataService;
private readonly ITcpDeviceConnectionService _tcpDeviceConnectionService;
private readonly LineSeries _heatReleaseSeries; private readonly LineSeries _heatReleaseSeries;
private readonly LineSeries _totalHeatSeries; private readonly LineSeries _totalHeatSeries;
private readonly LineSeries _totalSmokeSeries; private readonly LineSeries _totalSmokeSeries;
private bool _flameDetected; private bool _flameDetected;
private string _lastAction = "待机"; private string _lastAction = "待机";
public TestPageViewModel(IExperimentDataService experimentDataService) : base("测试界面") public TestPageViewModel(
IExperimentDataService experimentDataService,
ITcpDeviceConnectionService tcpDeviceConnectionService) : base("测试界面")
{ {
_experimentDataService = experimentDataService; _experimentDataService = experimentDataService;
_tcpDeviceConnectionService = tcpDeviceConnectionService;
TopMetrics = TopMetrics =
[ [
@@ -42,8 +47,8 @@ public sealed class TestPageViewModel : PageViewModel
HeatMetrics = HeatMetrics =
[ [
new MetricDisplayViewModel("热释放速率", "kW/m²"), new MetricDisplayViewModel("热释放速率", "kW/m²"),
new MetricDisplayViewModel("qa(180)", "MJ/m²"), new MetricDisplayViewModel("热释放速率180", "MJ/m²"),
new MetricDisplayViewModel("qa(300)", "MJ/m²"), new MetricDisplayViewModel("热释放速率300", "MJ/m²"),
new MetricDisplayViewModel("放热总量", "MJ/m²"), new MetricDisplayViewModel("放热总量", "MJ/m²"),
new MetricDisplayViewModel("产烟量", "m³"), new MetricDisplayViewModel("产烟量", "m³"),
new MetricDisplayViewModel("当前质量", "g"), new MetricDisplayViewModel("当前质量", "g"),
@@ -63,12 +68,12 @@ public sealed class TestPageViewModel : PageViewModel
new DeviceActionViewModel("辐射锥降", ExecuteDeviceActionCommand), new DeviceActionViewModel("辐射锥降", ExecuteDeviceActionCommand),
new DeviceActionViewModel("称重台升", ExecuteDeviceActionCommand), new DeviceActionViewModel("称重台升", ExecuteDeviceActionCommand),
new DeviceActionViewModel("称重台降", ExecuteDeviceActionCommand), new DeviceActionViewModel("称重台降", ExecuteDeviceActionCommand),
new DeviceActionViewModel("复位", ExecuteDeviceActionCommand), new DeviceActionViewModel("测试开始", ExecuteDeviceActionCommand),
new DeviceActionViewModel("停止", ExecuteDeviceActionCommand), new DeviceActionViewModel("测试结束", ExecuteDeviceActionCommand),
new DeviceActionViewModel("测试", ExecuteDeviceActionCommand),
new DeviceActionViewModel("风机开", ExecuteDeviceActionCommand), new DeviceActionViewModel("风机开", ExecuteDeviceActionCommand),
new DeviceActionViewModel("风机关", ExecuteDeviceActionCommand), new DeviceActionViewModel("风机关", ExecuteDeviceActionCommand),
new DeviceActionViewModel("点火", ExecuteDeviceActionCommand) new DeviceActionViewModel("点火", ExecuteDeviceActionCommand),
new DeviceActionViewModel("复位", ExecuteDeviceActionCommand)
]; ];
HeatReleasePlot = CreatePlotModel(out _heatReleaseSeries, out _totalHeatSeries, out _totalSmokeSeries); HeatReleasePlot = CreatePlotModel(out _heatReleaseSeries, out _totalHeatSeries, out _totalSmokeSeries);
@@ -227,6 +232,11 @@ public sealed class TestPageViewModel : PageViewModel
private static void AppendPoint(LineSeries series, double x, double y) 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)); series.Points.Add(new DataPoint(x, y));
if (series.Points.Count > 600) if (series.Points.Count > 600)
@@ -237,9 +247,64 @@ public sealed class TestPageViewModel : PageViewModel
private void ExecuteDeviceAction(string? action) private void ExecuteDeviceAction(string? action)
{ {
if (!string.IsNullOrWhiteSpace(action)) if (string.IsNullOrWhiteSpace(action))
{ {
LastAction = action; return;
}
LastAction = action;
if (!TryGetDeviceActionCoil(action, out var coilAddress, out var value))
{
return;
}
if (!_tcpDeviceConnectionService.TryWriteCoil(coilAddress, value))
{
Debug.WriteLine($"Device action '{action}' write failed.");
}
}
private static bool TryGetDeviceActionCoil(string action, out ushort coilAddress, out bool value)
{
value = true;
switch (action)
{
case "称重台升":
coilAddress = 93;
return true;
case "称重台降":
coilAddress = 94;
return true;
case "辐射锥升":
coilAddress = 83;
return true;
case "辐射锥降":
coilAddress = 84;
return true;
case "复位":
coilAddress = 88;
return true;
case "测试开始":
coilAddress = 65;
return true;
case "测试结束":
coilAddress = 67;
return true;
case "点火关":
coilAddress = 53;
return true;
case "风机开":
coilAddress = 54;
return true;
case "风机关":
coilAddress = 54;
value = false;
return true;
default:
coilAddress = 0;
return false;
} }
} }
} }

View File

@@ -238,15 +238,24 @@
</Grid> </Grid>
</StackPanel> </StackPanel>
<Button Grid.Row="2" <StackPanel Grid.Row="2"
Content="开始升温" Orientation="Horizontal"
Command="{Binding ActionCommand}" HorizontalAlignment="Center"
CommandParameter="开始升温" Margin="0,7,0,7">
Width="150" <Button Content="开始升温"
Height="42" Command="{Binding ActionCommand}"
HorizontalAlignment="Center" CommandParameter="开始升温"
Margin="0,7,0,7" Width="140"
Style="{StaticResource InstrumentPrimaryButtonStyle}" /> Height="42"
Margin="0,0,10,0"
Style="{StaticResource InstrumentPrimaryButtonStyle}" />
<Button Content="停止升温"
Command="{Binding ActionCommand}"
CommandParameter="停止升温"
Width="140"
Height="42"
Style="{StaticResource InstrumentButtonStyle}" />
</StackPanel>
<ItemsControl Grid.Row="3" <ItemsControl Grid.Row="3"
ItemsSource="{Binding CalibrationActions}" ItemsSource="{Binding CalibrationActions}"
@@ -289,19 +298,19 @@
FontSize="24" FontSize="24"
FontWeight="SemiBold" FontWeight="SemiBold"
VerticalAlignment="Center" /> VerticalAlignment="Center" />
<TextBox Grid.Column="1" <TextBlock Grid.Column="1"
Text="{Binding SlopeText, UpdateSourceTrigger=PropertyChanged}" Text="{Binding SlopeText}"
Style="{StaticResource ValueInputBoxStyle}" Style="{StaticResource ValueBoxTextStyle}"
VerticalAlignment="Center" /> VerticalAlignment="Center" />
<TextBlock Grid.Column="3" <TextBlock Grid.Column="3"
Text="截距:" Text="截距:"
FontSize="24" FontSize="24"
FontWeight="SemiBold" FontWeight="SemiBold"
VerticalAlignment="Center" /> VerticalAlignment="Center" />
<TextBox Grid.Column="4" <TextBlock Grid.Column="4"
Text="{Binding InterceptText, UpdateSourceTrigger=PropertyChanged}" Text="{Binding InterceptText}"
Style="{StaticResource ValueInputBoxStyle}" Style="{StaticResource ValueBoxTextStyle}"
VerticalAlignment="Center" /> VerticalAlignment="Center" />
<Button Grid.Column="6" <Button Grid.Column="6"
Content="循环水" Content="循环水"
Command="{Binding ActionCommand}" Command="{Binding ActionCommand}"