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.


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


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.



    • Colors of the Rainbow Contest

      Colors of the Rainbow Contest
    • Backyard Contest

      Backyard Contest
    • Fandom Contest

      Fandom Contest

    25 Discussions


    Question 17 days ago


    Thank you for detailed description of the algorithm.
    I have some issues could you please help me to resolve the issues.
    1. I interfaced the sensor with Cortex-m4(cc2652) device, when I cross checked the sensor output with one of the spo2 sensor device which I bought online I noticed I'm getting almost same heart rate value. But the problem is in spo2 value there is no change in spo2 value it's constant 99% through out my testing at the same time it's varying in device I bought online.
    2. The sensor taking too much time to give valid outputs compared to the device I bought online. Is there any way I can sort out these issues?

    and one more thing sum_X2 value you calculated seems to be wrong because (-49.5)^2 + (-48.5)^2 + (-47.5)^2 + ... + (47.5)^2 + (48.5)^2 + (49.5)^2 solution is n*(n+1)*(2n+1)/6 where n=49.5. Then answer is 83325/2 not 83325. Please correct me if I assumed anything wrong here.

    1 answer

    Answer 15 days ago

    Here is the sum calculated in Excel to dispel any doubts: it is 83325, indeed. Try it yourself. Your formula, I think, applies to numbers from 1 to n only.
    Now, the matter of unchanging results is concerning since my device's results do change as I wrote in other answers below. If you think it's the result of a bug in my code, then for debugging instructions as well as some tips please follow this link
    then scroll down to "How to report bugs" section. Otherwise, the sensor may be faulty in which case you should contact Maxim.


    22 days ago

    hi can i just check if an arduino mega will work with this code?

    1 reply

    5 weeks ago


    First, thanks a bunch for posting this instructable. It was very educational.

    I have a question, though, about the very basics of this board - mostly I'm suspicious that it's not even really doing much, at least in the realm of SPO2 measurements, and quite possibly heart rate as well.

    I posted something over on your GitHub repo about it, but then saw people were asking questions here. To keep the conversation going here, I've removed it there and I'll post here. Essentially: I am not sure I believe these boards are working right. There are long-term patterns that should be occurring with both SPO2 and heart rate that aren't reflected in charts I see from people using the board (mine included).

    This is what I posted on your git repo:

    I was trying to find your email so that I could ask this directly, but I cannot find it so I thought I'd open an issue. I'm not reporting a bug for your software, but mostly curious about an observation I've had when using the board, and also when observing the data plots (yours included).

    Namely this: the flat line for heart rate and SPO2 seem just plain wrong. Heart rate varies quite a bit during sleep, in fact, there should be a "saddle" effect that occurs when you're sleeping properly. Also, during REM sleep, breathing rate and heart rate also increase. In the data plot you're showing (https://www.instructables.com/id/Pulse-Oximeter-With-Much-Improved-Precision/), it's a straight line with what seems to be noise about it. I understand it might be how it looks due to scale, but it doesn't look like the type of heart patterns I've come to know as indicative of sleep cycles, etc.

    I have a belief I might have sleep apnea, so I bought one of these boards. I used the "noisy" version of the software provided by the manufacturer but noticed my SPO2 was constant throughout the entire night at nearly 97. This was suspect for me.

    Do we know for sure the data coming off these boards are even right? Both oxygen and heart rate should vary more than what I see when observing my data plots as well as that of others. I thus have a great deal of suspicion about how well these boards even function. Do you have any reason to believe at least the SPO2 signal is anything more than a constant number with random noise?

    2 replies

    Reply 4 weeks ago

    First, let's get minor inconsistencies out of the way first: The "straight lines" you see in my plots in the second figure are an illusion caused by large vertical scales (matching the extent of noise caused by the original algorithm). In fact, both SpO2 and HR vary in time, although not much. If you don't believe me, then click in the lower left corner to download the original image and expand it.
    Second, I had the same question: how can I validate that MAX30102 even works? I did a few tests to convince myself. For example, I measured my heart rate at rest and after physical exertion. As expected, the HR went up in this experiment. I also compared the sensor output to independently measured HR by a Sleeptracker device I installed not so long after this project. The numbers and trends agreed, so I am a bit more confident that at least this part of measurements is working OK.
    I did not verify SpO2 in a similar manner, but there is a way to do it, too. Chances are that your doctor has a commercial HR/SpO2 meter. Bring your device to the next medical check up and compare the numbers. Unless you stay in your doctor's office overnight :) , there is no way to know that long time trends of both devices would agree, though.
    Therefore, if you still have concerns regarding your sensor, then you should address these with Maxim Integrated, Inc.


    Reply 4 weeks ago

    Hi, thanks for your response.

    Yeah, I had admitted earlier that the scale was likely the reason I was seeing the data as 'straight lines'. Still, I was expecting more variability over the course of the night on the heart rate portion. I definitely believe you, it just wasn't what I was expecting. Perhaps you have a different sleep pattern than me.

    With respect to the SPO2: I actually do have a doctor friend and do have access to a high-quality, professional-grade SPO2 meter. It's possible I might be able to snag one from his office for a day or two and see how well it works.

    Regardless, your work is appreciated in this article and you clearly improved the signal by a wide margin. Thanks for the response. Cheers.


    Question 7 weeks ago

    Hi very nice blog,
    I have three questions and hope you could provides some insights.
    1. I have a Arduino Uno instead of the Adalogger you used, however when I tried to plot/display some datas by uncommented the TEST_MAXIM_ALGORITHM and/or DEBUG define. No results are plotting. So do you have any suggestion on how do I rework your code for it to work for on a UNO board.

    2. You mentioned that you would probably add an accelerometer to enable recording while the wearer is in motion, how did that goes? if you haven't made it yet, do you have any tips on how to do it, so when I'm able to extract the data, I could give that a try.

    3. Do you have any tips on how to re-calculate the coefficients for SpO2 if I want wanted to apply this sensor on other animals, for example my cat or dog.

    6 answers

    Answer 7 weeks ago

    Thank you for the interest in my project.
    1. Unfortunately, Arduino Uno is not powerful enough to execute this algorithm. I've learned it the hard way. :) But if you insist on using this platform, nonetheless, then you would have to go back to the original MAXREFDES117# code and see how they pulled it off. Basically, they've greatly sacrificed accuracy to save memory. For example, their uch_spo2_table[184] in algorithm.h is composed of uint8_t's instead of floats. I have retained some of their edits as comments in my code. IMHO, it's a losing battle not worth your time. It is much more efficient to just buy sufficiently powerful MCU.
    2. No, I haven't added an accelerometer. In fact, I have not touched this project since January 2018. One idea would be to use it to detect motion and hold sample taking until motion ceases. But in the case of your pets there may not be any periods of rest, especially considering dogs. :) The obvious idea then is to combine signal from accelerometer and SpO2 sensor to subtract motion artifacts. How? If I were to do it I would find out through experimentation... Also, consider MAXIM's kit that features such combination:
    3. Our pets use hemoglobin to transport oxygen, just like us. Thus, the basic principles of the sensor operation should be satisfied. But MAXIM's sensors might be already calibrated for human skin, thus would not work on animals. Honestly, I don't know; better ask MAXIM about it.


    Reply 7 weeks ago

    Thanks for the reply. I do have a spear ST32F0discovery evaluation kit, unfortunately it didn't support mbed, otherwise I would have tried their mbed code.

    While trying to translate the sample mbed to keil u vision (god bless keil cubemx they are so nice to use for setting up the registers on the chip), I found out that from multiple forums (including your post) that soft12cmaster code provided by MAXIM doesn't work, and it only have the header file but no .cpp file. TBH, I'm no fan of I2C and I think it involves too much work to get I2C working probably . So I'm not planning to write my own I2C functions/libraries if I don't have to, and is wondering, do you have any recommendation on I2C libraries (either STM or 3rd party is fine for me) that I could use to replace the SoftI2CMaster library and works in keil IDE.

    Lastly, I have an idea on trying to make this sensor work on my dog, without having to add an accelerometer. But of course, I need to first get the sensor working and showing results first before trying out the idea that I have. The idea that I have, is to strap the sensor tightly to the skin surface (without hurting the dog). and hopefully when doggo is running around the motion artifacts caused by relative motion between the sensor and skin surface can be effectively ignored. Not sure will this idea work, will tests it out and get back to you, after I got the sensor working with the STM32 chip.

    Have a nice week. ; )


    Reply 7 weeks ago

    Sorry, this is the first time I hear about "keil". I use Arduino IDE and am quite happy with it. Not knowing anything about keil indicates that I know even less about its libraries...

    Motion artifacts are not entirely about the sensor moving against the skin, but about moving muscles/tendons/etc. _under_ the skin. To the sensor, these are like loud sonic booms while you try recording bird songs. Hence, I am skeptical about MAXIM returning reliable data under these conditions. My setup, in particular, is almost guaranteed not to work - with a modicum of quality control the code would reject almost all signals. But not to discourage you, I admit that in my specific application I did not dedicate enough thought to combating these artifacts. Persistence is the mother of invention, so I wish you good luck!


    Reply 7 weeks ago

    Keil is a IDE specific for STM's Arm processors. Arduino IDEs is great on many levels.

    Thanks for your head up and hopefully I can get back to you with an update.


    Reply 6 weeks ago

    Yes, it's normal that once a while the signal quality does not meet standards and an indicator -999 is returned instead of the expected parameters.
    About serial plotting, I think you mean graphs posted in Step 2? Yes, that was Arduino IDE's plotter capturing live data. You must turn DEBUG on to get the raw data output.


    Reply 6 weeks ago

    Hi again,

    I've got myself and Adalogger just to try out your code. I'm surprise how small it is, I was expecting it is similar side to and UNO. when I run your code, I observe that every couple of seconds, the HR and SpO2 values will return -999 before spitting out the correct values in the next cycle. Did you have this observation as well?

    Also in your post you've plotted the data, did you use the serial plotter in the arduino IDE to plot it? Because when I use the serial plotter to plot the data live, it looks nowhere near that, as it appears to only plot the IR and red led outputs.


    7 weeks ago

    Hi Thanks for this awesome project ! I am using the Adafruit Feather M0 RFM96 too and I am running into troubles with the interrupts. (I plugged the INT pin to Feather M0 pin 10). The data is coming at random intervals or not coming at all. I already check the wiring its good . What should i look into to debug this ?
    Thanks for your help !

    2 replies

    Reply 7 weeks ago

    Hi ,
    Thanks for your reply ! I didn't submitted it as a bug because most probably its not a bug in your code it is more probably an error on my side >.< . I will have a look at the closed issues and post one if I don't find the answer


    Question 2 months ago

    I am using MAX30105 with this sketch. Seems to work will for spo2 but the heart rate stays at 78 and doesnt change. Do I have to change something for this sensor?

    1 answer

    Answer 2 months ago

    I wish I had powers of the Wizard of Oz, or at least those of a proficient mind reader, to be able to correctly guess what the problem is when given almost zero information about your system. Especially the one involving sensor I have never tested.
    Let's do this: follow this link
    then scroll down to "How to report bugs" section, OK?


    2 months ago

    Please explain how to fix the max30105 code problem.