Your Heart in Motion

A Hands-On Introduction to Digital Biomarker Research

wearables
ECG
digital biomarkers
teaching
Author
Published

August 26, 2026

NoteAbout this tutorial

The original workshop was written for the Digital Biomarker Discovery Pipeline and the BIG IDEAs Lab @ Duke for community engagement events. It can be adapted for students and groups of all ages!

It walks from data collection to raw sensor output of two common data types in wearable research - accelerometer (movement) and electrocardiography (electrical activity of the heart)

You are a digital biomarker researcher who studies health by analyzing signals collected from wearable devices during everyday life. Outside the clinic, people walk, climb stairs, laugh, feel stressed, get excited, and recover, and throughout all of this the heart continuously adjusts to meet the body’s needs. Your lab now uses the Polar H10, a chest-worn wearable used in clinical research and elite athletics, to capture physiology as it unfolds in real time.

Polar H10 Chest Strap

The Polar H10 records:

The Case Study

What happens to your heart when you start moving — and what happens after you stop?

More specifically:

  • Does your heart rate jump right away, or lag behind your movement?
  • Does your heart calm down as fast as it sped up?
  • Can we “see” activity just by looking at heart signals?

Data Collection

Option 1: Collect Your Own Data

Supplies

  • Polar H10 Strap
  • App to get raw data from the Polar – the App Store Polar App will not work for this!! (I custom built one based off of the Polar SDK)
  • Stopwatch and Paper/Excel - We use a synchronized clock such as this one to keep the paper record aligned with the device.

Protocol

One volunteer wears the Polar H10 chest strap, snug and centered under the chest, and recording starts before anything happens. The participant then moves through five phases:

Phase What the participant does
1. Baseline Sits quietly
2. Light activity Walks at a comfortable pace
3. Recovery Sits back down
4. Moderate/vigorous activity Jumping jacks or running in place
5. Final recovery Sits back down

A second person acts as recorder: they call each phase start and stop and write down the exact wall-clock time in HH:MM:SS, 24-hour format.

ImportantThe recorder has the hardest job (accurate data labeling is so important!!)

The sensor has no idea what the participant is doing. Every claim in this tutorial about “heart rate during walking” depends entirely on someone having written down when walking started!

The Polar App writes two whitespace-delimited text files, ECG.txt and ACC.txt.

Option 2: Sample Data

Everything below runs on three files. Download them into a data/ folder next to the notebook, or clone the repository and they are already in place.

ECG.txt · ACC.txt · timetracker.csv

ECG.txt — 127,093 samples at 130 Hz, whitespace-delimited, one header row.

Column Meaning
TIMESTAMP Nanoseconds since 2000-01-01 00:00:00 UTC (the Polar epoch, not the Unix epoch)
ECG(microV) Single-lead ECG amplitude in microvolts

ACC.txt — 24,624 samples at 25 Hz, same format.

Column Meaning
TIMESTAMP As above
X(mg), Y(mg), Z(mg) Acceleration per axis in milli-g; a stationary strap reads ~1000 mg total because of gravity

timetracker.csv — the recorder’s phase log, five rows.

Column Meaning
phase Protocol phase, numbered so it sorts chronologically
start_time, end_time Local wall clock (America/New_York), no timezone attached — this is what the recorder wrote down
duration_seconds Convenience column, derived

If the app reports a dropped stream it appends an ERROR line to the end of the file. There are none in this recording, but load_polar_file checks anyway.

Setup the code

The code and functions below get us started in processing the incoming data files from the Polar strap and our timestamps.

Code
import numpy as np
import pandas as pd
import matplotlib.pyplot as plt
import plotly.express as px
import plotly.graph_objects as go
from plotly.subplots import make_subplots
import neurokit2 as nk
from datetime import datetime, timezone

plt.rcParams.update({"figure.figsize": (8, 4.5), "figure.dpi": 130,
                     "axes.spines.top": False, "axes.spines.right": False})
