Heart Rate Module (Part 2)

modules biometric

In the last part of this tutorial, we already tried out the KY-039 module. Today, we are going to implement a proper heart rate detection.

Calculating the Heart Rate

The KY-039 heart rate modules are simple and the measuring principle itself is too. Calculating the heart rate with an Arduino is not and many people prefer heart rate sensors, for which an Arduino library that directly outputs the heart rate exists. But how can we determine the heart rate from the measurements of the sensor by ourselves?

At the first sight, it looks simple: we just count the heart beats for a fixed amount of time and then convert the count into beats per minute. It really is that simple. Now we just need to do it in code. How? This is where it gets tricky.

What we measure is not the heart rate. We measure the amount of blood in the blood vessels. Even that is not entirely true, we just measure a value that roughly relates to the amount of blood in our blood vessels and lets us detect the heart beat. All the values we measure are neither calibrated nor reproducible. Each time we put the finger on the sensor, the values differ, since the pressure we apply and the exact position of the finger is different.

Anyhow, the initially idea is correct and works flawlessly when applied manually. For applying it automatically, however, we need to solve two issues: detecting heart beats and aggregating the data over time. In this second part about the heart rate modules, we are going to search for a solution for these problems.

Heart Beat Detection

Let's start with the first issue: how can we detect a heart beat. If we apply the described method manually, we are immediately able to tell which peaks in our recorded data are heart beats and which are not. We are masters in pattern recognition, computers are not. Computers perform mathematical operations. However, it is possible to imitate our human pattern recognition skills using mathematical operations. This is where neural networks and machine learning algorithms unfold their full potential. They can be used to solve this issue on more powerful platforms. For Arduino Uno, however, they are not a good solution.

For the Arduino we need to simplify our problem to make it solvable with simple math. We don't need to check whether our heart beats occur regularly, and we don't aim to detect anomalies. Let's assume that the finger is correctly positioned. If that is the case, we want a value for our heart rate. To achieve this, a much simpler mathematical way should suffice.

How does a possible solution look like? Well, we can use a threshold and say that every time that we cross that threshold this is due to a heart beat. This is of course not necessarily true, but it's a good enough approximation for our case. The issue is that a simple threshold won't work, because we would need to recalibrate and recalculate it each time we move our finger or change the pressure. What we need is to define a dynamic threshold.

Heartbeat detection using a threshold

How does it work? The idea is very similar to performing a calibration: we determine the maximum and minimum value and then set the threshold in between them. As we don't want to hard code the threshold we define it using a relative value, e.g. 70 % between the minimum and maximum value. We could perform this calibration during the initialization of our program, but that wouldn't be very dynamic, wouldn't it?

To count heart beats and determine the heart rate, we need to look at a certain time window of measurement values. For our dynamic threshold, we just recalculate the maximum and minimum every time we look at a different time window with new measurement values. If we change our finger position we might use a wrong value at first, but after a short amount of time, the correct threshold should be determined. We can start measuring the time between the heart beats and calculating the heart rate from it.

Aggregating the Data

Before we can calculate the heart rate we need to collect enough data. We can't calculate the heart rate from a single measurement value. In our program, we are going to use a buffer to collect the measurement values for a time window of 5 s. We can then apply our heart rate detection to the collected data.

The use of time windows is a common practice when working with time series data. There are different methods to define such a time window. In this tutorial, we are going to look at two of them: tumbling windows and sliding windows.

Tumbling Window

The tumbling window method is probably one of the easiest methods to work with a series of measurements. We collect data for a fixed amount of time and then process it. After we are done, we start over and record the next batch of data. This strategy is illustrated in the image below.

Data collection and processing using a tumbling window

Let's have a look at the code:

// Constants
const float rThreshold = 0.7;
const int minDiff = 10;

// Sample Buffer
const int N_SAMPLES = 250;
int sampleBuffer[N_SAMPLES];

void setup() {
  Serial.begin(9600);
}

void loop() {
  int maxValue = 0;
  int minValue = 1024;
  
  // Wait for values and calculate maximum and minimum
  for(int i = 0; i < N_SAMPLES; i++) {
    sampleBuffer[i] = analogRead(A0);
    maxValue = max(sampleBuffer[i], maxValue);
    minValue = min(sampleBuffer[i], minValue);
    
    delay(20);
  }

  float diff = max(maxValue - minValue, minDiff);
  float threshold = diff * rThreshold + minValue;
  int nHeartbeats = 0;
  int tHeartbeats = 0;
  int lastHeartbeat = 0;
  
  for(int i = 1; i < N_SAMPLES; i++) {
    if(sampleBuffer[i] >= threshold && sampleBuffer[i-1] < threshold) {
      // Save time difference and increase counter
      // Don't count time difference if it is too short or too long
      // - 15 measurements correspond to roughly 250 bpm
      // - 150 measurements correspond to roughly 25 bpm
      if(lastHeartbeat && i-lastHeartbeat > 15 && i-lastHeartbeat < 150) {
        tHeartbeats += i-lastHeartbeat; 
        nHeartbeats++;
      }

      // Save timestamp
      lastHeartbeat = i;
    }
  }

  // Calculate bpm
  float bpm = 60000.0 * nHeartbeats / (tHeartbeats * 20);
  
  // Show results if enough heartbeats are found
  // In 5 s there should be 4 at 50 bpm
  if(nHeartbeats > 3) {
    Serial.print("Heart Rate (bpm): ");
    Serial.println(bpm);
  }
  else {
    Serial.println("No heart beat detected");
  }
}

