Sanuka0523 commited on
Commit
3978c5e
·
verified ·
1 Parent(s): 4648190

Deploy ecg-only 332caae0: ECG-derived HR, source labels

Browse files
.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