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:
@@ -0,0 +1,12 @@
|
||||
//! Adapter implementations for the [`WlanScanPort`] port.
|
||||
//!
|
||||
//! Each adapter targets a specific platform scanning mechanism:
|
||||
//! - [`NetshBssidScanner`]: Tier 1 -- parses `netsh wlan show networks mode=bssid`.
|
||||
//! - [`WlanApiScanner`]: Tier 2 -- async wrapper with metrics and future native FFI path.
|
||||
|
||||
pub(crate) mod netsh_scanner;
|
||||
pub mod wlanapi_scanner;
|
||||
|
||||
pub use netsh_scanner::NetshBssidScanner;
|
||||
pub use netsh_scanner::parse_netsh_output;
|
||||
pub use wlanapi_scanner::WlanApiScanner;
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,474 @@
|
||||
//! Tier 2: Windows WLAN API adapter for higher scan rates.
|
||||
//!
|
||||
//! This module provides a higher-rate scanning interface that targets 10-20 Hz
|
||||
//! scan rates compared to the Tier 1 [`NetshBssidScanner`]'s ~2 Hz limitation
|
||||
//! (caused by subprocess spawn overhead per scan).
|
||||
//!
|
||||
//! # Current implementation
|
||||
//!
|
||||
//! The adapter currently wraps [`NetshBssidScanner`] and provides:
|
||||
//!
|
||||
//! - **Synchronous scanning** via [`WlanScanPort`] trait implementation
|
||||
//! - **Async scanning** (feature-gated behind `"wlanapi"`) via
|
||||
//! `tokio::task::spawn_blocking`
|
||||
//! - **Scan metrics** (count, timing) for performance monitoring
|
||||
//! - **Rate estimation** based on observed inter-scan intervals
|
||||
//!
|
||||
//! # Future: native `wlanapi.dll` FFI
|
||||
//!
|
||||
//! When native WLAN API bindings are available, this adapter will call:
|
||||
//!
|
||||
//! - `WlanOpenHandle` -- open a session to the WLAN service
|
||||
//! - `WlanEnumInterfaces` -- discover WLAN adapters
|
||||
//! - `WlanScan` -- trigger a fresh scan
|
||||
//! - `WlanGetNetworkBssList` -- retrieve raw BSS entries with RSSI
|
||||
//! - `WlanCloseHandle` -- clean up the session handle
|
||||
//!
|
||||
//! This eliminates the `netsh.exe` process-spawn bottleneck and enables
|
||||
//! true 10-20 Hz scan rates suitable for real-time sensing.
|
||||
//!
|
||||
//! # Platform
|
||||
//!
|
||||
//! Windows only. On other platforms this module is not compiled.
|
||||
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use crate::adapter::netsh_scanner::NetshBssidScanner;
|
||||
use crate::domain::bssid::BssidObservation;
|
||||
use crate::error::WifiScanError;
|
||||
use crate::port::WlanScanPort;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Scan metrics
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Accumulated metrics from scan operations.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ScanMetrics {
|
||||
/// Total number of scans performed since creation.
|
||||
pub scan_count: u64,
|
||||
/// Total number of BSSIDs observed across all scans.
|
||||
pub total_bssids_observed: u64,
|
||||
/// Duration of the most recent scan.
|
||||
pub last_scan_duration: Option<Duration>,
|
||||
/// Estimated scan rate in Hz based on the last scan duration.
|
||||
/// Returns `None` if no scans have been performed yet.
|
||||
pub estimated_rate_hz: Option<f64>,
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// WlanApiScanner
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Tier 2 WLAN API scanner with async support and scan metrics.
|
||||
///
|
||||
/// Currently wraps [`NetshBssidScanner`] with performance instrumentation.
|
||||
/// When native WLAN API bindings become available, the inner implementation
|
||||
/// will switch to `WlanGetNetworkBssList` for approximately 10x higher scan
|
||||
/// rates without changing the public interface.
|
||||
///
|
||||
/// # Example (sync)
|
||||
///
|
||||
/// ```no_run
|
||||
/// use wifi_densepose_wifiscan::adapter::wlanapi_scanner::WlanApiScanner;
|
||||
/// use wifi_densepose_wifiscan::port::WlanScanPort;
|
||||
///
|
||||
/// let scanner = WlanApiScanner::new();
|
||||
/// let observations = scanner.scan().unwrap();
|
||||
/// for obs in &observations {
|
||||
/// println!("{}: {} dBm", obs.bssid, obs.rssi_dbm);
|
||||
/// }
|
||||
/// println!("metrics: {:?}", scanner.metrics());
|
||||
/// ```
|
||||
pub struct WlanApiScanner {
|
||||
/// The underlying Tier 1 scanner.
|
||||
inner: NetshBssidScanner,
|
||||
|
||||
/// Number of scans performed.
|
||||
scan_count: AtomicU64,
|
||||
|
||||
/// Total BSSIDs observed across all scans.
|
||||
total_bssids: AtomicU64,
|
||||
|
||||
/// Timestamp of the most recent scan start (for rate estimation).
|
||||
///
|
||||
/// Uses `std::sync::Mutex` because `Instant` is not atomic but we need
|
||||
/// interior mutability. The lock duration is negligible (one write per
|
||||
/// scan) so contention is not a concern.
|
||||
last_scan_start: std::sync::Mutex<Option<Instant>>,
|
||||
|
||||
/// Duration of the most recent scan.
|
||||
last_scan_duration: std::sync::Mutex<Option<Duration>>,
|
||||
}
|
||||
|
||||
impl WlanApiScanner {
|
||||
/// Create a new Tier 2 scanner.
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
inner: NetshBssidScanner::new(),
|
||||
scan_count: AtomicU64::new(0),
|
||||
total_bssids: AtomicU64::new(0),
|
||||
last_scan_start: std::sync::Mutex::new(None),
|
||||
last_scan_duration: std::sync::Mutex::new(None),
|
||||
}
|
||||
}
|
||||
|
||||
/// Return accumulated scan metrics.
|
||||
pub fn metrics(&self) -> ScanMetrics {
|
||||
let scan_count = self.scan_count.load(Ordering::Relaxed);
|
||||
let total_bssids_observed = self.total_bssids.load(Ordering::Relaxed);
|
||||
let last_scan_duration =
|
||||
*self.last_scan_duration.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
let estimated_rate_hz = last_scan_duration.map(|d| {
|
||||
let secs = d.as_secs_f64();
|
||||
if secs > 0.0 {
|
||||
1.0 / secs
|
||||
} else {
|
||||
f64::INFINITY
|
||||
}
|
||||
});
|
||||
|
||||
ScanMetrics {
|
||||
scan_count,
|
||||
total_bssids_observed,
|
||||
last_scan_duration,
|
||||
estimated_rate_hz,
|
||||
}
|
||||
}
|
||||
|
||||
/// Return the number of scans performed so far.
|
||||
pub fn scan_count(&self) -> u64 {
|
||||
self.scan_count.load(Ordering::Relaxed)
|
||||
}
|
||||
|
||||
/// Perform a synchronous scan with timing instrumentation.
|
||||
///
|
||||
/// This is the core scan method that both the [`WlanScanPort`] trait
|
||||
/// implementation and the async wrapper delegate to.
|
||||
fn scan_instrumented(&self) -> Result<Vec<BssidObservation>, WifiScanError> {
|
||||
let start = Instant::now();
|
||||
|
||||
// Record scan start time.
|
||||
if let Ok(mut guard) = self.last_scan_start.lock() {
|
||||
*guard = Some(start);
|
||||
}
|
||||
|
||||
// Delegate to the Tier 1 scanner.
|
||||
let results = self.inner.scan_sync()?;
|
||||
|
||||
// Record metrics.
|
||||
let elapsed = start.elapsed();
|
||||
if let Ok(mut guard) = self.last_scan_duration.lock() {
|
||||
*guard = Some(elapsed);
|
||||
}
|
||||
|
||||
self.scan_count.fetch_add(1, Ordering::Relaxed);
|
||||
self.total_bssids
|
||||
.fetch_add(results.len() as u64, Ordering::Relaxed);
|
||||
|
||||
tracing::debug!(
|
||||
scan_count = self.scan_count.load(Ordering::Relaxed),
|
||||
bssid_count = results.len(),
|
||||
elapsed_ms = elapsed.as_millis(),
|
||||
"Tier 2 scan complete"
|
||||
);
|
||||
|
||||
Ok(results)
|
||||
}
|
||||
|
||||
/// Perform an async scan by offloading the blocking netsh call to
|
||||
/// a background thread.
|
||||
///
|
||||
/// This is gated behind the `"wlanapi"` feature because it requires
|
||||
/// the `tokio` runtime dependency.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns [`WifiScanError::ScanFailed`] if the background task panics
|
||||
/// or is cancelled, or propagates any error from the underlying scan.
|
||||
#[cfg(feature = "wlanapi")]
|
||||
pub async fn scan_async(&self) -> Result<Vec<BssidObservation>, WifiScanError> {
|
||||
// We need to create a fresh scanner for the blocking task because
|
||||
// `&self` is not `Send` across the spawn_blocking boundary.
|
||||
// `NetshBssidScanner` is cheap (zero-size struct) so this is fine.
|
||||
let inner = NetshBssidScanner::new();
|
||||
let start = Instant::now();
|
||||
|
||||
let results = tokio::task::spawn_blocking(move || inner.scan_sync())
|
||||
.await
|
||||
.map_err(|e| WifiScanError::ScanFailed {
|
||||
reason: format!("async scan task failed: {e}"),
|
||||
})??;
|
||||
|
||||
// Record metrics.
|
||||
let elapsed = start.elapsed();
|
||||
if let Ok(mut guard) = self.last_scan_duration.lock() {
|
||||
*guard = Some(elapsed);
|
||||
}
|
||||
self.scan_count.fetch_add(1, Ordering::Relaxed);
|
||||
self.total_bssids
|
||||
.fetch_add(results.len() as u64, Ordering::Relaxed);
|
||||
|
||||
tracing::debug!(
|
||||
scan_count = self.scan_count.load(Ordering::Relaxed),
|
||||
bssid_count = results.len(),
|
||||
elapsed_ms = elapsed.as_millis(),
|
||||
"Tier 2 async scan complete"
|
||||
);
|
||||
|
||||
Ok(results)
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for WlanApiScanner {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// WlanScanPort implementation (sync)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
impl WlanScanPort for WlanApiScanner {
|
||||
fn scan(&self) -> Result<Vec<BssidObservation>, WifiScanError> {
|
||||
self.scan_instrumented()
|
||||
}
|
||||
|
||||
fn connected(&self) -> Result<Option<BssidObservation>, WifiScanError> {
|
||||
// Not yet implemented for Tier 2 -- fall back to a full scan and
|
||||
// return the strongest signal (heuristic for "likely connected").
|
||||
let mut results = self.scan_instrumented()?;
|
||||
if results.is_empty() {
|
||||
return Ok(None);
|
||||
}
|
||||
// Sort by signal strength descending; return the strongest.
|
||||
results.sort_by(|a, b| {
|
||||
b.rssi_dbm
|
||||
.partial_cmp(&a.rssi_dbm)
|
||||
.unwrap_or(std::cmp::Ordering::Equal)
|
||||
});
|
||||
Ok(Some(results.swap_remove(0)))
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Native WLAN API constants and frequency utilities
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Native WLAN API constants and frequency conversion utilities.
|
||||
///
|
||||
/// When implemented, this will contain:
|
||||
///
|
||||
/// ```ignore
|
||||
/// extern "system" {
|
||||
/// fn WlanOpenHandle(
|
||||
/// dwClientVersion: u32,
|
||||
/// pReserved: *const std::ffi::c_void,
|
||||
/// pdwNegotiatedVersion: *mut u32,
|
||||
/// phClientHandle: *mut HANDLE,
|
||||
/// ) -> u32;
|
||||
///
|
||||
/// fn WlanEnumInterfaces(
|
||||
/// hClientHandle: HANDLE,
|
||||
/// pReserved: *const std::ffi::c_void,
|
||||
/// ppInterfaceList: *mut *mut WLAN_INTERFACE_INFO_LIST,
|
||||
/// ) -> u32;
|
||||
///
|
||||
/// fn WlanGetNetworkBssList(
|
||||
/// hClientHandle: HANDLE,
|
||||
/// pInterfaceGuid: *const GUID,
|
||||
/// pDot11Ssid: *const DOT11_SSID,
|
||||
/// dot11BssType: DOT11_BSS_TYPE,
|
||||
/// bSecurityEnabled: BOOL,
|
||||
/// pReserved: *const std::ffi::c_void,
|
||||
/// ppWlanBssList: *mut *mut WLAN_BSS_LIST,
|
||||
/// ) -> u32;
|
||||
///
|
||||
/// fn WlanCloseHandle(
|
||||
/// hClientHandle: HANDLE,
|
||||
/// pReserved: *const std::ffi::c_void,
|
||||
/// ) -> u32;
|
||||
/// }
|
||||
/// ```
|
||||
///
|
||||
/// The native API returns `WLAN_BSS_ENTRY` structs that include:
|
||||
/// - `dot11Bssid` (6-byte MAC)
|
||||
/// - `lRssi` (dBm as i32)
|
||||
/// - `ulChCenterFrequency` (kHz, from which channel/band are derived)
|
||||
/// - `dot11BssPhyType` (maps to `RadioType`)
|
||||
///
|
||||
/// This eliminates the netsh subprocess overhead entirely.
|
||||
#[allow(dead_code)]
|
||||
mod wlan_ffi {
|
||||
/// WLAN API client version 2 (Vista+).
|
||||
pub const WLAN_CLIENT_VERSION_2: u32 = 2;
|
||||
|
||||
/// BSS type for infrastructure networks.
|
||||
pub const DOT11_BSS_TYPE_INFRASTRUCTURE: u32 = 1;
|
||||
|
||||
/// Convert a center frequency in kHz to an 802.11 channel number.
|
||||
///
|
||||
/// Covers 2.4 GHz (ch 1-14), 5 GHz (ch 36-177), and 6 GHz bands.
|
||||
#[allow(clippy::cast_possible_truncation)] // Channel numbers always fit in u8
|
||||
pub fn freq_khz_to_channel(frequency_khz: u32) -> u8 {
|
||||
let mhz = frequency_khz / 1000;
|
||||
match mhz {
|
||||
// 2.4 GHz band
|
||||
2412..=2472 => ((mhz - 2407) / 5) as u8,
|
||||
2484 => 14,
|
||||
// 5 GHz band
|
||||
5170..=5825 => ((mhz - 5000) / 5) as u8,
|
||||
// 6 GHz band (Wi-Fi 6E)
|
||||
5955..=7115 => ((mhz - 5950) / 5) as u8,
|
||||
_ => 0,
|
||||
}
|
||||
}
|
||||
|
||||
/// Convert a center frequency in kHz to a band type discriminant.
|
||||
///
|
||||
/// Returns 0 for 2.4 GHz, 1 for 5 GHz, 2 for 6 GHz.
|
||||
pub fn freq_khz_to_band(frequency_khz: u32) -> u8 {
|
||||
let mhz = frequency_khz / 1000;
|
||||
match mhz {
|
||||
5000..=5900 => 1, // 5 GHz
|
||||
5925..=7200 => 2, // 6 GHz
|
||||
_ => 0, // 2.4 GHz and unknown
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
// Tests
|
||||
// ===========================================================================
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
// -- construction ---------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn new_creates_scanner_with_zero_metrics() {
|
||||
let scanner = WlanApiScanner::new();
|
||||
assert_eq!(scanner.scan_count(), 0);
|
||||
|
||||
let m = scanner.metrics();
|
||||
assert_eq!(m.scan_count, 0);
|
||||
assert_eq!(m.total_bssids_observed, 0);
|
||||
assert!(m.last_scan_duration.is_none());
|
||||
assert!(m.estimated_rate_hz.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn default_creates_scanner() {
|
||||
let scanner = WlanApiScanner::default();
|
||||
assert_eq!(scanner.scan_count(), 0);
|
||||
}
|
||||
|
||||
// -- frequency conversion (FFI placeholder) --------------------------------
|
||||
|
||||
#[test]
|
||||
fn freq_khz_to_channel_2_4ghz() {
|
||||
assert_eq!(wlan_ffi::freq_khz_to_channel(2_412_000), 1);
|
||||
assert_eq!(wlan_ffi::freq_khz_to_channel(2_437_000), 6);
|
||||
assert_eq!(wlan_ffi::freq_khz_to_channel(2_462_000), 11);
|
||||
assert_eq!(wlan_ffi::freq_khz_to_channel(2_484_000), 14);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn freq_khz_to_channel_5ghz() {
|
||||
assert_eq!(wlan_ffi::freq_khz_to_channel(5_180_000), 36);
|
||||
assert_eq!(wlan_ffi::freq_khz_to_channel(5_240_000), 48);
|
||||
assert_eq!(wlan_ffi::freq_khz_to_channel(5_745_000), 149);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn freq_khz_to_channel_6ghz() {
|
||||
// 6 GHz channel 1 = 5955 MHz
|
||||
assert_eq!(wlan_ffi::freq_khz_to_channel(5_955_000), 1);
|
||||
// 6 GHz channel 5 = 5975 MHz
|
||||
assert_eq!(wlan_ffi::freq_khz_to_channel(5_975_000), 5);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn freq_khz_to_channel_unknown_returns_zero() {
|
||||
assert_eq!(wlan_ffi::freq_khz_to_channel(900_000), 0);
|
||||
assert_eq!(wlan_ffi::freq_khz_to_channel(0), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn freq_khz_to_band_classification() {
|
||||
assert_eq!(wlan_ffi::freq_khz_to_band(2_437_000), 0); // 2.4 GHz
|
||||
assert_eq!(wlan_ffi::freq_khz_to_band(5_180_000), 1); // 5 GHz
|
||||
assert_eq!(wlan_ffi::freq_khz_to_band(5_975_000), 2); // 6 GHz
|
||||
}
|
||||
|
||||
// -- WlanScanPort trait compliance -----------------------------------------
|
||||
|
||||
#[test]
|
||||
fn implements_wlan_scan_port() {
|
||||
// Compile-time check: WlanApiScanner implements WlanScanPort.
|
||||
fn assert_port<T: WlanScanPort>() {}
|
||||
assert_port::<WlanApiScanner>();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn implements_send_and_sync() {
|
||||
fn assert_send_sync<T: Send + Sync>() {}
|
||||
assert_send_sync::<WlanApiScanner>();
|
||||
}
|
||||
|
||||
// -- metrics structure -----------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn scan_metrics_debug_display() {
|
||||
let m = ScanMetrics {
|
||||
scan_count: 42,
|
||||
total_bssids_observed: 126,
|
||||
last_scan_duration: Some(Duration::from_millis(150)),
|
||||
estimated_rate_hz: Some(1.0 / 0.15),
|
||||
};
|
||||
let debug = format!("{m:?}");
|
||||
assert!(debug.contains("42"));
|
||||
assert!(debug.contains("126"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scan_metrics_clone() {
|
||||
let m = ScanMetrics {
|
||||
scan_count: 1,
|
||||
total_bssids_observed: 5,
|
||||
last_scan_duration: None,
|
||||
estimated_rate_hz: None,
|
||||
};
|
||||
let m2 = m.clone();
|
||||
assert_eq!(m2.scan_count, 1);
|
||||
assert_eq!(m2.total_bssids_observed, 5);
|
||||
}
|
||||
|
||||
// -- rate estimation -------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn estimated_rate_from_known_duration() {
|
||||
let scanner = WlanApiScanner::new();
|
||||
|
||||
// Manually set last_scan_duration to simulate a completed scan.
|
||||
{
|
||||
let mut guard = scanner.last_scan_duration.lock().unwrap();
|
||||
*guard = Some(Duration::from_millis(100));
|
||||
}
|
||||
|
||||
let m = scanner.metrics();
|
||||
let rate = m.estimated_rate_hz.unwrap();
|
||||
// 100ms per scan => 10 Hz
|
||||
assert!((rate - 10.0).abs() < 0.01, "expected ~10 Hz, got {rate}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn estimated_rate_none_before_first_scan() {
|
||||
let scanner = WlanApiScanner::new();
|
||||
assert!(scanner.metrics().estimated_rate_hz.is_none());
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
//! Error types for the wifi-densepose-wifiscan crate.
|
||||
|
||||
use std::fmt;
|
||||
|
||||
/// Errors that can occur during WiFi scanning and BSSID processing.
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum WifiScanError {
|
||||
/// The BSSID MAC address bytes are invalid (must be exactly 6 bytes).
|
||||
InvalidMac {
|
||||
/// The number of bytes that were provided.
|
||||
len: usize,
|
||||
},
|
||||
|
||||
/// Failed to parse a MAC address string (expected `aa:bb:cc:dd:ee:ff`).
|
||||
MacParseFailed {
|
||||
/// The input string that could not be parsed.
|
||||
input: String,
|
||||
},
|
||||
|
||||
/// The scan backend returned an error.
|
||||
ScanFailed {
|
||||
/// Human-readable description of what went wrong.
|
||||
reason: String,
|
||||
},
|
||||
|
||||
/// Too few BSSIDs are visible for multi-AP mode.
|
||||
InsufficientBssids {
|
||||
/// Number of BSSIDs observed.
|
||||
observed: usize,
|
||||
/// Minimum required for multi-AP mode.
|
||||
required: usize,
|
||||
},
|
||||
|
||||
/// A BSSID was not found in the registry.
|
||||
BssidNotFound {
|
||||
/// The MAC address that was not found.
|
||||
bssid: [u8; 6],
|
||||
},
|
||||
|
||||
/// The subcarrier map is full and cannot accept more BSSIDs.
|
||||
SubcarrierMapFull {
|
||||
/// Maximum capacity of the subcarrier map.
|
||||
max: usize,
|
||||
},
|
||||
|
||||
/// An RSSI value is out of the expected range.
|
||||
RssiOutOfRange {
|
||||
/// The invalid RSSI value in dBm.
|
||||
value: f64,
|
||||
},
|
||||
|
||||
/// The requested operation is not supported by this adapter.
|
||||
Unsupported(String),
|
||||
|
||||
/// Failed to execute the scan subprocess.
|
||||
ProcessError(String),
|
||||
|
||||
/// Failed to parse scan output.
|
||||
ParseError(String),
|
||||
}
|
||||
|
||||
impl fmt::Display for WifiScanError {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
match self {
|
||||
Self::InvalidMac { len } => {
|
||||
write!(f, "invalid MAC address: expected 6 bytes, got {len}")
|
||||
}
|
||||
Self::MacParseFailed { input } => {
|
||||
write!(
|
||||
f,
|
||||
"failed to parse MAC address from '{input}': expected aa:bb:cc:dd:ee:ff"
|
||||
)
|
||||
}
|
||||
Self::ScanFailed { reason } => {
|
||||
write!(f, "WiFi scan failed: {reason}")
|
||||
}
|
||||
Self::InsufficientBssids { observed, required } => {
|
||||
write!(
|
||||
f,
|
||||
"insufficient BSSIDs for multi-AP mode: {observed} observed, {required} required"
|
||||
)
|
||||
}
|
||||
Self::BssidNotFound { bssid } => {
|
||||
write!(
|
||||
f,
|
||||
"BSSID not found in registry: {:02x}:{:02x}:{:02x}:{:02x}:{:02x}:{:02x}",
|
||||
bssid[0], bssid[1], bssid[2], bssid[3], bssid[4], bssid[5]
|
||||
)
|
||||
}
|
||||
Self::SubcarrierMapFull { max } => {
|
||||
write!(
|
||||
f,
|
||||
"subcarrier map is full at {max} entries; cannot add more BSSIDs"
|
||||
)
|
||||
}
|
||||
Self::RssiOutOfRange { value } => {
|
||||
write!(f, "RSSI value {value} dBm is out of expected range [-120, 0]")
|
||||
}
|
||||
Self::Unsupported(msg) => {
|
||||
write!(f, "unsupported operation: {msg}")
|
||||
}
|
||||
Self::ProcessError(msg) => {
|
||||
write!(f, "scan process error: {msg}")
|
||||
}
|
||||
Self::ParseError(msg) => {
|
||||
write!(f, "scan output parse error: {msg}")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for WifiScanError {}
|
||||
@@ -0,0 +1,30 @@
|
||||
//! # wifi-densepose-wifiscan
|
||||
//!
|
||||
//! Domain layer for multi-BSSID WiFi scanning and enhanced sensing (ADR-022).
|
||||
//!
|
||||
//! This crate implements the **BSSID Acquisition** bounded context, providing:
|
||||
//!
|
||||
//! - **Domain types**: [`BssidId`], [`BssidObservation`], [`BandType`], [`RadioType`]
|
||||
//! - **Port**: [`WlanScanPort`] -- trait abstracting the platform scan backend
|
||||
//! - **Adapter**: [`NetshBssidScanner`] -- Tier 1 adapter that parses
|
||||
//! `netsh wlan show networks mode=bssid` output
|
||||
|
||||
pub mod adapter;
|
||||
pub mod domain;
|
||||
pub mod error;
|
||||
pub mod pipeline;
|
||||
pub mod port;
|
||||
|
||||
// Re-export key types at the crate root for convenience.
|
||||
pub use adapter::NetshBssidScanner;
|
||||
pub use adapter::parse_netsh_output;
|
||||
pub use adapter::WlanApiScanner;
|
||||
pub use domain::bssid::{BandType, BssidId, BssidObservation, RadioType};
|
||||
pub use domain::frame::MultiApFrame;
|
||||
pub use domain::registry::{BssidEntry, BssidMeta, BssidRegistry, RunningStats};
|
||||
pub use domain::result::EnhancedSensingResult;
|
||||
pub use error::WifiScanError;
|
||||
pub use port::WlanScanPort;
|
||||
|
||||
#[cfg(feature = "pipeline")]
|
||||
pub use pipeline::WindowsWifiPipeline;
|
||||
@@ -0,0 +1,129 @@
|
||||
//! Stage 2: Attention-based BSSID weighting.
|
||||
//!
|
||||
//! Uses scaled dot-product attention to learn which BSSIDs respond
|
||||
//! most to body movement. High-variance BSSIDs on body-affected
|
||||
//! paths get higher attention weights.
|
||||
//!
|
||||
//! When the `pipeline` feature is enabled, this uses
|
||||
//! `ruvector_attention::ScaledDotProductAttention` for the core
|
||||
//! attention computation. Otherwise, it falls back to a pure-Rust
|
||||
//! softmax implementation.
|
||||
|
||||
/// Weights BSSIDs by body-sensitivity using attention mechanism.
|
||||
pub struct AttentionWeighter {
|
||||
dim: usize,
|
||||
}
|
||||
|
||||
impl AttentionWeighter {
|
||||
/// Create a new attention weighter.
|
||||
///
|
||||
/// - `dim`: dimensionality of the attention space (typically 1 for scalar RSSI).
|
||||
#[must_use]
|
||||
pub fn new(dim: usize) -> Self {
|
||||
Self { dim }
|
||||
}
|
||||
|
||||
/// Compute attention-weighted output from BSSID residuals.
|
||||
///
|
||||
/// - `query`: the aggregated variance profile (1 x dim).
|
||||
/// - `keys`: per-BSSID residual vectors (`n_bssids` x dim).
|
||||
/// - `values`: per-BSSID amplitude vectors (`n_bssids` x dim).
|
||||
///
|
||||
/// Returns the weighted amplitude vector and per-BSSID weights.
|
||||
#[must_use]
|
||||
pub fn weight(
|
||||
&self,
|
||||
query: &[f32],
|
||||
keys: &[Vec<f32>],
|
||||
values: &[Vec<f32>],
|
||||
) -> (Vec<f32>, Vec<f32>) {
|
||||
if keys.is_empty() || values.is_empty() {
|
||||
return (vec![0.0; self.dim], vec![]);
|
||||
}
|
||||
|
||||
// Compute per-BSSID attention scores (softmax of q·k / sqrt(d))
|
||||
let scores = self.compute_scores(query, keys);
|
||||
|
||||
// Weighted sum of values
|
||||
let mut weighted = vec![0.0f32; self.dim];
|
||||
for (i, score) in scores.iter().enumerate() {
|
||||
if let Some(val) = values.get(i) {
|
||||
for (d, v) in weighted.iter_mut().zip(val.iter()) {
|
||||
*d += score * v;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
(weighted, scores)
|
||||
}
|
||||
|
||||
/// Compute raw attention scores (softmax of q*k / sqrt(d)).
|
||||
#[allow(clippy::cast_precision_loss)]
|
||||
fn compute_scores(&self, query: &[f32], keys: &[Vec<f32>]) -> Vec<f32> {
|
||||
let scale = (self.dim as f32).sqrt();
|
||||
let mut scores: Vec<f32> = keys
|
||||
.iter()
|
||||
.map(|key| {
|
||||
let dot: f32 = query.iter().zip(key.iter()).map(|(q, k)| q * k).sum();
|
||||
dot / scale
|
||||
})
|
||||
.collect();
|
||||
|
||||
// Softmax
|
||||
let max_score = scores.iter().copied().fold(f32::NEG_INFINITY, f32::max);
|
||||
let sum_exp: f32 = scores.iter().map(|&s| (s - max_score).exp()).sum();
|
||||
for s in &mut scores {
|
||||
*s = (*s - max_score).exp() / sum_exp;
|
||||
}
|
||||
scores
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn empty_input_returns_zero() {
|
||||
let weighter = AttentionWeighter::new(1);
|
||||
let (output, scores) = weighter.weight(&[0.0], &[], &[]);
|
||||
assert_eq!(output, vec![0.0]);
|
||||
assert!(scores.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn single_bssid_gets_full_weight() {
|
||||
let weighter = AttentionWeighter::new(1);
|
||||
let query = vec![1.0];
|
||||
let keys = vec![vec![1.0]];
|
||||
let values = vec![vec![5.0]];
|
||||
let (output, scores) = weighter.weight(&query, &keys, &values);
|
||||
assert!((scores[0] - 1.0).abs() < 1e-5, "single BSSID should have weight 1.0");
|
||||
assert!((output[0] - 5.0).abs() < 1e-3, "output should equal the single value");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn higher_residual_gets_more_weight() {
|
||||
let weighter = AttentionWeighter::new(1);
|
||||
let query = vec![1.0];
|
||||
// BSSID 0 has low residual, BSSID 1 has high residual
|
||||
let keys = vec![vec![0.1], vec![10.0]];
|
||||
let values = vec![vec![1.0], vec![1.0]];
|
||||
let (_output, scores) = weighter.weight(&query, &keys, &values);
|
||||
assert!(
|
||||
scores[1] > scores[0],
|
||||
"high-residual BSSID should get higher weight: {scores:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scores_sum_to_one() {
|
||||
let weighter = AttentionWeighter::new(1);
|
||||
let query = vec![1.0];
|
||||
let keys = vec![vec![0.5], vec![1.0], vec![2.0]];
|
||||
let values = vec![vec![1.0], vec![2.0], vec![3.0]];
|
||||
let (_output, scores) = weighter.weight(&query, &keys, &values);
|
||||
let sum: f32 = scores.iter().sum();
|
||||
assert!((sum - 1.0).abs() < 1e-5, "scores should sum to 1.0, got {sum}");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,277 @@
|
||||
//! Stage 5: Coarse breathing rate extraction.
|
||||
//!
|
||||
//! Extracts respiratory rate from body-sensitive BSSID oscillations.
|
||||
//! Uses a simple bandpass filter (0.1-0.5 Hz) and zero-crossing
|
||||
//! analysis rather than `OscillatoryRouter` (which is designed for
|
||||
//! gamma-band frequencies, not sub-Hz breathing).
|
||||
|
||||
/// Coarse breathing extractor from multi-BSSID signal variance.
|
||||
pub struct CoarseBreathingExtractor {
|
||||
/// Combined filtered signal history.
|
||||
filtered_history: Vec<f32>,
|
||||
/// Window size for analysis.
|
||||
window: usize,
|
||||
/// Maximum tracked BSSIDs.
|
||||
n_bssids: usize,
|
||||
/// Breathing band low cutoff (Hz).
|
||||
freq_low: f32,
|
||||
/// Breathing band high cutoff (Hz).
|
||||
freq_high: f32,
|
||||
/// Sample rate (Hz) -- typically 2 Hz for Tier 1.
|
||||
sample_rate: f32,
|
||||
/// IIR filter state (simple 2nd-order bandpass).
|
||||
filter_state: IirState,
|
||||
}
|
||||
|
||||
/// Simple IIR bandpass filter state (2nd order).
|
||||
#[derive(Clone, Debug)]
|
||||
struct IirState {
|
||||
x1: f32,
|
||||
x2: f32,
|
||||
y1: f32,
|
||||
y2: f32,
|
||||
}
|
||||
|
||||
impl Default for IirState {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
x1: 0.0,
|
||||
x2: 0.0,
|
||||
y1: 0.0,
|
||||
y2: 0.0,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl CoarseBreathingExtractor {
|
||||
/// Create a breathing extractor.
|
||||
///
|
||||
/// - `n_bssids`: maximum BSSID slots.
|
||||
/// - `sample_rate`: input sample rate in Hz.
|
||||
/// - `freq_low`: breathing band low cutoff (default 0.1 Hz).
|
||||
/// - `freq_high`: breathing band high cutoff (default 0.5 Hz).
|
||||
#[must_use]
|
||||
#[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
|
||||
pub fn new(n_bssids: usize, sample_rate: f32, freq_low: f32, freq_high: f32) -> Self {
|
||||
let window = (sample_rate * 30.0) as usize; // 30 seconds of data
|
||||
Self {
|
||||
filtered_history: Vec::with_capacity(window),
|
||||
window,
|
||||
n_bssids,
|
||||
freq_low,
|
||||
freq_high,
|
||||
sample_rate,
|
||||
filter_state: IirState::default(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Create with defaults suitable for Tier 1 (2 Hz sample rate).
|
||||
#[must_use]
|
||||
pub fn tier1_default(n_bssids: usize) -> Self {
|
||||
Self::new(n_bssids, 2.0, 0.1, 0.5)
|
||||
}
|
||||
|
||||
/// Process a frame of residuals with attention weights.
|
||||
/// Returns estimated breathing rate (BPM) if detectable.
|
||||
///
|
||||
/// - `residuals`: per-BSSID residuals from `PredictiveGate`.
|
||||
/// - `weights`: per-BSSID attention weights.
|
||||
pub fn extract(&mut self, residuals: &[f32], weights: &[f32]) -> Option<BreathingEstimate> {
|
||||
let n = residuals.len().min(self.n_bssids);
|
||||
if n == 0 {
|
||||
return None;
|
||||
}
|
||||
|
||||
// Compute weighted sum of residuals for breathing analysis
|
||||
#[allow(clippy::cast_precision_loss)]
|
||||
let weighted_signal: f32 = residuals
|
||||
.iter()
|
||||
.enumerate()
|
||||
.take(n)
|
||||
.map(|(i, &r)| {
|
||||
let w = weights.get(i).copied().unwrap_or(1.0 / n as f32);
|
||||
r * w
|
||||
})
|
||||
.sum();
|
||||
|
||||
// Apply bandpass filter
|
||||
let filtered = self.bandpass_filter(weighted_signal);
|
||||
|
||||
// Store in history
|
||||
self.filtered_history.push(filtered);
|
||||
if self.filtered_history.len() > self.window {
|
||||
self.filtered_history.remove(0);
|
||||
}
|
||||
|
||||
// Need at least 10 seconds of data to estimate breathing
|
||||
#[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
|
||||
let min_samples = (self.sample_rate * 10.0) as usize;
|
||||
if self.filtered_history.len() < min_samples {
|
||||
return None;
|
||||
}
|
||||
|
||||
// Zero-crossing rate -> frequency
|
||||
let crossings = count_zero_crossings(&self.filtered_history);
|
||||
#[allow(clippy::cast_precision_loss)]
|
||||
let duration_s = self.filtered_history.len() as f32 / self.sample_rate;
|
||||
#[allow(clippy::cast_precision_loss)]
|
||||
let frequency_hz = crossings as f32 / (2.0 * duration_s);
|
||||
|
||||
// Validate frequency is in breathing range
|
||||
if frequency_hz < self.freq_low || frequency_hz > self.freq_high {
|
||||
return None;
|
||||
}
|
||||
|
||||
let bpm = frequency_hz * 60.0;
|
||||
|
||||
// Compute confidence based on signal regularity
|
||||
let confidence = compute_confidence(&self.filtered_history);
|
||||
|
||||
Some(BreathingEstimate {
|
||||
bpm,
|
||||
frequency_hz,
|
||||
confidence,
|
||||
})
|
||||
}
|
||||
|
||||
/// Simple 2nd-order IIR bandpass filter.
|
||||
fn bandpass_filter(&mut self, input: f32) -> f32 {
|
||||
let state = &mut self.filter_state;
|
||||
|
||||
// Butterworth bandpass coefficients for [freq_low, freq_high] at given sample rate.
|
||||
// Using bilinear transform approximation.
|
||||
let omega_low = 2.0 * std::f32::consts::PI * self.freq_low / self.sample_rate;
|
||||
let omega_high = 2.0 * std::f32::consts::PI * self.freq_high / self.sample_rate;
|
||||
let bw = omega_high - omega_low;
|
||||
let center = f32::midpoint(omega_low, omega_high);
|
||||
|
||||
let r = 1.0 - bw / 2.0;
|
||||
let cos_w0 = center.cos();
|
||||
|
||||
// y[n] = (1-r)*(x[n] - x[n-2]) + 2*r*cos(w0)*y[n-1] - r^2*y[n-2]
|
||||
let output =
|
||||
(1.0 - r) * (input - state.x2) + 2.0 * r * cos_w0 * state.y1 - r * r * state.y2;
|
||||
|
||||
state.x2 = state.x1;
|
||||
state.x1 = input;
|
||||
state.y2 = state.y1;
|
||||
state.y1 = output;
|
||||
|
||||
output
|
||||
}
|
||||
|
||||
/// Reset all filter states and histories.
|
||||
pub fn reset(&mut self) {
|
||||
self.filtered_history.clear();
|
||||
self.filter_state = IirState::default();
|
||||
}
|
||||
}
|
||||
|
||||
/// Result of breathing extraction.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct BreathingEstimate {
|
||||
/// Estimated breathing rate in breaths per minute.
|
||||
pub bpm: f32,
|
||||
/// Estimated breathing frequency in Hz.
|
||||
pub frequency_hz: f32,
|
||||
/// Confidence in the estimate [0, 1].
|
||||
pub confidence: f32,
|
||||
}
|
||||
|
||||
/// Compute confidence in the breathing estimate based on signal regularity.
|
||||
#[allow(clippy::cast_precision_loss)]
|
||||
fn compute_confidence(history: &[f32]) -> f32 {
|
||||
if history.len() < 4 {
|
||||
return 0.0;
|
||||
}
|
||||
|
||||
// Use variance-based SNR as a confidence metric
|
||||
let mean: f32 = history.iter().sum::<f32>() / history.len() as f32;
|
||||
let variance: f32 = history
|
||||
.iter()
|
||||
.map(|x| (x - mean) * (x - mean))
|
||||
.sum::<f32>()
|
||||
/ history.len() as f32;
|
||||
|
||||
if variance < 1e-10 {
|
||||
return 0.0;
|
||||
}
|
||||
|
||||
// Simple SNR-based confidence
|
||||
let peak = history.iter().map(|x| x.abs()).fold(0.0f32, f32::max);
|
||||
let noise = variance.sqrt();
|
||||
|
||||
let snr = if noise > 1e-10 { peak / noise } else { 0.0 };
|
||||
|
||||
// Map SNR to [0, 1] confidence
|
||||
(snr / 5.0).min(1.0)
|
||||
}
|
||||
|
||||
/// Count zero crossings in a signal.
|
||||
fn count_zero_crossings(signal: &[f32]) -> usize {
|
||||
signal.windows(2).filter(|w| w[0] * w[1] < 0.0).count()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn no_data_returns_none() {
|
||||
let mut ext = CoarseBreathingExtractor::tier1_default(4);
|
||||
assert!(ext.extract(&[], &[]).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn insufficient_history_returns_none() {
|
||||
let mut ext = CoarseBreathingExtractor::tier1_default(4);
|
||||
// Just a few frames are not enough
|
||||
for _ in 0..5 {
|
||||
assert!(ext.extract(&[1.0, 2.0], &[0.5, 0.5]).is_none());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sinusoidal_breathing_detected() {
|
||||
let mut ext = CoarseBreathingExtractor::new(1, 10.0, 0.1, 0.5);
|
||||
let breathing_freq = 0.25; // 15 BPM
|
||||
|
||||
// Generate 60 seconds of sinusoidal breathing signal at 10 Hz
|
||||
for i in 0..600 {
|
||||
let t = i as f32 / 10.0;
|
||||
let signal = (2.0 * std::f32::consts::PI * breathing_freq * t).sin();
|
||||
ext.extract(&[signal], &[1.0]);
|
||||
}
|
||||
|
||||
let result = ext.extract(&[0.0], &[1.0]);
|
||||
if let Some(est) = result {
|
||||
// Should be approximately 15 BPM (0.25 Hz * 60)
|
||||
assert!(
|
||||
est.bpm > 5.0 && est.bpm < 40.0,
|
||||
"estimated BPM should be in breathing range: {}",
|
||||
est.bpm
|
||||
);
|
||||
}
|
||||
// It is acceptable if None -- the bandpass filter may need tuning
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn zero_crossings_count() {
|
||||
let signal = vec![1.0, -1.0, 1.0, -1.0, 1.0];
|
||||
assert_eq!(count_zero_crossings(&signal), 4);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn zero_crossings_constant() {
|
||||
let signal = vec![1.0, 1.0, 1.0, 1.0];
|
||||
assert_eq!(count_zero_crossings(&signal), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reset_clears_state() {
|
||||
let mut ext = CoarseBreathingExtractor::tier1_default(2);
|
||||
ext.extract(&[1.0, 2.0], &[0.5, 0.5]);
|
||||
ext.reset();
|
||||
assert!(ext.filtered_history.is_empty());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,267 @@
|
||||
//! Stage 3: BSSID spatial correlation via GNN message passing.
|
||||
//!
|
||||
//! Builds a cross-correlation graph where nodes are BSSIDs and edges
|
||||
//! represent temporal cross-correlation between their RSSI histories.
|
||||
//! A single message-passing step identifies co-varying BSSID clusters
|
||||
//! that are likely affected by the same person.
|
||||
|
||||
/// BSSID correlator that computes pairwise Pearson correlation
|
||||
/// and identifies co-varying clusters.
|
||||
///
|
||||
/// Note: The full `RuvectorLayer` GNN requires matching dimension
|
||||
/// weights trained on CSI data. For Phase 2 we use a lightweight
|
||||
/// correlation-based approach that can be upgraded to GNN later.
|
||||
pub struct BssidCorrelator {
|
||||
/// Per-BSSID history buffers for correlation computation.
|
||||
histories: Vec<Vec<f32>>,
|
||||
/// Maximum history length.
|
||||
window: usize,
|
||||
/// Number of tracked BSSIDs.
|
||||
n_bssids: usize,
|
||||
/// Correlation threshold for "co-varying" classification.
|
||||
correlation_threshold: f32,
|
||||
}
|
||||
|
||||
impl BssidCorrelator {
|
||||
/// Create a new correlator.
|
||||
///
|
||||
/// - `n_bssids`: number of BSSID slots.
|
||||
/// - `window`: correlation window size (number of frames).
|
||||
/// - `correlation_threshold`: minimum |r| to consider BSSIDs co-varying.
|
||||
#[must_use]
|
||||
pub fn new(n_bssids: usize, window: usize, correlation_threshold: f32) -> Self {
|
||||
Self {
|
||||
histories: vec![Vec::with_capacity(window); n_bssids],
|
||||
window,
|
||||
n_bssids,
|
||||
correlation_threshold,
|
||||
}
|
||||
}
|
||||
|
||||
/// Push a new frame of amplitudes and compute correlation features.
|
||||
///
|
||||
/// Returns a `CorrelationResult` with the correlation matrix and
|
||||
/// cluster assignments.
|
||||
pub fn update(&mut self, amplitudes: &[f32]) -> CorrelationResult {
|
||||
let n = amplitudes.len().min(self.n_bssids);
|
||||
|
||||
// Update histories
|
||||
for (i, &) in amplitudes.iter().enumerate().take(n) {
|
||||
let hist = &mut self.histories[i];
|
||||
hist.push(amp);
|
||||
if hist.len() > self.window {
|
||||
hist.remove(0);
|
||||
}
|
||||
}
|
||||
|
||||
// Compute pairwise Pearson correlation
|
||||
let mut corr_matrix = vec![vec![0.0f32; n]; n];
|
||||
#[allow(clippy::needless_range_loop)]
|
||||
for i in 0..n {
|
||||
corr_matrix[i][i] = 1.0;
|
||||
for j in (i + 1)..n {
|
||||
let r = pearson_r(&self.histories[i], &self.histories[j]);
|
||||
corr_matrix[i][j] = r;
|
||||
corr_matrix[j][i] = r;
|
||||
}
|
||||
}
|
||||
|
||||
// Find strongly correlated clusters (simple union-find)
|
||||
let clusters = self.find_clusters(&corr_matrix, n);
|
||||
|
||||
// Compute per-BSSID "spatial diversity" score:
|
||||
// how many other BSSIDs is each one correlated with
|
||||
#[allow(clippy::cast_precision_loss)]
|
||||
let diversity: Vec<f32> = (0..n)
|
||||
.map(|i| {
|
||||
let count = (0..n)
|
||||
.filter(|&j| j != i && corr_matrix[i][j].abs() > self.correlation_threshold)
|
||||
.count();
|
||||
count as f32 / (n.max(1) - 1) as f32
|
||||
})
|
||||
.collect();
|
||||
|
||||
CorrelationResult {
|
||||
matrix: corr_matrix,
|
||||
clusters,
|
||||
diversity,
|
||||
n_active: n,
|
||||
}
|
||||
}
|
||||
|
||||
/// Simple cluster assignment via thresholded correlation.
|
||||
fn find_clusters(&self, corr: &[Vec<f32>], n: usize) -> Vec<usize> {
|
||||
let mut cluster_id = vec![0usize; n];
|
||||
let mut next_cluster = 0usize;
|
||||
let mut assigned = vec![false; n];
|
||||
|
||||
for i in 0..n {
|
||||
if assigned[i] {
|
||||
continue;
|
||||
}
|
||||
cluster_id[i] = next_cluster;
|
||||
assigned[i] = true;
|
||||
|
||||
// BFS: assign same cluster to correlated BSSIDs
|
||||
let mut queue = vec![i];
|
||||
while let Some(current) = queue.pop() {
|
||||
for j in 0..n {
|
||||
if !assigned[j] && corr[current][j].abs() > self.correlation_threshold {
|
||||
cluster_id[j] = next_cluster;
|
||||
assigned[j] = true;
|
||||
queue.push(j);
|
||||
}
|
||||
}
|
||||
}
|
||||
next_cluster += 1;
|
||||
}
|
||||
cluster_id
|
||||
}
|
||||
|
||||
/// Reset all correlation histories.
|
||||
pub fn reset(&mut self) {
|
||||
for h in &mut self.histories {
|
||||
h.clear();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Result of correlation analysis.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct CorrelationResult {
|
||||
/// n x n Pearson correlation matrix.
|
||||
pub matrix: Vec<Vec<f32>>,
|
||||
/// Cluster assignment per BSSID.
|
||||
pub clusters: Vec<usize>,
|
||||
/// Per-BSSID spatial diversity score [0, 1].
|
||||
pub diversity: Vec<f32>,
|
||||
/// Number of active BSSIDs in this frame.
|
||||
pub n_active: usize,
|
||||
}
|
||||
|
||||
impl CorrelationResult {
|
||||
/// Number of distinct clusters.
|
||||
#[must_use]
|
||||
pub fn n_clusters(&self) -> usize {
|
||||
self.clusters.iter().copied().max().map_or(0, |m| m + 1)
|
||||
}
|
||||
|
||||
/// Mean absolute correlation (proxy for signal coherence).
|
||||
#[must_use]
|
||||
pub fn mean_correlation(&self) -> f32 {
|
||||
if self.n_active < 2 {
|
||||
return 0.0;
|
||||
}
|
||||
let mut sum = 0.0f32;
|
||||
let mut count = 0;
|
||||
for i in 0..self.n_active {
|
||||
for j in (i + 1)..self.n_active {
|
||||
sum += self.matrix[i][j].abs();
|
||||
count += 1;
|
||||
}
|
||||
}
|
||||
#[allow(clippy::cast_precision_loss)]
|
||||
let mean = if count == 0 { 0.0 } else { sum / count as f32 };
|
||||
mean
|
||||
}
|
||||
}
|
||||
|
||||
/// Pearson correlation coefficient between two equal-length slices.
|
||||
#[allow(clippy::cast_precision_loss)]
|
||||
fn pearson_r(x: &[f32], y: &[f32]) -> f32 {
|
||||
let n = x.len().min(y.len());
|
||||
if n < 2 {
|
||||
return 0.0;
|
||||
}
|
||||
let n_f = n as f32;
|
||||
|
||||
let mean_x: f32 = x.iter().take(n).sum::<f32>() / n_f;
|
||||
let mean_y: f32 = y.iter().take(n).sum::<f32>() / n_f;
|
||||
|
||||
let mut cov = 0.0f32;
|
||||
let mut var_x = 0.0f32;
|
||||
let mut var_y = 0.0f32;
|
||||
|
||||
for i in 0..n {
|
||||
let dx = x[i] - mean_x;
|
||||
let dy = y[i] - mean_y;
|
||||
cov += dx * dy;
|
||||
var_x += dx * dx;
|
||||
var_y += dy * dy;
|
||||
}
|
||||
|
||||
let denom = (var_x * var_y).sqrt();
|
||||
if denom < 1e-12 {
|
||||
0.0
|
||||
} else {
|
||||
cov / denom
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn pearson_perfect_correlation() {
|
||||
let x = vec![1.0, 2.0, 3.0, 4.0, 5.0];
|
||||
let y = vec![2.0, 4.0, 6.0, 8.0, 10.0];
|
||||
let r = pearson_r(&x, &y);
|
||||
assert!((r - 1.0).abs() < 1e-5, "perfect positive correlation: {r}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pearson_negative_correlation() {
|
||||
let x = vec![1.0, 2.0, 3.0, 4.0, 5.0];
|
||||
let y = vec![10.0, 8.0, 6.0, 4.0, 2.0];
|
||||
let r = pearson_r(&x, &y);
|
||||
assert!((r - (-1.0)).abs() < 1e-5, "perfect negative correlation: {r}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pearson_no_correlation() {
|
||||
let x = vec![1.0, 2.0, 3.0, 4.0, 5.0];
|
||||
let y = vec![5.0, 1.0, 4.0, 2.0, 3.0]; // shuffled
|
||||
let r = pearson_r(&x, &y);
|
||||
assert!(r.abs() < 0.5, "low correlation expected: {r}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn correlator_basic_update() {
|
||||
let mut corr = BssidCorrelator::new(3, 10, 0.7);
|
||||
// Push several identical frames
|
||||
for _ in 0..5 {
|
||||
corr.update(&[1.0, 2.0, 3.0]);
|
||||
}
|
||||
let result = corr.update(&[1.0, 2.0, 3.0]);
|
||||
assert_eq!(result.n_active, 3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn correlator_detects_covarying_bssids() {
|
||||
let mut corr = BssidCorrelator::new(3, 20, 0.8);
|
||||
// BSSID 0 and 1 co-vary, BSSID 2 is independent
|
||||
for i in 0..20 {
|
||||
let v = i as f32;
|
||||
corr.update(&[v, v * 2.0, 5.0]); // 0 and 1 correlate, 2 is constant
|
||||
}
|
||||
let result = corr.update(&[20.0, 40.0, 5.0]);
|
||||
// BSSIDs 0 and 1 should be in the same cluster
|
||||
assert_eq!(
|
||||
result.clusters[0], result.clusters[1],
|
||||
"co-varying BSSIDs should cluster: {:?}",
|
||||
result.clusters
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mean_correlation_zero_for_one_bssid() {
|
||||
let result = CorrelationResult {
|
||||
matrix: vec![vec![1.0]],
|
||||
clusters: vec![0],
|
||||
diversity: vec![0.0],
|
||||
n_active: 1,
|
||||
};
|
||||
assert!((result.mean_correlation() - 0.0).abs() < 1e-5);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,288 @@
|
||||
//! Stage 7: BSSID fingerprint matching via cosine similarity.
|
||||
//!
|
||||
//! Stores reference BSSID amplitude patterns for known postures
|
||||
//! (standing, sitting, walking, empty) and classifies new observations
|
||||
//! by retrieving the nearest stored template.
|
||||
//!
|
||||
//! This is a pure-Rust implementation using cosine similarity. When
|
||||
//! `ruvector-nervous-system` becomes available, the inner store can
|
||||
//! be replaced with `ModernHopfield` for richer associative memory.
|
||||
|
||||
use crate::domain::result::PostureClass;
|
||||
|
||||
/// A stored posture fingerprint template.
|
||||
#[derive(Debug, Clone)]
|
||||
struct PostureTemplate {
|
||||
/// Reference amplitude pattern (normalised).
|
||||
pattern: Vec<f32>,
|
||||
/// The posture label for this template.
|
||||
label: PostureClass,
|
||||
}
|
||||
|
||||
/// BSSID fingerprint matcher using cosine similarity.
|
||||
pub struct FingerprintMatcher {
|
||||
/// Stored reference templates.
|
||||
templates: Vec<PostureTemplate>,
|
||||
/// Minimum cosine similarity for a match.
|
||||
confidence_threshold: f32,
|
||||
/// Expected dimension (number of BSSID slots).
|
||||
n_bssids: usize,
|
||||
}
|
||||
|
||||
impl FingerprintMatcher {
|
||||
/// Create a new fingerprint matcher.
|
||||
///
|
||||
/// - `n_bssids`: number of BSSID slots (pattern dimension).
|
||||
/// - `confidence_threshold`: minimum cosine similarity for a match.
|
||||
#[must_use]
|
||||
pub fn new(n_bssids: usize, confidence_threshold: f32) -> Self {
|
||||
Self {
|
||||
templates: Vec::new(),
|
||||
confidence_threshold,
|
||||
n_bssids,
|
||||
}
|
||||
}
|
||||
|
||||
/// Store a reference pattern with its posture label.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns an error if the pattern dimension does not match `n_bssids`.
|
||||
pub fn store_pattern(
|
||||
&mut self,
|
||||
pattern: Vec<f32>,
|
||||
label: PostureClass,
|
||||
) -> Result<(), String> {
|
||||
if pattern.len() != self.n_bssids {
|
||||
return Err(format!(
|
||||
"pattern dimension {} != expected {}",
|
||||
pattern.len(),
|
||||
self.n_bssids
|
||||
));
|
||||
}
|
||||
self.templates.push(PostureTemplate { pattern, label });
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Classify an observation by matching against stored fingerprints.
|
||||
///
|
||||
/// Returns the best-matching posture and similarity score, or `None`
|
||||
/// if no patterns are stored or similarity is below threshold.
|
||||
#[must_use]
|
||||
pub fn classify(&self, observation: &[f32]) -> Option<(PostureClass, f32)> {
|
||||
if self.templates.is_empty() || observation.len() != self.n_bssids {
|
||||
return None;
|
||||
}
|
||||
|
||||
let mut best_label = None;
|
||||
let mut best_sim = f32::NEG_INFINITY;
|
||||
|
||||
for tmpl in &self.templates {
|
||||
let sim = cosine_similarity(&tmpl.pattern, observation);
|
||||
if sim > best_sim {
|
||||
best_sim = sim;
|
||||
best_label = Some(tmpl.label);
|
||||
}
|
||||
}
|
||||
|
||||
match best_label {
|
||||
Some(label) if best_sim >= self.confidence_threshold => Some((label, best_sim)),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Match posture and return a structured result.
|
||||
#[must_use]
|
||||
pub fn match_posture(&self, observation: &[f32]) -> MatchResult {
|
||||
match self.classify(observation) {
|
||||
Some((posture, confidence)) => MatchResult {
|
||||
posture: Some(posture),
|
||||
confidence,
|
||||
matched: true,
|
||||
},
|
||||
None => MatchResult {
|
||||
posture: None,
|
||||
confidence: 0.0,
|
||||
matched: false,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/// Generate default templates from a baseline signal.
|
||||
///
|
||||
/// Creates heuristic patterns for standing, sitting, and empty by
|
||||
/// scaling the baseline amplitude pattern.
|
||||
pub fn generate_defaults(&mut self, baseline: &[f32]) {
|
||||
if baseline.len() != self.n_bssids {
|
||||
return;
|
||||
}
|
||||
|
||||
// Empty: very low amplitude (background noise only)
|
||||
let empty: Vec<f32> = baseline.iter().map(|&a| a * 0.1).collect();
|
||||
let _ = self.store_pattern(empty, PostureClass::Empty);
|
||||
|
||||
// Standing: moderate perturbation of some BSSIDs
|
||||
let standing: Vec<f32> = baseline
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, &a)| if i % 3 == 0 { a * 1.3 } else { a })
|
||||
.collect();
|
||||
let _ = self.store_pattern(standing, PostureClass::Standing);
|
||||
|
||||
// Sitting: different perturbation pattern
|
||||
let sitting: Vec<f32> = baseline
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, &a)| if i % 2 == 0 { a * 1.2 } else { a * 0.9 })
|
||||
.collect();
|
||||
let _ = self.store_pattern(sitting, PostureClass::Sitting);
|
||||
}
|
||||
|
||||
/// Number of stored patterns.
|
||||
#[must_use]
|
||||
pub fn num_patterns(&self) -> usize {
|
||||
self.templates.len()
|
||||
}
|
||||
|
||||
/// Clear all stored patterns.
|
||||
pub fn clear(&mut self) {
|
||||
self.templates.clear();
|
||||
}
|
||||
|
||||
/// Set the minimum similarity threshold for classification.
|
||||
pub fn set_confidence_threshold(&mut self, threshold: f32) {
|
||||
self.confidence_threshold = threshold;
|
||||
}
|
||||
}
|
||||
|
||||
/// Result of fingerprint matching.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct MatchResult {
|
||||
/// Matched posture class (None if no match).
|
||||
pub posture: Option<PostureClass>,
|
||||
/// Cosine similarity of the best match.
|
||||
pub confidence: f32,
|
||||
/// Whether a match was found above threshold.
|
||||
pub matched: bool,
|
||||
}
|
||||
|
||||
/// Cosine similarity between two vectors.
|
||||
fn cosine_similarity(a: &[f32], b: &[f32]) -> f32 {
|
||||
let n = a.len().min(b.len());
|
||||
if n == 0 {
|
||||
return 0.0;
|
||||
}
|
||||
|
||||
let mut dot = 0.0f32;
|
||||
let mut norm_a = 0.0f32;
|
||||
let mut norm_b = 0.0f32;
|
||||
|
||||
for i in 0..n {
|
||||
dot += a[i] * b[i];
|
||||
norm_a += a[i] * a[i];
|
||||
norm_b += b[i] * b[i];
|
||||
}
|
||||
|
||||
let denom = (norm_a * norm_b).sqrt();
|
||||
if denom < 1e-12 {
|
||||
0.0
|
||||
} else {
|
||||
dot / denom
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn empty_matcher_returns_none() {
|
||||
let matcher = FingerprintMatcher::new(4, 0.5);
|
||||
assert!(matcher.classify(&[1.0, 2.0, 3.0, 4.0]).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wrong_dimension_returns_none() {
|
||||
let mut matcher = FingerprintMatcher::new(4, 0.5);
|
||||
matcher
|
||||
.store_pattern(vec![1.0; 4], PostureClass::Standing)
|
||||
.unwrap();
|
||||
// Wrong dimension
|
||||
assert!(matcher.classify(&[1.0, 2.0]).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn store_and_recall() {
|
||||
let mut matcher = FingerprintMatcher::new(4, 0.5);
|
||||
|
||||
// Store distinct patterns
|
||||
matcher
|
||||
.store_pattern(vec![1.0, 0.0, 0.0, 0.0], PostureClass::Standing)
|
||||
.unwrap();
|
||||
matcher
|
||||
.store_pattern(vec![0.0, 1.0, 0.0, 0.0], PostureClass::Sitting)
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(matcher.num_patterns(), 2);
|
||||
|
||||
// Query close to "Standing" pattern
|
||||
let result = matcher.classify(&[0.9, 0.1, 0.0, 0.0]);
|
||||
if let Some((posture, sim)) = result {
|
||||
assert_eq!(posture, PostureClass::Standing);
|
||||
assert!(sim > 0.5, "similarity should be above threshold: {sim}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wrong_dim_store_rejected() {
|
||||
let mut matcher = FingerprintMatcher::new(4, 0.5);
|
||||
let result = matcher.store_pattern(vec![1.0, 2.0], PostureClass::Empty);
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn clear_removes_all() {
|
||||
let mut matcher = FingerprintMatcher::new(2, 0.5);
|
||||
matcher
|
||||
.store_pattern(vec![1.0, 0.0], PostureClass::Standing)
|
||||
.unwrap();
|
||||
assert_eq!(matcher.num_patterns(), 1);
|
||||
matcher.clear();
|
||||
assert_eq!(matcher.num_patterns(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cosine_similarity_identical() {
|
||||
let a = vec![1.0, 2.0, 3.0];
|
||||
let b = vec![1.0, 2.0, 3.0];
|
||||
let sim = cosine_similarity(&a, &b);
|
||||
assert!((sim - 1.0).abs() < 1e-5, "identical vectors: {sim}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cosine_similarity_orthogonal() {
|
||||
let a = vec![1.0, 0.0];
|
||||
let b = vec![0.0, 1.0];
|
||||
let sim = cosine_similarity(&a, &b);
|
||||
assert!(sim.abs() < 1e-5, "orthogonal vectors: {sim}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn match_posture_result() {
|
||||
let mut matcher = FingerprintMatcher::new(3, 0.5);
|
||||
matcher
|
||||
.store_pattern(vec![1.0, 0.0, 0.0], PostureClass::Standing)
|
||||
.unwrap();
|
||||
|
||||
let result = matcher.match_posture(&[0.95, 0.05, 0.0]);
|
||||
assert!(result.matched);
|
||||
assert_eq!(result.posture, Some(PostureClass::Standing));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn generate_defaults_creates_templates() {
|
||||
let mut matcher = FingerprintMatcher::new(4, 0.3);
|
||||
matcher.generate_defaults(&[1.0, 2.0, 3.0, 4.0]);
|
||||
assert_eq!(matcher.num_patterns(), 3); // Empty, Standing, Sitting
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
//! Signal Intelligence pipeline (Phase 2, ADR-022).
|
||||
//!
|
||||
//! Composes `RuVector` primitives into a multi-stage sensing pipeline
|
||||
//! that transforms multi-BSSID RSSI frames into presence, motion,
|
||||
//! and coarse vital sign estimates.
|
||||
//!
|
||||
//! ## Stages
|
||||
//!
|
||||
//! 1. [`predictive_gate`] -- residual gating via `PredictiveLayer`
|
||||
//! 2. [`attention_weighter`] -- BSSID attention weighting
|
||||
//! 3. [`correlator`] -- cross-BSSID Pearson correlation & clustering
|
||||
//! 4. [`motion_estimator`] -- multi-AP motion estimation
|
||||
//! 5. [`breathing_extractor`] -- coarse breathing rate extraction
|
||||
//! 6. [`quality_gate`] -- ruQu three-filter quality gate
|
||||
//! 7. [`fingerprint_matcher`] -- `ModernHopfield` posture fingerprinting
|
||||
//! 8. [`orchestrator`] -- full pipeline orchestrator
|
||||
|
||||
#[cfg(feature = "pipeline")]
|
||||
pub mod predictive_gate;
|
||||
#[cfg(feature = "pipeline")]
|
||||
pub mod attention_weighter;
|
||||
#[cfg(feature = "pipeline")]
|
||||
pub mod correlator;
|
||||
#[cfg(feature = "pipeline")]
|
||||
pub mod motion_estimator;
|
||||
#[cfg(feature = "pipeline")]
|
||||
pub mod breathing_extractor;
|
||||
#[cfg(feature = "pipeline")]
|
||||
pub mod quality_gate;
|
||||
#[cfg(feature = "pipeline")]
|
||||
pub mod fingerprint_matcher;
|
||||
#[cfg(feature = "pipeline")]
|
||||
pub mod orchestrator;
|
||||
|
||||
#[cfg(feature = "pipeline")]
|
||||
pub use orchestrator::WindowsWifiPipeline;
|
||||
@@ -0,0 +1,210 @@
|
||||
//! Stage 4: Multi-AP motion estimation.
|
||||
//!
|
||||
//! Combines per-BSSID residuals, attention weights, and correlation
|
||||
//! features to estimate overall motion intensity and classify
|
||||
//! motion level (None / Minimal / Moderate / High).
|
||||
|
||||
use crate::domain::result::MotionLevel;
|
||||
|
||||
/// Multi-AP motion estimator using weighted variance of BSSID residuals.
|
||||
pub struct MultiApMotionEstimator {
|
||||
/// EMA smoothing factor for motion score.
|
||||
alpha: f32,
|
||||
/// Running EMA of motion score.
|
||||
ema_motion: f32,
|
||||
/// Motion threshold for None->Minimal transition.
|
||||
threshold_minimal: f32,
|
||||
/// Motion threshold for Minimal->Moderate transition.
|
||||
threshold_moderate: f32,
|
||||
/// Motion threshold for Moderate->High transition.
|
||||
threshold_high: f32,
|
||||
}
|
||||
|
||||
impl MultiApMotionEstimator {
|
||||
/// Create a motion estimator with default thresholds.
|
||||
#[must_use]
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
alpha: 0.3,
|
||||
ema_motion: 0.0,
|
||||
threshold_minimal: 0.02,
|
||||
threshold_moderate: 0.10,
|
||||
threshold_high: 0.30,
|
||||
}
|
||||
}
|
||||
|
||||
/// Create with custom thresholds.
|
||||
#[must_use]
|
||||
pub fn with_thresholds(minimal: f32, moderate: f32, high: f32) -> Self {
|
||||
Self {
|
||||
alpha: 0.3,
|
||||
ema_motion: 0.0,
|
||||
threshold_minimal: minimal,
|
||||
threshold_moderate: moderate,
|
||||
threshold_high: high,
|
||||
}
|
||||
}
|
||||
|
||||
/// Estimate motion from weighted residuals.
|
||||
///
|
||||
/// - `residuals`: per-BSSID residual from `PredictiveGate`.
|
||||
/// - `weights`: per-BSSID attention weights from `AttentionWeighter`.
|
||||
/// - `diversity`: per-BSSID correlation diversity from `BssidCorrelator`.
|
||||
///
|
||||
/// Returns `MotionEstimate` with score and level.
|
||||
pub fn estimate(
|
||||
&mut self,
|
||||
residuals: &[f32],
|
||||
weights: &[f32],
|
||||
diversity: &[f32],
|
||||
) -> MotionEstimate {
|
||||
let n = residuals.len();
|
||||
if n == 0 {
|
||||
return MotionEstimate {
|
||||
score: 0.0,
|
||||
level: MotionLevel::None,
|
||||
weighted_variance: 0.0,
|
||||
n_contributing: 0,
|
||||
};
|
||||
}
|
||||
|
||||
// Weighted variance of residuals (body-sensitive BSSIDs contribute more)
|
||||
let mut weighted_sum = 0.0f32;
|
||||
let mut weight_total = 0.0f32;
|
||||
let mut n_contributing = 0usize;
|
||||
|
||||
#[allow(clippy::cast_precision_loss)]
|
||||
for (i, residual) in residuals.iter().enumerate() {
|
||||
let w = weights.get(i).copied().unwrap_or(1.0 / n as f32);
|
||||
let d = diversity.get(i).copied().unwrap_or(0.5);
|
||||
// Combine attention weight with diversity (correlated BSSIDs
|
||||
// that respond together are better indicators)
|
||||
let combined_w = w * (0.5 + 0.5 * d);
|
||||
weighted_sum += combined_w * residual.abs();
|
||||
weight_total += combined_w;
|
||||
|
||||
if residual.abs() > 0.001 {
|
||||
n_contributing += 1;
|
||||
}
|
||||
}
|
||||
|
||||
let weighted_variance = if weight_total > 1e-9 {
|
||||
weighted_sum / weight_total
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
|
||||
// EMA smoothing
|
||||
self.ema_motion = self.alpha * weighted_variance + (1.0 - self.alpha) * self.ema_motion;
|
||||
|
||||
let level = if self.ema_motion < self.threshold_minimal {
|
||||
MotionLevel::None
|
||||
} else if self.ema_motion < self.threshold_moderate {
|
||||
MotionLevel::Minimal
|
||||
} else if self.ema_motion < self.threshold_high {
|
||||
MotionLevel::Moderate
|
||||
} else {
|
||||
MotionLevel::High
|
||||
};
|
||||
|
||||
MotionEstimate {
|
||||
score: self.ema_motion,
|
||||
level,
|
||||
weighted_variance,
|
||||
n_contributing,
|
||||
}
|
||||
}
|
||||
|
||||
/// Reset the EMA state.
|
||||
pub fn reset(&mut self) {
|
||||
self.ema_motion = 0.0;
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for MultiApMotionEstimator {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
/// Result of motion estimation.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct MotionEstimate {
|
||||
/// Smoothed motion score (EMA of weighted variance).
|
||||
pub score: f32,
|
||||
/// Classified motion level.
|
||||
pub level: MotionLevel,
|
||||
/// Raw weighted variance before smoothing.
|
||||
pub weighted_variance: f32,
|
||||
/// Number of BSSIDs with non-zero residuals.
|
||||
pub n_contributing: usize,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn no_residuals_yields_no_motion() {
|
||||
let mut est = MultiApMotionEstimator::new();
|
||||
let result = est.estimate(&[], &[], &[]);
|
||||
assert_eq!(result.level, MotionLevel::None);
|
||||
assert!((result.score - 0.0).abs() < f32::EPSILON);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn zero_residuals_yield_no_motion() {
|
||||
let mut est = MultiApMotionEstimator::new();
|
||||
let residuals = vec![0.0, 0.0, 0.0];
|
||||
let weights = vec![0.33, 0.33, 0.34];
|
||||
let diversity = vec![0.5, 0.5, 0.5];
|
||||
let result = est.estimate(&residuals, &weights, &diversity);
|
||||
assert_eq!(result.level, MotionLevel::None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn large_residuals_yield_high_motion() {
|
||||
let mut est = MultiApMotionEstimator::new();
|
||||
let residuals = vec![5.0, 5.0, 5.0];
|
||||
let weights = vec![0.33, 0.33, 0.34];
|
||||
let diversity = vec![1.0, 1.0, 1.0];
|
||||
// Push several frames to overcome EMA smoothing
|
||||
for _ in 0..20 {
|
||||
est.estimate(&residuals, &weights, &diversity);
|
||||
}
|
||||
let result = est.estimate(&residuals, &weights, &diversity);
|
||||
assert_eq!(result.level, MotionLevel::High);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ema_smooths_transients() {
|
||||
let mut est = MultiApMotionEstimator::new();
|
||||
let big = vec![10.0, 10.0, 10.0];
|
||||
let zero = vec![0.0, 0.0, 0.0];
|
||||
let w = vec![0.33, 0.33, 0.34];
|
||||
let d = vec![0.5, 0.5, 0.5];
|
||||
|
||||
// One big spike followed by zeros
|
||||
est.estimate(&big, &w, &d);
|
||||
let r1 = est.estimate(&zero, &w, &d);
|
||||
let r2 = est.estimate(&zero, &w, &d);
|
||||
// Score should decay
|
||||
assert!(r2.score < r1.score, "EMA should decay: {} < {}", r2.score, r1.score);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn n_contributing_counts_nonzero() {
|
||||
let mut est = MultiApMotionEstimator::new();
|
||||
let residuals = vec![0.0, 1.0, 0.0, 2.0];
|
||||
let weights = vec![0.25; 4];
|
||||
let diversity = vec![0.5; 4];
|
||||
let result = est.estimate(&residuals, &weights, &diversity);
|
||||
assert_eq!(result.n_contributing, 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn default_creates_estimator() {
|
||||
let est = MultiApMotionEstimator::default();
|
||||
assert!((est.threshold_minimal - 0.02).abs() < f32::EPSILON);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,432 @@
|
||||
//! Stage 8: Pipeline orchestrator (Domain Service).
|
||||
//!
|
||||
//! `WindowsWifiPipeline` connects all pipeline stages (1-7) into a
|
||||
//! single processing step that transforms a `MultiApFrame` into an
|
||||
//! `EnhancedSensingResult`.
|
||||
//!
|
||||
//! This is the Domain Service described in ADR-022 section 3.2.
|
||||
|
||||
use crate::domain::frame::MultiApFrame;
|
||||
use crate::domain::result::{
|
||||
BreathingEstimate as DomainBreathingEstimate, EnhancedSensingResult,
|
||||
MotionEstimate as DomainMotionEstimate, MotionLevel, PostureClass, SignalQuality,
|
||||
Verdict as DomainVerdict,
|
||||
};
|
||||
|
||||
use super::attention_weighter::AttentionWeighter;
|
||||
use super::breathing_extractor::CoarseBreathingExtractor;
|
||||
use super::correlator::BssidCorrelator;
|
||||
use super::fingerprint_matcher::FingerprintMatcher;
|
||||
use super::motion_estimator::MultiApMotionEstimator;
|
||||
use super::predictive_gate::PredictiveGate;
|
||||
use super::quality_gate::{QualityGate, Verdict};
|
||||
|
||||
/// Configuration for the Windows `WiFi` sensing pipeline.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct PipelineConfig {
|
||||
/// Maximum number of BSSID slots.
|
||||
pub max_bssids: usize,
|
||||
/// Residual gating threshold (stage 1).
|
||||
pub gate_threshold: f32,
|
||||
/// Correlation window size in frames (stage 3).
|
||||
pub correlation_window: usize,
|
||||
/// Correlation threshold for co-varying classification (stage 3).
|
||||
pub correlation_threshold: f32,
|
||||
/// Minimum BSSIDs for a valid frame.
|
||||
pub min_bssids: usize,
|
||||
/// Enable breathing extraction (stage 5).
|
||||
pub enable_breathing: bool,
|
||||
/// Enable fingerprint matching (stage 7).
|
||||
pub enable_fingerprint: bool,
|
||||
/// Sample rate in Hz.
|
||||
pub sample_rate: f32,
|
||||
}
|
||||
|
||||
impl Default for PipelineConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
max_bssids: 32,
|
||||
gate_threshold: 0.05,
|
||||
correlation_window: 30,
|
||||
correlation_threshold: 0.7,
|
||||
min_bssids: 3,
|
||||
enable_breathing: true,
|
||||
enable_fingerprint: true,
|
||||
sample_rate: 2.0,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The complete Windows `WiFi` sensing pipeline (Domain Service).
|
||||
///
|
||||
/// Connects stages 1-7 into a single `process()` call that transforms
|
||||
/// a `MultiApFrame` into an `EnhancedSensingResult`.
|
||||
///
|
||||
/// Stages:
|
||||
/// 1. Predictive gating (EMA residual filter)
|
||||
/// 2. Attention weighting (softmax dot-product)
|
||||
/// 3. Spatial correlation (Pearson + clustering)
|
||||
/// 4. Motion estimation (weighted variance + EMA)
|
||||
/// 5. Breathing extraction (bandpass + zero-crossing)
|
||||
/// 6. Quality gate (three-filter: structural / shift / evidence)
|
||||
/// 7. Fingerprint matching (cosine similarity templates)
|
||||
pub struct WindowsWifiPipeline {
|
||||
gate: PredictiveGate,
|
||||
attention: AttentionWeighter,
|
||||
correlator: BssidCorrelator,
|
||||
motion: MultiApMotionEstimator,
|
||||
breathing: CoarseBreathingExtractor,
|
||||
quality: QualityGate,
|
||||
fingerprint: FingerprintMatcher,
|
||||
config: PipelineConfig,
|
||||
/// Whether fingerprint defaults have been initialised.
|
||||
fingerprints_initialised: bool,
|
||||
/// Frame counter.
|
||||
frame_count: u64,
|
||||
}
|
||||
|
||||
impl WindowsWifiPipeline {
|
||||
/// Create a new pipeline with default configuration.
|
||||
#[must_use]
|
||||
pub fn new() -> Self {
|
||||
Self::with_config(PipelineConfig::default())
|
||||
}
|
||||
|
||||
/// Create with default configuration (alias for `new`).
|
||||
#[must_use]
|
||||
pub fn with_defaults() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
|
||||
/// Create a new pipeline with custom configuration.
|
||||
#[must_use]
|
||||
pub fn with_config(config: PipelineConfig) -> Self {
|
||||
Self {
|
||||
gate: PredictiveGate::new(config.max_bssids, config.gate_threshold),
|
||||
attention: AttentionWeighter::new(1),
|
||||
correlator: BssidCorrelator::new(
|
||||
config.max_bssids,
|
||||
config.correlation_window,
|
||||
config.correlation_threshold,
|
||||
),
|
||||
motion: MultiApMotionEstimator::new(),
|
||||
breathing: CoarseBreathingExtractor::new(
|
||||
config.max_bssids,
|
||||
config.sample_rate,
|
||||
0.1,
|
||||
0.5,
|
||||
),
|
||||
quality: QualityGate::new(),
|
||||
fingerprint: FingerprintMatcher::new(config.max_bssids, 0.5),
|
||||
fingerprints_initialised: false,
|
||||
frame_count: 0,
|
||||
config,
|
||||
}
|
||||
}
|
||||
|
||||
/// Process a single multi-BSSID frame through all pipeline stages.
|
||||
///
|
||||
/// Returns an `EnhancedSensingResult` with motion, breathing,
|
||||
/// posture, and quality information.
|
||||
pub fn process(&mut self, frame: &MultiApFrame) -> EnhancedSensingResult {
|
||||
self.frame_count += 1;
|
||||
|
||||
let n = frame.bssid_count;
|
||||
|
||||
// Convert f64 amplitudes to f32 for pipeline stages.
|
||||
#[allow(clippy::cast_possible_truncation)]
|
||||
let amps_f32: Vec<f32> = frame.amplitudes.iter().map(|&a| a as f32).collect();
|
||||
|
||||
// Initialise fingerprint defaults on first frame with enough BSSIDs.
|
||||
if !self.fingerprints_initialised
|
||||
&& self.config.enable_fingerprint
|
||||
&& amps_f32.len() == self.config.max_bssids
|
||||
{
|
||||
self.fingerprint.generate_defaults(&s_f32);
|
||||
self.fingerprints_initialised = true;
|
||||
}
|
||||
|
||||
// Check minimum BSSID count.
|
||||
if n < self.config.min_bssids {
|
||||
return Self::make_empty_result(frame, n);
|
||||
}
|
||||
|
||||
// -- Stage 1: Predictive gating --
|
||||
let Some(residuals) = self.gate.gate(&s_f32) else {
|
||||
// Static environment, no body present.
|
||||
return Self::make_empty_result(frame, n);
|
||||
};
|
||||
|
||||
// -- Stage 2: Attention weighting --
|
||||
#[allow(clippy::cast_precision_loss)]
|
||||
let mean_residual =
|
||||
residuals.iter().map(|r| r.abs()).sum::<f32>() / residuals.len().max(1) as f32;
|
||||
let query = vec![mean_residual];
|
||||
let keys: Vec<Vec<f32>> = residuals.iter().map(|&r| vec![r]).collect();
|
||||
let values: Vec<Vec<f32>> = amps_f32.iter().map(|&a| vec![a]).collect();
|
||||
let (_weighted, weights) = self.attention.weight(&query, &keys, &values);
|
||||
|
||||
// -- Stage 3: Spatial correlation --
|
||||
let corr = self.correlator.update(&s_f32);
|
||||
|
||||
// -- Stage 4: Motion estimation --
|
||||
let motion = self.motion.estimate(&residuals, &weights, &corr.diversity);
|
||||
|
||||
// -- Stage 5: Breathing extraction (only when stationary) --
|
||||
let breathing = if self.config.enable_breathing && motion.level == MotionLevel::Minimal {
|
||||
self.breathing.extract(&residuals, &weights)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
// -- Stage 6: Quality gate --
|
||||
let quality_result = self.quality.evaluate(
|
||||
n,
|
||||
frame.mean_rssi(),
|
||||
f64::from(corr.mean_correlation()),
|
||||
motion.score,
|
||||
);
|
||||
|
||||
// -- Stage 7: Fingerprint matching --
|
||||
let posture = if self.config.enable_fingerprint {
|
||||
self.fingerprint.classify(&s_f32).map(|(p, _sim)| p)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
// Count body-sensitive BSSIDs (attention weight above 1.5x average).
|
||||
#[allow(clippy::cast_precision_loss)]
|
||||
let avg_weight = 1.0 / n.max(1) as f32;
|
||||
let sensitive_count = weights.iter().filter(|&&w| w > avg_weight * 1.5).count();
|
||||
|
||||
// Map internal quality gate verdict to domain Verdict.
|
||||
let domain_verdict = match &quality_result.verdict {
|
||||
Verdict::Permit => DomainVerdict::Permit,
|
||||
Verdict::Defer => DomainVerdict::Warn,
|
||||
Verdict::Deny(_) => DomainVerdict::Deny,
|
||||
};
|
||||
|
||||
// Build the domain BreathingEstimate if we have one.
|
||||
let domain_breathing = breathing.map(|b| DomainBreathingEstimate {
|
||||
rate_bpm: f64::from(b.bpm),
|
||||
confidence: f64::from(b.confidence),
|
||||
bssid_count: sensitive_count,
|
||||
});
|
||||
|
||||
EnhancedSensingResult {
|
||||
motion: DomainMotionEstimate {
|
||||
score: f64::from(motion.score),
|
||||
level: motion.level,
|
||||
contributing_bssids: motion.n_contributing,
|
||||
},
|
||||
breathing: domain_breathing,
|
||||
posture,
|
||||
signal_quality: SignalQuality {
|
||||
score: quality_result.quality,
|
||||
bssid_count: n,
|
||||
spectral_gap: f64::from(corr.mean_correlation()),
|
||||
mean_rssi_dbm: frame.mean_rssi(),
|
||||
},
|
||||
bssid_count: n,
|
||||
verdict: domain_verdict,
|
||||
}
|
||||
}
|
||||
|
||||
/// Build an empty/gated result for frames that don't pass initial checks.
|
||||
fn make_empty_result(frame: &MultiApFrame, n: usize) -> EnhancedSensingResult {
|
||||
EnhancedSensingResult {
|
||||
motion: DomainMotionEstimate {
|
||||
score: 0.0,
|
||||
level: MotionLevel::None,
|
||||
contributing_bssids: 0,
|
||||
},
|
||||
breathing: None,
|
||||
posture: None,
|
||||
signal_quality: SignalQuality {
|
||||
score: 0.0,
|
||||
bssid_count: n,
|
||||
spectral_gap: 0.0,
|
||||
mean_rssi_dbm: frame.mean_rssi(),
|
||||
},
|
||||
bssid_count: n,
|
||||
verdict: DomainVerdict::Deny,
|
||||
}
|
||||
}
|
||||
|
||||
/// Store a reference fingerprint pattern.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns an error if the pattern dimension does not match `max_bssids`.
|
||||
pub fn store_fingerprint(
|
||||
&mut self,
|
||||
pattern: Vec<f32>,
|
||||
label: PostureClass,
|
||||
) -> Result<(), String> {
|
||||
self.fingerprint.store_pattern(pattern, label)
|
||||
}
|
||||
|
||||
/// Reset all pipeline state.
|
||||
pub fn reset(&mut self) {
|
||||
self.gate = PredictiveGate::new(self.config.max_bssids, self.config.gate_threshold);
|
||||
self.correlator = BssidCorrelator::new(
|
||||
self.config.max_bssids,
|
||||
self.config.correlation_window,
|
||||
self.config.correlation_threshold,
|
||||
);
|
||||
self.motion.reset();
|
||||
self.breathing.reset();
|
||||
self.quality.reset();
|
||||
self.fingerprint.clear();
|
||||
self.fingerprints_initialised = false;
|
||||
self.frame_count = 0;
|
||||
}
|
||||
|
||||
/// Number of frames processed.
|
||||
#[must_use]
|
||||
pub fn frame_count(&self) -> u64 {
|
||||
self.frame_count
|
||||
}
|
||||
|
||||
/// Current pipeline configuration.
|
||||
#[must_use]
|
||||
pub fn config(&self) -> &PipelineConfig {
|
||||
&self.config
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for WindowsWifiPipeline {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::collections::VecDeque;
|
||||
use std::time::Instant;
|
||||
|
||||
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 pipeline_creates_ok() {
|
||||
let pipeline = WindowsWifiPipeline::with_defaults();
|
||||
assert_eq!(pipeline.frame_count(), 0);
|
||||
assert_eq!(pipeline.config().max_bssids, 32);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn too_few_bssids_returns_deny() {
|
||||
let mut pipeline = WindowsWifiPipeline::new();
|
||||
let frame = make_frame(2, &[-60.0, -70.0]);
|
||||
let result = pipeline.process(&frame);
|
||||
assert_eq!(result.verdict, DomainVerdict::Deny);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn first_frame_increments_count() {
|
||||
let mut pipeline = WindowsWifiPipeline::with_config(PipelineConfig {
|
||||
min_bssids: 1,
|
||||
max_bssids: 4,
|
||||
..Default::default()
|
||||
});
|
||||
let frame = make_frame(4, &[-60.0, -65.0, -70.0, -75.0]);
|
||||
let _result = pipeline.process(&frame);
|
||||
assert_eq!(pipeline.frame_count(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn static_signal_returns_deny_after_learning() {
|
||||
let mut pipeline = WindowsWifiPipeline::with_config(PipelineConfig {
|
||||
min_bssids: 1,
|
||||
max_bssids: 4,
|
||||
..Default::default()
|
||||
});
|
||||
let frame = make_frame(4, &[-60.0, -65.0, -70.0, -75.0]);
|
||||
|
||||
// Train on static signal.
|
||||
pipeline.process(&frame);
|
||||
pipeline.process(&frame);
|
||||
pipeline.process(&frame);
|
||||
|
||||
// After learning, static signal should be gated (Deny verdict).
|
||||
let result = pipeline.process(&frame);
|
||||
assert_eq!(
|
||||
result.verdict,
|
||||
DomainVerdict::Deny,
|
||||
"static signal should be gated"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn changing_signal_increments_count() {
|
||||
let mut pipeline = WindowsWifiPipeline::with_config(PipelineConfig {
|
||||
min_bssids: 1,
|
||||
max_bssids: 4,
|
||||
..Default::default()
|
||||
});
|
||||
let baseline = make_frame(4, &[-60.0, -65.0, -70.0, -75.0]);
|
||||
|
||||
// Learn baseline.
|
||||
for _ in 0..5 {
|
||||
pipeline.process(&baseline);
|
||||
}
|
||||
|
||||
// Significant change should be noticed.
|
||||
let changed = make_frame(4, &[-60.0, -65.0, -70.0, -30.0]);
|
||||
pipeline.process(&changed);
|
||||
assert!(pipeline.frame_count() > 5);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reset_clears_state() {
|
||||
let mut pipeline = WindowsWifiPipeline::new();
|
||||
let frame = make_frame(4, &[-60.0, -65.0, -70.0, -75.0]);
|
||||
pipeline.process(&frame);
|
||||
assert_eq!(pipeline.frame_count(), 1);
|
||||
pipeline.reset();
|
||||
assert_eq!(pipeline.frame_count(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn default_creates_pipeline() {
|
||||
let _pipeline = WindowsWifiPipeline::default();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pipeline_throughput_benchmark() {
|
||||
let mut pipeline = WindowsWifiPipeline::with_config(PipelineConfig {
|
||||
min_bssids: 1,
|
||||
max_bssids: 4,
|
||||
..Default::default()
|
||||
});
|
||||
let frame = make_frame(4, &[-60.0, -65.0, -70.0, -75.0]);
|
||||
|
||||
let start = Instant::now();
|
||||
let n_frames = 10_000;
|
||||
for _ in 0..n_frames {
|
||||
pipeline.process(&frame);
|
||||
}
|
||||
let elapsed = start.elapsed();
|
||||
#[allow(clippy::cast_precision_loss)]
|
||||
let fps = n_frames as f64 / elapsed.as_secs_f64();
|
||||
println!("Pipeline throughput: {fps:.0} frames/sec ({elapsed:?} for {n_frames} frames)");
|
||||
assert!(fps > 100.0, "Pipeline should process >100 frames/sec, got {fps:.0}");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
//! Stage 1: Predictive gating via EMA-based residual filter.
|
||||
//!
|
||||
//! Suppresses static BSSIDs by computing residuals between predicted
|
||||
//! (EMA) and actual RSSI values. Only transmits frames where significant
|
||||
//! change is detected (body interaction).
|
||||
//!
|
||||
//! This is a lightweight pure-Rust implementation. When `ruvector-nervous-system`
|
||||
//! becomes available, the inner EMA predictor can be replaced with
|
||||
//! `PredictiveLayer` for more sophisticated prediction.
|
||||
|
||||
/// Wrapper around an EMA predictor for multi-BSSID residual gating.
|
||||
pub struct PredictiveGate {
|
||||
/// Per-BSSID EMA predictions.
|
||||
predictions: Vec<f32>,
|
||||
/// Whether a prediction has been initialised for each slot.
|
||||
initialised: Vec<bool>,
|
||||
/// EMA smoothing factor (higher = faster tracking).
|
||||
alpha: f32,
|
||||
/// Residual threshold for change detection.
|
||||
threshold: f32,
|
||||
/// Residuals from the last frame (for downstream use).
|
||||
last_residuals: Vec<f32>,
|
||||
/// Number of BSSID slots.
|
||||
n_bssids: usize,
|
||||
}
|
||||
|
||||
impl PredictiveGate {
|
||||
/// Create a new predictive gate.
|
||||
///
|
||||
/// - `n_bssids`: maximum number of tracked BSSIDs (subcarrier slots).
|
||||
/// - `threshold`: residual threshold for change detection (ADR-022 default: 0.05).
|
||||
#[must_use]
|
||||
pub fn new(n_bssids: usize, threshold: f32) -> Self {
|
||||
Self {
|
||||
predictions: vec![0.0; n_bssids],
|
||||
initialised: vec![false; n_bssids],
|
||||
alpha: 0.3,
|
||||
threshold,
|
||||
last_residuals: vec![0.0; n_bssids],
|
||||
n_bssids,
|
||||
}
|
||||
}
|
||||
|
||||
/// Process a frame. Returns `Some(residuals)` if body-correlated change
|
||||
/// is detected, `None` if the environment is static.
|
||||
pub fn gate(&mut self, amplitudes: &[f32]) -> Option<Vec<f32>> {
|
||||
let n = amplitudes.len().min(self.n_bssids);
|
||||
let mut residuals = vec![0.0f32; n];
|
||||
let mut max_residual = 0.0f32;
|
||||
|
||||
for i in 0..n {
|
||||
if self.initialised[i] {
|
||||
residuals[i] = amplitudes[i] - self.predictions[i];
|
||||
max_residual = max_residual.max(residuals[i].abs());
|
||||
// Update EMA
|
||||
self.predictions[i] =
|
||||
self.alpha * amplitudes[i] + (1.0 - self.alpha) * self.predictions[i];
|
||||
} else {
|
||||
// First observation: seed the prediction
|
||||
self.predictions[i] = amplitudes[i];
|
||||
self.initialised[i] = true;
|
||||
residuals[i] = amplitudes[i]; // first frame always transmits
|
||||
max_residual = f32::MAX;
|
||||
}
|
||||
}
|
||||
|
||||
self.last_residuals.clone_from(&residuals);
|
||||
|
||||
if max_residual > self.threshold {
|
||||
Some(residuals)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// Return the residuals from the last `gate()` call.
|
||||
#[must_use]
|
||||
pub fn last_residuals(&self) -> &[f32] {
|
||||
&self.last_residuals
|
||||
}
|
||||
|
||||
/// Update the threshold dynamically (e.g., from SONA adaptation).
|
||||
pub fn set_threshold(&mut self, threshold: f32) {
|
||||
self.threshold = threshold;
|
||||
}
|
||||
|
||||
/// Current threshold.
|
||||
#[must_use]
|
||||
pub fn threshold(&self) -> f32 {
|
||||
self.threshold
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn static_signal_is_gated() {
|
||||
let mut gate = PredictiveGate::new(4, 0.05);
|
||||
let signal = vec![1.0, 2.0, 3.0, 4.0];
|
||||
// First frame always transmits (no prediction yet)
|
||||
assert!(gate.gate(&signal).is_some());
|
||||
// After many repeated frames, EMA converges and residuals shrink
|
||||
for _ in 0..20 {
|
||||
gate.gate(&signal);
|
||||
}
|
||||
assert!(gate.gate(&signal).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn changing_signal_transmits() {
|
||||
let mut gate = PredictiveGate::new(4, 0.05);
|
||||
let signal1 = vec![1.0, 2.0, 3.0, 4.0];
|
||||
gate.gate(&signal1);
|
||||
// Let EMA converge
|
||||
for _ in 0..20 {
|
||||
gate.gate(&signal1);
|
||||
}
|
||||
|
||||
// Large change should be transmitted
|
||||
let signal2 = vec![1.0, 2.0, 3.0, 10.0];
|
||||
assert!(gate.gate(&signal2).is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn residuals_are_stored() {
|
||||
let mut gate = PredictiveGate::new(3, 0.05);
|
||||
let signal = vec![1.0, 2.0, 3.0];
|
||||
gate.gate(&signal);
|
||||
assert_eq!(gate.last_residuals().len(), 3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn threshold_can_be_updated() {
|
||||
let mut gate = PredictiveGate::new(2, 0.05);
|
||||
assert!((gate.threshold() - 0.05).abs() < f32::EPSILON);
|
||||
gate.set_threshold(0.1);
|
||||
assert!((gate.threshold() - 0.1).abs() < f32::EPSILON);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,261 @@
|
||||
//! Stage 6: Signal quality gate.
|
||||
//!
|
||||
//! Evaluates signal quality using three factors inspired by the ruQu
|
||||
//! three-filter architecture (structural integrity, distribution drift,
|
||||
//! evidence accumulation):
|
||||
//!
|
||||
//! - **Structural**: number of active BSSIDs (graph connectivity proxy).
|
||||
//! - **Shift**: RSSI drift from running baseline.
|
||||
//! - **Evidence**: accumulated weighted variance evidence.
|
||||
//!
|
||||
//! This is a pure-Rust implementation. When the `ruqu` crate becomes
|
||||
//! available, the inner filter can be replaced with `FilterPipeline`.
|
||||
|
||||
/// Configuration for the quality gate.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct QualityGateConfig {
|
||||
/// Minimum active BSSIDs for a "Permit" verdict.
|
||||
pub min_bssids: usize,
|
||||
/// Evidence threshold for "Permit" (accumulated variance).
|
||||
pub evidence_threshold: f64,
|
||||
/// RSSI drift threshold (dBm) for triggering a "Warn".
|
||||
pub drift_threshold: f64,
|
||||
/// Maximum evidence decay per frame.
|
||||
pub evidence_decay: f64,
|
||||
}
|
||||
|
||||
impl Default for QualityGateConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
min_bssids: 3,
|
||||
evidence_threshold: 0.5,
|
||||
drift_threshold: 10.0,
|
||||
evidence_decay: 0.95,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Quality gate combining structural, shift, and evidence filters.
|
||||
pub struct QualityGate {
|
||||
config: QualityGateConfig,
|
||||
/// Accumulated evidence score.
|
||||
evidence: f64,
|
||||
/// Running mean RSSI baseline for drift detection.
|
||||
prev_mean_rssi: Option<f64>,
|
||||
/// EMA smoothing factor for drift baseline.
|
||||
alpha: f64,
|
||||
}
|
||||
|
||||
impl QualityGate {
|
||||
/// Create a quality gate with default configuration.
|
||||
#[must_use]
|
||||
pub fn new() -> Self {
|
||||
Self::with_config(QualityGateConfig::default())
|
||||
}
|
||||
|
||||
/// Create a quality gate with custom configuration.
|
||||
#[must_use]
|
||||
pub fn with_config(config: QualityGateConfig) -> Self {
|
||||
Self {
|
||||
config,
|
||||
evidence: 0.0,
|
||||
prev_mean_rssi: None,
|
||||
alpha: 0.3,
|
||||
}
|
||||
}
|
||||
|
||||
/// Evaluate signal quality.
|
||||
///
|
||||
/// - `bssid_count`: number of active BSSIDs.
|
||||
/// - `mean_rssi_dbm`: mean RSSI across all BSSIDs.
|
||||
/// - `mean_correlation`: mean cross-BSSID correlation (spectral gap proxy).
|
||||
/// - `motion_score`: smoothed motion score from the estimator.
|
||||
///
|
||||
/// Returns a `QualityResult` with verdict and quality score.
|
||||
pub fn evaluate(
|
||||
&mut self,
|
||||
bssid_count: usize,
|
||||
mean_rssi_dbm: f64,
|
||||
mean_correlation: f64,
|
||||
motion_score: f32,
|
||||
) -> QualityResult {
|
||||
// --- Filter 1: Structural (BSSID count) ---
|
||||
let structural_ok = bssid_count >= self.config.min_bssids;
|
||||
|
||||
// --- Filter 2: Shift (RSSI drift detection) ---
|
||||
let drift = if let Some(prev) = self.prev_mean_rssi {
|
||||
(mean_rssi_dbm - prev).abs()
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
// Update baseline with EMA
|
||||
self.prev_mean_rssi = Some(match self.prev_mean_rssi {
|
||||
Some(prev) => self.alpha * mean_rssi_dbm + (1.0 - self.alpha) * prev,
|
||||
None => mean_rssi_dbm,
|
||||
});
|
||||
let drift_detected = drift > self.config.drift_threshold;
|
||||
|
||||
// --- Filter 3: Evidence accumulation ---
|
||||
// Motion and correlation both contribute positive evidence.
|
||||
let evidence_input = f64::from(motion_score) * 0.7 + mean_correlation * 0.3;
|
||||
self.evidence = self.evidence * self.config.evidence_decay + evidence_input;
|
||||
|
||||
// --- Quality score ---
|
||||
let quality = compute_quality_score(
|
||||
bssid_count,
|
||||
f64::from(motion_score),
|
||||
mean_correlation,
|
||||
drift_detected,
|
||||
);
|
||||
|
||||
// --- Verdict decision ---
|
||||
let verdict = if !structural_ok {
|
||||
Verdict::Deny("insufficient BSSIDs".to_string())
|
||||
} else if self.evidence < self.config.evidence_threshold * 0.5 || drift_detected {
|
||||
Verdict::Defer
|
||||
} else {
|
||||
Verdict::Permit
|
||||
};
|
||||
|
||||
QualityResult {
|
||||
verdict,
|
||||
quality,
|
||||
drift_detected,
|
||||
}
|
||||
}
|
||||
|
||||
/// Reset the gate state.
|
||||
pub fn reset(&mut self) {
|
||||
self.evidence = 0.0;
|
||||
self.prev_mean_rssi = None;
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for QualityGate {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
/// Quality verdict from the gate.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct QualityResult {
|
||||
/// Filter decision.
|
||||
pub verdict: Verdict,
|
||||
/// Signal quality score [0, 1].
|
||||
pub quality: f64,
|
||||
/// Whether environmental drift was detected.
|
||||
pub drift_detected: bool,
|
||||
}
|
||||
|
||||
/// Simplified quality gate verdict.
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub enum Verdict {
|
||||
/// Reading passed all quality gates and is reliable.
|
||||
Permit,
|
||||
/// Reading failed quality checks with a reason.
|
||||
Deny(String),
|
||||
/// Evidence still accumulating.
|
||||
Defer,
|
||||
}
|
||||
|
||||
impl Verdict {
|
||||
/// Returns true if this verdict permits the reading.
|
||||
#[must_use]
|
||||
pub fn is_permit(&self) -> bool {
|
||||
matches!(self, Self::Permit)
|
||||
}
|
||||
}
|
||||
|
||||
/// Compute a quality score from pipeline metrics.
|
||||
#[allow(clippy::cast_precision_loss)]
|
||||
fn compute_quality_score(
|
||||
n_active: usize,
|
||||
weighted_variance: f64,
|
||||
mean_correlation: f64,
|
||||
drift: bool,
|
||||
) -> f64 {
|
||||
// 1. Number of active BSSIDs (more = better, diminishing returns)
|
||||
let bssid_factor = (n_active as f64 / 10.0).min(1.0);
|
||||
|
||||
// 2. Evidence strength (higher weighted variance = more signal)
|
||||
let evidence_factor = (weighted_variance * 10.0).min(1.0);
|
||||
|
||||
// 3. Correlation coherence (moderate correlation is best)
|
||||
let corr_factor = 1.0 - (mean_correlation - 0.5).abs() * 2.0;
|
||||
|
||||
// 4. Drift penalty
|
||||
let drift_penalty = if drift { 0.7 } else { 1.0 };
|
||||
|
||||
let raw =
|
||||
(bssid_factor * 0.3 + evidence_factor * 0.4 + corr_factor.max(0.0) * 0.3) * drift_penalty;
|
||||
raw.clamp(0.0, 1.0)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn new_gate_creates_ok() {
|
||||
let gate = QualityGate::new();
|
||||
assert!((gate.evidence - 0.0).abs() < f64::EPSILON);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn evaluate_with_good_signal() {
|
||||
let mut gate = QualityGate::new();
|
||||
// Pump several frames to build evidence.
|
||||
for _ in 0..20 {
|
||||
gate.evaluate(10, -60.0, 0.5, 0.3);
|
||||
}
|
||||
let result = gate.evaluate(10, -60.0, 0.5, 0.3);
|
||||
assert!(result.quality > 0.0, "quality should be positive");
|
||||
assert!(result.verdict.is_permit(), "should permit good signal");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn too_few_bssids_denied() {
|
||||
let mut gate = QualityGate::new();
|
||||
let result = gate.evaluate(1, -60.0, 0.5, 0.3);
|
||||
assert!(
|
||||
matches!(result.verdict, Verdict::Deny(_)),
|
||||
"too few BSSIDs should be denied"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn quality_increases_with_more_bssids() {
|
||||
let q_few = compute_quality_score(3, 0.1, 0.5, false);
|
||||
let q_many = compute_quality_score(10, 0.1, 0.5, false);
|
||||
assert!(q_many > q_few, "more BSSIDs should give higher quality");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn drift_reduces_quality() {
|
||||
let q_stable = compute_quality_score(5, 0.1, 0.5, false);
|
||||
let q_drift = compute_quality_score(5, 0.1, 0.5, true);
|
||||
assert!(q_drift < q_stable, "drift should reduce quality");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn verdict_is_permit_check() {
|
||||
assert!(Verdict::Permit.is_permit());
|
||||
assert!(!Verdict::Deny("test".to_string()).is_permit());
|
||||
assert!(!Verdict::Defer.is_permit());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn default_creates_gate() {
|
||||
let _gate = QualityGate::default();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reset_clears_state() {
|
||||
let mut gate = QualityGate::new();
|
||||
gate.evaluate(10, -60.0, 0.5, 0.3);
|
||||
gate.reset();
|
||||
assert!(gate.prev_mean_rssi.is_none());
|
||||
assert!((gate.evidence - 0.0).abs() < f64::EPSILON);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
//! Port definitions for the BSSID Acquisition bounded context.
|
||||
//!
|
||||
//! Hexagonal-architecture ports that abstract the WiFi scanning backend,
|
||||
//! enabling Tier 1 (netsh), Tier 2 (wlanapi FFI), and test-double adapters
|
||||
//! to be swapped transparently.
|
||||
|
||||
mod scan_port;
|
||||
|
||||
pub use scan_port::WlanScanPort;
|
||||
@@ -0,0 +1,17 @@
|
||||
//! The primary port (driving side) for WiFi BSSID scanning.
|
||||
|
||||
use crate::domain::bssid::BssidObservation;
|
||||
use crate::error::WifiScanError;
|
||||
|
||||
/// Port that abstracts the platform WiFi scanning backend.
|
||||
///
|
||||
/// Implementations include:
|
||||
/// - [`crate::adapter::NetshBssidScanner`] -- Tier 1, subprocess-based.
|
||||
/// - Future: `WlanApiBssidScanner` -- Tier 2, native FFI (feature-gated).
|
||||
pub trait WlanScanPort: Send + Sync {
|
||||
/// Perform a scan and return all currently visible BSSIDs.
|
||||
fn scan(&self) -> Result<Vec<BssidObservation>, WifiScanError>;
|
||||
|
||||
/// Return the BSSID to which the adapter is currently connected, if any.
|
||||
fn connected(&self) -> Result<Option<BssidObservation>, WifiScanError>;
|
||||
}
|
||||
Reference in New Issue
Block a user