mirror of
https://github.com/ruvnet/RuView
synced 2026-06-19 11:53:19 +00:00
feat(adr-117/p3+p3.5): vitals + BFLD bindings
P3 — Vital sign extraction bindings (wifi-densepose-vitals): - VitalStatus enum (eq, eq_int, hash, frozen) — Valid/Degraded/Unreliable/Unavailable - VitalEstimate (frozen) — value_bpm + confidence + status - VitalReading (frozen) — HR + BR + signal quality composite - BreathingExtractor — 0.1–0.5 Hz bandpass + zero-crossing - HeartRateExtractor — 0.8–2.0 Hz bandpass + autocorrelation - py.allow_threads on extract() hot loops (Q5 audit confirmed core/vitals/signal are pure-sync — zero tokio deps, safe to release GIL with no embedded runtime needed) - 17 tests covering construction, getters, frozen immutability, esp32_default + explicit ctors, synthetic-signal end-to-end P3.5 — BFLD bindings (forward-compat surface, stub Rust): - BfldKind enum — CompressedHE20/40/80/160 + UncompressedHT20/40 with n_subcarriers, bandwidth_mhz, is_he metadata getters - BfldFrame (frozen) — from_compressed_feedback() accepts numpy Complex64 ndarray [Nr x Nc x Nsc], validates dims against kind, feedback_matrix() returns lossless roundtrip ndarray - BfldReport — aggregates frames, rejects mismatched kinds, computes inverse-CV coherence score - 19 tests covering all 6 PHY variants + numpy roundtrip + dim-mismatch error + aggregation - Real Rust ingestion (wifi-densepose-bfld crate) lands post-v2.0 per ADR-117 §11.11/12 — Python API will not change Total Python test count: 93 (was 57, +36 P3+P3.5). All passing. Refs: docs/adr/ADR-117-pip-wifi-densepose-modernization.md Refs: #785 Co-Authored-By: claude-flow <ruv@ruv.net>
This commit is contained in:
@@ -0,0 +1,263 @@
|
||||
"""ADR-117 P3.5 — Tests for BFLD (Beamforming Feedback Loop Data) bindings.
|
||||
|
||||
These tests cover the *stub-Rust-backed* forward-compatible Python
|
||||
surface defined in ADR-117 §5.7a. The real Rust ingestion crate
|
||||
(`wifi-densepose-bfld`) lands post-v2.0; this test suite locks in the
|
||||
Python API so a future swap-in is non-breaking.
|
||||
|
||||
Coverage:
|
||||
|
||||
- BfldKind enum — HE20/40/80/160 + HT20/40 variants
|
||||
- BfldKind metadata getters — n_subcarriers, bandwidth_mhz, is_he
|
||||
- BfldFrame.from_compressed_feedback — happy path + dim mismatch
|
||||
- BfldFrame numpy round-trip — feedback_matrix returns ndarray
|
||||
- BfldReport — frame aggregation, kind-mismatch error, coherence score
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
|
||||
import numpy as np
|
||||
import pytest
|
||||
|
||||
import wifi_densepose
|
||||
from wifi_densepose import BfldFrame, BfldKind, BfldReport
|
||||
|
||||
|
||||
# ─── BfldKind enum ───────────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_bfld_kind_variants_exist() -> None:
|
||||
assert BfldKind.CompressedHE20 != BfldKind.CompressedHE40
|
||||
assert BfldKind.CompressedHE80 != BfldKind.CompressedHE160
|
||||
assert BfldKind.UncompressedHT20 != BfldKind.UncompressedHT40
|
||||
|
||||
|
||||
def test_bfld_kind_is_hashable() -> None:
|
||||
s = {BfldKind.CompressedHE80, BfldKind.CompressedHE80}
|
||||
assert len(s) == 1
|
||||
|
||||
|
||||
def test_bfld_kind_n_subcarriers_he() -> None:
|
||||
assert BfldKind.CompressedHE20.n_subcarriers == 242
|
||||
assert BfldKind.CompressedHE40.n_subcarriers == 484
|
||||
assert BfldKind.CompressedHE80.n_subcarriers == 996
|
||||
assert BfldKind.CompressedHE160.n_subcarriers == 1992
|
||||
|
||||
|
||||
def test_bfld_kind_n_subcarriers_ht() -> None:
|
||||
assert BfldKind.UncompressedHT20.n_subcarriers == 52
|
||||
assert BfldKind.UncompressedHT40.n_subcarriers == 108
|
||||
|
||||
|
||||
def test_bfld_kind_bandwidth_mhz() -> None:
|
||||
assert BfldKind.CompressedHE20.bandwidth_mhz == 20
|
||||
assert BfldKind.CompressedHE40.bandwidth_mhz == 40
|
||||
assert BfldKind.CompressedHE80.bandwidth_mhz == 80
|
||||
assert BfldKind.CompressedHE160.bandwidth_mhz == 160
|
||||
assert BfldKind.UncompressedHT20.bandwidth_mhz == 20
|
||||
assert BfldKind.UncompressedHT40.bandwidth_mhz == 40
|
||||
|
||||
|
||||
def test_bfld_kind_is_he_flag() -> None:
|
||||
assert BfldKind.CompressedHE20.is_he is True
|
||||
assert BfldKind.CompressedHE160.is_he is True
|
||||
assert BfldKind.UncompressedHT20.is_he is False
|
||||
assert BfldKind.UncompressedHT40.is_he is False
|
||||
|
||||
|
||||
def test_bfld_kind_repr() -> None:
|
||||
r = repr(BfldKind.CompressedHE80)
|
||||
assert "BfldKind" in r and "CompressedHE80" in r
|
||||
|
||||
|
||||
# ─── BfldFrame construction ──────────────────────────────────────────
|
||||
|
||||
|
||||
def _make_matrix(n_rows: int, n_cols: int, n_subcarriers: int) -> np.ndarray:
|
||||
"""Synthetic feedback matrix with non-trivial amplitudes so the
|
||||
mean_amplitude getter has something to chew on."""
|
||||
rng = np.random.default_rng(seed=42)
|
||||
real = rng.standard_normal((n_rows, n_cols, n_subcarriers)).astype(np.float64)
|
||||
imag = rng.standard_normal((n_rows, n_cols, n_subcarriers)).astype(np.float64)
|
||||
return (real + 1j * imag).astype(np.complex128)
|
||||
|
||||
|
||||
def test_bfld_frame_he80_happy_path() -> None:
|
||||
fb = _make_matrix(2, 1, 996)
|
||||
frame = BfldFrame.from_compressed_feedback(
|
||||
timestamp_ms=1234,
|
||||
sounding_index=42,
|
||||
sta_mac="aa:bb:cc:dd:ee:ff",
|
||||
kind=BfldKind.CompressedHE80,
|
||||
feedback_matrix=fb,
|
||||
)
|
||||
assert frame.timestamp_ms == 1234
|
||||
assert frame.sounding_index == 42
|
||||
assert frame.sta_mac == "aa:bb:cc:dd:ee:ff"
|
||||
assert frame.kind == BfldKind.CompressedHE80
|
||||
assert frame.n_rows == 2
|
||||
assert frame.n_cols == 1
|
||||
assert frame.n_subcarriers == 996
|
||||
|
||||
|
||||
def test_bfld_frame_he160_2x2() -> None:
|
||||
fb = _make_matrix(2, 2, 1992)
|
||||
frame = BfldFrame.from_compressed_feedback(
|
||||
timestamp_ms=0,
|
||||
sounding_index=0,
|
||||
sta_mac="00:00:00:00:00:00",
|
||||
kind=BfldKind.CompressedHE160,
|
||||
feedback_matrix=fb,
|
||||
)
|
||||
assert frame.n_rows == 2
|
||||
assert frame.n_cols == 2
|
||||
assert frame.n_subcarriers == 1992
|
||||
|
||||
|
||||
def test_bfld_frame_ht20_legacy_path() -> None:
|
||||
fb = _make_matrix(1, 1, 52)
|
||||
frame = BfldFrame.from_compressed_feedback(
|
||||
timestamp_ms=0,
|
||||
sounding_index=0,
|
||||
sta_mac="aa:bb:cc:dd:ee:ff",
|
||||
kind=BfldKind.UncompressedHT20,
|
||||
feedback_matrix=fb,
|
||||
)
|
||||
assert frame.kind == BfldKind.UncompressedHT20
|
||||
assert frame.n_subcarriers == 52
|
||||
|
||||
|
||||
def test_bfld_frame_subcarrier_dim_mismatch_raises() -> None:
|
||||
# HE80 requires 996 subcarriers; pass 64 → ValueError.
|
||||
bad = _make_matrix(2, 1, 64)
|
||||
with pytest.raises(ValueError, match="subcarrier"):
|
||||
BfldFrame.from_compressed_feedback(
|
||||
timestamp_ms=0,
|
||||
sounding_index=0,
|
||||
sta_mac="aa:bb:cc:dd:ee:ff",
|
||||
kind=BfldKind.CompressedHE80,
|
||||
feedback_matrix=bad,
|
||||
)
|
||||
|
||||
|
||||
def test_bfld_frame_mean_amplitude_is_finite() -> None:
|
||||
fb = _make_matrix(2, 1, 996)
|
||||
frame = BfldFrame.from_compressed_feedback(
|
||||
timestamp_ms=0,
|
||||
sounding_index=0,
|
||||
sta_mac="aa:bb:cc:dd:ee:ff",
|
||||
kind=BfldKind.CompressedHE80,
|
||||
feedback_matrix=fb,
|
||||
)
|
||||
amp = frame.mean_amplitude
|
||||
assert math.isfinite(amp) and amp > 0.0
|
||||
|
||||
|
||||
def test_bfld_frame_numpy_roundtrip_preserves_shape() -> None:
|
||||
fb = _make_matrix(2, 1, 996)
|
||||
frame = BfldFrame.from_compressed_feedback(
|
||||
timestamp_ms=0,
|
||||
sounding_index=0,
|
||||
sta_mac="aa:bb:cc:dd:ee:ff",
|
||||
kind=BfldKind.CompressedHE80,
|
||||
feedback_matrix=fb,
|
||||
)
|
||||
out = frame.feedback_matrix()
|
||||
assert out.shape == (2, 1, 996)
|
||||
# Roundtrip should be lossless (Complex64 in, Complex64 out).
|
||||
assert np.allclose(out, fb.astype(np.complex128))
|
||||
|
||||
|
||||
def test_bfld_frame_repr_is_readable() -> None:
|
||||
fb = _make_matrix(2, 1, 996)
|
||||
frame = BfldFrame.from_compressed_feedback(
|
||||
timestamp_ms=0,
|
||||
sounding_index=0,
|
||||
sta_mac="aa:bb:cc:dd:ee:ff",
|
||||
kind=BfldKind.CompressedHE80,
|
||||
feedback_matrix=fb,
|
||||
)
|
||||
r = repr(frame)
|
||||
assert "BfldFrame" in r
|
||||
assert "996" in r
|
||||
assert "CompressedHE80" in r
|
||||
|
||||
|
||||
# ─── BfldReport ──────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_bfld_report_starts_empty() -> None:
|
||||
report = BfldReport()
|
||||
assert report.n_frames == 0
|
||||
assert report.kind is None
|
||||
assert report.timestamp_first is None
|
||||
assert report.timestamp_last is None
|
||||
assert report.coherence_score == 0.0
|
||||
|
||||
|
||||
def test_bfld_report_aggregates_homogeneous_frames() -> None:
|
||||
report = BfldReport()
|
||||
fb = _make_matrix(2, 1, 996)
|
||||
for i in range(5):
|
||||
frame = BfldFrame.from_compressed_feedback(
|
||||
timestamp_ms=1000 + i * 100,
|
||||
sounding_index=i,
|
||||
sta_mac="aa:bb:cc:dd:ee:ff",
|
||||
kind=BfldKind.CompressedHE80,
|
||||
feedback_matrix=fb,
|
||||
)
|
||||
report.add_frame(frame)
|
||||
assert report.n_frames == 5
|
||||
assert report.kind == BfldKind.CompressedHE80
|
||||
assert report.timestamp_first == 1000
|
||||
assert report.timestamp_last == 1400
|
||||
# Identical synthetic matrices → near-perfect coherence.
|
||||
assert report.coherence_score >= 0.99
|
||||
|
||||
|
||||
def test_bfld_report_rejects_mismatched_kind() -> None:
|
||||
report = BfldReport()
|
||||
fb_he80 = _make_matrix(2, 1, 996)
|
||||
fb_he40 = _make_matrix(2, 1, 484)
|
||||
he80 = BfldFrame.from_compressed_feedback(
|
||||
timestamp_ms=0,
|
||||
sounding_index=0,
|
||||
sta_mac="aa:bb:cc:dd:ee:ff",
|
||||
kind=BfldKind.CompressedHE80,
|
||||
feedback_matrix=fb_he80,
|
||||
)
|
||||
he40 = BfldFrame.from_compressed_feedback(
|
||||
timestamp_ms=0,
|
||||
sounding_index=0,
|
||||
sta_mac="aa:bb:cc:dd:ee:ff",
|
||||
kind=BfldKind.CompressedHE40,
|
||||
feedback_matrix=fb_he40,
|
||||
)
|
||||
report.add_frame(he80)
|
||||
with pytest.raises(ValueError, match="kind"):
|
||||
report.add_frame(he40)
|
||||
|
||||
|
||||
def test_bfld_report_repr_summarises() -> None:
|
||||
report = BfldReport()
|
||||
fb = _make_matrix(2, 1, 996)
|
||||
frame = BfldFrame.from_compressed_feedback(
|
||||
timestamp_ms=0,
|
||||
sounding_index=0,
|
||||
sta_mac="aa:bb:cc:dd:ee:ff",
|
||||
kind=BfldKind.CompressedHE80,
|
||||
feedback_matrix=fb,
|
||||
)
|
||||
report.add_frame(frame)
|
||||
r = repr(report)
|
||||
assert "BfldReport" in r
|
||||
assert "n_frames=1" in r
|
||||
|
||||
|
||||
# ─── Build feature flag ──────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_p3_5_bfld_in_build_features() -> None:
|
||||
assert "p3.5-bfld-bindings" in wifi_densepose.__build_features__
|
||||
@@ -0,0 +1,196 @@
|
||||
"""ADR-117 P3 — Tests for vital-sign extraction bindings.
|
||||
|
||||
Covers:
|
||||
|
||||
- VitalStatus enum (eq, eq_int, hash, frozen)
|
||||
- VitalEstimate construction + getters + immutability
|
||||
- VitalReading composite + getters
|
||||
- BreathingExtractor + HeartRateExtractor — esp32_default, explicit
|
||||
ctor, extract() return type, validation behaviour
|
||||
|
||||
The Rust pipeline is unit-tested in `v2/crates/wifi-densepose-vitals/`.
|
||||
These tests are deliberately scoped to the *binding* layer — does the
|
||||
Python surface return the right shapes, raise the right errors, and
|
||||
release the GIL safely.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
from random import Random
|
||||
|
||||
import pytest
|
||||
|
||||
import wifi_densepose
|
||||
from wifi_densepose import (
|
||||
BreathingExtractor,
|
||||
HeartRateExtractor,
|
||||
VitalEstimate,
|
||||
VitalReading,
|
||||
VitalStatus,
|
||||
)
|
||||
|
||||
|
||||
# ─── VitalStatus enum ────────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_vital_status_variants_present() -> None:
|
||||
assert VitalStatus.Valid != VitalStatus.Degraded
|
||||
assert VitalStatus.Unreliable != VitalStatus.Unavailable
|
||||
|
||||
|
||||
def test_vital_status_equality_against_int() -> None:
|
||||
# eq_int → enum can be compared to int (PyO3 0.22 surface)
|
||||
assert VitalStatus.Valid == 0
|
||||
assert VitalStatus.Unavailable == 3
|
||||
|
||||
|
||||
def test_vital_status_is_hashable() -> None:
|
||||
# frozen + hash → can be used as dict key / set member
|
||||
s = {VitalStatus.Valid, VitalStatus.Valid, VitalStatus.Degraded}
|
||||
assert len(s) == 2
|
||||
|
||||
|
||||
def test_vital_status_repr_contains_variant_name() -> None:
|
||||
r = repr(VitalStatus.Valid)
|
||||
assert "VitalStatus" in r and "Valid" in r
|
||||
|
||||
|
||||
# ─── VitalEstimate ───────────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_vital_estimate_construction_and_getters() -> None:
|
||||
est = VitalEstimate(value_bpm=72.4, confidence=0.85, status=VitalStatus.Valid)
|
||||
assert math.isclose(est.value_bpm, 72.4)
|
||||
assert math.isclose(est.confidence, 0.85)
|
||||
assert est.status == VitalStatus.Valid
|
||||
|
||||
|
||||
def test_vital_estimate_is_frozen() -> None:
|
||||
est = VitalEstimate(value_bpm=72.0, confidence=0.9, status=VitalStatus.Valid)
|
||||
with pytest.raises(AttributeError):
|
||||
est.value_bpm = 100.0 # type: ignore[misc]
|
||||
|
||||
|
||||
def test_vital_estimate_repr_is_readable() -> None:
|
||||
est = VitalEstimate(value_bpm=72.0, confidence=0.9, status=VitalStatus.Valid)
|
||||
r = repr(est)
|
||||
assert "VitalEstimate" in r
|
||||
assert "72" in r
|
||||
|
||||
|
||||
# ─── VitalReading ────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_vital_reading_construction_and_getters() -> None:
|
||||
br = VitalEstimate(value_bpm=14.0, confidence=0.9, status=VitalStatus.Valid)
|
||||
hr = VitalEstimate(value_bpm=72.0, confidence=0.8, status=VitalStatus.Degraded)
|
||||
reading = VitalReading(
|
||||
respiratory_rate=br,
|
||||
heart_rate=hr,
|
||||
subcarrier_count=56,
|
||||
signal_quality=0.77,
|
||||
timestamp_secs=1700000000.5,
|
||||
)
|
||||
assert reading.respiratory_rate.value_bpm == 14.0
|
||||
assert reading.heart_rate.status == VitalStatus.Degraded
|
||||
assert reading.subcarrier_count == 56
|
||||
assert math.isclose(reading.signal_quality, 0.77)
|
||||
assert math.isclose(reading.timestamp_secs, 1700000000.5)
|
||||
|
||||
|
||||
# ─── BreathingExtractor ──────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_breathing_esp32_default_constructs() -> None:
|
||||
br = BreathingExtractor.esp32_default()
|
||||
assert br is not None
|
||||
assert "BreathingExtractor" in repr(br)
|
||||
|
||||
|
||||
def test_breathing_explicit_ctor() -> None:
|
||||
br = BreathingExtractor(n_subcarriers=64, sample_rate=200.0, window_secs=20.0)
|
||||
assert br is not None
|
||||
|
||||
|
||||
def test_breathing_extract_returns_none_with_too_few_samples() -> None:
|
||||
"""One frame can't produce a 30-second window — must return None.
|
||||
|
||||
Verifies the binding propagates Rust's `Option<VitalEstimate>` →
|
||||
Python None correctly (vs raising or returning a default).
|
||||
"""
|
||||
br = BreathingExtractor.esp32_default()
|
||||
out = br.extract(residuals=[0.0] * 56, weights=[])
|
||||
assert out is None
|
||||
|
||||
|
||||
def test_breathing_extract_accepts_empty_weights() -> None:
|
||||
"""Empty weights vector means "equal weight per subcarrier" by
|
||||
convention (per breathing.rs)."""
|
||||
br = BreathingExtractor.esp32_default()
|
||||
out = br.extract(residuals=[0.01] * 56, weights=[])
|
||||
# Even with synthetic input it may return None until enough history
|
||||
# accumulates — what matters is that the call doesn't panic.
|
||||
assert out is None or isinstance(out, VitalEstimate)
|
||||
|
||||
|
||||
def test_breathing_extract_with_synthetic_signal() -> None:
|
||||
"""Drive the extractor with a synthetic 0.25 Hz sine (15 BPM) for
|
||||
enough samples to fill the 30-second window. Don't assert the exact
|
||||
BPM — just that the extractor *eventually* produces a result (rather
|
||||
than returning None forever)."""
|
||||
br = BreathingExtractor.esp32_default()
|
||||
sample_rate = 100.0
|
||||
target_freq = 0.25 # 15 BPM
|
||||
# Run 40 seconds of synthetic data — comfortably past the 30s window.
|
||||
n_samples = int(40 * sample_rate)
|
||||
weights = [1.0] * 56
|
||||
|
||||
produced_estimate = False
|
||||
rng = Random(42)
|
||||
for i in range(n_samples):
|
||||
t = i / sample_rate
|
||||
base = math.sin(2.0 * math.pi * target_freq * t)
|
||||
# Per-subcarrier residual: same signal + small per-carrier noise
|
||||
residuals = [base + rng.gauss(0.0, 0.01) for _ in range(56)]
|
||||
est = br.extract(residuals=residuals, weights=weights)
|
||||
if est is not None:
|
||||
produced_estimate = True
|
||||
assert isinstance(est.value_bpm, float)
|
||||
assert 0.0 <= est.confidence <= 1.0
|
||||
assert est.status in (
|
||||
VitalStatus.Valid,
|
||||
VitalStatus.Degraded,
|
||||
VitalStatus.Unreliable,
|
||||
VitalStatus.Unavailable,
|
||||
)
|
||||
break
|
||||
|
||||
assert produced_estimate, "BreathingExtractor never produced an estimate after 40s of synthetic data"
|
||||
|
||||
|
||||
# ─── HeartRateExtractor ──────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_heart_rate_esp32_default_constructs() -> None:
|
||||
hr = HeartRateExtractor.esp32_default()
|
||||
assert hr is not None
|
||||
assert "HeartRateExtractor" in repr(hr)
|
||||
|
||||
|
||||
def test_heart_rate_explicit_ctor() -> None:
|
||||
hr = HeartRateExtractor(n_subcarriers=64, sample_rate=200.0, window_secs=10.0)
|
||||
assert hr is not None
|
||||
|
||||
|
||||
def test_heart_rate_extract_returns_none_with_too_few_samples() -> None:
|
||||
hr = HeartRateExtractor.esp32_default()
|
||||
out = hr.extract(residuals=[0.0] * 56, weights=[])
|
||||
assert out is None
|
||||
|
||||
|
||||
# ─── Build feature flag ──────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_p3_vitals_in_build_features() -> None:
|
||||
assert "p3-vitals-bindings" in wifi_densepose.__build_features__
|
||||
Reference in New Issue
Block a user