#!/usr/bin/env python3 """R13 — Critical scrutiny: contactless blood pressure from CSI? See docs/research/sota-2026-05-22/R13-contactless-bp-negative.md. Two published approaches to contactless BP: (a) Pulse Transit Time (PTT) — measure delay between pulse arrival at two body sites, then PTT -> BP via Bramwell-Hill / Moens-Korteweg. (b) Contour-based ML — learn (pulse waveform contour -> cuff BP). This script quantifies the physics floors for both: (a) PTT requires (i) ms-scale temporal resolution AND (ii) spatial separation of two body sites. Spatial resolution is bounded by R6 (Fresnel envelope), so we compute whether the per-site signals can be resolved at all. (b) Contour-based ML requires recovering a pulse waveform from a CSI stream where breathing motion is 100x larger. We compute the breathing-vs-pulse motion amplitude ratio and the resulting SNR needed to separate the two by temporal filtering. Pure NumPy. """ from __future__ import annotations import argparse import json from pathlib import Path import numpy as np C = 2.998e8 # ===== Physiology constants ===== PWV_HEALTHY_ADULT_MPS = 7.0 # 5-10 m/s typical (Mukkamala 2015, lit median) CAROTID_FEMORAL_DIST_M = 0.55 # typical anatomic distance CHEST_BREATHING_AMPLITUDE_MM = 8.0 # rest tidal volume, typical adult CHEST_HR_AMPLITUDE_MM = 0.3 # ballistocardiographic chest motion (Inan 2015) CAROTID_PULSE_AMPLITUDE_MM = 0.4 # surface pulse displacement (Liu 2014) RESPIRATION_HZ = 0.25 # 15 BPM HR_HZ = 1.2 # 72 BPM MOTION_NOISE_AMPLITUDE_MM = 2.0 # subject "still" but not motionless # WiFi WAVELENGTH_2_4GHZ_M = 0.125 PHASE_DEG_PER_MM_2_4 = 360.0 / (WAVELENGTH_2_4GHZ_M * 1000) # ~2.88 deg/mm def ptt_seconds(distance_m: float = CAROTID_FEMORAL_DIST_M, pwv_mps: float = PWV_HEALTHY_ADULT_MPS) -> float: return distance_m / pwv_mps def ptt_change_per_bp_mmhg() -> float: """Empirical: 10 mmHg BP change <-> ~5 ms PTT change for typical adult. (Geddes 1981, lit consensus). So sensitivity is ~0.5 ms / mmHg.""" return 5e-3 / 10.0 # 0.5 ms/mmHg def required_ptt_resolution_for_mmhg(target_mmhg: float) -> float: """How precise must PTT measurement be to resolve a target BP delta?""" return target_mmhg * ptt_change_per_bp_mmhg() def fresnel_radius_m(freq_ghz: float, link_m: float, p: float = 0.5) -> float: """Reused from R6.""" lam = C / (freq_ghz * 1e9) return float(np.sqrt(lam * link_m * p * (1 - p))) def signal_phase_change(motion_mm: float) -> float: """Approximate CSI phase change in degrees for a chest motion amplitude. Assumes round-trip path-length change = motion_mm (chest moves toward / away).""" # Path-length change is roughly 2x the motion (in/out scattering) return 2 * motion_mm * PHASE_DEG_PER_MM_2_4 def main(): parser = argparse.ArgumentParser() parser.add_argument("--out", default="examples/research-sota/r13_bp_results.json") args = parser.parse_args() # ====== Part 1: PTT temporal resolution requirements ====== ptt_baseline = ptt_seconds() ptt_for_1mmhg = required_ptt_resolution_for_mmhg(1.0) ptt_for_5mmhg = required_ptt_resolution_for_mmhg(5.0) ptt_for_10mmhg = required_ptt_resolution_for_mmhg(10.0) # CSI sampling: at 100 Hz, time resolution is 10 ms; at 200 Hz, 5 ms. # We need 0.5 ms (1 mmHg) -- that's 2000 Hz CSI rate, which ESP32 *cannot* do. # Max ESP32 CSI rate is ~1000 Hz (Hernandez 2020); typical deployments are 50-100 Hz. # ====== Part 2: Spatial separation of two body sites ====== # For PTT, need to resolve carotid (~neck) and femoral (~hip) signals separately. # The Fresnel envelope at typical room ranges is too wide -- the two sites are # within the same envelope and cannot be separated by single-link CSI. fresnel_envelope_5m = fresnel_radius_m(2.4, 5.0) fresnel_envelope_2m = fresnel_radius_m(2.4, 2.0) sites_resolvable_5m = (CAROTID_FEMORAL_DIST_M / 2) > fresnel_envelope_5m sites_resolvable_2m = (CAROTID_FEMORAL_DIST_M / 2) > fresnel_envelope_2m # Multi-link multistatic could ALMOST resolve them, but the inverse problem # is severely ill-posed with only 4-6 anchors. # ====== Part 3: Pulse contour SNR vs breathing ====== # Phase change per motion: breath_phase_deg = signal_phase_change(CHEST_BREATHING_AMPLITUDE_MM) # ~46 deg pulse_phase_deg = signal_phase_change(CHEST_HR_AMPLITUDE_MM) # ~1.7 deg motion_phase_deg = signal_phase_change(MOTION_NOISE_AMPLITUDE_MM) # ~11.5 deg breath_vs_pulse_amp_ratio = breath_phase_deg / pulse_phase_deg # After bandpass filter (HR band 0.8-3.0 Hz, breathing 0.1-0.4 Hz), # breathing should drop by ~40 dB. So in HR band: breath_after_bandpass_db = -40.0 # typical 4th-order Butterworth pulse_in_hr_band_db = 0.0 motion_in_hr_band_db = -20.0 # micro-motion bleeds into HR band partially # SNR for HR contour recovery: hr_snr_db = pulse_in_hr_band_db - max(motion_in_hr_band_db, breath_after_bandpass_db) # For BP contour, we need to recover the SHAPE of the pulse, not just the rate. # Contour-quality recovery typically needs ~20-30 dB above any contaminating # signal (Mukkamala 2015). Our HR-band SNR is +20 dB -- BARELY enough for # rate, NOT enough for shape. bp_contour_required_snr_db = 25.0 # literature standard for waveform-shape recovery bp_contour_feasibility = "INFEASIBLE" if hr_snr_db < bp_contour_required_snr_db else "MARGINAL" # ====== Part 4: Compare to cuff baseline ====== cuff_accuracy_mmhg = 2.0 # arm-cuff BIHS Grade A published_csi_bp_mae_mmhg = 10.0 # representative lit (Yang 2022 et al.) # Conclusion: even the best published CSI BP is 5x worse than a $20 cuff. out = { "model": "PTT + pulse-contour physics scrutiny for contactless BP", "ptt": { "baseline_ms": ptt_baseline * 1e3, "sensitivity_ms_per_mmHg": ptt_change_per_bp_mmhg() * 1e3, "required_resolution_for_1mmHg_ms": ptt_for_1mmhg * 1e3, "required_resolution_for_5mmHg_ms": ptt_for_5mmhg * 1e3, "required_resolution_for_10mmHg_ms": ptt_for_10mmhg * 1e3, "esp32_max_csi_rate_hz": 1000, "esp32_max_temporal_resolution_ms": 1.0, "esp32_typical_csi_rate_hz": 100, "esp32_typical_temporal_resolution_ms": 10.0, }, "spatial_resolution": { "carotid_femoral_distance_m": CAROTID_FEMORAL_DIST_M, "fresnel_envelope_5m_link_m": fresnel_envelope_5m, "fresnel_envelope_2m_link_m": fresnel_envelope_2m, "sites_resolvable_5m_link": bool(sites_resolvable_5m), "sites_resolvable_2m_link": bool(sites_resolvable_2m), "comment": "Single-link CSI cannot spatially separate two body sites. PTT requires multi-link multistatic with severely ill-posed inverse problem.", }, "snr": { "breath_phase_deg": breath_phase_deg, "pulse_phase_deg": pulse_phase_deg, "motion_phase_deg": motion_phase_deg, "breath_vs_pulse_amp_ratio": breath_vs_pulse_amp_ratio, "hr_band_snr_db": hr_snr_db, "bp_contour_required_snr_db": bp_contour_required_snr_db, "bp_contour_feasibility": bp_contour_feasibility, }, "vs_baseline": { "arm_cuff_accuracy_mmHg": cuff_accuracy_mmhg, "published_csi_bp_mae_mmHg": published_csi_bp_mae_mmhg, "ratio_worse": published_csi_bp_mae_mmhg / cuff_accuracy_mmhg, }, } Path(args.out).parent.mkdir(parents=True, exist_ok=True) Path(args.out).write_text(json.dumps(out, indent=2)) print("=== PTT temporal resolution requirements ===") print(f" Baseline PTT (55 cm body, 7 m/s PWV): {ptt_baseline*1e3:.1f} ms") print(f" Sensitivity: {ptt_change_per_bp_mmhg()*1e3:.2f} ms / mmHg") print(f" Required for 1 mmHg precision: {ptt_for_1mmhg*1e3:.2f} ms") print(f" Required for 5 mmHg precision: {ptt_for_5mmhg*1e3:.2f} ms") print(f" Required for 10 mmHg precision: {ptt_for_10mmhg*1e3:.2f} ms") print(f" ESP32 max CSI rate (~1000 Hz): 1.0 ms resolution -- meets 1 mmHg req") print(f" ESP32 typical (~100 Hz): 10.0 ms resolution -- meets only 20 mmHg") print() print("=== Spatial resolution (Fresnel envelope) ===") print(f" Carotid-to-femoral distance: {CAROTID_FEMORAL_DIST_M*100:.0f} cm") print(f" Fresnel envelope @ 5 m link: {fresnel_envelope_5m*100:.0f} cm -- sites NOT resolvable") print(f" Fresnel envelope @ 2 m link: {fresnel_envelope_2m*100:.0f} cm -- sites NOT resolvable") print() print("=== Phase change per motion (CSI 2.4 GHz) ===") print(f" Chest breathing (8 mm): {breath_phase_deg:.1f} deg") print(f" HR ballistocardiographic (0.3 mm): {pulse_phase_deg:.1f} deg") print(f" Subject 'still' motion (2 mm): {motion_phase_deg:.1f} deg") print(f" Breathing-to-pulse amplitude ratio: {breath_vs_pulse_amp_ratio:.0f}x") print() print(f"=== BP contour recovery ===") print(f" HR-band SNR after bandpass: {hr_snr_db:.1f} dB") print(f" Required for BP contour shape: {bp_contour_required_snr_db:.1f} dB") print(f" Verdict: {bp_contour_feasibility}") print() print(f"=== Vs $20 arm cuff baseline ===") print(f" Arm cuff (BIHS Grade A): ±{cuff_accuracy_mmhg:.0f} mmHg") print(f" Best published CSI BP: ±{published_csi_bp_mae_mmhg:.0f} mmHg") print(f" CSI is worse by: {published_csi_bp_mae_mmhg/cuff_accuracy_mmhg:.0f}x") print() print(f"Wrote {args.out}") if __name__ == "__main__": main()