Skip to content

Latest commit

 

History

History
401 lines (329 loc) · 20.2 KB

File metadata and controls

401 lines (329 loc) · 20.2 KB

ADR-013: Feature-Level Sensing on Commodity Gear (Option 3)

Status

Accepted — Implemented (36/36 unit tests pass, see v1/src/sensing/ and v1/tests/unit/test_sensing.py)

Date

2026-02-28

Context

Not Everyone Can Deploy Custom Hardware

ADR-012 specifies an ESP32 CSI mesh that provides real CSI data. However, it requires:

  • Purchasing ESP32 boards
  • Flashing custom firmware
  • ESP-IDF toolchain installation
  • Physical placement of nodes

For many users - especially those evaluating WiFi-DensePose or deploying in managed environments - modifying hardware is not an option. We need a sensing path that works with existing, unmodified consumer WiFi gear.

What Commodity Hardware Exposes

Standard WiFi drivers and tools expose several metrics without custom firmware:

Signal Source Availability Sampling Rate
RSSI (Received Signal Strength) iwconfig, iw, NetworkManager Universal 1-10 Hz
Noise floor iw dev wlan0 survey dump Most Linux drivers ~1 Hz
Link quality /proc/net/wireless Linux 1-10 Hz
MCS index / PHY rate iw dev wlan0 link Most drivers Per-packet
TX/RX bytes /sys/class/net/wlan0/statistics/ Universal Continuous
Retry count iw dev wlan0 station dump Most drivers ~1 Hz
Beacon interval timing iw dev wlan0 scan dump Universal Per-scan
Channel utilization iw dev wlan0 survey dump Most drivers ~1 Hz

RSSI is the primary signal. It varies when humans move through the propagation path between any transmitter-receiver pair. Research confirms RSSI-based sensing for:

  • Presence detection (single receiver, threshold on variance)
  • Device-free motion detection (RSSI variance increases with movement)
  • Coarse room-level localization (multi-receiver RSSI fingerprinting)
  • Breathing detection (specialized setups, marginal quality)

Research Support

  • RSSI-based presence: Youssef et al. (2007) demonstrated device-free passive detection using RSSI from multiple receivers with >90% accuracy.
  • RSSI breathing: Abdelnasser et al. (2015) showed respiration detection via RSSI variance in controlled settings with ~85% accuracy using 4+ receivers.
  • Device-free tracking: Multiple receivers with RSSI fingerprinting achieve room-level (3-5m) accuracy.

Decision

We will implement a Feature-Level Sensing module that extracts motion, presence, and coarse activity information from standard WiFi metrics available on any Linux machine without hardware modification.

Architecture

┌──────────────────────────────────────────────────────────────────────┐
│              Feature-Level Sensing Pipeline                           │
├──────────────────────────────────────────────────────────────────────┤
│                                                                       │
│  Data Sources (any Linux WiFi device):                               │
│  ┌─────────┐ ┌─────────┐ ┌─────────┐ ┌──────────────┐              │
│  │ RSSI    │ │ Noise   │ │ Link    │ │ Packet Stats │              │
│  │ Stream  │ │ Floor   │ │ Quality │ │ (TX/RX/Retry)│              │
│  └────┬────┘ └────┬────┘ └────┬────┘ └──────┬───────┘              │
│       │           │           │              │                       │
│       └───────────┴───────────┴──────────────┘                       │
│                           │                                          │
│                           ▼                                          │
│  ┌────────────────────────────────────────────────┐                  │
│  │           Feature Extraction Engine             │                  │
│  │                                                 │                  │
│  │  1. Rolling statistics (mean, var, skew, kurt)  │                  │
│  │  2. Spectral features (FFT of RSSI time series) │                  │
│  │  3. Change-point detection (CUSUM, PELT)        │                  │
│  │  4. Cross-receiver correlation                   │                  │
│  │  5. Packet timing jitter analysis               │                  │
│  └────────────────────────┬───────────────────────┘                  │
│                           │                                          │
│                           ▼                                          │
│  ┌────────────────────────────────────────────────┐                  │
│  │          Classification / Decision              │                  │
│  │                                                 │                  │
│  │  • Presence: RSSI variance > threshold          │                  │
│  │  • Motion class: spectral peak frequency        │                  │
│  │  • Occupancy change: change-point event         │                  │
│  │  • Confidence: cross-receiver agreement         │                  │
│  └────────────────────────┬───────────────────────┘                  │
│                           │                                          │
│                           ▼                                          │
│  ┌────────────────────────────────────────────────┐                  │
│  │         Output: Presence/Motion Events          │                  │
│  │                                                 │                  │
│  │  { "timestamp": "...",                          │                  │
│  │    "presence": true,                            │                  │
│  │    "motion_level": "active",                    │                  │
│  │    "confidence": 0.87,                          │                  │
│  │    "receivers_agreeing": 3,                     │                  │
│  │    "rssi_variance": 4.2 }                       │                  │
│  └────────────────────────────────────────────────┘                  │
└──────────────────────────────────────────────────────────────────────┘

