Game of Life glider, animated

Open Operational Research

Poking data, mostly Open

James Riley

3-Minute Read

I was entirely prepared to be making a post about how fitness/smart watches are terrible at measuring heart rate. However, we don’t do science to prove that we’re correct, but to become correct.

I took my Wahoo Tickr ( ECG-based ) and my Samsung Galaxy Watch Active ( PPG-based ) out for 30 minutes of cycling at lunchtime.

Samsung lets me download all my health data, as a lot of badly-labelled JSON files. Wahoo uses Garmin’s proprietary format which {sf} doesn’t read, but the Free Software GPS Babel does.

I’ve stripped just the heart rate & timestamp data, because I don’t want to announce to everyone where I live.

Initially, they look like they have pretty good agreement:

wahoo <- read_csv(here::here("static/data/cycling/tickr.csv")) %>%
  rename(heart_rate = description) %>%
  mutate(sensor = "wahoo")

samsung <- read_csv(here::here("static/data/cycling/samsung.csv")) %>%
  rename(timestamp = start_time) %>%
  mutate(sensor = "samsung")

heart_data <- bind_rows(wahoo, samsung)

heart_data %>%
  ggplot(aes(x = timestamp, y = heart_rate, colour = sensor)) + geom_line() + theme_minimal() + ggthemes::scale_colour_few()

Before I start looking at an error function, there’s a few bits that are worth zooming-in.

heart_data %>%
  filter(timestamp < ymd_hm("2020-10-08 12:05")) %>% 
  ggplot(aes(x = timestamp, y = heart_rate, colour = sensor)) + geom_point() + theme_minimal() + ggthemes::scale_colour_few()

The Wahoo looks like it’s measuring HR about 1ce/second, while the Samsung normally measures about 1ce/10 seconds, but gave up for 2 minutes near the start of the workout.

heart_data %>%
  filter(timestamp > ymd_hm("2020-10-08 12:05")) %>% 
  ggplot(aes(x = timestamp, y = heart_rate, colour = sensor)) + geom_point() + theme_minimal() + ggthemes::scale_colour_few()

But once HR had adjusted to workout levels, they agree pretty well.

Now to build an error function. Given that the tickr has more data-points, I’ll compare the tickr against the last figure the active watch produced:

error_table <- wahoo %>%
  left_join(samsung, by="timestamp") %>%
  select(timestamp, heart_rate.x, heart_rate.y) %>%
  rename(wahoo=heart_rate.x, samsung=heart_rate.y) %>%
  mutate(tick = cumsum(!is.na(samsung))) %>% 
  filter(tick>0) %>%
  group_by(tick) %>%
  mutate(samsung=first(samsung)) %>%
  mutate(error = samsung-wahoo) %>%
  ungroup() %>%
  select(-tick)

error_table %>% 
  ggplot(aes(x = timestamp, y = error)) + geom_line() + theme_minimal()

Which is clearly terrible before 12:05, so let’s look at the timescale where the Samsung was actually recording.

error_table %>% 
  filter(timestamp > ymd_hm("2020-10-08 12:05")) %>% 
  ggplot(aes(x = timestamp, y = error)) + geom_line() + theme_minimal()

It spends most of the time within 5 beats per minute of the better sensor, which is better than I expected.

Some summary stats:

error_table %>%
  summarise( mean_error = mean(error),  RMS = sqrt(mean(error^2))) %>% 
  knitr::kable(digits = 1)
mean_error RMS
-2.8 9.1

I can strip out the time dimension and plot the Samsung reading against the Wahoo reading. I’m starting from 12:05 in this one, and adding the line y=x as it shows “perfect agreement with Wahoo and Samsung”.

error_table %>% 
  filter(timestamp > ymd_hm("2020-10-08 12:05")) %>% 
  ggplot(aes(x = wahoo, y = samsung, colour = error)) + geom_point() + geom_abline(slope = 1) + coord_fixed() + scale_color_gradient2() + theme_minimal() 

Recent Posts

categories