Summary
HeartRateExtractor.extract() (PyPI wifi-densepose / ruview 2.0.0a1, PyO3 binding) returns None on every call when used per its own docstring (weights=[] = equal weights). The DSP itself works — the binding's second parameter is misnamed: the Rust core takes per-subcarrier phases, and an empty list zeroes the gate so nothing ever emits.
Reproduction (macOS arm64, py3.11, wheel built from sdist)
import math
from wifi_densepose import HeartRateExtractor
hr = HeartRateExtractor.esp32_default() # 56 subcarriers, 100 Hz, 15 s window
for i in range(6000): # 60 s of frames
t = i / 100.0
residuals = [0.05 * math.sin(2 * math.pi * 1.2 * t)] * 56 # clean 72 BPM tone
est = hr.extract(residuals=residuals, weights=[]) # per docstring
assert est is None # never emits — passes for all 6000 frames
Tested variants that all return None with weights=[]: amps 0.02–0.1, noise 0→0.002, combined 0.25 Hz + 1.2 Hz signal, up to 60 s, esp32_default() and explicit HeartRateExtractor(n_subcarriers=56, sample_rate=100.0, window_secs=15.0). BreathingExtractor works fine with weights=[] (recovered an injected 15.0 BPM exactly).
Root cause
v2/crates/wifi-densepose-vitals/src/heartrate.rs:90-94 (same in the 2.0.0a1 sdist):
pub fn extract(&mut self, residuals: &[f64], phases: &[f64]) -> Option<VitalEstimate> {
let n = residuals.len().min(self.n_subcarriers).min(phases.len());
if n == 0 {
return None;
}
The PyO3 binding python/src/bindings/vitals.rs:270 exposes that phases parameter as weights: Vec<f64> and passes it straight through — so the documented weights=[] makes phases.len() == 0 → n == 0 → permanent None. BreathingExtractor genuinely treats its second arg as optional weights, which makes the shared docstring wording actively misleading for the heart path.
Proof the DSP is fine
Passing any same-length phase array unblocks it and recovery is accurate:
phases = [i * 0.001 for i in range(56)]
est = hr.extract(residuals=residuals, weights=phases)
# after ~5 s: VitalEstimate(value_bpm=72.29, confidence=0.944, status=Valid) — injected 72.0
Suggested fix (either works; (1) preserves signature)
- In the heart binding, treat an empty second arg as neutral phases, matching breathing's empty-weights ergonomics:
let weights = if weights.is_empty() { vec![0.0; residuals.len()] } else { weights }; before the allow_threads call.
- Or rename the binding param to
phases and correct the docstring so callers know real per-subcarrier phase data is expected (and [] is invalid).
Related: ADR-117 / #785 (the PyO3 modernization this binding shipped under).
Summary
HeartRateExtractor.extract()(PyPIwifi-densepose/ruview2.0.0a1, PyO3 binding) returnsNoneon every call when used per its own docstring (weights=[]= equal weights). The DSP itself works — the binding's second parameter is misnamed: the Rust core takes per-subcarrierphases, and an empty list zeroes the gate so nothing ever emits.Reproduction (macOS arm64, py3.11, wheel built from sdist)
Tested variants that all return
Nonewithweights=[]: amps 0.02–0.1, noise 0→0.002, combined 0.25 Hz + 1.2 Hz signal, up to 60 s,esp32_default()and explicitHeartRateExtractor(n_subcarriers=56, sample_rate=100.0, window_secs=15.0).BreathingExtractorworks fine withweights=[](recovered an injected 15.0 BPM exactly).Root cause
v2/crates/wifi-densepose-vitals/src/heartrate.rs:90-94(same in the 2.0.0a1 sdist):The PyO3 binding
python/src/bindings/vitals.rs:270exposes thatphasesparameter asweights: Vec<f64>and passes it straight through — so the documentedweights=[]makesphases.len() == 0→n == 0→ permanentNone.BreathingExtractorgenuinely treats its second arg as optional weights, which makes the shared docstring wording actively misleading for the heart path.Proof the DSP is fine
Passing any same-length phase array unblocks it and recovery is accurate:
Suggested fix (either works; (1) preserves signature)
let weights = if weights.is_empty() { vec![0.0; residuals.len()] } else { weights };before theallow_threadscall.phasesand correct the docstring so callers know real per-subcarrier phase data is expected (and[]is invalid).Related: ADR-117 / #785 (the PyO3 modernization this binding shipped under).