Spaces:
Running
Running
Deploy ecg-only 332caae0: ECG-derived HR, source labels
Browse files- .pytest_cache/v/cache/nodeids +10 -1
- app/models/vitals.py +3 -0
- app/routes/vitals.py +20 -0
- scripts/firmware_ecg.py +62 -0
- scripts/playback_ptbxl.py +2 -0
- scripts/replay_recording.py +104 -0
- tests/test_ecg_heart_rate.py +13 -0
- tests/test_firmware_ecg.py +40 -0
- tests/test_vitals_source.py +48 -0
.pytest_cache/v/cache/nodeids
CHANGED
|
@@ -1,6 +1,12 @@
|
|
| 1 |
[
|
| 2 |
"tests/test_database.py::test_connect_db_does_not_raise_when_cluster_dns_missing",
|
| 3 |
"tests/test_database.py::test_health_reports_db_disconnected_when_cluster_dns_missing",
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 4 |
"tests/test_signal_quality.py::test_atrial_fibrillation_is_not_rejected_for_irregularity[100]",
|
| 5 |
"tests/test_signal_quality.py::test_atrial_fibrillation_is_not_rejected_for_irregularity[250]",
|
| 6 |
"tests/test_signal_quality.py::test_heart_rate_agrees_between_sample_rates",
|
|
@@ -46,5 +52,8 @@
|
|
| 46 |
"tests/test_signal_quality.py::test_too_short_window_is_poor",
|
| 47 |
"tests/test_signal_quality.py::test_upload_marks_lead_off",
|
| 48 |
"tests/test_signal_quality.py::test_upload_runs_prediction_for_good_signal",
|
| 49 |
-
"tests/test_signal_quality.py::test_upload_skips_prediction_for_poor_signal"
|
|
|
|
|
|
|
|
|
|
| 50 |
]
|
|
|
|
| 1 |
[
|
| 2 |
"tests/test_database.py::test_connect_db_does_not_raise_when_cluster_dns_missing",
|
| 3 |
"tests/test_database.py::test_health_reports_db_disconnected_when_cluster_dns_missing",
|
| 4 |
+
"tests/test_ecg_heart_rate.py::test_ignores_missing_or_implausible_values",
|
| 5 |
+
"tests/test_ecg_heart_rate.py::test_uses_ecg_measured_heart_rate_from_model_features",
|
| 6 |
+
"tests/test_firmware_ecg.py::test_constant_input_settles_to_the_2048_centre",
|
| 7 |
+
"tests/test_firmware_ecg.py::test_matches_values_in_device_garbage_fixture_range",
|
| 8 |
+
"tests/test_firmware_ecg.py::test_scaling_keeps_clipped_device_peaks_off_the_rails",
|
| 9 |
+
"tests/test_firmware_ecg.py::test_splits_into_full_ten_second_windows",
|
| 10 |
"tests/test_signal_quality.py::test_atrial_fibrillation_is_not_rejected_for_irregularity[100]",
|
| 11 |
"tests/test_signal_quality.py::test_atrial_fibrillation_is_not_rejected_for_irregularity[250]",
|
| 12 |
"tests/test_signal_quality.py::test_heart_rate_agrees_between_sample_rates",
|
|
|
|
| 52 |
"tests/test_signal_quality.py::test_too_short_window_is_poor",
|
| 53 |
"tests/test_signal_quality.py::test_upload_marks_lead_off",
|
| 54 |
"tests/test_signal_quality.py::test_upload_runs_prediction_for_good_signal",
|
| 55 |
+
"tests/test_signal_quality.py::test_upload_skips_prediction_for_poor_signal",
|
| 56 |
+
"tests/test_vitals_source.py::test_response_includes_source_label",
|
| 57 |
+
"tests/test_vitals_source.py::test_response_source_is_none_for_live_device_docs",
|
| 58 |
+
"tests/test_vitals_source.py::test_upload_accepts_optional_source_label"
|
| 59 |
]
|
app/models/vitals.py
CHANGED
|
@@ -13,6 +13,7 @@ class VitalsCreate(BaseModel):
|
|
| 13 |
ecg_lead_off: bool = Field(default=False)
|
| 14 |
ecg_samples: List[int] = Field(..., min_length=100, max_length=6000)
|
| 15 |
beat_timestamps_ms: List[int] = Field(default_factory=list)
|
|
|
|
| 16 |
|
| 17 |
|
| 18 |
class VitalsResponse(BaseModel):
|
|
@@ -20,6 +21,7 @@ class VitalsResponse(BaseModel):
|
|
| 20 |
device_id: str
|
| 21 |
timestamp: datetime
|
| 22 |
heart_rate_bpm: float
|
|
|
|
| 23 |
spo2_percent: int
|
| 24 |
ecg_lead_off: bool
|
| 25 |
sample_count: int
|
|
@@ -27,6 +29,7 @@ class VitalsResponse(BaseModel):
|
|
| 27 |
sample_rate_hz: Optional[int] = None
|
| 28 |
prediction: Optional[dict] = None
|
| 29 |
signal_quality: Optional[str] = None
|
|
|
|
| 30 |
created_at: datetime
|
| 31 |
|
| 32 |
|
|
|
|
| 13 |
ecg_lead_off: bool = Field(default=False)
|
| 14 |
ecg_samples: List[int] = Field(..., min_length=100, max_length=6000)
|
| 15 |
beat_timestamps_ms: List[int] = Field(default_factory=list)
|
| 16 |
+
source: Optional[str] = Field(None, max_length=120, description="Origin label for playback data")
|
| 17 |
|
| 18 |
|
| 19 |
class VitalsResponse(BaseModel):
|
|
|
|
| 21 |
device_id: str
|
| 22 |
timestamp: datetime
|
| 23 |
heart_rate_bpm: float
|
| 24 |
+
heart_rate_source: Optional[str] = None # "sensor" (MAX30100) or "ecg" (derived from R-peaks)
|
| 25 |
spo2_percent: int
|
| 26 |
ecg_lead_off: bool
|
| 27 |
sample_count: int
|
|
|
|
| 29 |
sample_rate_hz: Optional[int] = None
|
| 30 |
prediction: Optional[dict] = None
|
| 31 |
signal_quality: Optional[str] = None
|
| 32 |
+
source: Optional[str] = None
|
| 33 |
created_at: datetime
|
| 34 |
|
| 35 |
|
app/routes/vitals.py
CHANGED
|
@@ -11,6 +11,12 @@ from app.services.signal_quality import assess_ecg_quality
|
|
| 11 |
router = APIRouter()
|
| 12 |
|
| 13 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 14 |
def _vitals_doc_to_response(doc: dict, prediction: dict = None) -> VitalsResponse:
|
| 15 |
ecg = doc.get("ecg_samples")
|
| 16 |
return VitalsResponse(
|
|
@@ -18,6 +24,7 @@ def _vitals_doc_to_response(doc: dict, prediction: dict = None) -> VitalsRespons
|
|
| 18 |
device_id=doc["device_id"],
|
| 19 |
timestamp=doc["timestamp"],
|
| 20 |
heart_rate_bpm=doc["heart_rate_bpm"],
|
|
|
|
| 21 |
spo2_percent=doc["spo2_percent"],
|
| 22 |
ecg_lead_off=doc["ecg_lead_off"],
|
| 23 |
sample_count=len(ecg) if ecg else 0,
|
|
@@ -25,6 +32,7 @@ def _vitals_doc_to_response(doc: dict, prediction: dict = None) -> VitalsRespons
|
|
| 25 |
sample_rate_hz=doc.get("sample_rate_hz"),
|
| 26 |
prediction=prediction,
|
| 27 |
signal_quality=doc.get("signal_quality"),
|
|
|
|
| 28 |
created_at=doc["created_at"],
|
| 29 |
)
|
| 30 |
|
|
@@ -65,12 +73,14 @@ async def upload_vitals(data: VitalsCreate, _=Depends(verify_api_key)):
|
|
| 65 |
"window_ms": data.window_ms,
|
| 66 |
"sample_rate_hz": data.sample_rate_hz,
|
| 67 |
"heart_rate_bpm": data.heart_rate_bpm,
|
|
|
|
| 68 |
"spo2_percent": data.spo2_percent,
|
| 69 |
"ecg_lead_off": data.ecg_lead_off,
|
| 70 |
"ecg_samples": data.ecg_samples,
|
| 71 |
"beat_timestamps_ms": data.beat_timestamps_ms,
|
| 72 |
"signal_quality": signal_quality,
|
| 73 |
"signal_quality_reason": signal_quality_reason,
|
|
|
|
| 74 |
"created_at": datetime.utcnow(),
|
| 75 |
}
|
| 76 |
result = await db.vitals.insert_one(vitals_doc)
|
|
@@ -153,6 +163,16 @@ async def upload_vitals(data: VitalsCreate, _=Depends(verify_api_key)):
|
|
| 153 |
history_features=history_features,
|
| 154 |
)
|
| 155 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 156 |
if ml_result["risk_label"] != "unknown":
|
| 157 |
pred_doc = {
|
| 158 |
"vitals_id": str(result.inserted_id),
|
|
|
|
| 11 |
router = APIRouter()
|
| 12 |
|
| 13 |
|
| 14 |
+
def _ecg_heart_rate(features: dict):
|
| 15 |
+
"""Heart rate the feature extractor measured from the ECG R-peaks, if plausible."""
|
| 16 |
+
hr = (features or {}).get("mean_hr_ecg")
|
| 17 |
+
return round(float(hr), 1) if hr and 30 <= hr <= 220 else None
|
| 18 |
+
|
| 19 |
+
|
| 20 |
def _vitals_doc_to_response(doc: dict, prediction: dict = None) -> VitalsResponse:
|
| 21 |
ecg = doc.get("ecg_samples")
|
| 22 |
return VitalsResponse(
|
|
|
|
| 24 |
device_id=doc["device_id"],
|
| 25 |
timestamp=doc["timestamp"],
|
| 26 |
heart_rate_bpm=doc["heart_rate_bpm"],
|
| 27 |
+
heart_rate_source=doc.get("heart_rate_source"),
|
| 28 |
spo2_percent=doc["spo2_percent"],
|
| 29 |
ecg_lead_off=doc["ecg_lead_off"],
|
| 30 |
sample_count=len(ecg) if ecg else 0,
|
|
|
|
| 32 |
sample_rate_hz=doc.get("sample_rate_hz"),
|
| 33 |
prediction=prediction,
|
| 34 |
signal_quality=doc.get("signal_quality"),
|
| 35 |
+
source=doc.get("source"),
|
| 36 |
created_at=doc["created_at"],
|
| 37 |
)
|
| 38 |
|
|
|
|
| 73 |
"window_ms": data.window_ms,
|
| 74 |
"sample_rate_hz": data.sample_rate_hz,
|
| 75 |
"heart_rate_bpm": data.heart_rate_bpm,
|
| 76 |
+
"heart_rate_source": "sensor" if data.heart_rate_bpm > 0 else None,
|
| 77 |
"spo2_percent": data.spo2_percent,
|
| 78 |
"ecg_lead_off": data.ecg_lead_off,
|
| 79 |
"ecg_samples": data.ecg_samples,
|
| 80 |
"beat_timestamps_ms": data.beat_timestamps_ms,
|
| 81 |
"signal_quality": signal_quality,
|
| 82 |
"signal_quality_reason": signal_quality_reason,
|
| 83 |
+
"source": data.source,
|
| 84 |
"created_at": datetime.utcnow(),
|
| 85 |
}
|
| 86 |
result = await db.vitals.insert_one(vitals_doc)
|
|
|
|
| 163 |
history_features=history_features,
|
| 164 |
)
|
| 165 |
|
| 166 |
+
# No pulse sensor reading (e.g. MAX30100 disabled): use the HR measured from the ECG
|
| 167 |
+
if data.heart_rate_bpm <= 0:
|
| 168 |
+
ecg_hr = _ecg_heart_rate(ml_result.get("features"))
|
| 169 |
+
if ecg_hr:
|
| 170 |
+
vitals_doc.update(heart_rate_bpm=ecg_hr, heart_rate_source="ecg")
|
| 171 |
+
await db.vitals.update_one(
|
| 172 |
+
{"_id": result.inserted_id},
|
| 173 |
+
{"$set": {"heart_rate_bpm": ecg_hr, "heart_rate_source": "ecg"}},
|
| 174 |
+
)
|
| 175 |
+
|
| 176 |
if ml_result["risk_label"] != "unknown":
|
| 177 |
pred_doc = {
|
| 178 |
"vitals_id": str(result.inserted_id),
|
scripts/firmware_ecg.py
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Python port of the ESP32 ECG processing (firmware/src/ecg_filter.h + sensor_manager.cpp).
|
| 2 |
+
|
| 3 |
+
Turns raw 250 Hz AD8232 ADC samples into the exact 10 s windows the firmware uploads:
|
| 4 |
+
50 Hz notch -> 40 Hz low-pass -> DC removal, scaled by ECG_OUTPUT_SCALE, re-centred
|
| 5 |
+
at 2048 and clamped to 12 bits, with filter state reset at every window boundary.
|
| 6 |
+
Keep in sync with firmware/include/config.h.
|
| 7 |
+
"""
|
| 8 |
+
|
| 9 |
+
SAMPLE_RATE_HZ = 250
|
| 10 |
+
WINDOW_SAMPLES = 2500
|
| 11 |
+
ECG_OUTPUT_SCALE = 0.5
|
| 12 |
+
|
| 13 |
+
|
| 14 |
+
class _Notch50:
|
| 15 |
+
b0, b1, b2, a1, a2 = 0.981334, -0.606498, 0.981334, -0.606498, 0.962668
|
| 16 |
+
|
| 17 |
+
def __init__(self):
|
| 18 |
+
self.x1 = self.x2 = self.y1 = self.y2 = 0.0
|
| 19 |
+
|
| 20 |
+
def step(self, x):
|
| 21 |
+
y = self.b0 * x + self.b1 * self.x1 + self.b2 * self.x2 - self.a1 * self.y1 - self.a2 * self.y2
|
| 22 |
+
self.x2, self.x1, self.y2, self.y1 = self.x1, x, self.y1, y
|
| 23 |
+
return y
|
| 24 |
+
|
| 25 |
+
|
| 26 |
+
class _LowPass40:
|
| 27 |
+
b0, b1, b2, a1, a2 = 0.145310, 0.290620, 0.145310, -0.670919, 0.252160
|
| 28 |
+
|
| 29 |
+
def __init__(self):
|
| 30 |
+
self.z1 = self.z2 = 0.0
|
| 31 |
+
|
| 32 |
+
def step(self, x):
|
| 33 |
+
y = self.b0 * x + self.z1
|
| 34 |
+
self.z1 = self.b1 * x - self.a1 * y + self.z2
|
| 35 |
+
self.z2 = self.b2 * x - self.a2 * y
|
| 36 |
+
return y
|
| 37 |
+
|
| 38 |
+
|
| 39 |
+
class _DcRemover:
|
| 40 |
+
def __init__(self, alpha=0.9875):
|
| 41 |
+
self.alpha, self.w = alpha, 0.0
|
| 42 |
+
|
| 43 |
+
def step(self, x):
|
| 44 |
+
old = self.w
|
| 45 |
+
self.w = x + self.alpha * self.w
|
| 46 |
+
return self.w - old
|
| 47 |
+
|
| 48 |
+
|
| 49 |
+
def firmware_windows(raw, scale=ECG_OUTPUT_SCALE, reset_each_window=True):
|
| 50 |
+
"""Split raw ADC samples into firmware-processed 10 s windows (lists of ints)."""
|
| 51 |
+
chain = [_Notch50(), _LowPass40(), _DcRemover()]
|
| 52 |
+
windows = []
|
| 53 |
+
for start in range(0, len(raw) - WINDOW_SAMPLES + 1, WINDOW_SAMPLES):
|
| 54 |
+
if reset_each_window:
|
| 55 |
+
chain = [_Notch50(), _LowPass40(), _DcRemover()]
|
| 56 |
+
window = []
|
| 57 |
+
for v in raw[start:start + WINDOW_SAMPLES]:
|
| 58 |
+
for f in chain:
|
| 59 |
+
v = f.step(v)
|
| 60 |
+
window.append(int(min(4095, max(0, round(v * scale + 2048)))))
|
| 61 |
+
windows.append(window)
|
| 62 |
+
return windows
|
scripts/playback_ptbxl.py
CHANGED
|
@@ -36,6 +36,7 @@ from scipy.signal import resample_poly
|
|
| 36 |
DEVICE_ID = "PLAYBACK_PTBXL"
|
| 37 |
DEVICE_NAME = "PTB-XL playback (recorded clinical ECGs)"
|
| 38 |
MI_CODES = {"IMI", "AMI", "ASMI", "ALMI", "ILMI", "LMI", "PMI", "IPLMI"}
|
|
|
|
| 39 |
COUNTS_PER_MV = 400.0
|
| 40 |
CENTER = 2048
|
| 41 |
SAMPLE_RATE_HZ = 250
|
|
@@ -131,6 +132,7 @@ def main():
|
|
| 131 |
"spo2_percent": 0,
|
| 132 |
"ecg_lead_off": False,
|
| 133 |
"ecg_samples": samples,
|
|
|
|
| 134 |
})
|
| 135 |
r.raise_for_status()
|
| 136 |
body = r.json()
|
|
|
|
| 36 |
DEVICE_ID = "PLAYBACK_PTBXL"
|
| 37 |
DEVICE_NAME = "PTB-XL playback (recorded clinical ECGs)"
|
| 38 |
MI_CODES = {"IMI", "AMI", "ASMI", "ALMI", "ILMI", "LMI", "PMI", "IPLMI"}
|
| 39 |
+
LABEL_TEXT = {"NORM": "normal ECG", "MI": "myocardial infarction"}
|
| 40 |
COUNTS_PER_MV = 400.0
|
| 41 |
CENTER = 2048
|
| 42 |
SAMPLE_RATE_HZ = 250
|
|
|
|
| 132 |
"spo2_percent": 0,
|
| 133 |
"ecg_lead_off": False,
|
| 134 |
"ecg_samples": samples,
|
| 135 |
+
"source": f"PTB-XL #{row.ecg_id} · label: {LABEL_TEXT.get(row.label, row.label)}",
|
| 136 |
})
|
| 137 |
r.raise_for_status()
|
| 138 |
body = r.json()
|
scripts/replay_recording.py
ADDED
|
@@ -0,0 +1,104 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Replay a raw AD8232 recording from the ESP32 through the vitals API, window by window.
|
| 2 |
+
|
| 3 |
+
Each 10 s window is processed exactly as the firmware does (firmware_ecg.py) and sent
|
| 4 |
+
from a playback device (default PLAYBACK_DEVICE_REC) with a source label, so stored
|
| 5 |
+
data is never mistaken for a live session. HR/SpO2 are sent as 0 (no pulse sensor);
|
| 6 |
+
the server derives HR from the ECG.
|
| 7 |
+
|
| 8 |
+
Input: a serial capture with "D:v,v,..." lines (ECG diagnostic sketch, 250 Hz raw) or
|
| 9 |
+
a JSON file {"sample_rate_hz": 250, "samples": [...]}.
|
| 10 |
+
|
| 11 |
+
Usage (from backend/):
|
| 12 |
+
API_KEY=... uv run --quiet --no-project --python 3.11 --with httpx \
|
| 13 |
+
python scripts/replay_recording.py recording.raw --api http://localhost:7860 \
|
| 14 |
+
--label "Recorded on this device · 2026-10-11 · healthy adult at rest" \
|
| 15 |
+
[--email demo@example.com --password ...] [--interval 10]
|
| 16 |
+
"""
|
| 17 |
+
|
| 18 |
+
import argparse
|
| 19 |
+
import json
|
| 20 |
+
import os
|
| 21 |
+
import time
|
| 22 |
+
from collections import Counter
|
| 23 |
+
|
| 24 |
+
import httpx
|
| 25 |
+
|
| 26 |
+
from firmware_ecg import SAMPLE_RATE_HZ, firmware_windows
|
| 27 |
+
|
| 28 |
+
DEVICE_ID = "PLAYBACK_DEVICE_REC"
|
| 29 |
+
DEVICE_NAME = "Recorded session from the ESP32 (replayed)"
|
| 30 |
+
|
| 31 |
+
|
| 32 |
+
def load_raw(path: str) -> list:
|
| 33 |
+
text = open(path, "rb").read().decode("ascii", "ignore")
|
| 34 |
+
if text.lstrip().startswith("{"):
|
| 35 |
+
data = json.loads(text)
|
| 36 |
+
assert data.get("sample_rate_hz", SAMPLE_RATE_HZ) == SAMPLE_RATE_HZ
|
| 37 |
+
return data["samples"]
|
| 38 |
+
return [int(v) for line in text.replace("\r", "").splitlines() if line.startswith("D:")
|
| 39 |
+
for v in line[2:].split(",") if v.isdigit()]
|
| 40 |
+
|
| 41 |
+
|
| 42 |
+
def register_device(client: httpx.Client, device_id: str, email: str, password: str):
|
| 43 |
+
r = client.post("/api/v1/auth/login", json={"email": email, "password": password})
|
| 44 |
+
r.raise_for_status()
|
| 45 |
+
headers = {"Authorization": f"Bearer {r.json()['access_token']}"}
|
| 46 |
+
r = client.post("/api/v1/devices/register", headers=headers,
|
| 47 |
+
json={"device_id": device_id, "name": DEVICE_NAME})
|
| 48 |
+
r.raise_for_status()
|
| 49 |
+
print(f"Registered {device_id} to {email}")
|
| 50 |
+
|
| 51 |
+
|
| 52 |
+
def main():
|
| 53 |
+
p = argparse.ArgumentParser(description=__doc__.splitlines()[0])
|
| 54 |
+
p.add_argument("recording", help="serial capture (.raw) or JSON recording")
|
| 55 |
+
p.add_argument("--api", required=True, help="API base URL")
|
| 56 |
+
p.add_argument("--api-key", default=os.environ.get("API_KEY"), help="device API key (default: $API_KEY)")
|
| 57 |
+
p.add_argument("--label", required=True, help="source label shown with every window")
|
| 58 |
+
p.add_argument("--device-id", default=DEVICE_ID)
|
| 59 |
+
p.add_argument("--email", help="account to register the playback device to")
|
| 60 |
+
p.add_argument("--password")
|
| 61 |
+
p.add_argument("--interval", type=float, default=0, help="seconds between windows")
|
| 62 |
+
args = p.parse_args()
|
| 63 |
+
if not args.api_key:
|
| 64 |
+
p.error("--api-key or $API_KEY is required")
|
| 65 |
+
if len(args.label) > 120:
|
| 66 |
+
p.error("--label must be at most 120 characters")
|
| 67 |
+
|
| 68 |
+
windows = firmware_windows(load_raw(args.recording))
|
| 69 |
+
print(f"{len(windows)} windows of 10 s from {args.recording}")
|
| 70 |
+
|
| 71 |
+
with httpx.Client(base_url=args.api, timeout=120) as client:
|
| 72 |
+
if args.email:
|
| 73 |
+
register_device(client, args.device_id, args.email, args.password)
|
| 74 |
+
|
| 75 |
+
labels = Counter()
|
| 76 |
+
print(f"{'t':>5} {'quality':8} {'HR':>5} {'risk':>6} label")
|
| 77 |
+
for n, window in enumerate(windows):
|
| 78 |
+
if n and args.interval:
|
| 79 |
+
time.sleep(args.interval)
|
| 80 |
+
r = client.post("/api/v1/vitals", headers={"X-API-Key": args.api_key}, json={
|
| 81 |
+
"device_id": args.device_id,
|
| 82 |
+
"timestamp": int(time.time()),
|
| 83 |
+
"window_ms": 10000,
|
| 84 |
+
"sample_rate_hz": SAMPLE_RATE_HZ,
|
| 85 |
+
"heart_rate_bpm": 0,
|
| 86 |
+
"spo2_percent": 0,
|
| 87 |
+
"ecg_lead_off": False,
|
| 88 |
+
"ecg_samples": window,
|
| 89 |
+
"source": args.label,
|
| 90 |
+
})
|
| 91 |
+
r.raise_for_status()
|
| 92 |
+
body = r.json()
|
| 93 |
+
pred = body.get("prediction") or {}
|
| 94 |
+
label = pred.get("risk_label", "-")
|
| 95 |
+
labels[label] += 1
|
| 96 |
+
score = f"{pred['risk_score']:.3f}" if pred else "-"
|
| 97 |
+
quality = body.get("signal_quality") or "-"
|
| 98 |
+
print(f"{n * 10:>4}s {quality:8} {body['heart_rate_bpm']:>5} {score:>6} {label}")
|
| 99 |
+
|
| 100 |
+
print(f"\nSummary: {dict(labels)}")
|
| 101 |
+
|
| 102 |
+
|
| 103 |
+
if __name__ == "__main__":
|
| 104 |
+
main()
|
tests/test_ecg_heart_rate.py
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from app.routes.vitals import _ecg_heart_rate
|
| 2 |
+
|
| 3 |
+
|
| 4 |
+
def test_uses_ecg_measured_heart_rate_from_model_features():
|
| 5 |
+
assert _ecg_heart_rate({"mean_hr_ecg": 71.3456, "rmssd": 30.1}) == 71.3
|
| 6 |
+
|
| 7 |
+
|
| 8 |
+
def test_ignores_missing_or_implausible_values():
|
| 9 |
+
assert _ecg_heart_rate({}) is None
|
| 10 |
+
assert _ecg_heart_rate(None) is None
|
| 11 |
+
assert _ecg_heart_rate({"mean_hr_ecg": 0}) is None
|
| 12 |
+
assert _ecg_heart_rate({"mean_hr_ecg": 12.0}) is None
|
| 13 |
+
assert _ecg_heart_rate({"mean_hr_ecg": 260.0}) is None
|
tests/test_firmware_ecg.py
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import json
|
| 2 |
+
import os
|
| 3 |
+
import sys
|
| 4 |
+
|
| 5 |
+
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "scripts"))
|
| 6 |
+
|
| 7 |
+
from firmware_ecg import WINDOW_SAMPLES, firmware_windows # noqa: E402
|
| 8 |
+
|
| 9 |
+
|
| 10 |
+
def test_splits_into_full_ten_second_windows():
|
| 11 |
+
windows = firmware_windows([1800] * (WINDOW_SAMPLES * 3 + 100))
|
| 12 |
+
assert len(windows) == 3
|
| 13 |
+
assert all(len(w) == WINDOW_SAMPLES for w in windows)
|
| 14 |
+
|
| 15 |
+
|
| 16 |
+
def test_constant_input_settles_to_the_2048_centre():
|
| 17 |
+
window = firmware_windows([1800] * WINDOW_SAMPLES)[0]
|
| 18 |
+
assert abs(window[-1] - 2048) <= 1
|
| 19 |
+
assert all(0 <= v <= 4095 for v in window)
|
| 20 |
+
|
| 21 |
+
|
| 22 |
+
def test_scaling_keeps_clipped_device_peaks_off_the_rails():
|
| 23 |
+
# Raw device-like window: baseline 1774 with R-peaks clipped at the ADC limit
|
| 24 |
+
raw = [1774] * WINDOW_SAMPLES
|
| 25 |
+
for beat in range(100, WINDOW_SAMPLES, 210):
|
| 26 |
+
raw[beat - 2:beat + 3] = [2600, 3600, 4095, 3600, 2600]
|
| 27 |
+
scaled = firmware_windows(raw)[0]
|
| 28 |
+
unscaled = firmware_windows(raw, scale=1.0)[0]
|
| 29 |
+
assert max(scaled) < 4093
|
| 30 |
+
assert max(unscaled) >= 4093
|
| 31 |
+
|
| 32 |
+
|
| 33 |
+
def test_matches_values_in_device_garbage_fixture_range():
|
| 34 |
+
# The firmware output is 12-bit, so it must be a valid VitalsCreate payload
|
| 35 |
+
path = os.path.join(os.path.dirname(__file__), "fixtures", "device_garbage.json")
|
| 36 |
+
raw = json.load(open(path))
|
| 37 |
+
windows = raw["windows"] if isinstance(raw, dict) else raw
|
| 38 |
+
samples = [v for w in windows for v in w["ecg_samples"]]
|
| 39 |
+
out = firmware_windows(samples)
|
| 40 |
+
assert out and all(0 <= v <= 4095 for w in out for v in w)
|
tests/test_vitals_source.py
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from datetime import datetime
|
| 2 |
+
|
| 3 |
+
from bson import ObjectId
|
| 4 |
+
|
| 5 |
+
from app.models.vitals import VitalsCreate
|
| 6 |
+
from app.routes.vitals import _vitals_doc_to_response
|
| 7 |
+
|
| 8 |
+
PLAYBACK_SOURCE = "PTB-XL #8473 · cardiologist label: MI"
|
| 9 |
+
|
| 10 |
+
|
| 11 |
+
def _payload(**overrides):
|
| 12 |
+
payload = {
|
| 13 |
+
"device_id": "PLAYBACK_PTBXL",
|
| 14 |
+
"timestamp": 1760000000,
|
| 15 |
+
"heart_rate_bpm": 71.3,
|
| 16 |
+
"spo2_percent": 0,
|
| 17 |
+
"ecg_samples": [2048] * 2500,
|
| 18 |
+
}
|
| 19 |
+
payload.update(overrides)
|
| 20 |
+
return payload
|
| 21 |
+
|
| 22 |
+
|
| 23 |
+
def _doc(**overrides):
|
| 24 |
+
doc = {
|
| 25 |
+
"_id": ObjectId(),
|
| 26 |
+
"device_id": "PLAYBACK_PTBXL",
|
| 27 |
+
"timestamp": datetime(2026, 10, 9),
|
| 28 |
+
"heart_rate_bpm": 71.3,
|
| 29 |
+
"spo2_percent": 0,
|
| 30 |
+
"ecg_lead_off": False,
|
| 31 |
+
"ecg_samples": [2048] * 2500,
|
| 32 |
+
"created_at": datetime(2026, 10, 9),
|
| 33 |
+
}
|
| 34 |
+
doc.update(overrides)
|
| 35 |
+
return doc
|
| 36 |
+
|
| 37 |
+
|
| 38 |
+
def test_upload_accepts_optional_source_label():
|
| 39 |
+
assert VitalsCreate(**_payload(source=PLAYBACK_SOURCE)).source == PLAYBACK_SOURCE
|
| 40 |
+
assert VitalsCreate(**_payload()).source is None
|
| 41 |
+
|
| 42 |
+
|
| 43 |
+
def test_response_includes_source_label():
|
| 44 |
+
assert _vitals_doc_to_response(_doc(source=PLAYBACK_SOURCE)).source == PLAYBACK_SOURCE
|
| 45 |
+
|
| 46 |
+
|
| 47 |
+
def test_response_source_is_none_for_live_device_docs():
|
| 48 |
+
assert _vitals_doc_to_response(_doc(device_id="ESP32_21E5F0")).source is None
|