diff --git a/ConeCalorimeter/Services/ExperimentDataService.cs b/ConeCalorimeter/Services/ExperimentDataService.cs index d379ac9..f08b20a 100644 --- a/ConeCalorimeter/Services/ExperimentDataService.cs +++ b/ConeCalorimeter/Services/ExperimentDataService.cs @@ -8,12 +8,14 @@ namespace ConeCalorimeter.Services; public sealed class ExperimentDataService : IExperimentDataService { - private const int MaximumRows = 1200; private const double TotalHeatReleaseDivisor = 1000; + private static readonly TimeSpan TimerInterval = TimeSpan.FromMilliseconds(250); + private static readonly TimeSpan DevicePollInterval = TimeSpan.FromSeconds(1); private readonly IRealtimeDataService _realtimeDataService; private readonly DispatcherTimer _timer; - private readonly DateTime _startedAt = DateTime.Now; + private readonly Stopwatch _testClock = new(); + private DateTime _lastDevicePollStartedAtUtc = DateTime.MinValue; private double? _initialMass; private double? _previousMass; private int? _previousMassSeconds; @@ -28,13 +30,13 @@ public sealed class ExperimentDataService : IExperimentDataService { _realtimeDataService = realtimeDataService; Records = []; - CurrentSnapshot = _realtimeDataService.GetCurrentSnapshot(TimeSpan.Zero); + CurrentSnapshot = BuildIdleSnapshot(_realtimeDataService.GetCurrentSnapshot(TimeSpan.Zero)); _timer = new DispatcherTimer { - Interval = TimeSpan.FromSeconds(1) + Interval = TimerInterval }; - _timer.Tick += async (_, _) => await RecordCurrentSnapshotAsync(); + _timer.Tick += async (_, _) => await TickAsync(); _timer.Start(); } @@ -49,21 +51,33 @@ public sealed class ExperimentDataService : IExperimentDataService Records.Clear(); ResetComputedState(); _isTestRunning = true; + _testClock.Restart(); if (double.IsFinite(CurrentSnapshot.CurrentMass)) { _initialMass = CurrentSnapshot.CurrentMass; } + RecordElapsedSnapshots(); + QueueImmediateDevicePoll(); + Log.Information( - "Experiment test started. InitialMass={InitialMass}, SnapshotSeconds={TestSeconds}.", + "Experiment test started. InitialMass={InitialMass}, SystemSeconds={TestSeconds}.", _initialMass, CurrentSnapshot.TestSeconds); } public void StopTest() { + if (_isTestRunning) + { + RecordElapsedSnapshots(); + } + _isTestRunning = false; + _testClock.Stop(); + PublishSnapshot(BuildIdleSnapshot(CurrentSnapshot)); + Log.Information( "Experiment test stopped. Records={RecordCount}, LastRecordedSeconds={LastRecordedSeconds}.", Records.Count, @@ -76,24 +90,40 @@ public sealed class ExperimentDataService : IExperimentDataService if (!_isTestRunning) { + _testClock.Reset(); ResetComputedState(); + PublishSnapshot(BuildIdleSnapshot(CurrentSnapshot)); } } - private async Task RecordCurrentSnapshotAsync() + private async Task TickAsync() { - if (_isPollingSnapshot) + if (_isTestRunning) + { + RecordElapsedSnapshots(); + } + + await RefreshDeviceSnapshotAsync(); + } + + private void QueueImmediateDevicePoll() + { + _lastDevicePollStartedAtUtc = DateTime.MinValue; + _ = RefreshDeviceSnapshotAsync(); + } + + private async Task RefreshDeviceSnapshotAsync() + { + if (!TryBeginDevicePoll()) { return; } - _isPollingSnapshot = true; - try { - var elapsed = DateTime.Now - _startedAt; + var elapsed = _isTestRunning ? _testClock.Elapsed : TimeSpan.Zero; var snapshot = await Task.Run(() => _realtimeDataService.GetCurrentSnapshot(elapsed)); - RecordSnapshot(snapshot); + PublishSnapshot(BuildDisplaySnapshot(snapshot)); } catch (Exception ex) { @@ -106,31 +136,102 @@ public sealed class ExperimentDataService : IExperimentDataService } } - private void RecordSnapshot(RealtimeSnapshot snapshot) + private bool TryBeginDevicePoll() { - var massSnapshot = ApplyMassLoss(snapshot); - var accumulatedSnapshot = ApplyAccumulatedTotals(massSnapshot); - - CurrentSnapshot = accumulatedSnapshot; - - if (_isTestRunning && ShouldRecordSnapshot(accumulatedSnapshot)) + if (_isPollingSnapshot) { - Records.Add(RealtimeDataRecord.FromSnapshot(accumulatedSnapshot)); - _lastRecordedSeconds = accumulatedSnapshot.TestSeconds; - - while (Records.Count > MaximumRows) - { - Records.RemoveAt(0); - } + return false; } - SnapshotUpdated?.Invoke(this, accumulatedSnapshot); + var now = DateTime.UtcNow; + if (now - _lastDevicePollStartedAtUtc < DevicePollInterval) + { + return false; + } + + _lastDevicePollStartedAtUtc = now; + _isPollingSnapshot = true; + return true; } - private bool ShouldRecordSnapshot(RealtimeSnapshot snapshot) + private void RecordElapsedSnapshots() { - return snapshot.TestSeconds >= 0 - && (!_lastRecordedSeconds.HasValue || snapshot.TestSeconds > _lastRecordedSeconds.Value); + if (!_isTestRunning) + { + return; + } + + var currentSecond = GetElapsedTestSeconds(); + var nextSecond = _lastRecordedSeconds.HasValue ? _lastRecordedSeconds.Value + 1 : 0; + + for (var second = nextSecond; second <= currentSecond; second++) + { + var timedSnapshot = CurrentSnapshot with + { + TestSeconds = second + }; + var massSnapshot = ApplyMassLoss(timedSnapshot); + var accumulatedSnapshot = ApplyAccumulatedTotals(massSnapshot); + + PublishSnapshot(accumulatedSnapshot); + Records.Add(RealtimeDataRecord.FromSnapshot(accumulatedSnapshot)); + _lastRecordedSeconds = second; + } + } + + private RealtimeSnapshot BuildDisplaySnapshot(RealtimeSnapshot snapshot) + { + if (!_isTestRunning) + { + return BuildIdleSnapshot(snapshot); + } + + var timedSnapshot = snapshot with + { + TestSeconds = GetElapsedTestSeconds() + }; + + if (!_initialMass.HasValue && double.IsFinite(timedSnapshot.CurrentMass)) + { + _initialMass = timedSnapshot.CurrentMass; + } + + var massLoss = _initialMass.HasValue && double.IsFinite(timedSnapshot.CurrentMass) + ? _initialMass.Value - timedSnapshot.CurrentMass + : double.NaN; + + return timedSnapshot with + { + InitialMass = _initialMass ?? double.NaN, + MassLoss = massLoss, + MassLossRate = CurrentSnapshot.MassLossRate, + TotalHeatRelease = _accumulatedTotalHeatRelease, + TotalSmoke = _accumulatedTotalSmoke + }; + } + + private RealtimeSnapshot BuildIdleSnapshot(RealtimeSnapshot snapshot) + { + return snapshot with + { + TestSeconds = -1, + InitialMass = _initialMass ?? double.NaN, + MassLoss = double.NaN, + MassLossRate = double.NaN, + TotalHeatRelease = double.NaN, + TotalSmoke = double.NaN + }; + } + + private int GetElapsedTestSeconds() + { + return Math.Max(0, (int)Math.Floor(_testClock.Elapsed.TotalSeconds)); + } + + private void PublishSnapshot(RealtimeSnapshot snapshot) + { + CurrentSnapshot = snapshot; + SnapshotUpdated?.Invoke(this, snapshot); } private RealtimeSnapshot ApplyAccumulatedTotals(RealtimeSnapshot snapshot) diff --git a/ConeCalorimeter/Services/ModbusRealtimeDataService.cs b/ConeCalorimeter/Services/ModbusRealtimeDataService.cs index b8892bc..d81a8ca 100644 --- a/ConeCalorimeter/Services/ModbusRealtimeDataService.cs +++ b/ConeCalorimeter/Services/ModbusRealtimeDataService.cs @@ -26,7 +26,6 @@ public sealed class ModbusRealtimeDataService : IRealtimeDataService private const ushort SmokeProductionRegister = 392; private const ushort IrradianceRegister = 410; private const ushort IgnitionSecondsRegister = 1014; - private const ushort TestSecondsRegister = 1015; private const ushort M3FlameMonitorBit = 3; private static readonly ModbusFloatByteOrder[] RealtimeFloatByteOrders = [ @@ -85,10 +84,17 @@ public sealed class ModbusRealtimeDataService : IRealtimeDataService MassLoss: double.NaN, MassLossRate: double.NaN, IgnitionSeconds: ReadInt16OrEmpty(IgnitionSecondsRegister), - TestSeconds: ReadInt16OrEmpty(TestSecondsRegister), + TestSeconds: ToElapsedSeconds(elapsed), TotalSmoke: double.NaN); } + private static int ToElapsedSeconds(TimeSpan elapsed) + { + return elapsed >= TimeSpan.Zero + ? (int)Math.Floor(elapsed.TotalSeconds) + : -1; + } + private double ReadRangedFloatOrEmpty(string label, ushort registerAddress, double minimum, double maximum) { if (!_tcpDeviceConnectionService.TryReadFloatValues(registerAddress, out var result)) diff --git a/ConeCalorimeter/ViewModels/TestPageViewModel.cs b/ConeCalorimeter/ViewModels/TestPageViewModel.cs index a3ae483..2b916da 100644 --- a/ConeCalorimeter/ViewModels/TestPageViewModel.cs +++ b/ConeCalorimeter/ViewModels/TestPageViewModel.cs @@ -431,8 +431,8 @@ public sealed class TestPageViewModel : PageViewModel { if (isStarting) { - _experimentDataService.StartTest(); ClearPlotSeries(); + _experimentDataService.StartTest(); } else {