更新2027

This commit is contained in:
GukSang.Jin
2026-06-17 16:11:14 +08:00
parent d55713542c
commit 5212c4f5ef
19 changed files with 1080 additions and 14 deletions

View File

@@ -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;
}
}
}
}

View 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);

View 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; } = [];
}

View File

@@ -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 =>

View 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));
}
}

View File

@@ -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;

View 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;
}
}

View File

@@ -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();

View File

@@ -0,0 +1,8 @@
using ConeCalorimeter.Models;
namespace ConeCalorimeter.Services;
public interface IHeatReleaseCorrectionService
{
HeatReleaseCorrectionResult Correct(IReadOnlyList<RealtimeDataRecord> rawRecords);
}

View File

@@ -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);

View File

@@ -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);
}
}

View File

@@ -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²"),

View File

@@ -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];

View File

@@ -32,6 +32,7 @@ public sealed class RealtimeDataViewModel : PageViewModel
RebuildRows();
_experimentDataService.Records.CollectionChanged += RecordsChanged;
_experimentDataService.HeatReleaseCorrectionStatusChanged += (_, _) => RebuildRows();
}
public ObservableCollection<RealtimeDataRowViewModel> Rows { get; }

View File

@@ -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);

View File

@@ -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))

View 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>

View 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);
}
}

View 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 与校正版本仅在程序内部保留,不新增操作员可见列。
- 缺少有效标定文件或校正失败时,可以导出原始实时数据,但禁止导出正式报表。