Code
# --- Recording configuration -------------------------------------------------
ECG_FILE = "data/ECG.txt"
ACC_FILE = "data/ACC.txt"
PHASE_FILE = "data/timetracker.csv"

# Timezone the recorder's wall clock was in. The device writes UTC; the paper
# record is local time. Getting this wrong silently labels everything "Unknown".
LOCAL_TZ = "America/New_York"

ECG_FS = 130   # Hz, Polar H10 ECG stream
ACC_FS = 25    # Hz, confirmed from the timestamps below

# Seconds of smoothing applied to instantaneous heart rate. Must be short
# relative to your shortest phase, or you will smooth away the thing you
# are trying to measure.
HR_SMOOTH_SEC = 10

# Minimum NeuroKit signal-quality score for a beat to count toward heart rate.
ECG_QUALITY_MIN = 0.90

Helper functions

These four functions handle the unglamorous part: turning device timestamps into real clock times, reading the files, and attaching phase labels.

Loading and timestamp resolution
def resolve_timestamps(df, local_tz=LOCAL_TZ):
    """Convert Polar nanosecond timestamps (epoch 2000-01-01 UTC) to local wall
    clock, then drop the timezone so the values compare directly against the
    recorder's paper log."""
    polar_epoch = datetime(2000, 1, 1, tzinfo=timezone.utc)
    unix_epoch = datetime(1970, 1, 1, tzinfo=timezone.utc)
    offset_ns = int((polar_epoch - unix_epoch).total_seconds() * 1e9)

    df = df.copy()
    df["Timestamp"] = (
        pd.to_datetime(df["raw_time"] + offset_ns, unit="ns", utc=True)
          .dt.tz_convert(local_tz)
          .dt.tz_localize(None)
    )
    return df.drop(columns=["raw_time"])


def load_polar_file(path, value_cols):
    """Read a Polar text export. Returns (dataframe, list_of_device_errors).

    The app appends an ERROR line if the stream drops, so we look for those
    rather than assuming the file is clean."""
    df = pd.read_csv(path, sep=r"\s+", skiprows=1, header=None,
                     names=["raw_time"] + value_cols, engine="python")

    is_error = df["raw_time"].astype(str).str.contains("ERROR", case=False, na=False)
    errors = df.loc[is_error, "raw_time"].astype(str).tolist()
    df = df[~is_error]

    for col in ["raw_time"] + value_cols:
        df[col] = pd.to_numeric(df[col], errors="coerce")
    df = df.dropna(subset=["raw_time"] + value_cols)

    return resolve_timestamps(df), errors


def report_sampling_rate(df, name):
    dt_ms = df["Timestamp"].diff().dt.total_seconds().median() * 1000
    print(f"{name}: {len(df):>7,} samples | "
          f"{df.Timestamp.min():%H:%M:%S} to {df.Timestamp.max():%H:%M:%S} | "
          f"median interval {dt_ms:.2f} ms ({1000/dt_ms:.1f} Hz)")
Phase labelling
def label_by_phase(df, event_df, unknown_label="Unknown"):
    """Tag every sample with the phase whose window contains it."""
    out = df.copy()
    out["activity"] = unknown_label
    for _, evt in event_df.iterrows():
        in_window = (out["Timestamp"] >= evt["start_time"]) & (out["Timestamp"] <= evt["end_time"])
        out.loc[in_window, "activity"] = evt["phase"]
    return out


def collect_phase_times(phases, date=None):
    """Interactive entry of the recorder's paper log. Used live in the workshop;
    the published version reads the saved CSV instead."""
    date = date or datetime.today().strftime("%Y-%m-%d")
    records = []
    print("\nManual phase time entry — enter times as HH:MM:SS\n")
    for phase in phases:
        while True:
            try:
                start_dt = datetime.strptime(f"{date} {input(f'Start of {phase}: ')}", "%Y-%m-%d %H:%M:%S")
                end_dt = datetime.strptime(f"{date} {input(f'End of   {phase}: ')}", "%Y-%m-%d %H:%M:%S")
                if end_dt <= start_dt:
                    raise ValueError("End time must be after start time.")
                records.append({"phase": phase, "start_time": start_dt, "end_time": end_dt,
                                "duration_seconds": (end_dt - start_dt).total_seconds()})
                break
            except ValueError as err:
                print(f"  Invalid input: {err}. Try again.\n")
    return pd.DataFrame(records)

