Heart Beat Counter on Raspberry Pi Zero Using Pulse Sensor and LCD Display
01 Overview
Your heart rate, or pulse, is the number of times your heart beats per minute. Normal heart rate varies from person to person — knowing yours can be an important heart-health gauge.
In this tutorial, we build a Heartbeat Counter that displays beats per minute on an LCD, using a Raspberry Pi Zero.
Project Use Case
This project is a good introduction to reading analog sensor data on a Raspberry Pi (via an external ADC), processing a noisy waveform to extract a meaningful signal, and displaying live results on an I2C character LCD — techniques that extend well beyond heart-rate monitoring into any analog-sensing project.
02 Hardware and Software Components
Gather everything below before you start.
Hardware Components
| Component | Description |
|---|---|
| Raspberry Pi Zero | Main computer that reads the sensor data and drives the LCD. |
| I2C 1602 LCD | Displays the calculated heart rate (BPM). |
| Pulse Sensor | Optical sensor that detects blood flow to measure heartbeats. |
| ADS1115 | 16-bit ADC that reads the Pulse Sensor's analog output for the Raspberry Pi (which has no built-in analog input). |
| Jumper Wires (Male-to-Male) | Connects components on the breadboard. |
| Jumper Wires (Male-to-Female) | Connects breadboard components to the Raspberry Pi's GPIO header. |
| Breadboard | Platform for building and testing the circuit. |
Software Tools
| Software | Description |
|---|---|
| VNC Viewer | Used to remotely access the Raspberry Pi Zero's desktop. |
| Python 3 (IDLE) | Used to write and run the heartbeat-counting script. |
Libraries Used
| Library | Purpose |
|---|---|
| time | Standard Python library used for timing the pulse waveform. |
| Adafruit_ADS1x15 | Driver library for reading analog values from the ADS1115 ADC. |
| I2C_LCD_driver | Custom driver script (included below) for controlling the I2C 1602 LCD. |
03 Application Discussion
How the Pulse Sensor Works
The Pulse/Heartbeat Sensor works in a fairly simple way. The sensor has two sides — on one side sits an LED alongside an ambient light sensor, and on the other side is the circuitry responsible for amplification and noise cancellation. The LED on the front of the sensor is placed directly over a vein, typically on a fingertip or ear tip.
The LED emits light that falls directly on the vein. Veins only have blood flowing through them when the heart is pumping, so monitoring blood flow lets you monitor heartbeats. When blood flow is detected, the ambient light sensor picks up more reflected light; this minor change in received light is analyzed over time to determine heart beats.
What Is an I2C 1602 LCD?
The I2C 1602 LCD module is a 2-line by 16-character display interfaced to an I2C daughter board. The I2C interface only requires 2 data connections, plus +5VDC and GND, to operate.
I2C (Inter-Integrated Circuit)
In I2C, you can connect multiple slaves to a single master, and you can have multiple masters controlling a single slave or multiple slaves. This is useful when you want more than one microcontroller logging data to a single memory card or displaying text on a single LCD.
I2C is a serial communication protocol, so data transfers bit by bit along a single wire (the SDA line). Like SPI, I2C is synchronous — the output of bits is synchronized to the sampling of bits by a clock signal shared between the master and the slave, with the clock always controlled by the master.
ADS1115
The ADS1115 is a 16-bit Analog-to-Digital Converter that can greatly improve resolution and measurement accuracy. It has four input channels that can be configured for single-ended, differential, or comparator measurements.
04 Hardware Setup
Wire the Pulse Sensor, ADS1115, and LCD to the Raspberry Pi Zero as described below.
Pulse Sensor Setup
| Sensor Pin | Connect To |
|---|---|
| VCC | Raspberry Pi Zero 3V3 pin |
| GND | Raspberry Pi Zero GND pin |
ADS1115 Setup
| ADS1115 Pin | Connect To |
|---|---|
| SDA | Raspberry Pi Zero GPIO 2 (SDA) |
| SCL | Raspberry Pi Zero GPIO 3 (SCL) |
| VCC | Raspberry Pi Zero 3V3 |
| GND | Raspberry Pi Zero GND |
I2C 1602 LCD Setup
| LCD Pin | Connect To |
|---|---|
| SDA | Raspberry Pi Zero GPIO 2 |
| SCL | Raspberry Pi Zero GPIO 3 |
| VCC | Raspberry Pi Zero 5V pin |
| GND | Raspberry Pi Zero GND pin |
05 Software Setup
Follow these steps before running the script.
Step 1 — Set Up Remote Access (Optional)
Install VNC Viewer on your computer if you'd like to access your Raspberry Pi Zero's desktop remotely.
Step 2 — Install the ADS1x15 Library
Install the Adafruit_ADS1x15 Python library on your Raspberry Pi Zero, following the installation instructions in its repository.
Step 3 — Add the I2C LCD Driver
Create a new Python file named I2C_LCD_driver.py, copy in the driver code from the Code section below, and save it in the same folder as your main script.
Step 4 — Run the Script
Save the main Python code from the Code section as its own file in the same folder, then run it with Python 3 (IDLE or terminal). Place your finger on the Pulse Sensor and watch the LCD for your BPM reading.
06 Code
Read the Code Breakdown section to understand what each part does.
I2C_LCD_driver.py
# -*- coding: utf-8 -*-
# Original code found at:
# https://gist.github.com/DenisFromHR/cc863375a6e19dce359d
"""
Compiled, mashed and generally mutilated 2014-2015 by Denis Pleic
Made available under GNU GENERAL PUBLIC LICENSE
# Modified Python I2C library for Raspberry Pi
# as found on http://www.recantha.co.uk/blog/?p=4849
# Joined existing 'i2c_lib.py' and 'lcddriver.py' into a single library
# added bits and pieces from various sources
# By DenisFromHR (Denis Pleic)
# 2015-02-10, ver 0.1
"""
# i2c bus (0 -- original Pi, 1 -- Rev 2 Pi)
I2CBUS = 1
# LCD Address
ADDRESS = 0x27
import smbus
from time import sleep
class i2c_device:
def __init__(self, addr, port=I2CBUS):
self.addr = addr
self.bus = smbus.SMBus(port)
# Write a single command
def write_cmd(self, cmd):
self.bus.write_byte(self.addr, cmd)
sleep(0.0001)
# Write a command and argument
def write_cmd_arg(self, cmd, data):
self.bus.write_byte_data(self.addr, cmd, data)
sleep(0.0001)
# Write a block of data
def write_block_data(self, cmd, data):
self.bus.write_block_data(self.addr, cmd, data)
sleep(0.0001)
# Read a single byte
def read(self):
return self.bus.read_byte(self.addr)
# Read
def read_data(self, cmd):
return self.bus.read_byte_data(self.addr, cmd)
# Read a block of data
def read_block_data(self, cmd):
return self.bus.read_block_data(self.addr, cmd)
# commands
LCD_CLEARDISPLAY = 0x01
LCD_RETURNHOME = 0x02
LCD_ENTRYMODESET = 0x04
LCD_DISPLAYCONTROL = 0x08
LCD_CURSORSHIFT = 0x10
LCD_FUNCTIONSET = 0x20
LCD_SETCGRAMADDR = 0x40
LCD_SETDDRAMADDR = 0x80
# flags for display entry mode
LCD_ENTRYRIGHT = 0x00
LCD_ENTRYLEFT = 0x02
LCD_ENTRYSHIFTINCREMENT = 0x01
LCD_ENTRYSHIFTDECREMENT = 0x00
# flags for display on/off control
LCD_DISPLAYON = 0x04
LCD_DISPLAYOFF = 0x00
LCD_CURSORON = 0x02
LCD_CURSOROFF = 0x00
LCD_BLINKON = 0x01
LCD_BLINKOFF = 0x00
# flags for display/cursor shift
LCD_DISPLAYMOVE = 0x08
LCD_CURSORMOVE = 0x00
LCD_MOVERIGHT = 0x04
LCD_MOVELEFT = 0x00
# flags for function set
LCD_8BITMODE = 0x10
LCD_4BITMODE = 0x00
LCD_2LINE = 0x08
LCD_1LINE = 0x00
LCD_5x10DOTS = 0x04
LCD_5x8DOTS = 0x00
# flags for backlight control
LCD_BACKLIGHT = 0x08
LCD_NOBACKLIGHT = 0x00
En = 0b00000100 # Enable bit
Rw = 0b00000010 # Read/Write bit
Rs = 0b00000001 # Register select bit
class lcd:
#initializes objects and lcd
def __init__(self):
self.lcd_device = i2c_device(ADDRESS)
self.lcd_write(0x03)
self.lcd_write(0x03)
self.lcd_write(0x03)
self.lcd_write(0x02)
self.lcd_write(LCD_FUNCTIONSET | LCD_2LINE | LCD_5x8DOTS | LCD_4BITMODE)
self.lcd_write(LCD_DISPLAYCONTROL | LCD_DISPLAYON)
self.lcd_write(LCD_CLEARDISPLAY)
self.lcd_write(LCD_ENTRYMODESET | LCD_ENTRYLEFT)
sleep(0.2)
# clocks EN to latch command
def lcd_strobe(self, data):
self.lcd_device.write_cmd(data | En | LCD_BACKLIGHT)
sleep(.0005)
self.lcd_device.write_cmd(((data & ~En) | LCD_BACKLIGHT))
sleep(.0001)
def lcd_write_four_bits(self, data):
self.lcd_device.write_cmd(data | LCD_BACKLIGHT)
self.lcd_strobe(data)
# write a command to lcd
def lcd_write(self, cmd, mode=0):
self.lcd_write_four_bits(mode | (cmd & 0xF0))
self.lcd_write_four_bits(mode | ((cmd << 4) & 0xF0))
# write a character to lcd (or character rom) 0x09: backlight | RS=DR<
# works!
def lcd_write_char(self, charvalue, mode=1):
self.lcd_write_four_bits(mode | (charvalue & 0xF0))
self.lcd_write_four_bits(mode | ((charvalue << 4) & 0xF0))
# put string function with optional char positioning
def lcd_display_string(self, string, line=1, pos=0):
if line == 1:
pos_new = pos
elif line == 2:
pos_new = 0x40 + pos
elif line == 3:
pos_new = 0x14 + pos
elif line == 4:
pos_new = 0x54 + pos
self.lcd_write(0x80 + pos_new)
for char in string:
self.lcd_write(ord(char), Rs)
# clear lcd and set to home
def lcd_clear(self):
self.lcd_write(LCD_CLEARDISPLAY)
self.lcd_write(LCD_RETURNHOME)
# define backlight on/off (lcd.backlight(1); off= lcd.backlight(0)
def backlight(self, state): # for state, 1 = on, 0 = off
if state == 1:
self.lcd_device.write_cmd(LCD_BACKLIGHT)
elif state == 0:
self.lcd_device.write_cmd(LCD_NOBACKLIGHT)
# add custom characters (0 - 7)
def lcd_load_custom_chars(self, fontdata):
self.lcd_write(0x40);
for char in fontdata:
for line in char:
self.lcd_write_char(line)
I2C_LCD_driver.py, copy this code into it, and save it in the same folder as your main script.heartbeat_counter.py
import time
# Import the ADS1x15 module.
import Adafruit_ADS1x15
import I2C_LCD_driver
from time import sleep, strftime
mylcd = I2C_LCD_driver.lcd()
adc = Adafruit_ADS1x15.ADS1015()
# initialization
GAIN = 2/3
curState = 0
thresh = 525 # mid point in the waveform
P = 512
T = 512
stateChanged = 0
sampleCounter = 0
lastBeatTime = 0
firstBeat = True
secondBeat = False
Pulse = False
IBI = 600
rate = [0]*10
amp = 100
lastBPM = 0
exitfunc = 0
startfunc = 1
lastTime = int(time.time()*1000)
while True:
# read from the ADC
Signal = adc.read_adc(0, gain=GAIN) #TODO: Select the correct ADC channel. I have selected A0 here
curTime = int(time.time()*1000)
sampleCounter += curTime - lastTime; # # keep track of the time in mS with this variable
lastTime = curTime
N = sampleCounter - lastBeatTime; # # monitor the time since the last beat to avoid noise
#print N, Signal, curTime, sampleCounter, lastBeatTime
## find the peak and trough of the pulse wave
if Signal < thresh and N > (IBI/5.0)*3.0 : # # avoid dichrotic noise by waiting 3/5 of last IBI
if Signal < T : # T is the trough
T = Signal; # keep track of lowest point in pulse wave
if Signal > thresh and Signal > P: # thresh condition helps avoid noise
P = Signal; # P is the peak
# keep track of highest point in pulse wave
# NOW IT'S TIME TO LOOK FOR THE HEART BEAT
# signal surges up in value every time there is a pulse
if N > 250 : # avoid high frequency noise
if (Signal > thresh) and (Pulse == False) and (N > (IBI/5.0)*3.0) :
Pulse = True; # set the Pulse flag when we think there is a pulse
IBI = sampleCounter - lastBeatTime; # measure time between beats in mS
lastBeatTime = sampleCounter; # keep track of time for next pulse
if secondBeat : # if this is the second beat, if secondBeat == TRUE
secondBeat = False; # clear secondBeat flag
for i in range(0,10): # seed the running total to get a realisitic BPM at startup
rate[i] = IBI;
if firstBeat : # if it's the first time we found a beat, if firstBeat == TRUE
firstBeat = False; # clear firstBeat flag
secondBeat = True; # set the second beat flag
continue # IBI value is unreliable so discard it
# keep a running total of the last 10 IBI values
runningTotal = 0; # clear the runningTotal variable
for i in range(0,9): # shift data in the rate array
rate[i] = rate[i+1]; # and drop the oldest IBI value
runningTotal += rate[i]; # add up the 9 oldest IBI values
rate[9] = IBI; # add the latest IBI to the rate array
runningTotal += rate[9]; # add the latest IBI to runningTotal
runningTotal /= 10; # average the last 10 IBI values
BPM = 60000/runningTotal; # how many beats can fit into a minute? that's BPM!
print 'BPM: {}'.format(BPM)
mylcd.lcd_display_string(" ", 2, 0)
mylcd.lcd_display_string("Heart Rate:", 1, 0)
if lastBPM == BPM:
if BPM > 120:
mylcd.lcd_display_string('Please Wait', 2, 0);
if BPM < 120:
mylcd.lcd_display_string('BPM: {}'.format(BPM),2,0);
mylcd.lcd_display_string(' <3',2,9);
sys.exit()
if lastBPM != BPM:
mylcd.lcd_display_string('Please Wait :)', 2, 0);
lastBPM = BPM;
if Signal < thresh and Pulse == True : # when the values are going down, the beat is over
Pulse = False; # reset the Pulse flag so we can do it again
amp = P - T; # get amplitude of the pulse wave
thresh = amp/2 + T; # set thresh at 50% of the amplitude
P = thresh; # reset these for next time
T = thresh;
if N > 2500 : # if 2.5 seconds go by without a beat
thresh = 512; # set thresh default
P = 512; # set P default
T = 512; # set T default
lastBeatTime = sampleCounter; # bring the lastBeatTime up to date
firstBeat = True; # set these to avoid noise
secondBeat = False; # when we get the heartbeat back
print "no beats found"
mylcd.lcd_display_string("Heart Rate:")
mylcd.lcd_display_string("no beats found",2,0)
time.sleep(0.05)
print statements (e.g. print 'BPM: {}'.format(BPM)). If you're running Python 3, update these to function-call syntax, e.g. print('BPM: {}'.format(BPM)).07 Code Breakdown
Here is what the main parts of heartbeat_counter.py do.
Reading the Signal and Finding Peak/Trough
# read from the ADC
Signal = adc.read_adc(0, gain=GAIN) #TODO: Select the correct ADC channel. I have selected A0 here
curTime = int(time.time()*1000)
sampleCounter += curTime - lastTime; # keep track of the time in mS with this variable
lastTime = curTime
N = sampleCounter - lastBeatTime; # monitor the time since the last beat to avoid noise
## find the peak and trough of the pulse wave
if Signal < thresh and N > (IBI/5.0)*3.0 : # avoid dichrotic noise by waiting 3/5 of last IBI
if Signal < T : # T is the trough
T = Signal; # keep track of lowest point in pulse wave
if Signal > thresh and Signal > P: # thresh condition helps avoid noise
P = Signal; # P is the peak
This reads the analog value from the pulse sensor via the ADC, then tracks the peak (P) and trough (T) of the pulse waveform while filtering out noise.
Detecting a Heartbeat and Calculating BPM
# NOW IT'S TIME TO LOOK FOR THE HEART BEAT
# signal surges up in value every time there is a pulse
if N > 250 : # avoid high frequency noise
if (Signal > thresh) and (Pulse == False) and (N > (IBI/5.0)*3.0) :
Pulse = True; # set the Pulse flag when we think there is a pulse
IBI = sampleCounter - lastBeatTime; # measure time between beats in mS
lastBeatTime = sampleCounter; # keep track of time for next pulse
if secondBeat :
secondBeat = False;
for i in range(0,10): # seed the running total to get a realisitic BPM at startup
rate[i] = IBI;
if firstBeat :
firstBeat = False;
secondBeat = True;
continue # IBI value is unreliable so discard it
# keep a running total of the last 10 IBI values
runningTotal = 0;
for i in range(0,9):
rate[i] = rate[i+1];
runningTotal += rate[i];
rate[9] = IBI;
runningTotal += rate[9];
runningTotal /= 10; # average the last 10 IBI values
BPM = 60000/runningTotal; # how many beats can fit into a minute? that's BPM!
This is where a heartbeat is detected and BPM (Beats Per Minute) is calculated. When the signal surges above the threshold and no pulse is currently flagged, it's treated as a new beat: the time since the last beat (IBI, inter-beat interval) is measured, and the first two beats are used to seed a running average rather than being trusted outright (their IBI values are unreliable). From then on, the code keeps a running total of the last 10 IBI values, averages them, and converts that average into BPM.
Displaying and Resetting
if Signal < thresh and Pulse == True : # when the values are going down, the beat is over
Pulse = False;
amp = P - T; # get amplitude of the pulse wave
thresh = amp/2 + T; # set thresh at 50% of the amplitude
P = thresh;
T = thresh;
if N > 2500 : # if 2.5 seconds go by without a beat
thresh = 512;
P = 512;
T = 512;
lastBeatTime = sampleCounter;
firstBeat = True;
secondBeat = False;
print "no beats found"
mylcd.lcd_display_string("Heart Rate:")
mylcd.lcd_display_string("no beats found",2,0)
Once a BPM value is calculated and matches the previous reading (lastBPM == BPM), it's considered stable and gets printed to the LCD — showing "Please Wait" if the reading is above 120 BPM (likely noise), or the actual BPM if it's below 120. If 2.5 seconds pass without detecting a beat, the thresholds reset to their defaults and "no beats found" is shown, so the algorithm can recover cleanly the next time a finger is placed on the sensor.
08 Conclusion
There are many types of sensors today applicable to health monitoring and the medical field. The pulse sensor is a great example — it lets us build a project that counts our heartbeat and helps us keep an eye on our heart health.
09 References
-
Arduino ADS1115 Module Getting Started Tutorial — Henry's Bench
(http://henrysbench.capnfatz.com/henrys-bench/arduino-voltage-measurements/arduino-ads1115-module-getting-started-tutorial/) -
Pulse Sensor — Components101
(https://components101.com/sensors/pulse-sensor) -
Basics of the I2C Communication Protocol — Circuit Basics
(http://www.circuitbasics.com/basics-of-the-i2c-communication-protocol/)
10 Project Authors
- Jason Lopez
- Lukie-Mar Lonzaga
- Miles Darren Bagnol
- Elijah Joseph Orias
