diff --git a/ConeCalorimeter/MainWindow.xaml.cs b/ConeCalorimeter/MainWindow.xaml.cs index 33148cd..3f53f5f 100644 --- a/ConeCalorimeter/MainWindow.xaml.cs +++ b/ConeCalorimeter/MainWindow.xaml.cs @@ -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; + } + } } } diff --git a/ConeCalorimeter/Models/HeatReleaseCorrectionResult.cs b/ConeCalorimeter/Models/HeatReleaseCorrectionResult.cs new file mode 100644 index 0000000..3a4b6c5 --- /dev/null +++ b/ConeCalorimeter/Models/HeatReleaseCorrectionResult.cs @@ -0,0 +1,12 @@ +namespace ConeCalorimeter.Models; + +public sealed record HeatReleaseCorrectionResult( + bool IsApplied, + string Status, + string CalibrationVersion, + IReadOnlyList Records, + double RawPeak, + double CorrectedPeak, + double RawFinalTotalHeatRelease, + double CorrectedFinalTotalHeatRelease, + double AreaErrorPercent); diff --git a/ConeCalorimeter/Models/HeatReleaseResponseCalibration.cs b/ConeCalorimeter/Models/HeatReleaseResponseCalibration.cs new file mode 100644 index 0000000..203b0e6 --- /dev/null +++ b/ConeCalorimeter/Models/HeatReleaseResponseCalibration.cs @@ -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 ResponseKernel { get; set; } = []; +} diff --git a/ConeCalorimeter/Models/RealtimeDataRecord.cs b/ConeCalorimeter/Models/RealtimeDataRecord.cs index aa9663f..2dc9453 100644 --- a/ConeCalorimeter/Models/RealtimeDataRecord.cs +++ b/ConeCalorimeter/Models/RealtimeDataRecord.cs @@ -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 => diff --git a/ConeCalorimeter/Services/BulkObservableCollection.cs b/ConeCalorimeter/Services/BulkObservableCollection.cs new file mode 100644 index 0000000..a15522a --- /dev/null +++ b/ConeCalorimeter/Services/BulkObservableCollection.cs @@ -0,0 +1,21 @@ +using System.Collections.ObjectModel; +using System.Collections.Specialized; +using System.ComponentModel; + +namespace ConeCalorimeter.Services; + +public sealed class BulkObservableCollection : ObservableCollection +{ + public void ReplaceAll(IEnumerable 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)); + } +} diff --git a/ConeCalorimeter/Services/ExperimentDataService.cs b/ConeCalorimeter/Services/ExperimentDataService.cs index 1050c03..5efa744 100644 --- a/ConeCalorimeter/Services/ExperimentDataService.cs +++ b/ConeCalorimeter/Services/ExperimentDataService.cs @@ -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 _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? 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; diff --git a/ConeCalorimeter/Services/HeatReleaseCorrectionService.cs b/ConeCalorimeter/Services/HeatReleaseCorrectionService.cs new file mode 100644 index 0000000..a8ba7c5 --- /dev/null +++ b/ConeCalorimeter/Services/HeatReleaseCorrectionService.cs @@ -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 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 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 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 kernel) + { + var sum = kernel.Sum(); + return kernel.Select(value => value / sum).ToArray(); + } + + private static double[] SolveConstrainedDeconvolution( + IReadOnlyList raw, + IReadOnlyList weights, + double targetArea, + IReadOnlyList 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 values, IReadOnlyList 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 values, IReadOnlyList 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 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 values, + IReadOnlyList 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 values, + IReadOnlyList 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 BuildCorrectedRecords( + IReadOnlyList rawRecords, + IReadOnlyList correctedHrr, + IReadOnlyList weights, + string calibrationVersion) + { + var result = new List(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 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 left, IReadOnlyList right) + { + var result = 0d; + for (var i = 0; i < left.Count; i++) + { + result += left[i] * right[i]; + } + + return result; + } +} diff --git a/ConeCalorimeter/Services/IExperimentDataService.cs b/ConeCalorimeter/Services/IExperimentDataService.cs index 39042ba..5d92f40 100644 --- a/ConeCalorimeter/Services/IExperimentDataService.cs +++ b/ConeCalorimeter/Services/IExperimentDataService.cs @@ -9,8 +9,14 @@ public interface IExperimentDataService RealtimeSnapshot CurrentSnapshot { get; } + bool IsHeatReleaseCorrectionApplied { get; } + + string HeatReleaseCorrectionStatus { get; } + event EventHandler? SnapshotUpdated; + event EventHandler? HeatReleaseCorrectionStatusChanged; + void StartTest(); void StopTest(); diff --git a/ConeCalorimeter/Services/IHeatReleaseCorrectionService.cs b/ConeCalorimeter/Services/IHeatReleaseCorrectionService.cs new file mode 100644 index 0000000..a932238 --- /dev/null +++ b/ConeCalorimeter/Services/IHeatReleaseCorrectionService.cs @@ -0,0 +1,8 @@ +using ConeCalorimeter.Models; + +namespace ConeCalorimeter.Services; + +public interface IHeatReleaseCorrectionService +{ + HeatReleaseCorrectionResult Correct(IReadOnlyList rawRecords); +} diff --git a/ConeCalorimeter/Services/ITcpDeviceConnectionService.cs b/ConeCalorimeter/Services/ITcpDeviceConnectionService.cs index fdff365..bf13b4e 100644 --- a/ConeCalorimeter/Services/ITcpDeviceConnectionService.cs +++ b/ConeCalorimeter/Services/ITcpDeviceConnectionService.cs @@ -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); diff --git a/ConeCalorimeter/Services/JsonHeatReleaseResponseCalibrationStore.cs b/ConeCalorimeter/Services/JsonHeatReleaseResponseCalibrationStore.cs new file mode 100644 index 0000000..26bc48d --- /dev/null +++ b/ConeCalorimeter/Services/JsonHeatReleaseResponseCalibrationStore.cs @@ -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(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); + } +} diff --git a/ConeCalorimeter/Services/NpoiReportExportService.cs b/ConeCalorimeter/Services/NpoiReportExportService.cs index dda6102..1770ede 100644 --- a/ConeCalorimeter/Services/NpoiReportExportService.cs +++ b/ConeCalorimeter/Services/NpoiReportExportService.cs @@ -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²"), diff --git a/ConeCalorimeter/Services/TcpDeviceConnectionService.cs b/ConeCalorimeter/Services/TcpDeviceConnectionService.cs index 7115f65..0e17277 100644 --- a/ConeCalorimeter/Services/TcpDeviceConnectionService.cs +++ b/ConeCalorimeter/Services/TcpDeviceConnectionService.cs @@ -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 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 destination, float value, ModbusFloatByteOrder byteOrder) + { + Span 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 payload = stackalloc byte[4]; diff --git a/ConeCalorimeter/ViewModels/RealtimeDataViewModel.cs b/ConeCalorimeter/ViewModels/RealtimeDataViewModel.cs index 4ee0263..01f0158 100644 --- a/ConeCalorimeter/ViewModels/RealtimeDataViewModel.cs +++ b/ConeCalorimeter/ViewModels/RealtimeDataViewModel.cs @@ -32,6 +32,7 @@ public sealed class RealtimeDataViewModel : PageViewModel RebuildRows(); _experimentDataService.Records.CollectionChanged += RecordsChanged; + _experimentDataService.HeatReleaseCorrectionStatusChanged += (_, _) => RebuildRows(); } public ObservableCollection Rows { get; } diff --git a/ConeCalorimeter/ViewModels/ReportPageViewModel.cs b/ConeCalorimeter/ViewModels/ReportPageViewModel.cs index 774a47b..29b728e 100644 --- a/ConeCalorimeter/ViewModels/ReportPageViewModel.cs +++ b/ConeCalorimeter/ViewModels/ReportPageViewModel.cs @@ -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); diff --git a/ConeCalorimeter/ViewModels/TestPageViewModel.cs b/ConeCalorimeter/ViewModels/TestPageViewModel.cs index 2b916da..3d9d4f8 100644 --- a/ConeCalorimeter/ViewModels/TestPageViewModel.cs +++ b/ConeCalorimeter/ViewModels/TestPageViewModel.cs @@ -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 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)) diff --git a/ConeCalorimeter/Views/HeatFluxCoefficientWindow.xaml b/ConeCalorimeter/Views/HeatFluxCoefficientWindow.xaml new file mode 100644 index 0000000..f93b680 --- /dev/null +++ b/ConeCalorimeter/Views/HeatFluxCoefficientWindow.xaml @@ -0,0 +1,72 @@ + + + + + + + + + + + + + + + + + + + + + + + + + +