feat(adr-106): expose full complex CSI in WS NodeInfo (amp+phase+meta)

Operator asked for maximum raw signal off the sensors so a future
trained pose / fine-motion model has everything it needs, instead of
only the amplitude scalar we surfaced before. Adds four fields to
NodeInfo:

  phases: Vec<f64>          per-subcarrier atan2(Q,I), radians
  n_antennas: u8            RX antenna count from WiFi driver
  noise_floor_dbm: i8       noise floor reported by ESP-IDF
  timestamp_us: u64         per-frame µs timestamp from the sensor

Each is `skip_serializing_if = zero-or-empty` so feature_state ticks
(which carry no raw CSI) stay slim in the WS payload — only real raw
CSI frames populate them.

NodeState gains: latest_phases / latest_noise_floor /
latest_n_antennas / latest_timestamp_us (per-node stash, replaces
having to keep a parallel phase_history). The raw-CSI ingest path
populates these on every frame.

Verified live: WS now emits 185 messages over 4 s (~46 fps) with
both amplitude[56] and phases[56] populated; noise_floor reports -91
dBm; n_antennas reports 1 (ESP32-S3 single antenna).
This commit is contained in:
arsen 2026-05-17 11:47:33 +07:00
parent 45c759d207
commit 4daa2c9bc2
1 changed files with 83 additions and 8 deletions

View File

