Pulse Oximeter With Much Improved Precision

29,414

49

64

Introduction: 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

  1. Pulse oximeter and heart rate sensor MAX30102 system board from MAXIM Integrated, Inc.
  2. Feather M0 Adalogger from Adafruit, Inc.
  3. Lithium Ion Battery from Adafruit, Inc.

Connections:

  • 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:

  1. 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.
  2. 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:

https://github.com/aromring/MAX30102_by_RF

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.

1 Person Made This Project!

Recommendations

  • Puzzles Speed Challenge

    Puzzles Speed Challenge
  • "Can't Touch This" Family Contest

    "Can't Touch This" Family Contest
  • CNC Contest 2020

    CNC Contest 2020

64 Discussions

0
nicholastan98
nicholastan98

19 hours ago

HI, have you tried this sensor on the wrist?
I tried it on the finger, the results were fine, could get above 99% spo2 and valid heart rate.

But when I tried it on the wrist, I could not get any results of both, all -999.

On the wrist:
The raw value of Red is lower than IR about 50000

On the finger:
The raw value of Red is only lower than IR about 10000.

0
bbogdanmircea
bbogdanmircea

Question 7 weeks ago

I looked more in detail at your code and I must first congratulate for the professional way in which you wrote the code and commented it, from 4 MAX30102 drivers that I found, yours is by far the easiest to understand and use.
And now my question, I am running your driver on an ESP32 board that is connected to the MAX30102 board only by SCL SDA (INT is not connected), so for your code the LEDs are lighting, but of course there is no measurement as the uC is waiting for the interrupt line.
But other drivers do not use interrupt line, is it possible to do a small modification to the code so that it works without an interrupt line?
I will check the datasheet in detail and try to understand how to do that by myself, but if you have a tip it would be really helpful.

0
MolecularD
MolecularD

Answer 7 weeks ago

Hmm, all drivers I know use interrupt line. Can you elaborate on your "other drivers do not use interrupt line"? Please provide links to relevant examples.

0
bbogdanmircea
bbogdanmircea

Reply 7 weeks ago

The one that I tried and the examples work on my board is the SparkFun 30105 Arduino library that I downloaded from Arduino library manager. The code is very similar to yours.
The heart rate seems to be okish, but the SPO2 is quite unstable.
I also tried another 30100 driver from GitHub, this is from the guy that made also the Flexiplot tool, but this driver is not working for the 30102, the LEDs are not starting.
For your driver, I tried to modify it and disable the interrupts and also enable the FIFO rollover, but still the values that I get are not ok.
Thanks for the support!

1
MolecularD
MolecularD

Reply 7 weeks ago

