1use serde::{Deserialize, Serialize};
10
11use crate::adapter::AdapterProfile;
12use crate::frame::{CsiFrame, ValidationStatus};
13
14#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
16pub struct ValidationPolicy {
17 pub min_subcarriers: u16,
19 pub max_subcarriers: u16,
21 pub rssi_dbm_bounds: (i16, i16),
23 pub strict_monotonic_time: bool,
26 pub degrade_instead_of_reject: bool,
29 pub min_quality: f32,
32}
33
34impl Default for ValidationPolicy {
35 fn default() -> Self {
36 ValidationPolicy {
37 min_subcarriers: 1,
38 max_subcarriers: 4096,
39 rssi_dbm_bounds: (-110, 0),
40 strict_monotonic_time: false,
41 degrade_instead_of_reject: true,
42 min_quality: 0.25,
43 }
44 }
45}
46
47#[derive(Debug, Clone, PartialEq)]
54pub struct QualityScore {
55 pub value: f32,
57 pub reasons: Vec<String>,
59}
60
61impl QualityScore {
62 fn full() -> Self {
63 QualityScore {
64 value: 1.0,
65 reasons: Vec::new(),
66 }
67 }
68
69 fn penalize(&mut self, factor: f32, reason: impl Into<String>) {
70 self.value = (self.value * factor).clamp(0.0, 1.0);
71 self.reasons.push(reason.into());
72 }
73}
74
75#[derive(Debug, Clone, PartialEq, thiserror::Error)]
77#[non_exhaustive]
78pub enum ValidationError {
79 #[error("vector length mismatch: i={i}, q={q}, amp={amp}, phase={phase}, subcarrier_count={sc}")]
81 LengthMismatch {
82 i: usize,
84 q: usize,
86 amp: usize,
88 phase: usize,
90 sc: usize,
92 },
93 #[error("subcarrier count {count} not allowed (policy {min}..={max}, profile-allowed: {profile_ok})")]
95 SubcarrierCount {
96 count: u16,
98 min: u16,
100 max: u16,
102 profile_ok: bool,
104 },
105 #[error("non-finite value in '{vector}' at index {index}")]
107 NonFinite {
108 vector: &'static str,
110 index: usize,
112 },
113 #[error("implausible RSSI {rssi} dBm (bounds {min}..={max})")]
115 ImplausibleRssi {
116 rssi: i16,
118 min: i16,
120 max: i16,
122 },
123 #[error("non-monotonic timestamp: {ts} <= previous {prev}")]
125 NonMonotonicTime {
126 ts: u64,
128 prev: u64,
130 },
131 #[error("channel {channel} not in source profile")]
133 UnsupportedChannel {
134 channel: u16,
136 },
137 #[error("quality {quality} below minimum {min}")]
139 BelowMinQuality {
140 quality: f32,
142 min: f32,
144 },
145}
146
147const RSSI_HARD_MARGIN: i16 = 30;
150
151pub fn validate_frame(
162 frame: &mut CsiFrame,
163 profile: &AdapterProfile,
164 policy: &ValidationPolicy,
165 prev_timestamp_ns: Option<u64>,
166) -> Result<(), ValidationError> {
167 let sc = frame.subcarrier_count as usize;
169 if frame.i_values.len() != sc
170 || frame.q_values.len() != sc
171 || frame.amplitude.len() != sc
172 || frame.phase.len() != sc
173 {
174 frame.validation = ValidationStatus::Rejected;
175 return Err(ValidationError::LengthMismatch {
176 i: frame.i_values.len(),
177 q: frame.q_values.len(),
178 amp: frame.amplitude.len(),
179 phase: frame.phase.len(),
180 sc,
181 });
182 }
183
184 let profile_ok = profile.accepts_subcarrier_count(frame.subcarrier_count);
185 if frame.subcarrier_count < policy.min_subcarriers
186 || frame.subcarrier_count > policy.max_subcarriers
187 || !profile_ok
188 {
189 frame.validation = ValidationStatus::Rejected;
190 return Err(ValidationError::SubcarrierCount {
191 count: frame.subcarrier_count,
192 min: policy.min_subcarriers,
193 max: policy.max_subcarriers,
194 profile_ok,
195 });
196 }
197
198 for (name, v) in [
199 ("i_values", &frame.i_values),
200 ("q_values", &frame.q_values),
201 ("amplitude", &frame.amplitude),
202 ("phase", &frame.phase),
203 ] {
204 if let Some(idx) = v.iter().position(|x| !x.is_finite()) {
205 frame.validation = ValidationStatus::Rejected;
206 return Err(ValidationError::NonFinite {
207 vector: name,
208 index: idx,
209 });
210 }
211 }
212
213 if !profile.accepts_channel(frame.channel) {
214 frame.validation = ValidationStatus::Rejected;
215 return Err(ValidationError::UnsupportedChannel {
216 channel: frame.channel,
217 });
218 }
219
220 let (rssi_lo, rssi_hi) = policy.rssi_dbm_bounds;
221 if let Some(rssi) = frame.rssi_dbm {
222 if rssi < rssi_lo - RSSI_HARD_MARGIN || rssi > rssi_hi + RSSI_HARD_MARGIN {
223 frame.validation = ValidationStatus::Rejected;
224 return Err(ValidationError::ImplausibleRssi {
225 rssi,
226 min: rssi_lo,
227 max: rssi_hi,
228 });
229 }
230 }
231
232 let mut recovered_time = false;
233 if let Some(prev) = prev_timestamp_ns {
234 if frame.timestamp_ns <= prev {
235 if policy.strict_monotonic_time {
236 frame.validation = ValidationStatus::Rejected;
237 return Err(ValidationError::NonMonotonicTime {
238 ts: frame.timestamp_ns,
239 prev,
240 });
241 }
242 recovered_time = true;
243 }
244 }
245
246 let mut q = QualityScore::full();
248
249 if let Some(rssi) = frame.rssi_dbm {
250 if rssi < rssi_lo || rssi > rssi_hi {
251 q.penalize(0.6, format!("rssi {rssi} dBm outside [{rssi_lo},{rssi_hi}]"));
252 }
253 }
254
255 let dead = frame.amplitude.iter().filter(|a| **a < 1e-6).count();
257 if dead > 0 {
258 let frac = dead as f32 / sc.max(1) as f32;
259 q.penalize((1.0 - frac).max(0.05), format!("{dead}/{sc} dead subcarriers"));
260 }
261
262 if sc >= 3 {
264 let mut sorted: Vec<f32> = frame.amplitude.clone();
265 sorted.sort_by(|a, b| a.partial_cmp(b).unwrap_or(core::cmp::Ordering::Equal));
266 let median = sorted[sc / 2].max(1e-9);
267 let max = *sorted.last().unwrap();
268 if max > median * 50.0 {
269 q.penalize(0.7, format!("amplitude spike: max {max:.3} vs median {median:.3}"));
270 }
271 }
272
273 if frame.rssi_dbm.is_none() {
275 q.penalize(0.95, "missing rssi");
276 }
277
278 let status = if recovered_time {
279 ValidationStatus::Recovered
280 } else if q.value < policy.min_quality {
281 if policy.degrade_instead_of_reject {
282 ValidationStatus::Degraded
283 } else {
284 frame.validation = ValidationStatus::Rejected;
285 return Err(ValidationError::BelowMinQuality {
286 quality: q.value,
287 min: policy.min_quality,
288 });
289 }
290 } else if q.reasons.is_empty() {
291 ValidationStatus::Accepted
292 } else if policy.degrade_instead_of_reject {
293 ValidationStatus::Accepted
295 } else {
296 ValidationStatus::Accepted
297 };
298
299 frame.validation = status;
300 frame.quality_score = q.value;
301 frame.quality_reasons = q.reasons;
302 Ok(())
303}
304
305#[cfg(test)]
306mod tests {
307 use super::*;
308 use crate::adapter::AdapterKind;
309 use crate::ids::{FrameId, SessionId, SourceId};
310
311 fn raw(sc: usize) -> CsiFrame {
312 CsiFrame::from_iq(
313 FrameId(0),
314 SessionId(0),
315 SourceId::from("t"),
316 AdapterKind::File,
317 1_000,
318 6,
319 20,
320 vec![1.0; sc],
321 vec![1.0; sc],
322 )
323 }
324
325 #[test]
326 fn clean_frame_is_accepted_with_perfect_quality() {
327 let mut f = raw(56).with_rssi(-55);
328 validate_frame(&mut f, &AdapterProfile::offline(AdapterKind::File), &ValidationPolicy::default(), None).unwrap();
329 assert_eq!(f.validation, ValidationStatus::Accepted);
330 assert_eq!(f.quality_score, 1.0);
331 assert!(f.quality_reasons.is_empty());
332 assert!(f.is_exposable());
333 }
334
335 #[test]
336 fn missing_rssi_is_a_minor_penalty_not_a_reject() {
337 let mut f = raw(56);
338 validate_frame(&mut f, &AdapterProfile::offline(AdapterKind::File), &ValidationPolicy::default(), None).unwrap();
339 assert_eq!(f.validation, ValidationStatus::Accepted);
340 assert!(f.quality_score < 1.0);
341 assert!(f.quality_reasons.iter().any(|r| r.contains("rssi")));
342 }
343
344 #[test]
345 fn length_mismatch_is_rejected() {
346 let mut f = raw(56);
347 f.q_values.pop();
348 let err = validate_frame(&mut f, &AdapterProfile::offline(AdapterKind::File), &ValidationPolicy::default(), None).unwrap_err();
349 assert!(matches!(err, ValidationError::LengthMismatch { .. }));
350 assert_eq!(f.validation, ValidationStatus::Rejected);
351 assert!(!f.is_exposable());
352 }
353
354 #[test]
355 fn non_finite_is_rejected() {
356 let mut f = raw(4);
357 f.amplitude[2] = f32::NAN;
358 let err = validate_frame(&mut f, &AdapterProfile::offline(AdapterKind::File), &ValidationPolicy::default(), None).unwrap_err();
359 assert!(matches!(err, ValidationError::NonFinite { vector: "amplitude", index: 2 }));
360 }
361
362 #[test]
363 fn subcarrier_count_must_match_profile() {
364 let mut f = raw(57); let err = validate_frame(&mut f, &AdapterProfile::esp32_default(), &ValidationPolicy::default(), None).unwrap_err();
366 assert!(matches!(err, ValidationError::SubcarrierCount { count: 57, .. }));
367 }
368
369 #[test]
370 fn non_monotonic_time_is_recovered_when_lenient_rejected_when_strict() {
371 let mut f = raw(56).with_rssi(-50);
372 validate_frame(&mut f, &AdapterProfile::offline(AdapterKind::File), &ValidationPolicy::default(), Some(2_000)).unwrap();
374 assert_eq!(f.validation, ValidationStatus::Recovered);
375 let mut g = raw(56).with_rssi(-50);
377 let policy = ValidationPolicy { strict_monotonic_time: true, ..Default::default() };
378 let err = validate_frame(&mut g, &AdapterProfile::offline(AdapterKind::File), &policy, Some(2_000)).unwrap_err();
379 assert!(matches!(err, ValidationError::NonMonotonicTime { .. }));
380 }
381
382 #[test]
383 fn dead_subcarriers_degrade_quality() {
384 let mut f = raw(10).with_rssi(-50);
385 for a in f.amplitude.iter_mut().take(8) {
386 *a = 0.0;
387 }
388 validate_frame(&mut f, &AdapterProfile::offline(AdapterKind::File), &ValidationPolicy::default(), None).unwrap();
389 assert!(f.quality_score < 0.5);
390 assert!(f.quality_reasons.iter().any(|r| r.contains("dead subcarriers")));
391 }
392
393 #[test]
394 fn very_low_quality_can_be_degraded_or_rejected() {
395 let mk = || {
397 let mut f = raw(10).with_rssi(-50);
398 for a in f.amplitude.iter_mut().take(9) {
399 *a = 0.0;
400 }
401 f
402 };
403 let mut f = mk();
404 validate_frame(&mut f, &AdapterProfile::offline(AdapterKind::File), &ValidationPolicy::default(), None).unwrap();
405 assert_eq!(f.validation, ValidationStatus::Degraded);
406
407 let mut g = mk();
408 let policy = ValidationPolicy { degrade_instead_of_reject: false, ..Default::default() };
409 let err = validate_frame(&mut g, &AdapterProfile::offline(AdapterKind::File), &policy, None).unwrap_err();
410 assert!(matches!(err, ValidationError::BelowMinQuality { .. }));
411 assert_eq!(g.validation, ValidationStatus::Rejected);
412 }
413
414 #[test]
415 fn implausible_rssi_is_hard_reject() {
416 let mut f = raw(56).with_rssi(50); let err = validate_frame(&mut f, &AdapterProfile::offline(AdapterKind::File), &ValidationPolicy::default(), None).unwrap_err();
418 assert!(matches!(err, ValidationError::ImplausibleRssi { .. }));
419 }
420}