GitHub Actions commited on
Commit
314b374
·
1 Parent(s): 338ec2d

Deploy v0.0.1 from GitHub Actions

Browse files
.env.example ADDED
@@ -0,0 +1,6 @@
 
 
 
 
 
 
 
1
+ MONGODB_URI=mongodb+srv://username:password@cluster.mongodb.net/
2
+ DATABASE_NAME=cardiac_monitor
3
+ JWT_SECRET=change-this-to-a-random-secret-key
4
+ JWT_ALGORITHM=HS256
5
+ JWT_EXPIRY_HOURS=24
6
+ API_KEY=your-device-api-key-here
.gitattributes DELETED
@@ -1,35 +0,0 @@
1
- *.7z filter=lfs diff=lfs merge=lfs -text
2
- *.arrow filter=lfs diff=lfs merge=lfs -text
3
- *.bin filter=lfs diff=lfs merge=lfs -text
4
- *.bz2 filter=lfs diff=lfs merge=lfs -text
5
- *.ckpt filter=lfs diff=lfs merge=lfs -text
6
- *.ftz filter=lfs diff=lfs merge=lfs -text
7
- *.gz filter=lfs diff=lfs merge=lfs -text
8
- *.h5 filter=lfs diff=lfs merge=lfs -text
9
- *.joblib filter=lfs diff=lfs merge=lfs -text
10
- *.lfs.* filter=lfs diff=lfs merge=lfs -text
11
- *.mlmodel filter=lfs diff=lfs merge=lfs -text
12
- *.model filter=lfs diff=lfs merge=lfs -text
13
- *.msgpack filter=lfs diff=lfs merge=lfs -text
14
- *.npy filter=lfs diff=lfs merge=lfs -text
15
- *.npz filter=lfs diff=lfs merge=lfs -text
16
- *.onnx filter=lfs diff=lfs merge=lfs -text
17
- *.ot filter=lfs diff=lfs merge=lfs -text
18
- *.parquet filter=lfs diff=lfs merge=lfs -text
19
- *.pb filter=lfs diff=lfs merge=lfs -text
20
- *.pickle filter=lfs diff=lfs merge=lfs -text
21
- *.pkl filter=lfs diff=lfs merge=lfs -text
22
- *.pt filter=lfs diff=lfs merge=lfs -text
23
- *.pth filter=lfs diff=lfs merge=lfs -text
24
- *.rar filter=lfs diff=lfs merge=lfs -text
25
- *.safetensors filter=lfs diff=lfs merge=lfs -text
26
- saved_model/**/* filter=lfs diff=lfs merge=lfs -text
27
- *.tar.* filter=lfs diff=lfs merge=lfs -text
28
- *.tar filter=lfs diff=lfs merge=lfs -text
29
- *.tflite filter=lfs diff=lfs merge=lfs -text
30
- *.tgz filter=lfs diff=lfs merge=lfs -text
31
- *.wasm filter=lfs diff=lfs merge=lfs -text
32
- *.xz filter=lfs diff=lfs merge=lfs -text
33
- *.zip filter=lfs diff=lfs merge=lfs -text
34
- *.zst filter=lfs diff=lfs merge=lfs -text
35
- *tfevents* filter=lfs diff=lfs merge=lfs -text
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
Dockerfile CHANGED
@@ -13,16 +13,21 @@ RUN pip install --no-cache-dir -r requirements.txt
13
  COPY app/ app/
14
  COPY ml_src/ ml_src/
15
 
