更新
This commit is contained in:
@@ -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>
|
||||
|
||||
BIN
ConeCalorimeter/MH-AWC(485通讯)(1).pdf
Normal file
BIN
ConeCalorimeter/MH-AWC(485通讯)(1).pdf
Normal file
Binary file not shown.
@@ -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);
|
||||
}
|
||||
|
||||
6
ConeCalorimeter/Services/IScaleService.cs
Normal file
6
ConeCalorimeter/Services/IScaleService.cs
Normal file
@@ -0,0 +1,6 @@
|
||||
namespace ConeCalorimeter.Services;
|
||||
|
||||
public interface IScaleService : IDisposable
|
||||
{
|
||||
bool TryReadCurrentMass(out double value);
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
250
ConeCalorimeter/Services/ModbusRtuScaleService.cs
Normal file
250
ConeCalorimeter/Services/ModbusRtuScaleService.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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++)
|
||||
|
||||
138
ConeCalorimeter/Services/SerialScaleOptions.cs
Normal file
138
ConeCalorimeter/Services/SerialScaleOptions.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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}" />
|
||||
|
||||
Reference in New Issue
Block a user