Log Timestamps

During the workshop this cell is run live, right after the protocol finishes, and the recorder reads their times off the paper log:

Code
phases = ["1_Baseline", "2_Light activity", "3_Recovery",
          "4_Moderate/vigorous activity", "5_Final recovery"]

event_df = collect_phase_times(phases)
event_df.to_csv(PHASE_FILE, index=False)

For this published version we read the saved timestap log (timestamp.csv) instead:

Code
event_df = pd.read_csv(PHASE_FILE, parse_dates=["start_time", "end_time"])
event_df
Table 1: Phase log for the recording in data/
phase start_time end_time duration_seconds
0 1_Baseline 2025-01-01 08:00:20 2025-01-01 08:02:20 120.0
1 2_Light activity 2025-01-01 08:02:25 2025-01-01 08:04:30 125.0
2 3_Recovery 2025-01-01 08:04:40 2025-01-01 08:06:35 115.0
3 4_Moderate/vigorous activity 2025-01-01 08:06:40 2025-01-01 08:07:37 57.0
4 5_Final recovery 2025-01-01 08:07:50 2025-01-01 08:09:05 75.0

Data Exploration

Load the Polar Files (ACC and ECG)

Code
ecg_df, ecg_errors = load_polar_file(ECG_FILE, ["ECG"])
acc_df, acc_errors = load_polar_file(ACC_FILE, ["X", "Y", "Z"])

report_sampling_rate(ecg_df, "ECG")
report_sampling_rate(acc_df, "ACC")

device_errors = ecg_errors + acc_errors
print(f"\nDevice-reported errors: {device_errors if device_errors else 'none'}")
ECG:  70,948 samples | 08:00:00 to 08:09:05 | median interval 7.69 ms (130.0 Hz)
ACC:  13,817 samples | 08:00:00 to 08:09:08 | median interval 39.66 ms (25.2 Hz)

Device-reported errors: none

Two things worth checking every single time you load a recording:

Does the sampling rate match what you configured? The ECG stream is 130 Hz as expected. The accelerometer is 25 Hz here.

Does the clock look right? The recording should land in the same part of the day your paper log does. Here the device wrote 13:00 UTC and the recorder wrote 08:00, and the two line up once you convert. If these don’t line up, it’s usually a timezone matching issue.

2.2 Attach phase labels

Code
acc_labeled = label_by_phase(acc_df, event_df)
ecg_labeled = label_by_phase(ecg_df, event_df)

coverage = (acc_labeled.activity != "Unknown").mean()
print(f"Accelerometer samples falling inside a labelled phase: {coverage:.1%}")
print(acc_labeled.activity.value_counts().to_string())
Accelerometer samples falling inside a labelled phase: 89.8%
activity
2_Light activity                3151
1_Baseline                      3025
3_Recovery                      2900
5_Final recovery                1891
4_Moderate/vigorous activity    1437
Unknown                         1413

2.3 What do the raw signals look like?

Here is the whole recording. The toggle in the top right shades the five phases.

Overview plot
# Hex so the same palette works in both Plotly and Matplotlib.
PHASE_COLORS = ["#66c2a5", "#fc8d62", "#8da0cb", "#e78ac3", "#a6d854", "#ffd92f"]

