This commit is contained in:
GukSang.Jin
2026-05-22 18:12:36 +08:00
parent b009fabfdb
commit 22261c48a9
10 changed files with 433 additions and 12 deletions

View File

@@ -13,6 +13,7 @@
<PackageReference Include="NPOI" Version="2.7.2" />
<PackageReference Include="OxyPlot.Wpf" Version="2.2.0" />
<PackageReference Include="SixLabors.ImageSharp" Version="3.1.12" />
<PackageReference Include="System.IO.Ports" Version="10.0.0" />
</ItemGroup>
<ItemGroup>

Binary file not shown.

View File

@@ -7,6 +7,7 @@ namespace ConeCalorimeter
public partial class MainWindow : Window
{
private readonly ITcpDeviceConnectionService _tcpDeviceConnectionService;
private readonly IScaleService _scaleService;
public MainWindow()
{
@@ -14,10 +15,12 @@ namespace ConeCalorimeter
_tcpDeviceConnectionService = new TcpDeviceConnectionService(
TcpDeviceConnectionOptions.FromEnvironment());
_scaleService = new ModbusRtuScaleService(
SerialScaleOptions.FromEnvironment());
_ = _tcpDeviceConnectionService.StartAsync();
var experimentDataService = new ExperimentDataService(
new ModbusRealtimeDataService(_tcpDeviceConnectionService));
new ModbusRealtimeDataService(_tcpDeviceConnectionService, _scaleService));
DataContext = new MainViewModel(
experimentDataService,
_tcpDeviceConnectionService,
@@ -28,6 +31,7 @@ namespace ConeCalorimeter
protected override async void OnClosed(EventArgs e)
{
_scaleService.Dispose();
await _tcpDeviceConnectionService.DisposeAsync();
base.OnClosed(e);
}

View File

@@ -0,0 +1,6 @@
namespace ConeCalorimeter.Services;
public interface IScaleService : IDisposable
{
bool TryReadCurrentMass(out double value);
}

View File

@@ -5,7 +5,6 @@ namespace ConeCalorimeter.Services;
public sealed class ModbusRealtimeDataService : IRealtimeDataService
{
private const ushort CurrentMassRegister = 1;
private const ushort OxygenRegister = 10;
private const ushort OrificeFlowRegister = 14;
private const ushort OrificePressureRegister = 16;
@@ -25,12 +24,16 @@ public sealed class ModbusRealtimeDataService : IRealtimeDataService
private const ushort TestSecondsRegister = 1015;
private const ushort M3FlameMonitorBit = 3;
private readonly ITcpDeviceConnectionService _tcpDeviceConnectionService;
private readonly IScaleService _scaleService;
private readonly HashSet<ushort> _loggedFloatDiagnostics = [];
private readonly HashSet<ushort> _loggedInvalidFloatDiagnostics = [];
public ModbusRealtimeDataService(ITcpDeviceConnectionService tcpDeviceConnectionService)
public ModbusRealtimeDataService(
ITcpDeviceConnectionService tcpDeviceConnectionService,
IScaleService scaleService)
{
_tcpDeviceConnectionService = tcpDeviceConnectionService;
_scaleService = scaleService;
}
public RealtimeSnapshot GetCurrentSnapshot(TimeSpan elapsed)
@@ -53,7 +56,7 @@ public sealed class ModbusRealtimeDataService : IRealtimeDataService
Qa300: ReadRangedFloatOrEmpty("Qa300", Qa300Register, 0, 1000),
TotalHeatRelease: double.NaN,
SmokeProduction: ReadRangedFloatOrEmpty("SmokeProduction", SmokeProductionRegister, 0, 100),
CurrentMass: ReadRangedFloatOrEmpty("CurrentMass", CurrentMassRegister, 0, 100000),
CurrentMass: ReadScaleCurrentMassOrEmpty(),
InitialMass: double.NaN,
MassLoss: double.NaN,
MassLossRate: double.NaN,
@@ -119,6 +122,13 @@ public sealed class ModbusRealtimeDataService : IRealtimeDataService
: -1;
}
private double ReadScaleCurrentMassOrEmpty()
{
return _scaleService.TryReadCurrentMass(out var value)
? NormalizeRealtimeValue(value)
: double.NaN;
}
private bool ReadCoilOrFalse(ushort coilAddress)
{
return _tcpDeviceConnectionService.TryReadCoil(coilAddress, out var value) && value;

View File

@@ -0,0 +1,250 @@
using System.Diagnostics;
using System.IO;
using System.IO.Ports;
namespace ConeCalorimeter.Services;
public sealed class ModbusRtuScaleService : IScaleService
{
private const byte ReadInputRegistersFunction = 0x04;
private const ushort RegisterCount = 2;
private const int ExpectedDataByteCount = 4;
private const double MinimumMass = 0;
private const double MaximumMass = 100000;
private static readonly TimeSpan ReadWriteTimeout = TimeSpan.FromMilliseconds(1000);
private readonly object _syncRoot = new();
private readonly SerialScaleOptions _options;
private SerialPort? _serialPort;
public ModbusRtuScaleService(SerialScaleOptions options)
{
_options = options;
}
public bool TryReadCurrentMass(out double value)
{
value = double.NaN;
lock (_syncRoot)
{
try
{
var port = GetOpenPort();
DiscardBufferedData(port);
WriteReadRequest(port);
var response = ReadResponse(port);
if (!TryDecodeCurrentMass(response, _options.UnitId, out value))
{
return false;
}
value = Math.Abs(value) < 0.005 ? 0 : value;
return true;
}
catch (Exception ex) when (ex is IOException
or InvalidDataException
or TimeoutException
or UnauthorizedAccessException
or InvalidOperationException
or ArgumentException)
{
Debug.WriteLine($"Scale read failed on {_options.PortName}: {ex.Message}");
CloseCurrentPort();
return false;
}
}
}
public void Dispose()
{
lock (_syncRoot)
{
CloseCurrentPort();
}
}
private SerialPort GetOpenPort()
{
if (_serialPort is { IsOpen: true })
{
return _serialPort;
}
CloseCurrentPort();
var port = new SerialPort(
_options.PortName,
_options.BaudRate,
_options.Parity,
_options.DataBits,
_options.StopBits)
{
ReadTimeout = (int)ReadWriteTimeout.TotalMilliseconds,
WriteTimeout = (int)ReadWriteTimeout.TotalMilliseconds
};
port.Open();
_serialPort = port;
return port;
}
private void CloseCurrentPort()
{
var port = _serialPort;
_serialPort = null;
if (port is null)
{
return;
}
try
{
if (port.IsOpen)
{
port.Close();
}
}
finally
{
port.Dispose();
}
}
private static void DiscardBufferedData(SerialPort port)
{
port.DiscardInBuffer();
port.DiscardOutBuffer();
}
private void WriteReadRequest(SerialPort port)
{
Span<byte> request = stackalloc byte[8];
request[0] = _options.UnitId;
request[1] = ReadInputRegistersFunction;
request[2] = (byte)(_options.RegisterAddress >> 8);
request[3] = (byte)_options.RegisterAddress;
request[4] = (byte)(RegisterCount >> 8);
request[5] = (byte)RegisterCount;
WriteCrc(request);
var requestBytes = request.ToArray();
port.Write(requestBytes, 0, requestBytes.Length);
}
private static byte[] ReadResponse(SerialPort port)
{
var header = new byte[3];
ReadExactly(port, header);
if (header[1] == (ReadInputRegistersFunction | 0x80))
{
var exceptionTail = new byte[2];
ReadExactly(port, exceptionTail);
var exceptionFrame = header.Concat(exceptionTail).ToArray();
ValidateCrc(exceptionFrame);
throw new InvalidDataException($"Scale returned Modbus exception code {header[2]}.");
}
if (header[1] != ReadInputRegistersFunction || header[2] != ExpectedDataByteCount)
{
throw new InvalidDataException("Invalid scale Modbus response header.");
}
var response = new byte[3 + ExpectedDataByteCount + 2];
header.CopyTo(response, 0);
ReadExactly(port, response.AsSpan(3));
ValidateCrc(response);
return response;
}
private static void ReadExactly(SerialPort port, Span<byte> buffer)
{
var totalRead = 0;
var scratch = new byte[Math.Min(256, buffer.Length)];
while (totalRead < buffer.Length)
{
var bytesToRead = Math.Min(scratch.Length, buffer.Length - totalRead);
var read = port.Read(scratch, 0, bytesToRead);
if (read == 0)
{
throw new IOException("Scale serial port returned no data.");
}
scratch.AsSpan(0, read).CopyTo(buffer[totalRead..]);
totalRead += read;
}
}
private static bool TryDecodeCurrentMass(byte[] response, byte unitId, out double value)
{
value = double.NaN;
if (response.Length != 9
|| response[0] != unitId
|| response[1] != ReadInputRegistersFunction
|| response[2] != ExpectedDataByteCount)
{
return false;
}
var result = new ModbusFloatReadResult(response[3], response[4], response[5], response[6]);
var candidate = result.Abcd;
if (!ModbusFloatSelector.IsInRange(candidate, MinimumMass, MaximumMass))
{
Debug.WriteLine(
$"Scale mass out of range raw [{result.RawHex}], ABCD={result.Abcd:G9}, "
+ $"CDAB={result.Cdab:G9}, BADC={result.Badc:G9}, DCBA={result.Dcba:G9}.");
return false;
}
value = candidate;
return true;
}
private static void WriteCrc(Span<byte> frame)
{
var crc = CalculateCrc(frame[..^2]);
frame[^2] = (byte)crc;
frame[^1] = (byte)(crc >> 8);
}
private static void ValidateCrc(ReadOnlySpan<byte> frame)
{
if (frame.Length < 3)
{
throw new InvalidDataException("Scale Modbus response is too short.");
}
var expected = CalculateCrc(frame[..^2]);
var actual = (ushort)(frame[^2] | (frame[^1] << 8));
if (actual != expected)
{
throw new InvalidDataException("Scale Modbus response CRC mismatch.");
}
}
private static ushort CalculateCrc(ReadOnlySpan<byte> data)
{
ushort crc = 0xFFFF;
foreach (var value in data)
{
crc ^= value;
for (var bit = 0; bit < 8; bit++)
{
var lsbSet = (crc & 0x0001) != 0;
crc >>= 1;
if (lsbSet)
{
crc ^= 0xA001;
}
}
}
return crc;
}
}

View File

@@ -16,6 +16,8 @@ public sealed class NpoiRealtimeDataExportService : IRealtimeDataExportService
"孔板压差 (Pa)",
"孔板温度 (℃)",
"HRR",
"热释放速率180",
"热释放速率300",
"THR (MJ/m2)",
"SPR",
"TSR (m2)",
@@ -61,14 +63,16 @@ public sealed class NpoiRealtimeDataExportService : IRealtimeDataExportService
SetNumeric(row, 4, record.OrificePressure);
SetNumeric(row, 5, record.OrificeTemperature);
SetNumeric(row, 6, record.HeatReleaseRate);
SetNumeric(row, 7, record.TotalHeatRelease);
SetNumeric(row, 8, record.SmokeProduction);
SetNumeric(row, 9, record.TotalSmoke);
SetNumeric(row, 10, record.MassLossRate);
SetNumeric(row, 11, record.HeatReleaseRateKw);
SetNumeric(row, 12, record.EffectiveHeatOfCombustion);
SetNumeric(row, 13, record.MassLoss);
SetNumeric(row, 14, record.SampleTemperature);
SetNumeric(row, 7, record.Qa180);
SetNumeric(row, 8, record.Qa300);
SetNumeric(row, 9, record.TotalHeatRelease);
SetNumeric(row, 10, record.SmokeProduction);
SetNumeric(row, 11, record.TotalSmoke);
SetNumeric(row, 12, record.MassLossRate);
SetNumeric(row, 13, record.HeatReleaseRateKw);
SetNumeric(row, 14, record.EffectiveHeatOfCombustion);
SetNumeric(row, 15, record.MassLoss);
SetNumeric(row, 16, record.SampleTemperature);
}
for (var i = 0; i < Headers.Length; i++)

View File

@@ -0,0 +1,138 @@
using System.IO.Ports;
namespace ConeCalorimeter.Services;
public sealed record SerialScaleOptions(
string PortName,
int BaudRate,
Parity Parity,
int DataBits,
StopBits StopBits,
byte UnitId,
ushort RegisterAddress)
{
public const string PortEnvironmentVariable = "CONE_SCALE_PORT";
public const string BaudRateEnvironmentVariable = "CONE_SCALE_BAUD_RATE";
public const string ParityEnvironmentVariable = "CONE_SCALE_PARITY";
public const string DataBitsEnvironmentVariable = "CONE_SCALE_DATA_BITS";
public const string StopBitsEnvironmentVariable = "CONE_SCALE_STOP_BITS";
public const string UnitIdEnvironmentVariable = "CONE_SCALE_UNIT_ID";
public const string RegisterEnvironmentVariable = "CONE_SCALE_REGISTER";
public static SerialScaleOptions Default { get; } = new(
"COM1",
9600,
Parity.None,
8,
StopBits.One,
0,
0);
public static SerialScaleOptions FromEnvironment()
{
var portName = Environment.GetEnvironmentVariable(PortEnvironmentVariable);
var baudRateText = Environment.GetEnvironmentVariable(BaudRateEnvironmentVariable);
var parityText = Environment.GetEnvironmentVariable(ParityEnvironmentVariable);
var dataBitsText = Environment.GetEnvironmentVariable(DataBitsEnvironmentVariable);
var stopBitsText = Environment.GetEnvironmentVariable(StopBitsEnvironmentVariable);
var unitIdText = Environment.GetEnvironmentVariable(UnitIdEnvironmentVariable);
var registerText = Environment.GetEnvironmentVariable(RegisterEnvironmentVariable);
var baudRate = int.TryParse(baudRateText, out var parsedBaudRate) && parsedBaudRate > 0
? parsedBaudRate
: Default.BaudRate;
var parity = ParseParity(parityText);
var dataBits = int.TryParse(dataBitsText, out var parsedDataBits) && parsedDataBits is >= 5 and <= 8
? parsedDataBits
: Default.DataBits;
var stopBits = ParseStopBits(stopBitsText);
var unitId = TryParseByte(unitIdText, out var parsedUnitId)
? parsedUnitId
: Default.UnitId;
var registerAddress = TryParseUInt16(registerText, out var parsedRegisterAddress)
? parsedRegisterAddress
: Default.RegisterAddress;
return new SerialScaleOptions(
string.IsNullOrWhiteSpace(portName) ? Default.PortName : portName.Trim(),
baudRate,
parity,
dataBits,
stopBits,
unitId,
registerAddress);
}
private static Parity ParseParity(string? value)
{
if (string.IsNullOrWhiteSpace(value))
{
return Default.Parity;
}
return value.Trim().ToUpperInvariant() switch
{
"N" => Parity.None,
"E" => Parity.Even,
"O" => Parity.Odd,
"M" => Parity.Mark,
"S" => Parity.Space,
_ => Enum.TryParse<Parity>(value, ignoreCase: true, out var parsedParity)
? parsedParity
: Default.Parity
};
}
private static StopBits ParseStopBits(string? value)
{
if (string.IsNullOrWhiteSpace(value))
{
return Default.StopBits;
}
return value.Trim() switch
{
"1" => StopBits.One,
"1.5" => StopBits.OnePointFive,
"2" => StopBits.Two,
_ => Enum.TryParse<StopBits>(value, ignoreCase: true, out var parsedStopBits)
&& parsedStopBits != StopBits.None
? parsedStopBits
: Default.StopBits
};
}
private static bool TryParseByte(string? value, out byte result)
{
if (TryParseUInt16(value, out var parsed) && parsed <= byte.MaxValue)
{
result = (byte)parsed;
return true;
}
result = 0;
return false;
}
private static bool TryParseUInt16(string? value, out ushort result)
{
result = 0;
if (string.IsNullOrWhiteSpace(value))
{
return false;
}
var trimmed = value.Trim();
if (trimmed.StartsWith("0x", StringComparison.OrdinalIgnoreCase))
{
return ushort.TryParse(
trimmed[2..],
System.Globalization.NumberStyles.HexNumber,
provider: null,
out result);
}
return ushort.TryParse(trimmed, out result);
}
}

View File

@@ -18,6 +18,8 @@ public sealed class RealtimeDataRowViewModel
OrificePressureText = Format(record.OrificePressure);
OrificeTemperatureText = Format(record.OrificeTemperature);
Hrr50Text = Format(record.HeatReleaseRate);
Qa180Text = Format(record.Qa180);
Qa300Text = Format(record.Qa300);
ThrText = Format(record.TotalHeatRelease);
Spr50Text = Format(record.SmokeProduction);
TsrText = Format(record.TotalSmoke);
@@ -42,6 +44,10 @@ public sealed class RealtimeDataRowViewModel
public string Hrr50Text { get; init; } = string.Empty;
public string Qa180Text { get; init; } = string.Empty;
public string Qa300Text { get; init; } = string.Empty;
public string ThrText { get; init; } = string.Empty;
public string Spr50Text { get; init; } = string.Empty;

View File

@@ -140,6 +140,8 @@
<DataGridTextColumn Header="孔板压差 (Pa)" Binding="{Binding OrificePressureText}" Width="116" ElementStyle="{StaticResource RealtimeTextElementStyle}" />
<DataGridTextColumn Header="孔板温度 (℃)" Binding="{Binding OrificeTemperatureText}" Width="116" ElementStyle="{StaticResource RealtimeTextElementStyle}" />
<DataGridTextColumn Header="HRR" Binding="{Binding Hrr50Text}" Width="78" ElementStyle="{StaticResource RealtimeTextElementStyle}" />
<DataGridTextColumn Header="热释放速率180" Binding="{Binding Qa180Text}" Width="118" ElementStyle="{StaticResource RealtimeTextElementStyle}" />
<DataGridTextColumn Header="热释放速率300" Binding="{Binding Qa300Text}" Width="118" ElementStyle="{StaticResource RealtimeTextElementStyle}" />
<DataGridTextColumn Header="THR (MJ/m2)" Binding="{Binding ThrText}" Width="108" ElementStyle="{StaticResource RealtimeTextElementStyle}" />
<DataGridTextColumn Header="SPR" Binding="{Binding Spr50Text}" Width="78" ElementStyle="{StaticResource RealtimeTextElementStyle}" />
<DataGridTextColumn Header="TSR (m2)" Binding="{Binding TsrText}" Width="88" ElementStyle="{StaticResource RealtimeTextElementStyle}" />