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,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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user