Home▸Articles▸Biosignals

Heart Rate Variability Explained: A Data Science Perspective

What is heart rate variability (HRV), why does it matter for health, and how to analyze HRV data with Python. Includes RMSSD, SDNN, frequency domain analysis, and a live dashboard.

mysimulator teamUpdated 2026-04≈ 5 min read▶ Open the simulation

Heart rate variability (HRV) is one of the most information-dense biosignals the human body produces. A single metric that reveals your nervous system state, recovery status, stress level, and cardiovascular health — all from the time intervals between heartbeats.

This guide explains HRV from first principles and shows you how to analyze it with Python and visualize it in a dashboard.

What Is HRV?

Your heart does not beat like a metronome. Between consecutive heartbeats there are tiny variations in timing — measured in milliseconds. These variations are HRV.

A healthy, well-rested person has high HRV — the nervous system is flexible and responsive. A stressed, sleep-deprived, or overtrained person has low HRV — the body is in a state of physiological strain.

The Autonomic Nervous System Connection

HRV directly reflects the balance between: - Sympathetic nervous system (fight-or-flight): reduces HRV, speeds up heart rate - Parasympathetic nervous system (rest-and-digest): increases HRV, slows heart rate

This is why HRV is such a sensitive marker — it responds to stress, sleep, exercise, illness, and even nutrition within hours.

Key HRV Metrics

Time Domain Metrics

These operate on the raw R-R interval (RRI) series — the millisecond gaps between heartbeats.

import numpy as np

rri = np.array([
    820, 815, 832, 810, 808, 825, 818, 830, 821, 814, 
    838, 812, 819, 827, 811, 824, 817, 833, 809, 822
])

# SDNN — Standard Deviation of all Normal-to-Normal intervals
sdnn = np.std(rri, ddof=1)
print(f"SDNN: {sdnn:.1f} ms")  # Healthy adult: 40-60 ms

# RMSSD — Root Mean Square of Successive Differences (reflects parasympathetic tone)
successive_diffs = np.diff(rri)
rmssd = np.sqrt(np.mean(successive_diffs ** 2))
print(f"RMSSD: {rmssd:.1f} ms")  # Healthy adult: 20-50 ms

# pNN50 — Percentage of intervals differing by >50 ms
pnn50 = np.mean(np.abs(successive_diffs) > 50) * 100
print(f"pNN50: {pnn50:.1f}%")  # Healthy adult: 3-20%

# Mean HR from RRI
mean_hr = 60000 / np.mean(rri)
print(f"Mean HR: {mean_hr:.1f} bpm")

Frequency Domain Metrics

Frequency analysis decomposes HRV into physiological bands using spectral analysis (FFT or Lomb-Scargle).

from scipy import signal
from scipy.interpolate import interp1d

def compute_hrv_frequency(rri_ms: np.ndarray, fs_interp: float = 4.0):
    """
    Compute LF/HF power ratio from R-R intervals.

    Args:
        rri_ms: Array of R-R intervals in milliseconds
        fs_interp: Interpolation frequency (Hz) — standard is 4 Hz

    Returns:
        dict with LF power, HF power, LF/HF ratio
    """
    # Convert RRI to time series
    cumulative_time = np.cumsum(rri_ms) / 1000.0  # seconds

    # Interpolate to uniform time grid (required for FFT)
    t_interp = np.arange(cumulative_time[0], cumulative_time[-1], 1.0 / fs_interp)
    rri_interp = interp1d(cumulative_time, rri_ms, kind='cubic')(t_interp)

    # Detrend to remove slow drifts
    rri_detrended = signal.detrend(rri_interp)

    # Welch periodogram
    freqs, psd = signal.welch(rri_detrended, fs=fs_interp, nperseg=len(rri_detrended))

    # Physiological frequency bands
    lf_mask = (freqs >= 0.04) & (freqs < 0.15)
    hf_mask = (freqs >= 0.15) & (freqs < 0.40)

    lf_power = np.trapz(psd[lf_mask], freqs[lf_mask])
    hf_power = np.trapz(psd[hf_mask], freqs[hf_mask])

    return {
        'lf_power': lf_power,
        'hf_power': hf_power,
        'lf_hf_ratio': lf_power / hf_power if hf_power > 0 else float('inf'),
    }