First, I don't think adding an INT wire is such a big deal, but maybe your ESP32 board does not have enough digital pins?
Anyway, I took a look at the role of INT signal in my code (https://github.com/aromring/MAX30102_by_RF/blob/master/RD117_ARDUINO.ino) - it's only use is detection of the new data availability in the main signal loop:

while(digitalRead(oxiInt)==1); //wait until the interrupt pin asserts

The analogous loop in SparkFun library (https://github.com/sparkfun/SparkFun_MAX3010x_Sensor_Library/blob/master/examples/Example8_SPO2/Example8_SPO2.ino) features the following calls instead:

while (particleSensor.available() == false) //do we have new data?
particleSensor.check(); //Check the sensor for new data

See the enclosed images. Thus, it's rather obvious what to do. I might add that the particleSensor.check() is complicated and rather expensive. If the sensor's hardware offers an instant INT signal replacing it, then why not use it?
Sample_loop_mine.pngSample_loop_Sparkfun.png
0
bbogdanmircea
bbogdanmircea

Reply 7 weeks ago

Hello, thanks again for the answer.
I followed your advice (kind of), and made a fork of your sketch https://github.com/bbogdanmircea/MAX30102_by_RF
I used the Sparkfun MAX30105 library calls instead of yours to read samples from the sensor, and after filling the sample array I use your algorithm.
But even if the samples look ok, I use FlexiPlot to see them and they are very clean (see Image), the calculation of the HeartRate is wrong by some multiplication factor, and the SPO2 is totally wrong.
I modified the samples to 400 and the average samples to 8 and the HeartRate seems to be realistic, but the SPO2 is even more wrong.
I suspect that you have some defines inside your driver that are also used in the algorithm and these could cause these problems, I will try tomorrow to check it more in detail but if you have some tips it would be great.

Btw I also made your driver kind of work without the interrupt line, by commenting all the INTERRUPT register reads, but the samples that I get are always the same value, so that's why I used the MAX30105 library, as I could not understand what I was doing wrong.

I know it would have been much easier to just solder an interrupt line, but then I wouldn't have learned so much.
Is your algorithm similar to what is done here: https://morf.lv/implementing-pulse-oximeter-using-... ?
I find the explanations very good, also the usage of the FlexiPlot tool helps a lot to understand what is happening.



MAX30102.PNGMAX30102_Serial.PNG
0
MolecularD
MolecularD

Reply 6 weeks ago

Yes, algorithm_by_RF.h uses defines that are different from yours, so start your debugging from there.
Raivis Strogonovs' approach is interesting and well explained, but it's very different from mine.
The FlexiPlot you have attached (thank you) has period of 0.96-0.97 s between peaks. Hence, it corresponds to an approximate HR = 62 bpm. No such HR is reported in the Serial Monitor capture, your second image, thus I am assuming FlexiPlot does not represent raw data for any of these outputs.

0
bbogdanmircea
bbogdanmircea

Reply 6 weeks ago

Actually the Flexiplot I posted is corresponding to that Serial data, but I never checked the period from the Flexiplot, I saw that it is under 1s so I supposed it is correct to what the Serial data is showing.
Thanks for the observation!

1
rainboy912
rainboy912

3 months ago

Hi.
Your code is amazing. i am using it for small project ventilator.
Normally the I2C of arduino is 0V(logic 0), 5V(0). But the I2C of Max30102 is 0V(logic0) 1.8(logic 1) and the MAX30102 board have 4.7k pull up resistors to 3.3V. The first time i connect Max30102 to arduino it did not work with any codes i found in github, and i ask the supplier "is that sensor died" then they tell me to make 4.7k pull-up resistors for SDA SCL to 5V and it works. Can you guys help understand Why i have to do that and did that have any bad effect to the signal or microchip. I google it but still cant understand
Thank you so much

1c7e7426de6d24337d7c.jpg
0
bbogdanmircea
bbogdanmircea

Reply 8 weeks ago

Hello, I am trying to use the MAX30102 PCB with ESP32 based board.
I wired the SCL, SDA to pins 21, 22 directly and GND, VIN to GND and 3.3V of the ESP32 board.
The I2C doesn't seem to work in any sketch, I checked also an I2C Scanner and it tells that it can't find any device.
I measured and there is voltage on the pins on the board.
Any ideas how to make it work?
Both the ESP32 and the MAX30102 use 3.3V so it should be no problem to connect directly?

0
MolecularD
MolecularD

Reply 7 weeks ago

Sorry, I have never used ESP32 board.

0
bbogdanmircea
bbogdanmircea

Reply 7 weeks ago

See my comment above, worked after pull-up fix for the board. Seems to be a design error.
I did not use this code yet, actually only for the SparkFun MAX30105 library the examples work, but the measurements for the SPO2 are very unstable.
I will try this repository next week to on my ESP32 board and feedback if the sensor works.
Basically for ESP32 if you just change board and port in Arduino IDE it works to compile and upload if you have the ESP32 packages installed.

0
bbogdanmircea
bbogdanmircea

Reply 7 weeks ago

I found this fix on another site, my understanding that the pull-up to 1.8V was a design error. Anyway I've cut the track to 1.8V and soldered a wire to 3.3V and the sensor started working for me too. MAX30102

1
bbogdanmircea
bbogdanmircea

8 weeks ago

Hello, I am wondering if this project can be ported on an ESP32 based board without very big difficulties.
I have a TTGO board which has an OLED screen which is perfect for displaying the heart rate and SPO2, also it seems to be fast enough to maybe even show the Heart Rate Graphic.
Anyway I will give it a try.

0
extravert
extravert

2 months ago

Hello
I have noticed that both your code and the Maxim reference code set the value of register 0x10 (#defined to REG_PILOT_PA) in the set up to 0x7f. However, in my datasheet (Rev 1 10/18) for the MAX60102 register 0x10 is not defined. I've tried removing the line that writes to this register and it seems to make no difference on my STM32 port. Any ideas what this register does?

0
MolecularD
MolecularD

Reply 2 months ago

Well, I am no expert regarding Maxim hardware, thus I simply copied Maxim's reference settings and never touched them again. However, I have done some Internet search and found out that in _other_ Maxim sensor this register controls the current of IR LED in proximity mode. This is an extra function that is activated when the sensor enters standby state, awaiting the object sensed. For more details see:
https://datasheets.maximintegrated.com/en/ds/MAX86...
https://datasheets.maximintegrated.com/en/ds/MAX30105.pdf

1
extravert
extravert

Tip 2 months ago

For those asking for a STM32 port, here is one I have done...

https://github.com/miniwinwm/BluePillDemo/tree/master/BluePillDemo_I2C_MAX30102_Pulse_Oximeter

It doesn't log to a SD card but displays simplified output on a serial terminal. It has the addition of a heart beat LED which shows nicely when a good signal is being received. The code is for a STM32F103C8 which is the processor on the Blue Pill board. It is buildable in the STM32CubeIDE, not the Arduino IDE. It uses ST's HAL library so should be easily portable to any other STM32. There's a simple schematic showing how to wire up a MAX30102 breakout board to a Blue Pill board.

0
MolecularD
MolecularD

Reply 2 months ago

Thank you very much for good work!

0
dilkd
dilkd

3 months ago

Hello MolecularD. I have tried this code for diy smart band. I'm very happy with the results. but the code is working for only UNO. If I tred it for mega, it getting error. Please help me. Thanks.