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:

# SPDX-FileCopyrightText: 2018 Kattni Rembor for Adafruit Industries # # SPDX-License-Identifier: MIT 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!