wifi-densepose/v2/crates/wifi-densepose-calibration/src/multistatic.rs

306 lines
10 KiB
Rust

//! Multistatic fusion (ADR-029 / ADR-151) — combine several *co-located* nodes
//! observing one room.
//!
//! More links = more geometric diversity, so a person hidden from one node's
//! line of sight is caught by another. Each node carries its own room-calibrated
//! [`SpecialistBank`] (its own baseline + anchors); this fuses their per-window
//! readings into a single [`RoomState`]:
//!
//! - **presence** — OR across nodes (any node seeing a person wins);
//! - **posture / breathing / heartbeat** — the highest-*confidence* node (best
//! viewpoint for that signal that window);
//! - **restlessness** — max (any node detecting movement);
//! - **anomaly / veto** — max / any (a single implausible node vetoes the room);
//! - **stale** — any node's bank stale flags the fused result.
//!
//! This is *same-room* multistatic. Nodes in *different* rooms are a federation
//! concern (ADR-105), not fusion — see ADR-151 §3.3.
use std::collections::BTreeMap;
use crate::bank::SpecialistBank;
use crate::extract::Features;
use crate::geometry::NodeGeometry;
use crate::runtime::{MixtureOfSpecialists, RoomState};
use crate::specialist::SpecialistReading;
/// A bank plus the node's current baseline id (for per-node staleness).
struct NodeEntry {
mixture: MixtureOfSpecialists,
baseline_id: String,
}
/// Fuses co-located nodes' specialist banks into one room state.
#[derive(Default)]
pub struct MultiNodeMixture {
nodes: BTreeMap<u8, NodeEntry>,
}
impl MultiNodeMixture {
/// Empty fusion set.
pub fn new() -> Self {
Self {
nodes: BTreeMap::new(),
}
}
/// Register a node's bank. `current_baseline_id` is the baseline the node is
/// observing now (drift vs the bank's training baseline → STALE).
pub fn add_node(
&mut self,
node_id: u8,
bank: SpecialistBank,
current_baseline_id: impl Into<String>,
) {
self.nodes.insert(
node_id,
NodeEntry {
mixture: MixtureOfSpecialists::new(bank),
baseline_id: current_baseline_id.into(),
},
);
}
/// Number of registered nodes.
pub fn node_count(&self) -> usize {
self.nodes.len()
}
/// The transceiver-geometry snapshot a node's bank was trained under
/// (ADR-152 §2.1.1), if its enrollment recorded one. Threaded through for
/// the fusion logic; **not used algorithmically yet** — geometry-aware
/// fusion is the §2.1.2 learned-embedding work (ADR-151 P6).
pub fn node_geometry(&self, node_id: u8) -> Option<&[NodeGeometry]> {
self.nodes
.get(&node_id)
.map(|e| e.mixture.bank().geometry.as_slice())
.filter(|g| !g.is_empty())
}
/// All registered nodes' geometry snapshots, keyed by node id. Nodes whose
/// banks carry no geometry are omitted.
pub fn geometries(&self) -> BTreeMap<u8, &[NodeGeometry]> {
self.nodes
.keys()
.filter_map(|&id| self.node_geometry(id).map(|g| (id, g)))
.collect()
}
/// Fuse per-node feature windows into one room state. Nodes without a feature
/// entry this window are skipped.
pub fn infer(&self, per_node: &BTreeMap<u8, Features>) -> RoomState {
let states: Vec<RoomState> = per_node
.iter()
.filter_map(|(id, f)| {
self.nodes
.get(id)
.map(|e| e.mixture.infer(f, &e.baseline_id))
})
.collect();
if states.is_empty() {
return RoomState::default();
}
let presence = fuse_presence(&states);
let anomaly = max_value(states.iter().map(|s| &s.anomaly));
// Conservative: a single node seeing a physically-implausible signal
// vetoes the room (anti-hallucination, same as the single-node runtime).
let vetoed = states.iter().any(|s| s.vetoed);
let present = presence.as_ref().map(|r| r.value > 0.5).unwrap_or(true);
// Vitals/posture only when present and not vetoed.
let (posture, breathing, heartbeat) = if present && !vetoed {
(
best_confidence(states.iter().map(|s| &s.posture)),
best_confidence(states.iter().map(|s| &s.breathing)),
best_confidence(states.iter().map(|s| &s.heartbeat)),
)
} else {
(None, None, None)
};
RoomState {
presence,
posture,
breathing,
heartbeat,
restlessness: max_value(states.iter().map(|s| &s.restlessness)),
anomaly,
vetoed,
stale: states.iter().any(|s| s.stale),
}
}
}
/// Presence: a person is present if ANY node sees one; confidence = max.
fn fuse_presence(states: &[RoomState]) -> Option<SpecialistReading> {
let readings: Vec<&SpecialistReading> =
states.iter().filter_map(|s| s.presence.as_ref()).collect();
if readings.is_empty() {
return None;
}
let any_present = readings.iter().any(|r| r.value > 0.5);
let confidence = readings.iter().map(|r| r.confidence).fold(0.0f32, f32::max);
Some(SpecialistReading {
kind: readings[0].kind,
value: if any_present { 1.0 } else { 0.0 },
confidence,
label: Some(if any_present { "present" } else { "absent" }.into()),
})
}
/// Pick the highest-confidence reading across nodes.
fn best_confidence<'a>(
readings: impl Iterator<Item = &'a Option<SpecialistReading>>,
) -> Option<SpecialistReading> {
readings
.flatten()
.fold(None::<&SpecialistReading>, |best, r| match best {
Some(b) if b.confidence >= r.confidence => Some(b),
_ => Some(r),
})
.cloned()
}
/// Pick the reading with the maximum value across nodes (movement / anomaly).
fn max_value<'a>(
readings: impl Iterator<Item = &'a Option<SpecialistReading>>,
) -> Option<SpecialistReading> {
readings
.flatten()
.fold(None::<&SpecialistReading>, |best, r| match best {
Some(b) if b.value >= r.value => Some(b),
_ => Some(r),
})
.cloned()
}
#[cfg(test)]
mod tests {
use super::*;
use crate::anchor::AnchorLabel;
use crate::extract::AnchorFeature;
fn af(label: AnchorLabel, variance: f32, motion: f32) -> AnchorFeature {
AnchorFeature {
room_id: "r".into(),
label,
features: Features {
mean: 1.0,
variance,
motion,
breathing_score: 0.0,
breathing_hz: 0.0,
heart_score: 0.0,
heart_hz: 0.0,
},
}
}
fn bank(baseline: &str) -> SpecialistBank {
let anchors = vec![
af(AnchorLabel::Empty, 1.0, 0.1),
af(AnchorLabel::StandStill, 10.0, 0.2),
af(AnchorLabel::Sit, 6.0, 0.2),
af(AnchorLabel::SmallMove, 4.0, 1.2),
af(AnchorLabel::SleepPosture, 3.0, 0.1),
];
SpecialistBank::train("r", baseline, &anchors, 1).unwrap()
}
fn live(variance: f32, motion: f32, br_hz: f32, br_score: f32) -> Features {
Features {
mean: 1.0,
variance,
motion,
breathing_score: br_score,
breathing_hz: br_hz,
heart_score: 0.0,
heart_hz: 0.0,
}
}
#[test]
fn two_nodes_register() {
let mut m = MultiNodeMixture::new();
m.add_node(1, bank("b1"), "b1");
m.add_node(2, bank("b2"), "b2");
assert_eq!(m.node_count(), 2);
}
#[test]
fn geometry_threads_through_to_fusion() {
let geo1 = vec![NodeGeometry::new(1, "tape-measure")
.with_position(0.0, 0.0, 1.0)
.with_distance(2, 3.0)];
let mut m = MultiNodeMixture::new();
m.add_node(1, bank("b1").with_geometry(geo1.clone()), "b1");
m.add_node(2, bank("b1"), "b1"); // no geometry recorded for node 2
assert_eq!(m.node_geometry(1), Some(geo1.as_slice()));
assert_eq!(m.node_geometry(2), None, "geometry-free bank reads None");
assert_eq!(m.node_geometry(9), None, "unknown node reads None");
let all = m.geometries();
assert_eq!(all.len(), 1);
assert_eq!(all.get(&1), Some(&geo1.as_slice()));
}
#[test]
fn presence_or_across_nodes() {
let mut m = MultiNodeMixture::new();
m.add_node(1, bank("b1"), "b1");
m.add_node(2, bank("b1"), "b1");
// Node 1 sees nobody (low variance), node 2 sees a person (high variance).
let mut per = BTreeMap::new();
per.insert(1u8, live(1.0, 0.1, 0.0, 0.0));
per.insert(2u8, live(12.0, 0.2, 0.3, 0.9));
let s = m.infer(&per);
assert_eq!(s.presence.unwrap().value, 1.0, "any node present → present");
assert!(s.breathing.is_some());
}
#[test]
fn breathing_picks_best_confidence_node() {
let mut m = MultiNodeMixture::new();
m.add_node(1, bank("b1"), "b1");
m.add_node(2, bank("b1"), "b1");
let mut per = BTreeMap::new();
// Both present; node 2 has the stronger breathing periodicity.
per.insert(1u8, live(12.0, 0.2, 0.2, 0.4));
per.insert(2u8, live(12.0, 0.2, 0.3, 0.95));
let s = m.infer(&per);
let br = s.breathing.unwrap();
assert!((br.value - 18.0).abs() < 0.3, "picked 0.3 Hz node");
assert!(br.confidence > 0.9);
}
#[test]
fn anomaly_in_one_node_vetoes_room() {
let mut m = MultiNodeMixture::new();
m.add_node(1, bank("b1"), "b1");
m.add_node(2, bank("b1"), "b1");
let mut per = BTreeMap::new();
per.insert(1u8, live(12.0, 0.2, 0.3, 0.9));
per.insert(2u8, live(9000.0, 500.0, 0.0, 0.0)); // wild outlier
let s = m.infer(&per);
assert!(s.vetoed);
assert!(s.breathing.is_none());
}
#[test]
fn stale_node_flags_room() {
let mut m = MultiNodeMixture::new();
m.add_node(1, bank("b1"), "b2"); // trained on b1, now observing b2 → stale
let mut per = BTreeMap::new();
per.insert(1u8, live(12.0, 0.2, 0.3, 0.9));
assert!(m.infer(&per).stale);
}
#[test]
fn empty_window_safe() {
let m = MultiNodeMixture::new();
let s = m.infer(&BTreeMap::new());
assert!(s.presence.is_none());
}
}