feat: Training mode, ADR docs, vitals and wifiscan crates

- Add --train CLI flag with dataset loading, graph transformer training,
  cosine-scheduled SGD, PCK/OKS validation, and checkpoint saving
- Refactor main.rs to import training modules from lib.rs instead of
  duplicating mod declarations
- Add ADR-021 (vital sign detection), ADR-022 (Windows WiFi enhanced
  fidelity), ADR-023 (trained DensePose pipeline) documentation
- Add wifi-densepose-vitals crate: breathing, heartrate, anomaly
  detection, preprocessor, and temporal store
- Add wifi-densepose-wifiscan crate: 8-stage signal intelligence
  pipeline with netsh/wlanapi adapters, multi-BSSID registry,
  attention weighting, spatial correlation, and breathing extraction

Co-Authored-By: claude-flow <ruv@ruv.net>
This commit is contained in:
ruv
2026-02-28 23:50:20 -05:00
parent add9f192aa
commit 3e06970428
37 changed files with 10667 additions and 8 deletions

View File

@@ -0,0 +1,282 @@
//! Core value objects for BSSID identification and observation.
//!
//! These types form the shared kernel of the BSSID Acquisition bounded context
//! as defined in ADR-022 section 3.1.
use std::fmt;
use std::time::Instant;
#[cfg(feature = "serde")]
use serde::{Deserialize, Serialize};
use crate::error::WifiScanError;
// ---------------------------------------------------------------------------
// BssidId -- Value Object
// ---------------------------------------------------------------------------
/// A unique BSSID identifier wrapping a 6-byte IEEE 802.11 MAC address.
///
/// This is the primary identity for access points in the multi-BSSID scanning
/// pipeline. Two `BssidId` values are equal when their MAC bytes match.
#[derive(Clone, Copy, Hash, Eq, PartialEq, Ord, PartialOrd)]
pub struct BssidId(pub [u8; 6]);
impl BssidId {
/// Create a `BssidId` from a byte slice.
///
/// Returns an error if the slice is not exactly 6 bytes.
pub fn from_bytes(bytes: &[u8]) -> Result<Self, WifiScanError> {
let arr: [u8; 6] = bytes
.try_into()
.map_err(|_| WifiScanError::InvalidMac { len: bytes.len() })?;
Ok(Self(arr))
}
/// Parse a `BssidId` from a colon-separated hex string such as
/// `"aa:bb:cc:dd:ee:ff"`.
pub fn parse(s: &str) -> Result<Self, WifiScanError> {
let parts: Vec<&str> = s.split(':').collect();
if parts.len() != 6 {
return Err(WifiScanError::MacParseFailed {
input: s.to_owned(),
});
}
let mut bytes = [0u8; 6];
for (i, part) in parts.iter().enumerate() {
bytes[i] = u8::from_str_radix(part, 16).map_err(|_| WifiScanError::MacParseFailed {
input: s.to_owned(),
})?;
}
Ok(Self(bytes))
}
/// Return the raw 6-byte MAC address.
pub fn as_bytes(&self) -> &[u8; 6] {
&self.0
}
}
impl fmt::Debug for BssidId {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "BssidId({self})")
}
}
impl fmt::Display for BssidId {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
let [a, b, c, d, e, g] = self.0;
write!(f, "{a:02x}:{b:02x}:{c:02x}:{d:02x}:{e:02x}:{g:02x}")
}
}
// ---------------------------------------------------------------------------
// BandType -- Value Object
// ---------------------------------------------------------------------------
/// The WiFi frequency band on which a BSSID operates.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
pub enum BandType {
/// 2.4 GHz (channels 1-14)
Band2_4GHz,
/// 5 GHz (channels 36-177)
Band5GHz,
/// 6 GHz (Wi-Fi 6E / 7)
Band6GHz,
}
impl BandType {
/// Infer the band from an 802.11 channel number.
pub fn from_channel(channel: u8) -> Self {
match channel {
1..=14 => Self::Band2_4GHz,
32..=177 => Self::Band5GHz,
_ => Self::Band6GHz,
}
}
}
impl fmt::Display for BandType {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::Band2_4GHz => write!(f, "2.4 GHz"),
Self::Band5GHz => write!(f, "5 GHz"),
Self::Band6GHz => write!(f, "6 GHz"),
}
}
}
// ---------------------------------------------------------------------------
// RadioType -- Value Object
// ---------------------------------------------------------------------------
/// The 802.11 radio standard reported by the access point.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
pub enum RadioType {
/// 802.11n (Wi-Fi 4)
N,
/// 802.11ac (Wi-Fi 5)
Ac,
/// 802.11ax (Wi-Fi 6 / 6E)
Ax,
/// 802.11be (Wi-Fi 7)
Be,
}
impl RadioType {
/// Parse a radio type from a `netsh` output string such as `"802.11ax"`.
///
/// Returns `None` for unrecognised strings.
pub fn from_netsh_str(s: &str) -> Option<Self> {
let lower = s.trim().to_ascii_lowercase();
if lower.contains("802.11be") || lower.contains("be") {
Some(Self::Be)
} else if lower.contains("802.11ax") || lower.contains("ax") || lower.contains("wi-fi 6")
{
Some(Self::Ax)
} else if lower.contains("802.11ac") || lower.contains("ac") || lower.contains("wi-fi 5")
{
Some(Self::Ac)
} else if lower.contains("802.11n") || lower.contains("wi-fi 4") {
Some(Self::N)
} else {
None
}
}
}
impl fmt::Display for RadioType {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::N => write!(f, "802.11n"),
Self::Ac => write!(f, "802.11ac"),
Self::Ax => write!(f, "802.11ax"),
Self::Be => write!(f, "802.11be"),
}
}
}
// ---------------------------------------------------------------------------
// BssidObservation -- Value Object
// ---------------------------------------------------------------------------
/// A single observation of a BSSID from a WiFi scan.
///
/// This is the fundamental measurement unit: one access point observed once
/// at a specific point in time.
#[derive(Clone, Debug)]
pub struct BssidObservation {
/// The MAC address of the observed access point.
pub bssid: BssidId,
/// Received signal strength in dBm (typically -30 to -90).
pub rssi_dbm: f64,
/// Signal quality as a percentage (0-100), as reported by the driver.
pub signal_pct: f64,
/// The 802.11 channel number.
pub channel: u8,
/// The frequency band.
pub band: BandType,
/// The 802.11 radio standard.
pub radio_type: RadioType,
/// The SSID (network name). May be empty for hidden networks.
pub ssid: String,
/// When this observation was captured.
pub timestamp: Instant,
}
impl BssidObservation {
/// Convert signal percentage (0-100) to an approximate dBm value.
///
/// Uses the common linear mapping: `dBm = (pct / 2) - 100`.
/// This matches the conversion used by Windows WLAN API.
pub fn pct_to_dbm(pct: f64) -> f64 {
(pct / 2.0) - 100.0
}
/// Convert dBm to a linear amplitude suitable for pseudo-CSI frames.
///
/// Formula: `10^((rssi_dbm + 100) / 20)`, mapping -100 dBm to 1.0.
pub fn rssi_to_amplitude(rssi_dbm: f64) -> f64 {
10.0_f64.powf((rssi_dbm + 100.0) / 20.0)
}
/// Return the amplitude of this observation (linear scale).
pub fn amplitude(&self) -> f64 {
Self::rssi_to_amplitude(self.rssi_dbm)
}
/// Encode the channel number as a pseudo-phase value in `[0, pi]`.
///
/// This provides downstream pipeline compatibility with code that expects
/// phase data, even though RSSI-based scanning has no true phase.
pub fn pseudo_phase(&self) -> f64 {
(self.channel as f64 / 48.0) * std::f64::consts::PI
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn bssid_id_roundtrip() {
let mac = [0xaa, 0xbb, 0xcc, 0xdd, 0xee, 0xff];
let id = BssidId(mac);
assert_eq!(id.to_string(), "aa:bb:cc:dd:ee:ff");
assert_eq!(BssidId::parse("aa:bb:cc:dd:ee:ff").unwrap(), id);
}
#[test]
fn bssid_id_parse_errors() {
assert!(BssidId::parse("aa:bb:cc").is_err());
assert!(BssidId::parse("zz:bb:cc:dd:ee:ff").is_err());
assert!(BssidId::parse("").is_err());
}
#[test]
fn bssid_id_from_bytes() {
let bytes = vec![0x01, 0x02, 0x03, 0x04, 0x05, 0x06];
let id = BssidId::from_bytes(&bytes).unwrap();
assert_eq!(id.0, [0x01, 0x02, 0x03, 0x04, 0x05, 0x06]);
assert!(BssidId::from_bytes(&[0x01, 0x02]).is_err());
}
#[test]
fn band_type_from_channel() {
assert_eq!(BandType::from_channel(1), BandType::Band2_4GHz);
assert_eq!(BandType::from_channel(11), BandType::Band2_4GHz);
assert_eq!(BandType::from_channel(36), BandType::Band5GHz);
assert_eq!(BandType::from_channel(149), BandType::Band5GHz);
}
#[test]
fn radio_type_from_netsh() {
assert_eq!(RadioType::from_netsh_str("802.11ax"), Some(RadioType::Ax));
assert_eq!(RadioType::from_netsh_str("802.11ac"), Some(RadioType::Ac));
assert_eq!(RadioType::from_netsh_str("802.11n"), Some(RadioType::N));
assert_eq!(RadioType::from_netsh_str("802.11be"), Some(RadioType::Be));
assert_eq!(RadioType::from_netsh_str("unknown"), None);
}
#[test]
fn pct_to_dbm_conversion() {
// 100% -> -50 dBm
assert!((BssidObservation::pct_to_dbm(100.0) - (-50.0)).abs() < f64::EPSILON);
// 0% -> -100 dBm
assert!((BssidObservation::pct_to_dbm(0.0) - (-100.0)).abs() < f64::EPSILON);
}
#[test]
fn rssi_to_amplitude_baseline() {
// At -100 dBm, amplitude should be 1.0
let amp = BssidObservation::rssi_to_amplitude(-100.0);
assert!((amp - 1.0).abs() < 1e-9);
// At -80 dBm, amplitude should be 10.0
let amp = BssidObservation::rssi_to_amplitude(-80.0);
assert!((amp - 10.0).abs() < 1e-9);
}
}

View File

@@ -0,0 +1,148 @@
//! Multi-AP frame value object.
//!
//! A `MultiApFrame` is a snapshot of all BSSID observations at a single point
//! in time. It serves as the input to the signal intelligence pipeline
//! (Bounded Context 2 in ADR-022), providing the multi-dimensional
//! pseudo-CSI data that replaces the single-RSSI approach.
use std::collections::VecDeque;
use std::time::Instant;
/// A snapshot of all tracked BSSIDs at a single point in time.
///
/// This value object is produced by [`BssidRegistry::to_multi_ap_frame`] and
/// consumed by the signal intelligence pipeline. Each index `i` in the
/// vectors corresponds to the `i`-th entry in the registry's subcarrier map.
///
/// [`BssidRegistry::to_multi_ap_frame`]: crate::domain::registry::BssidRegistry::to_multi_ap_frame
#[derive(Debug, Clone)]
pub struct MultiApFrame {
/// Number of BSSIDs (pseudo-subcarriers) in this frame.
pub bssid_count: usize,
/// RSSI values in dBm, one per BSSID.
///
/// Index matches the subcarrier map ordering.
pub rssi_dbm: Vec<f64>,
/// Linear amplitudes derived from RSSI via `10^((rssi + 100) / 20)`.
///
/// This maps -100 dBm to amplitude 1.0, providing a scale that is
/// compatible with the downstream attention and correlation stages.
pub amplitudes: Vec<f64>,
/// Pseudo-phase values derived from channel numbers.
///
/// Encoded as `(channel / 48) * pi`, giving a value in `[0, pi]`.
/// This is a heuristic that provides spatial diversity information
/// to pipeline stages that expect phase data.
pub phases: Vec<f64>,
/// Per-BSSID RSSI variance (Welford), one per BSSID.
///
/// High variance indicates a BSSID whose signal is modulated by body
/// movement; low variance indicates a static background AP.
pub per_bssid_variance: Vec<f64>,
/// Per-BSSID RSSI history (ring buffer), one per BSSID.
///
/// Used by the spatial correlator and breathing extractor to compute
/// cross-correlation and spectral features.
pub histories: Vec<VecDeque<f64>>,
/// Estimated effective sample rate in Hz.
///
/// Tier 1 (netsh): approximately 2 Hz.
/// Tier 2 (wlanapi): approximately 10-20 Hz.
pub sample_rate_hz: f64,
/// When this frame was constructed.
pub timestamp: Instant,
}
impl MultiApFrame {
/// Whether this frame has enough BSSIDs for multi-AP sensing.
///
/// The `min_bssids` parameter comes from `WindowsWifiConfig::min_bssids`.
pub fn is_sufficient(&self, min_bssids: usize) -> bool {
self.bssid_count >= min_bssids
}
/// The maximum amplitude across all BSSIDs. Returns 0.0 for empty frames.
pub fn max_amplitude(&self) -> f64 {
self.amplitudes
.iter()
.copied()
.fold(0.0_f64, f64::max)
}
/// The mean RSSI across all BSSIDs in dBm. Returns `f64::NEG_INFINITY`
/// for empty frames.
pub fn mean_rssi(&self) -> f64 {
if self.rssi_dbm.is_empty() {
return f64::NEG_INFINITY;
}
let sum: f64 = self.rssi_dbm.iter().sum();
sum / self.rssi_dbm.len() as f64
}
/// The total variance across all BSSIDs (sum of per-BSSID variances).
///
/// Higher values indicate more environmental change, which correlates
/// with human presence and movement.
pub fn total_variance(&self) -> f64 {
self.per_bssid_variance.iter().sum()
}
}
#[cfg(test)]
mod tests {
use super::*;
fn make_frame(bssid_count: usize, rssi_values: &[f64]) -> MultiApFrame {
let amplitudes: Vec<f64> = rssi_values
.iter()
.map(|&r| 10.0_f64.powf((r + 100.0) / 20.0))
.collect();
MultiApFrame {
bssid_count,
rssi_dbm: rssi_values.to_vec(),
amplitudes,
phases: vec![0.0; bssid_count],
per_bssid_variance: vec![0.1; bssid_count],
histories: vec![VecDeque::new(); bssid_count],
sample_rate_hz: 2.0,
timestamp: Instant::now(),
}
}
#[test]
fn is_sufficient_checks_threshold() {
let frame = make_frame(5, &[-60.0, -65.0, -70.0, -75.0, -80.0]);
assert!(frame.is_sufficient(3));
assert!(frame.is_sufficient(5));
assert!(!frame.is_sufficient(6));
}
#[test]
fn mean_rssi_calculation() {
let frame = make_frame(3, &[-60.0, -70.0, -80.0]);
assert!((frame.mean_rssi() - (-70.0)).abs() < 1e-9);
}
#[test]
fn empty_frame_handles_gracefully() {
let frame = make_frame(0, &[]);
assert_eq!(frame.max_amplitude(), 0.0);
assert!(frame.mean_rssi().is_infinite());
assert_eq!(frame.total_variance(), 0.0);
assert!(!frame.is_sufficient(1));
}
#[test]
fn total_variance_sums_per_bssid() {
let mut frame = make_frame(3, &[-60.0, -70.0, -80.0]);
frame.per_bssid_variance = vec![0.1, 0.2, 0.3];
assert!((frame.total_variance() - 0.6).abs() < 1e-9);
}
}

View File

@@ -0,0 +1,11 @@
//! Domain types for the BSSID Acquisition bounded context (ADR-022).
pub mod bssid;
pub mod frame;
pub mod registry;
pub mod result;
pub use bssid::{BandType, BssidId, BssidObservation, RadioType};
pub use frame::MultiApFrame;
pub use registry::{BssidEntry, BssidMeta, BssidRegistry, RunningStats};
pub use result::EnhancedSensingResult;

View File

@@ -0,0 +1,511 @@
//! BSSID Registry aggregate root.
//!
//! The `BssidRegistry` is the aggregate root of the BSSID Acquisition bounded
//! context. It tracks all visible access points across scans, maintains
//! identity stability as BSSIDs appear and disappear, and provides a
//! consistent subcarrier mapping for pseudo-CSI frame construction.
use std::collections::HashMap;
use std::collections::VecDeque;
use std::time::Instant;
use crate::domain::bssid::{BandType, BssidId, BssidObservation, RadioType};
use crate::domain::frame::MultiApFrame;
// ---------------------------------------------------------------------------
// RunningStats -- Welford online statistics
// ---------------------------------------------------------------------------
/// Welford online algorithm for computing running mean and variance.
///
/// This allows us to compute per-BSSID statistics incrementally without
/// storing the entire history, which is essential for detecting which BSSIDs
/// show body-correlated variance versus static background.
#[derive(Debug, Clone)]
pub struct RunningStats {
/// Number of samples seen.
count: u64,
/// Running mean.
mean: f64,
/// Running M2 accumulator (sum of squared differences from the mean).
m2: f64,
}
impl RunningStats {
/// Create a new empty `RunningStats`.
pub fn new() -> Self {
Self {
count: 0,
mean: 0.0,
m2: 0.0,
}
}
/// Push a new sample into the running statistics.
pub fn push(&mut self, value: f64) {
self.count += 1;
let delta = value - self.mean;
self.mean += delta / self.count as f64;
let delta2 = value - self.mean;
self.m2 += delta * delta2;
}
/// The number of samples observed.
pub fn count(&self) -> u64 {
self.count
}
/// The running mean. Returns 0.0 if no samples have been pushed.
pub fn mean(&self) -> f64 {
self.mean
}
/// The population variance. Returns 0.0 if fewer than 2 samples.
pub fn variance(&self) -> f64 {
if self.count < 2 {
0.0
} else {
self.m2 / self.count as f64
}
}
/// The sample variance (Bessel-corrected). Returns 0.0 if fewer than 2 samples.
pub fn sample_variance(&self) -> f64 {
if self.count < 2 {
0.0
} else {
self.m2 / (self.count - 1) as f64
}
}
/// The population standard deviation.
pub fn std_dev(&self) -> f64 {
self.variance().sqrt()
}
/// Reset all statistics to zero.
pub fn reset(&mut self) {
self.count = 0;
self.mean = 0.0;
self.m2 = 0.0;
}
}
impl Default for RunningStats {
fn default() -> Self {
Self::new()
}
}
// ---------------------------------------------------------------------------
// BssidMeta -- metadata about a tracked BSSID
// ---------------------------------------------------------------------------
/// Static metadata about a tracked BSSID, captured on first observation.
#[derive(Debug, Clone)]
pub struct BssidMeta {
/// The SSID (network name). May be empty for hidden networks.
pub ssid: String,
/// The 802.11 channel number.
pub channel: u8,
/// The frequency band.
pub band: BandType,
/// The radio standard.
pub radio_type: RadioType,
/// When this BSSID was first observed.
pub first_seen: Instant,
}
// ---------------------------------------------------------------------------
// BssidEntry -- Entity
// ---------------------------------------------------------------------------
/// A tracked BSSID with observation history and running statistics.
///
/// Each entry corresponds to one physical access point. The ring buffer
/// stores recent RSSI values (in dBm) for temporal analysis, while the
/// `RunningStats` provides efficient online mean/variance without needing
/// the full history.
#[derive(Debug, Clone)]
pub struct BssidEntry {
/// The unique identifier for this BSSID.
pub id: BssidId,
/// Static metadata (SSID, channel, band, radio type).
pub meta: BssidMeta,
/// Ring buffer of recent RSSI observations (dBm).
pub history: VecDeque<f64>,
/// Welford online statistics over the full observation lifetime.
pub stats: RunningStats,
/// When this BSSID was last observed.
pub last_seen: Instant,
/// Index in the subcarrier map, or `None` if not yet assigned.
pub subcarrier_idx: Option<usize>,
}
impl BssidEntry {
/// Maximum number of RSSI samples kept in the ring buffer history.
pub const DEFAULT_HISTORY_CAPACITY: usize = 128;
/// Create a new entry from a first observation.
fn new(obs: &BssidObservation) -> Self {
let mut stats = RunningStats::new();
stats.push(obs.rssi_dbm);
let mut history = VecDeque::with_capacity(Self::DEFAULT_HISTORY_CAPACITY);
history.push_back(obs.rssi_dbm);
Self {
id: obs.bssid,
meta: BssidMeta {
ssid: obs.ssid.clone(),
channel: obs.channel,
band: obs.band,
radio_type: obs.radio_type,
first_seen: obs.timestamp,
},
history,
stats,
last_seen: obs.timestamp,
subcarrier_idx: None,
}
}
/// Record a new observation for this BSSID.
fn record(&mut self, obs: &BssidObservation) {
self.stats.push(obs.rssi_dbm);
if self.history.len() >= Self::DEFAULT_HISTORY_CAPACITY {
self.history.pop_front();
}
self.history.push_back(obs.rssi_dbm);
self.last_seen = obs.timestamp;
// Update mutable metadata in case the AP changed channel/band
self.meta.channel = obs.channel;
self.meta.band = obs.band;
self.meta.radio_type = obs.radio_type;
if !obs.ssid.is_empty() {
self.meta.ssid = obs.ssid.clone();
}
}
/// The RSSI variance over the observation lifetime (Welford).
pub fn variance(&self) -> f64 {
self.stats.variance()
}
/// The most recent RSSI observation in dBm.
pub fn latest_rssi(&self) -> Option<f64> {
self.history.back().copied()
}
}
// ---------------------------------------------------------------------------
// BssidRegistry -- Aggregate Root
// ---------------------------------------------------------------------------
/// Aggregate root that tracks all visible BSSIDs across scans.
///
/// The registry maintains:
/// - A map of known BSSIDs with per-BSSID history and statistics.
/// - An ordered subcarrier map that assigns each BSSID a stable index,
/// sorted by first-seen time so that the mapping is deterministic.
/// - Expiry logic to remove BSSIDs that have not been observed recently.
#[derive(Debug, Clone)]
pub struct BssidRegistry {
/// Known BSSIDs with sliding window of observations.
entries: HashMap<BssidId, BssidEntry>,
/// Ordered list of BSSID IDs for consistent subcarrier mapping.
/// Sorted by first-seen time for stability.
subcarrier_map: Vec<BssidId>,
/// Maximum number of tracked BSSIDs (maps to max pseudo-subcarriers).
max_bssids: usize,
/// How long a BSSID can go unseen before being expired (in seconds).
expiry_secs: u64,
}
impl BssidRegistry {
/// Default maximum number of tracked BSSIDs.
pub const DEFAULT_MAX_BSSIDS: usize = 32;
/// Default expiry time in seconds.
pub const DEFAULT_EXPIRY_SECS: u64 = 30;
/// Create a new registry with the given capacity and expiry settings.
pub fn new(max_bssids: usize, expiry_secs: u64) -> Self {
Self {
entries: HashMap::with_capacity(max_bssids),
subcarrier_map: Vec::with_capacity(max_bssids),
max_bssids,
expiry_secs,
}
}
/// Update the registry with a batch of observations from a single scan.
///
/// New BSSIDs are registered and assigned subcarrier indices. Existing
/// BSSIDs have their history and statistics updated. BSSIDs that have
/// not been seen within the expiry window are removed.
pub fn update(&mut self, observations: &[BssidObservation]) {
let now = if let Some(obs) = observations.first() {
obs.timestamp
} else {
return;
};
// Update or insert each observed BSSID
for obs in observations {
if let Some(entry) = self.entries.get_mut(&obs.bssid) {
entry.record(obs);
} else if self.subcarrier_map.len() < self.max_bssids {
// New BSSID: register it
let mut entry = BssidEntry::new(obs);
let idx = self.subcarrier_map.len();
entry.subcarrier_idx = Some(idx);
self.subcarrier_map.push(obs.bssid);
self.entries.insert(obs.bssid, entry);
}
// If we are at capacity, silently ignore new BSSIDs.
// A smarter policy (evict lowest-variance) can be added later.
}
// Expire stale BSSIDs
self.expire(now);
}
/// Remove BSSIDs that have not been observed within the expiry window.
fn expire(&mut self, now: Instant) {
let expiry = std::time::Duration::from_secs(self.expiry_secs);
let stale: Vec<BssidId> = self
.entries
.iter()
.filter(|(_, entry)| now.duration_since(entry.last_seen) > expiry)
.map(|(id, _)| *id)
.collect();
for id in &stale {
self.entries.remove(id);
}
if !stale.is_empty() {
// Rebuild the subcarrier map without the stale entries,
// preserving relative ordering.
self.subcarrier_map.retain(|id| !stale.contains(id));
// Re-index remaining entries
for (idx, id) in self.subcarrier_map.iter().enumerate() {
if let Some(entry) = self.entries.get_mut(id) {
entry.subcarrier_idx = Some(idx);
}
}
}
}
/// Look up the subcarrier index assigned to a BSSID.
pub fn subcarrier_index(&self, bssid: &BssidId) -> Option<usize> {
self.entries
.get(bssid)
.and_then(|entry| entry.subcarrier_idx)
}
/// Return the ordered subcarrier map (list of BSSID IDs).
pub fn subcarrier_map(&self) -> &[BssidId] {
&self.subcarrier_map
}
/// The number of currently tracked BSSIDs.
pub fn len(&self) -> usize {
self.entries.len()
}
/// Whether the registry is empty.
pub fn is_empty(&self) -> bool {
self.entries.is_empty()
}
/// The maximum number of BSSIDs this registry can track.
pub fn capacity(&self) -> usize {
self.max_bssids
}
/// Get an entry by BSSID ID.
pub fn get(&self, bssid: &BssidId) -> Option<&BssidEntry> {
self.entries.get(bssid)
}
/// Iterate over all tracked entries.
pub fn entries(&self) -> impl Iterator<Item = &BssidEntry> {
self.entries.values()
}
/// Build a `MultiApFrame` from the current registry state.
///
/// The frame contains one slot per subcarrier (BSSID), with amplitudes
/// derived from the most recent RSSI observation and pseudo-phase from
/// the channel number.
pub fn to_multi_ap_frame(&self) -> MultiApFrame {
let n = self.subcarrier_map.len();
let mut rssi_dbm = vec![0.0_f64; n];
let mut amplitudes = vec![0.0_f64; n];
let mut phases = vec![0.0_f64; n];
let mut per_bssid_variance = vec![0.0_f64; n];
let mut histories: Vec<VecDeque<f64>> = Vec::with_capacity(n);
for (idx, bssid_id) in self.subcarrier_map.iter().enumerate() {
if let Some(entry) = self.entries.get(bssid_id) {
let latest = entry.latest_rssi().unwrap_or(-100.0);
rssi_dbm[idx] = latest;
amplitudes[idx] = BssidObservation::rssi_to_amplitude(latest);
phases[idx] = (entry.meta.channel as f64 / 48.0) * std::f64::consts::PI;
per_bssid_variance[idx] = entry.variance();
histories.push(entry.history.clone());
} else {
histories.push(VecDeque::new());
}
}
// Estimate sample rate from observation count and time span
let sample_rate_hz = self.estimate_sample_rate();
MultiApFrame {
bssid_count: n,
rssi_dbm,
amplitudes,
phases,
per_bssid_variance,
histories,
sample_rate_hz,
timestamp: Instant::now(),
}
}
/// Rough estimate of the effective sample rate based on observation history.
fn estimate_sample_rate(&self) -> f64 {
// Default to 2 Hz (Tier 1 netsh rate) when we cannot compute
if self.entries.is_empty() {
return 2.0;
}
// Use the first entry with enough history
for entry in self.entries.values() {
if entry.stats.count() >= 4 {
let elapsed = entry
.last_seen
.duration_since(entry.meta.first_seen)
.as_secs_f64();
if elapsed > 0.0 {
return entry.stats.count() as f64 / elapsed;
}
}
}
2.0 // Fallback: assume Tier 1 rate
}
}
impl Default for BssidRegistry {
fn default() -> Self {
Self::new(Self::DEFAULT_MAX_BSSIDS, Self::DEFAULT_EXPIRY_SECS)
}
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
#[cfg(test)]
mod tests {
use super::*;
use crate::domain::bssid::{BandType, RadioType};
fn make_obs(mac: [u8; 6], rssi: f64, channel: u8) -> BssidObservation {
BssidObservation {
bssid: BssidId(mac),
rssi_dbm: rssi,
signal_pct: (rssi + 100.0) * 2.0,
channel,
band: BandType::from_channel(channel),
radio_type: RadioType::Ax,
ssid: "TestNetwork".to_string(),
timestamp: Instant::now(),
}
}
#[test]
fn registry_tracks_new_bssids() {
let mut reg = BssidRegistry::default();
let obs = vec![
make_obs([0x01; 6], -60.0, 6),
make_obs([0x02; 6], -70.0, 36),
];
reg.update(&obs);
assert_eq!(reg.len(), 2);
assert_eq!(reg.subcarrier_index(&BssidId([0x01; 6])), Some(0));
assert_eq!(reg.subcarrier_index(&BssidId([0x02; 6])), Some(1));
}
#[test]
fn registry_updates_existing_bssid() {
let mut reg = BssidRegistry::default();
let mac = [0xaa; 6];
let obs1 = vec![make_obs(mac, -60.0, 6)];
reg.update(&obs1);
let obs2 = vec![make_obs(mac, -65.0, 6)];
reg.update(&obs2);
let entry = reg.get(&BssidId(mac)).unwrap();
assert_eq!(entry.stats.count(), 2);
assert_eq!(entry.history.len(), 2);
assert!((entry.stats.mean() - (-62.5)).abs() < 1e-9);
}
#[test]
fn registry_respects_capacity() {
let mut reg = BssidRegistry::new(2, 30);
let obs = vec![
make_obs([0x01; 6], -60.0, 1),
make_obs([0x02; 6], -70.0, 6),
make_obs([0x03; 6], -80.0, 11), // Should be ignored
];
reg.update(&obs);
assert_eq!(reg.len(), 2);
assert!(reg.get(&BssidId([0x03; 6])).is_none());
}
#[test]
fn to_multi_ap_frame_builds_correct_frame() {
let mut reg = BssidRegistry::default();
let obs = vec![
make_obs([0x01; 6], -60.0, 6),
make_obs([0x02; 6], -70.0, 36),
];
reg.update(&obs);
let frame = reg.to_multi_ap_frame();
assert_eq!(frame.bssid_count, 2);
assert_eq!(frame.rssi_dbm.len(), 2);
assert_eq!(frame.amplitudes.len(), 2);
assert_eq!(frame.phases.len(), 2);
assert!(frame.amplitudes[0] > frame.amplitudes[1]); // -60 dBm > -70 dBm
}
#[test]
fn welford_stats_accuracy() {
let mut stats = RunningStats::new();
let values = [2.0, 4.0, 4.0, 4.0, 5.0, 5.0, 7.0, 9.0];
for v in &values {
stats.push(*v);
}
assert_eq!(stats.count(), 8);
assert!((stats.mean() - 5.0).abs() < 1e-9);
// Population variance of this dataset is 4.0
assert!((stats.variance() - 4.0).abs() < 1e-9);
// Sample variance is 4.571428...
assert!((stats.sample_variance() - (32.0 / 7.0)).abs() < 1e-9);
}
}

View File

@@ -0,0 +1,216 @@
//! Enhanced sensing result value object.
//!
//! The `EnhancedSensingResult` is the output of the signal intelligence
//! pipeline, carrying motion, breathing, posture, and quality metrics
//! derived from multi-BSSID pseudo-CSI data.
#[cfg(feature = "serde")]
use serde::{Deserialize, Serialize};
// ---------------------------------------------------------------------------
// MotionLevel
// ---------------------------------------------------------------------------
/// Coarse classification of detected motion intensity.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
pub enum MotionLevel {
/// No significant change in BSSID variance; room likely empty.
None,
/// Very small fluctuations consistent with a stationary person
/// (e.g., breathing, minor fidgeting).
Minimal,
/// Moderate changes suggesting slow movement (e.g., walking, gesturing).
Moderate,
/// Large variance swings indicating vigorous or rapid movement.
High,
}
impl MotionLevel {
/// Map a normalised motion score `[0.0, 1.0]` to a `MotionLevel`.
///
/// The thresholds are tuned for multi-BSSID RSSI variance and can be
/// overridden via `WindowsWifiConfig` in the pipeline layer.
pub fn from_score(score: f64) -> Self {
if score < 0.05 {
Self::None
} else if score < 0.20 {
Self::Minimal
} else if score < 0.60 {
Self::Moderate
} else {
Self::High
}
}
}
// ---------------------------------------------------------------------------
// MotionEstimate
// ---------------------------------------------------------------------------
/// Quantitative motion estimate from the multi-BSSID pipeline.
#[derive(Debug, Clone)]
#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
pub struct MotionEstimate {
/// Normalised motion score in `[0.0, 1.0]`.
pub score: f64,
/// Coarse classification derived from the score.
pub level: MotionLevel,
/// The number of BSSIDs contributing to this estimate.
pub contributing_bssids: usize,
}
// ---------------------------------------------------------------------------
// BreathingEstimate
// ---------------------------------------------------------------------------
/// Coarse respiratory rate estimate extracted from body-sensitive BSSIDs.
///
/// Only valid when motion level is `Minimal` (person stationary) and at
/// least 3 body-correlated BSSIDs are available. The accuracy is limited
/// by the low sample rate of Tier 1 scanning (~2 Hz).
#[derive(Debug, Clone)]
#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
pub struct BreathingEstimate {
/// Estimated breaths per minute (typical: 12-20 for adults at rest).
pub rate_bpm: f64,
/// Confidence in the estimate, `[0.0, 1.0]`.
pub confidence: f64,
/// Number of BSSIDs used for the spectral analysis.
pub bssid_count: usize,
}
// ---------------------------------------------------------------------------
// PostureClass
// ---------------------------------------------------------------------------
/// Coarse posture classification from BSSID fingerprint matching.
///
/// Based on Hopfield template matching of the multi-BSSID amplitude
/// signature against stored reference patterns.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
pub enum PostureClass {
/// Room appears empty.
Empty,
/// Person standing.
Standing,
/// Person sitting.
Sitting,
/// Person lying down.
LyingDown,
/// Person walking / in motion.
Walking,
/// Unknown posture (insufficient confidence).
Unknown,
}
// ---------------------------------------------------------------------------
// SignalQuality
// ---------------------------------------------------------------------------
/// Signal quality metrics for the current multi-BSSID frame.
#[derive(Debug, Clone)]
#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
pub struct SignalQuality {
/// Overall quality score `[0.0, 1.0]`, where 1.0 is excellent.
pub score: f64,
/// Number of BSSIDs in the current frame.
pub bssid_count: usize,
/// Spectral gap from the BSSID correlation graph.
/// A large gap indicates good signal separation.
pub spectral_gap: f64,
/// Mean RSSI across all tracked BSSIDs (dBm).
pub mean_rssi_dbm: f64,
}
// ---------------------------------------------------------------------------
// Verdict
// ---------------------------------------------------------------------------
/// Quality gate verdict from the ruQu three-filter pipeline.
///
/// The pipeline evaluates structural integrity, statistical shift
/// significance, and evidence accumulation before permitting a reading.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
pub enum Verdict {
/// Reading passed all quality gates and is reliable.
Permit,
/// Reading shows some anomalies but is usable with reduced confidence.
Warn,
/// Reading failed quality checks and should be discarded.
Deny,
}
// ---------------------------------------------------------------------------
// EnhancedSensingResult
// ---------------------------------------------------------------------------
/// The output of the multi-BSSID signal intelligence pipeline.
///
/// This value object carries all sensing information derived from a single
/// scan cycle. It is converted to a `SensingUpdate` by the Sensing Output
/// bounded context for delivery to the UI.
#[derive(Debug, Clone)]
#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
pub struct EnhancedSensingResult {
/// Motion detection result.
pub motion: MotionEstimate,
/// Coarse respiratory rate, if detectable.
pub breathing: Option<BreathingEstimate>,
/// Posture classification, if available.
pub posture: Option<PostureClass>,
/// Signal quality metrics for the current frame.
pub signal_quality: SignalQuality,
/// Number of BSSIDs used in this sensing cycle.
pub bssid_count: usize,
/// Quality gate verdict.
pub verdict: Verdict,
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn motion_level_thresholds() {
assert_eq!(MotionLevel::from_score(0.0), MotionLevel::None);
assert_eq!(MotionLevel::from_score(0.04), MotionLevel::None);
assert_eq!(MotionLevel::from_score(0.05), MotionLevel::Minimal);
assert_eq!(MotionLevel::from_score(0.19), MotionLevel::Minimal);
assert_eq!(MotionLevel::from_score(0.20), MotionLevel::Moderate);
assert_eq!(MotionLevel::from_score(0.59), MotionLevel::Moderate);
assert_eq!(MotionLevel::from_score(0.60), MotionLevel::High);
assert_eq!(MotionLevel::from_score(1.0), MotionLevel::High);
}
#[test]
fn enhanced_result_construction() {
let result = EnhancedSensingResult {
motion: MotionEstimate {
score: 0.3,
level: MotionLevel::Moderate,
contributing_bssids: 10,
},
breathing: Some(BreathingEstimate {
rate_bpm: 16.0,
confidence: 0.7,
bssid_count: 5,
}),
posture: Some(PostureClass::Standing),
signal_quality: SignalQuality {
score: 0.85,
bssid_count: 15,
spectral_gap: 0.42,
mean_rssi_dbm: -65.0,
},
bssid_count: 15,
verdict: Verdict::Permit,
};
assert_eq!(result.motion.level, MotionLevel::Moderate);
assert_eq!(result.verdict, Verdict::Permit);
assert_eq!(result.bssid_count, 15);
}
}