The program uses a buffer that has space for 250 data samples. Since a new measurement is performed every 20 ms this buffer is big enough to store our five seconds of data. In a first step the buffer is filled with measurements from the analog input A0. Additionally, the minimum and maximum value is calculated.

In a second step the threshold is determined. For this a relative threshold of 70 % is defined by the constant rThreshold. This constant is then multiplied with the observed value range (difference between minimum and maximum). To prevent minor changes from being detected as heart beat the constant minDiff is used to ensure a reasonable value range. This makes it possible to provide at least a rudimentary indication of whether there is a finger on the sensor or not.

The third step is to calculate the heart rate. For this, the last and the current value are compared to check whether the threshold was crossed. If it is, the time difference to the last detected heart beat is calculated and added to the variable tHeartbeats. The number of detected heart beats is saved in the variable nHeartbeats. The two variables make it possible to later calculate the average time between heart beats. This value is then used to calculate the heart rate in beats per minute (bpm) which is the displayed in the serial monitor.

In addition to the calculations, the program contains some plausibility checks that try to eliminate wrong measurements. Heart beats are only counted if the time difference matches a heart rate between 50 and 250 bpm. If not enough valid heart beats are detected, no heart rate is outputted, instead the message No heart beat detected is shown. While these checks cannot ensure the correctness of the outputted data, they at least prevent the display of junk data e.g. if the finger is moved or not on the sensor at all.

Sliding Window

Using the tumbling window method, we need to wait five seconds for each heart rate calculation. This can be avoided using a sliding window. As shown in the image below, we use overlapping time windows for this method. This way we can always calculate a heart rate by combining new and old measurements.

Data collection and processing using a sliding window

A sliding window can be implemented using a ring buffer. For the ring buffer we can use the same array as before. The difference is, that we don't fill the buffer from the start to the end. When using a ring buffer, we keep track of the index we need to write to using a separate variable. Once the buffer is filled the index wraps around and starts again at 0. Once this happens, old data is overwritten. However, we don't need to wait until the buffer is fully overwritten to perform our next calculation. We can use the saved index to determine the oldest data sample in the buffer and start our calculation from there.

In theory, we can perform a new calculation every time we acquire a new measurement value. In practice, this does not make much sense, as it takes some time until the next heart beat occurs and our heart rate value changes. It is enough to perform the calculation every once in a while. I chose to do it every 500 ms in the example program.

Here is the code for this program:

// Constants
const float rThreshold = 0.7;
const int minDiff = 10;

// Sample Buffer
const int N_SAMPLES = 250;
int nSamples = 0;
int sampleBuffer[N_SAMPLES];
int index = 0;

// Last time the bpm was shown
long lastTime;

void setup() {
  Serial.begin(9600);
}

void loop() {
  sampleBuffer[index] = analogRead(A0);

  // Show results every 500 ms once the buffer is filled
  if(nSamples >= N_SAMPLES && millis()-lastTime > 500) {
    lastTime = millis();
    
    int maxValue = 0;
    int minValue = 1024;
    
    // Calculate maximum and minimum
    for(int i = 0; i < N_SAMPLES; i++) {
      maxValue = max(sampleBuffer[i], maxValue);
      minValue = min(sampleBuffer[i], minValue); 
    }
  
    float diff = max(maxValue - minValue, minDiff);
    float threshold = diff * rThreshold + minValue;
    int nHeartbeats = 0;
    int tHeartbeats = 0;
    int lastHeartbeat = 0;
    
    for(int i = 1; i < N_SAMPLES; i++) {
      if(sampleBuffer[(index+i+1)%N_SAMPLES] >= threshold 
          && sampleBuffer[(index+i)%N_SAMPLES] < threshold) {
        // Save time difference and increase counter
        // Don't count time difference if it is too short or too long
        // - 15 measurements correspond to roughly 250 bpm
        // - 150 measurements correspond to roughly 25 bpm
        if(lastHeartbeat && i-lastHeartbeat > 15 && i-lastHeartbeat < 150) {
          tHeartbeats += i-lastHeartbeat; 
          nHeartbeats++;
        }
  
        // Save timestamp
        lastHeartbeat = i;
      }
    }
  
    // Calculate bpm
    float bpm = 60000.0 * nHeartbeats / (tHeartbeats * 20);
    
    // Show results if enough heartbeats are found
    // In 5 s there should be 4 at 50 bpm
    if(nHeartbeats > 3) {
      Serial.print("Heart Rate (bpm): ");
      Serial.println(bpm);
    }
    else {
      Serial.println("No heart beat detected");
    }
  }
  else {
    nSamples++;
  }
  
  // Next Index
  index = (index+1) % N_SAMPLES;
  delay(20);
}

The Result

With both methods it is possible to reliably calculate the heart rate. Using a sliding window we get the additional advantage of a quicker feedback and can observe changes in the heart rate without waiting five seconds for the new measurements. However, the observation period is five seconds for both methods. For this reason you need wait a bit until you get the first results once you put your finger on the sensor. For a quicker response time, you can reduce the time window, at the cost of more jumpy and less accurate heart rate readings.

Result shown in the serial monitor

Both the tumbling and the sliding window method, are well understood in literate and practice. An interesting question is, however, whether we can do all this without using a buffer to save our measurements? In our example it is not important, but there are cases were it becomes essential. What if we would need more samples for our calculations, than we could store in the Arduino's memory? If we make certain compromises, we can also calculate the heart rate without buffering the measurement values first. I challenge you to try to come up with your own solution for this. It's a good programming and thinking exercise. I will show you my solution in the next and last part of this tutorial series on the KY-039 heart rate module.

Previous Post Next Post