Feature Extraction Specification

class RssiFeatureExtractor:
    """Extract sensing features from RSSI and link statistics.

    No custom hardware required. Works with any WiFi interface
    that exposes standard Linux wireless statistics.
    """

    def __init__(self, config: FeatureSensingConfig):
        self.window_size = config.window_size  # 30 seconds
        self.sampling_rate = config.sampling_rate  # 10 Hz
        self.rssi_buffer = deque(maxlen=self.window_size * self.sampling_rate)
        self.noise_buffer = deque(maxlen=self.window_size * self.sampling_rate)

    def extract_features(self) -> FeatureVector:
        rssi_array = np.array(self.rssi_buffer)

        return FeatureVector(
            # Time-domain statistics
            rssi_mean=np.mean(rssi_array),
            rssi_variance=np.var(rssi_array),
            rssi_skewness=scipy.stats.skew(rssi_array),
            rssi_kurtosis=scipy.stats.kurtosis(rssi_array),
            rssi_range=np.ptp(rssi_array),
            rssi_iqr=np.subtract(*np.percentile(rssi_array, [75, 25])),

            # Spectral features (FFT of RSSI time series)
            spectral_energy=self._spectral_energy(rssi_array),
            dominant_frequency=self._dominant_freq(rssi_array),
            breathing_band_power=self._band_power(rssi_array, 0.1, 0.5),  # Hz
            motion_band_power=self._band_power(rssi_array, 0.5, 3.0),    # Hz

            # Change-point features
            num_change_points=self._cusum_changes(rssi_array),
            max_step_magnitude=self._max_step(rssi_array),

            # Noise floor features (environment stability)
            noise_mean=np.mean(np.array(self.noise_buffer)),
            snr_estimate=np.mean(rssi_array) - np.mean(np.array(self.noise_buffer)),
        )

    def _spectral_energy(self, rssi: np.ndarray) -> float:
        """Total spectral energy excluding DC component."""
        spectrum = np.abs(scipy.fft.rfft(rssi - np.mean(rssi)))
        return float(np.sum(spectrum[1:] ** 2))

    def _dominant_freq(self, rssi: np.ndarray) -> float:
        """Dominant frequency in RSSI time series."""
        spectrum = np.abs(scipy.fft.rfft(rssi - np.mean(rssi)))
        freqs = scipy.fft.rfftfreq(len(rssi), d=1.0/self.sampling_rate)
        return float(freqs[np.argmax(spectrum[1:]) + 1])

    def _band_power(self, rssi: np.ndarray, low_hz: float, high_hz: float) -> float:
        """Power in a specific frequency band."""
        spectrum = np.abs(scipy.fft.rfft(rssi - np.mean(rssi))) ** 2
        freqs = scipy.fft.rfftfreq(len(rssi), d=1.0/self.sampling_rate)
        mask = (freqs >= low_hz) & (freqs <= high_hz)
        return float(np.sum(spectrum[mask]))

    def _cusum_changes(self, rssi: np.ndarray) -> int:
        """Count change points using CUSUM algorithm."""
        mean = np.mean(rssi)
        cusum_pos = np.zeros_like(rssi)
        cusum_neg = np.zeros_like(rssi)
        threshold = 3.0 * np.std(rssi)
        changes = 0
        for i in range(1, len(rssi)):
            cusum_pos[i] = max(0, cusum_pos[i-1] + rssi[i] - mean - 0.5)
            cusum_neg[i] = max(0, cusum_neg[i-1] - rssi[i] + mean - 0.5)
            if cusum_pos[i] > threshold or cusum_neg[i] > threshold:
                changes += 1
                cusum_pos[i] = 0
                cusum_neg[i] = 0
        return changes