result = compute_hrv_frequency(rri)
print(f"LF Power:  {result['lf_power']:.2f} ms²")   # Low Frequency: 0.04–0.15 Hz (sympathetic + parasympathetic)
print(f"HF Power:  {result['hf_power']:.2f} ms²")   # High Frequency: 0.15–0.40 Hz (parasympathetic, respiration)
print(f"LF/HF:     {result['lf_hf_ratio']:.2f}")   # >2.0 = stress/sympathetic dominance

What the bands mean: - LF (0.04–0.15 Hz): Mixed sympathetic + parasympathetic. Responds to standing, stress. - HF (0.15–0.40 Hz): Purely parasympathetic. Linked to respiratory sinus arrhythmia. - LF/HF ratio: Sympathovagal balance. High (>2.0) indicates sympathetic dominance (stress).

Interpreting HRV Values

Metric Low (concern) Normal High (excellent)
RMSSD <15 ms 20–50 ms >60 ms
SDNN <30 ms 40–60 ms >80 ms
LF/HF >3.0 (stress) 1.5–2.0 <1.0 (recovery)

Important: HRV is highly individual. A value of 25 ms RMSSD may be excellent for one person and below average for another. Track trends over time rather than comparing to population norms.

Building an HRV Monitoring Pipeline

import pandas as pd
from datetime import datetime, timedelta

class HRVMonitor:
    def __init__(self, window_days: int = 7):
        self.window_days = window_days
        self.history: list[dict] = []

    def record_session(self, rri_ms: np.ndarray, timestamp: datetime = None):
        if timestamp is None:
            timestamp = datetime.now()

        metrics = {
            'timestamp': timestamp,
            'rmssd': np.sqrt(np.mean(np.diff(rri_ms) ** 2)),
            'sdnn': np.std(rri_ms, ddof=1),
            'mean_hr': 60000 / np.mean(rri_ms),
            'pnn50': np.mean(np.abs(np.diff(rri_ms)) > 50) * 100,
        }
        self.history.append(metrics)
        return metrics

    def rolling_baseline(self) -> dict:
        """Calculate rolling 7-day baseline for relative comparison."""
        if len(self.history) < 3:
            return {}

        df = pd.DataFrame(self.history)
        cutoff = datetime.now() - timedelta(days=self.window_days)
        recent = df[df['timestamp'] >= cutoff]

        return {
            'rmssd_baseline': recent['rmssd'].mean(),
            'rmssd_std': recent['rmssd'].std(),
            'readiness_score': self._readiness(recent),
        }

    def _readiness(self, df: pd.DataFrame) -> float:
        """Simple readiness score 0-100 based on recent trend."""
        if len(df) < 2:
            return 50.0
        trend = df['rmssd'].iloc[-1] - df['rmssd'].mean()
        # Normalize: +10ms above baseline = +20 readiness points
        score = 50 + min(max(trend * 2, -30), 30)
        return round(score, 1)

# Usage
monitor = HRVMonitor()
monitor.record_session(rri, datetime.now() - timedelta(days=1))
monitor.record_session(rri * 1.05, datetime.now())
print(monitor.rolling_baseline())

Live HRV Dashboard

For a technical deep dive into building these dashboards, see Building Data Dashboards with Next.js and Recharts.

Data Sources for HRV Analysis

Device Export Format Data Quality
Polar H10 CSV, Bluetooth Research grade
Garmin watches FIT files Consumer grade
Apple Watch HealthKit SDK Beat-to-beat (limited)
Oura Ring API + CSV Sleep-focused
Shimmer ECG CSV Medical research

Summary

HRV is a window into your autonomic nervous system — accessible, non-invasive, and information-rich. Key takeaways:

  1. RMSSD is the most practical metric — robust, parasympathetic-specific, works on short recordings
  2. Trends matter more than absolute values — track your personal baseline
  3. Morning measurements (3-5 minute, supine, before coffee) give the most consistent readings
  4. LF/HF ratio adds sympathovagal context but requires 5+ minute recordings

For a deeper dive into sleep and recovery data science, read Sleep Tracking with Data Science.

Try it live

Explore the related model hands-on: open Heart Rate Variability Lab and change its parameters while it runs. Everything stays in your browser.

▶ Open Heart Rate Variability Lab simulation

What did you find?

Add reproduction steps (optional)