Intro: Pulse Oximeter With Much Improved Precision
If you recently visited a doctor, chances are that your basic vital signs were examined by a nurse. Weight, height, blood pressure, as well as heart rate (HR) and oxygen saturation in peripheral blood (SpO2). Perhaps, the last two were obtained from a red-glowing electronic finger probe that displayed relevant numbers on a tiny screen in minutes. That probe is called pulse oximeter and you can find all the basic info about it here.
One can easily buy a simple pulse oximeter, sure, but where is the fun in it? I have decided to build my own, first for the heck of it, but more importantly with a specific application in mind: nocturnal oximetry where both HR and SpO2 data would be continuously collected overnight and recorded on a micro SD card. Instructables already contains several projects of this kind, e.g., two involving Arduino here and here, and one utilizing Raspberry Pi. Mine uses slightly newer sensor MAX30102 from MAXIM Integrated and Adafruit's Feather M0 Adalogger for control and data recording.
Our project is thus not particularly innovative in terms of hardware and as such would not be worth writing this Instructable, but in the process of creating it I have made crucial advances in software that allowed me to extract data from MAX30102 with much higher consistency and much less noise than software written by MAXIM for this sensor. The performance of our signal processing algorithm is illustrated in the above chart where the two top graphs contain overnight heart rate and oxygen saturation calculated from raw signals by our method (identified by "RF"), while the bottom two graphs show MAXIM's results produced from exactly the same signals. Standard deviations for HR are 4.7 bpm and 18.1 bpm, and for SpO2 0.9% and 4.4%, for RF and MAXIM, respectively.
(Both RF graphs correspond to minimal autocorrelation threshold of 0.25 and no limit on R / IR correlation; see Steps 4 and 5 for explanation of these terms.)
Step 1: Hardware
- Pulse oximeter and heart rate sensor MAX30102 system board from MAXIM Integrated, Inc.
- Feather M0 Adalogger from Adafruit, Inc.
- Lithium Ion Battery from Adafruit, Inc.
- Adalogger pins SCL and SDA to corresponding SCL and SDA pins on MAX30102 board
- Adalogger pin 10 to pin INT on MAX30102 board
- Adalogger GND to MAX30102 board GND
- Adalogger 3V to MAX30102 VIN
Step 2: Digital Signals Returned by MAX30102
The principles of the sensor operation are very simple: two LEDs, one red (660 nm) and one infrared (880 nm, IR) shine light through human skin. The light is partially absorbed by underlying tissues, including peripheral blood. Sensor's photodetector collects reflected light at both wavelengths and returns two corresponding relative intensities using I2C protocol. Since absorption spectra for oxygenated and deoxygenated hemoglobin differ for both wavelengths, the reflected light has a variable component as the amount of arterial blood that is present under the skin pulses with each heartbeat. Figuring out heart rate and oxygen saturation is up to the signal processing software.
Examples of raw signals (IR channel only) are illustrated in the above images. One can notice a periodic component overlaid on a variable baseline that is shifting due to multiple factors mentioned in the Wikipedia page. Motion induced artifacts are particularly annoying since they may mask the useful HR signal and cause bogus results. Hence, advanced commercial oximeters feature accelerometers that help nullify these artifacts.
I may add an accelerometer to the next version of my oximeter, but for nocturnal HR/SpO2 recording, when sensor remains motionless most of the time, it is sufficient to detect and omit distorted signals.
The MAX30102 sensor itself comes in a tiny surface-mounted package, but MAXIM graciously offers a breakout board (System Board 6300) plus signal processing software for Arduino and mbed - all in the reference design package MAXREFDES117#. I happily bought it expecting to just solder some wires between the sensor and Adalogger and have a working, good oximeter in a single day. I adapted the RD117_ARDUINO version of MAXIM's software to run on the Adalogger's ARM Cortex M0 processor. Basically, all I had to do was to replace incompatible SofI2C functions in max30102.cpp by the corresponding Wire library calls. The code compiled fine in the Arduino IDE v1.8.5 and ran on M0 without any errors. The net results, however, were disappointing. In the Introduction step I have already shown very high variance of both HR and SpO2. Naturally, one may claim that I have done something wrong and this was my original thought, too. However, in MAXIM's instructional video you can also observe wildly swinging HR values displayed on the screen. Moreover, comments below video confirm that others have noticed a similar phenomenon, as well.
To make a long story short, after some experimentation I have determined that the sensor is operating OK and an alternative method of digital signal processing results in much better stability. This new method, indicated by "RF", is described in the next steps.
Step 3: Signal Preprocessing
In our implementation, the raw signal is collected at the rate of 25 Hz (same as MAXIM's) for full 4 seconds (MAXIM's software collects only 1 second's worth), resulting in 100 digitized time points per end data point. Each 100-point sequence must be preprocessed in the following way:
- Mean-centering (a.k.a. "removal of the DC component" to electrical engineers). The raw data coming from the sensor is a time series of integers in the 105 range. The useful signal, though, is only a part of light reflected from arterial blood which varies on the order of only 102 - first figure. For meaningful signal processing, it is therefore desirable to subtract the mean from each series point. This part is no different from what the MAXIM software already does. What's different, however, is additional mean-centering of time indices themselves. In other words, instead of indexing series points by numbers from 0 to 99, the new indices are now numbers -49.5, -48.5, ... ,49.5. It may seem weird at first, but thanks to this procedure the signal curve's "center of gravity" coincides with the origin of the coordinate system (second figure). This fact becomes quite useful in the next step.
- Baseline leveling. Another look at the waveforms shown in Step 2 illustrates that the baseline of real oximetry signals is far from being horizontally flat, but varies through different slopes. Third figure shows a mean-centered IR signal (blue curve) and its baseline (blue straight line). In this case, the baseline's slope is negative. The signal processing method described ahead requires baseline to be horizontal. This can be achieved by simply subtracting the baseline from the mean-centered signal. Thanks to the mean-centering of both the Y and the X coordinates, the baseline's intercept is zero and its slope equation is particularly simple, as shown in the fourth figure.The baseline-leveled signal is shown by orange curve in the third figure.
Thus preprocessed signal is ready for the next step.
Step 4: The Workhorse: Autocorrelation Function
Returning back to the usual 1,...,n indexing, the first figure shows definition of the autocorrelation function rm - a quantity found to be very useful in detecting signal's periodicity as well as quality. It is simply a normalized scalar product of the signal's time series with itself shifted by lag m. In our application, though, it is convenient to scale each autocorrelation value with respect to its value at lag = 0, i.e., use relative autocorrelation defined by rm / r0.
Plot of the relative autocorrelation of a typical good quality IR signal is shown in the second figure. As expected, it's value at lag = 0 is at its global maximum equal to 1. The next (local) maximum occurs at lag = 23 and equals to 0.79. The presence of local minima and maxima in autocorrelation plot is easy to understand: as the signal shifts to the right its peaks interfere destructively with each other at first, but at certain point the interference becomes constructive and achieves maximum at the lag equal to the average period of the signal.
The last phrase is crucial: in order to determine the average time period between peaks, from which one can calculate signal's frequency (i.e., heart rate) it is sufficient to find the first local maximum of the autocorrelation function! By default, MAX30102 samples analog input at a rate of 25 points per second, therefore at given m the period in seconds is equal to m / 25. This leads to heart rate expressed in beats per minute (bpm) by:
HR = 60*25 / m = 1500 / m
Of course, it is not necessary to do expensive calculations of rm at all lag values. Our algorithm makes the first guess of heart rate = 60 bpm, which corresponds to m = 25. Autocorrelation function is evaluated at that point and compared to the value at its left neighbor, m = 24. If the neighbors value is higher, then the march continues to the left until rm-1 < rm. Thus determined final m is then returned as the lag at maximum. The next iteration starts from that value instead of 25 and the whole process repeats. If the first left neighbor is lower, then the above routine marches lag points to the right in similar manner. Most of the time, lag at maximum requires just a few evaluations of the autocorrelation function. In addition, maximum and minimum acceptable lags (corresponding to minimal and maximal heart rate, respectively) are used as limiting values.
The above works very well for good quality signals, but the real world is far from ideal. Some signals come out distorted, mostly due to motion artifacts. Such a signal is shown in the third figure. Poor periodicity is reflected in the shape of its autocorrelation function as well as in low value, 0.28, of the first local maximum at m = 11. Compare it to the maximum value of 0.79 determined for the good quality signal. Along with lag limiting values, therefore, the value of rm / r0 at maximum is a good indicator of signal quality and a requirement for it to exceed certain threshold may be used to filter out motion artifacts. The "RF" graphs shown in the introductions resulted from such threshold equal to 0.25.
Step 5: Determining Oxygen Saturation
The previous step was sufficient for determining heart rate. The SpO2 requires more work. First, the so far neglected signal in the red (R) channel must be taken into account. Next, the ratio of red to infrared signals, Z = R/IR, both reflected off the arterial blood, is calculated. The "arterial blood" part is crucial, since most of the light is actually reflected off tissues and venous blood. How to pick portion of the signal corresponding to arterial blood? Well, this is the pulsatile component that varies with each heartbeat. In words of electrical engineers, it's the "AC part", while the remaining reflected light is the "DC part". Since absolute intensities of R and IR light are not commensurate, the Z ratio is calculated from relative intensities, as shown in the first figure. In terms of actually calculated quantities, I use root-mean-square (RMS) of the mean-centered, baseline-leveled signal, y, to the already known mean of the raw signal, <Y>; see second figure. The Z ratio is only half of the work, however. The nonlinear sensor response requires an empirical calibration between Z and the final SpO2 values. I took the calibration equation from MAXIM's code:
SpO2 = (-45.06*Z + 30.354)*Z + 94.845
Keep in mind this equation is valid only for MAX30102 design board purchased in 2017! It is likely that MAXIM may recalibrate its sensors at a later date.
The above procedure still produces a lot of false SpO2 readings. The red channel suffers from many artifacts, just like the IR one. It is reasonable to assume that both signals should be strongly correlated. In fact, good quality signals, like the example in third figure, do correlate very well. The Pearson correlation coefficient is in this case as high as 0.99. This is not always the case, as illustrated in the fourth figure. Although the IR signal would pass the heart rate quality filter with its rm / r0 = 0.76, the distorted R signal results in a poor correlation coefficient between the two equal to only 0.42. This observation offers the second quality filter: having the correlation coefficient between channels greater than certain threshold.
The last two figures exemplify net effect of such quality filtering. First, the measured oxygen saturation is plotted with HR quality threshold of 0.25, but without the SpO2 filter. Next plot results from filtering out poor HR and SpO2 results at the 0.5 rm / r0 and 0.8 correlation coefficient thresholds. Overall, poor data points amounting to 12% of the total were filtered out by the stricter regime.
In our code the correlation coefficient, cc, is calculated according to the formula in fifth figure, where y represents the mean-centered, baseline-leveled signal, whereas r0 was defined in the previous step.
Step 6: The Source Code
The C source code for this project, formatted for the Arduino IDE, is available from our Github account at the following link:
Its Readme page describes individual components.
I would like to take a moment to praise Adafruit for making such an excellent product as M0-based Adalogger. Its fast 48 MHz ARM Cortex M0 processor, with lots of RAM, certainly helped make this project viable, while directly attached SD card reader (plus Adafruit's SD library) remove all the hobbyist's pains associated with real time storage of large amounts of data.