Data Collection (No Root Required)

class LinuxWifiCollector:
    """Collect WiFi statistics from standard Linux interfaces.

    No root required for most operations.
    No custom drivers or firmware.
    Works with NetworkManager, wpa_supplicant, or raw iw.
    """

    def __init__(self, interface: str = "wlan0"):
        self.interface = interface

    def get_rssi(self) -> float:
        """Get current RSSI from connected AP."""
        # Method 1: /proc/net/wireless (no root)
        with open("/proc/net/wireless") as f:
            for line in f:
                if self.interface in line:
                    parts = line.split()
                    return float(parts[3].rstrip('.'))

        # Method 2: iw (no root for own station)
        result = subprocess.run(
            ["iw", "dev", self.interface, "link"],
            capture_output=True, text=True
        )
        for line in result.stdout.split('\n'):
            if 'signal:' in line:
                return float(line.split(':')[1].strip().split()[0])

        raise SensingError(f"Cannot read RSSI from {self.interface}")

    def get_noise_floor(self) -> float:
        """Get noise floor estimate."""
        result = subprocess.run(
            ["iw", "dev", self.interface, "survey", "dump"],
            capture_output=True, text=True
        )
        for line in result.stdout.split('\n'):
            if 'noise:' in line:
                return float(line.split(':')[1].strip().split()[0])
        return -95.0  # Default noise floor estimate

    def get_link_stats(self) -> dict:
        """Get link quality statistics."""
        result = subprocess.run(
            ["iw", "dev", self.interface, "station", "dump"],
            capture_output=True, text=True
        )
        stats = {}
        for line in result.stdout.split('\n'):
            if 'tx bytes:' in line:
                stats['tx_bytes'] = int(line.split(':')[1].strip())
            elif 'rx bytes:' in line:
                stats['rx_bytes'] = int(line.split(':')[1].strip())
            elif 'tx retries:' in line:
                stats['tx_retries'] = int(line.split(':')[1].strip())
            elif 'signal:' in line:
                stats['signal'] = float(line.split(':')[1].strip().split()[0])
        return stats

Classification Rules

class PresenceClassifier:
    """Rule-based presence and motion classifier.

    Uses simple, interpretable rules rather than ML to ensure
    transparency and debuggability.
    """

    def __init__(self, config: ClassifierConfig):
        self.variance_threshold = config.variance_threshold  # 2.0 dBm²
        self.motion_threshold = config.motion_threshold      # 5.0 dBm²
        self.spectral_threshold = config.spectral_threshold  # 10.0
        self.confidence_min_receivers = config.min_receivers  # 2

    def classify(self, features: FeatureVector,
                 multi_receiver: list[FeatureVector] = None) -> SensingResult:

        # Presence: RSSI variance exceeds empty-room baseline
        presence = features.rssi_variance > self.variance_threshold

        # Motion level
        if features.rssi_variance > self.motion_threshold:
            motion = MotionLevel.ACTIVE
        elif features.rssi_variance > self.variance_threshold:
            motion = MotionLevel.PRESENT_STILL
        else:
            motion = MotionLevel.ABSENT

        # Confidence from spectral energy and receiver agreement
        spectral_conf = min(1.0, features.spectral_energy / self.spectral_threshold)
        if multi_receiver:
            agreeing = sum(1 for f in multi_receiver
                          if (f.rssi_variance > self.variance_threshold) == presence)
            receiver_conf = agreeing / len(multi_receiver)
        else:
            receiver_conf = 0.5  # Single receiver = lower confidence

        confidence = 0.6 * spectral_conf + 0.4 * receiver_conf

        return SensingResult(
            presence=presence,
            motion_level=motion,
            confidence=confidence,
            dominant_frequency=features.dominant_frequency,
            breathing_band_power=features.breathing_band_power,
        )

Capability Matrix (Honest Assessment)

Capability Single Receiver 3 Receivers 6 Receivers Accuracy
Binary presence Yes Yes Yes 90-95%
Coarse motion (still/moving) Yes Yes Yes 85-90%
Room-level location No Marginal Yes 70-80%
Person count No Marginal Marginal 50-70%
Activity class (walk/sit/stand) Marginal Marginal Yes 60-75%
Respiration detection No Marginal Marginal 40-60%
Heartbeat No No No N/A
Body pose No No No N/A

