After building the heart beat sensor, it's time to process the output signal to get the actual heart beat
The heart pulse signal that comes out of a photoplethysmograph is an analog fluctuation in voltage, and it has a predictable wave shape as shown in figure below
My goal is to find successive moments of instantaneous heart beat and measure the time between, called the Inter Beat Interval (IBI). By following the predictable shape and pattern of the PPG wave, we are able to do just that.
When the heart pumps blood through the body, with every beat there is a pulse wave (kind of like a shock wave) that travels along all arteries to the very extremities of capillary tissue where the Pulse Sensor is attached. Actual blood circulates in the body much slower than the pulse wave travels. Let's follow events as they progress from point 'T' on the PPG below. A rapid upward rise in signal value occurs as the pulse wave passes under the sensor, then the signal falls back down toward the normal point. Sometimes, the dichrotic notch (downward spike) is more pronounced than others, but generally the signal settles down to background noise before the next pulse wave washes through. Since the wave is repeating and predictable, we could choose almost any recognizable feature as a reference point, say the peak, and measure the heart rate by doing math on the time between each peak. This, however, can run into false readings from the dichrotic notch, if present, and may be susceptible to inaccuracy from baseline noise as well. There are other good reasons not to base the beat-finding algorithm on arbitrary wave phenomena. Ideally, we want to find the instantaneous moment of the heart beat. This is important for accurate BPM calculation, Heart Rate Variability (HRV) studies, and Pulse Transit Time (PTT) measurement. .
Some heart researchers say it's when the signal gets to 25% of the amplitude, some say when it's 50% of the amplitude, and some say it's the point when the slope is steepest during the upward rise event. This version 1.1 of Pulse Sensor code is designed to measure the IBI by timing between moments when the signal crosses 50% of the wave amplitude during that fast upward rise. The BPM is derived every beat from an average of the previous 10 IBI times.
First off, it's important to have a regular sample rate with high enough resolution to get reliable measurement of the timing between each beat. To do this, we set up Timer2, an 8 bit hardware timer so that it throws an interrupt every other millisecond. That gives us a sample rate of 500Hz, and beat-to-beat timing resolution of 2mS. This will disable PWM output on pin 3 and 11
void interruptSetup(){ TCCR1A = 0x00; TCCR1B = 0x0C; OCR1A = 0x7C; TIMSK1 = 0x02; sei(); }
The register settings above tell Timer2 to go into CTC mode, and to count up to 124 (0x7C) over and over and over again. A prescaler of 256 is used to get the timing right so that it takes 2 milliseconds to count to 124. An interrupt flag is set every time Timer2 reaches 124, and a special function called an Interrupt Service Routine (ISR) that we wrote is run at the very next possible moment, no matter what the rest of the program is doing. sei() ensures that global interrupts are enabled.
The only other thing you will need is the correct ISR vector in the next step. ATmega32u4 devices use ISR(TIMER1_COMPA_vect)
So, when the Arduino is powered up and running with pulse sensor plugged into analog pin 0, it constantly (every 2 mS) reads the sensor value and looks for the heart beat. Here's how that works:
ISR(TIMER2_COMPA_vect){ Signal = analogRead(pulsePin); sampleCounter += 2; int N = sampleCounter - lastBeatTime;
This function is called every 2 milliseconds. First thing to do is to take an analog reading of the Pulse Sensor. Next, we increment the variable sampleCounter. The sampleCounter variable is used to keep track of time. The variable N will help avoid noise later.
Next, we keep track of the highest and lowest values of the PPG wave, to get an accurate measure of amplitude. Note that this processing will be also useful when we will try to measure blood pressure
if(Signal < thresh && N > (IBI/5)*3){ if (Signal < T){ T = Signal; } } if(Signal > thresh && Signal > P){ P = Signal; }
Variable P and T hold peak and trough values, respectively. The thresh variable is initialized at 512 (middle of analog range) and changes during run time to track a point at 50% of amplitude as we will see later. There is a time period of 3/5 IBI that must pass before T gets updated as a way to avoid noise and false readings from the dicroic notch.
Now, let's check and see if we have a pulse.
if (N > 250){ if ( (Signal > thresh) && (Pulse == false) && (N > ((IBI/5)*3) ){ Pulse = true; digitalWrite(pulsePin,HIGH); IBI = sampleCounter - lastBeatTime; lastBeatTime = sampleCounter;
Before we even consider looking for a heartbeat, a minimum amount of time has to pass. This helps avoid high frequency noise. 250 millisecond minimum N places an upper limit of 240 BPM. If you expect to have a higher BPM, adjust this accordingly and see a doctor. When the waveform rises past the thresh value, and 3/5 of the last IBI has passed, we have a pulse! Time to set the Pulse flag and turn on the pulsePin LED. Then we calculate the time since the last beat to get IBI, and update the lastBeatTime.
The next bit is used to make sure we begin with a realistic BPM value on startup.
if(secondBeat){ secondBeat = false; for(int i=0; i<=9; i++){ rate[i] = IBI; } } if(firstBeat){ firstBeat = false; secondBeat = true; sei(): return; }
The boolean firstBeat is initialized as true and secondBeat is initialized as false on start up, so the very first time we find a beat and get this far in the ISR, we get kicked out by the return; in the firstBeat conditional. That will end up throwing the first IBI reading away, cause it's lousy. The second time through, we can trust (more or less) the IBI, and use it to seed the rate[] array in order to start with a more accurate BPM. The BPM is derived from an average of the last 10 IBI values, hence the need to seed.
Here is the code to calculate BPM
runningTotal = 0; for(int i=0; i<=8; i++){ rate[i] = rate[i+1]; runningTotal += rate[i]; } rate[9] = IBI; runningTotal += rate[9]; runningTotal /= 10; BPM = 60000/runningTotal; QS = true; }
First, we grab a large variable, runningTotal, to collect the IBIs, then the contents of rate[] are shifted over and added to runnungTotal. The oldest IBI (11 beats ago) falls out of position 0, and the fresh IBI gets put into position 9. Then it's a simple process to average the array and calculate BPM. Last thing to do is to set the QS flag (short for Quantified Self, awesome kickstarter supporters!) so the rest of the program knows we have found the beat. That's it for the things to do when we find the beat.
There's a couple of other loose ends that need tying off before we're done, like finding the not-beat.
if (Signal < thresh && Pulse == true){ digitalWrite(13,LOW); Pulse = false; amp = P - T; thresh = amp/2 + T; P = thresh; T = thresh; }
Pulse was declared true during the upward rise in Pulse Sensor signal when we found the beat, above, so when the signal crosses thresh going down, we can figure that the pulse is over. A little housekeeping in clearing pulsePin and the Pulse boolean. Then the amplitude of the wave that just passed is measured, and thresh is updated with the new 50% mark. P and T are reset to the new thresh. The algorithm is now primed and ready to find the next beat.
There's one more question to ask before the ISR is done. What if there are no beats?
if (N > 2500){ thresh = 512; P = 512; T = 512; firstBeat = true; secondBeat = false; lastBeatTime = sampleCounter; }
If there is no beat event for 2.5 seconds, variables used to find the heartbeat are reinitialized to the start up values. Sort of a soft soft-reset. That's the end of the ISR!
By using Timer2 interrupt, our beat finding algorithm runs 'in the background' and automatically updates variable values , Here's a list of useful variables, and how often they are updated.
- Signal: raw pulse sensor signal, read every 2 ms
- IBI: time between heartbeats in ms, refreshed every time a beat detected
- BPM: beats per minute, refreshed every time a beat is detected
- QS: set true every time a beat is detected, must be cleared by user
- Pulse: set true every time a beat is detected, must be cleared by ISR
There you have the basic beat finding code. Having all the vital variables updated automatically makes it easy do stuff in the loop() function.
void setup() { pinMode(13,OUTPUT); pinMode(10,OUTPUT); Serial.begin(115200); interruptSetup(); // analogReference(EXTERNAL); }
Here we go. pulsePin is the analog pin number that Pulse Sensor purple wire is plugged into. You can change it if you need to. blinkPin will blink with the pulse. The fadeRate variable is used to provide an optional fading LED effect with every beat on fadePin (must be a PWM pin, but not 3 or 11). It looks nicer than the blink on pin 13. The other variables should look familiar to you now. They are declared volatile because they get used in the ISR and the other parts of the code. In the setup, pin directions are declared and the interruptSetup routine is run. The last line in setup is used only when you have a different voltage powering the Pulse Sensor than you are using to power the Arduino. Mostly, it should be commented out.
void loop() { if (QS == true){ fadeVal = 255; QS = false; } ledFadeToBeat(); delay(20); }
Here's the loop function. Note the delay at the end. This loop will run every 20mS. Since Signal is updated automatically, you can do whatever you want with it as well, modulate a tone output on a speaker, etc.. Remember that the QS flag gets set when our ISR finds the heart beat, so by checking it we can know when the beat happens. Inside the if statement we set the fadeVal to maximum brightness and reset the QS flag for next time. the last thing to do before the delay is to fade the LED and then we're done with the loop.
void ledFadeToBeat() { fadeRate-= 15; fadeRate= constrain(fadeRate,0,255); analogWrite(fadePin, fadeRate); }
The ledFadeToBeat function is pretty simple too. It reduces the fadeRate variable by 15 (you can change this as needed), makes sure the fadeRate doesn't roll over into negative numbers,or get higher than 255, then uses analogWrite to set the brightness if the LED on fadePin.
Top Comments