更新2027
This commit is contained in:
@@ -1,19 +1,42 @@
|
|||||||
using System.Windows;
|
using System.Windows;
|
||||||
|
using System.Windows.Input;
|
||||||
|
using System.Windows.Threading;
|
||||||
using ConeCalorimeter.Services;
|
using ConeCalorimeter.Services;
|
||||||
using ConeCalorimeter.ViewModels;
|
using ConeCalorimeter.ViewModels;
|
||||||
|
using ConeCalorimeter.Views;
|
||||||
using Serilog;
|
using Serilog;
|
||||||
|
|
||||||
namespace ConeCalorimeter
|
namespace ConeCalorimeter
|
||||||
{
|
{
|
||||||
public partial class MainWindow : Window
|
public partial class MainWindow : Window
|
||||||
{
|
{
|
||||||
|
private const double HiddenHeatFluxEntrySize = 80;
|
||||||
|
private static readonly TimeSpan HiddenHeatFluxLongPressDuration = TimeSpan.FromSeconds(2);
|
||||||
|
|
||||||
private readonly ITcpDeviceConnectionService _tcpDeviceConnectionService;
|
private readonly ITcpDeviceConnectionService _tcpDeviceConnectionService;
|
||||||
private readonly IScaleService _scaleService;
|
private readonly IScaleService _scaleService;
|
||||||
|
private readonly DispatcherTimer _hiddenHeatFluxLongPressTimer;
|
||||||
|
private bool _hiddenHeatFluxLongPressActive;
|
||||||
|
private bool _hiddenHeatFluxDialogOpenedByLongPress;
|
||||||
|
private bool _isHeatFluxCoefficientDialogOpen;
|
||||||
|
private int? _hiddenHeatFluxTouchDeviceId;
|
||||||
|
|
||||||
public MainWindow()
|
public MainWindow()
|
||||||
{
|
{
|
||||||
InitializeComponent();
|
InitializeComponent();
|
||||||
|
|
||||||
|
_hiddenHeatFluxLongPressTimer = new DispatcherTimer
|
||||||
|
{
|
||||||
|
Interval = HiddenHeatFluxLongPressDuration
|
||||||
|
};
|
||||||
|
_hiddenHeatFluxLongPressTimer.Tick += HiddenHeatFluxLongPressTimer_Tick;
|
||||||
|
PreviewMouseLeftButtonDown += MainWindow_PreviewMouseLeftButtonDown;
|
||||||
|
PreviewMouseLeftButtonUp += MainWindow_PreviewMouseLeftButtonUp;
|
||||||
|
PreviewMouseMove += MainWindow_PreviewMouseMove;
|
||||||
|
PreviewTouchDown += MainWindow_PreviewTouchDown;
|
||||||
|
PreviewTouchUp += MainWindow_PreviewTouchUp;
|
||||||
|
PreviewTouchMove += MainWindow_PreviewTouchMove;
|
||||||
|
|
||||||
var tcpOptions = TcpDeviceConnectionOptions.FromEnvironment();
|
var tcpOptions = TcpDeviceConnectionOptions.FromEnvironment();
|
||||||
var scaleOptions = SerialScaleOptions.FromEnvironment();
|
var scaleOptions = SerialScaleOptions.FromEnvironment();
|
||||||
|
|
||||||
@@ -26,7 +49,8 @@ namespace ConeCalorimeter
|
|||||||
_ = _tcpDeviceConnectionService.StartAsync();
|
_ = _tcpDeviceConnectionService.StartAsync();
|
||||||
|
|
||||||
var experimentDataService = new ExperimentDataService(
|
var experimentDataService = new ExperimentDataService(
|
||||||
new ModbusRealtimeDataService(_tcpDeviceConnectionService, _scaleService));
|
new ModbusRealtimeDataService(_tcpDeviceConnectionService, _scaleService),
|
||||||
|
new HeatReleaseCorrectionService(new JsonHeatReleaseResponseCalibrationStore()));
|
||||||
DataContext = new MainViewModel(
|
DataContext = new MainViewModel(
|
||||||
experimentDataService,
|
experimentDataService,
|
||||||
_tcpDeviceConnectionService,
|
_tcpDeviceConnectionService,
|
||||||
@@ -43,5 +67,134 @@ namespace ConeCalorimeter
|
|||||||
await _tcpDeviceConnectionService.DisposeAsync();
|
await _tcpDeviceConnectionService.DisposeAsync();
|
||||||
base.OnClosed(e);
|
base.OnClosed(e);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private void MainWindow_PreviewMouseLeftButtonDown(object sender, MouseButtonEventArgs e)
|
||||||
|
{
|
||||||
|
if (IsInHiddenHeatFluxEntry(e.GetPosition(this)))
|
||||||
|
{
|
||||||
|
BeginHiddenHeatFluxLongPress();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void MainWindow_PreviewMouseLeftButtonUp(object sender, MouseButtonEventArgs e)
|
||||||
|
{
|
||||||
|
CancelHiddenHeatFluxLongPress();
|
||||||
|
|
||||||
|
if (_hiddenHeatFluxDialogOpenedByLongPress)
|
||||||
|
{
|
||||||
|
_hiddenHeatFluxDialogOpenedByLongPress = false;
|
||||||
|
e.Handled = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void MainWindow_PreviewMouseMove(object sender, MouseEventArgs e)
|
||||||
|
{
|
||||||
|
if (!_hiddenHeatFluxLongPressActive)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (e.LeftButton != MouseButtonState.Pressed
|
||||||
|
|| !IsInHiddenHeatFluxEntry(e.GetPosition(this)))
|
||||||
|
{
|
||||||
|
CancelHiddenHeatFluxLongPress();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void MainWindow_PreviewTouchDown(object? sender, TouchEventArgs e)
|
||||||
|
{
|
||||||
|
if (IsInHiddenHeatFluxEntry(e.GetTouchPoint(this).Position))
|
||||||
|
{
|
||||||
|
_hiddenHeatFluxTouchDeviceId = e.TouchDevice.Id;
|
||||||
|
BeginHiddenHeatFluxLongPress();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void MainWindow_PreviewTouchUp(object? sender, TouchEventArgs e)
|
||||||
|
{
|
||||||
|
if (_hiddenHeatFluxTouchDeviceId == e.TouchDevice.Id)
|
||||||
|
{
|
||||||
|
_hiddenHeatFluxTouchDeviceId = null;
|
||||||
|
CancelHiddenHeatFluxLongPress();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void MainWindow_PreviewTouchMove(object? sender, TouchEventArgs e)
|
||||||
|
{
|
||||||
|
if (!_hiddenHeatFluxLongPressActive
|
||||||
|
|| _hiddenHeatFluxTouchDeviceId != e.TouchDevice.Id)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!IsInHiddenHeatFluxEntry(e.GetTouchPoint(this).Position))
|
||||||
|
{
|
||||||
|
_hiddenHeatFluxTouchDeviceId = null;
|
||||||
|
CancelHiddenHeatFluxLongPress();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void HiddenHeatFluxLongPressTimer_Tick(object? sender, EventArgs e)
|
||||||
|
{
|
||||||
|
_hiddenHeatFluxLongPressTimer.Stop();
|
||||||
|
if (!_hiddenHeatFluxLongPressActive)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
_hiddenHeatFluxLongPressActive = false;
|
||||||
|
_hiddenHeatFluxTouchDeviceId = null;
|
||||||
|
_hiddenHeatFluxDialogOpenedByLongPress = true;
|
||||||
|
ShowHeatFluxCoefficientDialog();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void BeginHiddenHeatFluxLongPress()
|
||||||
|
{
|
||||||
|
if (_isHeatFluxCoefficientDialogOpen)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
_hiddenHeatFluxDialogOpenedByLongPress = false;
|
||||||
|
_hiddenHeatFluxLongPressActive = true;
|
||||||
|
_hiddenHeatFluxLongPressTimer.Stop();
|
||||||
|
_hiddenHeatFluxLongPressTimer.Start();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void CancelHiddenHeatFluxLongPress()
|
||||||
|
{
|
||||||
|
_hiddenHeatFluxLongPressTimer.Stop();
|
||||||
|
_hiddenHeatFluxLongPressActive = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool IsInHiddenHeatFluxEntry(Point position)
|
||||||
|
{
|
||||||
|
return position.X >= 0
|
||||||
|
&& position.Y >= 0
|
||||||
|
&& position.X <= HiddenHeatFluxEntrySize
|
||||||
|
&& position.Y <= HiddenHeatFluxEntrySize;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void ShowHeatFluxCoefficientDialog()
|
||||||
|
{
|
||||||
|
if (_isHeatFluxCoefficientDialogOpen)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
_isHeatFluxCoefficientDialogOpen = true;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var dialog = new HeatFluxCoefficientWindow(_tcpDeviceConnectionService)
|
||||||
|
{
|
||||||
|
Owner = this
|
||||||
|
};
|
||||||
|
dialog.ShowDialog();
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
_isHeatFluxCoefficientDialogOpen = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
12
ConeCalorimeter/Models/HeatReleaseCorrectionResult.cs
Normal file
12
ConeCalorimeter/Models/HeatReleaseCorrectionResult.cs
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
namespace ConeCalorimeter.Models;
|
||||||
|
|
||||||
|
public sealed record HeatReleaseCorrectionResult(
|
||||||
|
bool IsApplied,
|
||||||
|
string Status,
|
||||||
|
string CalibrationVersion,
|
||||||
|
IReadOnlyList<RealtimeDataRecord> Records,
|
||||||
|
double RawPeak,
|
||||||
|
double CorrectedPeak,
|
||||||
|
double RawFinalTotalHeatRelease,
|
||||||
|
double CorrectedFinalTotalHeatRelease,
|
||||||
|
double AreaErrorPercent);
|
||||||
26
ConeCalorimeter/Models/HeatReleaseResponseCalibration.cs
Normal file
26
ConeCalorimeter/Models/HeatReleaseResponseCalibration.cs
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
namespace ConeCalorimeter.Models;
|
||||||
|
|
||||||
|
public sealed class HeatReleaseResponseCalibration
|
||||||
|
{
|
||||||
|
public string Version { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
public DateTime CalibratedAtUtc { get; set; }
|
||||||
|
|
||||||
|
public string Method { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
public double SampleIntervalSeconds { get; set; } = 1;
|
||||||
|
|
||||||
|
public double RegularizationLambda { get; set; } = 0.02;
|
||||||
|
|
||||||
|
public double GradientStep { get; set; } = 0.05;
|
||||||
|
|
||||||
|
public int Iterations { get; set; } = 1500;
|
||||||
|
|
||||||
|
public double ConvergenceTolerance { get; set; } = 0.000001;
|
||||||
|
|
||||||
|
public double MaximumAreaErrorPercent { get; set; } = 0.01;
|
||||||
|
|
||||||
|
public double? ValidationErrorPercent { get; set; }
|
||||||
|
|
||||||
|
public List<double> ResponseKernel { get; set; } = [];
|
||||||
|
}
|
||||||
@@ -26,7 +26,11 @@ public sealed record RealtimeDataRecord(
|
|||||||
double MassLossRate,
|
double MassLossRate,
|
||||||
int IgnitionSeconds,
|
int IgnitionSeconds,
|
||||||
int TestSeconds,
|
int TestSeconds,
|
||||||
double TotalSmoke)
|
double TotalSmoke,
|
||||||
|
double RawHeatReleaseRate = double.NaN,
|
||||||
|
double RawTotalHeatRelease = double.NaN,
|
||||||
|
bool IsHeatReleaseCorrected = false,
|
||||||
|
string HeatReleaseCorrectionVersion = "")
|
||||||
{
|
{
|
||||||
private const double DefaultIrradiatedAreaSquareMeters = 0.01;
|
private const double DefaultIrradiatedAreaSquareMeters = 0.01;
|
||||||
|
|
||||||
@@ -58,7 +62,9 @@ public sealed record RealtimeDataRecord(
|
|||||||
snapshot.MassLossRate,
|
snapshot.MassLossRate,
|
||||||
snapshot.IgnitionSeconds,
|
snapshot.IgnitionSeconds,
|
||||||
snapshot.TestSeconds,
|
snapshot.TestSeconds,
|
||||||
snapshot.TotalSmoke);
|
snapshot.TotalSmoke,
|
||||||
|
snapshot.HeatReleaseRate,
|
||||||
|
snapshot.TotalHeatRelease);
|
||||||
}
|
}
|
||||||
|
|
||||||
public double HeatReleaseRateKw =>
|
public double HeatReleaseRateKw =>
|
||||||
|
|||||||
21
ConeCalorimeter/Services/BulkObservableCollection.cs
Normal file
21
ConeCalorimeter/Services/BulkObservableCollection.cs
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
using System.Collections.ObjectModel;
|
||||||
|
using System.Collections.Specialized;
|
||||||
|
using System.ComponentModel;
|
||||||
|
|
||||||
|
namespace ConeCalorimeter.Services;
|
||||||
|
|
||||||
|
public sealed class BulkObservableCollection<T> : ObservableCollection<T>
|
||||||
|
{
|
||||||
|
public void ReplaceAll(IEnumerable<T> items)
|
||||||
|
{
|
||||||
|
Items.Clear();
|
||||||
|
foreach (var item in items)
|
||||||
|
{
|
||||||
|
Items.Add(item);
|
||||||
|
}
|
||||||
|
|
||||||
|
OnPropertyChanged(new PropertyChangedEventArgs(nameof(Count)));
|
||||||
|
OnPropertyChanged(new PropertyChangedEventArgs("Item[]"));
|
||||||
|
OnCollectionChanged(new NotifyCollectionChangedEventArgs(NotifyCollectionChangedAction.Reset));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -13,6 +13,8 @@ public sealed class ExperimentDataService : IExperimentDataService
|
|||||||
private static readonly TimeSpan DevicePollInterval = TimeSpan.FromSeconds(1);
|
private static readonly TimeSpan DevicePollInterval = TimeSpan.FromSeconds(1);
|
||||||
|
|
||||||
private readonly IRealtimeDataService _realtimeDataService;
|
private readonly IRealtimeDataService _realtimeDataService;
|
||||||
|
private readonly IHeatReleaseCorrectionService _heatReleaseCorrectionService;
|
||||||
|
private readonly BulkObservableCollection<RealtimeDataRecord> _records;
|
||||||
private readonly DispatcherTimer _timer;
|
private readonly DispatcherTimer _timer;
|
||||||
private readonly Stopwatch _testClock = new();
|
private readonly Stopwatch _testClock = new();
|
||||||
private DateTime _lastDevicePollStartedAtUtc = DateTime.MinValue;
|
private DateTime _lastDevicePollStartedAtUtc = DateTime.MinValue;
|
||||||
@@ -30,11 +32,16 @@ public sealed class ExperimentDataService : IExperimentDataService
|
|||||||
private bool _isPollingSnapshot;
|
private bool _isPollingSnapshot;
|
||||||
private RealtimeSnapshot? _completedSnapshot;
|
private RealtimeSnapshot? _completedSnapshot;
|
||||||
|
|
||||||
public ExperimentDataService(IRealtimeDataService realtimeDataService)
|
public ExperimentDataService(
|
||||||
|
IRealtimeDataService realtimeDataService,
|
||||||
|
IHeatReleaseCorrectionService heatReleaseCorrectionService)
|
||||||
{
|
{
|
||||||
_realtimeDataService = realtimeDataService;
|
_realtimeDataService = realtimeDataService;
|
||||||
Records = [];
|
_heatReleaseCorrectionService = heatReleaseCorrectionService;
|
||||||
|
_records = [];
|
||||||
|
Records = _records;
|
||||||
CurrentSnapshot = BuildIdleSnapshot(_realtimeDataService.GetCurrentSnapshot(TimeSpan.Zero));
|
CurrentSnapshot = BuildIdleSnapshot(_realtimeDataService.GetCurrentSnapshot(TimeSpan.Zero));
|
||||||
|
HeatReleaseCorrectionStatus = "尚未完成测试,未生成正式 HRR 校正结果。";
|
||||||
|
|
||||||
_timer = new DispatcherTimer
|
_timer = new DispatcherTimer
|
||||||
{
|
{
|
||||||
@@ -48,13 +55,20 @@ public sealed class ExperimentDataService : IExperimentDataService
|
|||||||
|
|
||||||
public RealtimeSnapshot CurrentSnapshot { get; private set; }
|
public RealtimeSnapshot CurrentSnapshot { get; private set; }
|
||||||
|
|
||||||
|
public bool IsHeatReleaseCorrectionApplied { get; private set; }
|
||||||
|
|
||||||
|
public string HeatReleaseCorrectionStatus { get; private set; }
|
||||||
|
|
||||||
public event EventHandler<RealtimeSnapshot>? SnapshotUpdated;
|
public event EventHandler<RealtimeSnapshot>? SnapshotUpdated;
|
||||||
|
|
||||||
|
public event EventHandler? HeatReleaseCorrectionStatusChanged;
|
||||||
|
|
||||||
public void StartTest()
|
public void StartTest()
|
||||||
{
|
{
|
||||||
Records.Clear();
|
Records.Clear();
|
||||||
ResetComputedState();
|
ResetComputedState();
|
||||||
_completedSnapshot = null;
|
_completedSnapshot = null;
|
||||||
|
SetHeatReleaseCorrectionStatus(false, "测试进行中,当前显示和记录为原始 HRR/THR。");
|
||||||
_isTestRunning = true;
|
_isTestRunning = true;
|
||||||
_isRecordingActive = false;
|
_isRecordingActive = false;
|
||||||
_testClock.Reset();
|
_testClock.Reset();
|
||||||
@@ -73,6 +87,7 @@ public sealed class ExperimentDataService : IExperimentDataService
|
|||||||
_isTestRunning = false;
|
_isTestRunning = false;
|
||||||
_isRecordingActive = false;
|
_isRecordingActive = false;
|
||||||
_testClock.Stop();
|
_testClock.Stop();
|
||||||
|
ApplyFinalHeatReleaseCorrection();
|
||||||
_completedSnapshot = BuildCompletedSnapshot(CurrentSnapshot);
|
_completedSnapshot = BuildCompletedSnapshot(CurrentSnapshot);
|
||||||
PublishSnapshot(BuildIdleSnapshot(CurrentSnapshot));
|
PublishSnapshot(BuildIdleSnapshot(CurrentSnapshot));
|
||||||
|
|
||||||
@@ -91,6 +106,7 @@ public sealed class ExperimentDataService : IExperimentDataService
|
|||||||
_testClock.Reset();
|
_testClock.Reset();
|
||||||
_isRecordingActive = false;
|
_isRecordingActive = false;
|
||||||
_completedSnapshot = null;
|
_completedSnapshot = null;
|
||||||
|
SetHeatReleaseCorrectionStatus(false, "记录已清空,未生成正式 HRR 校正结果。");
|
||||||
ResetComputedState();
|
ResetComputedState();
|
||||||
PublishSnapshot(BuildIdleSnapshot(CurrentSnapshot));
|
PublishSnapshot(BuildIdleSnapshot(CurrentSnapshot));
|
||||||
}
|
}
|
||||||
@@ -246,6 +262,8 @@ public sealed class ExperimentDataService : IExperimentDataService
|
|||||||
|
|
||||||
return snapshot with
|
return snapshot with
|
||||||
{
|
{
|
||||||
|
HeatReleaseRate = completedSnapshot?.HeatReleaseRate ?? snapshot.HeatReleaseRate,
|
||||||
|
PeakHeatReleaseRate = completedSnapshot?.PeakHeatReleaseRate ?? snapshot.PeakHeatReleaseRate,
|
||||||
TestSeconds = completedSnapshot?.TestSeconds ?? -1,
|
TestSeconds = completedSnapshot?.TestSeconds ?? -1,
|
||||||
InitialMass = completedSnapshot?.InitialMass ?? double.NaN,
|
InitialMass = completedSnapshot?.InitialMass ?? double.NaN,
|
||||||
MassLoss = completedSnapshot?.MassLoss ?? double.NaN,
|
MassLoss = completedSnapshot?.MassLoss ?? double.NaN,
|
||||||
@@ -282,6 +300,8 @@ public sealed class ExperimentDataService : IExperimentDataService
|
|||||||
var lastRecord = Records[Records.Count - 1];
|
var lastRecord = Records[Records.Count - 1];
|
||||||
return snapshot with
|
return snapshot with
|
||||||
{
|
{
|
||||||
|
HeatReleaseRate = lastRecord.HeatReleaseRate,
|
||||||
|
PeakHeatReleaseRate = lastRecord.PeakHeatReleaseRate,
|
||||||
TotalHeatRelease = lastRecord.TotalHeatRelease,
|
TotalHeatRelease = lastRecord.TotalHeatRelease,
|
||||||
InitialMass = lastRecord.InitialMass,
|
InitialMass = lastRecord.InitialMass,
|
||||||
MassLoss = lastRecord.MassLoss,
|
MassLoss = lastRecord.MassLoss,
|
||||||
@@ -292,6 +312,30 @@ public sealed class ExperimentDataService : IExperimentDataService
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private void ApplyFinalHeatReleaseCorrection()
|
||||||
|
{
|
||||||
|
if (Records.Count == 0)
|
||||||
|
{
|
||||||
|
SetHeatReleaseCorrectionStatus(false, "没有有效记录,无法生成正式 HRR 校正结果。");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var result = _heatReleaseCorrectionService.Correct(Records.ToList());
|
||||||
|
if (result.IsApplied)
|
||||||
|
{
|
||||||
|
_records.ReplaceAll(result.Records);
|
||||||
|
}
|
||||||
|
|
||||||
|
SetHeatReleaseCorrectionStatus(result.IsApplied, result.Status);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void SetHeatReleaseCorrectionStatus(bool isApplied, string status)
|
||||||
|
{
|
||||||
|
IsHeatReleaseCorrectionApplied = isApplied;
|
||||||
|
HeatReleaseCorrectionStatus = status;
|
||||||
|
HeatReleaseCorrectionStatusChanged?.Invoke(this, EventArgs.Empty);
|
||||||
|
}
|
||||||
|
|
||||||
private void PrepareDeviceTestTimerGate()
|
private void PrepareDeviceTestTimerGate()
|
||||||
{
|
{
|
||||||
var currentDeviceSeconds = CurrentSnapshot.DeviceTestSeconds;
|
var currentDeviceSeconds = CurrentSnapshot.DeviceTestSeconds;
|
||||||
|
|||||||
403
ConeCalorimeter/Services/HeatReleaseCorrectionService.cs
Normal file
403
ConeCalorimeter/Services/HeatReleaseCorrectionService.cs
Normal file
@@ -0,0 +1,403 @@
|
|||||||
|
using ConeCalorimeter.Models;
|
||||||
|
using Serilog;
|
||||||
|
|
||||||
|
namespace ConeCalorimeter.Services;
|
||||||
|
|
||||||
|
public sealed class HeatReleaseCorrectionService : IHeatReleaseCorrectionService
|
||||||
|
{
|
||||||
|
private const double TotalHeatReleaseDivisor = 1000;
|
||||||
|
private const double NumericalTolerance = 1e-9;
|
||||||
|
private const double MaximumCorrectedHeatReleaseRate = 5000;
|
||||||
|
|
||||||
|
private readonly JsonHeatReleaseResponseCalibrationStore _calibrationStore;
|
||||||
|
|
||||||
|
public HeatReleaseCorrectionService(JsonHeatReleaseResponseCalibrationStore calibrationStore)
|
||||||
|
{
|
||||||
|
_calibrationStore = calibrationStore;
|
||||||
|
}
|
||||||
|
|
||||||
|
public HeatReleaseCorrectionResult Correct(IReadOnlyList<RealtimeDataRecord> rawRecords)
|
||||||
|
{
|
||||||
|
var orderedRecords = rawRecords
|
||||||
|
.Where(record => record.TestSeconds >= 0)
|
||||||
|
.OrderBy(record => record.TestSeconds)
|
||||||
|
.ThenBy(record => record.Timestamp)
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
if (orderedRecords.Count < 3)
|
||||||
|
{
|
||||||
|
return NotApplied(orderedRecords, "有效 HRR 记录不足,无法生成正式校正结果。");
|
||||||
|
}
|
||||||
|
|
||||||
|
var calibration = _calibrationStore.Load();
|
||||||
|
if (!TryValidateCalibration(calibration, out var calibrationError))
|
||||||
|
{
|
||||||
|
return NotApplied(orderedRecords, calibrationError);
|
||||||
|
}
|
||||||
|
|
||||||
|
var rawHrr = orderedRecords
|
||||||
|
.Select(record => double.IsFinite(record.HeatReleaseRate) ? Math.Max(0, record.HeatReleaseRate) : 0)
|
||||||
|
.ToArray();
|
||||||
|
var weights = BuildIntegrationWeights(orderedRecords);
|
||||||
|
if (!TryValidateSampleInterval(weights, calibration!.SampleIntervalSeconds, out var intervalError))
|
||||||
|
{
|
||||||
|
return NotApplied(orderedRecords, intervalError);
|
||||||
|
}
|
||||||
|
|
||||||
|
var rawFinalThr = orderedRecords
|
||||||
|
.Where(record => double.IsFinite(record.TotalHeatRelease))
|
||||||
|
.Select(record => record.TotalHeatRelease)
|
||||||
|
.DefaultIfEmpty(double.NaN)
|
||||||
|
.Last();
|
||||||
|
var targetArea = double.IsFinite(rawFinalThr)
|
||||||
|
? rawFinalThr * TotalHeatReleaseDivisor
|
||||||
|
: Dot(weights, rawHrr);
|
||||||
|
if (targetArea <= NumericalTolerance)
|
||||||
|
{
|
||||||
|
return NotApplied(orderedRecords, "原始 HRR 积分为零,无法生成正式校正结果。");
|
||||||
|
}
|
||||||
|
|
||||||
|
var kernel = NormalizeKernel(calibration.ResponseKernel);
|
||||||
|
double[] correctedHrr;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
correctedHrr = SolveConstrainedDeconvolution(rawHrr, weights, targetArea, kernel, calibration);
|
||||||
|
}
|
||||||
|
catch (InvalidOperationException ex)
|
||||||
|
{
|
||||||
|
return NotApplied(orderedRecords, ex.Message);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (correctedHrr.Any(value => !double.IsFinite(value) || value < 0 || value > MaximumCorrectedHeatReleaseRate))
|
||||||
|
{
|
||||||
|
return NotApplied(
|
||||||
|
orderedRecords,
|
||||||
|
$"HRR 校正结果超出允许范围 0-{MaximumCorrectedHeatReleaseRate:F0} kW/m²。");
|
||||||
|
}
|
||||||
|
|
||||||
|
var correctedRecords = BuildCorrectedRecords(orderedRecords, correctedHrr, weights, calibration.Version);
|
||||||
|
var correctedFinalThr = correctedRecords[^1].TotalHeatRelease;
|
||||||
|
var areaErrorPercent = Math.Abs(correctedFinalThr - rawFinalThr) / rawFinalThr * 100;
|
||||||
|
|
||||||
|
if (areaErrorPercent > calibration.MaximumAreaErrorPercent)
|
||||||
|
{
|
||||||
|
return NotApplied(
|
||||||
|
orderedRecords,
|
||||||
|
$"HRR 校正面积守恒误差 {areaErrorPercent:F6}% 超过允许值 {calibration.MaximumAreaErrorPercent:F6}%。");
|
||||||
|
}
|
||||||
|
|
||||||
|
var rawPeak = rawHrr.Max();
|
||||||
|
var correctedPeak = correctedHrr.Max();
|
||||||
|
Log.Information(
|
||||||
|
"Heat release correction applied. CalibrationVersion={CalibrationVersion}, Records={RecordCount}, RawPeak={RawPeak}, CorrectedPeak={CorrectedPeak}, RawFinalTHR={RawFinalTHR}, CorrectedFinalTHR={CorrectedFinalTHR}, AreaErrorPercent={AreaErrorPercent}.",
|
||||||
|
calibration.Version,
|
||||||
|
correctedRecords.Count,
|
||||||
|
rawPeak,
|
||||||
|
correctedPeak,
|
||||||
|
rawFinalThr,
|
||||||
|
correctedFinalThr,
|
||||||
|
areaErrorPercent);
|
||||||
|
|
||||||
|
return new HeatReleaseCorrectionResult(
|
||||||
|
true,
|
||||||
|
$"正式 HRR 校正已完成,标定版本:{calibration.Version}",
|
||||||
|
calibration.Version,
|
||||||
|
correctedRecords,
|
||||||
|
rawPeak,
|
||||||
|
correctedPeak,
|
||||||
|
rawFinalThr,
|
||||||
|
correctedFinalThr,
|
||||||
|
areaErrorPercent);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool TryValidateCalibration(
|
||||||
|
HeatReleaseResponseCalibration? calibration,
|
||||||
|
out string error)
|
||||||
|
{
|
||||||
|
if (calibration is null)
|
||||||
|
{
|
||||||
|
error = "缺少热释放响应标定文件,无法生成正式校正结果。";
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (string.IsNullOrWhiteSpace(calibration.Version)
|
||||||
|
|| string.IsNullOrWhiteSpace(calibration.Method)
|
||||||
|
|| calibration.CalibratedAtUtc == default)
|
||||||
|
{
|
||||||
|
error = "热释放响应标定缺少版本、方法或标定日期。";
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (calibration.ResponseKernel.Count < 2
|
||||||
|
|| calibration.ResponseKernel.Any(value => !double.IsFinite(value) || value < 0)
|
||||||
|
|| calibration.ResponseKernel.Sum() <= NumericalTolerance)
|
||||||
|
{
|
||||||
|
error = "热释放响应标定核无效。";
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!double.IsFinite(calibration.SampleIntervalSeconds) || calibration.SampleIntervalSeconds <= 0
|
||||||
|
|| !double.IsFinite(calibration.RegularizationLambda) || calibration.RegularizationLambda < 0
|
||||||
|
|| !double.IsFinite(calibration.GradientStep) || calibration.GradientStep <= 0 || calibration.GradientStep > 1
|
||||||
|
|| calibration.Iterations < 1 || calibration.Iterations > 100000
|
||||||
|
|| calibration.ResponseKernel.Count > 600
|
||||||
|
|| !double.IsFinite(calibration.ConvergenceTolerance) || calibration.ConvergenceTolerance <= 0
|
||||||
|
|| !double.IsFinite(calibration.MaximumAreaErrorPercent) || calibration.MaximumAreaErrorPercent < 0
|
||||||
|
|| !calibration.ValidationErrorPercent.HasValue
|
||||||
|
|| !double.IsFinite(calibration.ValidationErrorPercent.Value)
|
||||||
|
|| calibration.ValidationErrorPercent.Value < 0)
|
||||||
|
{
|
||||||
|
error = "热释放响应标定参数无效。";
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
error = string.Empty;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static double[] BuildIntegrationWeights(IReadOnlyList<RealtimeDataRecord> records)
|
||||||
|
{
|
||||||
|
var weights = new double[records.Count];
|
||||||
|
for (var i = 1; i < records.Count; i++)
|
||||||
|
{
|
||||||
|
weights[i] = records[i].TestSeconds - records[i - 1].TestSeconds;
|
||||||
|
}
|
||||||
|
|
||||||
|
return weights;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool TryValidateSampleInterval(
|
||||||
|
IReadOnlyList<double> weights,
|
||||||
|
double expectedInterval,
|
||||||
|
out string error)
|
||||||
|
{
|
||||||
|
var validIntervals = weights.Skip(1).Where(value => value > 0).ToArray();
|
||||||
|
if (validIntervals.Length != weights.Count - 1)
|
||||||
|
{
|
||||||
|
error = "HRR 记录时间不连续或存在重复,无法生成正式校正结果。";
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
var tolerance = Math.Max(0.05, expectedInterval * 0.1);
|
||||||
|
if (validIntervals.Any(value => Math.Abs(value - expectedInterval) > tolerance))
|
||||||
|
{
|
||||||
|
error = $"HRR 采样周期与标定周期 {expectedInterval:F3}s 不一致。";
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
error = string.Empty;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static double[] NormalizeKernel(IReadOnlyList<double> kernel)
|
||||||
|
{
|
||||||
|
var sum = kernel.Sum();
|
||||||
|
return kernel.Select(value => value / sum).ToArray();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static double[] SolveConstrainedDeconvolution(
|
||||||
|
IReadOnlyList<double> raw,
|
||||||
|
IReadOnlyList<double> weights,
|
||||||
|
double targetArea,
|
||||||
|
IReadOnlyList<double> kernel,
|
||||||
|
HeatReleaseResponseCalibration calibration)
|
||||||
|
{
|
||||||
|
var current = raw.ToArray();
|
||||||
|
for (var iteration = 0; iteration < calibration.Iterations; iteration++)
|
||||||
|
{
|
||||||
|
var predicted = Convolve(current, kernel);
|
||||||
|
var residual = predicted.Zip(raw, (left, right) => left - right).ToArray();
|
||||||
|
var gradient = TransposedConvolve(residual, kernel);
|
||||||
|
AddSmoothnessGradient(current, gradient, calibration.RegularizationLambda);
|
||||||
|
|
||||||
|
var candidate = current
|
||||||
|
.Zip(gradient, (value, derivative) => value - calibration.GradientStep * derivative)
|
||||||
|
.ToArray();
|
||||||
|
if (candidate.Any(value => !double.IsFinite(value)))
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException("HRR 校正数值不稳定,请检查响应核、步长和正则参数。");
|
||||||
|
}
|
||||||
|
|
||||||
|
var projected = ProjectNonNegativeWeightedArea(candidate, weights, targetArea);
|
||||||
|
var maximumChange = projected
|
||||||
|
.Zip(current, (left, right) => Math.Abs(left - right))
|
||||||
|
.Max();
|
||||||
|
var scale = Math.Max(1, projected.Max());
|
||||||
|
current = projected;
|
||||||
|
if (maximumChange / scale <= calibration.ConvergenceTolerance)
|
||||||
|
{
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return current;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static double[] Convolve(IReadOnlyList<double> values, IReadOnlyList<double> kernel)
|
||||||
|
{
|
||||||
|
var result = new double[values.Count];
|
||||||
|
for (var i = 0; i < result.Length; i++)
|
||||||
|
{
|
||||||
|
for (var k = 0; k < kernel.Count && k <= i; k++)
|
||||||
|
{
|
||||||
|
result[i] += kernel[k] * values[i - k];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static double[] TransposedConvolve(IReadOnlyList<double> values, IReadOnlyList<double> kernel)
|
||||||
|
{
|
||||||
|
var result = new double[values.Count];
|
||||||
|
for (var i = 0; i < result.Length; i++)
|
||||||
|
{
|
||||||
|
for (var k = 0; k < kernel.Count && i + k < values.Count; k++)
|
||||||
|
{
|
||||||
|
result[i] += kernel[k] * values[i + k];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void AddSmoothnessGradient(
|
||||||
|
IReadOnlyList<double> values,
|
||||||
|
double[] gradient,
|
||||||
|
double lambda)
|
||||||
|
{
|
||||||
|
if (lambda <= 0)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (var i = 1; i < values.Count - 1; i++)
|
||||||
|
{
|
||||||
|
var secondDifference = values[i - 1] - (2 * values[i]) + values[i + 1];
|
||||||
|
gradient[i - 1] += lambda * secondDifference;
|
||||||
|
gradient[i] -= 2 * lambda * secondDifference;
|
||||||
|
gradient[i + 1] += lambda * secondDifference;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static double[] ProjectNonNegativeWeightedArea(
|
||||||
|
IReadOnlyList<double> values,
|
||||||
|
IReadOnlyList<double> weights,
|
||||||
|
double targetArea)
|
||||||
|
{
|
||||||
|
var lower = -1d;
|
||||||
|
var upper = 1d;
|
||||||
|
while (WeightedProjectedArea(values, weights, lower) < targetArea)
|
||||||
|
{
|
||||||
|
lower *= 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
while (WeightedProjectedArea(values, weights, upper) > targetArea)
|
||||||
|
{
|
||||||
|
upper *= 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (var iteration = 0; iteration < 100; iteration++)
|
||||||
|
{
|
||||||
|
var midpoint = (lower + upper) / 2;
|
||||||
|
if (WeightedProjectedArea(values, weights, midpoint) > targetArea)
|
||||||
|
{
|
||||||
|
lower = midpoint;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
upper = midpoint;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var threshold = (lower + upper) / 2;
|
||||||
|
return values
|
||||||
|
.Select((value, index) => Math.Max(0, value - threshold * weights[index]))
|
||||||
|
.ToArray();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static double WeightedProjectedArea(
|
||||||
|
IReadOnlyList<double> values,
|
||||||
|
IReadOnlyList<double> weights,
|
||||||
|
double threshold)
|
||||||
|
{
|
||||||
|
var area = 0d;
|
||||||
|
for (var i = 0; i < values.Count; i++)
|
||||||
|
{
|
||||||
|
area += weights[i] * Math.Max(0, values[i] - threshold * weights[i]);
|
||||||
|
}
|
||||||
|
|
||||||
|
return area;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static List<RealtimeDataRecord> BuildCorrectedRecords(
|
||||||
|
IReadOnlyList<RealtimeDataRecord> rawRecords,
|
||||||
|
IReadOnlyList<double> correctedHrr,
|
||||||
|
IReadOnlyList<double> weights,
|
||||||
|
string calibrationVersion)
|
||||||
|
{
|
||||||
|
var result = new List<RealtimeDataRecord>(rawRecords.Count);
|
||||||
|
var accumulatedThr = 0d;
|
||||||
|
var correctedPeak = correctedHrr.Max();
|
||||||
|
for (var i = 0; i < rawRecords.Count; i++)
|
||||||
|
{
|
||||||
|
accumulatedThr += correctedHrr[i] * weights[i] / TotalHeatReleaseDivisor;
|
||||||
|
var raw = rawRecords[i];
|
||||||
|
result.Add(raw with
|
||||||
|
{
|
||||||
|
HeatReleaseRate = correctedHrr[i],
|
||||||
|
PeakHeatReleaseRate = correctedPeak,
|
||||||
|
TotalHeatRelease = accumulatedThr,
|
||||||
|
RawHeatReleaseRate = double.IsFinite(raw.RawHeatReleaseRate)
|
||||||
|
? raw.RawHeatReleaseRate
|
||||||
|
: raw.HeatReleaseRate,
|
||||||
|
RawTotalHeatRelease = double.IsFinite(raw.RawTotalHeatRelease)
|
||||||
|
? raw.RawTotalHeatRelease
|
||||||
|
: raw.TotalHeatRelease,
|
||||||
|
IsHeatReleaseCorrected = true,
|
||||||
|
HeatReleaseCorrectionVersion = calibrationVersion
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static HeatReleaseCorrectionResult NotApplied(
|
||||||
|
IReadOnlyList<RealtimeDataRecord> rawRecords,
|
||||||
|
string status)
|
||||||
|
{
|
||||||
|
var rawPeak = rawRecords
|
||||||
|
.Where(record => double.IsFinite(record.HeatReleaseRate))
|
||||||
|
.Select(record => record.HeatReleaseRate)
|
||||||
|
.DefaultIfEmpty(double.NaN)
|
||||||
|
.Max();
|
||||||
|
var rawFinalThr = rawRecords
|
||||||
|
.Where(record => double.IsFinite(record.TotalHeatRelease))
|
||||||
|
.Select(record => record.TotalHeatRelease)
|
||||||
|
.DefaultIfEmpty(double.NaN)
|
||||||
|
.Last();
|
||||||
|
|
||||||
|
Log.Warning("Heat release correction not applied. Reason={Reason}", status);
|
||||||
|
return new HeatReleaseCorrectionResult(
|
||||||
|
false,
|
||||||
|
status,
|
||||||
|
string.Empty,
|
||||||
|
rawRecords.ToList(),
|
||||||
|
rawPeak,
|
||||||
|
double.NaN,
|
||||||
|
rawFinalThr,
|
||||||
|
double.NaN,
|
||||||
|
double.NaN);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static double Dot(IReadOnlyList<double> left, IReadOnlyList<double> right)
|
||||||
|
{
|
||||||
|
var result = 0d;
|
||||||
|
for (var i = 0; i < left.Count; i++)
|
||||||
|
{
|
||||||
|
result += left[i] * right[i];
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -9,8 +9,14 @@ public interface IExperimentDataService
|
|||||||
|
|
||||||
RealtimeSnapshot CurrentSnapshot { get; }
|
RealtimeSnapshot CurrentSnapshot { get; }
|
||||||
|
|
||||||
|
bool IsHeatReleaseCorrectionApplied { get; }
|
||||||
|
|
||||||
|
string HeatReleaseCorrectionStatus { get; }
|
||||||
|
|
||||||
event EventHandler<RealtimeSnapshot>? SnapshotUpdated;
|
event EventHandler<RealtimeSnapshot>? SnapshotUpdated;
|
||||||
|
|
||||||
|
event EventHandler? HeatReleaseCorrectionStatusChanged;
|
||||||
|
|
||||||
void StartTest();
|
void StartTest();
|
||||||
|
|
||||||
void StopTest();
|
void StopTest();
|
||||||
|
|||||||
@@ -0,0 +1,8 @@
|
|||||||
|
using ConeCalorimeter.Models;
|
||||||
|
|
||||||
|
namespace ConeCalorimeter.Services;
|
||||||
|
|
||||||
|
public interface IHeatReleaseCorrectionService
|
||||||
|
{
|
||||||
|
HeatReleaseCorrectionResult Correct(IReadOnlyList<RealtimeDataRecord> rawRecords);
|
||||||
|
}
|
||||||
@@ -24,6 +24,8 @@ public interface ITcpDeviceConnectionService : IAsyncDisposable
|
|||||||
|
|
||||||
bool TryWriteFloat(ushort registerAddress, float value);
|
bool TryWriteFloat(ushort registerAddress, float value);
|
||||||
|
|
||||||
|
bool TryWriteFloat(ushort registerAddress, float value, ModbusFloatByteOrder byteOrder);
|
||||||
|
|
||||||
bool TryReadCoil(ushort coilAddress, out bool value);
|
bool TryReadCoil(ushort coilAddress, out bool value);
|
||||||
|
|
||||||
bool TryWriteCoil(ushort coilAddress, bool value);
|
bool TryWriteCoil(ushort coilAddress, bool value);
|
||||||
|
|||||||
@@ -0,0 +1,66 @@
|
|||||||
|
using System.IO;
|
||||||
|
using System.Text.Json;
|
||||||
|
using ConeCalorimeter.Models;
|
||||||
|
using Serilog;
|
||||||
|
|
||||||
|
namespace ConeCalorimeter.Services;
|
||||||
|
|
||||||
|
public sealed class JsonHeatReleaseResponseCalibrationStore
|
||||||
|
{
|
||||||
|
private static readonly JsonSerializerOptions JsonOptions = new()
|
||||||
|
{
|
||||||
|
PropertyNameCaseInsensitive = true,
|
||||||
|
WriteIndented = true
|
||||||
|
};
|
||||||
|
|
||||||
|
public JsonHeatReleaseResponseCalibrationStore()
|
||||||
|
: this(Path.Combine(
|
||||||
|
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
|
||||||
|
"ConeCalorimeter",
|
||||||
|
"heat-release-response-calibration.json"))
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
public JsonHeatReleaseResponseCalibrationStore(string storagePath)
|
||||||
|
{
|
||||||
|
StoragePath = storagePath;
|
||||||
|
}
|
||||||
|
|
||||||
|
public string StoragePath { get; }
|
||||||
|
|
||||||
|
public HeatReleaseResponseCalibration? Load()
|
||||||
|
{
|
||||||
|
if (!File.Exists(StoragePath))
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
using var stream = File.OpenRead(StoragePath);
|
||||||
|
return JsonSerializer.Deserialize<HeatReleaseResponseCalibration>(stream, JsonOptions);
|
||||||
|
}
|
||||||
|
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or JsonException)
|
||||||
|
{
|
||||||
|
Log.Warning(ex, "Heat release response calibration load failed. Path={Path}.", StoragePath);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Save(HeatReleaseResponseCalibration calibration)
|
||||||
|
{
|
||||||
|
var directory = Path.GetDirectoryName(StoragePath);
|
||||||
|
if (!string.IsNullOrWhiteSpace(directory))
|
||||||
|
{
|
||||||
|
Directory.CreateDirectory(directory);
|
||||||
|
}
|
||||||
|
|
||||||
|
var tempPath = $"{StoragePath}.tmp";
|
||||||
|
using (var stream = File.Create(tempPath))
|
||||||
|
{
|
||||||
|
JsonSerializer.Serialize(stream, calibration, JsonOptions);
|
||||||
|
}
|
||||||
|
|
||||||
|
File.Move(tempPath, StoragePath, true);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -48,6 +48,12 @@ public sealed class NpoiReportExportService : IReportExportService
|
|||||||
throw new InvalidOperationException("请先点击“测试开始”,产生有效实验数据后再导出报表。");
|
throw new InvalidOperationException("请先点击“测试开始”,产生有效实验数据后再导出报表。");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (exportRecords.Any(record => !record.IsHeatReleaseCorrected)
|
||||||
|
|| exportRecords.Any(record => string.IsNullOrWhiteSpace(record.HeatReleaseCorrectionVersion)))
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException("正式报表要求使用已完成响应校正的 HRR/THR 数据。");
|
||||||
|
}
|
||||||
|
|
||||||
ValidateExportRecords(exportRecords);
|
ValidateExportRecords(exportRecords);
|
||||||
|
|
||||||
var templatePath = FindTemplatePath();
|
var templatePath = FindTemplatePath();
|
||||||
@@ -571,7 +577,7 @@ public sealed class NpoiReportExportService : IReportExportService
|
|||||||
|
|
||||||
var last = records[^1];
|
var last = records[^1];
|
||||||
return new ReportSummary(
|
return new ReportSummary(
|
||||||
PeakHeatReleaseRate: FormatWithUnit(LastFinite(records, record => record.PeakHeatReleaseRate), "kW/㎡"),
|
PeakHeatReleaseRate: FormatWithUnit(MaxFinite(records, record => record.HeatReleaseRate), "kW/㎡"),
|
||||||
PeakSmokeProduction: FormatWithUnit(MaxFinite(records, record => record.SmokeProduction), "m²/s"),
|
PeakSmokeProduction: FormatWithUnit(MaxFinite(records, record => record.SmokeProduction), "m²/s"),
|
||||||
TotalHeatRelease: FormatWithUnit(LastFinite(records, record => record.TotalHeatRelease), "MJ/㎡"),
|
TotalHeatRelease: FormatWithUnit(LastFinite(records, record => record.TotalHeatRelease), "MJ/㎡"),
|
||||||
TotalSmoke: FormatWithUnit(LastFinite(records, record => record.TotalSmoke), "m²"),
|
TotalSmoke: FormatWithUnit(LastFinite(records, record => record.TotalSmoke), "m²"),
|
||||||
|
|||||||
@@ -258,6 +258,11 @@ public sealed class TcpDeviceConnectionService : ITcpDeviceConnectionService
|
|||||||
}
|
}
|
||||||
|
|
||||||
public bool TryWriteFloat(ushort registerAddress, float value)
|
public bool TryWriteFloat(ushort registerAddress, float value)
|
||||||
|
{
|
||||||
|
return TryWriteFloat(registerAddress, value, ModbusFloatByteOrder.Abcd);
|
||||||
|
}
|
||||||
|
|
||||||
|
public bool TryWriteFloat(ushort registerAddress, float value, ModbusFloatByteOrder byteOrder)
|
||||||
{
|
{
|
||||||
lock (_syncRoot)
|
lock (_syncRoot)
|
||||||
{
|
{
|
||||||
@@ -269,12 +274,13 @@ public sealed class TcpDeviceConnectionService : ITcpDeviceConnectionService
|
|||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
WriteFloat(_client, registerAddress, value);
|
WriteFloat(_client, registerAddress, value, byteOrder);
|
||||||
Log.Information(
|
Log.Information(
|
||||||
"TCP float register write succeeded. Endpoint={Endpoint}, Register=D{RegisterAddress}, Value={Value}.",
|
"TCP float register write succeeded. Endpoint={Endpoint}, Register=D{RegisterAddress}, Value={Value}, ByteOrder={ByteOrder}.",
|
||||||
Endpoint,
|
Endpoint,
|
||||||
registerAddress,
|
registerAddress,
|
||||||
value);
|
value,
|
||||||
|
byteOrder);
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
catch (Exception ex) when (ex is IOException or SocketException or InvalidDataException or ObjectDisposedException)
|
catch (Exception ex) when (ex is IOException or SocketException or InvalidDataException or ObjectDisposedException)
|
||||||
@@ -282,10 +288,11 @@ public sealed class TcpDeviceConnectionService : ITcpDeviceConnectionService
|
|||||||
Debug.WriteLine($"TCP device register {registerAddress} float write failed: {ex.Message}");
|
Debug.WriteLine($"TCP device register {registerAddress} float write failed: {ex.Message}");
|
||||||
Log.Warning(
|
Log.Warning(
|
||||||
ex,
|
ex,
|
||||||
"TCP float register write failed. Endpoint={Endpoint}, Register=D{RegisterAddress}, Value={Value}.",
|
"TCP float register write failed. Endpoint={Endpoint}, Register=D{RegisterAddress}, Value={Value}, ByteOrder={ByteOrder}.",
|
||||||
Endpoint,
|
Endpoint,
|
||||||
registerAddress,
|
registerAddress,
|
||||||
value);
|
value,
|
||||||
|
byteOrder);
|
||||||
SetConnectionState(false, $"写入寄存器 {registerAddress} 失败:{ex.Message}");
|
SetConnectionState(false, $"写入寄存器 {registerAddress} 失败:{ex.Message}");
|
||||||
CloseCurrentClientCore();
|
CloseCurrentClientCore();
|
||||||
return false;
|
return false;
|
||||||
@@ -527,13 +534,13 @@ public sealed class TcpDeviceConnectionService : ITcpDeviceConnectionService
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private void WriteFloat(TcpClient client, ushort registerAddress, float value)
|
private void WriteFloat(TcpClient client, ushort registerAddress, float value, ModbusFloatByteOrder byteOrder)
|
||||||
{
|
{
|
||||||
Span<byte> payload = stackalloc byte[9];
|
Span<byte> payload = stackalloc byte[9];
|
||||||
BinaryPrimitives.WriteUInt16BigEndian(payload[0..2], registerAddress);
|
BinaryPrimitives.WriteUInt16BigEndian(payload[0..2], registerAddress);
|
||||||
BinaryPrimitives.WriteUInt16BigEndian(payload[2..4], 2);
|
BinaryPrimitives.WriteUInt16BigEndian(payload[2..4], 2);
|
||||||
payload[4] = 4;
|
payload[4] = 4;
|
||||||
BinaryPrimitives.WriteInt32BigEndian(payload[5..9], BitConverter.SingleToInt32Bits(value));
|
WriteFloatPayloadBytes(payload[5..9], value, byteOrder);
|
||||||
|
|
||||||
var pdu = SendModbusRequest(client, WriteMultipleRegistersFunction, payload);
|
var pdu = SendModbusRequest(client, WriteMultipleRegistersFunction, payload);
|
||||||
|
|
||||||
@@ -545,6 +552,39 @@ public sealed class TcpDeviceConnectionService : ITcpDeviceConnectionService
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static void WriteFloatPayloadBytes(Span<byte> destination, float value, ModbusFloatByteOrder byteOrder)
|
||||||
|
{
|
||||||
|
Span<byte> raw = stackalloc byte[4];
|
||||||
|
BinaryPrimitives.WriteInt32BigEndian(raw, BitConverter.SingleToInt32Bits(value));
|
||||||
|
|
||||||
|
switch (byteOrder)
|
||||||
|
{
|
||||||
|
case ModbusFloatByteOrder.Abcd:
|
||||||
|
raw.CopyTo(destination);
|
||||||
|
break;
|
||||||
|
case ModbusFloatByteOrder.Cdab:
|
||||||
|
destination[0] = raw[2];
|
||||||
|
destination[1] = raw[3];
|
||||||
|
destination[2] = raw[0];
|
||||||
|
destination[3] = raw[1];
|
||||||
|
break;
|
||||||
|
case ModbusFloatByteOrder.Badc:
|
||||||
|
destination[0] = raw[1];
|
||||||
|
destination[1] = raw[0];
|
||||||
|
destination[2] = raw[3];
|
||||||
|
destination[3] = raw[2];
|
||||||
|
break;
|
||||||
|
case ModbusFloatByteOrder.Dcba:
|
||||||
|
destination[0] = raw[3];
|
||||||
|
destination[1] = raw[2];
|
||||||
|
destination[2] = raw[1];
|
||||||
|
destination[3] = raw[0];
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
throw new InvalidDataException($"Unsupported Modbus float byte order: {byteOrder}.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private bool ReadCoil(TcpClient client, ushort coilAddress)
|
private bool ReadCoil(TcpClient client, ushort coilAddress)
|
||||||
{
|
{
|
||||||
Span<byte> payload = stackalloc byte[4];
|
Span<byte> payload = stackalloc byte[4];
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ public sealed class RealtimeDataViewModel : PageViewModel
|
|||||||
|
|
||||||
RebuildRows();
|
RebuildRows();
|
||||||
_experimentDataService.Records.CollectionChanged += RecordsChanged;
|
_experimentDataService.Records.CollectionChanged += RecordsChanged;
|
||||||
|
_experimentDataService.HeatReleaseCorrectionStatusChanged += (_, _) => RebuildRows();
|
||||||
}
|
}
|
||||||
|
|
||||||
public ObservableCollection<RealtimeDataRowViewModel> Rows { get; }
|
public ObservableCollection<RealtimeDataRowViewModel> Rows { get; }
|
||||||
|
|||||||
@@ -55,6 +55,7 @@ public sealed class ReportPageViewModel : PageViewModel
|
|||||||
|
|
||||||
ExportCommand = new RelayCommand(ExportReport);
|
ExportCommand = new RelayCommand(ExportReport);
|
||||||
_experimentDataService.Records.CollectionChanged += RecordsChanged;
|
_experimentDataService.Records.CollectionChanged += RecordsChanged;
|
||||||
|
_experimentDataService.HeatReleaseCorrectionStatusChanged += (_, _) => HeatReleaseCorrectionStatusChanged();
|
||||||
UpdateSummary();
|
UpdateSummary();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -159,13 +160,24 @@ public sealed class ReportPageViewModel : PageViewModel
|
|||||||
SummaryItems[1].Value = records.First().Timestamp.ToString("yyyy-MM-dd HH:mm:ss", CultureInfo.CurrentCulture);
|
SummaryItems[1].Value = records.First().Timestamp.ToString("yyyy-MM-dd HH:mm:ss", CultureInfo.CurrentCulture);
|
||||||
SummaryItems[2].Value = records.Last().Timestamp.ToString("yyyy-MM-dd HH:mm:ss", CultureInfo.CurrentCulture);
|
SummaryItems[2].Value = records.Last().Timestamp.ToString("yyyy-MM-dd HH:mm:ss", CultureInfo.CurrentCulture);
|
||||||
var last = records.Last();
|
var last = records.Last();
|
||||||
SummaryItems[3].Value = FormatWithUnit(last.PeakHeatReleaseRate, "kW/㎡");
|
SummaryItems[3].Value = FormatWithUnit(
|
||||||
|
records.Where(record => double.IsFinite(record.HeatReleaseRate))
|
||||||
|
.Select(record => record.HeatReleaseRate)
|
||||||
|
.DefaultIfEmpty(double.NaN)
|
||||||
|
.Max(),
|
||||||
|
"kW/㎡");
|
||||||
SummaryItems[4].Value = FormatWithUnit(LastFinite(records, record => record.TotalHeatRelease), "MJ/㎡");
|
SummaryItems[4].Value = FormatWithUnit(LastFinite(records, record => record.TotalHeatRelease), "MJ/㎡");
|
||||||
SummaryItems[5].Value = FormatWithUnit(LastFinite(records, record => record.TotalSmoke), "m²");
|
SummaryItems[5].Value = FormatWithUnit(LastFinite(records, record => record.TotalSmoke), "m²");
|
||||||
SummaryItems[6].Value = FormatWithUnit(LastFinite(records, record => record.MassLoss), "g");
|
SummaryItems[6].Value = FormatWithUnit(LastFinite(records, record => record.MassLoss), "g");
|
||||||
FillCollectedReportFields(records);
|
FillCollectedReportFields(records);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private void HeatReleaseCorrectionStatusChanged()
|
||||||
|
{
|
||||||
|
UpdateSummary();
|
||||||
|
StatusText = _experimentDataService.HeatReleaseCorrectionStatus;
|
||||||
|
}
|
||||||
|
|
||||||
private void ExportReport()
|
private void ExportReport()
|
||||||
{
|
{
|
||||||
var records = _experimentDataService.Records.ToList();
|
var records = _experimentDataService.Records.ToList();
|
||||||
@@ -176,6 +188,16 @@ public sealed class ReportPageViewModel : PageViewModel
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!_experimentDataService.IsHeatReleaseCorrectionApplied)
|
||||||
|
{
|
||||||
|
StatusText = $"无法导出正式报表:{_experimentDataService.HeatReleaseCorrectionStatus}";
|
||||||
|
Log.Warning(
|
||||||
|
"Formal report export blocked because heat release correction was not applied. Status={Status}.",
|
||||||
|
_experimentDataService.HeatReleaseCorrectionStatus);
|
||||||
|
MessageBox.Show(StatusText, "导出报表", MessageBoxButton.OK, MessageBoxImage.Warning);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
RefreshCurrentCValueReportField(overwriteManual: false);
|
RefreshCurrentCValueReportField(overwriteManual: false);
|
||||||
var input = BuildInput();
|
var input = BuildInput();
|
||||||
var defaultName = BuildDefaultFileName(input);
|
var defaultName = BuildDefaultFileName(input);
|
||||||
|
|||||||
@@ -110,6 +110,7 @@ public sealed class TestPageViewModel : PageViewModel
|
|||||||
HeatReleasePlot = CreatePlotModel(out _heatReleaseSeries, out _totalHeatSeries, out _totalSmokeSeries);
|
HeatReleasePlot = CreatePlotModel(out _heatReleaseSeries, out _totalHeatSeries, out _totalSmokeSeries);
|
||||||
UpdateSnapshot(_experimentDataService.CurrentSnapshot);
|
UpdateSnapshot(_experimentDataService.CurrentSnapshot);
|
||||||
_experimentDataService.SnapshotUpdated += (_, snapshot) => UpdateSnapshot(snapshot);
|
_experimentDataService.SnapshotUpdated += (_, snapshot) => UpdateSnapshot(snapshot);
|
||||||
|
_experimentDataService.HeatReleaseCorrectionStatusChanged += (_, _) => RebuildFinalHeatReleasePlot();
|
||||||
}
|
}
|
||||||
|
|
||||||
public ObservableCollection<MetricDisplayViewModel> TopMetrics { get; }
|
public ObservableCollection<MetricDisplayViewModel> TopMetrics { get; }
|
||||||
@@ -408,6 +409,33 @@ public sealed class TestPageViewModel : PageViewModel
|
|||||||
HeatReleasePlot.InvalidatePlot(true);
|
HeatReleasePlot.InvalidatePlot(true);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private void RebuildFinalHeatReleasePlot()
|
||||||
|
{
|
||||||
|
if (!_experimentDataService.IsHeatReleaseCorrectionApplied)
|
||||||
|
{
|
||||||
|
LastAction = _experimentDataService.HeatReleaseCorrectionStatus;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
ClearPlotSeries();
|
||||||
|
foreach (var record in _experimentDataService.Records)
|
||||||
|
{
|
||||||
|
AppendPoint(_heatReleaseSeries, record.TestSeconds, record.HeatReleaseRate);
|
||||||
|
AppendPoint(_totalHeatSeries, record.TestSeconds, record.TotalHeatRelease);
|
||||||
|
AppendPoint(_totalSmokeSeries, record.TestSeconds, record.TotalSmoke);
|
||||||
|
_lastPlottedSeconds = record.TestSeconds;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (_lastPlottedSeconds.HasValue)
|
||||||
|
{
|
||||||
|
UpdateTimeAxis(_lastPlottedSeconds.Value);
|
||||||
|
}
|
||||||
|
|
||||||
|
UpdateValueAxes();
|
||||||
|
HeatReleasePlot.InvalidatePlot(true);
|
||||||
|
LastAction = _experimentDataService.HeatReleaseCorrectionStatus;
|
||||||
|
}
|
||||||
|
|
||||||
private async void ExecuteTestControlAction()
|
private async void ExecuteTestControlAction()
|
||||||
{
|
{
|
||||||
if (!TryBeginPulseAction(TestAction))
|
if (!TryBeginPulseAction(TestAction))
|
||||||
|
|||||||
72
ConeCalorimeter/Views/HeatFluxCoefficientWindow.xaml
Normal file
72
ConeCalorimeter/Views/HeatFluxCoefficientWindow.xaml
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
<Window x:Class="ConeCalorimeter.Views.HeatFluxCoefficientWindow"
|
||||||
|
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
|
||||||
|
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||||
|
Title="热流计系数"
|
||||||
|
Width="420"
|
||||||
|
Height="250"
|
||||||
|
ResizeMode="NoResize"
|
||||||
|
WindowStartupLocation="CenterOwner"
|
||||||
|
FontFamily="Microsoft YaHei UI">
|
||||||
|
<Border Background="#F4F6F5"
|
||||||
|
Padding="22">
|
||||||
|
<Grid>
|
||||||
|
<Grid.RowDefinitions>
|
||||||
|
<RowDefinition Height="Auto" />
|
||||||
|
<RowDefinition Height="Auto" />
|
||||||
|
<RowDefinition Height="*" />
|
||||||
|
<RowDefinition Height="Auto" />
|
||||||
|
</Grid.RowDefinitions>
|
||||||
|
|
||||||
|
<TextBlock Text="热流计系数"
|
||||||
|
FontSize="24"
|
||||||
|
FontWeight="SemiBold"
|
||||||
|
Foreground="#172320" />
|
||||||
|
|
||||||
|
<Grid Grid.Row="1"
|
||||||
|
Margin="0,22,0,0">
|
||||||
|
<Grid.ColumnDefinitions>
|
||||||
|
<ColumnDefinition Width="112" />
|
||||||
|
<ColumnDefinition Width="*" />
|
||||||
|
</Grid.ColumnDefinitions>
|
||||||
|
|
||||||
|
<TextBlock Text="系数值:"
|
||||||
|
VerticalAlignment="Center"
|
||||||
|
FontSize="18"
|
||||||
|
Foreground="#172320" />
|
||||||
|
<TextBox x:Name="CoefficientTextBox"
|
||||||
|
Grid.Column="1"
|
||||||
|
Height="42"
|
||||||
|
Padding="10,4"
|
||||||
|
VerticalContentAlignment="Center"
|
||||||
|
FontSize="20"
|
||||||
|
HorizontalContentAlignment="Right" />
|
||||||
|
</Grid>
|
||||||
|
|
||||||
|
<TextBlock x:Name="StatusTextBlock"
|
||||||
|
Grid.Row="2"
|
||||||
|
Margin="0,14,0,0"
|
||||||
|
FontSize="14"
|
||||||
|
Foreground="#4F5B56"
|
||||||
|
TextWrapping="Wrap" />
|
||||||
|
|
||||||
|
<StackPanel Grid.Row="3"
|
||||||
|
Orientation="Horizontal"
|
||||||
|
HorizontalAlignment="Right">
|
||||||
|
<Button Content="读取"
|
||||||
|
Width="88"
|
||||||
|
Margin="0,0,10,0"
|
||||||
|
Style="{StaticResource InstrumentButtonStyle}"
|
||||||
|
Click="ReadButton_Click" />
|
||||||
|
<Button Content="写入"
|
||||||
|
Width="88"
|
||||||
|
Margin="0,0,10,0"
|
||||||
|
Style="{StaticResource InstrumentPrimaryButtonStyle}"
|
||||||
|
Click="WriteButton_Click" />
|
||||||
|
<Button Content="关闭"
|
||||||
|
Width="88"
|
||||||
|
Style="{StaticResource InstrumentButtonStyle}"
|
||||||
|
Click="CloseButton_Click" />
|
||||||
|
</StackPanel>
|
||||||
|
</Grid>
|
||||||
|
</Border>
|
||||||
|
</Window>
|
||||||
110
ConeCalorimeter/Views/HeatFluxCoefficientWindow.xaml.cs
Normal file
110
ConeCalorimeter/Views/HeatFluxCoefficientWindow.xaml.cs
Normal file
@@ -0,0 +1,110 @@
|
|||||||
|
using System.Globalization;
|
||||||
|
using System.Windows;
|
||||||
|
using ConeCalorimeter.Services;
|
||||||
|
|
||||||
|
namespace ConeCalorimeter.Views;
|
||||||
|
|
||||||
|
public partial class HeatFluxCoefficientWindow : Window
|
||||||
|
{
|
||||||
|
private const ushort HeatFluxCoefficientRegister = 474;
|
||||||
|
private const ModbusFloatByteOrder HeatFluxCoefficientByteOrder = ModbusFloatByteOrder.Dcba;
|
||||||
|
private const string CoefficientFormat = "0.0000";
|
||||||
|
|
||||||
|
private readonly ITcpDeviceConnectionService _tcpDeviceConnectionService;
|
||||||
|
|
||||||
|
public HeatFluxCoefficientWindow(ITcpDeviceConnectionService tcpDeviceConnectionService)
|
||||||
|
{
|
||||||
|
_tcpDeviceConnectionService = tcpDeviceConnectionService;
|
||||||
|
InitializeComponent();
|
||||||
|
|
||||||
|
Loaded += (_, _) => ReadCoefficient();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void ReadButton_Click(object sender, RoutedEventArgs e)
|
||||||
|
{
|
||||||
|
ReadCoefficient();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void WriteButton_Click(object sender, RoutedEventArgs e)
|
||||||
|
{
|
||||||
|
if (!TryParseCoefficient(out var value))
|
||||||
|
{
|
||||||
|
StatusTextBlock.Text = "请输入有效的热流计系数。";
|
||||||
|
CoefficientTextBox.Focus();
|
||||||
|
CoefficientTextBox.SelectAll();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!_tcpDeviceConnectionService.TryWriteFloat(
|
||||||
|
HeatFluxCoefficientRegister,
|
||||||
|
value,
|
||||||
|
HeatFluxCoefficientByteOrder))
|
||||||
|
{
|
||||||
|
StatusTextBlock.Text = "写入失败:PLC未连接或通讯异常。";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!TryReadCoefficient(out var readbackValue))
|
||||||
|
{
|
||||||
|
StatusTextBlock.Text = "写入已发送,但读回确认失败。";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
CoefficientTextBox.Text = FormatCoefficient(readbackValue);
|
||||||
|
StatusTextBlock.Text = BitConverter.SingleToInt32Bits(value) == BitConverter.SingleToInt32Bits(readbackValue)
|
||||||
|
? "写入成功,已读回确认。"
|
||||||
|
: $"写入完成,读回值为 {FormatCoefficient(readbackValue)}。";
|
||||||
|
}
|
||||||
|
|
||||||
|
private void CloseButton_Click(object sender, RoutedEventArgs e)
|
||||||
|
{
|
||||||
|
Close();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void ReadCoefficient()
|
||||||
|
{
|
||||||
|
if (TryReadCoefficient(out var value))
|
||||||
|
{
|
||||||
|
CoefficientTextBox.Text = FormatCoefficient(value);
|
||||||
|
StatusTextBlock.Text = "已读取当前热流计系数。";
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
StatusTextBlock.Text = "读取失败:PLC未连接、通讯异常或D474数据无效。";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private bool TryReadCoefficient(out float value)
|
||||||
|
{
|
||||||
|
value = 0;
|
||||||
|
|
||||||
|
if (!_tcpDeviceConnectionService.TryReadFloatValues(HeatFluxCoefficientRegister, out var result))
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
var decodedValue = result.GetValue(HeatFluxCoefficientByteOrder);
|
||||||
|
if (!double.IsFinite(decodedValue)
|
||||||
|
|| decodedValue < -float.MaxValue
|
||||||
|
|| decodedValue > float.MaxValue)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
value = (float)decodedValue;
|
||||||
|
return float.IsFinite(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
private bool TryParseCoefficient(out float value)
|
||||||
|
{
|
||||||
|
var text = CoefficientTextBox.Text.Trim();
|
||||||
|
return (float.TryParse(text, NumberStyles.Float, CultureInfo.CurrentCulture, out value)
|
||||||
|
|| float.TryParse(text, NumberStyles.Float, CultureInfo.InvariantCulture, out value))
|
||||||
|
&& float.IsFinite(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string FormatCoefficient(float value)
|
||||||
|
{
|
||||||
|
return value.ToString(CoefficientFormat, CultureInfo.InvariantCulture);
|
||||||
|
}
|
||||||
|
}
|
||||||
40
ConeCalorimeter/热释放响应校正说明.md
Normal file
40
ConeCalorimeter/热释放响应校正说明.md
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
# 热释放响应校正
|
||||||
|
|
||||||
|
正式 HRR 校正在测试结束后执行。测试期间记录的 `D354 × 1000` 为原始 HRR,原始数据不会被覆盖。
|
||||||
|
|
||||||
|
## 标定文件
|
||||||
|
|
||||||
|
标定文件路径:
|
||||||
|
|
||||||
|
```text
|
||||||
|
%LOCALAPPDATA%\ConeCalorimeter\heat-release-response-calibration.json
|
||||||
|
```
|
||||||
|
|
||||||
|
标定参数必须来自已知热释放输入的燃烧器阶跃或脉冲试验,不得根据某次试样的目标峰值手工调整。
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"Version": "标定版本",
|
||||||
|
"CalibratedAtUtc": "2026-06-12T00:00:00Z",
|
||||||
|
"Method": "燃烧器阶跃或脉冲标定方法说明",
|
||||||
|
"SampleIntervalSeconds": 1.0,
|
||||||
|
"RegularizationLambda": 0.02,
|
||||||
|
"GradientStep": 0.05,
|
||||||
|
"Iterations": 1500,
|
||||||
|
"ConvergenceTolerance": 0.000001,
|
||||||
|
"MaximumAreaErrorPercent": 0.01,
|
||||||
|
"ValidationErrorPercent": 0.0,
|
||||||
|
"ResponseKernel": [0.0]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`ResponseKernel` 和 `ValidationErrorPercent` 示例值仅表示字段结构,不能直接用于正式检测。必须替换为燃烧器标定及独立验证得到的值;程序会自动归一化响应核。
|
||||||
|
|
||||||
|
## 正式结果约束
|
||||||
|
|
||||||
|
- 校正 HRR 全程非负。
|
||||||
|
- 校正后的最终 THR 严格匹配设备原始最终 THR。
|
||||||
|
- 正式最大 HRR 从校正曲线计算。
|
||||||
|
- 实时数据页和 Excel 保持原有列结构,测试结束后原有 HRR、THR 和最大热释放字段直接使用校正结果。
|
||||||
|
- 原始 HRR、THR 与校正版本仅在程序内部保留,不新增操作员可见列。
|
||||||
|
- 缺少有效标定文件或校正失败时,可以导出原始实时数据,但禁止导出正式报表。
|
||||||
Reference in New Issue
Block a user