use serde::{Deserialize, Serialize};
use crate::ids::{SessionId, SourceId, WindowId};
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct CsiWindow {
pub window_id: WindowId,
pub session_id: SessionId,
pub source_id: SourceId,
pub start_ns: u64,
pub end_ns: u64,
pub frame_count: u32,
pub mean_amplitude: Vec<f32>,
pub phase_variance: Vec<f32>,
pub motion_energy: f32,
pub presence_score: f32,
pub quality_score: f32,
}
#[derive(Debug, Clone, PartialEq, thiserror::Error)]
#[non_exhaustive]
pub enum WindowError {
#[error("window start {start_ns} not before end {end_ns}")]
BadTimeOrder {
start_ns: u64,
end_ns: u64,
},
#[error("score '{name}' = {value} out of [0,1]")]
ScoreOutOfRange {
name: &'static str,
value: f32,
},
#[error("stat length mismatch: mean_amplitude={a}, phase_variance={b}")]
StatLengthMismatch {
a: usize,
b: usize,
},
#[error("empty window")]
Empty,
}
impl CsiWindow {
pub fn duration_ns(&self) -> u64 {
self.end_ns.saturating_sub(self.start_ns)
}
pub fn subcarrier_count(&self) -> usize {
self.mean_amplitude.len()
}
pub fn validate(&self) -> Result<(), WindowError> {
if self.frame_count == 0 {
return Err(WindowError::Empty);
}
if self.start_ns >= self.end_ns {
return Err(WindowError::BadTimeOrder {
start_ns: self.start_ns,
end_ns: self.end_ns,
});
}
if self.mean_amplitude.len() != self.phase_variance.len() {
return Err(WindowError::StatLengthMismatch {
a: self.mean_amplitude.len(),
b: self.phase_variance.len(),
});
}
for (name, v) in [
("presence_score", self.presence_score),
("quality_score", self.quality_score),
] {
if !(0.0..=1.0).contains(&v) || !v.is_finite() {
return Err(WindowError::ScoreOutOfRange { name, value: v });
}
}
if !self.motion_energy.is_finite() || self.motion_energy < 0.0 {
return Err(WindowError::ScoreOutOfRange {
name: "motion_energy",
value: self.motion_energy,
});
}
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
fn good() -> CsiWindow {
CsiWindow {
window_id: WindowId(0),
session_id: SessionId(0),
source_id: SourceId::from("test"),
start_ns: 1_000,
end_ns: 2_000,
frame_count: 10,
mean_amplitude: vec![1.0, 2.0, 3.0],
phase_variance: vec![0.1, 0.1, 0.2],
motion_energy: 0.5,
presence_score: 0.8,
quality_score: 0.9,
}
}
#[test]
fn valid_window_passes() {
let w = good();
assert!(w.validate().is_ok());
assert_eq!(w.duration_ns(), 1_000);
assert_eq!(w.subcarrier_count(), 3);
}
#[test]
fn rejects_bad_time_order() {
let mut w = good();
w.end_ns = w.start_ns;
assert!(matches!(w.validate(), Err(WindowError::BadTimeOrder { .. })));
}
#[test]
fn rejects_out_of_range_score() {
let mut w = good();
w.presence_score = 1.5;
assert!(matches!(w.validate(), Err(WindowError::ScoreOutOfRange { name: "presence_score", .. })));
let mut w = good();
w.motion_energy = -0.1;
assert!(matches!(w.validate(), Err(WindowError::ScoreOutOfRange { name: "motion_energy", .. })));
}
#[test]
fn rejects_stat_mismatch_and_empty() {
let mut w = good();
w.phase_variance.push(0.3);
assert!(matches!(w.validate(), Err(WindowError::StatLengthMismatch { .. })));
let mut w = good();
w.frame_count = 0;
assert!(matches!(w.validate(), Err(WindowError::Empty)));
}
}