IMU-based stability classification for incline dumbbell bench press. Uses Apple Watch wrist motion data to detect reps and score each set as CLEAN, BORDERLINE, or SHAKY.
from stability_analysis import classify
result = classify("path/to/WristMotion.csv", expected_reps=8)
result["verdict"] # CLEAN | BORDERLINE | SHAKY
result["score"] # 0.0–1.0 instability score
result["confidence"] # 0.0–1.0
result["n_reps"] # reps detected
result["reasons"] # human-readable explanations
result["metrics"] # all computed metricsBatch mode (auto-discovers W<weight>_..._R<reps> folders, exports to output/):
python stability_analysis.pystability_analysis.py # Main module (importable + batch CLI)
weak_point_report.py # Per-phase weak point analysis
api.py # FastAPI REST API
convert_dataset.py # Convert dataset/ CSVs to WristMotion format
example.py # Single-CSV example
stability_analysis.ipynb # Notebook with visualizations
data/
labeled-self/ # W<weight>_..._R<reps>-<timestamp>/ folders
dataset/ # Flat CSVs (different column naming)
output/
rep_features.csv # Per-rep metrics
set_summary.csv # Set-level verdicts
- Window detection — longest contiguous region with pitch > -0.3 rad (filters unrack/rack), trimmed 1s each end
- Rep segmentation — Savitzky-Golay smoothed pitch,
find_peakson troughs/peaks, trough-to-trough = 1 rep - Parameter derivation —
distfrom duration/reps/sample_rate,promfrom pitch range (floor 0.03) - Tiered scoring — weighted sum across metric tiers (below)
| Tier | Weight | Metrics | Signal |
|---|---|---|---|
| T1 | 60% | RotY variance, trough pitch std | Direct instability |
| T2 | 25% | ROM std, peak pitch std | Fatigue-confounded |
| T3 | 15% | Rep duration, concentric ratio std | Correlated |
| T4 | 0% | Acceleration tremor, path jerk | Tracked only |
Thresholds: >0.40 = SHAKY, >0.22 = BORDERLINE, else CLEAN
Confidence factors: tier agreement, rotY clarity (away from 0.065–0.075 ambiguous zone), rep count, tempo.
| Metric | Range | Description |
|---|---|---|
instability_score |
0–1 | Set-level instability (tiered model) |
score (per-rep) |
0–10 | Rep stability (10 = best). 50% rotY, 25% tremor, 25% jerk |
rotY_var |
0+ | Lateral gyro variance — primary instability signal |
acc_tremor |
0+ | High-freq (>3 Hz) acceleration RMS |
path_jerk |
0+ | Pitch 2nd derivative RMS |
confidence |
0–1 | Verdict decisiveness |
uvicorn api:app --reload --port 8000
# Swagger docs: http://localhost:8000/docs| Method | Path | Description |
|---|---|---|
| GET | /sets |
List discovered sets |
| GET | /sets/{index}/report |
Weak-point report for one set |
| GET | /reports |
All weak-point reports |
| GET | /analyze?file=...&reps=... |
Analyze CSV by path |
| POST | /upload?reps=... |
Upload and analyze a CSV |
# Upload example
curl -X POST "http://localhost:8000/upload?reps=8" -F "file=@WristMotion.csv"JSON response structure
WristMotion.csv columns (100 Hz sample rate):
| Column | Description |
|---|---|
time |
Timestamp (ns) |
seconds_elapsed |
Time since start |
rotationRateX/Y/Z |
Gyroscope (rad/s) |
gravityX/Y/Z |
Gravity vector |
accelerationX/Y/Z |
User acceleration |
quaternionW/X/Y/Z |
Orientation quaternion |
pitch, roll, yaw |
Euler angles (Apple convention) |
Alternative format (data/dataset/ CSVs) — convert first:
python convert_dataset.py data/dataset/input.csv data/converted.csvnumpy pandas scipy -- core
fastapi uvicorn python-multipart -- API
matplotlib seaborn -- notebook only
- Rep count must be provided — unsupervised counting is unreliable (single-arm sub-movements overlap rep frequencies)
- Incline DB bench only — thresholds and window detection are exercise-specific
- Apple Watch convention — pitch/roll/yaw differ from standard ZYX Euler angles
{ "set_info": { "index", "folder", "label", "arm", "weight", "expected_reps" }, "n_reps": 8, "verdict": { "label": "SHAKY", // CLEAN | BORDERLINE | SHAKY "instability_score": 0.524, "confidence": 0.71, "reasons": ["high lateral wobble (rotY_var=0.1014)", "..."] }, "set_metrics": { "avg_rotY_var", "trough_pitch_std", // T1 "rom_std", "peak_pitch_std", // T2 "avg_duration", "con_ratio_std", // T3 "avg_acc_tremor", "avg_path_jerk", // T4 "avg_rom" }, "avg_score": 5.4, // mean per-rep score (0–10) "reps": [{ "rep": 1, "score": 6.6, "duration": 3.077, "rom": 0.338, "con_ratio": 0.848, "worst_phase": "lockout", "phases": { "concentric|lockout|eccentric|bottom": { "duration", "rotY_var", "acc_tremor", "path_jerk", "pitch_start", "pitch_end", "pitch_range" } } }], "phase_summary": { "concentric|lockout|eccentric|bottom": { "mean_duration", "std_duration", "mean_rotY_var", "std_rotY_var", "mean_acc_tremor", "std_acc_tremor", "mean_path_jerk", "std_path_jerk" } }, "n_flags": 0, "n_warnings": 4, "findings": [{ "severity": "flag|warning", "phase": "concentric", "message": "...", "evidence": { ... }, "affected_reps": [1, 5] }], "summary": "Minor concerns in concentric, eccentric." }