def plot_ecg_acc_with_phase_toggle(df, event_df, ecg_col="ECG_Clean",
                                   decimate=4, height=650):
    """Two stacked panels — accelerometer and ECG — with a show/hide phase overlay.

    `decimate` thins the traces before they are serialized into the page. At full
    resolution this figure is several megabytes of JSON."""
    d = df.iloc[::decimate]

    fig = make_subplots(rows=2, cols=1, shared_xaxes=True, vertical_spacing=0.07,
                        subplot_titles=("Accelerometer (X, Y, Z)", "ECG (cleaned)"))

    for col in ("X", "Y", "Z"):
        fig.add_trace(go.Scattergl(x=d.Timestamp, y=d[col], mode="lines",
                                   name=f"ACC {col}", opacity=0.75), row=1, col=1)

    fig.add_trace(go.Scattergl(x=d.Timestamp, y=d[ecg_col], mode="lines",
                               name="ECG", line=dict(color="#222")), row=2, col=1)

    shapes, annotations = [], []
    for i, r in event_df.reset_index(drop=True).iterrows():
        colour = PHASE_COLORS[i % len(PHASE_COLORS)]
        shapes.append(dict(type="rect", xref="x", yref="paper",
                           x0=r.start_time, x1=r.end_time, y0=0, y1=1,
                           fillcolor=colour, opacity=0.18,
                           line=dict(width=0), layer="below"))
        annotations.append(dict(x=r.start_time + (r.end_time - r.start_time) / 2,
                                xref="x", y=1.0, yref="paper", text=r.phase,
                                showarrow=False, font=dict(size=10),
                                bgcolor="rgba(255,255,255,0.75)"))

    fig.update_layout(
        shapes=shapes, annotations=annotations,
        height=height, hovermode="x unified",
        margin=dict(t=90, b=40, l=60, r=20),
        legend=dict(orientation="h", yanchor="bottom", y=1.06, xanchor="right", x=1),
        updatemenus=[dict(type="buttons", direction="left", showactive=True,
                          x=0.99, y=1.12, xanchor="right", yanchor="bottom",
                          buttons=[
                              dict(label="Hide phases", method="relayout",
                                   args=[{"shapes": [], "annotations": []}]),
                              dict(label="Show phases", method="relayout",
                                   args=[{"shapes": shapes, "annotations": annotations}]),
                          ])])
    fig.update_yaxes(title_text="mg", row=1, col=1)
    fig.update_yaxes(title_text="µV", row=2, col=1)
    return fig

Building the digital biomarkers

Accelerometer → movement intensity

The most common transformation is the vector magnitude:

\mathrm{ACC}_{\mathrm{mag}} = \sqrt{x^2 + y^2 + z^2}

This turns three-dimensional motion into one number per time point.

There is a wrinkle. The sensor measures gravity too, so a completely stationary strap still reads about 1000 mg. What actually distinguishes sitting from jumping is not the magnitude but how much it varies, so we take the rolling standard deviation of the magnitude over a one-second window.

Code
acc_labeled["ACC_Magnitude"] = np.sqrt(
    acc_labeled.X**2 + acc_labeled.Y**2 + acc_labeled.Z**2
)

# Movement = variability in magnitude, not magnitude itself (gravity is ~1000 mg).
acc_labeled["ActivityIntensity"] = (
    acc_labeled["ACC_Magnitude"]
    .rolling(ACC_FS, min_periods=1, center=True)
    .std()
    .fillna(0)
)

print(acc_labeled.groupby("activity")["ACC_Magnitude"].mean().round(0).to_string())
activity
1_Baseline                       988.0
2_Light activity                1000.0
3_Recovery                       987.0
4_Moderate/vigorous activity     986.0
5_Final recovery                 988.0
Unknown                          991.0

Notice how little the raw magnitude separates the phases — it hovers near 1000 mg throughout, because gravity dominates. The variability measure is what carries the signal.

ECG → Heart Rate

Getting from a voltage trace to beats per minute takes four steps:

  1. Detect the R-peaks. An ECG measures the electrical activity of the heart as it contracts. The most prominent feature is the R-peak, the tall spike. We filter the signal to remove noise and baseline drift, then run a peak-finding algorithm.

  2. Compute RR intervals, the time between consecutive beats: RR_i = t_{i+1} - t_i

  3. Convert to beats per minute: \text{HR} = 60 / RR

  4. Smooth. Real heart rate fluctuates beat to beat, influenced by breathing, posture, and movement.

