Skip to main content

rvcsi_core/
window.rs

1//! The [`CsiWindow`] aggregate — a bounded sequence of frames from one source.
2
3use serde::{Deserialize, Serialize};
4
5use crate::ids::{SessionId, SourceId, WindowId};
6
7/// A bounded window of frames, summarized into per-subcarrier statistics plus
8/// scalar motion / presence / quality scores.
9///
10/// Invariants (enforced by the DSP windowing stage, [`CsiWindow::validate`]):
11/// * all frames came from one `source_id` and one `session_id`
12/// * `start_ns < end_ns`
13/// * `0.0 <= presence_score <= 1.0` and `0.0 <= quality_score <= 1.0`
14/// * `mean_amplitude.len() == phase_variance.len()`
15#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
16pub struct CsiWindow {
17    /// Window id.
18    pub window_id: WindowId,
19    /// Owning session.
20    pub session_id: SessionId,
21    /// Source the frames came from.
22    pub source_id: SourceId,
23    /// Timestamp of the first frame, ns.
24    pub start_ns: u64,
25    /// Timestamp of the last frame, ns.
26    pub end_ns: u64,
27    /// Number of frames aggregated.
28    pub frame_count: u32,
29    /// Mean amplitude per subcarrier.
30    pub mean_amplitude: Vec<f32>,
31    /// Phase variance per subcarrier.
32    pub phase_variance: Vec<f32>,
33    /// Scalar motion energy (>= 0).
34    pub motion_energy: f32,
35    /// Presence score in `[0.0, 1.0]`.
36    pub presence_score: f32,
37    /// Window quality in `[0.0, 1.0]`.
38    pub quality_score: f32,
39}
40
41/// Reasons a [`CsiWindow`] failed its invariants.
42#[derive(Debug, Clone, PartialEq, thiserror::Error)]
43#[non_exhaustive]
44pub enum WindowError {
45    /// `start_ns >= end_ns`.
46    #[error("window start {start_ns} not before end {end_ns}")]
47    BadTimeOrder {
48        /// start
49        start_ns: u64,
50        /// end
51        end_ns: u64,
52    },
53    /// A score escaped `[0, 1]`.
54    #[error("score '{name}' = {value} out of [0,1]")]
55    ScoreOutOfRange {
56        /// which score
57        name: &'static str,
58        /// the value
59        value: f32,
60    },
61    /// `mean_amplitude` and `phase_variance` disagree in length.
62    #[error("stat length mismatch: mean_amplitude={a}, phase_variance={b}")]
63    StatLengthMismatch {
64        /// mean_amplitude length
65        a: usize,
66        /// phase_variance length
67        b: usize,
68    },
69    /// Zero frames in the window.
70    #[error("empty window")]
71    Empty,
72}
73
74impl CsiWindow {
75    /// Duration covered by the window, ns.
76    pub fn duration_ns(&self) -> u64 {
77        self.end_ns.saturating_sub(self.start_ns)
78    }
79
80    /// Number of subcarriers summarized.
81    pub fn subcarrier_count(&self) -> usize {
82        self.mean_amplitude.len()
83    }
84
85    /// Check the aggregate invariants.
86    pub fn validate(&self) -> Result<(), WindowError> {
87        if self.frame_count == 0 {
88            return Err(WindowError::Empty);
89        }
90        if self.start_ns >= self.end_ns {
91            return Err(WindowError::BadTimeOrder {
92                start_ns: self.start_ns,
93                end_ns: self.end_ns,
94            });
95        }
96        if self.mean_amplitude.len() != self.phase_variance.len() {
97            return Err(WindowError::StatLengthMismatch {
98                a: self.mean_amplitude.len(),
99                b: self.phase_variance.len(),
100            });
101        }
102        for (name, v) in [
103            ("presence_score", self.presence_score),
104            ("quality_score", self.quality_score),
105        ] {
106            if !(0.0..=1.0).contains(&v) || !v.is_finite() {
107                return Err(WindowError::ScoreOutOfRange { name, value: v });
108            }
109        }
110        if !self.motion_energy.is_finite() || self.motion_energy < 0.0 {
111            return Err(WindowError::ScoreOutOfRange {
112                name: "motion_energy",
113                value: self.motion_energy,
114            });
115        }
116        Ok(())
117    }
118}
119
120#[cfg(test)]
121mod tests {
122    use super::*;
123
124    fn good() -> CsiWindow {
125        CsiWindow {
126            window_id: WindowId(0),
127            session_id: SessionId(0),
128            source_id: SourceId::from("test"),
129            start_ns: 1_000,
130            end_ns: 2_000,
131            frame_count: 10,
132            mean_amplitude: vec![1.0, 2.0, 3.0],
133            phase_variance: vec![0.1, 0.1, 0.2],
134            motion_energy: 0.5,
135            presence_score: 0.8,
136            quality_score: 0.9,
137        }
138    }
139
140    #[test]
141    fn valid_window_passes() {
142        let w = good();
143        assert!(w.validate().is_ok());
144        assert_eq!(w.duration_ns(), 1_000);
145        assert_eq!(w.subcarrier_count(), 3);
146    }
147
148    #[test]
149    fn rejects_bad_time_order() {
150        let mut w = good();
151        w.end_ns = w.start_ns;
152        assert!(matches!(w.validate(), Err(WindowError::BadTimeOrder { .. })));
153    }
154
155    #[test]
156    fn rejects_out_of_range_score() {
157        let mut w = good();
158        w.presence_score = 1.5;
159        assert!(matches!(w.validate(), Err(WindowError::ScoreOutOfRange { name: "presence_score", .. })));
160        let mut w = good();
161        w.motion_energy = -0.1;
162        assert!(matches!(w.validate(), Err(WindowError::ScoreOutOfRange { name: "motion_energy", .. })));
163    }
164
165    #[test]
166    fn rejects_stat_mismatch_and_empty() {
167        let mut w = good();
168        w.phase_variance.push(0.3);
169        assert!(matches!(w.validate(), Err(WindowError::StatLengthMismatch { .. })));
170        let mut w = good();
171        w.frame_count = 0;
172        assert!(matches!(w.validate(), Err(WindowError::Empty)));
173    }
174}