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:
parent
45c759d207
commit
4daa2c9bc2
|
|
@ -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)| {
|
||||||
|
let amps: Vec<f64> = n.frame_history.back()
|
||||||
|
.map(|a| a.iter().take(56).cloned().collect())
|
||||||
|
.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,
|
node_id: id,
|
||||||
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: n.frame_history.back()
|
amplitude: amps,
|
||||||
.map(|a| a.iter().take(56).cloned().collect())
|
phases,
|
||||||
.unwrap_or_default(),
|
subcarrier_count: sub_count,
|
||||||
subcarrier_count: n.frame_history.back().map_or(0, |a| a.len()),
|
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,
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue