diff --git a/ConeCalorimeter/Services/ExperimentDataService.cs b/ConeCalorimeter/Services/ExperimentDataService.cs index c999371..ea471fc 100644 --- a/ConeCalorimeter/Services/ExperimentDataService.cs +++ b/ConeCalorimeter/Services/ExperimentDataService.cs @@ -1,4 +1,5 @@ using System.Collections.ObjectModel; +using System.Diagnostics; using System.Windows.Threading; using ConeCalorimeter.Models; @@ -17,7 +18,9 @@ public sealed class ExperimentDataService : IExperimentDataService private double _accumulatedTotalHeatRelease; private double _accumulatedTotalSmoke; private int? _lastAccumulationSeconds; + private int? _lastRecordedSeconds; private bool _isTestRunning; + private bool _isPollingSnapshot; public ExperimentDataService(IRealtimeDataService realtimeDataService) { @@ -29,8 +32,7 @@ public sealed class ExperimentDataService : IExperimentDataService { Interval = TimeSpan.FromSeconds(1) }; - _timer.Tick += (_, _) => RecordSnapshot( - _realtimeDataService.GetCurrentSnapshot(DateTime.Now - _startedAt)); + _timer.Tick += async (_, _) => await RecordCurrentSnapshotAsync(); _timer.Start(); } @@ -60,7 +62,36 @@ public sealed class ExperimentDataService : IExperimentDataService public void ClearRecords() { Records.Clear(); - ResetComputedState(); + + if (!_isTestRunning) + { + ResetComputedState(); + } + } + + private async Task RecordCurrentSnapshotAsync() + { + if (_isPollingSnapshot) + { + return; + } + + _isPollingSnapshot = true; + + try + { + var elapsed = DateTime.Now - _startedAt; + var snapshot = await Task.Run(() => _realtimeDataService.GetCurrentSnapshot(elapsed)); + RecordSnapshot(snapshot); + } + catch (Exception ex) + { + Debug.WriteLine($"Realtime snapshot polling failed: {ex.Message}"); + } + finally + { + _isPollingSnapshot = false; + } } private void RecordSnapshot(RealtimeSnapshot snapshot) @@ -70,9 +101,10 @@ public sealed class ExperimentDataService : IExperimentDataService CurrentSnapshot = accumulatedSnapshot; - if (_isTestRunning) + if (_isTestRunning && ShouldRecordSnapshot(accumulatedSnapshot)) { Records.Add(RealtimeDataRecord.FromSnapshot(accumulatedSnapshot)); + _lastRecordedSeconds = accumulatedSnapshot.TestSeconds; while (Records.Count > MaximumRows) { @@ -83,6 +115,12 @@ public sealed class ExperimentDataService : IExperimentDataService SnapshotUpdated?.Invoke(this, accumulatedSnapshot); } + private bool ShouldRecordSnapshot(RealtimeSnapshot snapshot) + { + return snapshot.TestSeconds >= 0 + && (!_lastRecordedSeconds.HasValue || snapshot.TestSeconds > _lastRecordedSeconds.Value); + } + private RealtimeSnapshot ApplyAccumulatedTotals(RealtimeSnapshot snapshot) { if (!_isTestRunning) @@ -199,5 +237,6 @@ public sealed class ExperimentDataService : IExperimentDataService _accumulatedTotalHeatRelease = 0; _accumulatedTotalSmoke = 0; _lastAccumulationSeconds = null; + _lastRecordedSeconds = null; } } diff --git a/ConeCalorimeter/Services/NpoiRealtimeDataExportService.cs b/ConeCalorimeter/Services/NpoiRealtimeDataExportService.cs index e88a92d..6e04bda 100644 --- a/ConeCalorimeter/Services/NpoiRealtimeDataExportService.cs +++ b/ConeCalorimeter/Services/NpoiRealtimeDataExportService.cs @@ -14,7 +14,7 @@ public sealed class NpoiRealtimeDataExportService : IRealtimeDataExportService "CO2 (%)", "CO (%)", "孔板压差 (Pa)", - "孔板温度 (℃)", + "孔板温度 (K)", "HRR", "热释放速率180", "热释放速率300", diff --git a/ConeCalorimeter/Services/NpoiReportExportService.cs b/ConeCalorimeter/Services/NpoiReportExportService.cs index 7433797..e676225 100644 --- a/ConeCalorimeter/Services/NpoiReportExportService.cs +++ b/ConeCalorimeter/Services/NpoiReportExportService.cs @@ -23,7 +23,7 @@ public sealed class NpoiReportExportService : IReportExportService "CO2 (%)", "CO (%)", "孔板压差 (Pa)", - "孔板温度 (℃)", + "孔板温度 (K)", "MLR (g/s)", "热释放KW/m2", "EHC", diff --git a/ConeCalorimeter/ViewModels/TestPageViewModel.cs b/ConeCalorimeter/ViewModels/TestPageViewModel.cs index 2428e44..b9e5d43 100644 --- a/ConeCalorimeter/ViewModels/TestPageViewModel.cs +++ b/ConeCalorimeter/ViewModels/TestPageViewModel.cs @@ -17,6 +17,10 @@ public sealed class TestPageViewModel : PageViewModel private const string ResetAction = "复位"; private const ushort ResetCoil = 88; private const ushort ResetCompleteCoil = 89; + private const double MinimumHeatReleaseAxisMaximum = 150; + private const double MinimumTotalAxisMaximum = 150; + private const double PlotAxisPaddingFactor = 1.1; + private const int MaximumPlotPoints = 600; private static readonly TimeSpan ResetPulseDuration = TimeSpan.FromMilliseconds(300); private static readonly TimeSpan ResetCompletionTimeout = TimeSpan.FromSeconds(15); @@ -29,6 +33,7 @@ public sealed class TestPageViewModel : PageViewModel private DeviceActionViewModel _resetAction = null!; private bool _flameDetected; private bool _resetInProgress; + private int? _lastPlottedSeconds; private DateTime _resetStartedAt; private string _lastAction = "待机"; @@ -45,7 +50,7 @@ public sealed class TestPageViewModel : PageViewModel new MetricDisplayViewModel("孔板压差", "Pa"), new MetricDisplayViewModel("孔板温度", "K"), new MetricDisplayViewModel("辐射锥温度", "°C"), - new MetricDisplayViewModel("辐射照度", "kW") + new MetricDisplayViewModel("辐射照度", "kW/㎡") ]; GasMetrics = @@ -236,20 +241,36 @@ public sealed class TestPageViewModel : PageViewModel FlameDetected = snapshot.FlameDetected; OnPropertyChanged(nameof(FlameStatus)); + UpdatePlot(snapshot); + } + + private void UpdatePlot(RealtimeSnapshot snapshot) + { + if (!ShouldPlotSnapshot(snapshot)) + { + return; + } + var x = snapshot.TestSeconds; AppendPoint(_heatReleaseSeries, x, snapshot.HeatReleaseRate); AppendPoint(_totalHeatSeries, x, snapshot.TotalHeatRelease); AppendPoint(_totalSmokeSeries, x, snapshot.TotalSmoke); + _lastPlottedSeconds = snapshot.TestSeconds; - var bottomAxis = HeatReleasePlot.Axes.First(axis => axis.Position == AxisPosition.Bottom); - if (x > bottomAxis.Maximum - 20) - { - bottomAxis.Maximum = x + 40; - } - + UpdateTimeAxis(x); + UpdateValueAxes(); HeatReleasePlot.InvalidatePlot(true); } + private bool ShouldPlotSnapshot(RealtimeSnapshot snapshot) + { + return snapshot.TestSeconds >= 0 + && (!_lastPlottedSeconds.HasValue || snapshot.TestSeconds > _lastPlottedSeconds.Value) + && double.IsFinite(snapshot.HeatReleaseRate) + && double.IsFinite(snapshot.TotalHeatRelease) + && double.IsFinite(snapshot.TotalSmoke); + } + private static void AppendPoint(LineSeries series, double x, double y) { if (!double.IsFinite(x) || x < 0 || !double.IsFinite(y)) @@ -259,12 +280,43 @@ public sealed class TestPageViewModel : PageViewModel series.Points.Add(new DataPoint(x, y)); - if (series.Points.Count > 600) + if (series.Points.Count > MaximumPlotPoints) { series.Points.RemoveAt(0); } } + private void UpdateTimeAxis(double latestSeconds) + { + var bottomAxis = HeatReleasePlot.Axes.First(axis => axis.Position == AxisPosition.Bottom); + bottomAxis.Minimum = Math.Max(0, latestSeconds - MaximumPlotPoints); + bottomAxis.Maximum = Math.Max(300, latestSeconds + 40); + } + + private void UpdateValueAxes() + { + var heatReleaseAxis = HeatReleasePlot.Axes.First(axis => axis.Position == AxisPosition.Left); + heatReleaseAxis.Maximum = CalculateAxisMaximum(MinimumHeatReleaseAxisMaximum, _heatReleaseSeries); + + var totalAxis = HeatReleasePlot.Axes.First(axis => axis.Key == "TotalAxis"); + totalAxis.Maximum = CalculateAxisMaximum( + MinimumTotalAxisMaximum, + _totalHeatSeries, + _totalSmokeSeries); + } + + private static double CalculateAxisMaximum(double minimumMaximum, params LineSeries[] seriesCollection) + { + var maximum = seriesCollection + .SelectMany(series => series.Points) + .Where(point => double.IsFinite(point.Y)) + .Select(point => point.Y) + .DefaultIfEmpty(0) + .Max(); + + return Math.Max(minimumMaximum, Math.Ceiling(maximum * PlotAxisPaddingFactor)); + } + public bool StartMomentaryDeviceAction(string? action) { return TryWriteMomentaryDeviceAction(action, true); @@ -396,7 +448,10 @@ public sealed class TestPageViewModel : PageViewModel _totalSmokeSeries.Points.Clear(); var bottomAxis = HeatReleasePlot.Axes.First(axis => axis.Position == AxisPosition.Bottom); + bottomAxis.Minimum = 0; bottomAxis.Maximum = 300; + UpdateValueAxes(); + _lastPlottedSeconds = null; HeatReleasePlot.InvalidatePlot(true); } diff --git a/ConeCalorimeter/Views/RealtimeDataView.xaml b/ConeCalorimeter/Views/RealtimeDataView.xaml index a782966..9ff633d 100644 --- a/ConeCalorimeter/Views/RealtimeDataView.xaml +++ b/ConeCalorimeter/Views/RealtimeDataView.xaml @@ -112,7 +112,8 @@ - - + diff --git a/ConeCalorimeter/Views/RealtimeDataView.xaml.cs b/ConeCalorimeter/Views/RealtimeDataView.xaml.cs index efbeb05..310e4f0 100644 --- a/ConeCalorimeter/Views/RealtimeDataView.xaml.cs +++ b/ConeCalorimeter/Views/RealtimeDataView.xaml.cs @@ -1,11 +1,49 @@ +using System.Collections.Specialized; using System.Windows.Controls; +using System.Windows.Threading; +using ConeCalorimeter.ViewModels; namespace ConeCalorimeter.Views; public partial class RealtimeDataView : UserControl { + private INotifyCollectionChanged? _rowsCollection; + public RealtimeDataView() { InitializeComponent(); + DataContextChanged += (_, _) => SubscribeRowsCollection(); + Unloaded += (_, _) => UnsubscribeRowsCollection(); + } + + private void SubscribeRowsCollection() + { + UnsubscribeRowsCollection(); + + if (DataContext is RealtimeDataViewModel viewModel) + { + _rowsCollection = viewModel.Rows; + _rowsCollection.CollectionChanged += RowsCollectionChanged; + } + } + + private void UnsubscribeRowsCollection() + { + if (_rowsCollection is not null) + { + _rowsCollection.CollectionChanged -= RowsCollectionChanged; + _rowsCollection = null; + } + } + + private void RowsCollectionChanged(object? sender, NotifyCollectionChangedEventArgs e) + { + if (e.Action != NotifyCollectionChangedAction.Add || e.NewItems is null || e.NewItems.Count == 0) + { + return; + } + + var latest = e.NewItems[e.NewItems.Count - 1]; + Dispatcher.BeginInvoke(new Action(() => RealtimeDataGrid.ScrollIntoView(latest)), DispatcherPriority.Background); } }