aboutsummaryrefslogtreecommitdiff
path: root/Hotline/Library/NetSocket/TransferRateEstimator.swift
blob: 60647a716eeff692e95c6de01181377b8145b05f (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
// TransferRateEstimator
// Dustin Mierau • @mierau
// MIT License

import Foundation

/// Transfer rate estimator using exponential moving average (EMA)
///
/// Tracks transfer speed and estimates time remaining. Designed to smooth out
/// network jitter and provide stable estimates after collecting enough samples.
///
/// Example:
/// ```swift
/// var estimator = TransferRateEstimator(total: fileSize)
///
/// while transferring {
///   let chunk = try await receiveData()
///   let progress = estimator.update(bytes: chunk.count)
///   print("Speed: \(progress.bytesPerSecond ?? 0) B/s, ETA: \(progress.estimatedTimeRemaining ?? 0)s")
/// }
/// ```
public struct TransferRateEstimator {
  /// Total bytes to transfer (nil if unknown)
  public let total: Int?
  
  /// Exponential moving average of transfer rate (bytes/second)
  private var emaBytesPerSecond: Double = 0
  
  /// Smoothing factor for EMA (0 < alpha ≤ 1)
  /// Higher = more responsive to recent changes, lower = more smoothing
  private let alpha: Double
  
  /// Number of samples collected
  private var sampleCount: Int = 0
  
  /// Timestamp of first sample (for elapsed time calculation)
  private var startTime: ContinuousClock.Instant?
  
  /// Timestamp of last update (for calculating sample duration)
  private var lastUpdateTime: ContinuousClock.Instant?
  
  /// Minimum elapsed time before trusting estimates (seconds)
  private let minElapsedTime: TimeInterval
  
  /// Minimum number of samples before trusting estimates
  private let minSamples: Int
  
  /// Current number of bytes transferred
  public private(set) var transferred: Int = 0
  
  /// Create a new transfer rate estimator
  ///
  /// - Parameters:
  ///   - total: Total bytes to transfer (nil if unknown)
  ///   - alpha: EMA smoothing factor (default: 0.2). Range: 0.0-1.0
  ///   - minElapsedTime: Minimum elapsed time before estimates are reliable (default: 2.0s)
  ///   - minSamples: Minimum samples before estimates are reliable (default: 4)
  public init(
    total: Int? = nil,
    alpha: Double = 0.2,
    minElapsedTime: TimeInterval = 2.0,
    minSamples: Int = 8
  ) {
    precondition(alpha > 0 && alpha <= 1, "alpha must be in range (0, 1]")
    precondition(minSamples >= 0, "minSamples must be >= 0")
    
    self.total = total
    self.alpha = alpha
    self.minElapsedTime = minElapsedTime
    self.minSamples = minSamples
  }
  
  public mutating func update(total: Int) -> NetSocket.FileProgress {
    return self.update(bytes: max(0, total - self.transferred))
  }
  
  /// Update the estimator with a new data sample
  ///
  /// Automatically calculates the duration since the last update.
  ///
  /// - Parameter bytes: Number of bytes transferred in this sample
  /// - Returns: Current progress with speed and ETA estimates
  public mutating func update(bytes: Int) -> NetSocket.FileProgress {
    let clock = ContinuousClock()
    let now = clock.now
    
    // Record start time on first sample
    if self.startTime == nil {
      self.startTime = now
    }
    
    // Calculate duration since last update
    let duration = self.lastUpdateTime.map { now - $0 } ?? .zero
    self.lastUpdateTime = now
    
    // Update transferred count
    self.transferred += bytes
    
    // Calculate instantaneous rate for this sample
    let seconds: Double = duration / .seconds(1.0)
    if seconds > 0 {
      let instantRate = Double(bytes) / seconds
      self.sampleCount += 1
      
      // Update EMANetSocket
      if self.emaBytesPerSecond == 0 {
        self.emaBytesPerSecond = instantRate
      } else {
        self.emaBytesPerSecond += self.alpha * (instantRate - self.emaBytesPerSecond)
      }
    }
    
    // Determine if we have enough data to trust the estimate
    let elapsed = self.startTime.map { now - $0 } ?? .zero
    let elapsedSeconds: Double = elapsed / .seconds(1.0)
    let haveEstimate = (elapsedSeconds >= self.minElapsedTime || self.sampleCount >= self.minSamples) && self.emaBytesPerSecond > 0
    
    // Calculate ETA if we have both an estimate and a known total
    let eta: TimeInterval?
    if haveEstimate, let total = self.total {
      let remaining = total - self.transferred
      eta = remaining > 0 ? TimeInterval(Double(remaining) / self.emaBytesPerSecond) : 0
    } else {
      eta = nil
    }
    
    return NetSocket.FileProgress(
      sent: self.transferred,
      total: self.total,
      bytesPerSecond: haveEstimate ? self.emaBytesPerSecond : nil,
      estimatedTimeRemaining: eta
    )
  }
}