Skip to content

HeartRateExtractor.extract() always returns None with weights=[] — PyO3 binding misnames Rust phases param as weights (2.0.0a1) #1225

Description

@trevor-commits

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() == 0n == 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)

  1. 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.
  2. 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).

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't working

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions