1use anyhow::{bail, Result};
31use clap::Args;
32use ndarray::Array2;
33use num_complex::Complex64;
34use std::time::{Duration, Instant};
35use tokio::net::UdpSocket;
36use wifi_densepose_core::types::{
37 AntennaConfig, CsiFrame, CsiMetadata, DeviceId, FrequencyBand, Timestamp,
38};
39use wifi_densepose_signal::{
40 BaselineCalibration, CalibrationConfig, CalibrationDeviationScore, CalibrationRecorder,
41};
42
43#[derive(Args, Debug, Clone)]
49pub struct CalibrateArgs {
50 #[arg(long, default_value_t = 5005)]
53 pub udp_port: u16,
54
55 #[arg(long, default_value = "0.0.0.0")]
58 pub bind: String,
59
60 #[arg(long, default_value_t = 30)]
64 pub duration_s: u32,
65
66 #[arg(long, default_value = "./baseline.bin")]
68 pub output: String,
69
70 #[arg(long, default_value = "ht20")]
73 pub tier: String,
74
75 #[arg(long, default_value_t = 20)]
78 pub banner_every: u32,
79
80 #[arg(long, default_value_t = 2.0)]
83 pub abort_z_threshold: f32,
84
85 #[arg(long, default_value_t = 0)]
90 pub min_frames: u32,
91}
92
93const RECV_BUF: usize = 2048;
99
100const ABORT_WINDOW_INTERVALS: u32 = 20;
102
103pub async fn execute(args: CalibrateArgs) -> Result<()> {
109 validate_args(&args)?;
110
111 let mut config = tier_config(&args.tier);
112 if args.min_frames > 0 {
113 config.min_frames = args.min_frames;
114 eprintln!(
115 "[calibrate] WARN: --min-frames={} overrides ADR-135 tier default ({} for {}). \
116 This relaxes the phase-concentration guarantee; do not use in production.",
117 args.min_frames, tier_config(&args.tier).min_frames, args.tier
118 );
119 }
120 let target_frames = config.min_frames as usize;
121
122 let addr = format!("{}:{}", args.bind, args.udp_port);
123 let socket = UdpSocket::bind(&addr).await
124 .map_err(|e| anyhow::anyhow!("cannot bind UDP socket on {addr}: {e}"))?;
125
126 eprintln!("[calibrate] listening on udp://{addr}");
127 eprintln!(
128 "[calibrate] capturing {} frames (~{} s, tier={}) — ensure room is empty",
129 target_frames, args.duration_s, args.tier
130 );
131
132 let mut recorder = CalibrationRecorder::new(config);
133 let mut buf = vec![0u8; RECV_BUF];
134 let mut high_z_count: u32 = 0;
135 let deadline = Instant::now() + Duration::from_secs(args.duration_s as u64);
136
137 loop {
138 let remaining = deadline.saturating_duration_since(Instant::now());
139 if remaining.is_zero() {
140 break;
141 }
142
143 let timeout = remaining.min(Duration::from_millis(500));
144 let recv = tokio::time::timeout(timeout, socket.recv(&mut buf)).await;
145
146 let n = match recv {
147 Ok(Ok(n)) => n,
148 Ok(Err(e)) => { eprintln!("[calibrate] recv error: {e}"); continue; }
149 Err(_) => continue, };
151
152 let Some(csi_frame) = parse_csi_packet(&buf[..n], &args.tier) else {
153 continue;
154 };
155
156 let score: CalibrationDeviationScore = match recorder.record(&csi_frame) {
157 Ok(s) => s,
158 Err(e) => { eprintln!("[calibrate] WARN frame skipped: {e}"); continue; }
159 };
160
161 let frames = recorder.frames_recorded() as usize;
162
163 if args.banner_every > 0 && (frames as u32) % args.banner_every == 0 {
164 print_banner(frames, target_frames, &score);
165
166 if args.abort_z_threshold > 0.0 && score.amplitude_z_median > args.abort_z_threshold {
167 high_z_count += 1;
168 if high_z_count >= ABORT_WINDOW_INTERVALS {
169 bail!(
170 "aborted: amplitude_z_median={:.2} exceeded threshold={:.2} for {} \
171 consecutive banner intervals — ensure the room is empty and retry",
172 score.amplitude_z_median, args.abort_z_threshold, high_z_count
173 );
174 }
175 } else {
176 high_z_count = 0;
177 }
178 }
179
180 if frames >= target_frames {
181 break;
182 }
183 }
184
185 finalise_and_save(recorder, &args.output)
186}
187
188fn print_banner(frames: usize, target: usize, score: &CalibrationDeviationScore) {
193 let motion_str = if score.motion_flagged {
194 "YES \u{2190} operator should be still"
195 } else {
196 "no"
197 };
198 eprintln!(
199 "[calibrate] {}/{} frames | z_med={:.2} z_max={:.2} | motion: {}",
200 frames, target, score.amplitude_z_median, score.amplitude_z_max, motion_str
201 );
202}
203
204fn finalise_and_save(recorder: CalibrationRecorder, output: &str) -> Result<()> {
209 let frames = recorder.frames_recorded();
210 eprintln!("[calibrate] finalising baseline from {frames} frames…");
211
212 let baseline: BaselineCalibration = recorder
213 .finalize()
214 .map_err(|e| anyhow::anyhow!("calibration failed: {e}"))?;
215
216 let bytes = baseline.to_bytes();
217 std::fs::write(output, &bytes)
218 .map_err(|e| anyhow::anyhow!("cannot write {output}: {e}"))?;
219
220 eprintln!(
221 "[calibrate] baseline saved to {output} ({} bytes)",
222 bytes.len()
223 );
224 eprintln!(
225 "[calibrate] summary: frames={} tier={:?} subcarriers={}",
226 baseline.frame_count,
227 baseline.tier,
228 baseline.subcarriers.len(),
229 );
230 Ok(())
231}
232
233pub(crate) fn tier_config(tier: &str) -> CalibrationConfig {
238 match tier.to_ascii_lowercase().as_str() {
239 "ht40" => CalibrationConfig::ht40(),
240 "he20" => CalibrationConfig::he20(),
241 "he40" => CalibrationConfig::he40(),
242 _ => CalibrationConfig::ht20(), }
244}
245
246pub(crate) fn parse_csi_packet(buf: &[u8], tier: &str) -> Option<CsiFrame> {
256 if buf.len() < 20 {
257 return None;
258 }
259 let magic = u32::from_le_bytes([buf[0], buf[1], buf[2], buf[3]]);
260 if magic != 0xC511_0001 {
261 return None;
262 }
263
264 let node_id = buf[4];
265 let n_antennas = buf[5] as usize;
266 let n_subcarriers = u16::from_le_bytes([buf[6], buf[7]]) as usize;
269 let freq_mhz = u32::from_le_bytes([buf[8], buf[9], buf[10], buf[11]]);
270 let freq_mhz = u16::try_from(freq_mhz).unwrap_or(0);
271 let _sequence = u32::from_le_bytes([buf[12], buf[13], buf[14], buf[15]]);
272 let rssi = buf[16] as i8;
273 let noise_floor = buf[17] as i8;
274 let _ppdu_type = buf[18]; let n_pairs = n_antennas * n_subcarriers;
277 let iq_start = 20usize;
278 if buf.len() < iq_start + n_pairs * 2 {
279 return None;
280 }
281
282 let mut data = Array2::<Complex64>::zeros((n_antennas.max(1), n_subcarriers.max(1)));
284 for s in 0..n_antennas {
285 for k in 0..n_subcarriers {
286 let idx = s * n_subcarriers + k;
287 let i_val = buf[iq_start + idx * 2] as i8 as f64;
288 let q_val = buf[iq_start + idx * 2 + 1] as i8 as f64;
289 data[[s, k]] = Complex64::new(i_val, q_val);
290 }
291 }
292
293 let band = if freq_mhz >= 5000 {
294 FrequencyBand::Band5GHz
295 } else {
296 FrequencyBand::Band2_4GHz
297 };
298 let bw = tier_to_bw_mhz(tier);
299
300 let mut meta = CsiMetadata::new(
301 DeviceId::new(format!("esp32-node{}", node_id)),
302 band,
303 freq_mhz_to_channel(freq_mhz),
304 );
305 meta.bandwidth_mhz = bw;
306 meta.rssi_dbm = rssi;
307 meta.noise_floor_dbm = noise_floor;
308 meta.antenna_config = AntennaConfig {
309 tx_antennas: 1,
310 rx_antennas: n_antennas as u8,
311 spacing_mm: None,
312 };
313 meta.timestamp = Timestamp::now();
314
315 Some(CsiFrame::new(meta, data))
316}
317
318fn tier_to_bw_mhz(tier: &str) -> u16 {
320 match tier.to_ascii_lowercase().as_str() {
321 "ht40" | "he40" => 40,
322 _ => 20,
323 }
324}
325
326fn freq_mhz_to_channel(freq_mhz: u16) -> u8 {
328 if freq_mhz < 3000 {
330 ((freq_mhz.saturating_sub(2407)) / 5) as u8
331 } else {
332 ((freq_mhz.saturating_sub(5000)) / 5) as u8
334 }
335}
336
337fn validate_args(args: &CalibrateArgs) -> Result<()> {
342 if args.duration_s < 10 {
343 bail!(
344 "--duration-s must be at least 10 s (got {}). \
345 Fewer frames produce unreliable phase-concentration estimates (ADR-135 §2.3).",
346 args.duration_s
347 );
348 }
349 if args.duration_s > 300 {
350 eprintln!(
351 "[calibrate] WARN: --duration-s={} exceeds 300 s; this is unusual.",
352 args.duration_s
353 );
354 }
355 let valid = ["ht20", "ht40", "he20", "he40"];
356 if !valid.contains(&args.tier.to_ascii_lowercase().as_str()) {
357 bail!(
358 "--tier must be one of {:?} (got {:?})",
359 valid, args.tier
360 );
361 }
362 Ok(())
363}
364
365#[cfg(test)]
370mod tests {
371 use super::*;
372
373 #[test]
374 fn test_validate_args_min_duration() {
375 let mut args = default_args();
376 args.duration_s = 5;
377 assert!(validate_args(&args).is_err());
378 }
379
380 #[test]
381 fn test_validate_args_ok() {
382 let args = default_args();
383 assert!(validate_args(&args).is_ok());
384 }
385
386 #[test]
387 fn test_validate_args_bad_tier() {
388 let mut args = default_args();
389 args.tier = "ht80".into();
390 assert!(validate_args(&args).is_err());
391 }
392
393 #[test]
394 fn test_tier_config_ht20() {
395 let cfg = tier_config("ht20");
396 assert_eq!(cfg.num_active, 52);
397 }
398
399 #[test]
400 fn test_tier_config_ht40() {
401 let cfg = tier_config("ht40");
402 assert_eq!(cfg.num_active, 114);
403 }
404
405 #[test]
406 fn test_tier_config_he20() {
407 let cfg = tier_config("he20");
408 assert_eq!(cfg.num_active, 256);
411 }
412
413 #[test]
414 fn test_parse_csi_packet_bad_magic() {
415 let buf = vec![0u8; 32];
416 assert!(parse_csi_packet(&buf, "ht20").is_none());
417 }
418
419 #[test]
420 fn test_parse_csi_packet_too_short() {
421 let buf = vec![0u8; 10];
422 assert!(parse_csi_packet(&buf, "ht20").is_none());
423 }
424
425 fn build_frame(n_subcarriers: u16, ppdu: u8) -> Vec<u8> {
427 let mut buf = vec![0u8; 20 + n_subcarriers as usize * 2];
428 buf[0..4].copy_from_slice(&0xC511_0001u32.to_le_bytes());
429 buf[4] = 12; buf[5] = 1; buf[6..8].copy_from_slice(&n_subcarriers.to_le_bytes());
432 buf[8..12].copy_from_slice(&2432u32.to_le_bytes()); buf[12..16].copy_from_slice(&11610u32.to_le_bytes()); buf[16] = (-40i8) as u8; buf[17] = (-87i8) as u8; buf[18] = ppdu;
437 buf[19] = 0x10; for k in 0..n_subcarriers as usize {
439 buf[20 + k * 2] = (10 + (k % 100) as i8) as u8;
440 buf[20 + k * 2 + 1] = (k % 50) as u8;
441 }
442 buf
443 }
444
445 #[test]
446 fn test_parse_csi_packet_valid() {
447 let buf = build_frame(2, 0);
448 let frame = parse_csi_packet(&buf, "ht20");
449 assert!(frame.is_some());
450 let f = frame.unwrap();
451 assert_eq!(f.num_spatial_streams(), 1);
452 assert_eq!(f.num_subcarriers(), 2);
453 assert_eq!(f.metadata.rssi_dbm, -40);
454 assert_eq!(f.metadata.noise_floor_dbm, -87);
455 }
456
457 #[test]
458 fn test_parse_csi_packet_he_su_256_bins() {
459 let buf = build_frame(256, 1);
462 assert_eq!(buf.len(), 532); let f = parse_csi_packet(&buf, "he20").expect("256-bin HE frame must parse");
464 assert_eq!(f.num_subcarriers(), 256);
465 assert_eq!(f.metadata.rssi_dbm, -40);
466 let mut he = wifi_densepose_signal::CalibrationRecorder::new(tier_config("he20"));
469 assert!(he.record(&f).is_ok());
470 let mut ht = wifi_densepose_signal::CalibrationRecorder::new(tier_config("ht20"));
471 assert!(ht.record(&f).is_err());
472 }
473
474 #[test]
475 fn test_freq_to_channel_24ghz() {
476 assert_eq!(freq_mhz_to_channel(2437), 6);
477 }
478
479 #[test]
480 fn test_freq_to_channel_5ghz() {
481 assert_eq!(freq_mhz_to_channel(5180), 36);
482 }
483
484 fn default_args() -> CalibrateArgs {
485 CalibrateArgs {
486 udp_port: 5005,
487 bind: "0.0.0.0".into(),
488 duration_s: 30,
489 output: "./baseline.bin".into(),
490 tier: "ht20".into(),
491 banner_every: 20,
492 abort_z_threshold: 2.0,
493 min_frames: 0,
494 }
495 }
496}