element14 Community
element14 Community
    Register Log In
  • Site
  • Search
  • Log In Register
  • Community Hub
    Community Hub
    • What's New on element14
    • Feedback and Support
    • Benefits of Membership
    • Personal Blogs
    • Members Area
    • Achievement Levels
  • Learn
    Learn
    • Ask an Expert
    • eBooks
    • element14 presents
    • Learning Center
    • Tech Spotlight
    • STEM Academy
    • Webinars, Training and Events
    • Learning Groups
  • Technologies
    Technologies
    • 3D Printing
    • FPGA
    • Industrial Automation
    • Internet of Things
    • Power & Energy
    • Sensors
    • Technology Groups
  • Challenges & Projects
    Challenges & Projects
    • Design Challenges
    • element14 presents Projects
    • Project14
    • Arduino Projects
    • Raspberry Pi Projects
    • Project Groups
  • Products & Partners
    Products & Partners
    • Arduino
    • Avnet & Tria Boards Community
    • Dev Tools
    • Manufacturers
    • Multicomp Pro
    • Product Groups
    • Raspberry Pi
    • RoadTests & Reviews
  • About Us
    About the element14 Community
  • Store
    Store
    • Visit Your Store
    • Choose another store...
      • Europe
      •  Austria (German)
      •  Belgium (Dutch, French)
      •  Bulgaria (Bulgarian)
      •  Czech Republic (Czech)
      •  Denmark (Danish)
      •  Estonia (Estonian)
      •  Finland (Finnish)
      •  France (French)
      •  Germany (German)
      •  Hungary (Hungarian)
      •  Ireland
      •  Israel
      •  Italy (Italian)
      •  Latvia (Latvian)
      •  
      •  Lithuania (Lithuanian)
      •  Netherlands (Dutch)
      •  Norway (Norwegian)
      •  Poland (Polish)
      •  Portugal (Portuguese)
      •  Romania (Romanian)
      •  Russia (Russian)
      •  Slovakia (Slovak)
      •  Slovenia (Slovenian)
      •  Spain (Spanish)
      •  Sweden (Swedish)
      •  Switzerland(German, French)
      •  Turkey (Turkish)
      •  United Kingdom
      • Asia Pacific
      •  Australia
      •  China
      •  Hong Kong
      •  India
      •  Japan
      •  Korea (Korean)
      •  Malaysia
      •  New Zealand
      •  Philippines
      •  Singapore
      •  Taiwan
      •  Thailand (Thai)
      •  Vietnam
      • Americas
      •  Brazil (Portuguese)
      •  Canada
      •  Mexico (Spanish)
      •  United States
      Can't find the country/region you're looking for? Visit our export site or find a local distributor.
  • Translate
  • Profile
  • Settings
Smart Home & Healthcare Challenge
  • Challenges & Projects
  • Design Challenges
  • Smart Home & Healthcare Challenge
  • More
  • Cancel
Smart Home & Healthcare Challenge
Forum Palno & Slemon #3 Building the Algorithm
  • News
  • Projects
  • Forum
  • Details
  • Leaderboard
  • Files
  • Members
  • DC
  • More
  • Cancel
  • New
Join Smart Home & Healthcare Challenge to participate - click to join for free!
Actions
  • Share
  • More
  • Cancel
Forum Thread Details
  • Replies 1 reply
  • Subscribers 18 subscribers
  • Views 26 views
  • Users 0 members are here
  • maxrefdes117
  • analog devices
  • smart home & healthcare
Related

Palno & Slemon #3 Building the Algorithm

Qbit
Qbit 4 hours ago

Hi, I hope you are doing well, and I am really sorry for the delay  in posting this part, I was a little lost and busy with other work. But I will give my best finishing this project and making it reliable.