@ -925,10 +925,34 @@ struct NodeInfo {
node_id: u8, node_id: u8,
rssi_dbm: f64, rssi_dbm: f64,
position: [f64; 3], position: [f64; 3],
/// Per-subcarrier amplitude = sqrt(I² + Q²) — primary CSI signal.
amplitude: Vec<f64>, amplitude: Vec<f64>,
/// Per-subcarrier phase in radians = atan2(Q, I). ADR-106: now
/// exposed alongside amplitude so downstream consumers (vital-
/// signs FFT on phase, pose estimation, ML training) have the
/// full complex CSI. Empty when the carrying packet was a
/// feature_state (no raw CSI).
#[serde(default, skip_serializing_if = "Vec::is_empty")]
phases: Vec<f64>,
subcarrier_count: usize, subcarrier_count: usize,
/// Number of receive antennas reported by the WiFi driver
/// (ESP32-S3 typically 1). 0 when the source packet didn't carry it.
#[serde(default, skip_serializing_if = "is_zero_u8")]
n_antennas: u8,
/// Receiver noise floor in dBm. 0 means "not reported".
#[serde(default, skip_serializing_if = "is_zero_i8")]
noise_floor_dbm: i8,
/// Per-frame µs timestamp from the receiving sensor. Lets the
/// server / model align frames across nodes when computing FFTs
/// or cross-correlations. 0 means "not available".
#[serde(default, skip_serializing_if = "is_zero_u64")]
timestamp_us: u64,
} }
fn is_zero_u8(v: &u8) -> bool { *v == 0 }
fn is_zero_i8(v: &i8) -> bool { *v == 0 }
fn is_zero_u64(v: &u64) -> bool { *v == 0 }
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
struct FeatureInfo { struct FeatureInfo {
mean_rssi: f64, mean_rssi: f64,
@ -1007,6 +1031,16 @@ struct NodeState {
edge_vitals: Option<Esp32VitalsPacket>, edge_vitals: Option<Esp32VitalsPacket>,
/// Latest extracted features for cross-node fusion. /// Latest extracted features for cross-node fusion.
latest_features: Option<FeatureInfo>, latest_features: Option<FeatureInfo>,
/// ADR-106: latest per-subcarrier phases (radians, atan2(Q,I)) and
/// noise floor + sensor µs timestamp from the most recent raw CSI
/// frame. Surfaced in `NodeInfo` so downstream consumers
/// (vital-signs FFT on phase, future ML model) get the full
/// complex CSI without re-routing through `frame_history` which
/// is amplitude-only.
latest_phases: Option<Vec<f64>>,
latest_noise_floor: i8,
latest_timestamp_us: u64,
latest_n_antennas: u8,
// ── RuVector Phase 2: Temporal smoothing & coherence gating ── // ── RuVector Phase 2: Temporal smoothing & coherence gating ──
/// Previous frame's smoothed keypoint positions for EMA temporal smoothing. /// Previous frame's smoothed keypoint positions for EMA temporal smoothing.
prev_keypoints: Option<Vec<[f64; 3]>>, prev_keypoints: Option<Vec<[f64; 3]>>,
@ -1069,6 +1103,10 @@ impl NodeState {
last_frame_time: None, last_frame_time: None,
edge_vitals: None, edge_vitals: None,
latest_features: None, latest_features: None,
latest_phases: None,
latest_noise_floor: 0,
latest_timestamp_us: 0,
latest_n_antennas: 0,
prev_keypoints: None, prev_keypoints: None,
motion_energy_history: VecDeque::with_capacity(COHERENCE_WINDOW), motion_energy_history: VecDeque::with_capacity(COHERENCE_WINDOW),
coherence_score: 1.0, // assume stable initially coherence_score: 1.0, // assume stable initially
@ -2528,8 +2566,12 @@ async fn windows_wifi_task(state: SharedState, tick_ms: u64) {
node_id: 0, node_id: 0,
rssi_dbm: first_rssi, rssi_dbm: first_rssi,
position: [0.0, 0.0, 0.0], position: [0.0, 0.0, 0.0],
amplitude: multi_ap_frame.amplitudes, amplitude: multi_ap_frame.amplitudes.clone(),
phases: multi_ap_frame.phases.clone(),
subcarrier_count: obs_count, subcarrier_count: obs_count,
n_antennas: 1,
noise_floor_dbm: 0,
timestamp_us: 0,
}], }],
features, features,
classification, classification,
@ -2668,7 +2710,11 @@ async fn windows_wifi_fallback_tick(state: &SharedState, seq: u32) {
rssi_dbm, rssi_dbm,
position: [0.0, 0.0, 0.0], position: [0.0, 0.0, 0.0],
amplitude: vec![signal_pct], amplitude: vec![signal_pct],
phases: Vec::new(),
subcarrier_count: 1, subcarrier_count: 1,
n_antennas: 0,
noise_floor_dbm: 0,
timestamp_us: 0,
}], }],
features, features,
classification, classification,
@ -4567,7 +4613,11 @@ async fn udp_receiver_task(state: SharedState, udp_port: u16) {
rssi_dbm: n.rssi_history.back().copied().unwrap_or(0.0), rssi_dbm: n.rssi_history.back().copied().unwrap_or(0.0),
position: [2.0, 0.0, 1.5], position: [2.0, 0.0, 1.5],
amplitude: vec![], amplitude: vec![],
phases: vec![],
subcarrier_count: 0, subcarrier_count: 0,
n_antennas: 0,
noise_floor_dbm: 0,
timestamp_us: 0,
}) })
.collect(); .collect();
@ -4752,6 +4802,16 @@ async fn udp_receiver_task(state: SharedState, udp_port: u16) {
ns.frame_history.pop_front(); ns.frame_history.pop_front();
} }
// ADR-106: stash latest raw-CSI metadata (phase,
// noise floor, sensor µs timestamp, antenna count)
// so build_node_features can surface the full
// complex signal in NodeInfo.
if !frame.phases.is_empty() {
ns.latest_phases = Some(frame.phases.clone());
}
ns.latest_noise_floor = frame.noise_floor;
ns.latest_n_antennas = frame.n_antennas;
let sample_rate_hz = 1000.0 / 500.0_f64; let sample_rate_hz = 1000.0 / 500.0_f64;
let (features, mut classification, breathing_rate_hz, sub_variances, raw_motion) = let (features, mut classification, breathing_rate_hz, sub_variances, raw_motion) =
extract_features_from_frame(&frame, &ns.frame_history, sample_rate_hz); extract_features_from_frame(&frame, &ns.frame_history, sample_rate_hz);
@ -4871,14 +4931,25 @@ async fn udp_receiver_task(state: SharedState, udp_port: u16) {
// Build nodes array with all active nodes. // Build nodes array with all active nodes.
let active_nodes: Vec<NodeInfo> = s.node_states.iter() let active_nodes: Vec<NodeInfo> = s.node_states.iter()
.filter(|(_, n)| n.last_frame_time.map_or(false, |t| now.duration_since(t).as_secs() < 10)) .filter(|(_, n)| n.last_frame_time.map_or(false, |t| now.duration_since(t).as_secs() < 10))
.map(|(&id, n)| NodeInfo { .map(|(&id, n)| {
node_id: id, let amps: Vec<f64> = n.frame_history.back()
rssi_dbm: n.rssi_history.back().copied().unwrap_or(0.0),
position: [2.0, 0.0, 1.5],
amplitude: n.frame_history.back()
.map(|a| a.iter().take(56).cloned().collect()) .map(|a| a.iter().take(56).cloned().collect())
.unwrap_or_default(), .unwrap_or_default();
subcarrier_count: n.frame_history.back().map_or(0, |a| a.len()), let phases: Vec<f64> = n.latest_phases.as_ref()
.map(|p| p.iter().take(56).cloned().collect())
.unwrap_or_default();
let sub_count = amps.len();
NodeInfo {
node_id: id,
rssi_dbm: n.rssi_history.back().copied().unwrap_or(0.0),
position: [2.0, 0.0, 1.5],
amplitude: amps,
phases,
subcarrier_count: sub_count,
n_antennas: n.latest_n_antennas,
noise_floor_dbm: n.latest_noise_floor,
timestamp_us: n.latest_timestamp_us,
}
}) })
.collect(); .collect();
@ -5018,7 +5089,11 @@ async fn simulated_data_task(state: SharedState, tick_ms: u64) {
rssi_dbm: features.mean_rssi, rssi_dbm: features.mean_rssi,
position: [2.0, 0.0, 1.5], position: [2.0, 0.0, 1.5],
amplitude: frame_amplitudes, amplitude: frame_amplitudes,
phases: Vec::new(),
subcarrier_count: frame_n_sub as usize, subcarrier_count: frame_n_sub as usize,
n_antennas: 0,
noise_floor_dbm: 0,
timestamp_us: 0,
}], }],
features: features.clone(), features: features.clone(),
classification, classification,