1use serde::{Deserialize, Serialize};
4
5use crate::ids::{SessionId, SourceId, WindowId};
6
7#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
16pub struct CsiWindow {
17 pub window_id: WindowId,
19 pub session_id: SessionId,
21 pub source_id: SourceId,
23 pub start_ns: u64,
25 pub end_ns: u64,
27 pub frame_count: u32,
29 pub mean_amplitude: Vec<f32>,
31 pub phase_variance: Vec<f32>,
33 pub motion_energy: f32,
35 pub presence_score: f32,
37 pub quality_score: f32,
39}
40
41#[derive(Debug, Clone, PartialEq, thiserror::Error)]
43#[non_exhaustive]
44pub enum WindowError {
45 #[error("window start {start_ns} not before end {end_ns}")]
47 BadTimeOrder {
48 start_ns: u64,
50 end_ns: u64,
52 },
53 #[error("score '{name}' = {value} out of [0,1]")]
55 ScoreOutOfRange {
56 name: &'static str,
58 value: f32,
60 },
61 #[error("stat length mismatch: mean_amplitude={a}, phase_variance={b}")]
63 StatLengthMismatch {
64 a: usize,
66 b: usize,
68 },
69 #[error("empty window")]
71 Empty,
72}
73
74impl CsiWindow {
75 pub fn duration_ns(&self) -> u64 {
77 self.end_ns.saturating_sub(self.start_ns)
78 }
79
80 pub fn subcarrier_count(&self) -> usize {
82 self.mean_amplitude.len()
83 }
84
85 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}