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:
- RMSSD is the most practical metric — robust, parasympathetic-specific, works on short recordings
- Trends matter more than absolute values — track your personal baseline
- Morning measurements (3-5 minute, supine, before coffee) give the most consistent readings
- 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