Skip to main content

screen_app/recp/
fps_monitor.rs

1//! M-RECP.2 / AUT-263 — Frame-rate budget instrumentation.
2//!
3//! `FrameRateMonitor` keeps a sliding window of frame timestamps and
4//! emits a `tracing::warn!` when the sustained fps drops below a
5//! threshold. Hysteresis (separate low / high thresholds) prevents
6//! log spam when fps oscillates near the boundary.
7//!
8//! Wired into M-CAM.3's frame pipeline once the pipeline lands.
9
10use std::collections::VecDeque;
11use std::time::Duration;
12
13/// Default warn threshold — emit when sustained fps drops below 24.
14pub const WARN_THRESHOLD_FPS: f64 = 24.0;
15
16/// Default recovery threshold — clear the warn state when fps returns
17/// above 26 (hysteresis prevents log spam).
18pub const RECOVER_THRESHOLD_FPS: f64 = 26.0;
19
20/// Sliding-window frame-rate monitor.
21///
22/// Constructed once per preview pipeline; `observe` is called every
23/// frame with the current wall-clock timestamp. The internal buffer
24/// caps at `capacity` to keep memory bounded.
25#[derive(Debug)]
26pub struct FrameRateMonitor {
27    capacity: usize,
28    window: VecDeque<Duration>,
29    warn_threshold: f64,
30    recover_threshold: f64,
31    /// `true` when we've already emitted the low-fps warning and
32    /// haven't recovered yet. Suppresses repeat warns.
33    in_warn_state: bool,
34}
35
36impl Default for FrameRateMonitor {
37    fn default() -> Self {
38        // 150 frames = 5 seconds at 30 fps.
39        Self::new(150, WARN_THRESHOLD_FPS, RECOVER_THRESHOLD_FPS)
40    }
41}
42
43impl FrameRateMonitor {
44    /// Build a monitor with the given window size + thresholds.
45    #[must_use]
46    pub fn new(capacity: usize, warn_threshold: f64, recover_threshold: f64) -> Self {
47        Self {
48            capacity,
49            window: VecDeque::with_capacity(capacity),
50            warn_threshold,
51            recover_threshold,
52            in_warn_state: false,
53        }
54    }
55
56    /// Record a frame timestamp + return `Some(fps)` when the window
57    /// crosses a threshold and the caller should emit a warn/recover
58    /// log line. `None` for the common case where no transition
59    /// happened.
60    pub fn observe(&mut self, timestamp: Duration) -> Option<Transition> {
61        if self.window.len() == self.capacity {
62            self.window.pop_front();
63        }
64        self.window.push_back(timestamp);
65        if self.window.len() < 2 {
66            return None;
67        }
68        let fps = self.current_fps();
69        if !self.in_warn_state && fps < self.warn_threshold {
70            self.in_warn_state = true;
71            Some(Transition::DroppedBelow(fps))
72        } else if self.in_warn_state && fps >= self.recover_threshold {
73            self.in_warn_state = false;
74            Some(Transition::Recovered(fps))
75        } else {
76            None
77        }
78    }
79
80    /// Snapshot the current sustained fps over the window.
81    #[must_use]
82    pub fn current_fps(&self) -> f64 {
83        if self.window.len() < 2 {
84            return 0.0;
85        }
86        let first = self.window.front().copied().unwrap_or_default();
87        let last = self.window.back().copied().unwrap_or_default();
88        let elapsed = last.saturating_sub(first).as_secs_f64();
89        if elapsed <= 0.0 {
90            return 0.0;
91        }
92        #[allow(
93            clippy::cast_precision_loss,
94            reason = "window length is well under 2^53; lossy conversion is fine"
95        )]
96        let frames = (self.window.len() - 1) as f64;
97        frames / elapsed
98    }
99}
100
101/// Threshold-crossing event from [`FrameRateMonitor::observe`].
102#[derive(Clone, Copy, Debug, PartialEq)]
103pub enum Transition {
104    /// Sustained fps dropped below the warn threshold. Caller should
105    /// emit `tracing::warn!`. `0.0` is the measured fps.
106    DroppedBelow(f64),
107    /// Sustained fps recovered above the recover threshold. Caller
108    /// can emit an `info` log clearing the warning.
109    Recovered(f64),
110}
111
112#[cfg(test)]
113mod tests {
114    use super::*;
115
116    fn ts(ms: u64) -> Duration {
117        Duration::from_millis(ms)
118    }
119
120    #[test]
121    fn default_window_is_five_seconds_at_30_fps() {
122        let m = FrameRateMonitor::default();
123        assert_eq!(m.capacity, 150);
124    }
125
126    #[test]
127    fn single_frame_has_no_fps() {
128        let mut m = FrameRateMonitor::default();
129        assert_eq!(m.observe(ts(0)), None);
130        assert!(m.current_fps().abs() < 1e-6);
131    }
132
133    #[test]
134    fn thirty_fps_steady_state_no_transition() {
135        let mut m = FrameRateMonitor::default();
136        for i in 0..60 {
137            // 33 ms per frame ≈ 30 fps
138            let t = u64::try_from(i).unwrap() * 33;
139            assert_eq!(m.observe(ts(t)), None);
140        }
141        let fps = m.current_fps();
142        assert!((fps - 30.0).abs() < 1.0, "got {fps}");
143    }
144
145    #[test]
146    fn fps_drop_below_threshold_emits_transition() {
147        let mut m = FrameRateMonitor::default();
148        // 100 ms per frame = 10 fps.
149        let mut transition = None;
150        for i in 0..30 {
151            let t = u64::try_from(i).unwrap() * 100;
152            if let Some(tr) = m.observe(ts(t)) {
153                transition = Some(tr);
154            }
155        }
156        assert!(
157            matches!(transition, Some(Transition::DroppedBelow(_))),
158            "expected DroppedBelow, got {transition:?}"
159        );
160    }
161
162    #[test]
163    fn recovery_emits_transition_only_after_warn() {
164        // Use a small window so old low-fps frames evict quickly +
165        // the recovery transition fires inside the test loop.
166        let mut m = FrameRateMonitor::new(30, WARN_THRESHOLD_FPS, RECOVER_THRESHOLD_FPS);
167        // Step 1: 30 frames at 100 ms apart = 10 fps. Fills the
168        // capacity-30 window entirely.
169        for i in 0..30 {
170            let t = u64::try_from(i).unwrap() * 100;
171            m.observe(ts(t));
172        }
173        assert!(m.in_warn_state);
174
175        // Step 2: 200 frames at 16 ms apart starting after the drop
176        // ends — enough to fully push out the slow frames + dominate
177        // the window at high fps.
178        let mut recovered = None;
179        for i in 0..200 {
180            let t = 3000 + u64::try_from(i).unwrap() * 16;
181            if let Some(tr) = m.observe(ts(t)) {
182                recovered = Some(tr);
183            }
184        }
185        assert!(
186            matches!(recovered, Some(Transition::Recovered(_))),
187            "expected Recovered, got {recovered:?}; final fps = {}",
188            m.current_fps()
189        );
190    }
191
192    #[test]
193    fn hysteresis_prevents_repeat_warns() {
194        let mut m = FrameRateMonitor::default();
195        // Drop to low fps.
196        let mut warn_count = 0;
197        for i in 0..200 {
198            let t = u64::try_from(i).unwrap() * 100;
199            if let Some(Transition::DroppedBelow(_)) = m.observe(ts(t)) {
200                warn_count += 1;
201            }
202        }
203        // Even after 200 frames at 10 fps, we should have warned only
204        // once — the second warn requires a recovery in between.
205        assert_eq!(warn_count, 1);
206    }
207}