namespace TravelEar.Core; /// /// Rolling window over Offset samples (REQ-OFFSET-MEASURE): the mod adds one sample per /// Sink frame the Helper reports and reads the average of the last for the /// log line (and, later, the settings row). Single-threaded: the back-pipe reader thread owns it. /// public sealed class OffsetAverager { public const double DefaultWindowMs = 10_000; private readonly Queue<(double At, double Value)> _samples = new(); private double _sum; public OffsetAverager(double windowMs = DefaultWindowMs) { if (windowMs <= 0 || double.IsNaN(windowMs)) throw new ArgumentOutOfRangeException(nameof(windowMs), windowMs, "The window must be positive."); WindowMs = windowMs; } public double WindowMs { get; } /// Samples currently inside the window (after the last or ). public int Count => _samples.Count; // [impl->REQ-OFFSET-MEASURE] /// Adds one Offset sample taken at and drops samples older than the window. public void Add(double offsetMs, double nowMs) { if (double.IsNaN(offsetMs) || double.IsInfinity(offsetMs)) return; _samples.Enqueue((nowMs, offsetMs)); _sum += offsetMs; Trim(nowMs); } // [impl->REQ-OFFSET-MEASURE] /// /// The average, minimum and maximum of the samples inside the window ending at /// . False when the window is empty. /// public bool TryAverage(double nowMs, out double averageMs, out double minMs, out double maxMs, out int count) { Trim(nowMs); count = _samples.Count; if (count == 0) { averageMs = minMs = maxMs = 0; return false; } averageMs = _sum / count; minMs = double.PositiveInfinity; maxMs = double.NegativeInfinity; foreach (var (_, value) in _samples) { if (value < minMs) minMs = value; if (value > maxMs) maxMs = value; } return true; } private void Trim(double nowMs) { while (_samples.Count > 0 && nowMs - _samples.Peek().At > WindowMs) _sum -= _samples.Dequeue().Value; if (_samples.Count == 0) _sum = 0; // no drift accumulates across empty windows } }