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,
rssi_dbm: f64,
position: [f64; 3],
/// Per-subcarrier amplitude = sqrt(I² + Q²) — primary CSI signal.
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,
/// 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)]
struct FeatureInfo {
mean_rssi: f64,
@ -1007,6 +1031,16 @@ struct NodeState {
edge_vitals: Option<Esp32VitalsPacket>,
/// Latest extracted features for cross-node fusion.
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 ──
/// Previous frame's smoothed keypoint positions for EMA temporal smoothing.
prev_keypoints: Option<Vec<[f64; 3]>>,
@ -1069,6 +1103,10 @@ impl NodeState {
last_frame_time: None,
edge_vitals: None,
latest_features: None,
latest_phases: None,
latest_noise_floor: 0,
latest_timestamp_us: 0,
latest_n_antennas: 0,
prev_keypoints: None,
motion_energy_history: VecDeque::with_capacity(COHERENCE_WINDOW),
coherence_score: 1.0, // assume stable initially
@ -2528,8 +2566,12 @@ async fn windows_wifi_task(state: SharedState, tick_ms: u64) {
node_id: 0,
rssi_dbm: first_rssi,
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,
n_antennas: 1,
noise_floor_dbm: 0,
timestamp_us: 0,
}],
features,
classification,
@ -2668,7 +2710,11 @@ async fn windows_wifi_fallback_tick(state: &SharedState, seq: u32) {
rssi_dbm,
position: [0.0, 0.0, 0.0],
amplitude: vec![signal_pct],
phases: Vec::new(),
subcarrier_count: 1,
n_antennas: 0,
noise_floor_dbm: 0,
timestamp_us: 0,
}],
features,
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),
position: [2.0, 0.0, 1.5],
amplitude: vec![],
phases: vec![],
subcarrier_count: 0,
n_antennas: 0,
noise_floor_dbm: 0,
timestamp_us: 0,
})
.collect();
@ -4752,6 +4802,16 @@ async fn udp_receiver_task(state: SharedState, udp_port: u16) {
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 (features, mut classification, breathing_rate_hz, sub_variances, raw_motion) =
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.
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))
.map(|(&id, n)| NodeInfo {
node_id: id,
rssi_dbm: n.rssi_history.back().copied().unwrap_or(0.0),
position: [2.0, 0.0, 1.5],
amplitude: n.frame_history.back()
.map(|(&id, n)| {
let amps: Vec<f64> = n.frame_history.back()
.map(|a| a.iter().take(56).cloned().collect())
.unwrap_or_default(),
subcarrier_count: n.frame_history.back().map_or(0, |a| a.len()),
.unwrap_or_default();
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();
@ -5018,7 +5089,11 @@ async fn simulated_data_task(state: SharedState, tick_ms: u64) {
rssi_dbm: features.mean_rssi,
position: [2.0, 0.0, 1.5],
amplitude: frame_amplitudes,
phases: Vec::new(),
subcarrier_count: frame_n_sub as usize,
n_antennas: 0,
noise_floor_dbm: 0,
timestamp_us: 0,
}],
features: features.clone(),
classification,