Heart Rate Module (Part 3)

modules biometric

As promised, we are going to look at another solution for detecting heart beats. This time without using a buffer.

Heart Rate Measurement without Buffer

In the last part of this tutorial series on the KY-039 heart rate sensor, we already found a way to calculate the heart rate. However, I ended the last part with a challenge: finding a solution that works without needing a buffer. Did you succeed? In this third and last part, I will show you how I solved it.

Here is my solution:

// Constants
const float rThreshold = 0.7;
const float decayRate = 0.01;
const float thrRate = 0.05;
const int minDiff = 5;

// Current values
float maxValue = 0;
float minValue = 1024;
float threshold = 512;

// Timestamp of the last heartbeat
long lastHeartbeat = 0;

// Last value to detect crossing the threshold
int lastValue = 1024;

void setup() {
  Serial.begin(9600);
}
  
void loop() {
  int currentValue = analogRead(A0);
  maxValue = max(maxValue, currentValue);
  minValue = min(minValue, currentValue);

  // Detect Heartbeat
  float nthreshold = (maxValue - minValue) * rThreshold + minValue;
  threshold = threshold * (1-thrRate) + nthreshold * thrRate;
  threshold = min(maxValue, max(minValue, threshold));
  
  if(currentValue >= threshold 
      && lastValue < threshold 
      && (maxValue-minValue) > minDiff 
      && millis() - lastHeartbeat > 300) {
        
    if(lastHeartbeat != 0) {
      // Show Results
      int bpm = 60000/(millis() - lastHeartbeat);
      if(bpm > 50 && bpm < 250) {
        Serial.print("Heart Rate (bpm): ");
        Serial.println(bpm);
      }
    }
    lastHeartbeat = millis();
  }

  // Decay for max/min
  maxValue -= (maxValue-currentValue)*decayRate;
  minValue += (currentValue-minValue)*decayRate;

  lastValue = currentValue;
  delay(20);
}

Working Principle

Well, that's a lot of code, but how does it work? The basic idea remains the same as in the previous part of this tutorial. We use a relative threshold at 70 % between the minimum and maximum of the recorded data to detect the heart beats. Without a buffer, we cannot simply iterate over all the values to determine minimum or maximum and calculate the threshold anymore. I looked for an alternative and decided to use an approximation for these values instead.

Approximating Minimum and Maximum

How can we approximate the minimum and maximum value? Well, determining the all-time maximum and minimum is not hard. We just need to save the current maximum and minimum in a variable. Each time a higher or lower value occurs we update these variables accordingly. This is done by the following lines of code:

int currentValue = analogRead(A0);
maxValue = max(maxValue, currentValue);
minValue = min(minValue, currentValue);

The issue is that we don't need an all-time maximum and minimum, we need the minimum and maximum in a limited time frame. To approximate this, we can use a trick: we decrease the maximum and increase the minimum value over time. If the maximum value is reached again and again, the variable is repeatedly set to it and remains high. If it is not reached for a longer time, however, the saved maximum decreases more and more. Similarly, the minimum increases till it is adjusted to the current values.

For this method to work well, we need to choose an appropriate way to increase and decrease minimum and maximum. If we adjust the values too fast, the stored values become useless since they diverge too fast towards the current value. If they are adjusted too slowly, the reaction time of our heart beat detection increases, and we have to wait for a long time until the heart rate is properly calculated. What we need is a good compromise in between.

In my solution, the rate at which maximum and minimum are adjusted to the current value can be configured with the constant decayRate. The decayRate is then multiplied with the difference between the saved maximum or minimum and the current value. This means that if the difference is big, maximum and minimum are adjusted faster than if it is small. This ensures a fast adjustment if the value range changes significantly and also works if the amplitude of measured signal is small.

// Decay for max/min
maxValue -= (maxValue-currentValue)*decayRate;
minValue += (currentValue-minValue)*decayRate;

How much the measured values change in case of a heart beat depends a lot on the sensor, the used measurement method and on whether the module applies a signal amplification. Especially, the KY-039 version that uses an IR LED to shine through the finger has a very small signal amplitude. The value changes only by about 10 to 20 ticks. Other module versions, like the one that was used to create the graphs that are shown in this part and the previous parts of this tutorial, have a higher amplitude. The value changes by several hundred ticks. By not using a fixed value to increase and decrease minimum and maximum, the code works for both.

