Wi-Fi respiratory monitoring and heart rate from CSI: what works

By Wu Jun · Updated 2026-11-02 · 8 min read

Chest movement and heartbeats can change multipath, and that modulation may appear in CSI amplitude or phase [1]. A peak in a spectrum is not proof of a physiological signal, however. Fans, HVAC, walking, chain switching and hardware phase drift can all create a plausible-looking number.

This page gives a reproducible engineering route: calculate the true independent measurement rate, choose a relatively stable signal, resample by timestamp, use 30-second windows, gate unusable outputs and calibrate against an external reference. It is not medical advice, and the open dataset is not evidence of clinical accuracy.

0. Bottom line

Task Relative feasibility Main reason
Respiration rate Medium Slow motion and usually larger displacement, but still vulnerable to body motion and HVAC cycles
Heart rate Low to medium Tiny displacement; respiration harmonics, phase noise and multipath can dominate
Multiple people at once Low Reflections overlap and a single spectral peak cannot usually be assigned to one person
Through-wall or cross-room Not assumed Walls and range change SNR; every environment needs revalidation
Medical diagnosis Not applicable Consumer CSI and this project do not replace compliant medical equipment

The defensible design is not "print a bpm for every window." It is to emit a value together with quality metadata, or return Unavailable. Accuracy must be measured against a synchronised chest band, impedance device, pulse oximeter or manual count.

1. Record rate is not independent measurement rate

Our 10-minute capture contains 57,293 chain records, or about 95.5 records/s. One CSI measurement normally contains four or eight chain records that share the same sampling instant. Splitting on chain_info bit 15 gives 7,609 complete measurement groups, so the independent measurement rate is:

7,609 / 599.924 s ≈ 12.68 measurements/s

The full analysis script also removes 25 zero-heavy groups, using 7,583 measurements and obtaining about 12.64 Hz. This is above the 4 Hz Nyquist minimum for a 2 Hz heart-rate component, but the margin is not enormous. Spectral resolution, packet loss, timestamp jitter and window length still determine whether a result is usable.

Before plotting a spectrum:

  1. close measurements on chain_info bit 15;
  2. select one valid chain per group, or construct a two-antenna ratio;
  3. sort by the actual timestamp instead of inventing time from a record index and a fixed frame rate;
  4. inspect missing chains, 64-subcarrier records, all-zero I/Q and gaps longer than one second.

2. Which signal should you use?

Amplitude is easy to consume, but automatic gain, rate control and gross motion affect it. Phase contains finer displacement information, yet commodity NIC phase includes carrier and sampling frequency offsets plus hardware drift. Unwrapping one chain does not automatically remove those errors.

PhaseBeat uses CSI phase for respiration and heart-rate monitoring [2]. FarSense uses the CSI ratio of two antennas to suppress common-mode noise and extend respiration range [3]. A practical order to test is:

  1. phase difference or complex ratio between two antennas in the same measurement;
  2. phase difference between adjacent subcarriers;
  3. first principal component of multi-subcarrier amplitude changes;
  4. a single-chain amplitude baseline.

For every candidate, collect three classes first: empty room, still person and moving person. Check whether the feature tracks respiratory cycles rather than only detecting motion.

3. A deliberately conservative estimator

The functions below assume the input is a real-valued series produced by selecting one chain for each measurement. The estimator removes a linear trend, applies a Hann window and reports peak frequency plus two quality markers. Resample on the real timestamp grid first:

import numpy as np
from numpy.fft import rfft, rfftfreq

def resample_by_time(x, t, fs=20.0):
    x = np.asarray(x, dtype=float)
    t = np.asarray(t, dtype=float)
    order = np.argsort(t)
    t, x = t[order], x[order]
    tu = np.arange(t[0], t[-1], 1.0 / fs)
    xu = np.interp(tu, t, x)
    return xu, tu

def estimate_bpm(x, fs, band):
    x = np.asarray(x, dtype=float)
    n = len(x)
    if n < int(fs * 20):
        return None

    idx = np.arange(n, dtype=float)
    slope, intercept = np.polyfit(idx, x, 1)
    y = x - (slope * idx + intercept)
    y = (y - y.mean()) * np.hanning(n)

    power = np.abs(rfft(y)) ** 2
    freqs = rfftfreq(n, 1.0 / fs)
    mask = (freqs >= band[0]) & (freqs <= band[1])
    if not mask.any():
        return None

    band_power = power[mask]
    band_freqs = freqs[mask]
    k = int(np.argmax(band_power))
    peak_freq = band_freqs[k]

    floor = float(np.median(band_power)) + 1e-12
    prominence_db = 10.0 * np.log10((band_power[k] + 1e-12) / floor)
    band_ratio = float(band_power.sum() / (power.sum() + 1e-12))

    return {
        "bpm": float(peak_freq * 60.0),
        "prominence_db": float(prominence_db),
        "band_power_ratio": band_ratio,
    }

Example bands:

# Interpolation regularises time; it does not add physiological information.
xu, tu = resample_by_time(signal, timestamps, fs=20.0)

breath = estimate_bpm(xu, fs=20.0, band=(0.10, 0.50))  # 6–30 bpm
heart  = estimate_bpm(xu, fs=20.0, band=(0.80, 2.00))  # 48–120 bpm

A 30-second window has a theoretical bin spacing of 1 / 30 = 0.033 Hz, or about 2 bpm. That is not a promise of 2 bpm error. SNR, window leakage and non-stationarity still matter.

