Even before the Covid-19 pandemic, when I created my list of projects for the year, I included one which would look at oxygen saturation. Given the current pandemic it has become even more interesting to look at creating such a project.
In this project we are going to examine how we can use PYNQ and its high level frameworks to configure and control the Click Heart Rate 4 sensor using the Ultra96.
This Heart Rate click will provide us information on the oxygen saturation in the subjects blood using different LED wavelengths.
The resulting waveforms can be processed to extract not only the heart rate of the subject but also the oxygen saturation.
Of course, it goes without saying this project is for understanding and education and not intended to create a medical device.
PhotoplethysmographyWe can measure a subjects heart rate and Oxygen saturation using reflective pulse oximetry. This techniques uses different wavelength LEDs which illuminate the skin (normally a finger). The reflected signal is then monitored for changes in light absorption, the resulting signal is call a photoplethysmography of PPG signal.
When we are determining the Oxygen saturation we actually have two PPG signals one each for one of the wavelengths used.
The wavelengths used to create the PPG signals are red (660 nm) and IR (880nm). These wavelengths are chosen to determine the Oxygen saturation we need to measure Hemoglobin. In this case the red wavelength allows us to measure the de-oxygenated Hemoglobin while the IR wavelength allows us to measure the oxygenated Hemoglobin.
To create the PPG signal we measure the reflected signal for each wavelength. Of course, we do not illuminate with both wavelengths at the same time instead the illumination of each wave length is staggered. Ambient light must also be cancelled rejected by the processing sensor.
We can then use the Red and IR PPG signals to determine the oxygen saturation in the blood stream.
For high oxygen saturation the IR PPG signal should be high while the red PPG signal is low. A low oxygen saturation demonstrates the inverse.
But first we need to be able to drive the selected sensor correctly, for the application the sensor is the Maxim 30101 in this case mounted on the click hear rate four.
Hardware ConfigurationWe will be connecting the Heart Rate 4 click to the Ultra96 using the Avnet click mezzanine. This mezzanine allows us to connect up to two Mikro Elektronika click modules to the Ultra96.
The mezzanine connects to the Ultra96-v2 low speed connector and as such connects to the PS side of the Zynq MPSoC.
Micro Click modules communicate using either SPI, I2C or UART, in the case of the heart rate 4 we use the i2c interface.
On my system I connected the heart rate 4 to the click position two
The first thing we need to do, if we do not already have a PYNQ image for the Ultra96 is to download the image and flash it to the SD Card for booting.
Once the Ultra96 boots with PYNQ we can connect run the WiFi notebook to connect to the internet. We need to do this as we are going to need to add a few new python packages.
The first new package we need to add is SMBUS this enables us to work easily with I2C interfaces. We can discover, read and write and burst transfer information as required by the application.
To add this we need to open a new terminal window and run the following command.
sudo apt install python3-smbus
The second addition we need to make is to add in the SciPy package again use the command
pip3 install scipy --upgrade
This might take a little time to update and install.
Once both of these packages are installed we can get started on our application development.
The first thing we need to do in the terminal window is make sure we can see the Heart Rate 4 click on the I2C bus.
This device should be at address 0x57 on the I2C bus, running the command i2cdetect -l will list the I2C adapters in the system.
The Ultra96 has a I2C expander, on board.
From the schematic we can see the Channel 1 on the I2C expander is connected to the click mezzanine slot two.
In the Ultra96 software environment this means channel 1 is i2c-3, scanning this channel will provide a list of the I2C devices connected to the bus.
In this case we can see the heart rate click is responding at the predicted address.
This means we can then begin to configure the Max30101 sensor on the Heart Rate 4 as we desire.
All of the registers are memory mapped to the I2C bus, we even read out the sensor data over the I2C.
The first thing we need to do is to set up the Max30101 correctly to do this we are going to
- Open the correct SMBus port
- Read the Max30101 device ID to make sure it equals 0x15
- Read the Mode and spO2 configuration registers at 0x09 and 0x0A
- Set the Interrupt enables at register 0x02 and 0x03
- Set the spO2 at 0x0A for 16384 ADC Range Control, 400 SPS and pulse width of 411 Micro-Seconds
- Set the mode register at 0x09 for spO2 mode
- Set the averaging for 32 samples and FIFO roll over at register 0x08
- Set the proximity sensor to 1023 counts before sampling
- Set the Red, IR and Prox LED to half power illumination
import smbus
import time
import matplotlib.pyplot as plt
import statistics
i2c_bus = smbus.SMBus(3)
data = i2c_bus.read_byte_data(0x57,0xff)
data = 0xff & data
print(hex(data))
data = i2c_bus.read_byte_data(0x57,0x09)
data = 0xff & data
print(hex(data))
i2c_bus.write_byte_data(0x57,0x0a,0x01)
data = i2c_bus.read_byte_data(0x57,0x0a)
data = 0xff & data
print(hex(data))
i2c_bus.write_byte_data(0x57,0x02,0xf0)
i2c_bus.write_byte_data(0x57,0x03,0x01)
i2c_bus.write_byte_data(0x57,0x0a,0x6f)
i2c_bus.write_byte_data(0x57,0x09,0x03)
i2c_bus.write_byte_data(0x57,0x08,0xf0)
i2c_bus.write_byte_data(0x57,0x30,0x01)
i2c_bus.write_byte_data(0x57,0x0c,0x3f)
i2c_bus.write_byte_data(0x57,0x0d,0x3f)
i2c_bus.write_byte_data(0x57,0x0e,0x00)
i2c_bus.write_byte_data(0x57,0x10,0x3f)
With the Max30101 set up the next step is to read out the data when a sample is performed.
To perform a sample we do the following
- Read the read and write pointers to determine the number of samples in the FIFO
- determine the number of samples (taking into account the FIFO roll over as well)
- Each Sample consists of three bytes as such for every sample in the FIFO we need to read three bytes from the FIFO. To read the red and IR sample we need to read 6 samples.
- The sample will look over a number number of samples and read out only when there is information to read.
- read the 6 samples will be converted into the red and ir values.
- These red and ir samples are then appended to a list of values for each sample
Once the samples have been collated these can be plotted and further processed to determine the spO2.
data = []
red_samples = []
ir_samples = []
i2c_bus.write_byte_data(0x57,0x04,0x00)
i2c_bus.write_byte_data(0x57,0x05,0x00)
i2c_bus.write_byte_data(0x57,0x06,0x00)
t_end = time.time() + 2
while time.time() < t_end:
wtr = i2c_bus.read_byte_data(0x57,0x04)
rd = i2c_bus.read_byte_data(0x57,0x06)
samples = abs(wtr - rd)
bytes_to_rd = samples * 2
while bytes_to_rd > 6:
data = i2c_bus.read_i2c_block_data(0x57, 0x07, 6)
red = data[0] << 16 | data[1] << 8 | data[2]
ir = data[3] << 16 | data[4] << 8 | data[5]
ir_samples.append(ir)
red_samples.append(red)
bytes_to_rd = bytes_to_rd - 6
Running through different settings on the Max30101 you can see the impact of averaging and sample rate.
We can plot the data using the following code
plt.plot(red_samples)
plt.show()
With no averaging and 50 SPS the PPG signal looks as below for both the red and ir channel.
Increasing the sample rate to 400 SPS increase the number of samples and also shows an increase in the noise in the PPG signal
The final setting of the system was with the sampling rate set at 400 SPS while the averaging was set at 32 samples. This smooths the noise and produces a better PPG signal to further process.
The spO2 saturation is calculated using the following equations
The range of R should be between 0.3 and 3.4.
From the statistics package we can determine the Dc component of the signal and then strip the AC signal of the DC component.
We can then create a simple function to determine the RMS component of the AC signal.
dc_ir = statistics.mean(ir_samples)
dc_red = statistics.mean(red_samples)
import math
def rmsValue(arr, n):
square = 0
mean = 0.0
root = 0.0
#Calculate square
for i in range(0,n):
square += (arr[i]**2)
#Calculate Mean
mean = (square / (float)(n))
#Calculate Root
root = math.sqrt(mean)
return root
n = len(ir_samples)
ir_rms = rmsValue(ir_samples, n)
print(ir_rms)
n = len(red_samples)
red_rms = rmsValue(red_samples, n)
print(red_rms)
r= (red_rms/dc_red) / (ir_rms/dc_ir)
spO2 = 110 - 17 * r
When I ran this in the PYNQ notebook, I calculated a 98% O2 saturation, which is not bad to say I have not calibrated the Red and IR channels or compensated for the die temperature.
A normal reading is between 100% and 94%.
Wrap UpHopefully now we understand how oxygen saturation sensors work and how we can use the PYNQ framework to implement quickly the algorithm needed for processing the signals and creating the resulting statistic.
See previous projects here.
Additional information on Xilinx FPGA / SoC development can be found weekly on MicroZed Chronicles.
Comments