Smoothing the Threshold Value

Using minimum and maximum we can now calculate a threshold - just like we did it before. If we do so, however, the threshold value will vary a lot since maximum and minimum change all the time. To compensate for this the code uses a smoothing algorithm. The used algorithm is known as exponential smoothing.

The idea is simple: we don't replace the threshold in each iteration, but use part of the old value and part of the new value to calculate the next threshold. This is done by the following lines of code:

float nthreshold = (maxValue - minValue) * rThreshold + minValue;
threshold = threshold * (1-thrRate) + nthreshold * thrRate;
threshold = min(maxValue, max(minValue, threshold));

The constant thrRate allows specifying how fast the threshold value is adjusted. The higher the value the faster the threshold is adjusted. If the rate is small it is possible that the threshold adjust so slow, that it would not be in between maximum and minimum anymore. The third line of code addresses this issue and ensures that the threshold always stays in between minimum and maximum.

The Algorithm in Action

Enough theory, let's see the heart rate detection algorithm in action. I created a small visualization for this. The graph below shows about 5 s of recorded sensor data. Maximum and minimum are represented by the blue line while the threshold is shown in green. Each of the red dots marks a detected heart beat. Please note that the heart beat is always detected after the value already crossed the threshold. This is not an error. The reason for this behavior is that we only sample every 20 ms. In the next sample after crossing the threshold the measured value is already a lot higher than the threshold itself.

If you want to, you can try out the visualization with your own data. For this, use the code under "Measurement Program" to record some data. You can then copy that data from the serial monitor into the text field shown in the data tab. The graph will then display your data and if you need to, you can also adjust the constants for the heart beat detection algorithm. In most cases, however, it should work out of the box.

Relative Threshold (%)
Minimum Difference
Decay Rate
Threshold Rate

The Result

Does the program work in practice? Yes, it does. In the image below you can see the measurements displayed in the serial monitor. Output in the serial monitor

Is this solution better than the previous ones? Well, that depends on what is important to you. The new solution definitely needs less computational resources, but, let's be real, as long as everything fits on the Arduino who cares? What really matters are accuracy, reaction time and detection stability. If we compare them, there is no clear winner.

The new solution has a slightly faster reaction time since there is no need to fill a buffer first. The heart beat detection is less likely to miss a heartbeat as the threshold adjust faster to changes in the applied pressure or the position of the finger. However, the outputted heart rate values are not as stable as before. The downside of the new solution is that it is more likely to wrongly detect heart beats and then output incorrect values. This could possibly be fixed by adjusting the threshold. However, there is also more variation in between the individual measurements. The reason is simple: the old solution always averaged over multiple heart beats while the new solution determines the heart rate based on the time difference between only two heart beats. This reduces errors and hides the variability in our measurements.

One cause of the variability is the low sample rate. In the previous examples we counted the heart beats. In this new solution, we measure the time between to heart beats and extrapolate this value to a beats per minute value. For this we require a higher sampling rate. With the current sampling interval of 20 ms, we have to expect a variability of up to ± 40 ms. At a heart rate of 60 bpm, this corresponds to a variance of ± 3 bpm. This is the variability caused solely by our current measurement technique.

However, without averaging we will never get a stable heart rate reading. The reason is, that there is a natural variation of the time between individual heart beats. This means, we can't expect the measurements to be all the same. This is a very interesting fact, that can easily trick you into thinking your measurements aren't correct. On the bright side, the heart rate variability is also a very interesting research topic. If you open the serial plotter you will likely see, that the heart rate increases and decreases in sync with your breath. If you want to know more you can have a look at the following two papers:

There is still a lot that you can improve, especially with the right signal processing knowledge. If you want to see how a more professional heart rate detection can be implemented, I recommend you the following write-up from Foroozan and Wu from Analog Devices:

If you want to start with simpler improvements, start by increasing the sampling rate. Note, that you'll also have to adjust the decay rates. If you want stable output values, add an averaging mechanism like the exponential smoothing algorithm or the moving average we looked at the last time. In doing so, we trade in the fast reaction time for more stable output values. The final solution is always a compromise.

Whatever direction you choose, don't give up too soon. You can do it. But please remember: a more complicated solution is not necessarily a better one.

Previous Post Next Post