Measuring pulse is a very common thing nowadays as there are pulse meters in almost all smart watches and bands and some are going even further with additional measurements. During the recent pandemic, measuring blood oxygen saturation was made popular as an indication of the general health of people so I then purchased a sensor to try out, namely the MAX30105.
Device Properties
The MAX30105 sensor is an optical particle sensor but since it is very sensitive, it is commonly used to measure human heart rate as well as the oxygen saturation of the blood by tracking the color and illumination changes of the finger as blood passes through it.
The final device consists of a NodeMCU microcontroller, an SSD1306 OLED screen, and the MAX30105 sensor. Both the OLED and the MAX30105 sensor are connected to the I2C interface on the NodeMCU on pins D1 and D2. The OLED is powered from 3.3V while the MAX30105 sensor is powered from 5V instead for better stability, but it can also be powered from 3.3V.
This device is made for educational purposes only and it should not be used in any way to detect or diagnose medical issues.
Video explanation
You can see the full video for the oximeter device below.
Tools and materials used in the project
- MAX30105 Particle Sensor - https://s.click.aliexpress.com/e/_msvCviO
- SSD1306 OLED Screen - https://s.click.aliexpress.com/e/_mOrFLOW
- NodeMCU development Board - https://s.click.aliexpress.com/e/_oFMZD4O
- Mini Breadboards - https://s.click.aliexpress.com/e/_oF23CFC
- Dupont jumper wires - https://s.click.aliexpress.com/e/_mNyfSvg
- Breadboard Jumper PCB - https://www.tindie.com/products/taste_the_code/mini-breadboard-jumper-pcb/
- Multimeter - https://s.click.aliexpress.com/e/_oBvhWkE
- Rework Station - https://s.click.aliexpress.com/e/_EGO3LVf
- Wire Snips - https://s.click.aliexpress.com/e/_oDXGUN8
- Automatic Wire Stripper - https://s.click.aliexpress.com/e/_oo1ubvY
- RD6012 Bench Power Supply - https://s.click.aliexpress.com/e/_oChVfR8
Arduino Code
The device is programmed using the Arduino IDE with the code below.
#include <MAX3010x.h>
#include <Adafruit_SSD1306.h>
#include "filters.h"
#define SCREEN_WIDTH 128 // OLED display width, in pixels
#define SCREEN_HEIGHT 64 // OLED display height, in pixels
#define OLED_RESET -1 // Reset pin # (or -1 if sharing Arduino reset pin)
Adafruit_SSD1306 display(SCREEN_WIDTH, SCREEN_HEIGHT, &Wire, OLED_RESET);
// Sensor (adjust to your sensor type)
MAX30105 sensor;
const auto kSamplingRate = sensor.SAMPLING_RATE_400SPS;
const float kSamplingFrequency = 400.0;
// Finger Detection Threshold and Cooldown
const unsigned long kFingerThreshold = 10000;
const unsigned int kFingerCooldownMs = 500;
// Edge Detection Threshold (decrease for MAX30100)
const float kEdgeThreshold = -2000.0;
// Filters
const float kLowPassCutoff = 5.0;
const float kHighPassCutoff = 0.5;
// Averaging
const bool kEnableAveraging = true;
const int kAveragingSamples = 5;
const int kSampleThreshold = 5;
void setup() {
Serial.begin(9600);
// SSD1306_SWITCHCAPVCC = generate display voltage from 3.3V internally
if(!display.begin(SSD1306_SWITCHCAPVCC, 0x3C)) {
Serial.println(F("SSD1306 allocation failed"));
while (1);
}
if(sensor.begin() && sensor.setSamplingRate(kSamplingRate)) {
Serial.println("Sensor initialized");
}
else {
Serial.println("Sensor not found");
while(1);
}
display.clearDisplay();
initDrawScreen();
}
// Filter Instances
LowPassFilter low_pass_filter_red(kLowPassCutoff, kSamplingFrequency);
LowPassFilter low_pass_filter_ir(kLowPassCutoff, kSamplingFrequency);
HighPassFilter high_pass_filter(kHighPassCutoff, kSamplingFrequency);
Differentiator differentiator(kSamplingFrequency);
MovingAverageFilter<kAveragingSamples> averager_bpm;
MovingAverageFilter<kAveragingSamples> averager_r;
MovingAverageFilter<kAveragingSamples> averager_spo2;
// Statistic for pulse oximetry
MinMaxAvgStatistic stat_red;
MinMaxAvgStatistic stat_ir;
// R value to SpO2 calibration factors
// See https://www.maximintegrated.com/en/design/technical-documents/app-notes/6/6845.html
float kSpO2_A = 1.5958422;
float kSpO2_B = -34.6596622;
float kSpO2_C = 112.6898759;
// Timestamp of the last heartbeat
long last_heartbeat = 0;
// Timestamp for finger detection
long finger_timestamp = 0;
bool finger_detected = false;
// Last diff to detect zero crossing
float last_diff = NAN;
bool crossed = false;
long crossed_time = 0;
void loop() {
auto sample = sensor.readSample(1000);
float current_value_red = sample.red;
float current_value_ir = sample.ir;
// Detect Finger using raw sensor value
if(sample.red > kFingerThreshold) {
if(millis() - finger_timestamp > kFingerCooldownMs) {
finger_detected = true;
}
}
else {
// Reset values if the finger is removed
differentiator.reset();
averager_bpm.reset();
averager_r.reset();
averager_spo2.reset();
low_pass_filter_red.reset();
low_pass_filter_ir.reset();
high_pass_filter.reset();
stat_red.reset();
stat_ir.reset();
finger_detected = false;
finger_timestamp = millis();
}
if(finger_detected) {
displayMeasuredValues(false, 0, 0);
current_value_red = low_pass_filter_red.process(current_value_red);
current_value_ir = low_pass_filter_ir.process(current_value_ir);
// Statistics for pulse oximetry
stat_red.process(current_value_red);
stat_ir.process(current_value_ir);
// Heart beat detection using value for red LED
float current_value = high_pass_filter.process(current_value_red);
float current_diff = differentiator.process(current_value);
// Valid values?
if(!isnan(current_diff) && !isnan(last_diff)) {
// Detect Heartbeat - Zero-Crossing
if(last_diff > 0 && current_diff < 0) {
crossed = true;
crossed_time = millis();
}
if(current_diff > 0) {
crossed = false;
}
// Detect Heartbeat - Falling Edge Threshold
if(crossed && current_diff < kEdgeThreshold) {
if(last_heartbeat != 0 && crossed_time - last_heartbeat > 300) {
// Show Results
int bpm = 60000/(crossed_time - last_heartbeat);
float rred = (stat_red.maximum()-stat_red.minimum())/stat_red.average();
float rir = (stat_ir.maximum()-stat_ir.minimum())/stat_ir.average();
float r = rred/rir;
float spo2 = kSpO2_A * r * r + kSpO2_B * r + kSpO2_C;
if(bpm > 50 && bpm < 250) {
// Average?
if(kEnableAveraging) {
int average_bpm = averager_bpm.process(bpm);
int average_r = averager_r.process(r);
int average_spo2 = averager_spo2.process(spo2);
// Show if enough samples have been collected
if(averager_bpm.count() >= kSampleThreshold) {
Serial.print("Time (ms): ");
Serial.println(millis());
Serial.print("Heart Rate (avg, bpm): ");
Serial.println(average_bpm);
Serial.print("R-Value (avg): ");
Serial.println(average_r);
Serial.print("SpO2 (avg, %): ");
Serial.println(average_spo2);
displayMeasuredValues(false, average_bpm, average_spo2);
}
}
else {
Serial.print("Time (ms): ");
Serial.println(millis());
Serial.print("Heart Rate (current, bpm): ");
Serial.println(bpm);
Serial.print("R-Value (current): ");
Serial.println(r);
Serial.print("SpO2 (current, %): ");
Serial.println(spo2);
displayMeasuredValues(false, bpm, spo2);
}
}
// Reset statistic
stat_red.reset();
stat_ir.reset();
}
crossed = false;
last_heartbeat = crossed_time;
}
}
last_diff = current_diff;
} else {
displayMeasuredValues(true, 0, 0);
}
}
void initDrawScreen(void) {
display.clearDisplay();
display.setTextSize(1); // Normal 1:1 pixel scale
display.setTextColor(WHITE); // Draw white text
display.setCursor(0,0); // Start at top-left corner
display.println(F(" Taste The Code"));
display.println(F(""));
display.setCursor(5, display.getCursorY());
display.setTextSize(2);
display.println(F("BPM %SpO2"));
display.display();
}
bool display_reset = true;
void displayMeasuredValues(bool no_finger, int32_t beatAvg, int32_t spo2) {
display.setCursor(5,35);
display.setTextColor(WHITE, BLACK);
if(no_finger) {
display.setTextSize(2);
display.println(F("NO Finger "));
display_reset = true;
display.display();
} else if(beatAvg < 30 && display_reset) {
display.setTextSize(2);
display.println(F("Pls. Wait "));
display_reset = false;
display.display();
} else if(beatAvg >= 30) {
display.setTextSize(2);
display.println(F(" "));
display.setCursor(5,35);
display.setTextSize(3);
display.print(beatAvg);
display.print(F(" "));
if(spo2 >= 20 && spo2 <= 100) {
display.print(spo2);
} else {
display.print(F("--"));
}
display.println(F(" "));
display.display();
}
}
The content of the filters.h file is as follows:
#ifndef FILTERS_H
#define FILTERS_H
/**
* @brief Statistic block for min/nax/avg
*/
class MinMaxAvgStatistic {
float min_;
float max_;
float sum_;
int count_;
public:
/**
* @brief Initialize the Statistic block
*/
MinMaxAvgStatistic() :
min_(NAN),
max_(NAN),
sum_(0),
count_(0){}
/**
* @brief Add value to the statistic
*/
void process(float value) {
min_ = isnan(min_) ? value : min(min_, value);
max_ = isnan(max_) ? value : max(max_, value);
sum_ += value;
count_++;
}
/**
* @brief Resets the stored values
*/
void reset() {
min_ = NAN;
max_ = NAN;
sum_ = 0;
count_ = 0;
}
/**
* @brief Get Minimum
* @return Minimum Value
*/
float minimum() const {
return min_;
}
/**
* @brief Get Maximum
* @return Maximum Value
*/
float maximum() const {
return max_;
}
/**
* @brief Get Average
* @return Average Value
*/
float average() const {
return sum_/count_;
}
};
/**
* @brief High Pass Filter
*/
class HighPassFilter {
const float kX;
const float kA0;
const float kA1;
const float kB1;
float last_filter_value_;
float last_raw_value_;
public:
/**
* @brief Initialize the High Pass Filter
* @param samples Number of samples until decay to 36.8 %
* @remark Sample number is an RC time-constant equivalent
*/
HighPassFilter(float samples) :
kX(exp(-1/samples)),
kA0((1+kX)/2),
kA1(-kA0),
kB1(kX),
last_filter_value_(NAN),
last_raw_value_(NAN){}
/**
* @brief Initialize the High Pass Filter
* @param cutoff Cutoff frequency
* @pram sampling_frequency Sampling frequency
*/
HighPassFilter(float cutoff, float sampling_frequency) :
HighPassFilter(sampling_frequency/(cutoff*2*PI)){}
/**
* @brief Applies the high pass filter
*/
float process(float value) {
if(isnan(last_filter_value_) || isnan(last_raw_value_)) {
last_filter_value_ = 0.0;
}
else {
last_filter_value_ =
kA0 * value
+ kA1 * last_raw_value_
+ kB1 * last_filter_value_;
}
last_raw_value_ = value;
return last_filter_value_;
}
/**
* @brief Resets the stored values
*/
void reset() {
last_raw_value_ = NAN;
last_filter_value_ = NAN;
}
};
/**
* @brief Low Pass Filter
*/
class LowPassFilter {
const float kX;
const float kA0;
const float kB1;
float last_value_;
public:
/**
* @brief Initialize the Low Pass Filter
* @param samples Number of samples until decay to 36.8 %
* @remark Sample number is an RC time-constant equivalent
*/
LowPassFilter(float samples) :
kX(exp(-1/samples)),
kA0(1-kX),
kB1(kX),
last_value_(NAN){}
/**
* @brief Initialize the Low Pass Filter
* @param cutoff Cutoff frequency
* @pram sampling_frequency Sampling frequency
*/
LowPassFilter(float cutoff, float sampling_frequency) :
LowPassFilter(sampling_frequency/(cutoff*2*PI)){}
/**
* @brief Applies the low pass filter
*/
float process(float value) {
if(isnan(last_value_)) {
last_value_ = value;
}
else {
last_value_ = kA0 * value + kB1 * last_value_;
}
return last_value_;
}
/**
* @brief Resets the stored values
*/
void reset() {
last_value_ = NAN;
}
};
/**
* @brief Differentiator
*/
class Differentiator {
const float kSamplingFrequency;
float last_value_;
public:
/**
* @brief Initializes the differentiator
*/
Differentiator(float sampling_frequency) :
kSamplingFrequency(sampling_frequency),
last_value_(NAN){}
/**
* @brief Applies the differentiator
*/
float process(float value) {
float diff = (value-last_value_)*kSamplingFrequency;
last_value_ = value;
return diff;
}
/**
* @brief Resets the stored values
*/
void reset() {
last_value_ = NAN;
}
};
/**
* @brief MovingAverageFilter
* @tparam buffer_size Number of samples to average over
*/
template<int kBufferSize> class MovingAverageFilter {
int index_;
int count_;
float values_[kBufferSize];
public:
/**
* @brief Initalize moving average filter
*/
MovingAverageFilter() :
index_(0),
count_(0){}
/**
* @brief Applies the moving average filter
*/
float process(float value) {
// Add value
values_[index_] = value;
// Increase index and count
index_ = (index_ + 1) % kBufferSize;
if(count_ < kBufferSize) {
count_++;
}
// Calculate sum
float sum = 0.0;
for(int i = 0; i < count_; i++) {
sum += values_[i];
}
// Calculate average
return sum/count_;
}
/**
* @brief Resets the stored values
*/
void reset() {
index_ = 0;
count_ = 0;
}
/**
* @brief Get number of samples
* @return Number of stored samples
*/
int count() const {
return count_;
}
};
#endif // FILTERS_H