ECG processing
def process_ecg(ecg_labeled, fs=ECG_FS, quality_min=ECG_QUALITY_MIN,
                smooth_sec=HR_SMOOTH_SEC):
    """Run NeuroKit's ECG pipeline and return a per-sample frame with cleaned
    signal, quality score, R-peak flags and smoothed heart rate."""
    raw = ecg_labeled["ECG"].to_numpy()
    signals, info = nk.ecg_process(raw, sampling_rate=fs, method="neurokit")

    # Discard heart-rate estimates from stretches the quality index distrusts,
    # then smooth what survives.
    rate = signals["ECG_Rate"].where(signals["ECG_Quality"] > quality_min)
    rate = rate.rolling(int(fs * smooth_sec), min_periods=1, center=True).mean()

    r_peaks = np.zeros(len(raw), dtype=int)
    r_peaks[info["ECG_R_Peaks"]] = 1

    return pd.DataFrame({
        "Timestamp": ecg_labeled["Timestamp"].values,
        "activity": ecg_labeled["activity"].values,
        "ECG_Clean": signals["ECG_Clean"].values,
        "ECG_Quality": signals["ECG_Quality"].values,
        "R_Peaks": r_peaks,
        "ECG_HeartRate": rate.values,
    }), info
Code
ecg_hr, ecg_info = process_ecg(ecg_labeled)

n_beats = int(ecg_hr.R_Peaks.sum())
duration_min = (ecg_hr.Timestamp.max() - ecg_hr.Timestamp.min()).total_seconds() / 60
usable = (ecg_hr.ECG_Quality > ECG_QUALITY_MIN).mean()

print(f"Detected beats:        {n_beats:,} over {duration_min:.1f} min "
      f"({n_beats/duration_min:.1f} beats/min average)")
print(f"Samples above the quality threshold: {usable:.1%}")
Detected beats:        920 over 9.1 min (101.1 beats/min average)
Samples above the quality threshold: 52.3%

Does the peak detection actually work?

Before trusting a single downstream number, look at the beats. Here are eight seconds from the baseline phase with the detected R-peaks marked.

Code
baseline_start = event_df.loc[event_df.phase.str.startswith("1"), "start_time"].iloc[0]
window = ecg_hr[(ecg_hr.Timestamp >= baseline_start + pd.Timedelta(seconds=30)) &
                (ecg_hr.Timestamp <= baseline_start + pd.Timedelta(seconds=38))]
beats = window[window.R_Peaks == 1]

fig, ax = plt.subplots()
ax.plot(window.Timestamp, window.ECG_Clean, lw=0.9, color="#333")
ax.plot(beats.Timestamp, beats.ECG_Clean, "o", ms=6, color="#d62728", label="R-peak")
ax.set_ylabel("ECG (µV)"); ax.set_xlabel("Time")
ax.legend(frameon=False)
ax.set_title("R-peak detection during baseline")
plt.tight_layout(); plt.show()
Figure 1: Eight seconds of cleaned ECG with detected R-peaks. Every spike should have exactly one marker.
TipThis is the check people skip

If a marker sits on a T-wave, or a spike has no marker, every heart rate number after this point is wrong. Run this same plot on a window from the vigorous-activity phase — motion artifact is much worse there, and it is where detection usually breaks first.

Align the two streams

The accelerometer runs at 25 Hz and the ECG at 130 Hz. merge_asof matches each accelerometer sample to the nearest ECG sample in time, giving one tidy frame at the slower rate.

Code
acc_labeled = acc_labeled.sort_values("Timestamp")
ecg_hr = ecg_hr.sort_values("Timestamp")

