Ectopic Beat Detection from Exercise ECG Data

Evidence for detection validity and association with heart rate irregularity

Overview

This document presents evidence that ectopic beats can be detected from RR interval data recorded during exercise activities via consumer heart rate monitors. The detection algorithm identifies the characteristic “short-long” pattern of premature ventricular contractions (PVCs) with compensatory pauses.

We also show that the presence of ectopic beats so detected is strongly associated with a Regularity value of Irregular on the Crickles Navigator.

Detection Algorithm

Physiological Basis

A premature ventricular contraction produces a characteristic RR interval pattern:

  1. Premature beat: The ectopic focus fires early, producing a shorter-than-expected RR interval
  2. Compensatory pause: The subsequent interval is longer than expected as the heart “resets”
  3. Conservation: The sum of the premature interval and compensatory pause approximates two normal beats

Implementation

The algorithm uses a rolling 11-beat window to establish the expected RR interval, then flags ectopics when:

  • Current RR interval is 15-45% shorter than the rolling mean
  • Next RR interval is 15-45% longer than the rolling mean
  • The sum of current + next interval is within 20% of twice the expected interval

These thresholds are designed to capture physiologically plausible PVCs while rejecting noise and artefacts.

Visual Evidence

The Short-Long Pattern

The following chart shows RR intervals around a detected ectopic beat. The pattern is unmistakable: a premature beat (393ms, red) followed by a compensatory pause (620ms, blue), against a baseline of approximately 470ms.

Code
data <- qread("~/crickles/new_posit/pilot/output/c_i91815479.qs")
rr <- data$rr |> mutate(row = row_number())
ectopic_rows <- rr |> filter(ectopic == TRUE) |> pull(row)
e_row <- ectopic_rows[1]

window <- rr |>
  filter(row >= e_row - 5, row <= e_row + 5) |>
  mutate(
    beat_num = row - e_row,
    bar_color = case_when(
      ectopic ~ "Ectopic (premature)",
      beat_num == 1 ~ "Compensatory pause",
      TRUE ~ "Normal"
    )
  )

mean_normal <- mean(rr$RR[rr$ectopic == FALSE], na.rm = TRUE)

ggplot(window, aes(x = beat_num, y = RR, fill = bar_color)) +
  geom_col(width = 0.7) +
  geom_text(aes(label = paste0(RR, "ms")), vjust = -0.5, size = 3.5) +
  geom_hline(yintercept = mean_normal, linetype = "dashed", color = "gray50", linewidth = 0.5) +
  scale_fill_manual(
    values = c("Normal" = "gray70",
               "Ectopic (premature)" = "#E41A1C",
               "Compensatory pause" = "#377EB8"),
    name = "Beat type"
  ) +
  scale_x_continuous(breaks = -5:5, labels = function(x) ifelse(x == 0, "PVC", x)) +
  labs(
    title = "RR Interval Pattern Around Detected Ectopic Beat",
    subtitle = "Classic short-long pattern: premature beat followed by compensatory pause",
    x = "Beat sequence (0 = detected ectopic)",
    y = "RR interval (ms)",
    caption = "Dashed line = mean normal RR interval"
  ) +
  theme_minimal(base_size = 12) +
  theme(legend.position = "bottom", panel.grid.minor = element_blank()) +
  coord_cartesian(ylim = c(0, max(window$RR) * 1.15))

Consistency Across Multiple Detections

The following grid shows six consecutive detected ectopic beats from a single activity. Each panel demonstrates the same short-long pattern, providing evidence of consistent detection behaviour.

Code
plot_ectopic_context <- function(rr, ectopic_num = 1, window_size = 4) {
  rr <- rr |> mutate(row = row_number())
  ectopic_rows <- rr |> filter(ectopic == TRUE) |> pull(row)

  if (length(ectopic_rows) < ectopic_num) return(NULL)

  e_row <- ectopic_rows[ectopic_num]
  mean_normal <- mean(rr$RR[rr$ectopic == FALSE], na.rm = TRUE)

  window <- rr |>
    filter(row >= e_row - window_size, row <= e_row + window_size) |>
    mutate(
      beat_num = row - e_row,
      bar_color = case_when(
        ectopic ~ "Ectopic",
        beat_num == 1 ~ "Compensatory",
        TRUE ~ "Normal"
      )
    )

  ggplot(window, aes(x = beat_num, y = RR, fill = bar_color)) +
    geom_col(width = 0.7) +
    geom_text(aes(label = RR), vjust = -0.3, size = 2.8) +
    geom_hline(yintercept = mean_normal, linetype = "dashed", color = "gray40") +
    scale_fill_manual(values = c("Normal" = "gray70", "Ectopic" = "#E41A1C", "Compensatory" = "#377EB8")) +
    labs(x = NULL, y = "RR (ms)") +
    theme_minimal(base_size = 10) +
    theme(legend.position = "none", panel.grid.minor = element_blank()) +
    coord_cartesian(ylim = c(0, max(window$RR) * 1.15))
}

data <- qread("~/crickles/new_posit/pilot/output/c_i91815479.qs")
plots <- lapply(1:6, function(i) plot_ectopic_context(data$rr, i, 4))

wrap_plots(plots, ncol = 3) +
  plot_annotation(
    title = "Six Detected Ectopic Beats from Single Activity",
    subtitle = "Each panel shows RR intervals around a detected ectopic (red = premature, blue = compensatory pause)"
  )

Note that panels 5-6 show consecutive ectopic beats (bigeminy pattern), which the algorithm correctly identifies.

Poincaré Plot Comparison

Poincaré plots (RRn vs RRn+1) are a standard tool for HRV analysis. Ectopic beats create characteristic outliers away from the main cluster.

Code
# Load ectopic case
# ect_data <- qread("output/c_i91815479.qs")
ect_data <- qread("~/crickles/new_posit/pilot/output/c_i84122210.qs")
ect_rr <- ect_data$rr |>
  mutate(rr_next = lead(RR)) |>
  filter(!is.na(rr_next))

# Load clean control case
ctrl_data <- qread("~/crickles/new_posit/pilot/output/c_i100088007.qs")
ctrl_rr <- ctrl_data$rr |>
  mutate(rr_next = lead(RR)) |>
  filter(!is.na(rr_next))

p_ctrl <- ggplot(ctrl_rr, aes(x = RR, y = rr_next)) +

geom_point(color = "gray40", alpha = 0.3, size = 0.8) +
  geom_abline(slope = 1, intercept = 0, linetype = "dashed", color = "gray30") +
  labs(
    title = "Control: No Ectopics Detected",
    subtitle = paste0("n = ", format(nrow(ctrl_rr), big.mark=","), " beats"),
    x = "RR(n) ms", y = "RR(n+1) ms"
  ) +
  coord_fixed(xlim = c(250, 900), ylim = c(250, 900)) +
  theme_minimal(base_size = 11)

p_ect <- ggplot(ect_rr, aes(x = RR, y = rr_next, color = ectopic)) +
  geom_point(alpha = 0.5, size = 1) +
  geom_abline(slope = 1, intercept = 0, linetype = "dashed", color = "gray30") +
  scale_color_manual(
    values = c("FALSE" = "gray40", "TRUE" = "#E41A1C"),
    labels = c("Normal", "Ectopic"),
    name = NULL
  ) +
  labs(
    title = "Case: 52 Ectopics Detected",
    subtitle = paste0("n = ", format(nrow(ect_rr), big.mark=","), " beats"),
    x = "RR(n) ms", y = "RR(n+1) ms"
  ) +
  coord_fixed(xlim = c(250, 900), ylim = c(250, 900)) +
  theme_minimal(base_size = 11) +
  theme(legend.position = "inside",
  legend.position.inside = c(0.85, 0.15))

p_ctrl + p_ect +
  plot_annotation(
    title = "Poincaré Plot Comparison",
    subtitle = "Ectopic beats create characteristic outliers away from the identity line"
  )

The control recording (left) shows a tight cluster along the identity line, indicating consistent beat-to-beat intervals. The ectopic case (right) shows red outliers scattered away from the main cluster - the signature of premature beats disrupting normal rhythm.

Algorithm Specificity

Distinguishing Ectopics from Dropped Beats

Both ectopic beats and dropped/missed beats (sensor artefacts) create Poincaré outliers, but they have distinct signatures:

Pattern RR Sequence Ratio (long/short)
Ectopic (PVC) Short (15-45% below expected) → Long (15-45% above) ~1.3-1.9
Dropped beat Normal → ~2× normal (sensor missed one beat) ~2.0

The algorithm correctly rejects dropped beats because:

  1. The “short” interval in a dropped beat sequence is not actually short - it’s normal
  2. The ratio between consecutive intervals (~2.0) falls outside the ectopic pattern
Code
# Show examples from a recording with dropped beats but no detected ectopics
ctrl_data_drops <- qread("~/crickles/new_posit/pilot/output/c_i96366195.qs")
rr_drops <- ctrl_data_drops$rr |>
  mutate(rr_next = lead(RR), ratio = rr_next / RR) |>
  filter(!is.na(rr_next))

# Filter to clear single-dropped-beat cases (ratio ~2.0)
suspects <- rr_drops |>
  filter(RR < 500 & rr_next > 700, ratio > 1.85 & ratio < 2.25) |>
  mutate(pattern = "Dropped beat") |>
  head(6) |>
  select(RR, rr_next, ratio, ectopic, pattern)

kable(suspects,
      col.names = c("RR (ms)", "Next RR (ms)", "Ratio", "Flagged as Ectopic", "Interpretation"),
      digits = 2,
      caption = "Short-long patterns with ratio ~2.0 correctly identified as dropped beats, not ectopics")
Short-long patterns with ratio ~2.0 correctly identified as dropped beats, not ectopics
RR (ms) Next RR (ms) Ratio Flagged as Ectopic Interpretation
405 805 1.99 FALSE Dropped beat
372 776 2.09 FALSE Dropped beat
368 791 2.15 FALSE Dropped beat
368 799 2.17 FALSE Dropped beat
375 771 2.06 FALSE Dropped beat
384 782 2.04 FALSE Dropped beat

Association with Heart Rate Irregularity

The Gappiness Metric

The “gappiness” metric counts missing integer heart rate values in the recorded range. When the HR sensor fails to track rapid changes (as might occur during arrhythmia), gaps appear in the HR distribution.

  • high_gaps: Missing values above 154 bpm (exercise intensity)
  • low_gaps: Missing values at or below 154 bpm

Activities are classified as:

  • Regular: high_gaps = 0
  • Mildly irregular: high_gaps = 1
  • Irregular: high_gaps > 1

Statistical Association

Code
# Classify activities (cycling only, relaxed threshold)
combined <- results |>
  left_join(sport_df, by = "id") |>
  filter(has_rr == TRUE, sport == "cycling") |>
  mutate(
    classification = case_when(
      stickiness > 90 | low_gaps > 2 | ohm_jump != 0 ~ "Flawed",
      high_gaps > 1 ~ "Irregular",
      high_gaps == 1 ~ "Mildly_irregular",
      TRUE ~ "Regular"
    ),
    has_ectopics = ectopic_count > 0
  )

valid <- combined |> filter(classification != "Flawed")

# Summary table
summary_tbl <- valid |>
  group_by(classification) |>
  summarise(
    n = n(),
    n_with_ectopics = sum(has_ectopics),
    pct_with_ectopics = round(100 * mean(has_ectopics), 1),
    .groups = "drop"
  ) |>
  arrange(match(classification, c("Regular", "Mildly_irregular", "Irregular")))

kable(summary_tbl,
      col.names = c("Classification", "n", "With Ectopics", "% with Ectopics"),
      caption = "Ectopic detection rate by activity classification (n = 1,531 cycling activities)")
Ectopic detection rate by activity classification (n = 1,531 cycling activities)
Classification n With Ectopics % with Ectopics
Regular 1494 359 24.0
Mildly_irregular 14 5 35.7
Irregular 23 14 60.9
Code
plot_data <- valid |>
  group_by(classification) |>
  summarise(
    n = n(),
    n_ectopics = sum(has_ectopics),
    pct = 100 * n_ectopics / n,
    se = sqrt(pct * (100 - pct) / n),
    .groups = "drop"
  ) |>
  mutate(classification = factor(classification,
    levels = c("Regular", "Mildly_irregular", "Irregular")))

ggplot(plot_data, aes(x = classification, y = pct, fill = classification)) +
  geom_col(width = 0.7) +
  geom_errorbar(aes(ymin = pmax(0, pct - 1.96*se), ymax = pmin(100, pct + 1.96*se)),
                width = 0.2) +
  geom_text(aes(label = paste0(round(pct, 1), "%\n(", n_ectopics, "/", n, ")")),
            vjust = -0.5, size = 3.5) +
  scale_fill_manual(values = c("Regular" = "#4DAF4A",
                               "Mildly_irregular" = "#FF7F00",
                               "Irregular" = "#E41A1C")) +
  labs(
    title = "Ectopic Detection Rate by Activity Classification",
    x = "Classification (based on high_gaps)",
    y = "% of Activities with Ectopics Detected"
  ) +
  theme_minimal(base_size = 12) +
  theme(legend.position = "none") +
  coord_cartesian(ylim = c(0, 85))

Code
test_data <- valid |>
  mutate(irregular_binary = classification %in% c("Irregular", "Mildly_irregular"))

contingency <- table(test_data$irregular_binary, test_data$has_ectopics)
chi_test <- chisq.test(contingency)

or_table <- as.matrix(contingency)
odds_ratio <- (or_table[2,2] * or_table[1,1]) / (or_table[2,1] * or_table[1,2])

Statistical test results:

  • Chi-square test: p = 0.000301
  • Odds ratio: 3.34 (Irregular vs Regular)

Activities classified as irregular based on heart rate gappiness are 3.3 times more likely to have detected ectopic beats than regular activities.

Interpretation

The significant association between the gappiness-based irregularity metric and ectopic detection provides mutual validation:

  1. If the ectopic detection is valid, we would expect ectopics to disrupt HR sensor tracking, creating gaps
  2. If the irregularity metric captures genuine rhythm disturbance, we would expect it to correlate with ectopic presence

The observed dose-response relationship (Regular → Mildly irregular → Irregular) strengthens the case for a genuine physiological association rather than coincidental correlation.

Summary

Evidence supporting the validity of this ectopic detection approach:

  1. Physiological plausibility: The algorithm specifically targets the short-long pattern characteristic of PVCs with compensatory pause

  2. Visual confirmation: Detected beats show the expected RR interval pattern when examined individually

  3. Consistency: Multiple detections within the same activity show the same characteristic pattern

  4. Specificity: The algorithm correctly rejects dropped beats and other artefacts that create Poincaré outliers but lack the ectopic signature

  5. External validation: Significant association with an independent irregularity metric (p < 0.001, OR = 3.3)

Limitations

  • Detection is limited to activities where HRV data (beat-to-beat intervals) is recorded
  • Cannot distinguish PVC origin (ventricular vs supraventricular) without ECG morphology
  • False negatives likely for ectopics that don’t produce classic compensatory pauses
  • Validation against gold-standard ECG monitoring would strengthen these findings