A Quick Recap

 I was working on project  that uses  data from multiple resources like breathing data from the radar module,  heart beat rate , blood oxygen level to detect  the stage of sleep you are in to help you wake up at the right time so you wouldn`t feel tired after you wake up, because wake you up at the right time.

These are the past posts:

Palno & Slemon #1 Introduction

Palno & Slemon #2.1 Configuring and Validating

Palno & Slemon #2.2 Configuring and Validating

Algorithm

We need to go there in two places:
  1. In the radar module
  2. In the heart rate and blood oxygen level sensor

First,  radar module. I have thought of using a model to detect breath but I wasn't successful as  it gave me terrible results, as I will show you soon. 

We already have the algorithm from the resources provided by the analog devices but I would like to explain how it works.
 
The Radar (HLK-LD2410)
image
(Primarily this is the one I am using.)
I would also like to share the part of the journey of making it, though I had not used it, finally, because it was really terrible.
Firstly I tried training the model but in the future I will see that it will become a terrible waste of time.
I first decided to collect data for that. As I am using esp32 for test purposes, I use its web capabilities to collect data and control pause and resume via the web interface. Do it a little bit.Then I collect a little bit of data, at least 93 samples: 46 for breathing data and 47 for normal data, like the background, when we hold the breath and train. We save it as a CSV, which is also timestamped.I also used Python with PySerial to communicate with the ESP32 and capture the data. From the web interface when the button switched on, it saves the data of the given time and stamps it with a time tag in milliseconds, assigns all the values, and saves it as CSV.
image
(This is the representation of a CSV dataset I made but it is not the same as what I used.)
There were many axes, like all the data the data module can give, so I don't show the graph here in the web interface because it will crash. It could crash and it is ready to. I had to use AI for this because I don't have a lot of time to finish it all up.
image
Screenshot of the web interface hosted on the ESP32
Sadly I was not able to use it because I was not able to capture a lot of sensible data. Though I have enough computing power on the Arduino Q for the project, the 100 samples (approximately) I collected are not enough to train that complex model, as I will show you later in the graph. This is because there were nearly a lot of delays and distortion during the collection of data. The sensor is good at its job collecting a lot of data and training a model with a lot of axes requires the equivalent amount of data.
image
I know this completely looks terrible and I really have wasted 4 to 5 days doing this.

Firstly I had got accuracy pretty well 50%, not that pretty well but better than 0%. I know, 50% was not that usable though. I thought after doing some more improvements I will really improve it but that wasn't the case. I even distorted the 50% and fell into 0%. Absolutely terrible.

This is how I lean towards the algorithm, with the stress that I had to it finish in 2 days.
3	30	100	30	0	100	100	34	10	9	10	2	11	8	100	100	80	41	41	28	1	1
3	53	100	30	0	100	100	100	11	9	2	5	8	5	100	100	80	42	42	29	1	1
3	66	100	53	0	100	100	100	19	10	8	11	9	13	100	100	82	44	45	31	1	1
3	72	100	66	0	100	100	100	33	8	10	11	8	13	100	100	85	48	48	34	1	1
3	73	100	72	31	99	100	53	19	13	9	8	9	7	100	100	89	52	51	38	1	1
3	75	100	73	34	36	100	31	2	10	12	8	9	8	100	100	92	55	54	41	1	1
3	67	79	75	36	35	79	14	3	5	8	10	9	7	100	100	95	58	56	44	1	1
3	71	55	67	38	19	55	11	6	3	6	9	8	8	100	100	98	61	58	47	1	1
3	41	94	71	37	90	94	9	10	1	3	8	5	7	100	100	99	64	61	49	1	1
3	72	100	41	40	42	100	18	9	3	8	6	5	9	100	100	100	67	63	52	1	1
3	62	100	72	40	100	100	18	7	3	10	4	3	10	100	100	100	70	65	54	1	1
3	66	100	62	41	57	100	16	9	2	9	4	2	9	100	100	100	73	68	57	1	1
3	62	62	66	41	37	62	12	9	3	6	4	2	8	100	100	100	75	70	59	1	1
3	63	100	62	41	79	100	21	4	4	4	5	3	4	100	100	100	78	72	61	1	1
This is the raw data I was getting from the sensors.
image
Visually
The ESP32 was communicating to the Radar using a serial connection at a 256,000 baud rate.
#define LD2410_BAUD 256000
#define RX_PIN 16
#define TX_PIN 17
#define MAX_GATES 9

// Frame parser state: header -> length -> payload -> footer
enum { ST_HDR, ST_LEN, ST_DATA, ST_FOOT } rxSt = ST_HDR;

void setup() {
  Serial.begin(115200);
  Serial2.begin(LD2410_BAUD, SERIAL_8N1, RX_PIN, TX_PIN);
  // Engineering mode is enabled later by sending 'n'
}

// Non-blocking: consumes whatever bytes are waiting on UART2
static void readRadar() {
  while (Serial2.available()) {
    uint8_t b = Serial2.read();
    switch (rxSt) {
      case ST_HDR:   /* slide a 4-byte window until F4 F3 F2 F1 (data)
                        or FD FC FB FA (command ACK) matches */      break;
      case ST_LEN:   /* read 2-byte little-endian payload length */   break;
      case ST_DATA:  /* collect 'length' payload bytes */             break;
      case ST_FOOT:  /* collect F8 F7 F6 F5, then parseData() */      break;
    }
  }
}

// Decode one engineering-mode frame (d points at the payload)
static void parseData() {
  const uint8_t* d = fb + 6;
  if (d[1] != 0xAA) return;                      // data marker

  g_state   = d[2];
  g_movDist = d[3] | (d[4] << 8);                // cm
  g_movEn   = d[5];
  g_staDist = d[6] | (d[7] << 8);                // cm
  g_detDist = d[9] | (d[10] << 8);               // cm

  if (d[0] == 0x01) {                            // engineering frame
    uint8_t idx = 13;
    for (uint8_t i = 0; i <= g_maxMov; i++) g_movG[i] = d[idx++];  // MG0..MG8
    for (uint8_t i = 0; i <= g_maxSta; i++) g_staG[i] = d[idx++];  // SG0..SG8
    g_light = d[idx++];
    g_out   = d[idx];
  }
  newData = true;
}

// One row per frame, 20 Hz
static void outputLine() {
  Serial.print(g_state);   Serial.print('\t');
  Serial.print(g_movDist); Serial.print('\t');
  Serial.print(g_movEn);   Serial.print('\t');
  Serial.print(g_staDist); Serial.print('\t');
  Serial.print(g_detDist); Serial.print('\t');
  for (uint8_t i = 0; i < MAX_GATES; i++) { Serial.print(g_movG[i]); Serial.print('\t'); }
  for (uint8_t i = 3; i < MAX_GATES; i++) { Serial.print(g_staG[i]); Serial.print('\t'); }
  Serial.print(g_light); Serial.print('\t');
  Serial.println(g_out);
}
This is the simplified version of the code I used for the ESP32 to send data over serial to the computer and also to take received data from the radar.
The radar streams binary frames continuously, so the firmware runs a small state machine that finds the frame header, reads the length, collects the payload and footer, and then decodes the fields. In engineering mode each frame carries the target state, moving and static distances, the energy of each of the nine range gates, and the light and OUT pin values. The sketch prints these as one tab-separated row at 20 Hz, which is the raw data above and the input to the CSV logger. The 22 columns are State, MovDist, MovEnergy, StaDist, DetDist, MG0–MG8, SG3–SG8, Light and OutPin.
The real algorithm
After the failure in creating the impulse, I had to work on the algorithm.
In order to create the algorithm first, I have to look for patterns while I'm breathing from the data. The best way a human can do that is visually. So I looked at the visual data.
image
I recorded the data. Keeping in mind that it detects breathing while sleeping, I made as minimalist a movement as possible to detect only the movement of breathing, along with the background noise (as it will be there while sleeping also). I captured it and after staring at it for a while, I noticed some patterns.
image
So as you can see, while breathing we also inhale and exhale and the duration while we do that at the peaks is not that much different. In between the zeroth values, while we are moving towards the next breathing process, there is a noticeable difference that we can use to detect the n number of breaths.
I noticed the pattern from the yellow but I also noticed that, from the green area, you are saying that I can also normalize the area and pick up the high points or crust of the area wave generated after processing to detect the number of breaths. It will be a little bit more sensible and more accurate also but it doesn't work that well. After some execution the difference algorithm only works better than this so I persisted with the difference algorithm.
And the code was very long because we have to manage the communication especially too and also while plotting it all and showing all the graphs. After implementation the code I developed was fairly long, I think really long. I won't be able to write it without using AI and I think if I do I will need a week or two.If you want to take a look at it, you can see it below.
"""
LD2410 Real-Time mmWave Monitor & Breath Counter (Algorithm 1)
=============================================================
Connects to ESP32 over serial, prompts the user to send 'n' to
start streaming, verifies incoming radar data, opens a fast real-time
graph window, and runs the breath counting algorithm (Algorithm 1):

Algorithm 1 Logic:
  1. Trigger / Arming: Waits until moving energy is >= 100 continuously
     for the first 3 seconds (TRIGGER_DURATION_SEC = 3.0s).
  2. Breath Counting: Once armed, counts breath pulses when non-zero peaks
     are separated by >= 25 consecutive zero values (energy <= baseline).
  3. Loop & Stop: Loops and increments breath count until moving energy
     reaches constant 100 for 3 seconds (STOP_DURATION_SEC = 3.0s).
     When constant 100 for 3.0s is met, the session concludes.
     If moving energy >= 100 for 3.0s occurs again, it re-arms and loops.

Requirements:
    pip install pyserial matplotlib numpy

Usage:
    python ld2410_plotter.py                      # auto-detect port
    python ld2410_plotter.py COM3                 # specific port
    python ld2410_plotter.py COM3 --auto-start    # send 'n' without prompt

Controls in Plot Window:
    [+] / [=] / [UP]  : Increase Breath Baseline noise floor live (+1)
    [-] / [_] / [DOWN]: Decrease Breath Baseline noise floor live (-1)
    [Mouse Drag]      : Drag the 'Baseline (Noise Floor)' slider at the bottom
    [b]               : Reset / Re-arm Breath Counter
    [n]               : Send 'n' (Start / Resume streaming)
    [x]               : Send 'x' (Pause streaming)
    [1] - [5]         : Set sensitivity presets (1=5/5 ... 5=75/75)
    [v]               : Read firmware version
    [p]               : Read radar parameters
    [q] or [ESC]      : Quit
"""

import sys
import os
import time
import argparse
import threading
import collections

import serial
import serial.tools.list_ports
import numpy as np
import matplotlib
import matplotlib.pyplot as plt
import matplotlib.animation as animation
from matplotlib.gridspec import GridSpec
from matplotlib.widgets import Slider

# ─────────────────────── USER CONFIGURATION (ALTER VARIABLES HERE) ────────────────────────
# You can freely alter any of these variables directly to adjust radar and breath detection behavior:

# ── 1. Breath Detection & Baseline Tuning ──
SAMPLE_BASELINE              = 15.0     # Breath detection noise floor / baseline (Moving Energy 0-100).
                                        # Values <= SAMPLE_BASELINE are treated as silence/pause (zeros);
                                        # Values > SAMPLE_BASELINE are detected as breathing motion peaks.
                                        # NOTE: You can also adjust this LIVE in the plot window via
                                        # the on-screen slider or [+] / [-] / [UP] / [DOWN] keys!

MIN_ZERO_GAP                 =  15.0
MIN_ZERO_GAP  = SAMPLE_BASELINE 
                                        # Minimum consecutive zero values required between peaks (default: 25).
                                        # Difference between two peaks is > 25 and all 25 or more values
                                        # in between are zero (<= SAMPLE_BASELINE).

# ── 2. Trigger & Stop Durations (3s at 100 Trigger & Exit Loop) ──
TRIGGER_ENERGY_REQ           = 100.0    # Energy required to arm (moving energy not less than 100).
TRIGGER_DURATION_SEC         = 3.0      # Moving energy must be >= 100 for first 3 seconds to start counting.
STOP_DURATION_SEC            = 3.0      # Loops until moving energy reaches constant 100 for 3 seconds to stop.

# ── 3. Radar & Display Window Settings ──
BAUD_RATE                    = 115200   # Serial communication baud rate (ESP32 defaults to 115200).
HISTORY                      = 200      # Rolling waveform buffer size (200 samples @ 20Hz = 10.0 seconds).
UPDATE_MS                    = 40       # Matplotlib GUI refresh interval in milliseconds (~25 FPS).
DARK_BG                      = True     # True: Dark mode (#111111); False: Light mode.
GATE_DIST_M                  = 0.75     # Radar gate resolution in metres (0.75m per gate).
MAX_GATES                    = 9        # Total number of distance gates (0 to 8).

# Column indices (0-based) for the 22 columns received from ESP32
COL_STATE    = 0
COL_MOVDIST  = 1
COL_MOVEN    = 2
COL_STADIST  = 3
COL_DETDIST  = 4
COL_MG_START = 5         # MG0..MG8  → indices 5..13
COL_SG_START = 14        # SG3..SG8  → indices 14..19
COL_LIGHT    = 20
COL_OUTPIN   = 21
TOTAL_COLS   = 22

# ─────────────────────── Serial auto-detect ───────────────────

def find_esp32_port():
    """Return the most likely ESP32 COM port, or None."""
    ESP_VIDS = {0x10C4, 0x1A86, 0x0403}   # CP2102, CH340, FTDI
    ports = serial.tools.list_ports.comports()
    for p in ports:
        if p.vid in ESP_VIDS:
            return p.device
    if ports:
        return ports[0].device
    return None

# ─────────────────────── Breath Counter (Algorithm 1) ───────────────────────

class BreathCounter:
    """
    Real-Time Breath Counter Algorithm (Algorithm 1):
    1. Arming / Trigger:
       - Requires Moving Energy >= 100 for the first 3.0 seconds to start counting.
    2. Peak & Zero-Crossing Detection:
       - Once armed, increments breath count when two non-zero peaks are separated
         by >= 25 consecutive zero values (moving energy <= SAMPLE_BASELINE).
    3. Loop & Session Stop:
       - Loops and continues counting until moving energy reaches constant 100
         for 3.0 continuous seconds to stop.
       - Re-arms if moving energy >= 100 for 3.0s occurs again.
    """
    STATE_WAITING = "WAITING_TRIGGER"  # Waiting for >= 100 for 3s
    STATE_ACTIVE  = "COUNTING"         # Active breath counting session
    STATE_STOPPED = "STOPPED"          # Stopped after constant 100 for 3s

    def __init__(self,
                 trigger_duration=TRIGGER_DURATION_SEC,
                 stop_duration=STOP_DURATION_SEC,
                 min_zero_gap=MIN_ZERO_GAP,
                 min_trigger_energy=TRIGGER_ENERGY_REQ,
                 peak_threshold=SAMPLE_BASELINE):
        self.trigger_duration = float(trigger_duration)
        self.stop_duration = float(stop_duration)
        self.min_zero_gap = int(min_zero_gap)
        self.min_trigger_energy = float(min_trigger_energy)
        self.peak_threshold = float(peak_threshold)

        self.state = self.STATE_WAITING
        self.breath_count = 0
        self.zero_count = 0
        self.in_breath = False
        self.has_peak = False

        # Timers & arming flags
        self.start_100_time = None
        self.stop_100_time = None
        self.waiting_to_clear_trigger = False

        # Progress tracking
        self.trigger_progress = 0.0
        self.stop_progress = 0.0

        # Diagnostics & BPM
        self.last_breath_time = None
        self.last_peak_val = 0.0
        self.last_zero_interval = 0
        self.bpm = 0.0

    def reset(self):
        """Reset counter back to waiting for trigger."""
        self.state = self.STATE_WAITING
        self.breath_count = 0
        self.zero_count = 0
        self.in_breath = False
        self.has_peak = False
        self.start_100_time = None
        self.stop_100_time = None
        self.waiting_to_clear_trigger = False
        self.trigger_progress = 0.0
        self.stop_progress = 0.0
        self.last_breath_time = None
        self.last_peak_val = 0.0
        self.last_zero_interval = 0
        self.bpm = 0.0
        print(f"\n[BREATH] Counter reset. Waiting for Moving Energy >= {self.min_trigger_energy:.0f} for {self.trigger_duration:.1f}s...\n")

    def process_sample(self, moving_energy: float, timestamp: float = None) -> bool:
        """
        Process incoming radar moving energy sample.
        Returns True if a new breath peak was detected on this sample, False otherwise.
        """
        if timestamp is None:
            timestamp = time.time()

        is_breath = False

        # ── State 1: WAITING_TRIGGER ──
        if self.state == self.STATE_WAITING:
            if moving_energy >= self.min_trigger_energy:
                if self.start_100_time is None:
                    self.start_100_time = timestamp
                elapsed = timestamp - self.start_100_time
                self.trigger_progress = min(1.0, elapsed / self.trigger_duration)
                if elapsed >= (self.trigger_duration - 1e-4):
                    self.state = self.STATE_ACTIVE
                    self.breath_count = 0
                    self.zero_count = 0
                    self.has_peak = True
                    self.waiting_to_clear_trigger = True
                    self.start_100_time = None
                    self.trigger_progress = 1.0
                    print(f"\n[BREATH] >>> TRIGGER MET! Moving Energy was >= {self.min_trigger_energy:.0f} for {self.trigger_duration:.1f}s. Breath counting ACTIVE! <<<\n")
            else:
                self.start_100_time = None
                self.trigger_progress = 0.0

        # ── State 2: COUNTING ──
        elif self.state == self.STATE_ACTIVE:
            # Check stop condition: constant 100 for stop_duration (3.0s)
            if moving_energy >= self.min_trigger_energy:
                if not self.waiting_to_clear_trigger:
                    if self.stop_100_time is None:
                        self.stop_100_time = timestamp
                    elapsed = timestamp - self.stop_100_time
                    self.stop_progress = min(1.0, elapsed / self.stop_duration)
                    if elapsed >= (self.stop_duration - 1e-4):
                        self.state = self.STATE_STOPPED
                        self.stop_progress = 1.0
                        print(f"\n[BREATH] >>> SESSION COMPLETED! Constant {self.min_trigger_energy:.0f} for {self.stop_duration:.1f}s reached. Final Breath Count: {self.breath_count} <<<\n")
                        return False
                # While energy is saturated at 100, do not count as a breath peak
                self.zero_count = 0
                return False
            else:
                self.waiting_to_clear_trigger = False
                self.stop_100_time = None
                self.stop_progress = 0.0

            # Peak & Zero-crossing detection based on baseline
            if moving_energy > self.peak_threshold:
                if not self.in_breath:
                    # Check if preceded by >= 25 consecutive zero values
                    if self.zero_count >= self.min_zero_gap:
                        self.breath_count += 1
                        self.in_breath = True
                        self.last_zero_interval = self.zero_count
                        self.last_peak_val = moving_energy

                        if self.last_breath_time is not None:
                            interval = timestamp - self.last_breath_time
                            if 1.0 <= interval <= 15.0:
                                self.bpm = 60.0 / interval

                        self.last_breath_time = timestamp
                        self.zero_count = 0
                        is_breath = True
                        bpm_str = f" ({self.bpm:.0f} BPM)" if self.bpm > 0 else ""
                        print(f"[BREATH] +1 Breath #{self.breath_count} detected!{bpm_str} (Zeros in between: {self.last_zero_interval}, Energy: {moving_energy:.0f})")
                    else:
                        # Continuing current breath or sub-pulse
                        self.zero_count = 0
                        self.last_peak_val = max(self.last_peak_val, moving_energy)
                else:
                    self.zero_count = 0
                    self.last_peak_val = max(self.last_peak_val, moving_energy)
            else:
                # Value is in the baseline noise floor (<= peak_threshold)
                self.zero_count += 1
                if self.zero_count >= self.min_zero_gap:
                    self.in_breath = False

        # ── State 3: STOPPED ──
        elif self.state == self.STATE_STOPPED:
            # Re-arm loop if moving energy >= 100 for 3s again
            if moving_energy < self.min_trigger_energy:
                self.start_100_time = None
                self.trigger_progress = 0.0
            else:
                if self.start_100_time is None:
                    self.start_100_time = timestamp
                elapsed = timestamp - self.start_100_time
                if elapsed >= (self.trigger_duration - 1e-4):
                    print(f"\n[BREATH] >>> RE-TRIGGER MET! Starting new breath counting session... <<<\n")
                    self.state = self.STATE_ACTIVE
                    self.breath_count = 0
                    self.zero_count = 0
                    self.has_peak = True
                    self.waiting_to_clear_trigger = True
                    self.start_100_time = None
                    self.stop_100_time = None

        return is_breath

    def get_status_summary(self):
        return {
            "state": self.state,
            "count": self.breath_count,
            "zero_count": self.zero_count,
            "last_interval": self.last_zero_interval,
            "trigger_progress": self.trigger_progress,
            "stop_progress": self.stop_progress,
            "bpm": self.bpm,
            "peak_threshold": self.peak_threshold
        }

# ─────────────────────── Data thread ──────────────────────────

class RadarReader(threading.Thread):
    """Reads serial lines in background and feeds data to thread-safe buffer."""

    def __init__(self, ser, breath_counter=None):
        super().__init__(daemon=True)
        self.ser            = ser
        self.buf            = collections.deque(maxlen=HISTORY)
        self.breath_flags   = collections.deque(maxlen=HISTORY)
        self.lock           = threading.Lock()
        self.status         = "Streaming"
        self.connected      = True
        self._stop          = False
        self.latest         = [0.0] * TOTAL_COLS
        self.packet_cnt     = 0
        self.breath_counter = breath_counter or BreathCounter()

    def stop(self):
        self._stop = True

    def send(self, cmd: str):
        """Send a single character command to ESP32."""
        if self.ser and self.ser.is_open:
            try:
                self.ser.write((cmd + '\n').encode('ascii'))
                self.ser.flush()
            except Exception as e:
                print(f"[SERIAL] Write error: {e}")

    def run(self):
        while not self._stop and self.ser and self.ser.is_open:
            try:
                raw = self.ser.readline()
                if not raw:
                    continue
                line = raw.decode('ascii', errors='ignore').strip()

                if not line or line.startswith('#'):
                    if line.startswith('#'):
                        # Display firmware / parameters feedback
                        if any(k in line for k in ("FW", "Params", "Sens", "MaxDist", "STREAMING")):
                            print(f"[ESP32] {line}")
                    continue

                # Data line starts with numeric State (0, 1, 2, 3)
                if not line[0].isdigit():
                    continue

                sep = ',' if ',' in line else '\t'
                parts = line.split(sep)
                if len(parts) != TOTAL_COLS:
                    continue

                row = [float(p) for p in parts]
                is_breath = self.breath_counter.process_sample(row[COL_MOVEN])

                with self.lock:
                    self.buf.append(row)
                    self.breath_flags.append(is_breath)
                    self.latest = row
                    self.packet_cnt += 1

            except serial.SerialException as e:
                self.status = f"Serial error: {e}"
                self.connected = False
                break
            except Exception as e:
                self.status = f"Error: {e}"

# ─────────────────────── Plot Setup ───────────────────────────

STATE_LABELS = {0: "No Target", 1: "Moving", 2: "Static", 3: "Both"}
STATE_COLORS = {0: '#555555', 1: '#00ccff', 2: '#ffaa00', 3: '#ff4444'}

if DARK_BG:
    plt.style.use('dark_background')

fig = plt.figure(figsize=(15, 9.5), facecolor='#111111' if DARK_BG else '#f5f5f5')
fig.canvas.manager.set_window_title('HLK-LD2410 Real-Time mmWave Monitor & Breath Counter')

gs = GridSpec(4, 2, figure=fig,
              left=0.06, right=0.97,
              top=0.92, bottom=0.075,
              hspace=0.52, wspace=0.30)

ax_dist   = fig.add_subplot(gs[0, :])    # distances — full width
ax_moven  = fig.add_subplot(gs[1, :])    # moving energy + target state + breath
ax_movg   = fig.add_subplot(gs[2, :])    # per-gate moving energies MG0–MG8
ax_stag   = fig.add_subplot(gs[3, 0])    # static gate energies SG3–SG8
ax_misc   = fig.add_subplot(gs[3, 1])    # Light + Output pin

def style_ax(ax, title, ylabel='', ylim=None):
    ax.set_title(title, fontsize=9.5, pad=3, fontweight='bold',
                 color='#dddddd' if DARK_BG else '#222222')
    ax.set_ylabel(ylabel, fontsize=8)
    ax.tick_params(labelsize=7.5)
    ax.grid(True, alpha=0.25, linestyle=':')
    if ylim: ax.set_ylim(ylim)

style_ax(ax_dist,  'Distance Tracking (cm)',            'cm',     (0, 700))
style_ax(ax_moven, 'Motion Energy (0-100) & Breath Detection (Algorithm 1)', 'Level',  (0, 105))
style_ax(ax_movg,  'Moving Gate Energies: Gate 0 to Gate 8 (0.75m steps)', 'Energy', (0, 105))
style_ax(ax_stag,  'Static Gate Energies: Gate 3 to Gate 8', 'Energy', (0, 105))
style_ax(ax_misc,  'Photosensitive Light & OUT Pin Status', 'Value')

x_data = list(range(HISTORY))

# ── Distance plot lines ──
line_movdist, = ax_dist.plot(x_data, [0]*HISTORY, color='#00d4ff', lw=1.5, label='Moving Target Dist')
line_stadist, = ax_dist.plot(x_data, [0]*HISTORY, color='#ffaa00', lw=1.5, label='Stationary Target Dist')
line_detdist, = ax_dist.plot(x_data, [0]*HISTORY, color='#44ff88', lw=1.2, ls='--', label='Detection Range')
ax_dist.legend(fontsize=7.5, loc='upper left', framealpha=0.35)

# ── Motion energy + state lines + breath detections + baseline ──
line_moven, = ax_moven.plot(x_data, [0]*HISTORY, color='#ff3388', lw=1.6, label='Moving Energy')
bar_state   = ax_moven.fill_between(x_data, 0, [0]*HISTORY, alpha=0.18, color='#00aaff', label='State Area')
line_baseline = ax_moven.axhline(
    y=SAMPLE_BASELINE, color='#ffee00', linestyle='--',
    linewidth=1.3, alpha=0.85, label=f'Baseline ({SAMPLE_BASELINE:.0f})', zorder=4
)
line_breaths, = ax_moven.plot([], [], 'o', color='#00ffff', markersize=7.5,
                             markeredgecolor='#ffffff', markeredgewidth=1.2,
                             label='Breath Peak Detected', zorder=5)
ax_moven.legend(fontsize=7.5, loc='upper left', framealpha=0.35)

# Breath detection HUD badge on ax_moven
breath_hud = ax_moven.text(
    0.985, 0.88, '[Breath] Initializing...',
    transform=ax_moven.transAxes, ha='right', va='top',
    fontsize=9.0, fontweight='bold',
    color='#ffaa00',
    bbox=dict(boxstyle='round,pad=0.38', facecolor='#151c28' if DARK_BG else '#ffffff',
              edgecolor='#ffaa00', alpha=0.9, lw=1.2)
)

# ── Moving gate energies MG0–MG8 ──
MOVG_COLORS = plt.cm.turbo(np.linspace(0.08, 0.92, MAX_GATES))
movg_lines  = []
for i in range(MAX_GATES):
    ln, = ax_movg.plot(x_data, [0]*HISTORY,
                       color=MOVG_COLORS[i], lw=1.2,
                       label=f'G{i} ({i*GATE_DIST_M:.2f}m)')
    movg_lines.append(ln)
ax_movg.legend(fontsize=6.5, loc='upper left', framealpha=0.35, ncol=5)

# ── Static gate energies SG3–SG8 ──
STAG_COLORS = plt.cm.cool(np.linspace(0.15, 0.95, 6))
stag_lines  = []
for j, i in enumerate(range(3, 9)):
    ln, = ax_stag.plot(x_data, [0]*HISTORY,
                       color=STAG_COLORS[j], lw=1.2,
                       label=f'SG{i} ({i*GATE_DIST_M:.2f}m)')
    stag_lines.append(ln)
ax_stag.legend(fontsize=6.5, loc='upper left', framealpha=0.35, ncol=3)

# ── Light & OutPin ──
line_light,  = ax_misc.plot(x_data, [0]*HISTORY, color='#ffee33', lw=1.3, label='Light Level')
line_outpin, = ax_misc.plot(x_data, [0]*HISTORY, color='#33ffaa', lw=1.6, ls='--', label='OUT Pin (Occupied=100)')
ax_misc.set_ylim(0, 260)
ax_misc.legend(fontsize=7.5, loc='upper left', framealpha=0.35)

# Inset horizontal snapshot bar chart for moving gates
bar_ax = ax_movg.inset_axes([0.91, 0.05, 0.08, 0.90])
bar_ax.set_xlim(0, 105)
bar_ax.set_ylim(-0.5, MAX_GATES - 0.5)
bar_ax.set_yticks(range(MAX_GATES))
bar_ax.set_yticklabels([f'G{i}' for i in range(MAX_GATES)], fontsize=6)
bar_ax.set_xticks([])
bar_ax.set_title('Live G0-8', fontsize=6.5, pad=2, color='#cccccc' if DARK_BG else '#333333')
bar_ax.invert_yaxis()
if DARK_BG: bar_ax.set_facecolor('#1c1c1c')
bars = bar_ax.barh(range(MAX_GATES), [0]*MAX_GATES, color=MOVG_COLORS, height=0.65)

# Top status header text
status_txt = fig.text(0.5, 0.965, 'Connecting…', ha='center', va='top',
                      fontsize=10, fontweight='bold',
                      color='#aaffaa' if DARK_BG else '#008800')

# Global background reader
reader = None

# Bottom interactive Slider for live Breath Baseline Noise Floor adjustment
slider_ax = fig.add_axes([0.22, 0.015, 0.56, 0.024], facecolor='#202020' if DARK_BG else '#e8e8e8')
baseline_slider = Slider(
    slider_ax, 'Breath Baseline (Noise Floor):',
    0.0, 70.0, valinit=SAMPLE_BASELINE, valstep=1.0,
    color='#00ffcc',
    initcolor='none',
    track_color='#141414' if DARK_BG else '#cccccc'
)
baseline_slider.label.set_color('#dddddd' if DARK_BG else '#222222')
baseline_slider.label.set_fontsize(8.5)
baseline_slider.label.set_fontweight('bold')
baseline_slider.valtext.set_color('#00ffcc')
baseline_slider.valtext.set_fontsize(8.5)
baseline_slider.valtext.set_fontweight('bold')

def on_baseline_slider(val):
    if reader and reader.breath_counter:
        reader.breath_counter.peak_threshold = float(val)
    line_baseline.set_ydata([val, val])
    line_baseline.set_label(f'Baseline ({val:.0f})')
    ax_moven.legend(fontsize=7.5, loc='upper left', framealpha=0.35)
    fig.canvas.draw_idle()

baseline_slider.on_changed(on_baseline_slider)

# ─────────────────────── Animation Update ─────────────────────

def update(_frame):
    global bar_state

    with reader.lock:
        data = list(reader.buf)
        b_flags = list(reader.breath_flags)
        latest = list(reader.latest)
        b_status = reader.breath_counter.get_status_summary()

    state  = int(latest[COL_STATE]) if latest else 0
    slabel = STATE_LABELS.get(state, 'Unknown')
    b_cnt  = b_status['count']
    b_st   = b_status['state']

    # Update top status text
    status_txt.set_text(
        f"State: {slabel}  │  "
        f"[Breath] Count: {b_cnt} [{b_st}]  │  "
        f"Moving: {latest[COL_MOVDIST]:.0f}cm (E:{latest[COL_MOVEN]:.0f})  │  "
        f"Static: {latest[COL_STADIST]:.0f}cm  │  "
        f"Detect: {latest[COL_DETDIST]:.0f}cm  │  "
        f"OUT: {'■ OCCUPIED' if latest[COL_OUTPIN] else '□ Empty'}"
    )
    status_txt.set_color(STATE_COLORS.get(state, '#aaaaaa') if reader.connected else '#ff4444')

    # Update breath HUD badge on ax_moven
    if b_st == BreathCounter.STATE_WAITING:
        pct = int(b_status['trigger_progress'] * 100)
        breath_hud.set_text(f"[Breath] WAITING TRIGGER (Moving Energy >= 100 for 3s): {pct}%\nBreaths: {b_cnt}")
        breath_hud.set_color('#ffaa00')
        breath_hud.get_bbox_patch().set_edgecolor('#ffaa00')
    elif b_st == BreathCounter.STATE_ACTIVE:
        stop_pct = int(b_status['stop_progress'] * 100)
        stop_hint = f" │ Stop E=100: {stop_pct}%" if stop_pct > 0 else ""
        bpm_str = f" │ Rate: {b_status['bpm']:.0f} BPM" if b_status.get('bpm', 0) > 0 else ""
        cur_base = b_status.get('peak_threshold', SAMPLE_BASELINE)
        breath_hud.set_text(
            f"[Breath] COUNT: {b_cnt}{bpm_str}  [ACTIVE]\n"
            f"Silence: {b_status['zero_count']}z (req >= 25z) │ Last Gap: {b_status['last_interval']}z │ Base: {cur_base:.0f}{stop_hint}"
        )
        breath_hud.set_color('#00ffcc')
        breath_hud.get_bbox_patch().set_edgecolor('#00ffcc')
    elif b_st == BreathCounter.STATE_STOPPED:
        breath_hud.set_text(
            f"[Breath] SESSION STOPPED (Constant 100 for 3s)\n"
            f"FINAL BREATH COUNT: {b_cnt}  │  Press [B] to Reset / Re-arm"
        )
        breath_hud.set_color('#ff5555')
        breath_hud.get_bbox_patch().set_edgecolor('#ff5555')

    if not data:
        return

    n = len(data)
    arr = np.array(data)
    pad = HISTORY - n

    def padded(col_idx):
        return [0.0]*pad + arr[:, col_idx].tolist()

    # 1. Distances with adaptive Y-axis
    d_mov = padded(COL_MOVDIST)
    d_sta = padded(COL_STADIST)
    d_det = padded(COL_DETDIST)
    line_movdist.set_ydata(d_mov)
    line_stadist.set_ydata(d_sta)
    line_detdist.set_ydata(d_det)

    max_dist = max(arr[:, [COL_MOVDIST, COL_STADIST, COL_DETDIST]].max(), 50)
    ax_dist.set_ylim(0, max(250, max_dist * 1.15))

    # 2. Motion Energy & State fill & Breath markers
    mov_energies = padded(COL_MOVEN)
    line_moven.set_ydata(mov_energies)
    bar_state.remove()
    state_scaled = [v * 25 for v in padded(COL_STATE)]
    bar_state = ax_moven.fill_between(x_data, 0, state_scaled, alpha=0.18, color='#00aaff')

    padded_flags = [False] * pad + b_flags
    b_indices = [idx for idx, flg in enumerate(padded_flags) if flg]
    b_vals = [mov_energies[idx] for idx in b_indices]
    line_breaths.set_data(b_indices, b_vals)

    # 3. Moving Gate Energies MG0–MG8
    for i, ln in enumerate(movg_lines):
        ln.set_ydata(padded(COL_MG_START + i))

    if n > 0:
        snap = arr[-1, COL_MG_START:COL_MG_START+MAX_GATES]
        for bar, val in zip(bars, snap):
            bar.set_width(val)

    # 4. Static Gate Energies SG3–SG8
    for j, ln in enumerate(stag_lines):
        ln.set_ydata(padded(COL_SG_START + j))

    # 5. Light & OUT Pin
    line_light.set_ydata(padded(COL_LIGHT))
    outpin_scaled = [v * 100 for v in padded(COL_OUTPIN)]
    line_outpin.set_ydata(outpin_scaled)

# ─────────────────────── Keyboard Handler ─────────────────────

def on_key(event):
    global reader
    if not event.key:
        return

    k = event.key

    # Live baseline threshold adjustment
    if k in ('+', '=', 'up'):
        new_val = min(70.0, baseline_slider.val + 1.0)
        baseline_slider.set_val(new_val)
        print(f"[BASELINE] Increased to {new_val:.0f}")
        return
    elif k in ('-', '_', 'down'):
        new_val = max(0.0, baseline_slider.val - 1.0)
        baseline_slider.set_val(new_val)
        print(f"[BASELINE] Decreased to {new_val:.0f}")
        return

    # Reset / Re-arm breath counter
    if k in ('b', 'B'):
        reader.breath_counter.reset()
        with reader.lock:
            reader.breath_flags.clear()
            reader.breath_flags.extend([False] * len(reader.buf))
        return

    # Relay commands directly to ESP32
    if k in ('n', 'x', '1', '2', '3', '4', '5', 'v', 'p', 'd', 'H'):
        reader.send(k)
        print(f"[CMD] Sent '{k}' to ESP32")
    elif k in ('q', 'escape'):
        print("[EXIT] Closing window...")
        plt.close(fig)

fig.canvas.mpl_connect('key_press_event', on_key)

# ─────────────────────── Main Entry ───────────────────────────

def main():
    global reader

    parser = argparse.ArgumentParser(description='HLK-LD2410 Live Plotter & Breath Counter (Algorithm 1)')
    parser.add_argument('port', nargs='?', default=None,
                        help='Serial port (e.g. COM3 or /dev/ttyUSB0)')
    parser.add_argument('--baud', type=int, default=BAUD_RATE,
                        help=f'Baud rate (default {BAUD_RATE})')
    parser.add_argument('--auto-start', action='store_true',
                        help='Send "n" without waiting for manual console Enter')
    parser.add_argument('--trigger-sec', type=float, default=TRIGGER_DURATION_SEC,
                        help=f'Seconds of moving energy >= 100 to start breath counting (default: {TRIGGER_DURATION_SEC})')
    parser.add_argument('--stop-sec', type=float, default=STOP_DURATION_SEC,
                        help=f'Seconds of constant 100 moving energy to stop breath counting (default: {STOP_DURATION_SEC})')
    parser.add_argument('--min-zeros', type=int, default=MIN_ZERO_GAP,
                        help=f'Minimum consecutive zero values between peaks (default: {MIN_ZERO_GAP})')
    parser.add_argument('--peak-thresh', type=float, default=SAMPLE_BASELINE,
                        help=f'Minimum moving energy noise floor / baseline (default: {SAMPLE_BASELINE})')
    args = parser.parse_args()

    port = args.port or find_esp32_port()
    if not port:
        print("ERROR: No serial port detected. Connect the ESP32 or specify port (e.g. COM3).")
        print("Available ports:", [p.device for p in serial.tools.list_ports.comports()])
        sys.exit(1)

    print("\n" + "=" * 65)
    print("   HLK-LD2410 mmWave Radar Plotter & Breath Counter (Alg 1)")
    print("=" * 65)
    print(f"Connecting to {port} @ {args.baud} baud...")

    try:
        ser = serial.Serial(port, args.baud, timeout=0.5)
    except Exception as e:
        print(f"Failed to open port {port}: {e}")
        sys.exit(1)

    # Discard any stale bootloader garbage in serial buffer
    time.sleep(1.0)
    ser.reset_input_buffer()

    # Read and print any initial startup messages from ESP32
    deadline = time.time() + 1.5
    print("\n[ESP32 Startup Messages]:")
    while time.time() < deadline:
        if ser.in_waiting:
            line = ser.readline().decode('ascii', errors='ignore').strip()
            if line:
                print(f"  {line}")

    # Prompt user in console to send 'n'
    if not args.auto_start:
        print("\n" + "-" * 65)
        print(" [READY] The ESP32 is waiting for the start command 'n'.")
        print(" Press [ENTER] or type 'n' to begin streaming radar data:")
        print("-" * 65)
        try:
            input(">>> ")
        except (KeyboardInterrupt, EOFError):
            ser.close()
            sys.exit(0)

    # Send 'n' to start streaming
    print("\nSending 'n' command to ESP32...")
    ser.write(b'n\n')
    ser.flush()

    # Verify that data has actually started flowing
    print("Verifying data stream from ESP32...")
    data_confirmed = False
    verify_deadline = time.time() + 5.0
    while time.time() < verify_deadline:
        if ser.in_waiting:
            line = ser.readline().decode('ascii', errors='ignore').strip()
            if line:
                if line.startswith('#'):
                    print(f"  [ESP32] {line}")
                elif line[0].isdigit():
                    parts = line.split('\t') if '\t' in line else line.split(',')
                    if len(parts) == TOTAL_COLS:
                        data_confirmed = True
                        print(f"  [OK] Valid data frame received ({TOTAL_COLS} columns)!")
                        break

    if not data_confirmed:
        print("\n[WARNING] Did not receive valid data frame yet, but launching plot window anyway.")
        print("If graph is empty, press 'n' inside the plot window to resend start command.")
    else:
        print("\n[SUCCESS] Radar streaming active! Launching live graph window...\n")

    print("Keyboard shortcuts in Plot Window:")
    print("  [+] / [UP]        : Increase Breath Baseline Noise Floor (+1)")
    print("  [-] / [DOWN]      : Decrease Breath Baseline Noise Floor (-1)")
    print("  [Slider]          : Drag 'Baseline' slider at bottom of window")
    print("  [b]               : Reset / re-arm Breath Counter")
    print("  [n]               : Start / Resume streaming")
    print("  [x]               : Pause streaming")
    print("  [1] - [5]         : Set sensitivity presets (1=5/5 ... 5=75/75)")
    print("  [v]               : Read firmware version")
    print("  [p]               : Read radar parameters")
    print("  [q] or [ESC]      : Quit\n")

    # Sync baseline slider with user args
    if baseline_slider.val != args.peak_thresh:
        baseline_slider.set_val(args.peak_thresh)

    # Initialize BreathCounter (Algorithm 1) and background reader
    breath_counter = BreathCounter(
        trigger_duration=args.trigger_sec,
        stop_duration=args.stop_sec,
        min_zero_gap=args.min_zeros,
        peak_threshold=args.peak_thresh
    )
    reader = RadarReader(ser, breath_counter=breath_counter)
    reader.start()

    # Start matplotlib animation
    ani = animation.FuncAnimation(
        fig, update,
        interval=UPDATE_MS,
        blit=False,
        cache_frame_data=False
    )

    try:
        plt.show()
    finally:
        reader.stop()
        if ser.is_open:
            ser.close()

if __name__ == '__main__':
    main()
As it is fairly long it won't be feasible to explain it all so I will be explaining it in short. This is in Python and it's also good in that, on the Linux side, I can use it with some modification.

The clock

The ESP32 prints one row every 50 ms, so the sample rate is 20 Hz and each sample is Δt = 0.05 s. The Python side works in samples, so converting between samples and seconds is the maths you’ll use most:

time = samples × 0.05 s        samples = time × 20

Step 1: the start signal (arming)

The counter sits in WAITING until E ≥ 100 continuously for 3 s. That’s 3 × 20 = 60 consecutive samples at 100. In practice you wave your hand in front of the sensor, which saturates the energy. If E drops below 100 even once, the timer resets. When the 3 s is met, it enters COUNTING with count = 0. The idea is that you tell the system “I’m now lying still, start counting” without touching the PC.

One detail: right after arming, waiting_to_clear_trigger stays true until E drops below 100. Without it, your starting wave would immediately look like a stop signal.

Step 2: turning energy into silence and motion

This is the core of it. Pick a baseline B (default 15, adjustable with the slider). Every sample is classified as:

E[k] > B   → motion
E[k] ≤ B   → silence

A running counter z tracks how many silent samples in a row you’ve seen. Any motion sample resets it to 0, and any silent sample adds 1.

Step 3: counting a breath

A new breath is counted when two things are true at once:

E[k] > B      (motion just appeared)
z ≥ G         (it was preceded by at least G silent samples)

G is MIN_ZERO_GAP. After a count, an in_breath flag stays on until a long enough silence passes, so one breath that wobbles up and down doesn’t count twice. In words: a breath is a burst of motion that comes after a proper pause.

Step 4: the rate

When a breath is counted, the code measures the time since the previous one, Δt_b, and ignores it unless 1 s ≤ Δt_b ≤ 15 s:

BPM = 60 / Δt_b

For example, a breath every 4 s gives 60/4 = 15 BPM, which is a normal resting rate. A period of 4 s is 80 samples, so a normal breath is roughly 80 samples long and the silent gap is a fraction of that.

Step 5: the stop signal

Wave again until E ≥ 100 for 3 s and the state flips to STOPPED, freezing the final count. If you hold 100 for 3 s again, it re-arms and starts a fresh session. While E ≥ 100 inside a session, samples are not counted as breaths, so the stop wave isn’t counted as breathing.

 The BPM and SPO2 Sensor ( MAXREFDES117)

image

It is fairly tiny compared to how it looks here.

The process of using this was fairly easy as they provide all the resources and the design files. I just needed to alter some specifications, like the pins for the I2C communication, according to my need and I am also testing this on the ESP32. For simplicity I had removed the other part of the original code so it includes some kind of adafruit, things that I don't need, so I had removed. Also the way it works is quite simple. Firstly there are two LEDs: one is IR and one is red. They emit light and there is also a sensor to detect them. The light that gets reflected back from the object, or your finger, or the skin, by that reflection, we measure the SpO2 and the BPM.

This is the sketch I used with the algorithm. I had just modified it a little bit from the original code I got from the design files.

#include <Arduino.h>
#include "algorithm.h"
#include "max30102.h"
#include <Wire.h>

#define INT_PIN 4
#define LED_PIN 2

uint32_t aun_ir_buffer[100]; //infrared LED sensor data
uint32_t aun_red_buffer[100];  //red LED sensor data
int32_t n_ir_buffer_length; //data length
int32_t n_spo2;  //SPO2 value
int8_t ch_spo2_valid;  //indicator to show if the SPO2 calculation is valid
int32_t n_heart_rate; //heart rate value
int8_t  ch_hr_valid;  //indicator to show if the heart rate calculation is valid
uint8_t uch_dummy;


// the setup routine runs once when you press reset:
void setup() {
  Wire.begin();
  maxim_max30102_reset(); //resets the MAX30102
  // initialize serial communication at 115200 bits per second:
  Serial.begin(115200);
  pinMode(INT_PIN, INPUT);  //pin connects to the interrupt output pin of the MAX30102
  pinMode(LED_PIN, OUTPUT);
  delay(1000);
  maxim_max30102_read_reg(REG_INTR_STATUS_1,&uch_dummy);  //Reads/clears the interrupt status register
  while(Serial.available()==0)  //wait until user presses a key
  {
    Serial.write(27);       // ESC command
    Serial.print(F("[2J"));    // clear screen command
    Serial.println(F("ESP32"));
    Serial.println(F("Press any key to start conversion"));
    delay(1000);
  }
  uch_dummy=Serial.read();
  maxim_max30102_init();  //initialize the MAX30102
}

// the loop routine runs over and over again forever:
void loop() {
  int32_t i;
  
  n_ir_buffer_length=100;  //buffer length of 100 stores 4 seconds of samples running at 25sps

  //read the first 100 samples, and determine the signal range
  for(i=0;i<n_ir_buffer_length;i++)
  {
    while(digitalRead(INT_PIN)==1);  //wait until the interrupt pin asserts
    maxim_max30102_read_fifo((aun_red_buffer+i), (aun_ir_buffer+i));  //read from MAX30102 FIFO
    
    Serial.print(F("red="));
    Serial.print(aun_red_buffer[i], DEC);
    Serial.print(F(", ir="));
    Serial.println(aun_ir_buffer[i], DEC);
  }
  //calculate heart rate and SpO2 after first 100 samples (first 4 seconds of samples)
  maxim_heart_rate_and_oxygen_saturation(aun_ir_buffer, n_ir_buffer_length, aun_red_buffer, &n_spo2, &ch_spo2_valid, &n_heart_rate, &ch_hr_valid); 

  //Continuously taking samples from MAX30102.  Heart rate and SpO2 are calculated every 1 second
  while(1)
  {
    i=0;

    //dumping the first 25 sets of samples in the memory and shift the last 75 sets of samples to the top
    for(i=25;i<100;i++)
    {
      aun_red_buffer[i-25]=aun_red_buffer[i];
      aun_ir_buffer[i-25]=aun_ir_buffer[i];
    }

    //take 25 sets of samples before calculating the heart rate.
    for(i=75;i<100;i++)
    {
      while(digitalRead(INT_PIN)==1);
      digitalWrite(LED_PIN, !digitalRead(LED_PIN));
      maxim_max30102_read_fifo((aun_red_buffer+i), (aun_ir_buffer+i));

      //send samples and calculation result to terminal program through UART
      Serial.print(F("red="));
      Serial.print(aun_red_buffer[i], DEC);
      Serial.print(F(", ir="));
      Serial.print(aun_ir_buffer[i], DEC);
      
      Serial.print(F(", HR="));
      Serial.print(n_heart_rate, DEC);
      
      Serial.print(F(", HRvalid="));
      Serial.print(ch_hr_valid, DEC);
      
      Serial.print(F(", SPO2="));
      Serial.print(n_spo2, DEC);

      Serial.print(F(", SPO2Valid="));
      Serial.println(ch_spo2_valid, DEC);
    }
    maxim_heart_rate_and_oxygen_saturation(aun_ir_buffer, n_ir_buffer_length, aun_red_buffer, &n_spo2, &ch_spo2_valid, &n_heart_rate, &ch_hr_valid); 
  }
}

Think of this sketch as a sliding 4-second window that the Maxim algorithm re-analyses every second. The sensor shines red and infrared light into your finger, and the sketch looks at how much light comes back. Each heartbeat pushes a little blood into the finger, which absorbs a little more light, so the signal pulses with your pulse. The code collects that wiggle, and the library function maxim_heart_rate_and_oxygen_saturation() turns it into HR and SpO2. (You called it DS117; the board is the MAXREFDES117#, which uses the MAX30102.)

The clock

The sensor is set up in maxim_max30102_init() to run at 100 samples/s with 4-sample averaging, which gives an effective rate of 25 samples/s. So:

Δt = 1/25 = 0.04 s per sample
buffer = 100 samples = 4 s
slide  = 25 samples  = 1 s

The while(digitalRead(INT_PIN)==1); line waits for the sensor’s interrupt pin (active low) to say “a new sample is ready”, then maxim_max30102_read_fifo() reads one red and one IR value.

The sliding window

First, 100 samples are collected, which is the 4 s warm-up with no valid reading. After that the loop repeats forever:

  1. Shift the last 75 samples to the front, dropping the oldest 25.
  2. Read 25 fresh samples (1 s) into the end.
  3. Recompute HR and SpO2 on all 100.

Consecutive windows overlap by 75%, so the answer updates every second but is always based on the last 4 s. That is why the numbers feel stable but lag a little.

How the library finds heart rate

Inside the function, the IR signal goes through four steps.

  1. Remove the DC part and flip the sign, so the signal is centred on zero and the pulses become peaks:
   x[k] = -(IR[k] - mean(IR))
  1. Smooth it with a 4-point moving average, so sensor noise doesn’t create fake peaks.
  2. Find peaks that are above a threshold (the signal mean, clamped to a range of about 30–60) and at least 4 samples apart.
  3. Average the gaps between peaks:
   T = (sum of peak gaps) / (number of peaks − 1)    [in samples]
   HR = 25 × 60 / T = 1500 / T

For example, if peaks are on average 20 samples apart, that is 0.8 s per beat, so HR = 1500/20 = 75 bpm. With fewer than two peaks it can’t compute a gap and sets HRvalid = 0.

How it finds SpO2

Oxygenated and deoxygenated blood absorb red and IR light differently. For each beat, the function measures the pulsing part (AC) and the steady part (DC) of both channels and forms the “ratio of ratios”:

R = (AC_red / DC_red) / (AC_ir / DC_ir)

It takes the median R across the beats in the window and maps it to a percentage with Maxim’s empirical calibration curve, roughly:

SpO2 ≈ −45.06·R² + 30.354·R + 94.845

So R ≈ 0.5 gives about 99%, and R ≈ 1 gives about 80%. If R falls outside the plausible range, SpO2Valid = 0. That flag matters for your project: never feed a reading into the sleep-stage logic unless its valid flag is 1.

What gets printed

Every sample prints red, ir, HR, HRvalid, SPO2 and SPO2Valid. Because HR and SpO2 are only recomputed once per second, the same values repeat on 25 consecutive lines, and then they jump. The LED on pin 2 toggles per sample as a “loop is alive” blink.

 

The Algorithm

It was going to be the most difficult part but it was fairly easy for me, as the developers of this have already given that in the design files. It was going to be the most difficult because we are just getting the IR and red LED readings for the detection of the emitted and reflected-back light. We have just those and we have detected the SpO2 and the BPM from this. It was going to be difficult but thankfully it doesn't.

#include "algorithm.h"
#include "arduino.h"

#if defined(ARDUINO_AVR_UNO)
//Arduino Uno doesn't have enough SRAM to store 100 samples of IR led data and red led data in 32-bit format
//To solve this problem, 16-bit MSB of the sampled data will be truncated.  Samples become 16-bit data.
void maxim_heart_rate_and_oxygen_saturation(uint16_t *pun_ir_buffer, int32_t n_ir_buffer_length, uint16_t *pun_red_buffer, int32_t *pn_spo2, int8_t *pch_spo2_valid, 
                int32_t *pn_heart_rate, int8_t *pch_hr_valid)
#else
void maxim_heart_rate_and_oxygen_saturation(uint32_t *pun_ir_buffer, int32_t n_ir_buffer_length, uint32_t *pun_red_buffer, int32_t *pn_spo2, int8_t *pch_spo2_valid, 
                int32_t *pn_heart_rate, int8_t *pch_hr_valid)
#endif
/**
* \brief        Calculate the heart rate and SpO2 level
* \par          Details
*               By detecting  peaks of PPG cycle and corresponding AC/DC of red/infra-red signal, the an_ratio for the SPO2 is computed.
*               Since this algorithm is aiming for Arm M0/M3. formaula for SPO2 did not achieve the accuracy due to register overflow.
*               Thus, accurate SPO2 is precalculated and save longo uch_spo2_table[] per each an_ratio.
*
* \param[in]    *pun_ir_buffer           - IR sensor data buffer
* \param[in]    n_ir_buffer_length      - IR sensor data buffer length
* \param[in]    *pun_red_buffer          - Red sensor data buffer
* \param[out]    *pn_spo2                - Calculated SpO2 value
* \param[out]    *pch_spo2_valid         - 1 if the calculated SpO2 value is valid
* \param[out]    *pn_heart_rate          - Calculated heart rate value
* \param[out]    *pch_hr_valid           - 1 if the calculated heart rate value is valid
*
* \retval       None
*/
{
  uint32_t un_ir_mean,un_only_once ;
  int32_t k, n_i_ratio_count;
  int32_t i, s, m, n_exact_ir_valley_locs_count, n_middle_idx;
  int32_t n_th1, n_npks, n_c_min;   
  int32_t an_ir_valley_locs[15] ;
  int32_t n_peak_interval_sum;
  
  int32_t n_y_ac, n_x_ac;
  int32_t n_spo2_calc; 
  int32_t n_y_dc_max, n_x_dc_max; 
  int32_t n_y_dc_max_idx, n_x_dc_max_idx; 
  int32_t an_ratio[5], n_ratio_average; 
  int32_t n_nume, n_denom ;

  // calculates DC mean and subtract DC from ir
  un_ir_mean =0; 
  for (k=0 ; k<n_ir_buffer_length ; k++ ) un_ir_mean += pun_ir_buffer[k] ;
  un_ir_mean =un_ir_mean/n_ir_buffer_length ;
    
  // remove DC and invert signal so that we can use peak detector as valley detector
  for (k=0 ; k<n_ir_buffer_length ; k++ )  
    an_x[k] = -1*(pun_ir_buffer[k] - un_ir_mean) ; 
    
  // 4 pt Moving Average
  for(k=0; k< BUFFER_SIZE-MA4_SIZE; k++){
    an_x[k]=( an_x[k]+an_x[k+1]+ an_x[k+2]+ an_x[k+3])/(int)4;        
  }
  // calculate threshold  
  n_th1=0; 
  for ( k=0 ; k<BUFFER_SIZE ;k++){
    n_th1 +=  an_x[k];
  }
  n_th1=  n_th1/ ( BUFFER_SIZE);
  if( n_th1<30) n_th1=30; // min allowed
  if( n_th1>60) n_th1=60; // max allowed

  for ( k=0 ; k<15;k++) an_ir_valley_locs[k]=0;
  // since we flipped signal, we use peak detector as valley detector
  maxim_find_peaks( an_ir_valley_locs, &n_npks, an_x, BUFFER_SIZE, n_th1, 4, 15 );//peak_height, peak_distance, max_num_peaks 
  n_peak_interval_sum =0;
  if (n_npks>=2){
    for (k=1; k<n_npks; k++) n_peak_interval_sum += (an_ir_valley_locs[k] -an_ir_valley_locs[k -1] ) ;
    n_peak_interval_sum =n_peak_interval_sum/(n_npks-1);
    *pn_heart_rate =(int32_t)( (FS*60)/ n_peak_interval_sum );
    *pch_hr_valid  = 1;
  }
  else  { 
    *pn_heart_rate = -999; // unable to calculate because # of peaks are too small
    *pch_hr_valid  = 0;
  }

  //  load raw value again for SPO2 calculation : RED(=y) and IR(=X)
  for (k=0 ; k<n_ir_buffer_length ; k++ )  {
      an_x[k] =  pun_ir_buffer[k] ; 
      an_y[k] =  pun_red_buffer[k] ; 
  }

  // find precise min near an_ir_valley_locs
  n_exact_ir_valley_locs_count =n_npks; 
  
  //using exact_ir_valley_locs , find ir-red DC andir-red AC for SPO2 calibration an_ratio
  //finding AC/DC maximum of raw

  n_ratio_average =0; 
  n_i_ratio_count = 0; 
  for(k=0; k< 5; k++) an_ratio[k]=0;
  for (k=0; k< n_exact_ir_valley_locs_count; k++){
    if (an_ir_valley_locs[k] > BUFFER_SIZE ){
      *pn_spo2 =  -999 ; // do not use SPO2 since valley loc is out of range
      *pch_spo2_valid  = 0; 
      return;
    }
  }
  // find max between two valley locations 
  // and use an_ratio betwen AC compoent of Ir & Red and DC compoent of Ir & Red for SPO2 
  for (k=0; k< n_exact_ir_valley_locs_count-1; k++){
    n_y_dc_max= -16777216 ; 
    n_x_dc_max= -16777216; 
    if (an_ir_valley_locs[k+1]-an_ir_valley_locs[k] >3){
        for (i=an_ir_valley_locs[k]; i< an_ir_valley_locs[k+1]; i++){
          if (an_x[i]> n_x_dc_max) {n_x_dc_max =an_x[i]; n_x_dc_max_idx=i;}
          if (an_y[i]> n_y_dc_max) {n_y_dc_max =an_y[i]; n_y_dc_max_idx=i;}
      }
      n_y_ac= (an_y[an_ir_valley_locs[k+1]] - an_y[an_ir_valley_locs[k] ] )*(n_y_dc_max_idx -an_ir_valley_locs[k]); //red
      n_y_ac=  an_y[an_ir_valley_locs[k]] + n_y_ac/ (an_ir_valley_locs[k+1] - an_ir_valley_locs[k])  ; 
      n_y_ac=  an_y[n_y_dc_max_idx] - n_y_ac;    // subracting linear DC compoenents from raw 
      n_x_ac= (an_x[an_ir_valley_locs[k+1]] - an_x[an_ir_valley_locs[k] ] )*(n_x_dc_max_idx -an_ir_valley_locs[k]); // ir
      n_x_ac=  an_x[an_ir_valley_locs[k]] + n_x_ac/ (an_ir_valley_locs[k+1] - an_ir_valley_locs[k]); 
      n_x_ac=  an_x[n_y_dc_max_idx] - n_x_ac;      // subracting linear DC compoenents from raw 
      n_nume=( n_y_ac *n_x_dc_max)>>7 ; //prepare X100 to preserve floating value
      n_denom= ( n_x_ac *n_y_dc_max)>>7;
      if (n_denom>0  && n_i_ratio_count <5 &&  n_nume != 0)
      {   
        an_ratio[n_i_ratio_count]= (n_nume*100)/n_denom ; //formular is ( n_y_ac *n_x_dc_max) / ( n_x_ac *n_y_dc_max) ;
        n_i_ratio_count++;
      }
    }
  }
  // choose median value since PPG signal may varies from beat to beat
  maxim_sort_ascend(an_ratio, n_i_ratio_count);
  n_middle_idx= n_i_ratio_count/2;

  if (n_middle_idx >1)
    n_ratio_average =( an_ratio[n_middle_idx-1] +an_ratio[n_middle_idx])/2; // use median
  else
    n_ratio_average = an_ratio[n_middle_idx ];

  if( n_ratio_average>2 && n_ratio_average <184){
    n_spo2_calc= uch_spo2_table[n_ratio_average] ;
    *pn_spo2 = n_spo2_calc ;
    *pch_spo2_valid  = 1;//  float_SPO2 =  -45.060*n_ratio_average* n_ratio_average/10000 + 30.354 *n_ratio_average/100 + 94.845 ;  // for comparison with table
  }
  else{
    *pn_spo2 =  -999 ; // do not use SPO2 since signal an_ratio is out of range
    *pch_spo2_valid  = 0; 
  }
}


void maxim_find_peaks( int32_t *pn_locs, int32_t *n_npks,  int32_t  *pn_x, int32_t n_size, int32_t n_min_height, int32_t n_min_distance, int32_t n_max_num )
/**
* \brief        Find peaks
* \par          Details
*               Find at most MAX_NUM peaks above MIN_HEIGHT separated by at least MIN_DISTANCE
*
* \retval       None
*/
{
  maxim_peaks_above_min_height( pn_locs, n_npks, pn_x, n_size, n_min_height );
  maxim_remove_close_peaks( pn_locs, n_npks, pn_x, n_min_distance );
  *n_npks = min( *n_npks, n_max_num );
}

void maxim_peaks_above_min_height( int32_t *pn_locs, int32_t *n_npks,  int32_t  *pn_x, int32_t n_size, int32_t n_min_height )
/**
* \brief        Find peaks above n_min_height
* \par          Details
*               Find all peaks above MIN_HEIGHT
*
* \retval       None
*/
{
  int32_t i = 1, n_width;
  *n_npks = 0;
  
  while (i < n_size-1){
    if (pn_x[i] > n_min_height && pn_x[i] > pn_x[i-1]){      // find left edge of potential peaks
      n_width = 1;
      while (i+n_width < n_size && pn_x[i] == pn_x[i+n_width])  // find flat peaks
        n_width++;
      if (pn_x[i] > pn_x[i+n_width] && (*n_npks) < 15 ){      // find right edge of peaks
        pn_locs[(*n_npks)++] = i;    
        // for flat peaks, peak location is left edge
        i += n_width+1;
      }
      else
        i += n_width;
    }
    else
      i++;
  }
}

void maxim_remove_close_peaks(int32_t *pn_locs, int32_t *pn_npks, int32_t *pn_x, int32_t n_min_distance)
/**
* \brief        Remove peaks
* \par          Details
*               Remove peaks separated by less than MIN_DISTANCE
*
* \retval       None
*/
{
    
  int32_t i, j, n_old_npks, n_dist;
    
  /* Order peaks from large to small */
  maxim_sort_indices_descend( pn_x, pn_locs, *pn_npks );

  for ( i = -1; i < *pn_npks; i++ ){
    n_old_npks = *pn_npks;
    *pn_npks = i+1;
    for ( j = i+1; j < n_old_npks; j++ ){
      n_dist =  pn_locs[j] - ( i == -1 ? -1 : pn_locs[i] ); // lag-zero peak of autocorr is at index -1
      if ( n_dist > n_min_distance || n_dist < -n_min_distance )
        pn_locs[(*pn_npks)++] = pn_locs[j];
    }
  }

  // Resort indices int32_to ascending order
  maxim_sort_ascend( pn_locs, *pn_npks );
}

void maxim_sort_ascend(int32_t  *pn_x, int32_t n_size) 
/**
* \brief        Sort array
* \par          Details
*               Sort array in ascending order (insertion sort algorithm)
*
* \retval       None
*/
{
  int32_t i, j, n_temp;
  for (i = 1; i < n_size; i++) {
    n_temp = pn_x[i];
    for (j = i; j > 0 && n_temp < pn_x[j-1]; j--)
        pn_x[j] = pn_x[j-1];
    pn_x[j] = n_temp;
  }
}

void maxim_sort_indices_descend(  int32_t  *pn_x, int32_t *pn_indx, int32_t n_size)
/**
* \brief        Sort indices
* \par          Details
*               Sort indices according to descending order (insertion sort algorithm)
*
* \retval       None
*/ 
{
  int32_t i, j, n_temp;
  for (i = 1; i < n_size; i++) {
    n_temp = pn_indx[i];
    for (j = i; j > 0 && pn_x[n_temp] > pn_x[pn_indx[j-1]]; j--)
      pn_indx[j] = pn_indx[j-1];
    pn_indx[j] = n_temp;
  }
}

Think of it as detective work on a wobbly line. Each heartbeat makes the finger absorb a little more light, so the IR signal dips once per beat. The code finds those dips and measures the spacing for heart rate. At each beat it also compares how strongly the red and IR light pulse for SpO2.

Step 1: Make the signal easy to read

First it subtracts the average (the DC part) and flips the sign:

x[k] = -(IR[k] - mean(IR))

The flip is there because the beat shows up as a dip, and flipping turns the dip into a peak, so one peak finder can do the job. Then a 4-point moving average smooths out sensor fuzz:

x[k] = (x[k] + x[k+1] + x[k+2] + x[k+3]) / 4

Step 2: The threshold

th = mean(x), clamped to the range 30 to 60

Here is the first correction. I said before that the threshold was “the signal mean, clamped”. That’s true, but we just subtracted the mean, so mean(x) is about 0. The clamp therefore pushes it up to 30 almost every time. In practice the threshold is a fixed 30 ADC counts, and any beat whose swing is smaller is invisible. If your finger pressure is light or the LED current is low, the signal shrinks and beats disappear. That is a common reason for HRvalid = 0.

Step 3: Find the peaks

A sample counts as a peak if it is above th and higher than both neighbours (for a flat top, the left edge counts). Then the code cleans up the list:

  1. Sort the peaks from tallest to shortest.
  2. Keep the tallest, and throw away any peak within 4 samples of one already kept.
  3. Re-sort by position and keep at most 15.

This is the second correction. The spacing rule is “more than 4”, so peaks must be at least 5 samples apart (0.2 s), not 4. That caps the detectable rate at 1500/5 = 300 bpm, which is far above anything real.

Step 4: Heart rate

The average gap between neighbouring peaks is the sum of the gaps divided by N−1. The sum telescopes, so it is just (last peak − first peak) / (N−1), and it is done in integer division. With FS = 25 samples/s:

HR = (25 × 60) / T = 1500 / T

For example, with peaks at samples 5, 25, 46, 66 and 86, the gaps add up to 81, and 81/4 = 20 after rounding down. Then HR = 1500/20 = 75 bpm, although the true average gap is 20.25 samples (74 bpm). That rounding is why the output moves in steps: T = 19, 20, 21 gives 78, 75, 71 bpm. A 4 s window can’t resolve finer than about 3 to 4 bpm.

If fewer than 2 peaks are found, you get HR = -999 and HRvalid = 0. Otherwise the flag is 1. It only means “at least two peaks were found”, and nothing checks that the number is plausible. A noisy window could produce 200 bpm with HRvalid = 1.

Step 5: SpO2

For SpO2 it reloads the raw red and IR values, because the smoothed, flipped signal has lost the amplitude information. Then, for each pair of neighbouring valleys v1 and v2 (more than 3 samples apart):

  • DC is the highest raw value between the two valleys, one for red and one for IR.
  • AC is how far above the straight line joining the two valleys the top of the pulse sits. At the peak index p, that line is:
base = raw[v1] + (raw[v2] − raw[v1]) × (p − v1) / (v2 − v1)
AC   = raw[p] − base

Each beat then gives the “ratio of ratios”:

R = (AC_red / DC_red) / (AC_ir / DC_ir) = (AC_red × DC_ir) / (AC_ir × DC_red)

The idea is that you compare how strongly each colour pulses relative to its own brightness. Oxygenated blood absorbs less red light, so more oxygen means a smaller red pulse and a smaller R. The code stores 100·R as an integer and uses >>7 shifts to avoid overflow on small ARM chips.

It keeps up to 5 beat ratios, sorts them, and takes the middle one (or the average of the two around the middle). This acts like a median, so one bad beat can’t wreck the answer.

The final conversion isn’t computed live. It is a lookup, uch_spo2_table[ratio], precomputed from Maxim’s empirical curve:

SpO2 ≈ −45.06·R² + 30.354·R + 94.845

R = 0.5 gives about 99%, and R = 1.0 gives about 80%. The result is marked valid when 2 < ratio < 184, which means R between 0.03 and 1.83. That range is very wide, so SPO2Valid = 1 doesn’t mean the number is believable either.

Results
image
I was not able to achieve the full accuracy it can give because it mattered a lot under pressure and depending on the distance between the sensor and the object.
In the next part I will be finishing from where I left off. I think I will finish it really soon and give my best.
Please let me know your thoughts about it.
Thank you for giving your valuable time reading it. 
  • Sign in to reply
  • Cancel
  • DAB
    DAB 1 hour ago

    Very good update.

    Thanks for walking through the math.

    • Cancel
    • Vote Up 0 Vote Down
    • Sign in to reply
    • Cancel
element14 Community

element14 is the first online community specifically for engineers. Connect with your peers and get expert answers to your questions.

  • Members
  • Learn
  • Technologies
  • Challenges & Projects
  • Products
  • Store
  • About Us
  • Feedback & Support
  • FAQs
  • Terms of Use
  • Privacy Policy
  • Legal and Copyright Notices
  • Sitemap
  • Cookies

An Avnet Company © 2026 Premier Farnell Limited. All Rights Reserved.

Premier Farnell Ltd, registered in England and Wales (no 00876412), registered office: Farnell House, Forge Lane, Leeds LS12 2NE.

Follow element14

  • X
  • Facebook
  • linkedin
  • YouTube