Pulse sensors have become popular due to their use in health-monitors like the Fitbit. The sensors used are cheap, simple and pretty reliable at getting a reasonable indication of heart rate in daily use. They work by sensing the change in light absorption or reflection by blood as it pulses through your arteries — a technique jauntily named photoplethysmography (PPG). The rising and falling light signal can be used to identify the pulse, and subsequently calculate heart rate.
Requirements | |||
---|---|---|---|
Wemos D1 v2.2+ or good imitations. | amazon | ||
Pulsesensor.com sensor Other types may work, but might need a amplifier/filter circuit. | amazon | ||
Something to hold sensor against your finger Velcro straps work well for this. | |||
Wires Loose ends, or jumper leads. | |||
0.91in OLED Screen 128x32 pixels, I2c interface. | amazon |
Most commercial sensors (Fitbit, etc.) use green-light based sensors and similar sensors are available for use in your own projects. In this project we're taking a Pulsesensor.com sensor and using it to build a working heart monitor with OLED pulse, BPM and trace display, using MicroPython on a Wemos D1.
Wiring up the sensor
In this project we're using an Wemos D1 and a Pulsesensor.com heart rate sensor, but other boards and sensors will also work fine. Wire up the sensor as follows, with the signal (S) pin connected to your board's analoge input.
Pulsesensor.com | Wemos D1 | Type |
---|---|---|
- |
GND |
|
+ |
3.3V |
|
S |
A0 |
Analog input |
Once your sensor is wired to this pin you can use following MicroPython code to read the value from the sensor:
import machine
adc = machine.ADC(0)
>>> adc.read()
550
The values output by this ADC are integers in the range 0-1023. A reading of 550 is equivalent to 0.54 on a 0-1 scale.
Detecting a beat
To get some data to work with I set up an loop to print out the above data to terminal while measuring my own pulse. The output was logged to an outfile using screen -L <device> 115200
Below is a plot showing the resulting output showing a rising peak for each beat.
Detecting a peak by sight is straightforward: the signal rises on a beat, and falls between beats, repeatedly reaching maximum and minimum values. However, biological variability makes things doing this automatically a little trickier. Firstly, the maximum and minimum values are affected by multiple things including ambient light, skin colour, depth and size of blood vessels. Secondly, the magnitude of the peaks is affected by the strength of the pulse, and depth of any arteries. Thirdly, the distance between peaks is non-constant: even for a perfectly healthy person, the heart will occasionally skip beats, or the sensor will miss them.
To detect the beat we have a couple of options —
- Detect the signal being over/under a threshold. Simple to implement, but the threshold must adjust to account for use variation.
- Detect the signal rising or falling (for N number of ticks) Bit trickier to implement, less affected by threshold issues, more affected by noise (transient dips).
Here we're going to use the first method, accounting for variation by using a pair of auto-adjusting threshold. We will count a pulse when the value rises 3/4 of the way to the current maximum and a pulse ends when the value falls below 1/2 of the current maximum.
Optimization
To understand why these values were selected, see the following plots. Below is a plot of pulse data (blue) alongside maxima and minima (purple, red) and the current threshold for the given window (grey). This uses a windowsize of 50 samples, and as you can see the maxima & minima bounce around, pulling the threshold all over.
If you see better data from your sensor don't be surprised, these were selected to be noisy.
Extending the window size to 200 gives us a much more stable maxima and minima measurement, although it's still bobbling a little. Notice also that the mid-point (50%) is crossed occasionally where there is no beat.
Extending the window size to 250 eliminates most of the bobble for a "normal" heart rate. Here the threshold line (grey) has been moved to 2/3rds which moves it clear of most of the noise. However, again on the very first peak there is a transient dip in the signal that brings it back below the threshold.
To protect against transient flicker around the cutoff, we can use two cutoff values with separation between them. Here we use a beat on threshold of 75%, and a beat off threshold of 50%. The LED will light once the signal has risen above 75% of maximum, but will not turn off until it falls back below 50%.
You can apply this same approach to sensing triggers on many fuzzy analogue signals.
Detecting a beat
The full code for detecting a beat using MicroPython on a Wemos D1 is given below. In this example, we flash the built-in LED each time a beat is detected.
from machine import Pin, Signal, ADC
adc = ADC(0)
# On my board on = off, need to reverse.
led = Signal(Pin(2, Pin.OUT), invert=True)
MAX_HISTORY = 250
# Maintain a log of previous values to
# determine min, max and threshold.
history = []
while True:
v = adc.read()
history.append(v)
# Get the tail, up to MAX_HISTORY length
history = history[-MAX_HISTORY:]
minima, maxima = min(history), max(history)
threshold_on = (minima + maxima * 3) // 4 # 3/4
threshold_off = (minima + maxima) // 2 # 1/2
if v > threshold_on:
led.on()
if v < threshold_off:
led.off()
The animation below shows the pulse sensor in action, with the LED flashing on each beat.
To support developers in [[ countryRegion ]] I give a [[ localizedDiscount[couponCode] ]]% discount on all books and courses.
[[ activeDiscount.description ]] I'm giving a [[ activeDiscount.discount ]]% discount on all books and courses.
Calculating HR
Maximum heart rate is around 220 BPM (1 beat every 0.27 seconds), and the lowest ever recorded was 26 bpm (1 beat every 2.3 seconds). The normal healthy range is usually considered to be around 60-80 bpm.
Now we can detect the beat, calculating the heart rate is simply a case of counting the number of beats we see within a certain time frame. This window needs to be large enough to ensure we capture at least 2 beats for even the slowest hearts. Here we've used 5 seconds (2.3 x 2 = 4.6). Longer durations will give more accurate heart rates, but be slower to refresh.
Timer based
We can calculate a very rough heart rate using interrupts. MicroPython providers timers, which can be configured to fire repeatedly at a given interval. We can use these, together with running counter to calculate the number of beats between each interval, and from there the number of beats in a minute. The following code will calculate this BPM and write it to the console.
from machine import Pin, Signal, ADC, Timer
adc = ADC(0)
# On my board on = off, need to reverse.
led = Signal(Pin(2, Pin.OUT), invert=True)
MAX_HISTORY = 250
# Maintain a log of previous values to
# determine min, max and threshold.
history = []
beat = False
beats = 0
def calculate_bpm(t):
global beats
print('BPM:', beats * 6) # Triggered every 10 seconds, * 6 = bpm
beats = 0
timer = Timer(1)
timer.init(period=10000, mode=Timer.PERIODIC, callback=calculate_bpm)
while True:
v = adc.read()
history.append(v)
# Get the tail, up to MAX_HISTORY length
history = history[-MAX_HISTORY:]
minima, maxima = min(history), max(history)
threshold_on = (minima + maxima * 3) // 4 # 3/4
threshold_off = (minima + maxima) // 2 # 1/2
if not beat and v > threshold_on:
beat = True
beats += 1
led.on()
if beat and v < threshold_off:
beat = False
led.off()
We have a timer set to 5 seconds, which when called sums up the total of beats since the last calculation, then multiplies this by 6 to give the BPM. The only other change required is the addition of a lock to prevent beats being re-registered once we've already seen one. We do this by toggling beat
between True
and False
— a new beat is only registered if the last beat has ended.
The limitation of this approach is we can only calculate heart rates to multiples of 60/timer_seconds
. With a timer at 5 seconds for example, the calculated heart rate can only be 12, (1 beat in the 5 seconds), 24 (2 beats in the 5 seconds), 36 (3...), 48 (4...), 60, 72, 84, 96, 108 or 120 etc.
Queue-based
We can avoid this limitating using a queue. On each beat, we push the current time onto the queue, truncating it to keep it within a reasonable length. To calculate the beat we can use the time different between the start and end of the queue (timespan), together with the total length (beats), to calculate beats/minute.
from machine import Pin, Signal, I2C, ADC, Timer
import ssd1306
import time
adc = ADC(0)
i2c = I2C(-1, Pin(5), Pin(4))
display = ssd1306.SSD1306_I2C(128, 32, i2c)
MAX_HISTORY = 250
TOTAL_BEATS = 30
def calculate_bpm(beats):
# Truncate beats queue to max, then calculate bpm.
# Calculate difference in time from the head to the
# tail of the list. Divide the number of beats by
# this duration (in seconds)
beats = beats[-TOTAL_BEATS:]
beat_time = beats[-1] - beats[0]
if beat_time:
bpm = (len(beats) / (beat_time)) * 60
display.text("%d bpm" % bpm, 12, 0)
def detect():
# Maintain a log of previous values to
# determine min, max and threshold.
history = []
beats = []
beat = False
while True:
v = adc.read()
history.append(v)
# Get the tail, up to MAX_HISTORY length
history = history[-MAX_HISTORY:]
minima, maxima = min(history), max(history)
threshold_on = (minima + maxima * 3) // 4 # 3/4
threshold_off = (minima + maxima) // 2 # 1/2
if v > threshold_on and beat == False:
beat = True
beats.append(time.time())
beats = beats[-TOTAL_BEATS:]
calculate_bpm(beats)
if v < threshold_off and beat == True:
beat = False
Heart monitor with OLED screen
To create a complete working heart-rate monitor, we can combine what we have so far with a display. The following code uses an 128x32 OLED i2c display using the ssd1306 display driver.
To use this display, just download that .py
file and upload it onto your controller.
The display is wired in using I2C, with the heart rate sensor connected on the same pins as before.
ssd1306 Display | Wemos D1 |
---|---|
GND |
GND |
VCC |
3.3V |
SCL |
D1 |
SDA |
D2 |
The LED flash is replaced with a graphic heart pulse inidicator on the display. The calculated BPM is also shown alongside on the screen. At the bottom we show a continuously updating trace of the sensor data.
For setting up I2C the Pins passed in don't match the D numbers. You can find the mapping here for all pins.
from machine import Pin, Signal, I2C, ADC, Timer
import ssd1306
import time
adc = ADC(0)
i2c = I2C(-1, Pin(5), Pin(4))
display = ssd1306.SSD1306_I2C(128, 32, i2c)
MAX_HISTORY = 200
TOTAL_BEATS = 30
The following block defines the heart image for display on the OLED screen. Since we're using a 1-color screen, we can set each pixel to either on 1
or off 0
.
HEART = [
[ 0, 0, 0, 0, 0, 0, 0, 0, 0],
[ 0, 1, 1, 0, 0, 0, 1, 1, 0],
[ 1, 1, 1, 1, 0, 1, 1, 1, 1],
[ 1, 1, 1, 1, 1, 1, 1, 1, 1],
[ 1, 1, 1, 1, 1, 1, 1, 1, 1],
[ 0, 1, 1, 1, 1, 1, 1, 1, 0],
[ 0, 0, 1, 1, 1, 1, 1, 0, 0],
[ 0, 0, 0, 1, 1, 1, 0, 0, 0],
[ 0, 0, 0, 0, 1, 0, 0, 0, 0],
]
In the refresh block we first scroll the display left, for the rolling trace meter. We scroll the whole display because there is no support for region scroll in framebuf
. The other areas of the display are wiped clear anyway, so it has no effect on appearance. If we have data we plot the trace line, scaled automatically to the min and max values for the current window. Finally we write the BPM to the display, along with the heart icon if we're currently in a beat state.
last_y = 0
def refresh(bpm, beat, v, minima, maxima):
global last_y
display.vline(0, 0, 32, 0)
display.scroll(-1,0) # Scroll left 1 pixel
if maxima-minima > 0:
# Draw beat line.
y = 32 - int(16 * (v-minima) / (maxima-minima))
display.line(125, last_y, 126, y, 1)
last_y = y
# Clear top text area.
display.fill_rect(0,0,128,16,0) # Clear the top text area
if bpm:
display.text("%d bpm" % bpm, 12, 0)
# Draw heart if beating.
if beat:
for y, row in enumerate(HEART):
for x, c in enumerate(row):
display.pixel(x, y, c)
display.show()
The BPM calculation uses the beats
queue, which contains the timestamp (in seconds) of each detected beat. By comparing the time at the beginning and the end of the queue we get a total time duration. The number of values in the list equals the number of beats detected. By dividing the number by the duration we get beats/second (*60
for beats per minute).
def calculate_bpm(beats):
if beats:
beat_time = beats[-1] - beats[0]
if beat_time:
return (len(beats) / (beat_time)) * 60
In the main detection loop we read the sensor, calculate the on and off thresholds and then test our value agains these. We recalculate BPM on each beat, and refresh the screen on each loop.
Depending on the speed of your display you may want to update less regularly.
def detect():
# Maintain a log of previous values to
# determine min, max and threshold.
history = []
beats = []
beat = False
bpm = None
# Clear screen to start.
display.fill(0)
while True:
v = adc.read()
history.append(v)
# Get the tail, up to MAX_HISTORY length
history = history[-MAX_HISTORY:]
minima, maxima = min(history), max(history)
threshold_on = (minima + maxima * 3) // 4 # 3/4
threshold_off = (minima + maxima) // 2 # 1/2
if v > threshold_on and beat == False:
beat = True
beats.append(time.time())
# Truncate beats queue to max
beats = beats[-TOTAL_BEATS:]
bpm = calculate_bpm(beats)
if v < threshold_off and beat == True:
beat = False
refresh(bpm, beat, v, minima, maxima)
Below is a short animation of the heart monitor with OLED display in action, showing the rolling heart trace, beats per minute and heart-beat indicator.