230 lines
7.6 KiB
Rust
230 lines
7.6 KiB
Rust
//! The normalized [`CsiFrame`] — the FFI-safe boundary object (ADR-095 D5/D6).
|
|
|
|
use serde::{Deserialize, Serialize};
|
|
|
|
use crate::adapter::AdapterKind;
|
|
use crate::ids::{FrameId, SessionId, SourceId};
|
|
|
|
/// Outcome of the validation pipeline for a frame.
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
|
pub enum ValidationStatus {
|
|
/// Not yet validated — set by adapters before [`crate::validate_frame`] runs.
|
|
/// A `Pending` frame must never cross a language boundary.
|
|
Pending,
|
|
/// Passed all checks.
|
|
Accepted,
|
|
/// Usable but with reduced confidence; carries a reason in `quality_reasons`.
|
|
Degraded,
|
|
/// Failed a hard check; quarantined when quarantine is enabled, otherwise dropped.
|
|
Rejected,
|
|
/// Reconstructed during replay or gap-recovery; timestamp monotonicity is waived.
|
|
Recovered,
|
|
}
|
|
|
|
impl ValidationStatus {
|
|
/// Whether a frame with this status may be exposed to SDK/DSP/memory/agents.
|
|
#[inline]
|
|
pub fn is_exposable(self) -> bool {
|
|
matches!(
|
|
self,
|
|
ValidationStatus::Accepted | ValidationStatus::Degraded | ValidationStatus::Recovered
|
|
)
|
|
}
|
|
}
|
|
|
|
/// One CSI observation at a timestamp, normalized across all sources.
|
|
///
|
|
/// Invariants enforced by [`crate::validate_frame`]:
|
|
/// * `i_values.len() == q_values.len() == amplitude.len() == phase.len() == subcarrier_count`
|
|
/// * all of `i_values`/`q_values`/`amplitude`/`phase` are finite
|
|
/// * `subcarrier_count` is within the source's [`crate::AdapterProfile`]
|
|
/// * `rssi_dbm`, when present, is within plausible device bounds
|
|
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
|
pub struct CsiFrame {
|
|
/// Monotonic id within the session.
|
|
pub frame_id: FrameId,
|
|
/// Owning capture session.
|
|
pub session_id: SessionId,
|
|
/// Human-readable source id.
|
|
pub source_id: SourceId,
|
|
/// Which adapter produced this frame.
|
|
pub adapter_kind: AdapterKind,
|
|
/// Source timestamp in nanoseconds.
|
|
pub timestamp_ns: u64,
|
|
/// WiFi channel number.
|
|
pub channel: u16,
|
|
/// Channel bandwidth in MHz (20, 40, 80, 160).
|
|
pub bandwidth_mhz: u16,
|
|
/// Received signal strength, dBm, if reported.
|
|
pub rssi_dbm: Option<i16>,
|
|
/// Noise floor, dBm, if reported.
|
|
pub noise_floor_dbm: Option<i16>,
|
|
/// Receive-antenna index, if reported.
|
|
pub antenna_index: Option<u8>,
|
|
/// Transmit chain index, if reported.
|
|
pub tx_chain: Option<u8>,
|
|
/// Receive chain index, if reported.
|
|
pub rx_chain: Option<u8>,
|
|
/// Number of subcarriers (== length of the four vectors below).
|
|
pub subcarrier_count: u16,
|
|
/// In-phase components, one per subcarrier.
|
|
pub i_values: Vec<f32>,
|
|
/// Quadrature components, one per subcarrier.
|
|
pub q_values: Vec<f32>,
|
|
/// Magnitude `sqrt(i^2 + q^2)`, one per subcarrier.
|
|
pub amplitude: Vec<f32>,
|
|
/// Phase `atan2(q, i)` in radians, one per subcarrier (unwrapped by DSP later).
|
|
pub phase: Vec<f32>,
|
|
/// Validation outcome.
|
|
pub validation: ValidationStatus,
|
|
/// Quality / usability confidence in `[0.0, 1.0]`.
|
|
pub quality_score: f32,
|
|
/// Reasons a frame was degraded (empty when `Accepted`).
|
|
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
|
pub quality_reasons: Vec<String>,
|
|
/// Calibration version this frame was processed against, if any.
|
|
pub calibration_version: Option<String>,
|
|
}
|
|
|
|
impl CsiFrame {
|
|
/// Build a raw (un-validated) frame from interleaved-free I/Q vectors.
|
|
///
|
|
/// `amplitude` and `phase` are derived from `i_values`/`q_values`. The
|
|
/// frame is returned with `validation = Pending` and `quality_score = 0.0`;
|
|
/// run [`crate::validate_frame`] before exposing it.
|
|
#[allow(clippy::too_many_arguments)]
|
|
pub fn from_iq(
|
|
frame_id: FrameId,
|
|
session_id: SessionId,
|
|
source_id: SourceId,
|
|
adapter_kind: AdapterKind,
|
|
timestamp_ns: u64,
|
|
channel: u16,
|
|
bandwidth_mhz: u16,
|
|
i_values: Vec<f32>,
|
|
q_values: Vec<f32>,
|
|
) -> Self {
|
|
let n = i_values.len();
|
|
let mut amplitude = Vec::with_capacity(n);
|
|
let mut phase = Vec::with_capacity(n);
|
|
for (i, q) in i_values.iter().zip(q_values.iter()) {
|
|
amplitude.push((i * i + q * q).sqrt());
|
|
phase.push(q.atan2(*i));
|
|
}
|
|
CsiFrame {
|
|
frame_id,
|
|
session_id,
|
|
source_id,
|
|
adapter_kind,
|
|
timestamp_ns,
|
|
channel,
|
|
bandwidth_mhz,
|
|
rssi_dbm: None,
|
|
noise_floor_dbm: None,
|
|
antenna_index: None,
|
|
tx_chain: None,
|
|
rx_chain: None,
|
|
subcarrier_count: n as u16,
|
|
i_values,
|
|
q_values,
|
|
amplitude,
|
|
phase,
|
|
validation: ValidationStatus::Pending,
|
|
quality_score: 0.0,
|
|
quality_reasons: Vec::new(),
|
|
calibration_version: None,
|
|
}
|
|
}
|
|
|
|
/// Builder-style setter for RSSI.
|
|
pub fn with_rssi(mut self, rssi_dbm: i16) -> Self {
|
|
self.rssi_dbm = Some(rssi_dbm);
|
|
self
|
|
}
|
|
|
|
/// Builder-style setter for noise floor.
|
|
pub fn with_noise_floor(mut self, noise_floor_dbm: i16) -> Self {
|
|
self.noise_floor_dbm = Some(noise_floor_dbm);
|
|
self
|
|
}
|
|
|
|
/// Builder-style setter for antenna / chain metadata.
|
|
pub fn with_chains(mut self, antenna: Option<u8>, tx: Option<u8>, rx: Option<u8>) -> Self {
|
|
self.antenna_index = antenna;
|
|
self.tx_chain = tx;
|
|
self.rx_chain = rx;
|
|
self
|
|
}
|
|
|
|
/// Mean amplitude across subcarriers (0.0 for an empty frame).
|
|
pub fn mean_amplitude(&self) -> f32 {
|
|
if self.amplitude.is_empty() {
|
|
0.0
|
|
} else {
|
|
self.amplitude.iter().sum::<f32>() / self.amplitude.len() as f32
|
|
}
|
|
}
|
|
|
|
/// Whether this frame may be exposed across a language boundary.
|
|
pub fn is_exposable(&self) -> bool {
|
|
self.validation.is_exposable()
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
fn sample() -> CsiFrame {
|
|
CsiFrame::from_iq(
|
|
FrameId(0),
|
|
SessionId(0),
|
|
SourceId::from("test"),
|
|
AdapterKind::File,
|
|
1_000,
|
|
6,
|
|
20,
|
|
vec![3.0, 0.0, -1.0],
|
|
vec![4.0, 2.0, 0.0],
|
|
)
|
|
}
|
|
|
|
#[test]
|
|
fn derives_amplitude_and_phase() {
|
|
let f = sample();
|
|
assert_eq!(f.subcarrier_count, 3);
|
|
assert!((f.amplitude[0] - 5.0).abs() < 1e-6); // 3-4-5 triangle
|
|
assert!((f.amplitude[1] - 2.0).abs() < 1e-6);
|
|
assert!((f.phase[0] - (4.0f32).atan2(3.0)).abs() < 1e-6);
|
|
assert_eq!(f.validation, ValidationStatus::Pending);
|
|
assert_eq!(f.quality_score, 0.0);
|
|
}
|
|
|
|
#[test]
|
|
fn builder_setters_and_mean() {
|
|
let f = sample().with_rssi(-55).with_noise_floor(-92).with_chains(Some(0), None, Some(1));
|
|
assert_eq!(f.rssi_dbm, Some(-55));
|
|
assert_eq!(f.noise_floor_dbm, Some(-92));
|
|
assert_eq!(f.antenna_index, Some(0));
|
|
assert_eq!(f.rx_chain, Some(1));
|
|
assert!((f.mean_amplitude() - (5.0 + 2.0 + 1.0) / 3.0).abs() < 1e-6);
|
|
}
|
|
|
|
#[test]
|
|
fn exposability_rules() {
|
|
assert!(!ValidationStatus::Pending.is_exposable());
|
|
assert!(!ValidationStatus::Rejected.is_exposable());
|
|
assert!(ValidationStatus::Accepted.is_exposable());
|
|
assert!(ValidationStatus::Degraded.is_exposable());
|
|
assert!(ValidationStatus::Recovered.is_exposable());
|
|
}
|
|
|
|
#[test]
|
|
fn frame_json_roundtrips() {
|
|
let f = sample().with_rssi(-60);
|
|
let json = serde_json::to_string(&f).unwrap();
|
|
let back: CsiFrame = serde_json::from_str(&json).unwrap();
|
|
assert_eq!(f, back);
|
|
}
|
|
}
|