labeled = pd.merge_asof(
    acc_labeled.drop(columns=["activity"]),
    ecg_hr,
    on="Timestamp",
    direction="nearest",
    tolerance=pd.Timedelta("50ms"),
)
labeled = labeled[labeled.activity != "Unknown"].dropna(subset=["ECG_HeartRate"])

PHASE_ORDER = sorted(labeled.activity.unique())
print(f"{len(labeled):,} aligned samples across {len(PHASE_ORDER)} phases")
12,404 aligned samples across 5 phases
Code
plot_ecg_acc_with_phase_toggle(labeled, event_df)
Figure 2: The full recording. Use the toggle to show or hide the phase shading.

What do the data tell us?

Boxplot helper
def phase_boxplot(df, column, ylabel, title, order=None):
    order = order or PHASE_ORDER
    data = [df.loc[df.activity == p, column].dropna().values for p in order]
    fig, ax = plt.subplots(figsize=(8, 4.5))
    bp = ax.boxplot(data, patch_artist=True, showfliers=False,
                    medianprops=dict(color="black"))
    ax.set_xticks(range(1, len(order) + 1))
    ax.set_xticklabels([p.split("_", 1)[1] for p in order])
    for patch, colour in zip(bp["boxes"], PHASE_COLORS):
        patch.set_facecolor(colour); patch.set_alpha(0.65); patch.set_edgecolor("#555")
    ax.set_ylabel(ylabel); ax.set_title(title)
    plt.setp(ax.get_xticklabels(), rotation=20, ha="right")
    plt.tight_layout(); plt.show()
Code
phase_boxplot(labeled, "ActivityIntensity",
              "Movement intensity (mg, rolling SD)",
              "Movement intensity by phase")

labeled.groupby("activity").ActivityIntensity.describe().round(1)
(a) Movement intensity by phase.
count mean std min 25% 50% 75% max
activity
1_Baseline 3025.0 15.2 14.1 1.5 5.6 11.7 19.7 93.3
2_Light activity 3151.0 110.4 29.5 4.0 99.0 115.6 128.3 226.2
3_Recovery 2900.0 11.9 9.8 1.8 5.5 9.0 14.9 68.0
4_Moderate/vigorous activity 1437.0 791.2 92.6 57.8 791.8 805.3 817.8 856.9
5_Final recovery 1891.0 17.0 7.2 5.2 12.2 15.8 19.8 65.4
(b)
Figure 3
Code
phase_boxplot(labeled, "ECG_HeartRate",
              "Heart rate (bpm)",
              "Heart rate by phase")

labeled.groupby("activity").ECG_HeartRate.describe().round(1)
(a) Heart rate by phase.
count mean std min 25% 50% 75% max
activity
1_Baseline 3025.0 89.0 8.3 74.1 81.4 90.6 95.8 101.8
2_Light activity 3151.0 98.7 4.3 87.0 96.1 97.4 101.3 107.2
3_Recovery 2900.0 85.7 6.1 76.2 81.3 83.0 89.4 99.5
4_Moderate/vigorous activity 1437.0 134.3 20.6 87.4 125.8 134.3 154.3 161.7
5_Final recovery 1891.0 126.2 20.5 98.2 102.7 126.9 144.8 158.4
(b)
Figure 4

Movement and heart rate, side by side

Timeline plot
def plot_activity_and_hr(df, decimate=3, height=620):
    d = df.iloc[::decimate]
    colour_map = {p: PHASE_COLORS[i % len(PHASE_COLORS)] for i, p in enumerate(PHASE_ORDER)}

    fig = make_subplots(rows=2, cols=1, shared_xaxes=True, vertical_spacing=0.08,
                        subplot_titles=("Movement intensity", "Heart rate"))

    seg_id = (d.activity != d.activity.shift()).cumsum()
    for _, seg in d.groupby(seg_id):
        phase = seg.activity.iloc[0]
        fig.add_trace(go.Scattergl(x=seg.Timestamp, y=seg.ActivityIntensity, mode="lines",
                                   line=dict(color=colour_map[phase], width=1.4),
                                   name=phase, legendgroup=phase,
                                   showlegend=phase not in [t.name for t in fig.data]),
                      row=1, col=1)
        fig.add_trace(go.Scattergl(x=seg.Timestamp, y=seg.ECG_HeartRate, mode="lines",
                                   line=dict(color=colour_map[phase], width=2),
                                   name=phase, legendgroup=phase, showlegend=False),
                      row=2, col=1)

    fig.update_layout(height=height, hovermode="x unified",
                      margin=dict(t=80, b=40, l=60, r=20),
                      legend=dict(orientation="h", yanchor="bottom", y=1.04,
                                  xanchor="right", x=1))
    fig.update_yaxes(title_text="mg (rolling SD)", row=1, col=1)
    fig.update_yaxes(title_text="bpm", row=2, col=1)
    return fig

We can start by looking at a summary of the data in a table…

Code
summary = (labeled.groupby("activity")
                  .agg(movement=("ActivityIntensity", "mean"),
                       heart_rate=("ECG_HeartRate", "mean"),
                       hr_start=("ECG_HeartRate", "first"),
                       hr_end=("ECG_HeartRate", "last"))
                  .round(1)
                  .reindex(PHASE_ORDER))
summary
Table 2: Mean movement intensity and heart rate by phase.
movement heart_rate hr_start hr_end
activity
1_Baseline 15.2 89.0 76.7 90.9
2_Light activity 110.4 98.7 87.0 97.1
3_Recovery 11.9 85.7 97.2 92.9
4_Moderate/vigorous activity 791.2 134.3 87.4 161.7
5_Final recovery 17.0 126.2 158.4 99.0

But visually analyzing the data gives us a better sense for what’s happening throughout the study!

Code
plot_activity_and_hr(labeled)
Figure 5: Movement intensity (top) and heart rate (bottom) over the full protocol, coloured by phase.

Questions to Consider!

Does heart rate jump right away when movement occurs?

Look at the timeline figure at the start of the vigorous phase. Movement intensity goes from near-zero to maximum within a single sample — the accelerometer responds instantly, because it is measuring mechanics. Heart rate takes tens of seconds to climb, because it is measuring a control system responding to demand.

Does heart rate calm down as fast as it sped up?

No, and this is the most interesting result in the recording. Compare the vigorous phase to the final recovery in the table above: the participant has stopped moving entirely, movement intensity is back to baseline, and heart rate is still far above where it started. The asymmetry between how fast heart rate rises and how slowly it falls is itself a biomarker — heart rate recovery is used clinically as an index of autonomic function and cardiovascular fitness.

Can we “see” activity just by looking at heart signals?

Partly. The vigorous phase is obvious from heart rate alone. Light walking is much less distinct — and the recovery periods look, from heart rate alone, like activity that hasn’t happened yet or has just finished. Without the accelerometer you could not tell an elevated heart rate from exertion apart from one caused by stress, caffeine, or a fever. This is the whole argument for multimodal sensing.

Next Steps and Future Directions

Below are some ideas on how you can alter the code and experiment with the data.

  • Change HR_SMOOTH_SEC to 1, then to 60, and re-render. At 60 seconds the recovery dynamics disappear entirely. Smoothing windows are not cosmetic choices.
  • Lower ECG_QUALITY_MIN to 0.5 and see how many more samples survive — and whether the heart rate curve starts showing physiologically impossible jumps.
  • Plot the R-peak check figure during the vigorous phase instead of baseline. Motion artifact is worst exactly when the signal matters most.
  • Compute heart rate recovery properly: the drop in bpm over the first 60 seconds after exercise stops. Compare it to published reference ranges.
  • Try RR-interval variability (ecg_info["ECG_R_Peaks"] gives you the beat indices). HRV behaves very differently from mean heart rate across these phases.
NoteData and reuse

If you use this material in a class or workshop, attribution is appreciated. Developed with the BIG IDEAs Lab Community Engagement Team at Duke University.