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)

2. Calculate RR intervals rr_intervals = get_rr_intervals ( peaks , fs ) rr_times = np . cumsum ( rr_intervals ) # 3. Resample (Interpolate) to 4Hz for FFT analysis interp_func = interp1d ( rr_times , rr_intervals , kind = ' cubic ' ) new_time = np . arange ( rr_times [ 0 ], rr_times [ - 1 ], 1 / 4.0 ) resampled_rr = interp_func ( new_time ) Step 4: The Stress Score Logic (LF/HF Ratio) The "Gold Standard" for stress in the frequency domain is the LF/HF Ratio : LF (Low Frequency: 0.04 - 0.15 Hz) : Reflects both Sympathetic and Parasympathetic activity. HF (High Frequency: 0.15 - 0.4 Hz) : Reflects Parasympathetic (vagal) activity—the "Rest and Digest" system. Stress Logic : High LF/HF Ratio = Sympathetic Dominance = High Stress . from scipy.fftpack import fft def calculate_stress_score ( resampled_rr , fs_resampled = 4.0 ): # Perform FFT n = len ( resampled_rr ) freq = np . fft . fftfreq ( n , d = 1 / fs_resampled ) power = np . abs ( fft ( resampled_rr )) ** 2 # Define bands lf_mask = ( freq >= 0.04 ) & ( freq <= 0.15 ) hf_mask = ( freq >= 0.15 ) & ( freq <= 0.4 ) lf_power = np . trapz ( power [ lf_mask ], freq [ lf_mask ]) hf_power = np . trapz ( power [ hf_mask ], freq [ hf_mask ]) return lf_power / hf_power # The LF/HF Ratio stress_index = calculate_stress_score ( resampled_rr ) print ( f " Calculated Stress Index: { stress_index : . 2 f } " ) 🥑 Going Beyond the Basics Building a DIY Stress Score engine is just the beginning of what's possible with biometric data. While we've used a simple LF/HF ratio here, production-grade systems often incorporate non-linear dynamics and machine learning to account for individual baselines. For more production-ready examples and advanced patterns in health-tech engineering, I highly recommend checking out the technical deep dives at WellAlly Tech Blog . They cover everything from signal artifact removal to the latest in multimodal health AI. Conclusion 🏁 You've just bypassed the expensive proprietary clouds of Garmin and Apple! By capturing raw PPG signals via BLE , filtering them with SciPy , and analyzing the frequency components of heart rate variability, you now have a functional stress-tracking pipeline. Next Steps : Try adding a rolling window to track stress changes in real-time. Experiment with the Poincaré plot for a visual (non-linear) HRV analysis. Are you working on a wearable project? Drop a comment below or share your results! Let’s keep building in public. 🥑💻