feat(adr-112): multi-AP signal_field via MultistaticFuser
signal_field_from_multistatic renders a 20×20 floor-plan heatmap by overlaying isotropic Gaussians at each ESP32 node's configured 3D position, scaled by cv²(fused_amplitude) × cross_node_coherence. Replaces ADR-105 D6's zero grid only when ≥2 nodes are active AND positions are configured (--node-positions); else preserves the zero grid (ADR-105 honesty contract). Honestly framed as a coverage × activity map, not a target-position estimate — commodity ESP32s have no phase-coherent ranging. Verified end-to-end: 320/400 cells non-zero with two live sensors at (1.5,2,1) and (-1.5,2,-1), all-zero on single sensor / no-position deployments. cargo test --workspace passes (313 tests). Co-Authored-By: claude-flow <ruv@ruv.net>
This commit is contained in:
parent
169a589def
commit
c8ac60f6ab
|
|
@ -2015,6 +2015,91 @@ fn generate_signal_field(
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// ADR-112: physically-grounded signal_field for multi-node deployments.
|
||||||
|
///
|
||||||
|
/// When `MultistaticFuser` succeeds with ≥ 2 contributing nodes, render a
|
||||||
|
/// 20×20 spatial heatmap by overlaying isotropic Gaussian "influence"
|
||||||
|
/// kernels at each node's configured position, scaled by the global
|
||||||
|
/// post-fusion activity (CV² of fused amplitude × cross-node coherence).
|
||||||
|
///
|
||||||
|
/// **What this map honestly shows**: regions of overlap between the
|
||||||
|
/// physical coverage zones of active sensors, modulated by how much
|
||||||
|
/// post-fusion CSI activity those sensors collectively see. Bright cells
|
||||||
|
/// = multiple sensors close by AND seeing modulation.
|
||||||
|
///
|
||||||
|
/// **What this map does NOT claim**: the position of a person. We do
|
||||||
|
/// not have phase-coherent ranging on commodity ESP32s (no UWB, no two-
|
||||||
|
/// way ranging), so any "location" rendered would be guessing. The map
|
||||||
|
/// is a *coverage × activity* visualization, deliberately not a
|
||||||
|
/// *target localization*.
|
||||||
|
///
|
||||||
|
/// On `< 2` active nodes or fusion failure, returns the same zero grid
|
||||||
|
/// `generate_signal_field` produces — preserving ADR-105's honesty
|
||||||
|
/// contract.
|
||||||
|
fn signal_field_from_multistatic(
|
||||||
|
fuser: &wifi_densepose_signal::ruvsense::multistatic::MultistaticFuser,
|
||||||
|
node_states: &std::collections::HashMap<u8, NodeState>,
|
||||||
|
) -> SignalField {
|
||||||
|
let grid = 20usize;
|
||||||
|
let zero = || SignalField { grid_size: [grid, 1, grid], values: vec![0.0; grid * grid] };
|
||||||
|
|
||||||
|
let (fused_opt, _) = multistatic_bridge::fuse_or_fallback(fuser, node_states);
|
||||||
|
let fused = match fused_opt {
|
||||||
|
Some(f) if f.active_nodes >= 2 && !f.node_positions.is_empty() => f,
|
||||||
|
_ => return zero(),
|
||||||
|
};
|
||||||
|
|
||||||
|
// Global activity proxy: CV² of fused amplitude × cross-node coherence.
|
||||||
|
// Both factors are in [0, 1]; their product gates the field on the
|
||||||
|
// simultaneous presence of CSI modulation AND inter-node agreement.
|
||||||
|
let amp = &fused.fused_amplitude;
|
||||||
|
if amp.is_empty() { return zero(); }
|
||||||
|
let mean = amp.iter().map(|&v| v as f64).sum::<f64>() / amp.len() as f64;
|
||||||
|
let var: f64 = amp.iter().map(|&v| {
|
||||||
|
let d = v as f64 - mean; d * d
|
||||||
|
}).sum::<f64>() / amp.len() as f64;
|
||||||
|
let cv2 = if mean.abs() > 1e-6 {
|
||||||
|
(var / (mean * mean)).clamp(0.0, 1.0)
|
||||||
|
} else {
|
||||||
|
0.0
|
||||||
|
};
|
||||||
|
let coherence = (fused.cross_node_coherence as f64).clamp(0.0, 1.0);
|
||||||
|
let global_activity = cv2 * coherence;
|
||||||
|
if global_activity < 1e-3 {
|
||||||
|
return zero();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Render in metric room coords. ROOM_EXTENT_M = half-width of the
|
||||||
|
// square room footprint the grid spans; SIGMA_M sets the kernel
|
||||||
|
// radius (Pace's ESPectre uses a similar σ ≈ room/4 heuristic).
|
||||||
|
// The grid spans [-ROOM_EXTENT_M, +ROOM_EXTENT_M] on both axes.
|
||||||
|
const ROOM_EXTENT_M: f64 = 3.0;
|
||||||
|
const SIGMA_M: f64 = ROOM_EXTENT_M / 4.0;
|
||||||
|
let two_sigma2 = 2.0 * SIGMA_M * SIGMA_M;
|
||||||
|
let cell_m = (2.0 * ROOM_EXTENT_M) / grid as f64;
|
||||||
|
|
||||||
|
let mut values = vec![0.0_f64; grid * grid];
|
||||||
|
for gy in 0..grid {
|
||||||
|
let py = -ROOM_EXTENT_M + (gy as f64 + 0.5) * cell_m;
|
||||||
|
for gx in 0..grid {
|
||||||
|
let px = -ROOM_EXTENT_M + (gx as f64 + 0.5) * cell_m;
|
||||||
|
let mut sum = 0.0_f64;
|
||||||
|
for n in &fused.node_positions {
|
||||||
|
// Project the 3D node position to the (x, z) floor plane
|
||||||
|
// (y = height, irrelevant for a 2D footprint view).
|
||||||
|
let nx = n[0] as f64;
|
||||||
|
let nz = n[2] as f64;
|
||||||
|
let dx = px - nx;
|
||||||
|
let dy = py - nz;
|
||||||
|
let d2 = dx * dx + dy * dy;
|
||||||
|
sum += global_activity * (-d2 / two_sigma2).exp();
|
||||||
|
}
|
||||||
|
values[gy * grid + gx] = sum.clamp(0.0, 1.0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
SignalField { grid_size: [grid, 1, grid], values }
|
||||||
|
}
|
||||||
|
|
||||||
// ── Feature extraction from ESP32 frame ──────────────────────────────────────
|
// ── Feature extraction from ESP32 frame ──────────────────────────────────────
|
||||||
|
|
||||||
/// Estimate breathing rate in Hz from the amplitude time series stored in `frame_history`.
|
/// Estimate breathing rate in Hz from the amplitude time series stored in `frame_history`.
|
||||||
|
|
@ -5480,10 +5565,23 @@ async fn udp_receiver_task(state: SharedState, udp_port: u16) {
|
||||||
classification.confidence = conf;
|
classification.confidence = conf;
|
||||||
}
|
}
|
||||||
|
|
||||||
let signal_field = generate_signal_field(
|
// ADR-112: prefer multistatic-derived signal_field
|
||||||
fused_features.mean_rssi, motion_score, vitals.breathing_rate_bpm / 60.0,
|
// when ≥ 2 ESP32 nodes are active; falls back to
|
||||||
(vitals.presence_score as f64).min(1.0), &[],
|
// ADR-105's zero grid on single-sensor / fusion-fail.
|
||||||
);
|
let signal_field = {
|
||||||
|
let multi = signal_field_from_multistatic(
|
||||||
|
&s.multistatic_fuser, &s.node_states,
|
||||||
|
);
|
||||||
|
if multi.values.iter().any(|&v| v > 0.0) {
|
||||||
|
multi
|
||||||
|
} else {
|
||||||
|
generate_signal_field(
|
||||||
|
fused_features.mean_rssi, motion_score,
|
||||||
|
vitals.breathing_rate_bpm / 60.0,
|
||||||
|
(vitals.presence_score as f64).min(1.0), &[],
|
||||||
|
)
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
let mut update = SensingUpdate {
|
let mut update = SensingUpdate {
|
||||||
msg_type: "sensing_update".to_string(),
|
msg_type: "sensing_update".to_string(),
|
||||||
|
|
@ -5817,10 +5915,21 @@ async fn udp_receiver_task(state: SharedState, udp_port: u16) {
|
||||||
nodes: active_nodes,
|
nodes: active_nodes,
|
||||||
features: fused_features.clone(),
|
features: fused_features.clone(),
|
||||||
classification,
|
classification,
|
||||||
signal_field: generate_signal_field(
|
// ADR-112: prefer multistatic spatial map when
|
||||||
fused_features.mean_rssi, motion_score, breathing_rate_hz,
|
// ≥ 2 ESP32 nodes active; else zero grid.
|
||||||
fused_features.variance.min(1.0), &sub_variances,
|
signal_field: {
|
||||||
),
|
let multi = signal_field_from_multistatic(
|
||||||
|
&s.multistatic_fuser, &s.node_states,
|
||||||
|
);
|
||||||
|
if multi.values.iter().any(|&v| v > 0.0) {
|
||||||
|
multi
|
||||||
|
} else {
|
||||||
|
generate_signal_field(
|
||||||
|
fused_features.mean_rssi, motion_score, breathing_rate_hz,
|
||||||
|
fused_features.variance.min(1.0), &sub_variances,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
vital_signs: Some(vitals),
|
vital_signs: Some(vitals),
|
||||||
enhanced_motion: None,
|
enhanced_motion: None,
|
||||||
enhanced_breathing: None,
|
enhanced_breathing: None,
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue