Our previous approach for heart rate measurements didn't work very well. It's time for a better and more scientific algorithm.
In the last part of this tutorial, we applied our old heart rate measurement algorithm to the MAX3010x sensors. Although it kind of worked, the result was not as good as desired. For this reason, we are going to look at a different more scientific approach today.
The new approach uses a processing pipeline consisting of multiple steps. At first, we apply our finger detection. The second step is to preprocess the raw measurements for the heart beat detection. For this we apply, both a digital low pass and a digital high pass filter. The heart beat detection itself is going to be a peak detector. For this, we look for zero-crossings in the derivation of our signal. This allows us to accurately determine the peaks in our signal. This might sound familiar to you. It is the common way to determining the maximum of a function in math or to be more specific in calculus. Once the heart beats are detected, the heart rate is calculated. As the last step, an averaging algorithm is applied, to provide a stable value for the heart rate.
How can we implement all this? Well, it's not very surprising, that the code is more complicated than normally. For this reason I split the code into two separate files: one with the main program and one containing the implementation for the individual blocks of our filter pipeline.
To create a second file click on the arrow on the right side of the Arduino IDE and click New Tab
or just press Ctrl+Shift+N
. Name the auxiliary file filters.h
.
Here is the code for the main program and the filter blocks:
#include <MAX3010x.h>
#include "filters.h"
// Sensor (adjust to your sensor type)
MAX30105 sensor;
const auto kSamplingRate = sensor.SAMPLING_RATE_400SPS;
const float kSamplingFrequency = 400.0;
// Finger Detection Threshold and Cooldown
const unsigned long kFingerThreshold = 10000;
const unsigned int kFingerCooldownMs = 500;
// Edge Detection Threshold (decrease for MAX30100)
const float kEdgeThreshold = -2000.0;
// Filters
const float kLowPassCutoff = 5.0;
const float kHighPassCutoff = 0.5;
// Averaging
const bool kEnableAveraging = true;
const int kAveragingSamples = 50;
const int kSampleThreshold = 5;
void setup() {
Serial.begin(9600);
if(sensor.begin() && sensor.setSamplingRate(kSamplingRate)) {
Serial.println("Sensor initialized");
}
else {
Serial.println("Sensor not found");
while(1);
}
}
// Filter Instances
HighPassFilter high_pass_filter(kHighPassCutoff, kSamplingFrequency);
LowPassFilter low_pass_filter(kLowPassCutoff, kSamplingFrequency);
Differentiator differentiator(kSamplingFrequency);
MovingAverageFilter<kAveragingSamples> averager;
// Timestamp of the last heartbeat
long last_heartbeat = 0;
// Timestamp for finger detection
long finger_timestamp = 0;
bool finger_detected = false;
// Last diff to detect zero crossing
float last_diff = NAN;
bool crossed = false;
long crossed_time = 0;
void loop() {
auto sample = sensor.readSample(1000);
float current_value = sample.red;
// Detect Finger using raw sensor value
if(sample.red > kFingerThreshold) {
if(millis() - finger_timestamp > kFingerCooldownMs) {
finger_detected = true;
}
}
else {
// Reset values if the finger is removed
differentiator.reset();
averager.reset();
low_pass_filter.reset();
high_pass_filter.reset();
finger_detected = false;
finger_timestamp = millis();
}
if(finger_detected) {
current_value = low_pass_filter.process(current_value);
current_value = high_pass_filter.process(current_value);
float current_diff = differentiator.process(current_value);
// Valid values?
if(!isnan(current_diff) && !isnan(last_diff)) {
// Detect Heartbeat - Zero-Crossing
if(last_diff > 0 && current_diff < 0) {
crossed = true;
crossed_time = millis();
}
if(current_diff > 0) {
crossed = false;
}
// Detect Heartbeat - Falling Edge Threshold
if(crossed && current_diff < kEdgeThreshold) {
if(last_heartbeat != 0 && crossed_time - last_heartbeat > 300) {
// Show Results
int bpm = 60000/(crossed_time - last_heartbeat);
if(bpm > 50 && bpm < 250) {
// Average?
if(kEnableAveraging) {
int average_bpm = averager.process(bpm);
// Show if enough samples have been collected
if(averager.count() > kSampleThreshold) {
Serial.print("Heart Rate (avg, bpm): ");
Serial.println(average_bpm);
}
}
else {
Serial.print("Heart Rate (current, bpm): ");
Serial.println(bpm);
}
}
}
crossed = false;
last_heartbeat = crossed_time;
}
}
last_diff = current_diff;
}
}
How does it work? Well, I won't be able to get into all the details in this tutorial, but I will try to give my best to explain to you how the main program works. If you are interested in the implementation of digital filters, I recommend you to read the DSP Guide from Steven W. Smith. You can find the theoretical background for the digital low pass and high pass filter in Chapter 19 - Recursive Filters.
Let's have a look at the program. The first step is the initialization in the setup
procedure. There is only one small change: the sample rate is increased from 50 to 400 samples per second. In the main loop the first step is the finger detection. It is implemented using a simple threshold. If the measured value is below this threshold no finger is present. In this case, all the filters a reset so that they start with fresh values when someone puts his finger on the sensor. This allows for a faster detection of the first heart beat. For the same reason there is a cooldown period of 500 ms. It helps to ignore any unstable and fast changing values that occur while putting the finger on the sensor. The cooldown period can be customized using the kFingerCooldownMs
variable. Only if a finger is detected the rest of the processing pipeline becomes active.
Let's look at the more interesting part: the new detection algorithm and the digital filters. The first filter that is applied is a low pass filter. A low pass filter attenuates the high frequency components of a signal and allows the low frequency components to pass. The cut-off frequency for this filter can be set via the kLowPassCutoff
variable and is set to 5 Hz by default. Since 5 Hz corresponds to 300 bpm, we can safely assume that all higher frequency signal components are sensor noise and not something we are actually interested in. The high pass filter works in the opposite way. Its cut-off frequency defaults to 0.5 Hz (30 bpm), which is defined in the kHighPassCutoff
variable. The filter removes the DC offset and the sensor drift caused by temperature changes. The effects of this filter are pretty similar to the ones of the dynamically adjusted threshold values in our old solution. The filter also suffers, from a long adjustment time after the sudden value changes when putting the finger on the sensor, which is exactly why I reset the filter values and implemented a cooldown period.
Let's move on to the detection algorithm itself. For the peak detection, we need to differentiate the input signal. Luckily this is very easy for discrete values with a constant sample rate. The only thing the differentiator does is subtracting the previous value from the current one and then dividing the result by the passed time. This gives us a sampling rate independent slope value for our measured signal. If the calculated value crosses zero, this means we are at a local maximum (positive to negative) or minimum (negative to positive). For our peak detection we need to watch for a zero crossing from positive to negative. This is exactly what the code does. However, we need to make sure that the peak is really a heart beat and not just caused by signal noise. Besides of using a low pass filter, we do this by ensuring that the signal continues to fall after the maximum and does so fast enough. The threshold for this is set in the variable kEdgeThreshold
. If you use a MAX30100 you might need to reduce this threshold value for the detection to work.
All that happens after the heart beat detection is the calculation of the heart rate based on the time difference and the optional averaging step. By default, averaging is turned on. You can disable it by setting kEnableAveraging
to false
. The used averaging algorithm is a simple moving average. The number of samples to average over can be set in the variable kAveragingSamples
and defaults to 50 samples. The variable kSampleThreshold
determines how many samples to collect before showing the first results. Note, that the certainty of the result increases the more samples are collected and reaches its maximum at kAveragingSamples
collected samples.
Enough for the theory. If you want to see the effect the filters have on the signal and the heart beat detection, you can use the simulator below. If you want to you can also import your data by using the provided measurement program and pasting the recorded values in the data tab.
Is the effort worth it? Yes, it is the heart beat detection is more reliable and there is no need to wait for 20 seconds for the first results anymore. Without averaging the first measurements appear after 2-3 seconds. With averaging the results appear after 7-8 seconds, as the default requires five samples to be collected until the average is shown for the first time. With averaging the output value is now stable and allows to easily determine the average heart rate. If you are interested in the heart rate variability, you can turn off averaging and will get the raw measurements based on the interval between only two heart beats.