diff --git a/ConeCalorimeter/Models/RealtimeSnapshot.cs b/ConeCalorimeter/Models/RealtimeSnapshot.cs index adb1b47..b8a47df 100644 --- a/ConeCalorimeter/Models/RealtimeSnapshot.cs +++ b/ConeCalorimeter/Models/RealtimeSnapshot.cs @@ -24,5 +24,6 @@ public sealed record RealtimeSnapshot( double MassLoss, double MassLossRate, int IgnitionSeconds, + int DeviceTestSeconds, int TestSeconds, double TotalSmoke); diff --git a/ConeCalorimeter/Services/ExperimentDataService.cs b/ConeCalorimeter/Services/ExperimentDataService.cs index e915d6e..1050c03 100644 --- a/ConeCalorimeter/Services/ExperimentDataService.cs +++ b/ConeCalorimeter/Services/ExperimentDataService.cs @@ -23,8 +23,12 @@ public sealed class ExperimentDataService : IExperimentDataService private double _accumulatedTotalSmoke; private int? _lastAccumulationSeconds; private int? _lastRecordedSeconds; + private int? _deviceTestTimerBaselineSeconds; + private bool _hasObservedDeviceTestTimerReset; private bool _isTestRunning; + private bool _isRecordingActive; private bool _isPollingSnapshot; + private RealtimeSnapshot? _completedSnapshot; public ExperimentDataService(IRealtimeDataService realtimeDataService) { @@ -50,26 +54,26 @@ public sealed class ExperimentDataService : IExperimentDataService { Records.Clear(); ResetComputedState(); + _completedSnapshot = null; _isTestRunning = true; - _testClock.Restart(); - - if (double.IsFinite(CurrentSnapshot.CurrentMass)) - { - _initialMass = CurrentSnapshot.CurrentMass; - } + _isRecordingActive = false; + _testClock.Reset(); + PrepareDeviceTestTimerGate(); + PublishSnapshot(BuildWaitingSnapshot(CurrentSnapshot)); QueueImmediateDevicePoll(); Log.Information( - "Experiment test started. InitialMass={InitialMass}, SystemSeconds={TestSeconds}.", - _initialMass, - CurrentSnapshot.TestSeconds); + "Experiment test started. Waiting for D1015 timing marker. DeviceTimerBaseline={DeviceTimerBaseline}.", + _deviceTestTimerBaselineSeconds); } public void StopTest() { _isTestRunning = false; + _isRecordingActive = false; _testClock.Stop(); + _completedSnapshot = BuildCompletedSnapshot(CurrentSnapshot); PublishSnapshot(BuildIdleSnapshot(CurrentSnapshot)); Log.Information( @@ -85,6 +89,8 @@ public sealed class ExperimentDataService : IExperimentDataService if (!_isTestRunning) { _testClock.Reset(); + _isRecordingActive = false; + _completedSnapshot = null; ResetComputedState(); PublishSnapshot(BuildIdleSnapshot(CurrentSnapshot)); } @@ -110,7 +116,7 @@ public sealed class ExperimentDataService : IExperimentDataService try { - var elapsed = _isTestRunning ? _testClock.Elapsed : TimeSpan.Zero; + var elapsed = _isTestRunning && _isRecordingActive ? _testClock.Elapsed : TimeSpan.Zero; var snapshot = await Task.Run(() => _realtimeDataService.GetCurrentSnapshot(elapsed)); if (_isTestRunning) { @@ -157,6 +163,28 @@ public sealed class ExperimentDataService : IExperimentDataService return; } + if (!_isRecordingActive) + { + if (!ShouldStartTimedRecording(snapshot)) + { + PublishSnapshot(BuildWaitingSnapshot(snapshot)); + return; + } + + BeginTimedRecording(snapshot); + snapshot = snapshot with + { + TestSeconds = 0 + }; + } + else + { + snapshot = snapshot with + { + TestSeconds = GetElapsedTestSeconds() + }; + } + var currentSecond = Math.Max(0, snapshot.TestSeconds); if (_lastRecordedSeconds.HasValue && currentSecond <= _lastRecordedSeconds.Value) { @@ -183,6 +211,11 @@ public sealed class ExperimentDataService : IExperimentDataService return BuildIdleSnapshot(snapshot); } + if (!_isRecordingActive) + { + return BuildWaitingSnapshot(snapshot); + } + var timedSnapshot = snapshot with { TestSeconds = GetElapsedTestSeconds() @@ -209,17 +242,103 @@ public sealed class ExperimentDataService : IExperimentDataService private RealtimeSnapshot BuildIdleSnapshot(RealtimeSnapshot snapshot) { + var completedSnapshot = _completedSnapshot; + + return snapshot with + { + TestSeconds = completedSnapshot?.TestSeconds ?? -1, + InitialMass = completedSnapshot?.InitialMass ?? double.NaN, + MassLoss = completedSnapshot?.MassLoss ?? double.NaN, + MassLossRate = completedSnapshot?.MassLossRate ?? double.NaN, + TotalHeatRelease = completedSnapshot?.TotalHeatRelease ?? double.NaN, + TotalSmoke = completedSnapshot?.TotalSmoke ?? double.NaN + }; + } + + private RealtimeSnapshot BuildWaitingSnapshot(RealtimeSnapshot snapshot) + { + var displayInitialMass = double.IsFinite(snapshot.CurrentMass) + ? snapshot.CurrentMass + : double.NaN; + return snapshot with { TestSeconds = -1, - InitialMass = _initialMass ?? double.NaN, - MassLoss = double.NaN, + InitialMass = displayInitialMass, + MassLoss = double.IsFinite(displayInitialMass) ? 0 : double.NaN, MassLossRate = double.NaN, - TotalHeatRelease = double.NaN, - TotalSmoke = double.NaN + TotalHeatRelease = 0, + TotalSmoke = 0 }; } + private RealtimeSnapshot? BuildCompletedSnapshot(RealtimeSnapshot snapshot) + { + if (Records.Count == 0) + { + return snapshot.TestSeconds >= 0 ? snapshot : null; + } + + var lastRecord = Records[Records.Count - 1]; + return snapshot with + { + TotalHeatRelease = lastRecord.TotalHeatRelease, + InitialMass = lastRecord.InitialMass, + MassLoss = lastRecord.MassLoss, + MassLossRate = lastRecord.MassLossRate, + IgnitionSeconds = lastRecord.IgnitionSeconds, + TestSeconds = lastRecord.TestSeconds, + TotalSmoke = lastRecord.TotalSmoke + }; + } + + private void PrepareDeviceTestTimerGate() + { + var currentDeviceSeconds = CurrentSnapshot.DeviceTestSeconds; + _deviceTestTimerBaselineSeconds = currentDeviceSeconds >= 0 ? currentDeviceSeconds : null; + _hasObservedDeviceTestTimerReset = !_deviceTestTimerBaselineSeconds.HasValue + || _deviceTestTimerBaselineSeconds.Value <= 0; + } + + private bool ShouldStartTimedRecording(RealtimeSnapshot snapshot) + { + var deviceSeconds = snapshot.DeviceTestSeconds; + if (deviceSeconds < 0) + { + return false; + } + + if (!_deviceTestTimerBaselineSeconds.HasValue || _hasObservedDeviceTestTimerReset) + { + return deviceSeconds > 0; + } + + var baselineSeconds = _deviceTestTimerBaselineSeconds.Value; + if (deviceSeconds <= 0) + { + _hasObservedDeviceTestTimerReset = true; + return false; + } + + return deviceSeconds != baselineSeconds; + } + + private void BeginTimedRecording(RealtimeSnapshot snapshot) + { + _isRecordingActive = true; + _testClock.Restart(); + + if (double.IsFinite(snapshot.CurrentMass)) + { + _initialMass = snapshot.CurrentMass; + } + + Log.Information( + "Experiment timed recording started by D1015. DeviceTestSeconds={DeviceTestSeconds}, InitialMass={InitialMass}.", + snapshot.DeviceTestSeconds, + _initialMass); + } + private int GetElapsedTestSeconds() { return Math.Max(0, (int)Math.Floor(_testClock.Elapsed.TotalSeconds)); diff --git a/ConeCalorimeter/Services/ModbusRealtimeDataService.cs b/ConeCalorimeter/Services/ModbusRealtimeDataService.cs index d81a8ca..1c554a4 100644 --- a/ConeCalorimeter/Services/ModbusRealtimeDataService.cs +++ b/ConeCalorimeter/Services/ModbusRealtimeDataService.cs @@ -26,6 +26,7 @@ public sealed class ModbusRealtimeDataService : IRealtimeDataService private const ushort SmokeProductionRegister = 392; private const ushort IrradianceRegister = 410; private const ushort IgnitionSecondsRegister = 1014; + private const ushort DeviceTestSecondsRegister = 1015; private const ushort M3FlameMonitorBit = 3; private static readonly ModbusFloatByteOrder[] RealtimeFloatByteOrders = [ @@ -84,6 +85,7 @@ public sealed class ModbusRealtimeDataService : IRealtimeDataService MassLoss: double.NaN, MassLossRate: double.NaN, IgnitionSeconds: ReadInt16OrEmpty(IgnitionSecondsRegister), + DeviceTestSeconds: ReadInt16OrEmpty(DeviceTestSecondsRegister), TestSeconds: ToElapsedSeconds(elapsed), TotalSmoke: double.NaN); }