Bottom line: Feature-level sensing on commodity gear does presence and motion well. It does NOT do pose estimation, heartbeat, or reliable respiration. Any claim otherwise would be dishonest.

Decision Matrix: Option 2 (ESP32) vs Option 3 (Commodity)

Factor ESP32 CSI (ADR-012) Commodity (ADR-013)
Headline capability Respiration + motion Presence + coarse motion
Hardware cost $54 (3-node kit) $0 (existing gear)
Setup time 2-4 hours 15 minutes
Technical barrier Medium (firmware flash) Low (pip install)
Data quality Real CSI (amplitude + phase) RSSI only
Multi-person Marginal Poor
Pose estimation Marginal No
Reproducibility High (controlled hardware) Medium (varies by hardware)
Public credibility High (real CSI artifact) Medium (RSSI is "obvious")

Proof Bundle for Commodity Sensing

v1/data/proof/commodity/
├── rssi_capture_30sec.json         # 30 seconds of RSSI from 3 receivers
├── rssi_capture_meta.json          # Hardware: Intel AX200, Router: TP-Link AX1800
├── scenario.txt                    # "Person walks through room at t=10s, sits at t=20s"
├── expected_features.json          # Feature extraction output
├── expected_classification.json    # Classification output
├── expected_features.sha256        # Verification hash
└── verify_commodity.py             # One-command verification

Integration with WiFi-DensePose Pipeline

The commodity sensing module outputs the same SensingResult type as the CSI pipeline, allowing graceful degradation:

class SensingBackend(Protocol):
    """Common interface for all sensing backends."""

    def get_features(self) -> FeatureVector: ...
    def get_capabilities(self) -> set[Capability]: ...

class CsiBackend(SensingBackend):
    """Full CSI pipeline (ESP32 or research NIC)."""
    def get_capabilities(self):
        return {Capability.PRESENCE, Capability.MOTION, Capability.RESPIRATION,
                Capability.LOCATION, Capability.POSE}

class CommodityBackend(SensingBackend):
    """RSSI-only commodity hardware."""
    def get_capabilities(self):
        return {Capability.PRESENCE, Capability.MOTION}

Consequences

Positive

  • Zero-cost entry: Works with existing WiFi hardware
  • 15-minute setup: pip install wifi-densepose && wdp sense --interface wlan0
  • Broad adoption: Any Linux laptop, Pi, or phone can participate
  • Honest capability reporting: get_capabilities() tells users exactly what works
  • Complements ESP32: Users start with commodity, upgrade to ESP32 for more capability
  • No mock data: Real RSSI from real hardware, deterministic pipeline

Negative

  • Limited capability: No pose, no heartbeat, marginal respiration
  • Hardware variability: RSSI calibration differs across chipsets
  • Environmental sensitivity: Commodity RSSI is more affected by interference than CSI
  • Not a "pose estimation" demo: This module honestly cannot do what the project name implies
  • Lower credibility ceiling: RSSI sensing is well-known; less impressive than CSI

Implementation Status

The full commodity sensing pipeline is implemented in v1/src/sensing/:

Module File Description
RSSI Collector rssi_collector.py LinuxWifiCollector (live hardware) + SimulatedCollector (deterministic testing) with ring buffer
Feature Extractor feature_extractor.py RssiFeatureExtractor with Hann-windowed FFT, band power (breathing 0.1-0.5 Hz, motion 0.5-3 Hz), CUSUM change-point detection
Classifier classifier.py PresenceClassifier with ABSENT/PRESENT_STILL/ACTIVE levels, confidence scoring
Backend backend.py CommodityBackend wiring collector → extractor → classifier, reports PRESENCE + MOTION capabilities

Test coverage: 36 tests in v1/tests/unit/test_sensing.py — all passing:

  • TestRingBuffer (4), TestSimulatedCollector (5), TestFeatureExtractor (8), TestCusum (4), TestPresenceClassifier (7), TestCommodityBackend (6), TestBandPower (2)

Dependencies: numpy, scipy (for FFT and spectral analysis)

Note: LinuxWifiCollector requires a connected Linux WiFi interface (/proc/net/wireless or iw). On Windows or disconnected interfaces, use SimulatedCollector for development and testing.

References