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