516 lines
16 KiB
C#
516 lines
16 KiB
C#
using System.Collections.ObjectModel;
|
|
using System.Diagnostics;
|
|
using System.Windows.Threading;
|
|
using ConeCalorimeter.Models;
|
|
using Serilog;
|
|
|
|
namespace ConeCalorimeter.Services;
|
|
|
|
public sealed class ExperimentDataService : IExperimentDataService
|
|
{
|
|
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 IHeatReleaseCorrectionService _heatReleaseCorrectionService;
|
|
private readonly BulkObservableCollection<RealtimeDataRecord> _records;
|
|
private readonly DispatcherTimer _timer;
|
|
private readonly Stopwatch _testClock = new();
|
|
private DateTime _lastDevicePollStartedAtUtc = DateTime.MinValue;
|
|
private double? _initialMass;
|
|
private double? _previousMass;
|
|
private int? _previousMassSeconds;
|
|
private double _accumulatedTotalHeatRelease;
|
|
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,
|
|
IHeatReleaseCorrectionService heatReleaseCorrectionService)
|
|
{
|
|
_realtimeDataService = realtimeDataService;
|
|
_heatReleaseCorrectionService = heatReleaseCorrectionService;
|
|
_records = [];
|
|
Records = _records;
|
|
CurrentSnapshot = BuildIdleSnapshot(_realtimeDataService.GetCurrentSnapshot(TimeSpan.Zero));
|
|
HeatReleaseCorrectionStatus = "尚未完成测试,未生成正式 HRR 校正结果。";
|
|
|
|
_timer = new DispatcherTimer
|
|
{
|
|
Interval = TimerInterval
|
|
};
|
|
_timer.Tick += async (_, _) => await TickAsync();
|
|
_timer.Start();
|
|
}
|
|
|
|
public ObservableCollection<RealtimeDataRecord> Records { get; }
|
|
|
|
public RealtimeSnapshot CurrentSnapshot { get; private set; }
|
|
|
|
public bool IsHeatReleaseCorrectionApplied { get; private set; }
|
|
|
|
public string HeatReleaseCorrectionStatus { get; private set; }
|
|
|
|
public event EventHandler<RealtimeSnapshot>? SnapshotUpdated;
|
|
|
|
public event EventHandler? HeatReleaseCorrectionStatusChanged;
|
|
|
|
public void StartTest()
|
|
{
|
|
Records.Clear();
|
|
ResetComputedState();
|
|
_completedSnapshot = null;
|
|
SetHeatReleaseCorrectionStatus(false, "测试进行中,当前显示和记录为原始 HRR/THR。");
|
|
_isTestRunning = true;
|
|
_isRecordingActive = false;
|
|
_testClock.Reset();
|
|
PrepareDeviceTestTimerGate();
|
|
PublishSnapshot(BuildWaitingSnapshot(CurrentSnapshot));
|
|
|
|
QueueImmediateDevicePoll();
|
|
|
|
Log.Information(
|
|
"Experiment test started. Waiting for D1015 timing marker. DeviceTimerBaseline={DeviceTimerBaseline}.",
|
|
_deviceTestTimerBaselineSeconds);
|
|
}
|
|
|
|
public void StopTest()
|
|
{
|
|
_isTestRunning = false;
|
|
_isRecordingActive = false;
|
|
_testClock.Stop();
|
|
ApplyFinalHeatReleaseCorrection();
|
|
_completedSnapshot = BuildCompletedSnapshot(CurrentSnapshot);
|
|
PublishSnapshot(BuildIdleSnapshot(CurrentSnapshot));
|
|
|
|
Log.Information(
|
|
"Experiment test stopped. Records={RecordCount}, LastRecordedSeconds={LastRecordedSeconds}.",
|
|
Records.Count,
|
|
_lastRecordedSeconds);
|
|
}
|
|
|
|
public void ClearRecords()
|
|
{
|
|
Records.Clear();
|
|
|
|
if (!_isTestRunning)
|
|
{
|
|
_testClock.Reset();
|
|
_isRecordingActive = false;
|
|
_completedSnapshot = null;
|
|
SetHeatReleaseCorrectionStatus(false, "记录已清空,未生成正式 HRR 校正结果。");
|
|
ResetComputedState();
|
|
PublishSnapshot(BuildIdleSnapshot(CurrentSnapshot));
|
|
}
|
|
}
|
|
|
|
private async Task TickAsync()
|
|
{
|
|
await RefreshDeviceSnapshotAsync();
|
|
}
|
|
|
|
private void QueueImmediateDevicePoll()
|
|
{
|
|
_lastDevicePollStartedAtUtc = DateTime.MinValue;
|
|
_ = RefreshDeviceSnapshotAsync();
|
|
}
|
|
|
|
private async Task RefreshDeviceSnapshotAsync()
|
|
{
|
|
if (!TryBeginDevicePoll())
|
|
{
|
|
return;
|
|
}
|
|
|
|
try
|
|
{
|
|
var elapsed = _isTestRunning && _isRecordingActive ? _testClock.Elapsed : TimeSpan.Zero;
|
|
var snapshot = await Task.Run(() => _realtimeDataService.GetCurrentSnapshot(elapsed));
|
|
if (_isTestRunning)
|
|
{
|
|
RecordDeviceSnapshot(snapshot);
|
|
}
|
|
else
|
|
{
|
|
PublishSnapshot(BuildIdleSnapshot(snapshot));
|
|
}
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
Debug.WriteLine($"Realtime snapshot polling failed: {ex.Message}");
|
|
Log.Warning(ex, "Realtime snapshot polling failed.");
|
|
}
|
|
finally
|
|
{
|
|
_isPollingSnapshot = false;
|
|
}
|
|
}
|
|
|
|
private bool TryBeginDevicePoll()
|
|
{
|
|
if (_isPollingSnapshot)
|
|
{
|
|
return false;
|
|
}
|
|
|
|
var now = DateTime.UtcNow;
|
|
if (now - _lastDevicePollStartedAtUtc < DevicePollInterval)
|
|
{
|
|
return false;
|
|
}
|
|
|
|
_lastDevicePollStartedAtUtc = now;
|
|
_isPollingSnapshot = true;
|
|
return true;
|
|
}
|
|
|
|
private void RecordDeviceSnapshot(RealtimeSnapshot snapshot)
|
|
{
|
|
if (!_isTestRunning)
|
|
{
|
|
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)
|
|
{
|
|
PublishSnapshot(BuildDisplaySnapshot(snapshot));
|
|
return;
|
|
}
|
|
|
|
var timedSnapshot = snapshot with
|
|
{
|
|
TestSeconds = currentSecond
|
|
};
|
|
var massSnapshot = ApplyMassLoss(timedSnapshot);
|
|
var accumulatedSnapshot = ApplyAccumulatedTotals(massSnapshot);
|
|
|
|
PublishSnapshot(accumulatedSnapshot);
|
|
Records.Add(RealtimeDataRecord.FromSnapshot(accumulatedSnapshot));
|
|
_lastRecordedSeconds = currentSecond;
|
|
}
|
|
|
|
private RealtimeSnapshot BuildDisplaySnapshot(RealtimeSnapshot snapshot)
|
|
{
|
|
if (!_isTestRunning)
|
|
{
|
|
return BuildIdleSnapshot(snapshot);
|
|
}
|
|
|
|
if (!_isRecordingActive)
|
|
{
|
|
return BuildWaitingSnapshot(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)
|
|
{
|
|
var completedSnapshot = _completedSnapshot;
|
|
|
|
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,
|
|
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 = displayInitialMass,
|
|
MassLoss = double.IsFinite(displayInitialMass) ? 0 : double.NaN,
|
|
MassLossRate = 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
|
|
{
|
|
HeatReleaseRate = lastRecord.HeatReleaseRate,
|
|
PeakHeatReleaseRate = lastRecord.PeakHeatReleaseRate,
|
|
TotalHeatRelease = lastRecord.TotalHeatRelease,
|
|
InitialMass = lastRecord.InitialMass,
|
|
MassLoss = lastRecord.MassLoss,
|
|
MassLossRate = lastRecord.MassLossRate,
|
|
IgnitionSeconds = lastRecord.IgnitionSeconds,
|
|
TestSeconds = lastRecord.TestSeconds,
|
|
TotalSmoke = lastRecord.TotalSmoke
|
|
};
|
|
}
|
|
|
|
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;
|
|
_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));
|
|
}
|
|
|
|
private void PublishSnapshot(RealtimeSnapshot snapshot)
|
|
{
|
|
CurrentSnapshot = snapshot;
|
|
SnapshotUpdated?.Invoke(this, snapshot);
|
|
}
|
|
|
|
private RealtimeSnapshot ApplyAccumulatedTotals(RealtimeSnapshot snapshot)
|
|
{
|
|
if (!_isTestRunning)
|
|
{
|
|
return snapshot with
|
|
{
|
|
TotalHeatRelease = double.NaN,
|
|
TotalSmoke = double.NaN
|
|
};
|
|
}
|
|
|
|
if (snapshot.TestSeconds < 0)
|
|
{
|
|
return snapshot with
|
|
{
|
|
TotalHeatRelease = _accumulatedTotalHeatRelease,
|
|
TotalSmoke = _accumulatedTotalSmoke
|
|
};
|
|
}
|
|
|
|
if (!_lastAccumulationSeconds.HasValue)
|
|
{
|
|
_lastAccumulationSeconds = snapshot.TestSeconds;
|
|
return snapshot with
|
|
{
|
|
TotalHeatRelease = _accumulatedTotalHeatRelease,
|
|
TotalSmoke = _accumulatedTotalSmoke
|
|
};
|
|
}
|
|
|
|
var deltaSeconds = snapshot.TestSeconds - _lastAccumulationSeconds.Value;
|
|
if (deltaSeconds > 0)
|
|
{
|
|
if (double.IsFinite(snapshot.HeatReleaseRate))
|
|
{
|
|
_accumulatedTotalHeatRelease += snapshot.HeatReleaseRate * deltaSeconds / TotalHeatReleaseDivisor;
|
|
}
|
|
|
|
if (double.IsFinite(snapshot.SmokeProduction))
|
|
{
|
|
_accumulatedTotalSmoke += snapshot.SmokeProduction * deltaSeconds;
|
|
}
|
|
|
|
_lastAccumulationSeconds = snapshot.TestSeconds;
|
|
}
|
|
|
|
return snapshot with
|
|
{
|
|
TotalHeatRelease = _accumulatedTotalHeatRelease,
|
|
TotalSmoke = _accumulatedTotalSmoke
|
|
};
|
|
}
|
|
|
|
private RealtimeSnapshot ApplyMassLoss(RealtimeSnapshot snapshot)
|
|
{
|
|
if (!_isTestRunning)
|
|
{
|
|
return snapshot with
|
|
{
|
|
InitialMass = _initialMass ?? double.NaN,
|
|
MassLoss = double.NaN,
|
|
MassLossRate = double.NaN
|
|
};
|
|
}
|
|
|
|
if (!_initialMass.HasValue && double.IsFinite(snapshot.CurrentMass))
|
|
{
|
|
_initialMass = snapshot.CurrentMass;
|
|
}
|
|
|
|
var massLoss = _initialMass.HasValue && double.IsFinite(snapshot.CurrentMass)
|
|
? _initialMass.Value - snapshot.CurrentMass
|
|
: double.NaN;
|
|
var massLossRate = CalculateMassLossRate(snapshot);
|
|
|
|
return snapshot with
|
|
{
|
|
InitialMass = _initialMass ?? double.NaN,
|
|
MassLoss = massLoss,
|
|
MassLossRate = massLossRate
|
|
};
|
|
}
|
|
|
|
private double CalculateMassLossRate(RealtimeSnapshot snapshot)
|
|
{
|
|
if (snapshot.TestSeconds < 0 || !double.IsFinite(snapshot.CurrentMass))
|
|
{
|
|
return double.NaN;
|
|
}
|
|
|
|
if (!_previousMass.HasValue || !_previousMassSeconds.HasValue)
|
|
{
|
|
_previousMass = snapshot.CurrentMass;
|
|
_previousMassSeconds = snapshot.TestSeconds;
|
|
return double.NaN;
|
|
}
|
|
|
|
var deltaSeconds = snapshot.TestSeconds - _previousMassSeconds.Value;
|
|
var deltaMass = _previousMass.Value - snapshot.CurrentMass;
|
|
|
|
_previousMass = snapshot.CurrentMass;
|
|
_previousMassSeconds = snapshot.TestSeconds;
|
|
|
|
return deltaSeconds > 0 && deltaMass >= 0
|
|
? deltaMass / deltaSeconds
|
|
: double.NaN;
|
|
}
|
|
|
|
private void ResetComputedState()
|
|
{
|
|
_initialMass = null;
|
|
_previousMass = null;
|
|
_previousMassSeconds = null;
|
|
_accumulatedTotalHeatRelease = 0;
|
|
_accumulatedTotalSmoke = 0;
|
|
_lastAccumulationSeconds = null;
|
|
_lastRecordedSeconds = null;
|
|
}
|
|
}
|