更新2027
This commit is contained in:
@@ -1,19 +1,42 @@
|
||||
using System.Windows;
|
||||
using System.Windows.Input;
|
||||
using System.Windows.Threading;
|
||||
using ConeCalorimeter.Services;
|
||||
using ConeCalorimeter.ViewModels;
|
||||
using ConeCalorimeter.Views;
|
||||
using Serilog;
|
||||
|
||||
namespace ConeCalorimeter
|
||||
{
|
||||
public partial class MainWindow : Window
|
||||
{
|
||||
private const double HiddenHeatFluxEntrySize = 80;
|
||||
private static readonly TimeSpan HiddenHeatFluxLongPressDuration = TimeSpan.FromSeconds(2);
|
||||
|
||||
private readonly ITcpDeviceConnectionService _tcpDeviceConnectionService;
|
||||
private readonly IScaleService _scaleService;
|
||||
private readonly DispatcherTimer _hiddenHeatFluxLongPressTimer;
|
||||
private bool _hiddenHeatFluxLongPressActive;
|
||||
private bool _hiddenHeatFluxDialogOpenedByLongPress;
|
||||
private bool _isHeatFluxCoefficientDialogOpen;
|
||||
private int? _hiddenHeatFluxTouchDeviceId;
|
||||
|
||||
public MainWindow()
|
||||
{
|
||||
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 scaleOptions = SerialScaleOptions.FromEnvironment();
|
||||
|
||||
@@ -26,7 +49,8 @@ namespace ConeCalorimeter
|
||||
_ = _tcpDeviceConnectionService.StartAsync();
|
||||
|
||||
var experimentDataService = new ExperimentDataService(
|
||||
new ModbusRealtimeDataService(_tcpDeviceConnectionService, _scaleService));
|
||||
new ModbusRealtimeDataService(_tcpDeviceConnectionService, _scaleService),
|
||||
new HeatReleaseCorrectionService(new JsonHeatReleaseResponseCalibrationStore()));
|
||||
DataContext = new MainViewModel(
|
||||
experimentDataService,
|
||||
_tcpDeviceConnectionService,
|
||||
@@ -43,5 +67,134 @@ namespace ConeCalorimeter
|
||||
await _tcpDeviceConnectionService.DisposeAsync();
|
||||
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,
|
||||
int IgnitionSeconds,
|
||||
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;
|
||||
|
||||
@@ -58,7 +62,9 @@ public sealed record RealtimeDataRecord(
|
||||
snapshot.MassLossRate,
|
||||
snapshot.IgnitionSeconds,
|
||||
snapshot.TestSeconds,
|
||||
snapshot.TotalSmoke);
|
||||
snapshot.TotalSmoke,
|
||||
snapshot.HeatReleaseRate,
|
||||
snapshot.TotalHeatRelease);
|
||||
}
|
||||
|
||||
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 readonly IRealtimeDataService _realtimeDataService;
|
||||
private readonly IHeatReleaseCorrectionService _heatReleaseCorrectionService;
|
||||
private readonly BulkObservableCollection<RealtimeDataRecord> _records;
|
||||
private readonly DispatcherTimer _timer;
|
||||
private readonly Stopwatch _testClock = new();
|
||||
private DateTime _lastDevicePollStartedAtUtc = DateTime.MinValue;
|
||||
@@ -30,11 +32,16 @@ public sealed class ExperimentDataService : IExperimentDataService
|
||||
private bool _isPollingSnapshot;
|
||||
private RealtimeSnapshot? _completedSnapshot;
|
||||
|
||||
public ExperimentDataService(IRealtimeDataService realtimeDataService)
|
||||
public ExperimentDataService(
|
||||
IRealtimeDataService realtimeDataService,
|
||||
IHeatReleaseCorrectionService heatReleaseCorrectionService)
|
||||
{
|
||||
_realtimeDataService = realtimeDataService;
|
||||
Records = [];
|
||||
_heatReleaseCorrectionService = heatReleaseCorrectionService;
|
||||
_records = [];
|
||||
Records = _records;
|
||||
CurrentSnapshot = BuildIdleSnapshot(_realtimeDataService.GetCurrentSnapshot(TimeSpan.Zero));
|
||||
HeatReleaseCorrectionStatus = "尚未完成测试,未生成正式 HRR 校正结果。";
|
||||
|
||||
_timer = new DispatcherTimer
|
||||
{
|
||||
@@ -48,13 +55,20 @@ public sealed class ExperimentDataService : IExperimentDataService
|
||||
|
||||
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? HeatReleaseCorrectionStatusChanged;
|
||||
|
||||
public void StartTest()
|
||||
{
|
||||
Records.Clear();
|
||||
ResetComputedState();
|
||||
_completedSnapshot = null;
|
||||
SetHeatReleaseCorrectionStatus(false, "测试进行中,当前显示和记录为原始 HRR/THR。");
|
||||
_isTestRunning = true;
|
||||
_isRecordingActive = false;
|
||||
_testClock.Reset();
|
||||
@@ -73,6 +87,7 @@ public sealed class ExperimentDataService : IExperimentDataService
|
||||
_isTestRunning = false;
|
||||
_isRecordingActive = false;
|
||||
_testClock.Stop();
|
||||
ApplyFinalHeatReleaseCorrection();
|
||||
_completedSnapshot = BuildCompletedSnapshot(CurrentSnapshot);
|
||||
PublishSnapshot(BuildIdleSnapshot(CurrentSnapshot));
|
||||
|
||||
@@ -91,6 +106,7 @@ public sealed class ExperimentDataService : IExperimentDataService
|
||||
_testClock.Reset();
|
||||
_isRecordingActive = false;
|
||||
_completedSnapshot = null;
|
||||
SetHeatReleaseCorrectionStatus(false, "记录已清空,未生成正式 HRR 校正结果。");
|
||||
ResetComputedState();
|
||||
PublishSnapshot(BuildIdleSnapshot(CurrentSnapshot));
|
||||
}
|
||||
@@ -246,6 +262,8 @@ public sealed class ExperimentDataService : IExperimentDataService
|
||||
|
||||
return snapshot with
|
||||
{
|
||||
HeatReleaseRate = completedSnapshot?.HeatReleaseRate ?? snapshot.HeatReleaseRate,
|
||||
PeakHeatReleaseRate = completedSnapshot?.PeakHeatReleaseRate ?? snapshot.PeakHeatReleaseRate,
|
||||
TestSeconds = completedSnapshot?.TestSeconds ?? -1,
|
||||
InitialMass = completedSnapshot?.InitialMass ?? double.NaN,
|
||||
MassLoss = completedSnapshot?.MassLoss ?? double.NaN,
|
||||
@@ -282,6 +300,8 @@ public sealed class ExperimentDataService : IExperimentDataService
|
||||
var lastRecord = Records[Records.Count - 1];
|
||||
return snapshot with
|
||||
{
|
||||
HeatReleaseRate = lastRecord.HeatReleaseRate,
|
||||
PeakHeatReleaseRate = lastRecord.PeakHeatReleaseRate,
|
||||
TotalHeatRelease = lastRecord.TotalHeatRelease,
|
||||
InitialMass = lastRecord.InitialMass,
|
||||
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()
|
||||
{
|
||||
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; }
|
||||
|
||||
bool IsHeatReleaseCorrectionApplied { get; }
|
||||
|
||||
string HeatReleaseCorrectionStatus { get; }
|
||||
|
||||
event EventHandler<RealtimeSnapshot>? SnapshotUpdated;
|
||||
|
||||
event EventHandler? HeatReleaseCorrectionStatusChanged;
|
||||
|
||||
void StartTest();
|
||||
|
||||
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, ModbusFloatByteOrder byteOrder);
|
||||
|
||||
bool TryReadCoil(ushort coilAddress, out 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("请先点击“测试开始”,产生有效实验数据后再导出报表。");
|
||||
}
|
||||
|
||||
if (exportRecords.Any(record => !record.IsHeatReleaseCorrected)
|
||||
|| exportRecords.Any(record => string.IsNullOrWhiteSpace(record.HeatReleaseCorrectionVersion)))
|
||||
{
|
||||
throw new InvalidOperationException("正式报表要求使用已完成响应校正的 HRR/THR 数据。");
|
||||
}
|
||||
|
||||
ValidateExportRecords(exportRecords);
|
||||
|
||||
var templatePath = FindTemplatePath();
|
||||
@@ -571,7 +577,7 @@ public sealed class NpoiReportExportService : IReportExportService
|
||||
|
||||
var last = records[^1];
|
||||
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"),
|
||||
TotalHeatRelease: FormatWithUnit(LastFinite(records, record => record.TotalHeatRelease), "MJ/㎡"),
|
||||
TotalSmoke: FormatWithUnit(LastFinite(records, record => record.TotalSmoke), "m²"),
|
||||
|
||||
@@ -258,6 +258,11 @@ public sealed class TcpDeviceConnectionService : ITcpDeviceConnectionService
|
||||
}
|
||||
|
||||
public bool TryWriteFloat(ushort registerAddress, float value)
|
||||
{
|
||||
return TryWriteFloat(registerAddress, value, ModbusFloatByteOrder.Abcd);
|
||||
}
|
||||
|
||||
public bool TryWriteFloat(ushort registerAddress, float value, ModbusFloatByteOrder byteOrder)
|
||||
{
|
||||
lock (_syncRoot)
|
||||
{
|
||||
@@ -269,12 +274,13 @@ public sealed class TcpDeviceConnectionService : ITcpDeviceConnectionService
|
||||
|
||||
try
|
||||
{
|
||||
WriteFloat(_client, registerAddress, value);
|
||||
WriteFloat(_client, registerAddress, value, byteOrder);
|
||||
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,
|
||||
registerAddress,
|
||||
value);
|
||||
value,
|
||||
byteOrder);
|
||||
return true;
|
||||
}
|
||||
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}");
|
||||
Log.Warning(
|
||||
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,
|
||||
registerAddress,
|
||||
value);
|
||||
value,
|
||||
byteOrder);
|
||||
SetConnectionState(false, $"写入寄存器 {registerAddress} 失败:{ex.Message}");
|
||||
CloseCurrentClientCore();
|
||||
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];
|
||||
BinaryPrimitives.WriteUInt16BigEndian(payload[0..2], registerAddress);
|
||||
BinaryPrimitives.WriteUInt16BigEndian(payload[2..4], 2);
|
||||
payload[4] = 4;
|
||||
BinaryPrimitives.WriteInt32BigEndian(payload[5..9], BitConverter.SingleToInt32Bits(value));
|
||||
WriteFloatPayloadBytes(payload[5..9], value, byteOrder);
|
||||
|
||||
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)
|
||||
{
|
||||
Span<byte> payload = stackalloc byte[4];
|
||||
|
||||
@@ -32,6 +32,7 @@ public sealed class RealtimeDataViewModel : PageViewModel
|
||||
|
||||
RebuildRows();
|
||||
_experimentDataService.Records.CollectionChanged += RecordsChanged;
|
||||
_experimentDataService.HeatReleaseCorrectionStatusChanged += (_, _) => RebuildRows();
|
||||
}
|
||||
|
||||
public ObservableCollection<RealtimeDataRowViewModel> Rows { get; }
|
||||
|
||||
@@ -55,6 +55,7 @@ public sealed class ReportPageViewModel : PageViewModel
|
||||
|
||||
ExportCommand = new RelayCommand(ExportReport);
|
||||
_experimentDataService.Records.CollectionChanged += RecordsChanged;
|
||||
_experimentDataService.HeatReleaseCorrectionStatusChanged += (_, _) => HeatReleaseCorrectionStatusChanged();
|
||||
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[2].Value = records.Last().Timestamp.ToString("yyyy-MM-dd HH:mm:ss", CultureInfo.CurrentCulture);
|
||||
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[5].Value = FormatWithUnit(LastFinite(records, record => record.TotalSmoke), "m²");
|
||||
SummaryItems[6].Value = FormatWithUnit(LastFinite(records, record => record.MassLoss), "g");
|
||||
FillCollectedReportFields(records);
|
||||
}
|
||||
|
||||
private void HeatReleaseCorrectionStatusChanged()
|
||||
{
|
||||
UpdateSummary();
|
||||
StatusText = _experimentDataService.HeatReleaseCorrectionStatus;
|
||||
}
|
||||
|
||||
private void ExportReport()
|
||||
{
|
||||
var records = _experimentDataService.Records.ToList();
|
||||
@@ -176,6 +188,16 @@ public sealed class ReportPageViewModel : PageViewModel
|
||||
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);
|
||||
var input = BuildInput();
|
||||
var defaultName = BuildDefaultFileName(input);
|
||||
|
||||
@@ -110,6 +110,7 @@ public sealed class TestPageViewModel : PageViewModel
|
||||
HeatReleasePlot = CreatePlotModel(out _heatReleaseSeries, out _totalHeatSeries, out _totalSmokeSeries);
|
||||
UpdateSnapshot(_experimentDataService.CurrentSnapshot);
|
||||
_experimentDataService.SnapshotUpdated += (_, snapshot) => UpdateSnapshot(snapshot);
|
||||
_experimentDataService.HeatReleaseCorrectionStatusChanged += (_, _) => RebuildFinalHeatReleasePlot();
|
||||
}
|
||||
|
||||
public ObservableCollection<MetricDisplayViewModel> TopMetrics { get; }
|
||||
@@ -408,6 +409,33 @@ public sealed class TestPageViewModel : PageViewModel
|
||||
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()
|
||||
{
|
||||
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