Skip to main content

rvcsi_core/
frame.rs

1//! The normalized [`CsiFrame`] — the FFI-safe boundary object (ADR-095 D5/D6).
2
3use serde::{Deserialize, Serialize};
4
5use crate::adapter::AdapterKind;
6use crate::ids::{FrameId, SessionId, SourceId};
7
8/// Outcome of the validation pipeline for a frame.
9#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
10pub enum ValidationStatus {
11    /// Not yet validated — set by adapters before [`crate::validate_frame`] runs.
12    /// A `Pending` frame must never cross a language boundary.
13    Pending,
14    /// Passed all checks.
15    Accepted,
16    /// Usable but with reduced confidence; carries a reason in `quality_reasons`.
17    Degraded,
18    /// Failed a hard check; quarantined when quarantine is enabled, otherwise dropped.
19    Rejected,
20    /// Reconstructed during replay or gap-recovery; timestamp monotonicity is waived.
21    Recovered,
22}
23
24impl ValidationStatus {
25    /// Whether a frame with this status may be exposed to SDK/DSP/memory/agents.
26    #[inline]
27    pub fn is_exposable(self) -> bool {
28        matches!(
29            self,
30            ValidationStatus::Accepted | ValidationStatus::Degraded | ValidationStatus::Recovered
31        )
32    }
33}
34
35/// One CSI observation at a timestamp, normalized across all sources.
36///
37/// Invariants enforced by [`crate::validate_frame`]:
38/// * `i_values.len() == q_values.len() == amplitude.len() == phase.len() == subcarrier_count`
39/// * all of `i_values`/`q_values`/`amplitude`/`phase` are finite
40/// * `subcarrier_count` is within the source's [`crate::AdapterProfile`]
41/// * `rssi_dbm`, when present, is within plausible device bounds
42#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
43pub struct CsiFrame {
44    /// Monotonic id within the session.
45    pub frame_id: FrameId,
46    /// Owning capture session.
47    pub session_id: SessionId,
48    /// Human-readable source id.
49    pub source_id: SourceId,
50    /// Which adapter produced this frame.
51    pub adapter_kind: AdapterKind,
52    /// Source timestamp in nanoseconds.
53    pub timestamp_ns: u64,
54    /// WiFi channel number.
55    pub channel: u16,
56    /// Channel bandwidth in MHz (20, 40, 80, 160).
57    pub bandwidth_mhz: u16,
58    /// Received signal strength, dBm, if reported.
59    pub rssi_dbm: Option<i16>,
60    /// Noise floor, dBm, if reported.
61    pub noise_floor_dbm: Option<i16>,
62    /// Receive-antenna index, if reported.
63    pub antenna_index: Option<u8>,
64    /// Transmit chain index, if reported.
65    pub tx_chain: Option<u8>,
66    /// Receive chain index, if reported.
67    pub rx_chain: Option<u8>,
68    /// Number of subcarriers (== length of the four vectors below).
69    pub subcarrier_count: u16,
70    /// In-phase components, one per subcarrier.
71    pub i_values: Vec<f32>,
72    /// Quadrature components, one per subcarrier.
73    pub q_values: Vec<f32>,
74    /// Magnitude `sqrt(i^2 + q^2)`, one per subcarrier.
75    pub amplitude: Vec<f32>,
76    /// Phase `atan2(q, i)` in radians, one per subcarrier (unwrapped by DSP later).
77    pub phase: Vec<f32>,
78    /// Validation outcome.
79    pub validation: ValidationStatus,
80    /// Quality / usability confidence in `[0.0, 1.0]`.
81    pub quality_score: f32,
82    /// Reasons a frame was degraded (empty when `Accepted`).
83    #[serde(default, skip_serializing_if = "Vec::is_empty")]
84    pub quality_reasons: Vec<String>,
85    /// Calibration version this frame was processed against, if any.
86    pub calibration_version: Option<String>,
87}
88
89impl CsiFrame {
90    /// Build a raw (un-validated) frame from interleaved-free I/Q vectors.
91    ///
92    /// `amplitude` and `phase` are derived from `i_values`/`q_values`. The
93    /// frame is returned with `validation = Pending` and `quality_score = 0.0`;
94    /// run [`crate::validate_frame`] before exposing it.
95    #[allow(clippy::too_many_arguments)]
96    pub fn from_iq(
97        frame_id: FrameId,
98        session_id: SessionId,
99        source_id: SourceId,
100        adapter_kind: AdapterKind,
101        timestamp_ns: u64,
102        channel: u16,
103        bandwidth_mhz: u16,
104        i_values: Vec<f32>,
105        q_values: Vec<f32>,
106    ) -> Self {
107        let n = i_values.len();
108        let mut amplitude = Vec::with_capacity(n);
109        let mut phase = Vec::with_capacity(n);
110        for (i, q) in i_values.iter().zip(q_values.iter()) {
111            amplitude.push((i * i + q * q).sqrt());
112            phase.push(q.atan2(*i));
113        }
114        CsiFrame {
115            frame_id,
116            session_id,
117            source_id,
118            adapter_kind,
119            timestamp_ns,
120            channel,
121            bandwidth_mhz,
122            rssi_dbm: None,
123            noise_floor_dbm: None,
124            antenna_index: None,
125            tx_chain: None,
126            rx_chain: None,
127            subcarrier_count: n as u16,
128            i_values,
129            q_values,
130            amplitude,
131            phase,
132            validation: ValidationStatus::Pending,
133            quality_score: 0.0,
134            quality_reasons: Vec::new(),
135            calibration_version: None,
136        }
137    }
138
139    /// Builder-style setter for RSSI.
140    pub fn with_rssi(mut self, rssi_dbm: i16) -> Self {
141        self.rssi_dbm = Some(rssi_dbm);
142        self
143    }
144
145    /// Builder-style setter for noise floor.
146    pub fn with_noise_floor(mut self, noise_floor_dbm: i16) -> Self {
147        self.noise_floor_dbm = Some(noise_floor_dbm);
148        self
149    }
150
151    /// Builder-style setter for antenna / chain metadata.
152    pub fn with_chains(mut self, antenna: Option<u8>, tx: Option<u8>, rx: Option<u8>) -> Self {
153        self.antenna_index = antenna;
154        self.tx_chain = tx;
155        self.rx_chain = rx;
156        self
157    }
158
159    /// Mean amplitude across subcarriers (0.0 for an empty frame).
160    pub fn mean_amplitude(&self) -> f32 {
161        if self.amplitude.is_empty() {
162            0.0
163        } else {
164            self.amplitude.iter().sum::<f32>() / self.amplitude.len() as f32
165        }
166    }
167
168    /// Whether this frame may be exposed across a language boundary.
169    pub fn is_exposable(&self) -> bool {
170        self.validation.is_exposable()
171    }
172}
173
174#[cfg(test)]
175mod tests {
176    use super::*;
177
178    fn sample() -> CsiFrame {
179        CsiFrame::from_iq(
180            FrameId(0),
181            SessionId(0),
182            SourceId::from("test"),
183            AdapterKind::File,
184            1_000,
185            6,
186            20,
187            vec![3.0, 0.0, -1.0],
188            vec![4.0, 2.0, 0.0],
189        )
190    }
191
192    #[test]
193    fn derives_amplitude_and_phase() {
194        let f = sample();
195        assert_eq!(f.subcarrier_count, 3);
196        assert!((f.amplitude[0] - 5.0).abs() < 1e-6); // 3-4-5 triangle
197        assert!((f.amplitude[1] - 2.0).abs() < 1e-6);
198        assert!((f.phase[0] - (4.0f32).atan2(3.0)).abs() < 1e-6);
199        assert_eq!(f.validation, ValidationStatus::Pending);
200        assert_eq!(f.quality_score, 0.0);
201    }
202
203    #[test]
204    fn builder_setters_and_mean() {
205        let f = sample().with_rssi(-55).with_noise_floor(-92).with_chains(Some(0), None, Some(1));
206        assert_eq!(f.rssi_dbm, Some(-55));
207        assert_eq!(f.noise_floor_dbm, Some(-92));
208        assert_eq!(f.antenna_index, Some(0));
209        assert_eq!(f.rx_chain, Some(1));
210        assert!((f.mean_amplitude() - (5.0 + 2.0 + 1.0) / 3.0).abs() < 1e-6);
211    }
212
213    #[test]
214    fn exposability_rules() {
215        assert!(!ValidationStatus::Pending.is_exposable());
216        assert!(!ValidationStatus::Rejected.is_exposable());
217        assert!(ValidationStatus::Accepted.is_exposable());
218        assert!(ValidationStatus::Degraded.is_exposable());
219        assert!(ValidationStatus::Recovered.is_exposable());
220    }
221
222    #[test]
223    fn frame_json_roundtrips() {
224        let f = sample().with_rssi(-60);
225        let json = serde_json::to_string(&f).unwrap();
226        let back: CsiFrame = serde_json::from_str(&json).unwrap();
227        assert_eq!(f, back);
228    }
229}