16
- # Download ML models from GitHub LFS (avoids LFS issues on HF Spaces)
17
- RUN mkdir -p ml_models && \
18
- apt-get update && apt-get install -y --no-install-recommends curl && \
19
- curl -L -o ml_models/ecgfounder_best.pt \
20
- "https://media.githubusercontent.com/media/Sanuka23/cardiac-monitor/main/backend/ml_models/ecgfounder_best.pt" && \
21
- curl -L -o ml_models/xgboost_cardiac.joblib \
22
- "https://media.githubusercontent.com/media/Sanuka23/cardiac-monitor/main/backend/ml_models/xgboost_cardiac.joblib" && \
23
- apt-get purge -y curl && apt-get autoremove -y && rm -rf /var/lib/apt/lists/* && \
24
- echo "Model sizes:" && ls -lh ml_models/
 
 
 
 
 
25
 
26
  EXPOSE 7860
27
 
28
- CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "7860"]
 
13
  COPY app/ app/
14
  COPY ml_src/ ml_src/
15
 
16
+ # Copy model files if they're Git LFS pointers, download the real files from GitHub
17
+ COPY ml_models/ ml_models/
18
+ RUN MODEL_SIZE=$(wc -c < ml_models/ecgfounder_best.pt) && \
19
+ if [ "$MODEL_SIZE" -lt 1000 ]; then \
20
+ echo "Detected LFS pointers — downloading real model files from GitHub..." && \
21
+ apt-get update && apt-get install -y --no-install-recommends curl && \
22
+ curl -L -o ml_models/ecgfounder_best.pt \
23
+ "https://media.githubusercontent.com/media/Sanuka23/cardiac-monitor/main/backend/ml_models/ecgfounder_best.pt" && \
24
+ curl -L -o ml_models/xgboost_cardiac.joblib \
25
+ "https://media.githubusercontent.com/media/Sanuka23/cardiac-monitor/main/backend/ml_models/xgboost_cardiac.joblib" && \
26
+ apt-get purge -y curl && apt-get autoremove -y && rm -rf /var/lib/apt/lists/*; \
27
+ else \
28
+ echo "Model files are real (not LFS pointers) — no download needed"; \
29
+ fi
30
 
31
  EXPOSE 7860
32
 
33
+ CMD uvicorn app.main:app --host 0.0.0.0 --port ${PORT:-7860}
README.md CHANGED
@@ -12,12 +12,139 @@ short_description: Cardiac monitoring API with ML risk prediction
12
 
13
  # Cardiac Monitor API
14
 
15
- FastAPI backend for ESP32 Heart Rate, SpO2 & ECG cardiac monitoring system with ML-based risk prediction.
16
 
17
- ## Endpoints
18
 
19
- - `GET /api/v1/health` — Health check
20
- - `POST /api/v1/auth/register` — Register
21
- - `POST /api/v1/auth/login` Login (JWT)
22
- - `GET /api/v1/vitals/{device_id}` — Vitals history
23
- - `GET /api/v1/predictions/{device_id}` — Risk predictions
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
12
 
13
  # Cardiac Monitor API
14
 
15
+ FastAPI backend for the ESP32 cardiac monitoring system. Handles user authentication, device management, vitals storage, and ML-based cardiac risk prediction.
16
 
17
+ ## Live Deployment
18
 
19
+ **URL**: `https://sanuka0523-cardiac-monitor-api.hf.space`
20
+
21
+ Hosted on Hugging Face Spaces (Docker SDK). The API docs are available at `/docs` (Swagger UI).
22
+
23
+ ## Tech Stack
24
+
25
+ - **Framework**: FastAPI 0.115
26
+ - **Database**: MongoDB Atlas via Motor (async)
27
+ - **Auth**: JWT (python-jose) + bcrypt password hashing
28
+ - **ML**: PyTorch (ECGFounder) + XGBoost ensemble
29
+ - **Signal Processing**: NeuroKit2, SciPy
30
+
31
+ ## API Reference
32
+
33
+ ### Authentication
34
+
35
+ | Method | Path | Body | Response |
36
+ |--------|------|------|----------|
37
+ | POST | `/api/v1/auth/register` | `{email, password, name}` | `{access_token, token_type}` |
38
+ | POST | `/api/v1/auth/login` | Form: `username, password` | `{access_token, token_type}` |
39
+ | GET | `/api/v1/auth/me` | — | User object |
40
+ | PUT | `/api/v1/auth/profile` | `{health_profile: {...}}` | Updated user |
41
+
42
+ ### Devices
43
+
44
+ | Method | Path | Body | Response |
45
+ |--------|------|------|----------|
46
+ | POST | `/api/v1/devices/register` | `{device_id}` | Device object |
47
+ | GET | `/api/v1/devices` | — | List of devices |
48
+
49
+ ### Vitals (ESP32 uploads)
50
+
51
+ | Method | Path | Auth | Body |
52
+ |--------|------|------|------|
53
+ | POST | `/api/v1/vitals` | API Key | `{device_id, timestamp, ecg_samples, heart_rate_bpm, spo2_percent, ...}` |
54
+ | GET | `/api/v1/vitals/{device_id}` | JWT | Vitals history (paginated) |
55
+ | GET | `/api/v1/vitals/{device_id}/latest` | JWT | Latest vitals reading |
56
+
57
+ ### Predictions
58
+
59
+ | Method | Path | Auth | Response |
60
+ |--------|------|------|----------|
61
+ | GET | `/api/v1/predictions/{device_id}/latest` | JWT | Latest risk prediction |
62
+ | GET | `/api/v1/predictions/{device_id}` | JWT | Prediction history |
63
+
64
+ ## Environment Variables
65
+
66
+ | Variable | Description | Required |
67
+ |----------|-------------|----------|
68
+ | `MONGODB_URI` | MongoDB Atlas connection string | Yes |
69
+ | `DATABASE_NAME` | Database name (default: `cardiac_monitor`) | No |
70
+ | `JWT_SECRET` | Secret key for JWT signing | Yes |
71
+ | `API_KEY` | API key for ESP32 device auth | Yes |
72
+
73
+ ## Database Collections
74
+
75
+ | Collection | Purpose | Key Fields |
76
+ |------------|---------|------------|
77
+ | `users` | User accounts + health profiles | email, password_hash, health_profile |
78
+ | `devices` | Registered ESP32 devices | device_id, owner_user_id |
79
+ | `vitals` | Raw vitals readings | device_id, timestamp, ecg_samples, heart_rate_bpm |
80
+ | `predictions` | ML risk predictions | vitals_id, risk_score, risk_label, confidence |
81
+
82
+ ## ML Pipeline
83
+
84
+ On each vitals upload, the backend runs an ensemble prediction:
85
+
86
+ 1. **ECGFounder** (PyTorch): Deep learning model extracts features from raw ECG waveform
87
+ 2. **Feature Engineering**: 14 clinical features (HRV, QRS duration, signal quality, etc.)
88
+ 3. **XGBoost**: Gradient-boosted classifier on combined features
89
+ 4. **Ensemble**: Weighted combination produces final risk score (0.0-1.0) and label
90
+
91
+ Risk labels: `normal`, `low`, `moderate`, `elevated`, `high`
92
+
93
+ ## Local Development
94
+
95
+ ```bash
96
+ cd backend
97
+ pip install -r requirements.txt
98
+
99
+ # Set environment variables
100
+ export MONGODB_URI="mongodb+srv://..."
101
+ export JWT_SECRET="your-secret"
102
+ export API_KEY="your-api-key"
103
+
104
+ # Run
105
+ uvicorn app.main:app --reload --port 8000
106
+ ```
107
+
108
+ ## Docker Deployment
109
+
110
+ ```bash
111
+ docker build -t cardiac-api .
112
+ docker run -p 7860:7860 \
113
+ -e MONGODB_URI="..." \
114
+ -e JWT_SECRET="..." \
115
+ -e API_KEY="..." \
116
+ cardiac-api
117
+ ```
118
+
119
+ ## Project Structure
120
+
121
+ ```
122
+ backend/
123
+ ├── app/
124
+ │ ├── main.py # FastAPI app, CORS, router mounting
125
+ │ ├── config.py # Pydantic settings (env vars)
126
+ │ ├── database.py # Motor client, index creation
127
+ │ ├── middleware/
128
+ │ │ └── auth.py # JWT + API key verification
129
+ │ ├── models/ # Pydantic request/response models
130
+ │ │ ├── user.py
131
+ │ │ ├── device.py
132
+ │ │ ├── vitals.py
133
+ │ │ └── prediction.py
134
+ │ ├── routes/ # API endpoint handlers
135
+ │ │ ├── auth.py
136
+ │ │ ├── devices.py
137
+ │ │ ├── health.py
138
+ │ │ ├── vitals.py
139
+ │ │ └── predictions.py
140
+ │ └── services/
141
+ │ └── ml_service.py # ML model loading + prediction
142
+ ├── ml_src/
143
+ │ ├── ecg_foundation.py # ECGFounder model definition
144
+ │ └── feature_engineer.py # Clinical feature extraction
145
+ ├── ml_models/ # Trained model files (LFS)
146
+ │ ├── ecgfounder_best.pt
147
+ │ └── xgboost_cardiac.joblib
148
+ ├── Dockerfile
149
+ └── requirements.txt
150
+ ```
app/database.py CHANGED
@@ -27,7 +27,6 @@ async def connect_db(uri: str, db_name: str):
27
 
28
 
29
  async def close_db():
30
- global client
31
  if client:
32
  client.close()
33
 
 
27
 
28
 
29
  async def close_db():
 
30
  if client:
31
  client.close()
32
 
app/main.py CHANGED
@@ -40,6 +40,7 @@ app.add_middleware(
40
  allow_headers=["*"],
41
  )
42
 
 
43
  @app.get("/", include_in_schema=False)
44
  async def root():
45
  return RedirectResponse(url="/docs")
 
40
  allow_headers=["*"],
41
  )
42
 
43
+
44
  @app.get("/", include_in_schema=False)
45
  async def root():
46
  return RedirectResponse(url="/docs")
app/models/prediction.py CHANGED
@@ -1,5 +1,4 @@
1
  from pydantic import BaseModel, Field
2
- from typing import Optional
3
  from datetime import datetime
4
 
5
 
 
1
  from pydantic import BaseModel, Field
 
2
  from datetime import datetime
3
 
4
 
app/models/user.py CHANGED
@@ -1,4 +1,4 @@
1
- from pydantic import BaseModel, EmailStr, Field
2
  from typing import List, Optional
3
  from datetime import datetime
4
 
 
1
+ from pydantic import BaseModel, Field
2
  from typing import List, Optional
3
  from datetime import datetime
4
 
app/services/feature_extractor.py CHANGED
@@ -10,7 +10,7 @@ import sys
10
  ML_SRC = os.path.join(os.path.dirname(__file__), "..", "..", "ml_src")
11
  sys.path.insert(0, ML_SRC)
12
 
13
- from feature_extractor import (
14
  extract_ecg_features,
15
  features_to_array,
16
  FEATURE_NAMES,
 
10
  ML_SRC = os.path.join(os.path.dirname(__file__), "..", "..", "ml_src")
11
  sys.path.insert(0, ML_SRC)
12
 
13
+ from feature_extractor import ( # noqa: E402
14
  extract_ecg_features,
15
  features_to_array,
16
  FEATURE_NAMES,
app/services/ml_service.py CHANGED
@@ -15,8 +15,8 @@ BACKEND_ROOT = os.path.join(os.path.dirname(__file__), "..", "..")
15
  ML_SRC = os.path.join(BACKEND_ROOT, "ml_src")
16
  sys.path.insert(0, ML_SRC)
17
 
18
- from net1d import Net1D
19
- from feature_extractor import extract_ecg_features, features_to_array, FEATURE_NAMES
20
 
21
  # Model paths — bundled in backend/ml_models/
22
  MODEL_DIR = os.path.join(BACKEND_ROOT, "ml_models")
@@ -83,7 +83,7 @@ def load_models():
83
  use_do=False,
84
  n_classes=1,
85
  )
86
- state_dict = torch.load(ecg_path, map_location=_device, weights_only=False)
87
  _ecg_model.load_state_dict(state_dict)
88
  _ecg_model.to(_device)
89
  _ecg_model.eval()
 
15
  ML_SRC = os.path.join(BACKEND_ROOT, "ml_src")
16
  sys.path.insert(0, ML_SRC)
17
 
18
+ from net1d import Net1D # noqa: E402
19
+ from feature_extractor import extract_ecg_features, features_to_array, FEATURE_NAMES # noqa: E402
20
 
21
  # Model paths — bundled in backend/ml_models/
22
  MODEL_DIR = os.path.join(BACKEND_ROOT, "ml_models")
 
83
  use_do=False,
84
  n_classes=1,
85
  )
86
+ state_dict = torch.load(ecg_path, map_location=_device, weights_only=False) # nosec B614
87
  _ecg_model.load_state_dict(state_dict)
88
  _ecg_model.to(_device)
89
  _ecg_model.eval()
ml_models/ecgfounder_best.pt ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:b632fd76127099d8aca8001bf2a816fd0113b65b4fea9a32fbabbeda0a74319a
3
+ size 123072541
ml_models/xgboost_cardiac.joblib ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:3f6a0c12e66bd9ea73e293602cd85ea1e9195fb5f98200dcb9c4595409341613
3
+ size 769646
ml_src/README.md ADDED
@@ -0,0 +1,97 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # ML Pipeline
2
+
3
+ Cardiac risk prediction using an ensemble of ECGFounder (deep learning) and XGBoost (gradient boosting).
4
+
5
+ ## Model Architecture
6
+
7
+ ```
8
+ Raw ECG (1000 samples @ 100Hz)
9
+
10
+ ├──> ECGFounder (1D CNN)──> 128-dim embedding
11
+ │ │
12
+ ├──> Feature Engineering ────────>├──> 14 clinical features
13
+ │ (NeuroKit2 + SciPy) │
14
+ │ v
15
+ │ XGBoost Classifier
16
+ │ │
17
+ └── Ensemble ────────────────────>│──> Risk Score (0.0-1.0)
18
+ (weighted average) Risk Label
19
+ ```
20
+
21
+ ### ECGFounder
22
+
23
+ - **Architecture**: 1D convolutional neural network (`net1d.py`)
24
+ - **Input**: 1000 ECG samples (10s window at 100Hz), bandpass filtered 0.5-40Hz
25
+ - **Output**: 128-dimensional feature embedding
26
+ - **Model file**: `ml_models/ecgfounder_best.pt` (117 MB)
27
+
28
+ ### XGBoost Classifier
29
+
30
+ - **Input**: 14 features (ECG embedding + clinical features)
31
+ - **Output**: Binary risk probability
32
+ - **Model file**: `ml_models/xgboost_cardiac.joblib` (752 KB)
33
+
34
+ ## Feature Engineering
35
+
36
+ The `feature_extractor.py` module computes clinical features from raw ECG:
37
+
38
+ | Feature | Source | Description |
39
+ |---------|--------|-------------|
40
+ | Heart Rate | Input | BPM from MAX30100 |
41
+ | SpO2 | Input | Blood oxygen percentage |
42
+ | HRV (SDNN) | NeuroKit2 | Heart rate variability |
43
+ | HRV (RMSSD) | NeuroKit2 | Root mean square of successive RR differences |
44
+ | QRS Duration | NeuroKit2 | Ventricular depolarization time |
45
+ | PR Interval | NeuroKit2 | Atrial-ventricular conduction |
46
+ | Signal Quality | SciPy | SNR estimate of ECG signal |
47
+ | Mean RR | Computed | Average R-R interval |
48
+ | RR Irregularity | Computed | Coefficient of variation of RR |
49
+ | Age | Profile | User age (if available) |
50
+ | Sex | Profile | Binary encoded |
51
+ | BMI | Profile | Computed from height/weight |
52
+ | Comorbidity Score | Profile | Count of risk factors |
53
+ | HR Deviation | History | Z-score vs 24h baseline |
54
+
55
+ ## Inference Flow
56
+
57
+ 1. Vitals uploaded to `POST /api/v1/vitals`
58
+ 2. If ECG leads are connected and >= 100 samples:
59
+ a. Load user health profile from database
60
+ b. Compute 24h and 7d historical baselines
61
+ c. Extract features from ECG waveform
62
+ d. Run ECGFounder for deep features
63
+ e. Combine and run XGBoost
64
+ f. Store prediction in `predictions` collection
65
+ 3. Return vitals + prediction in API response
66
+
67
+ ## Risk Labels
68
+
69
+ | Score Range | Label | Description |
70
+ |-------------|-------|-------------|
71
+ | 0.00 - 0.20 | normal | No concerning patterns |
72
+ | 0.20 - 0.40 | low | Minor irregularities |
73
+ | 0.40 - 0.60 | moderate | Some risk indicators |
74
+ | 0.60 - 0.80 | elevated | Multiple risk factors |
75
+ | 0.80 - 1.00 | high | Significant cardiac risk |
76
+
77
+ ## Files
78
+
79
+ ```
80
+ ml_src/
81
+ ├── __init__.py
82
+ ├── net1d.py # ECGFounder 1D CNN model definition
83
+ └── feature_extractor.py # Clinical feature extraction
84
+
85
+ ml_models/
86
+ ├── ecgfounder_best.pt # Trained ECGFounder weights (Git LFS)
87
+ └── xgboost_cardiac.joblib # Trained XGBoost model (Git LFS)
88
+ ```
89
+
90
+ ## Dependencies
91
+
92
+ - `torch` (CPU-only, installed from pytorch.org/whl/cpu)
93
+ - `xgboost >= 2.0.0`
94
+ - `neurokit2 >= 0.2.7`
95
+ - `scipy >= 1.14.1`
96
+ - `numpy >= 1.26.4`
97
+ - `joblib >= 1.3.0`
render.yaml ADDED
@@ -0,0 +1,21 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ services:
2
+ - type: web
3
+ name: cardiac-monitor-api
4
+ runtime: docker
5
+ dockerfilePath: ./Dockerfile
6
+ plan: starter
7
+ region: oregon
8
+ healthCheckPath: /api/v1/health
9
+ envVars:
10
+ - key: MONGODB_URI
11
+ sync: false
12
+ - key: DATABASE_NAME
13
+ value: cardiac_monitor
14
+ - key: JWT_SECRET
15
+ sync: false
16
+ - key: JWT_ALGORITHM
17
+ value: HS256
18
+ - key: JWT_EXPIRY_HOURS
19
+ value: "24"
20
+ - key: API_KEY
21
+ sync: false
requirements.txt CHANGED
@@ -1,15 +1,15 @@
1
- fastapi==0.115.6
2
- uvicorn[standard]==0.34.0
3
- motor==3.6.0
4
  pymongo>=4.9,<4.10
5
- pydantic==2.10.0
6
- pydantic-settings==2.7.0
7
- python-jose[cryptography]==3.3.0
8
- bcrypt==4.2.0
9
- python-multipart==0.0.18
10
  numpy==1.26.4
11
  scipy==1.14.1
12
- httpx==0.28.1
13
  xgboost>=2.0.0
14
  neurokit2>=0.2.7
15
  joblib>=1.3.0
 
1
+ fastapi>=0.115.6
2
+ uvicorn[standard]>=0.34.0
3
+ motor>=3.6.0
4
  pymongo>=4.9,<4.10
5
+ pydantic>=2.10.0
6
+ pydantic-settings>=2.7.0
7
+ python-jose[cryptography]>=3.5.0
8
+ bcrypt>=4.2.0
9
+ python-multipart>=0.0.22
10
  numpy==1.26.4
11
  scipy==1.14.1
12
+ httpx>=0.28.1
13
  xgboost>=2.0.0
14
  neurokit2>=0.2.7
15
  joblib>=1.3.0