4. Thirty-second windows and availability gating

Start with a 30-second window and a 5-second hop. Respiration is covered by at least three cycles, while heart rate contains more. The tradeoff is at least 30 seconds of warm-up. Store quality fields with every estimate:

window = {
    "start_s": start,
    "end_s": end,
    "valid_sample_ratio": valid_count / expected_count,
    "max_gap_s": max_gap,
    "motion_score": motion_score,
    "band_power_ratio": result["band_power_ratio"],
    "prominence_db": result["prominence_db"],
    "bpm": result["bpm"],
}

An example gate:

def vitals_status(w, limits):
    if w["valid_sample_ratio"] < limits["min_valid_ratio"]:
        return "Unavailable: data coverage"
    if w["max_gap_s"] > limits["max_gap_s"]:
        return "Unavailable: gap"
    if w["motion_score"] > limits["max_motion"]:
        return "Unavailable: motion"
    if w["prominence_db"] < limits["min_prominence_db"]:
        return "Unavailable: weak peak"
    if w["band_power_ratio"] < limits["min_band_ratio"]:
        return "Unavailable: broadband noise"
    return "OK"

Do not copy these thresholds as universal constants. Collect labelled empty-room, still, talking, walking and posture-change data on the actual firmware, channel, link and installation. Choose thresholds on that data and report both false-positive rate and coverage.

5. A reproducible calibration protocol

This is an experimental design, not a completed accuracy result. Participants must give informed consent and be told that the work is for research, not diagnosis.

5.1 Setup

5.2 Capture sequence

  1. empty room for two minutes;
  2. seated and still for two minutes, without talking or changing posture;
  3. paced breathing at 10, 15 and 20 breaths/min, at least one minute per segment;
  4. normal breathing for two minutes with a synchronised reference;
  5. small in-place motion for 30 seconds, followed by 60 seconds still;
  6. repeat after changing distance or orientation, at least three times.

Do not use "I feel like my heart rate is 70" as ground truth. Use a time-synchronised pulse oximeter or another validated reference for research comparison only.

5.3 Store with every run

Field Purpose
UTC start time and monotonic clock align CSI with the reference device
device, firmware, channel, bandwidth reproduce the capture
distance, orientation, posture interpret multipath changes
reference bpm and update time compute error rather than judging waveform appearance
RSSI, SNR, selected chain detect weak links or chain switching
loss, maximum gap, valid-sample ratio explain Unavailable windows
empty/still/motion labels estimate misses and false positives

6. How to report metrics

Reporting only the window with the smallest error against a reference overstates performance. At minimum report:

When comparing two algorithms, use the same windows, reference timeline and gate. If one method reports only 20% of windows and has a lower MAE, show both the error and the coverage.

7. Common failure modes

Symptom Likely cause Response
Empty room repeatedly reports 15 bpm fan, HVAC or periodic router load capture an empty-room baseline and investigate periodic sources
Talking causes a jump to high frequency speech and jaw motion dominate chest micro-motion gate the window as unavailable during motion
Heart rate is always 3–4× respiration respiratory harmonics or nonlinear mixing inspect peak separability; do not relabel the strongest harmonic as heart rate
Selected chain changes every window some chains are empty or rate control changes fix a valid chain or use a two-antenna ratio
Uniform-looking time axis creates fake peaks using record index instead of timestamps resample on timestamps and inspect large gaps
Performance collapses in another room different multipath, range, attenuation and gain recalibrate; do not reuse old thresholds
Deep-learning accuracy cannot be reproduced same-subject leakage or overlapping train/test windows split by subject and date

8. What the public dataset can and cannot prove

The public 10-minute capture is useful for:

It has no synchronised chest-band or heart-rate reference, so it cannot support claims such as "2 bpm respiration error" or "5 bpm heart-rate error." Those results require a new consented capture with a reference device.

9. Minimum implementation checklist

References and data sources

Every external claim carries a source and an evidence grade (grading scheme). Grade E marks first-party, reproducible measurements from this project.

  1. Grade A · standard/review WiFi Sensing with Channel State Information: A Survey
    W. Ma, W. Zhou, X. Wang · ACM Computing Surveys · 2019
    doi:10.1145/3310194 · citations 857 · verified: CrossRef API, 2026-10-11 · The field's most-cited CSI sensing survey (857 citations, verified via CrossRef).
  2. Grade B · peer-reviewed PhaseBeat: Exploiting CSI Phase Data for Vital Sign Monitoring with Commodity WiFi Devices
    X. Wang, C. Yang, S. Mao · IEEE ICDCS 2017 · 2017
    doi:10.1109/icdcs.2017.206 · citations 325 · verified: CrossRef API, 2026-10-11 · A representative work on respiration/heart-rate monitoring from CSI phase (325 citations).
  3. Grade B · peer-reviewed FarSense: Pushing the Range Limit of WiFi-based Respiration Sensing with CSI Ratio of Two Antennas
    Y. Zeng, D. Wu, J. Xiong, et al. · Proceedings of the ACM on Interactive, Mobile, Wearable and Ubiquitous Technologies (IMWUT) · 2019
    doi:10.1145/3351279 · citations 461 · verified: CrossRef API, 2026-10-11 · Range limits for respiration sensing and the two-antenna CSI ratio method (461 citations).