Heartbeat Pulse

We're going to use CircuitPython, Mu, and the light sensor on Circuit Playground Express to plot pulse sensing. We'll run this code on our Circuit Playground Express and use Mu to plot the pulse data that CircuitPython prints out.

Save the following as code.py on your Circuit Playground Express board, using the Mu editor:

import time

import analogio
import board
import neopixel

pixels = neopixel.NeoPixel(board.NEOPIXEL, 10, brightness=1.0)
light = analogio.AnalogIn(board.LIGHT)

# Turn only pixel #1 green
pixels[1] = (0, 255, 0)

# How many light readings per sample
NUM_OVERSAMPLE = 10
# How many samples we take to calculate 'average'
NUM_SAMPLES = 20
samples = [0] * NUM_SAMPLES

while True:
    for i in range(NUM_SAMPLES):
        # Take NUM_OVERSAMPLE number of readings really fast
        oversample = 0
        for s in range(NUM_OVERSAMPLE):
            oversample += float(light.value)
        # and save the average from the oversamples
        samples[i] = oversample / NUM_OVERSAMPLE  # Find the average

        mean = sum(samples) / float(len(samples))  # take the average
        print((samples[i] - mean,))  # 'center' the reading
        time.sleep(0.025)  # change to go faster/slower

For a detailed explanation of how LED pulse sensing works, check out this article.

There are two things we have to do.

First, the values that result from pulse sensing are often noisy or jittery: some are too high, and some are too low, so we smooth them out by taking an average. We take 10 readings as fast as possible (faster than we need to), find the average, and that smooth out the noise. This is called oversampling.

Second, we want to center the readings around zero. The original samples are all the values are positive (greater than or equal to zero). To center the values around zero, we find the average and shift all the values down. So instead of the value always being greater than zero, it will vary above and below zero, with the average being zero. This is called "removing the DC bias" on the signal. To learn more about DC bias, check out this article.

 Since the signal keeps changing, the average is also going to keep changing. So we keep the last 20 samples and compute their average. When the next sample comes along, we drop the oldest sample, and recompute the average again of the 20 most recent values. This is a called a "moving average".  Picture a moving window that is 20 samples wide, moving along the stream of sample data that we are taking. For more detailed information about moving averages, check out this article.

We begin our code by importing the modules we need: neopixel, analogio, time and board. Next, we create the pixels object for the NeoPixels and the light object for the light sensor.

Then, we turn the pixel next to the light sensor green. Note that we turned pixel number "1" green, but it's actually the second pixel on the board. This is because Python starts counting with 0, so the first pixel is pixel number "0".

Next, we assign NUM_OVERSAMPLE = 10 to specify how many light readings we'll take per sample, and  NUM_SAMPLES = 20 to specify how many samples we're going to take to calculate the average we need to remove the DC bias. Then we create a place to store the samples for the moving average, in samples = [0] * NUMSAMPLES.

Now we start reading samples. The outer loop for i in range(NUM_SAMPLES):  tells the code to cycle through  a range of 20 values, so  i keeps running between 0 and 19 continuously. 

As we mentioned above, we are also going to take 10 samples as a fast as possible and average them to smooth out the noise. The inner loop  for s in range(NUM_OVERSAMPLE): reads 10 values from the light sensor.  We sum up all those oversampling values using oversample += float(light.value), until we've added up NUM_SAMPLES number of values. Then we divide by 10 to find the average of the oversampling values, and store the value with samples[i] = oversample / NUM_OVERSAMPLE

After we've computed the oversampling average, we compute the moving average with mean = sum(samples) / float(len(samples)). The window wraps around the samples array. For instance, if i is 3, the most recent sample is in samples[3], and the previous samples are in samples[2], samples[1], samples[0], and then wraps around back to samples[19], samples[18], etc. all the way back to samples[4], the oldest value. This image provides a visual explanation.

We subtract the average from the sampled value, with samples[i] - mean, to center the reading around zero, or remove the DC bias.

Remember, we initialised the moving average samples array with samples = [0] * NUMSAMPLES. You'll notice the plotter doesn't respond correctly at the very beginning. This is because the the moving average that we're taking while the first 20 samples are still in our moving window includes the zeros that we started with before our main loop. So, until we've moved past the first 20 samples, our average will be skewed.

Then, we print it as a tuple print((samples[i] - mean,)).

Note that the Mu plotter looks for tuple values to print. Tuples in Python come in parentheses () with comma separators. If you have two values, a tuple would look like (1.0, 3.14). Since we have only one value, we need to have it print out like (1.0,) note the parentheses around the number, and the comma after the number. Thus the extra parentheses and comma in print((samples[i] - mean,)).

Finally we include time.sleep(0.025) to give a slight delay to the readings.

Once you have everything setup and running, try pressing your finger over the green LED and the light sensor on the Circuit Playground Express, and watch the plotter react! If you press too hard, sometimes it won't respond. But if you press lightly, you'll see a wave form on the plot that matches your pulse!

This is a great way to sense your pulse using the light sensor, and watch plot the changes as you press your finger against it!

This guide was first published on Apr 09, 2018. It was last updated on Apr 09, 2018. This page (Heartbeat Pulse) was last updated on Dec 12, 2018.