Have you ever wondered how your smartwatch actually knows you're stressed? Most of us treat the "Stress Score" on our wrists as a source of truth, but the logic remains hidden behind proprietary algorithms. Today, we are pulling back the curtain. We are going beyond basic heart rate tracking to perform PPG signal processing and HRV frequency domain analysis using Python. By the end of this guide, you’ll know how to ingest raw data via Bluetooth Low Energy (BLE) , apply digital filters with SciPy , and calculate the elusive LF/HF ratio to determine autonomic nervous system balance. If you are interested in advanced biometric algorithms or Python signal analysis , you’re in the right place. 🚀 The Architecture: From Photons to Stress Metrics Unlike standard heart rate (BPM), which just counts peaks, Stress Scores rely on Heart Rate Variability (HRV) —the millisecond-level variations between heartbeats. We'll be moving from raw light intensity data to a frequency-based stress index. graph TD A[Wearable Sensor / PPG] -->|Raw BLE Stream| B[Data Acquisition - Bleak] B --> C[Preprocessing - Bandpass Filter] C --> D[Peak Detection - Find R-R Intervals] D --> E[Cubic Spline Interpolation] E --> F[Fast Fourier Transform - FFT] F --> G[LF/HF Ratio Calculation] G --> H[Final Stress Score] 🛠 Prerequisites To follow this advanced tutorial, you’ll need: Hardware : A pulse oximeter or wearable that exposes raw PPG via BLE (e.g., Polar OH1, MAX30102 with an ESP32). Stack : NumPy & SciPy : For heavy-duty math and signal processing. Bleak : For cross-platform Bluetooth Low Energy communication. Matplotlib : To visualize the pulse waves. Step 1: Capturing the Raw PPG Stream (BLE) Photoplethysmography (PPG) works by shining green or red light into the skin and measuring the light absorption. First, let's grab that raw stream. import asyncio from bleak import BleakClient # UUID for the Raw PPG Characteristic (Device specific) PPG_CHAR_UUID = " 00002a37-0000-1000-8000-00805f9b34fb " def notification_handler ( sender , data ): # Convert bytearray to raw integer light intensity raw_value = int . from_bytes ( data , byteorder = ' little ' ) print ( f " Raw PPG Value: { raw_value } " ) async def run_ble_capture ( address ): async with BleakClient ( address ) as client : print ( f " Connected: { client . is_connected } " ) await client . start_notify ( PPG_CHAR_UUID , notification_handler ) await asyncio . sleep ( 60.0 ) # Capture for 1 minute await client . stop_notify ( PPG_CHAR_UUID ) # Usage: asyncio.run(run_ble_capture("XX:XX:XX:XX:XX:XX")) Step 2: Cleaning the Noise (Signal Processing) Raw PPG data is incredibly noisy—affected by movement, breathing, and ambient light. We use a Butterworth Bandpass Filter to isolate the pulse (typically 0.5Hz to 4Hz). import numpy as np from scipy.signal import butter , filtfilt def butter_bandpass_filter ( data , lowcut , highcut , fs , order = 5 ): nyq = 0.5 * fs low = lowcut / nyq high = highcut / nyq b , a = butter ( order , [ low , high ], btype = ' band ' ) y = filtfilt ( b , a , data ) return y # Example: fs=50Hz, keep frequencies between 0.7Hz (42bpm) and 3.5Hz (210bpm) fs = 50.0 filtered_ppg = butter_bandpass_filter ( raw_ppg_array , 0.7 , 3.5 , fs ) Step 3: Extracting R-R Intervals & Interpolation To perform Frequency Domain analysis, we need the time difference between beats (R-R intervals). However, these intervals are non-uniformly sampled. We must interpolate the signal to create a uniform time series before applying FFT. from scipy.interpolate import interp1d def get_rr_intervals ( peaks , fs ): return np . diff ( peaks ) / fs # 1. Detect Peaks (SciPy find_peaks)

Stop Trusting the Black Box: Building Your Own Stress Score Engine from Raw PPG Signals
Beck_Moulton

