Ishan2607 commited on
Commit
d5752a5
Β·
verified Β·
1 Parent(s): 10299b9

Add complete SmartChair AI codebase: ML models, firmware, cloud, data collection

Browse files
smart_chair/HARDWARE_BOM.md ADDED
@@ -0,0 +1,103 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # SmartChair Hardware Bill of Materials (BOM)
2
+
3
+ ## Core Components
4
+
5
+ | Component | Model | Qty | Purpose | Est. Cost |
6
+ |-----------|-------|-----|---------|-----------|
7
+ | **Microcontroller** | ESP32-S3-DevKitC-1 (8MB PSRAM) | 1 | Main MCU, edge ML inference | $8-12 |
8
+ | **IMU Sensor** | MPU6050 (or ICM-42688-P for better accuracy) | 1 | 6-axis accelerometer + gyroscope | $2-5 |
9
+ | **Load Cells** | 50kg Half-Bridge Strain Gauge | 4 | Weight distribution at chair corners | $4-8 (Γ—4) |
10
+ | **Load Cell ADC** | HX711 24-bit ADC | 4 | Amplify load cell signals | $1-2 (Γ—4) |
11
+ | **Thermal Sensor** | AMG8833 8Γ—8 IR Grid-EYE | 1 | Human presence detection | $15-25 |
12
+ | **Edge Computer** | Raspberry Pi 4B (4GB) | 1 | Complex ML + cloud gateway | $45-55 |
13
+
14
+ ## Supporting Components
15
+
16
+ | Component | Model | Qty | Purpose | Est. Cost |
17
+ |-----------|-------|-----|---------|-----------|
18
+ | **Status LED** | WS2812B RGB LED Strip (8 LEDs) | 1 | Visual posture feedback | $2 |
19
+ | **Vibration Motor** | 3V coin vibration motor | 1 | Haptic alerts | $1 |
20
+ | **Power Supply** | 5V 3A USB-C adapter | 1 | Power ESP32 + sensors | $5 |
21
+ | **RPi Power** | 5V 3A USB-C (official RPi) | 1 | Power Raspberry Pi | $8 |
22
+ | **Level Shifter** | 3.3V ↔ 5V bi-directional | 1 | I2C level conversion | $1 |
23
+ | **Breadboard/PCB** | Custom PCB or prototype board | 1 | Circuit assembly | $5-15 |
24
+ | **Wiring** | Jumper wires + JST connectors | 1 set | Sensor connections | $3 |
25
+ | **MicroSD Card** | 32GB Class 10 | 1 | RPi OS + local data storage | $5 |
26
+
27
+ ## Optional Enhancements
28
+
29
+ | Component | Model | Qty | Purpose | Est. Cost |
30
+ |-----------|-------|-----|---------|-----------|
31
+ | **Better IMU** | ICM-42688-P | 1 | Higher accuracy, lower noise | $8-12 |
32
+ | **BLE Module** | Built into ESP32-S3 | β€” | Mobile app connectivity | Included |
33
+ | **Speaker** | Small 8Ξ© 0.5W speaker | 1 | Audio break reminders | $1 |
34
+ | **OLED Display** | SSD1306 0.96" I2C | 1 | Local status display | $3 |
35
+ | **Enclosure** | 3D printed or laser-cut | 1 | Professional housing | $5-15 |
36
+
37
+ ## Total Estimated Cost: **$110-165**
38
+
39
+ ## Wiring Diagram
40
+
41
+ ```
42
+ β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
43
+ β”‚ ESP32-S3 β”‚
44
+ β”‚ β”‚
45
+ MPU6050 ──I2C───►│ GPIO21 (SDA) β”‚
46
+ AMG8833 ──I2C───►│ GPIO22 (SCL) β”‚
47
+ β”‚ β”‚
48
+ HX711_FL ────────►│ GPIO32/33 β”‚
49
+ HX711_FR ────────►│ GPIO25/26 β”‚
50
+ HX711_RL ────────►│ GPIO27/14 β”‚
51
+ HX711_RR ────────►│ GPIO12/13 β”‚
52
+ β”‚ β”‚
53
+ WS2812B ─────────►│ GPIO48 β”‚
54
+ Vibration Motor ──►│ GPIO4 (PWM) β”‚
55
+ β”‚ β”‚
56
+ RPi ──── UART ───►│ TX/RX β”‚
57
+ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
58
+ β”‚
59
+ WiFi/BLE
60
+ β”‚
61
+ β”Œβ”€β”€β”€β”€β”€β–Όβ”€β”€β”€β”€β”€β”€β”
62
+ β”‚ Raspberry β”‚
63
+ β”‚ Pi 4B β”‚
64
+ β”‚ │──── MQTT/TLS ───► AWS IoT Core
65
+ β”‚ Python ML β”‚
66
+ β”‚ Dashboard │──── HTTP ───► Local Web UI
67
+ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
68
+ ```
69
+
70
+ ## Load Cell Placement
71
+
72
+ ```
73
+ β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
74
+ β”‚ FRONT β”‚
75
+ β”‚ β”Œβ”€β”€β”€β”€β”€β” β”Œβ”€β”€β”€β”€β”€β” β”‚
76
+ β”‚ β”‚ FL β”‚ β”‚ FR β”‚ β”‚ FL = Front-Left Load Cell
77
+ β”‚ β””β”€β”€β”€β”€β”€β”˜ β””β”€β”€β”€β”€β”€β”˜ β”‚ FR = Front-Right Load Cell
78
+ β”‚ β”‚
79
+ β”‚ β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚
80
+ β”‚ β”‚ SEAT β”‚ β”‚ IMU mounted under seat center
81
+ β”‚ β”‚ (IMU) β”‚ β”‚ AMG8833 mounted on backrest
82
+ β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚
83
+ β”‚ β”‚
84
+ β”‚ β”Œβ”€β”€β”€β”€β”€β” β”Œβ”€β”€β”€β”€β”€β” β”‚
85
+ β”‚ β”‚ RL β”‚ β”‚ RR β”‚ β”‚ RL = Rear-Left Load Cell
86
+ β”‚ β””β”€β”€β”€β”€β”€β”˜ β””β”€β”€β”€β”€β”€β”˜ β”‚ RR = Rear-Right Load Cell
87
+ β”‚ BACK β”‚
88
+ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
89
+ ```
90
+
91
+ ## Assembly Notes
92
+
93
+ 1. **Load cells**: Mount at the 4 corners of the seat, between seat plate and chair frame. Use 3D-printed mounting brackets. Each cell connects to its own HX711 board.
94
+
95
+ 2. **IMU (MPU6050)**: Mount rigidly under the center of the seat using double-sided tape or screws. Ensure Z-axis points upward when seat is level. Calibrate by recording 10 seconds of data while seat is empty and level.
96
+
97
+ 3. **Thermal sensor (AMG8833)**: Mount on the backrest, facing the seated person. 60Β° FOV covers the upper body. Used only for presence detection (occupied/empty).
98
+
99
+ 4. **ESP32-S3**: Place in a small enclosure under the seat. Connect via I2C to IMU and thermal sensor, GPIO to HX711 boards.
100
+
101
+ 5. **Raspberry Pi**: Can be mounted under the seat or placed nearby. Connects to ESP32 via UART (USB-serial) or local WiFi.
102
+
103
+ 6. **Power**: USB-C power bank for portability, or wall adapter for fixed installation.
smart_chair/README.md ADDED
@@ -0,0 +1,208 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # πŸͺ‘ SmartChair AI β€” Intelligent Posture Monitoring System
2
+
3
+ **Real-time posture classification, spine risk prediction, and adaptive health coaching using IMU + load cell + thermal sensor fusion.**
4
+
5
+ [![Python 3.9+](https://img.shields.io/badge/Python-3.9+-blue.svg)](https://python.org)
6
+ [![TensorFlow](https://img.shields.io/badge/TensorFlow-2.x-orange.svg)](https://tensorflow.org)
7
+ [![ESP32](https://img.shields.io/badge/ESP32--S3-Firmware-green.svg)](https://espressif.com)
8
+ [![AWS IoT](https://img.shields.io/badge/AWS-IoT_Core-yellow.svg)](https://aws.amazon.com/iot-core/)
9
+
10
+ ---
11
+
12
+ ## 🎯 Features
13
+
14
+ | # | Feature | Status | Accuracy/Performance |
15
+ |---|---------|--------|---------------------|
16
+ | 1 | **Real-time posture classification** | βœ… | 95.48%+ ML accuracy (ensemble voting) |
17
+ | 2 | **Long-term spine risk prediction** | βœ… | RULA-based + exponential decay accumulator |
18
+ | 3 | **Personalised sitting behaviour model** | βœ… | Learns fatigue onset, break patterns per user |
19
+ | 4 | **Smart micro-break recommendations** | βœ… | Adaptive, score-based urgency (not fixed timer) |
20
+ | 5 | **Exercise suggestion system** | βœ… | 20+ exercises mapped to specific posture issues |
21
+ | 6 | **Hybrid sensor fusion** | βœ… | IMU + load cell + thermal (14-channel fusion) |
22
+ | 7 | **Fatigue & drowsiness detection** | βœ… | CoP variance + micro-movement analysis |
23
+ | 8 | **Daily/weekly posture score dashboard** | βœ… | 0-100 score with hourly breakdown + trends |
24
+ | 9 | **Injury risk alert system** | βœ… | Acute + chronic + trend-based alerts |
25
+ | 10 | **Cloud integration (AWS IoT Core)** | βœ… | MQTT/TLS, DynamoDB, Lambda scoring |
26
+ | 11 | **Multi-user recognition** | βœ… | Weight signature + k-NN (3-shot enrollment) |
27
+ | 12 | **Gamification system** | βœ… | Points, streaks, badges, leaderboard |
28
+
29
+ ## πŸ—οΈ Architecture
30
+
31
+ ```
32
+ β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
33
+ β”‚ SENSOR LAYER β”‚
34
+ β”‚ MPU6050 (50Hz) β”‚ 4Γ— HX711 Load Cells β”‚ AMG8833 β”‚
35
+ β”‚ 6-axis IMU β”‚ Weight Distribution β”‚ Thermal β”‚
36
+ β””β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”˜
37
+ β”‚ β”‚ β”‚
38
+ β–Ό β–Ό β–Ό
39
+ β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
40
+ β”‚ EDGE INFERENCE (ESP32-S3) β”‚
41
+ β”‚ Feature Extraction β†’ TFLite INT8 / Random Forest β”‚
42
+ β”‚ <30ms inference β”‚ <50KB model β”‚ 14 input channels β”‚
43
+ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
44
+ β”‚ UART / Local WiFi
45
+ β–Ό
46
+ β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
47
+ β”‚ INTELLIGENCE LAYER (Raspberry Pi) β”‚
48
+ β”‚ β”‚
49
+ β”‚ β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚
50
+ β”‚ β”‚ Ensemble β”‚ β”‚ RULA Risk β”‚ β”‚ Fatigue β”‚ β”‚
51
+ β”‚ β”‚ Classifier β”‚ β”‚ Scorer β”‚ β”‚ Detector β”‚ β”‚
52
+ β”‚ β”‚ (98%+ F1) β”‚ β”‚ (1-7 scale) β”‚ β”‚ (CoP+IMU) β”‚ β”‚
53
+ β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚
54
+ β”‚ β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚
55
+ β”‚ β”‚ User β”‚ β”‚ Break β”‚ β”‚ Exercise β”‚ β”‚
56
+ β”‚ β”‚ Recognition β”‚ β”‚ Engine β”‚ β”‚ Suggester β”‚ β”‚
57
+ β”‚ β”‚ (k-NN) β”‚ β”‚ (Adaptive) β”‚ β”‚ (Targeted) β”‚ β”‚
58
+ β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚
59
+ β”‚ β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚
60
+ β”‚ β”‚ Spine Risk β”‚ β”‚ Gamification β”‚ β”‚ Injury β”‚ β”‚
61
+ β”‚ β”‚ Predictor β”‚ β”‚ Engine β”‚ β”‚ Alerts β”‚ β”‚
62
+ β”‚ β”‚ (Long-term) β”‚ β”‚ (Points/Bge) β”‚ β”‚ (3-tier) β”‚ β”‚
63
+ β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€οΏ½οΏ½β”€β”€β”€β”€β”˜ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚
64
+ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
65
+ β”‚ MQTT / TLS
66
+ β–Ό
67
+ β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
68
+ β”‚ CLOUD LAYER (AWS) β”‚
69
+ β”‚ IoT Core β†’ DynamoDB (telemetry) β†’ Lambda (daily scores) β”‚
70
+ β”‚ β†’ SNS (critical alerts) β†’ S3 (raw data backup) β”‚
71
+ β”‚ β†’ API Gateway β†’ Dashboard Web App β”‚
72
+ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
73
+ ```
74
+
75
+ ## πŸ“Š ML Models
76
+
77
+ ### Posture Classifier (Primary)
78
+ - **Architecture**: Soft-voting ensemble (SVM + DecisionTree + MLP + XGBoost + RandomForest)
79
+ - **Basis**: [SitPose](https://arxiv.org/abs/2412.12216) (F1=98.2% on 7-class posture)
80
+ - **Input**: 119 hand-crafted features from 2.56s sliding windows (128 samples @ 50Hz)
81
+ - **Features**: IMU statistics, FFT spectral features, cross-axis correlations, CoP metrics, load distribution ratios
82
+ - **Edge deployment**: `micromlgen` β†’ C header for ESP32 (<5KB, <5ms inference)
83
+
84
+ ### MLSTM-FCN (Alternative/Deeper)
85
+ - **Architecture**: Parallel Conv1D-FCN + LSTM with Squeeze-Excite attention
86
+ - **Basis**: [MLSTM-FCN](https://arxiv.org/abs/1801.04503) + [FusionActNet](https://arxiv.org/abs/2310.02011)
87
+ - **Input**: (128, 14) sequential tensor (14 sensor channels Γ— 128 timesteps)
88
+ - **Edge deployment**: TFLite INT8 quantization (<50KB, <30ms on ESP32-S3)
89
+
90
+ ### Supporting Models
91
+ | Model | Method | Purpose |
92
+ |-------|--------|---------|
93
+ | RULA Scorer | Rule-based (ISO 11226) | Real-time ergonomic risk 1-7 |
94
+ | Spine Risk Predictor | Exponential decay accumulator | Long-term risk trajectory |
95
+ | Fatigue Detector | CoP variance + movement analysis | Fatigue/drowsiness classification |
96
+ | User Recognizer | k-NN on weight signatures | Multi-user identification |
97
+ | Break Engine | Weighted multi-factor scoring | Adaptive break timing |
98
+
99
+ ## πŸ”§ Hardware
100
+
101
+ **Total cost: ~$110-165**
102
+
103
+ See [HARDWARE_BOM.md](HARDWARE_BOM.md) for complete bill of materials and wiring diagram.
104
+
105
+ Key sensors:
106
+ - **IMU**: MPU6050 (6-axis, I2C, 50Hz) β€” mounted under seat center
107
+ - **Load Cells**: 4Γ— 50kg half-bridge with HX711 ADC β€” at chair corners
108
+ - **Thermal**: AMG8833 8Γ—8 IR grid β€” on backrest for presence detection
109
+ - **MCU**: ESP32-S3 (dual-core 240MHz, 8MB PSRAM, WiFi/BLE)
110
+ - **Gateway**: Raspberry Pi 4B (complex ML + cloud)
111
+
112
+ ## πŸš€ Quick Start
113
+
114
+ ### 1. Install Dependencies
115
+ ```bash
116
+ pip install numpy scipy scikit-learn xgboost tensorflow matplotlib pandas joblib
117
+ ```
118
+
119
+ ### 2. Run Demo (Synthetic Data)
120
+ ```bash
121
+ python -m smart_chair.main
122
+ ```
123
+
124
+ ### 3. Collect Real Data
125
+ ```bash
126
+ python -c "
127
+ from smart_chair.data_collection.protocol import DataCollectionSession
128
+ session = DataCollectionSession('participant_01', weight_kg=70)
129
+ session.run_collection(serial_port='/dev/ttyUSB0') # or None for synthetic
130
+ "
131
+ ```
132
+
133
+ ### 4. Train Production Model
134
+ ```python
135
+ from smart_chair.ml_models.posture_classifier import EnsemblePostureClassifier
136
+ from smart_chair.data_collection.protocol import prepare_training_dataset
137
+ from smart_chair.data_collection.synthetic_data_generator import subject_based_split
138
+
139
+ dataset = prepare_training_dataset("./collected_data")
140
+ train, test = subject_based_split(dataset)
141
+
142
+ clf = EnsemblePostureClassifier()
143
+ clf.train(train["imu_data"], train["load_data"], train["labels"],
144
+ test["imu_data"], test["load_data"], test["labels"])
145
+ clf.save("posture_model.joblib")
146
+ ```
147
+
148
+ ### 5. Deploy to ESP32
149
+ ```python
150
+ # Option A: Random Forest β†’ C header
151
+ from micromlgen import port
152
+ c_code = port(clf.model.estimators_[4]) # Export RF component
153
+ with open("posture_classifier.h", "w") as f:
154
+ f.write(c_code)
155
+
156
+ # Option B: MLSTM-FCN β†’ TFLite
157
+ from smart_chair.ml_models.posture_classifier import build_mlstm_fcn, convert_to_tflite
158
+ model = build_mlstm_fcn()
159
+ # ... train model ...
160
+ convert_to_tflite(model, X_train, "posture_model.tflite")
161
+ ```
162
+
163
+ ### 6. Flash ESP32 Firmware
164
+ Upload `firmware/esp32_firmware.ino` via Arduino IDE or PlatformIO.
165
+
166
+ ## πŸ“ Project Structure
167
+
168
+ ```
169
+ smart_chair/
170
+ β”œβ”€β”€ config/
171
+ β”‚ └── settings.py # All system constants and parameters
172
+ β”œβ”€β”€ utils/
173
+ β”‚ └── feature_engineering.py # Feature extraction pipeline (14 channels)
174
+ β”œβ”€β”€ data_collection/
175
+ β”‚ β”œβ”€β”€ synthetic_data_generator.py # Physics-based synthetic data
176
+ β”‚ └── protocol.py # Real data collection procedure
177
+ β”œβ”€β”€ ml_models/
178
+ β”‚ β”œβ”€β”€ posture_classifier.py # Ensemble + MLSTM-FCN classifiers
179
+ β”‚ β”œβ”€β”€ spine_risk_predictor.py # RULA scorer + fatigue detector + alerts
180
+ β”‚ β”œβ”€β”€ user_recognition.py # Multi-user recognition + personalized model
181
+ β”‚ β”œβ”€β”€ break_recommendation.py # Adaptive break engine + exercise suggester
182
+ β”‚ └── gamification.py # Points, badges, streaks, leaderboard
183
+ β”œβ”€β”€ cloud/
184
+ β”‚ └── aws_iot_integration.py # MQTT client + AWS infrastructure definitions
185
+ β”œβ”€β”€ firmware/
186
+ β”‚ └── esp32_firmware.ino # ESP32-S3 Arduino firmware
187
+ β”œβ”€β”€ HARDWARE_BOM.md # Parts list + wiring diagram
188
+ β”œβ”€β”€ main.py # System orchestrator (Raspberry Pi)
189
+ └── README.md # This file
190
+ ```
191
+
192
+ ## πŸ“š References
193
+
194
+ | Paper | Contribution | ArXiv |
195
+ |-------|-------------|-------|
196
+ | SitPose | Ensemble posture classification (F1=98.2%) | [2412.12216](https://arxiv.org/abs/2412.12216) |
197
+ | MLSTM-FCN | Multivariate time series architecture | [1801.04503](https://arxiv.org/abs/1801.04503) |
198
+ | FusionActNet | Static/Dynamic dual-expert IMU | [2310.02011](https://arxiv.org/abs/2310.02011) |
199
+ | SSL-Wearables | Self-supervised pre-training for HAR | [2206.02909](https://arxiv.org/abs/2206.02909) |
200
+ | UniMTS | Foundation model for IMU (zero-shot) | [2410.19818](https://arxiv.org/abs/2410.19818) |
201
+ | DULA/DEBA | Differentiable ergonomic assessment | [2205.03491](https://arxiv.org/abs/2205.03491) |
202
+ | TinyNav | TFLite Micro on ESP32 (<30ms) | [2603.11071](https://arxiv.org/abs/2603.11071) |
203
+ | AuthentiSense | Few-shot biometric authentication | [2302.02740](https://arxiv.org/abs/2302.02740) |
204
+ | Edge Impulse | TinyML MLOps platform | [2212.03332](https://arxiv.org/abs/2212.03332) |
205
+
206
+ ## πŸ“„ License
207
+
208
+ MIT License β€” Free for personal and commercial use.
smart_chair/__init__.py ADDED
File without changes
smart_chair/cloud/__init__.py ADDED
File without changes
smart_chair/cloud/aws_iot_integration.py ADDED
@@ -0,0 +1,358 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ SmartChair AWS IoT Core Integration.
3
+
4
+ Architecture:
5
+ ESP32-S3 β†’ UART/I2C β†’ Raspberry Pi β†’ MQTT/TLS β†’ AWS IoT Core β†’ DynamoDB/S3/Lambda
6
+
7
+ The ESP32 handles real-time inference locally (TFLite INT8).
8
+ The Raspberry Pi acts as IoT gateway:
9
+ - Runs complex models (MLSTM-FCN, fatigue detector)
10
+ - Manages MQTT connection to AWS IoT Core
11
+ - Publishes telemetry every 5 seconds
12
+ - Stores raw data locally for model retraining
13
+ - Handles OTA firmware updates
14
+
15
+ AWS IoT Core Topics:
16
+ smartchair/{device_id}/telemetry β€” posture + sensor data (every 5s)
17
+ smartchair/{device_id}/alerts β€” risk/fatigue/break alerts
18
+ smartchair/{device_id}/commands β€” remote commands (calibrate, update model)
19
+ smartchair/{device_id}/shadow β€” device shadow (current state)
20
+ """
21
+
22
+ import json
23
+ import time
24
+ import ssl
25
+ import os
26
+ from datetime import datetime
27
+ from typing import Dict, Optional, Callable
28
+ from dataclasses import dataclass, field, asdict
29
+
30
+
31
+ @dataclass
32
+ class TelemetryPayload:
33
+ """Standard telemetry packet sent to AWS IoT Core."""
34
+ device_id: str
35
+ timestamp: int # Unix epoch ms
36
+ user_id: str = "unknown"
37
+
38
+ # Posture classification
39
+ posture_class: int = 0
40
+ posture_name: str = "upright"
41
+ posture_confidence: float = 0.0
42
+
43
+ # Sensor readings
44
+ imu_accel: list = field(default_factory=lambda: [0, 0, 9.81])
45
+ imu_gyro: list = field(default_factory=lambda: [0, 0, 0])
46
+ load_cells: list = field(default_factory=lambda: [0, 0, 0, 0])
47
+ thermal_presence: bool = False
48
+
49
+ # Derived metrics
50
+ pitch_deg: float = 0.0
51
+ roll_deg: float = 0.0
52
+ cop_x: float = 0.0
53
+ cop_y: float = 0.0
54
+ total_weight_kg: float = 0.0
55
+
56
+ # Risk & fatigue
57
+ rula_score: int = 1
58
+ risk_level: str = "negligible"
59
+ fatigue_level: str = "none"
60
+ drowsiness_risk: str = "low"
61
+
62
+ # Session
63
+ session_duration_min: float = 0.0
64
+ good_posture_pct: float = 100.0
65
+ posture_score: float = 100.0
66
+
67
+ # Gamification
68
+ points_today: int = 0
69
+ streak_days: int = 0
70
+
71
+ def to_json(self) -> str:
72
+ return json.dumps(asdict(self))
73
+
74
+
75
+ @dataclass
76
+ class AlertPayload:
77
+ """Alert packet for critical events."""
78
+ device_id: str
79
+ timestamp: int
80
+ alert_type: str # "acute_risk", "fatigue", "break_needed", "chronic_risk"
81
+ severity: str # "info", "warning", "critical"
82
+ message: str
83
+ action: str = ""
84
+ user_id: str = "unknown"
85
+
86
+ def to_json(self) -> str:
87
+ return json.dumps(asdict(self))
88
+
89
+
90
+ class AWSIoTClient:
91
+ """
92
+ MQTT client for AWS IoT Core communication.
93
+
94
+ Handles:
95
+ - Secure MQTT connection (TLS 1.2 with X.509 certificates)
96
+ - Telemetry publishing
97
+ - Alert publishing
98
+ - Device shadow updates
99
+ - Command subscription
100
+
101
+ Certificate setup:
102
+ 1. Create Thing in AWS IoT Core console
103
+ 2. Download: root-CA.pem, device.pem.crt, private.pem.key
104
+ 3. Set paths via environment variables or config
105
+ """
106
+
107
+ def __init__(self, device_id: str,
108
+ endpoint: Optional[str] = None,
109
+ cert_dir: str = "./certs"):
110
+ self.device_id = device_id
111
+ self.endpoint = endpoint or os.getenv("AWS_IOT_ENDPOINT", "")
112
+ self.cert_dir = cert_dir
113
+ self.client = None
114
+ self.is_connected = False
115
+ self.command_callbacks = {}
116
+
117
+ # Topics
118
+ self.topic_telemetry = f"smartchair/{device_id}/telemetry"
119
+ self.topic_alerts = f"smartchair/{device_id}/alerts"
120
+ self.topic_commands = f"smartchair/{device_id}/commands"
121
+ self.topic_shadow = f"$aws/things/{device_id}/shadow/update"
122
+
123
+ def connect(self) -> bool:
124
+ """
125
+ Establish MQTT connection to AWS IoT Core.
126
+
127
+ Returns True if connection successful, False otherwise.
128
+
129
+ Note: Requires paho-mqtt and valid AWS IoT certificates.
130
+ In production, install via: pip install paho-mqtt awsiotsdk
131
+ """
132
+ try:
133
+ import paho.mqtt.client as mqtt
134
+ except ImportError:
135
+ print("⚠️ paho-mqtt not installed. Install with: pip install paho-mqtt")
136
+ print(" Running in offline/simulation mode.")
137
+ return False
138
+
139
+ if not self.endpoint:
140
+ print("⚠️ AWS IoT endpoint not configured. Set AWS_IOT_ENDPOINT env var.")
141
+ return False
142
+
143
+ cert_path = os.path.join(self.cert_dir, "device.pem.crt")
144
+ key_path = os.path.join(self.cert_dir, "private.pem.key")
145
+ ca_path = os.path.join(self.cert_dir, "AmazonRootCA1.pem")
146
+
147
+ if not all(os.path.exists(p) for p in [cert_path, key_path, ca_path]):
148
+ print("⚠️ AWS IoT certificates not found in", self.cert_dir)
149
+ print(" Download from AWS IoT Core console β†’ Manage β†’ Things β†’ Certificates")
150
+ return False
151
+
152
+ self.client = mqtt.Client(client_id=self.device_id, protocol=mqtt.MQTTv311)
153
+ self.client.tls_set(
154
+ ca_certs=ca_path,
155
+ certfile=cert_path,
156
+ keyfile=key_path,
157
+ tls_version=ssl.PROTOCOL_TLSv1_2
158
+ )
159
+
160
+ self.client.on_connect = self._on_connect
161
+ self.client.on_message = self._on_message
162
+ self.client.on_disconnect = self._on_disconnect
163
+
164
+ try:
165
+ self.client.connect(self.endpoint, 8883, 60)
166
+ self.client.loop_start()
167
+ self.is_connected = True
168
+ print(f"βœ… Connected to AWS IoT Core: {self.endpoint}")
169
+ return True
170
+ except Exception as e:
171
+ print(f"❌ Connection failed: {e}")
172
+ return False
173
+
174
+ def _on_connect(self, client, userdata, flags, rc):
175
+ if rc == 0:
176
+ print(f"βœ… MQTT connected. Subscribing to {self.topic_commands}")
177
+ client.subscribe(self.topic_commands, qos=1)
178
+ else:
179
+ print(f"❌ MQTT connect failed with code {rc}")
180
+
181
+ def _on_message(self, client, userdata, msg):
182
+ try:
183
+ payload = json.loads(msg.payload.decode())
184
+ command = payload.get("command", "")
185
+
186
+ if command in self.command_callbacks:
187
+ self.command_callbacks[command](payload)
188
+ else:
189
+ print(f"πŸ“₯ Unknown command: {command}")
190
+ except Exception as e:
191
+ print(f"❌ Message parse error: {e}")
192
+
193
+ def _on_disconnect(self, client, userdata, rc):
194
+ self.is_connected = False
195
+ if rc != 0:
196
+ print(f"⚠️ Unexpected disconnect (rc={rc}). Attempting reconnect...")
197
+
198
+ def publish_telemetry(self, payload: TelemetryPayload):
199
+ """Publish telemetry data to AWS IoT Core."""
200
+ if self.client and self.is_connected:
201
+ self.client.publish(self.topic_telemetry, payload.to_json(), qos=0)
202
+ else:
203
+ # Offline mode β€” log locally
204
+ self._log_offline(payload.to_json())
205
+
206
+ def publish_alert(self, payload: AlertPayload):
207
+ """Publish alert to AWS IoT Core (higher QoS for reliability)."""
208
+ if self.client and self.is_connected:
209
+ self.client.publish(self.topic_alerts, payload.to_json(), qos=1)
210
+ else:
211
+ self._log_offline(payload.to_json())
212
+
213
+ def update_shadow(self, state: Dict):
214
+ """Update device shadow with current state."""
215
+ shadow = {
216
+ "state": {
217
+ "reported": state
218
+ }
219
+ }
220
+ if self.client and self.is_connected:
221
+ self.client.publish(self.topic_shadow, json.dumps(shadow), qos=1)
222
+
223
+ def register_command(self, command: str, callback: Callable):
224
+ """Register callback for remote commands."""
225
+ self.command_callbacks[command] = callback
226
+
227
+ def _log_offline(self, payload_json: str):
228
+ """Store data locally when offline."""
229
+ log_dir = os.path.join(self.cert_dir, "..", "offline_logs")
230
+ os.makedirs(log_dir, exist_ok=True)
231
+ log_file = os.path.join(log_dir, f"{datetime.now().strftime('%Y%m%d')}.jsonl")
232
+ with open(log_file, 'a') as f:
233
+ f.write(payload_json + '\n')
234
+
235
+ def disconnect(self):
236
+ """Clean disconnect."""
237
+ if self.client:
238
+ self.client.loop_stop()
239
+ self.client.disconnect()
240
+ self.is_connected = False
241
+
242
+
243
+ # ═══════════════════════════════════════════════════════════════════════════════
244
+ # AWS IoT Rules & Lambda Functions (Infrastructure as Code reference)
245
+ # ═══════════════════════════════════════════════════════════════════════════════
246
+
247
+ AWS_IOT_RULE_TELEMETRY_TO_DYNAMODB = """
248
+ -- AWS IoT Rule SQL: Store telemetry in DynamoDB
249
+ SELECT
250
+ device_id,
251
+ timestamp,
252
+ user_id,
253
+ posture_class, posture_name, posture_confidence,
254
+ rula_score, risk_level, fatigue_level,
255
+ cop_x, cop_y, total_weight_kg,
256
+ posture_score, session_duration_min
257
+ FROM 'smartchair/+/telemetry'
258
+
259
+ -- Action: DynamoDB PutItem
260
+ -- Table: SmartChairTelemetry
261
+ -- Partition Key: device_id (String)
262
+ -- Sort Key: timestamp (Number)
263
+ """
264
+
265
+ AWS_IOT_RULE_ALERTS_TO_SNS = """
266
+ -- AWS IoT Rule SQL: Forward critical alerts to SNS
267
+ SELECT
268
+ device_id, user_id, alert_type, severity, message, action
269
+ FROM 'smartchair/+/alerts'
270
+ WHERE severity = 'critical'
271
+
272
+ -- Action: SNS Publish
273
+ -- Topic ARN: arn:aws:sns:region:account:SmartChairCriticalAlerts
274
+ """
275
+
276
+ DYNAMODB_TABLE_SCHEMA = {
277
+ "TableName": "SmartChairTelemetry",
278
+ "KeySchema": [
279
+ {"AttributeName": "device_id", "KeyType": "HASH"},
280
+ {"AttributeName": "timestamp", "KeyType": "RANGE"},
281
+ ],
282
+ "AttributeDefinitions": [
283
+ {"AttributeName": "device_id", "AttributeType": "S"},
284
+ {"AttributeName": "timestamp", "AttributeType": "N"},
285
+ ],
286
+ "BillingMode": "PAY_PER_REQUEST",
287
+ "GlobalSecondaryIndexes": [
288
+ {
289
+ "IndexName": "UserIndex",
290
+ "KeySchema": [
291
+ {"AttributeName": "user_id", "KeyType": "HASH"},
292
+ {"AttributeName": "timestamp", "KeyType": "RANGE"},
293
+ ],
294
+ "Projection": {"ProjectionType": "ALL"},
295
+ }
296
+ ],
297
+ }
298
+
299
+ LAMBDA_DAILY_SCORE_CALCULATOR = '''
300
+ # AWS Lambda: Triggered daily by CloudWatch Events
301
+ # Computes and stores daily posture score per user
302
+
303
+ import boto3
304
+ from datetime import datetime, timedelta
305
+ import json
306
+ from decimal import Decimal
307
+
308
+ dynamodb = boto3.resource('dynamodb')
309
+ table = dynamodb.Table('SmartChairTelemetry')
310
+ scores_table = dynamodb.Table('SmartChairDailyScores')
311
+
312
+ def lambda_handler(event, context):
313
+ """Calculate daily posture scores for all devices."""
314
+ yesterday = datetime.now() - timedelta(days=1)
315
+ start_ts = int(yesterday.replace(hour=0, minute=0).timestamp() * 1000)
316
+ end_ts = int(yesterday.replace(hour=23, minute=59).timestamp() * 1000)
317
+
318
+ # Query yesterday's data
319
+ response = table.query(
320
+ KeyConditionExpression='device_id = :did AND #ts BETWEEN :start AND :end',
321
+ ExpressionAttributeNames={'#ts': 'timestamp'},
322
+ ExpressionAttributeValues={
323
+ ':did': event.get('device_id', 'chair_001'),
324
+ ':start': start_ts,
325
+ ':end': end_ts,
326
+ }
327
+ )
328
+
329
+ items = response['Items']
330
+ if not items:
331
+ return {'statusCode': 200, 'body': 'No data for yesterday'}
332
+
333
+ # Compute weighted score
334
+ posture_weights = {
335
+ 'upright': 1.0, 'forward_lean': 0.4, 'backward_lean': 0.5,
336
+ 'left_lean': 0.6, 'right_lean': 0.6, 'slouch': 0.2
337
+ }
338
+
339
+ total_weighted = sum(
340
+ posture_weights.get(item.get('posture_name', 'upright'), 0.5)
341
+ for item in items
342
+ )
343
+ score = (total_weighted / len(items)) * 100
344
+
345
+ # Store daily score
346
+ scores_table.put_item(Item={
347
+ 'user_id': items[0].get('user_id', 'unknown'),
348
+ 'date': yesterday.strftime('%Y-%m-%d'),
349
+ 'score': Decimal(str(round(score, 1))),
350
+ 'total_readings': len(items),
351
+ 'device_id': event.get('device_id', 'chair_001'),
352
+ })
353
+
354
+ return {
355
+ 'statusCode': 200,
356
+ 'body': json.dumps({'date': yesterday.strftime('%Y-%m-%d'), 'score': round(score, 1)})
357
+ }
358
+ '''
smart_chair/config/__init__.py ADDED
File without changes
smart_chair/config/settings.py ADDED
@@ -0,0 +1,122 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ SmartChair Configuration β€” All system constants and hardware parameters.
3
+ Based on literature: SitPose (arxiv:2412.12216), FusionActNet (arxiv:2310.02011),
4
+ MLSTM-FCN (arxiv:1801.04503), TinyNav (arxiv:2603.11071)
5
+ """
6
+
7
+ # ─── Sensor Hardware ───────────────────────────────────────────────────────────
8
+ IMU_SENSOR = "MPU6050" # or ICM-42688 for better accuracy
9
+ IMU_SAMPLE_RATE_HZ = 50 # matches UCI-HAR standard
10
+ IMU_AXES = 6 # 3 accel + 3 gyro
11
+
12
+ LOAD_CELL_COUNT = 4 # FL, FR, RL, RR corners
13
+ LOAD_CELL_CAPACITY_KG = 200 # per cell, HX711 ADC
14
+ LOAD_CELL_SAMPLE_RATE_HZ = 10 # HX711 @ 10 SPS mode
15
+
16
+ THERMAL_SENSOR = "AMG8833" # 8x8 IR grid array
17
+ THERMAL_GRID_SIZE = (8, 8)
18
+ THERMAL_SAMPLE_RATE_HZ = 1 # presence detection only
19
+ THERMAL_PRESENCE_THRESHOLD_C = 28.0 # above ambient = human detected
20
+
21
+ # ─── Posture Classes ───────────────────────────────────────────────────────────
22
+ POSTURE_CLASSES = {
23
+ 0: "upright",
24
+ 1: "forward_lean",
25
+ 2: "backward_lean",
26
+ 3: "left_lean",
27
+ 4: "right_lean",
28
+ 5: "slouch",
29
+ 6: "absent"
30
+ }
31
+ NUM_POSTURE_CLASSES = len(POSTURE_CLASSES)
32
+
33
+ # ─── ML Model Parameters ──────────────────────────────────────────────────────
34
+ # Sliding window for temporal models (from MLSTM-FCN paper)
35
+ WINDOW_SIZE = 128 # 128 samples @ 50Hz = 2.56 seconds
36
+ WINDOW_STRIDE = 64 # 50% overlap
37
+ INPUT_CHANNELS = 14 # 6 IMU + 4 load cells + 2 computed angles + 2 CoP
38
+
39
+ # Ensemble voting classifier (from SitPose: F1=98.2%)
40
+ ENSEMBLE_SVM_C = 10.0
41
+ ENSEMBLE_SVM_KERNEL = "rbf"
42
+ ENSEMBLE_DT_MAX_DEPTH = 10
43
+ ENSEMBLE_MLP_HIDDEN = (64, 32)
44
+ ENSEMBLE_MLP_MAX_ITER = 500
45
+
46
+ # MLSTM-FCN architecture (from arxiv:1801.04503)
47
+ MLSTM_FCN_FILTERS = [128, 256, 128]
48
+ MLSTM_FCN_KERNELS = [8, 5, 3]
49
+ MLSTM_FCN_LSTM_UNITS = 8
50
+ MLSTM_FCN_DROPOUT = 0.8
51
+ MLSTM_FCN_SE_RATIO = 16
52
+
53
+ # Training
54
+ TRAIN_EPOCHS = 100
55
+ TRAIN_BATCH_SIZE = 64
56
+ TRAIN_LR = 1e-3
57
+ TRAIN_VAL_SPLIT = 0.2
58
+ EARLY_STOPPING_PATIENCE = 10
59
+
60
+ # ─── Fatigue Detection ─────────────────────────────────────────────────────────
61
+ FATIGUE_WINDOW_MINUTES = 30 # rolling window for fatigue analysis
62
+ FATIGUE_COP_VARIANCE_THRESHOLD = 0.15 # high CoP variance = restlessness/fatigue
63
+ FATIGUE_MICRO_MOVEMENT_THRESHOLD = 0.02 # below = fatigue
64
+ DROWSINESS_STILLNESS_SECONDS = 120 # 2 min stillness = possible drowsiness
65
+
66
+ # ─── Spine Risk (RULA-based, from arxiv:2205.03491) ───────────────────────────
67
+ RULA_TRUNK_ANGLES = [10, 20, 60] # degree thresholds
68
+ RULA_LATERAL_THRESHOLD = 15 # degrees
69
+ RISK_LEVELS = {1: "negligible", 2: "low", 3: "medium", 4: "high",
70
+ 5: "high", 6: "very_high", 7: "critical"}
71
+
72
+ # ─── Break Recommendation ─────────────────────────────────────────────────────
73
+ MIN_BREAK_INTERVAL_MINUTES = 20
74
+ MAX_SITTING_MINUTES = 50
75
+ BREAK_DURATION_SECONDS = 120 # 2-minute micro-break
76
+ BREAK_URGENCY_LEVELS = ["gentle", "moderate", "urgent"]
77
+
78
+ # ─── Multi-User Recognition ───────────────────────────────────────────────────
79
+ USER_ENROLLMENT_SAMPLES = 5 # sit-down events for enrollment
80
+ USER_WEIGHT_TOLERANCE_KG = 3.0 # weight matching tolerance
81
+ USER_COP_TOLERANCE = 0.1 # CoP ratio matching tolerance
82
+ MAX_REGISTERED_USERS = 10
83
+
84
+ # ─── Gamification ──────────────────────────────────────────────────────────────
85
+ POINTS_PER_GOOD_POSTURE_MINUTE = 10
86
+ POINTS_PER_BREAK_TAKEN = 50
87
+ POINTS_PER_EXERCISE_DONE = 100
88
+ STREAK_BONUS_MULTIPLIER = 1.5
89
+ BADGE_THRESHOLDS = {
90
+ "posture_novice": 500,
91
+ "posture_pro": 2000,
92
+ "posture_master": 5000,
93
+ "break_champion": 1000,
94
+ "streak_warrior": 3000,
95
+ "iron_spine": 10000
96
+ }
97
+
98
+ # ─── Cloud / AWS IoT Core ─────────────────────────────────────────────────────
99
+ AWS_IOT_ENDPOINT = "" # set via env var
100
+ AWS_IOT_TOPIC_TELEMETRY = "smartchair/{device_id}/telemetry"
101
+ AWS_IOT_TOPIC_ALERTS = "smartchair/{device_id}/alerts"
102
+ AWS_IOT_TOPIC_COMMANDS = "smartchair/{device_id}/commands"
103
+ MQTT_QOS = 1
104
+ TELEMETRY_PUBLISH_INTERVAL_S = 5 # publish every 5 seconds
105
+
106
+ # ─── Edge Deployment ──────────────────────────────────────────────────────────
107
+ MICROCONTROLLER = "ESP32-S3" # Xtensa LX7 dual-core, PSRAM
108
+ TFLITE_ARENA_SIZE_KB = 50 # tensor arena for TFLite Micro
109
+ TARGET_INFERENCE_LATENCY_MS = 30 # from TinyNav benchmarks
110
+ QUANTIZATION = "INT8" # post-training quantization
111
+
112
+ # ─── Dashboard ─────────────────────────────────────────────────────────────────
113
+ DASHBOARD_REFRESH_INTERVAL_S = 5
114
+ POSTURE_SCORE_WEIGHTS = {
115
+ "upright": 1.0,
116
+ "forward_lean": 0.4,
117
+ "backward_lean": 0.5,
118
+ "left_lean": 0.6,
119
+ "right_lean": 0.6,
120
+ "slouch": 0.2,
121
+ "absent": 0.0
122
+ }
smart_chair/data_collection/__init__.py ADDED
File without changes
smart_chair/data_collection/protocol.py ADDED
@@ -0,0 +1,258 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ SmartChair Data Collection Protocol.
3
+
4
+ Standardized procedure for collecting real sensor data to train production models.
5
+ Based on UCI-HAR and SitPose collection methodology.
6
+
7
+ Equipment needed:
8
+ - SmartChair hardware (ESP32-S3 + IMU + 4 load cells + thermal)
9
+ - Laptop running this script (USB serial connection to ESP32)
10
+ - Printed posture instruction cards for participants
11
+ - Consent forms
12
+
13
+ Protocol:
14
+ 1. Participant sits on chair
15
+ 2. System captures 5 seconds of baseline (upright)
16
+ 3. Researcher instructs posture via visual cards
17
+ 4. Each posture held for 60 seconds
18
+ 5. 10-second rest between postures
19
+ 6. Repeat all 6 postures (excluding absent)
20
+ 7. Total per participant: ~8 minutes
21
+ 8. Target: 20+ participants for 95%+ accuracy
22
+ """
23
+
24
+ import json
25
+ import time
26
+ import os
27
+ import numpy as np
28
+ from datetime import datetime
29
+ from typing import Optional
30
+
31
+
32
+ class DataCollectionSession:
33
+ """Manages a single data collection session."""
34
+
35
+ POSTURES_TO_COLLECT = [
36
+ {"id": 0, "name": "upright", "instruction": "Sit with back straight, feet flat, shoulders relaxed."},
37
+ {"id": 1, "name": "forward_lean", "instruction": "Lean forward ~15Β° as if reading something close."},
38
+ {"id": 2, "name": "backward_lean", "instruction": "Lean back against the backrest, relaxed."},
39
+ {"id": 3, "name": "left_lean", "instruction": "Lean to the left, resting on left armrest."},
40
+ {"id": 4, "name": "right_lean", "instruction": "Lean to the right, resting on right armrest."},
41
+ {"id": 5, "name": "slouch", "instruction": "Slouch forward with rounded shoulders, C-spine."},
42
+ ]
43
+
44
+ def __init__(self, participant_id: str, participant_weight_kg: float,
45
+ output_dir: str = "./collected_data"):
46
+ self.participant_id = participant_id
47
+ self.participant_weight = participant_weight_kg
48
+ self.output_dir = output_dir
49
+ self.session_id = datetime.now().strftime("%Y%m%d_%H%M%S")
50
+ self.data = {
51
+ "metadata": {
52
+ "participant_id": participant_id,
53
+ "weight_kg": participant_weight_kg,
54
+ "session_id": self.session_id,
55
+ "collection_date": datetime.now().isoformat(),
56
+ "protocol_version": "1.0",
57
+ "imu_hz": 50,
58
+ "load_cell_hz": 10,
59
+ "thermal_hz": 1,
60
+ },
61
+ "recordings": [],
62
+ }
63
+
64
+ os.makedirs(output_dir, exist_ok=True)
65
+
66
+ def run_collection(self, serial_port: Optional[str] = None):
67
+ """
68
+ Run the full data collection protocol.
69
+
70
+ Args:
71
+ serial_port: Serial port to read from ESP32 (e.g., '/dev/ttyUSB0')
72
+ If None, generates synthetic data for testing.
73
+ """
74
+ print(f"\n{'='*60}")
75
+ print(f"DATA COLLECTION SESSION")
76
+ print(f"Participant: {self.participant_id} ({self.participant_weight} kg)")
77
+ print(f"Session ID: {self.session_id}")
78
+ print(f"{'='*60}\n")
79
+
80
+ for posture in self.POSTURES_TO_COLLECT:
81
+ print(f"\nπŸ“Œ POSTURE: {posture['name'].upper()}")
82
+ print(f" Instruction: {posture['instruction']}")
83
+ print(f" Recording for 60 seconds...")
84
+
85
+ input(" Press ENTER when participant is in position...")
86
+
87
+ # Countdown
88
+ for i in range(3, 0, -1):
89
+ print(f" Starting in {i}...")
90
+ time.sleep(1)
91
+
92
+ # Record data
93
+ recording = self._record_posture(posture, duration_seconds=60, serial_port=serial_port)
94
+ self.data["recordings"].append(recording)
95
+
96
+ print(f" βœ… Recorded {recording['n_imu_samples']} IMU + {recording['n_load_samples']} load samples")
97
+
98
+ print(" Rest for 10 seconds...")
99
+ time.sleep(2) # Shortened for demo
100
+
101
+ # Also record absent (empty chair)
102
+ print(f"\nπŸ“Œ POSTURE: ABSENT")
103
+ print(f" Instruction: Participant stands up. Chair must be empty.")
104
+ input(" Press ENTER when chair is empty...")
105
+
106
+ recording = self._record_posture(
107
+ {"id": 6, "name": "absent", "instruction": "Empty chair"},
108
+ duration_seconds=30, serial_port=serial_port
109
+ )
110
+ self.data["recordings"].append(recording)
111
+
112
+ # Save
113
+ self._save()
114
+
115
+ print(f"\n{'='*60}")
116
+ print(f"βœ… SESSION COMPLETE")
117
+ print(f" Total recordings: {len(self.data['recordings'])}")
118
+ print(f" Saved to: {self.output_dir}/{self.participant_id}_{self.session_id}.json")
119
+ print(f"{'='*60}\n")
120
+
121
+ def _record_posture(self, posture: dict, duration_seconds: int,
122
+ serial_port: Optional[str] = None) -> dict:
123
+ """Record sensor data for one posture hold."""
124
+ start_time = time.time()
125
+ imu_samples = []
126
+ load_samples = []
127
+
128
+ if serial_port:
129
+ # Real hardware β€” read from ESP32 UART
130
+ import serial
131
+ ser = serial.Serial(serial_port, 115200, timeout=0.1)
132
+
133
+ while time.time() - start_time < duration_seconds:
134
+ line = ser.readline().decode().strip()
135
+ if line:
136
+ try:
137
+ data = json.loads(line)
138
+ if "imu" in data:
139
+ imu_samples.append(data["imu"])
140
+ if "load" in data:
141
+ load_samples.append(data["load"])
142
+ except json.JSONDecodeError:
143
+ pass
144
+
145
+ ser.close()
146
+ else:
147
+ # Synthetic data for protocol testing
148
+ from smart_chair.data_collection.synthetic_data_generator import (
149
+ generate_imu_data, generate_load_cell_data
150
+ )
151
+
152
+ n_imu = duration_seconds * 50 # 50 Hz
153
+ n_load = duration_seconds * 10 # 10 Hz
154
+
155
+ imu_data = generate_imu_data(posture["id"], n_imu, self.participant_weight)
156
+ load_data = generate_load_cell_data(posture["id"], n_load, self.participant_weight)
157
+
158
+ imu_samples = imu_data.tolist()
159
+ load_samples = load_data.tolist()
160
+
161
+ return {
162
+ "posture_id": posture["id"],
163
+ "posture_name": posture["name"],
164
+ "duration_seconds": duration_seconds,
165
+ "n_imu_samples": len(imu_samples),
166
+ "n_load_samples": len(load_samples),
167
+ "imu_data": imu_samples,
168
+ "load_data": load_samples,
169
+ "timestamp_start": datetime.fromtimestamp(start_time).isoformat(),
170
+ }
171
+
172
+ def _save(self):
173
+ """Save collected data to JSON."""
174
+ filename = f"{self.participant_id}_{self.session_id}.json"
175
+ filepath = os.path.join(self.output_dir, filename)
176
+
177
+ with open(filepath, 'w') as f:
178
+ json.dump(self.data, f)
179
+
180
+ # Also save as numpy arrays for direct ML training
181
+ for rec in self.data["recordings"]:
182
+ npy_dir = os.path.join(self.output_dir, "numpy", self.participant_id)
183
+ os.makedirs(npy_dir, exist_ok=True)
184
+
185
+ np.save(os.path.join(npy_dir, f"{rec['posture_name']}_imu.npy"),
186
+ np.array(rec['imu_data'], dtype=np.float32))
187
+ np.save(os.path.join(npy_dir, f"{rec['posture_name']}_load.npy"),
188
+ np.array(rec['load_data'], dtype=np.float32))
189
+
190
+
191
+ # ═══════════════════════════════════════════════════════════════════════════════
192
+ # DATASET PREPARATION
193
+ # ═══════════════════════════════════════════════════════════════════════════════
194
+
195
+ def prepare_training_dataset(data_dir: str = "./collected_data") -> dict:
196
+ """
197
+ Load all collected data and prepare for model training.
198
+ Applies subject-based train/test split.
199
+
200
+ Returns:
201
+ dict with train/test arrays + metadata
202
+ """
203
+ import glob
204
+
205
+ all_imu = []
206
+ all_load = []
207
+ all_labels = []
208
+ all_subjects = []
209
+ subject_map = {}
210
+
211
+ json_files = glob.glob(os.path.join(data_dir, "*.json"))
212
+
213
+ for fpath in sorted(json_files):
214
+ with open(fpath) as f:
215
+ session = json.load(f)
216
+
217
+ pid = session["metadata"]["participant_id"]
218
+ if pid not in subject_map:
219
+ subject_map[pid] = len(subject_map)
220
+ sid = subject_map[pid]
221
+
222
+ for rec in session["recordings"]:
223
+ imu = np.array(rec["imu_data"], dtype=np.float32)
224
+ load = np.array(rec["load_data"], dtype=np.float32)
225
+
226
+ # Align lengths (IMU at 50Hz, load at 10Hz β€” upsample load)
227
+ n = min(len(imu), len(load) * 5)
228
+ imu = imu[:n]
229
+
230
+ # Repeat load cells to match IMU rate
231
+ load_upsampled = np.repeat(load, 5, axis=0)[:n]
232
+
233
+ labels = np.full(n, rec["posture_id"], dtype=np.int32)
234
+ subjects = np.full(n, sid, dtype=np.int32)
235
+
236
+ all_imu.append(imu)
237
+ all_load.append(load_upsampled)
238
+ all_labels.append(labels)
239
+ all_subjects.append(subjects)
240
+
241
+ if not all_imu:
242
+ print("⚠️ No data found. Run data collection first.")
243
+ return None
244
+
245
+ dataset = {
246
+ "imu_data": np.concatenate(all_imu),
247
+ "load_data": np.concatenate(all_load),
248
+ "labels": np.concatenate(all_labels),
249
+ "subject_ids": np.concatenate(all_subjects),
250
+ "n_subjects": len(subject_map),
251
+ "subject_map": subject_map,
252
+ }
253
+
254
+ print(f"Loaded {len(json_files)} sessions, {len(subject_map)} participants")
255
+ print(f"Total samples: {len(dataset['labels'])}")
256
+ print(f"Class distribution: {np.bincount(dataset['labels'])}")
257
+
258
+ return dataset
smart_chair/data_collection/synthetic_data_generator.py ADDED
@@ -0,0 +1,327 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Synthetic Data Generator for SmartChair Development & Testing.
3
+ Generates realistic IMU + Load Cell + Thermal sensor data for each posture class.
4
+
5
+ Physics-based: Each posture has characteristic sensor signatures derived from
6
+ biomechanical models of seated humans. Noise levels match real MPU6050 + HX711 specs.
7
+
8
+ Used for: model architecture validation, pipeline testing, and pre-training before
9
+ real data collection. Real data collection protocol is in data_collection/protocol.py
10
+ """
11
+
12
+ import numpy as np
13
+ from typing import Dict, Tuple, Optional
14
+
15
+
16
+ # ─── Posture Sensor Signatures ─────────────────────────────────────────────────
17
+ # Based on biomechanics literature and SitPose angular distributions
18
+ # Format: (accel_mean_xyz, gyro_mean_xyz, load_ratios_FLFRRLRR)
19
+ POSTURE_SIGNATURES = {
20
+ 0: { # Upright (good posture)
21
+ "name": "upright",
22
+ "accel_mean": [0.0, 0.0, 9.81], # gravity along z
23
+ "accel_std": [0.05, 0.05, 0.05],
24
+ "gyro_mean": [0.0, 0.0, 0.0],
25
+ "gyro_std": [0.02, 0.02, 0.02],
26
+ "load_ratios": [0.25, 0.25, 0.25, 0.25], # balanced
27
+ "load_std": [0.02, 0.02, 0.02, 0.02],
28
+ "thermal_center": (4, 4), # centered on 8x8 grid
29
+ "thermal_spread": 2.0,
30
+ },
31
+ 1: { # Forward lean
32
+ "name": "forward_lean",
33
+ "accel_mean": [2.5, 0.0, 9.5], # tilted forward ~15Β°
34
+ "accel_std": [0.08, 0.05, 0.08],
35
+ "gyro_mean": [0.0, 0.3, 0.0], # slow forward rotation
36
+ "gyro_std": [0.03, 0.05, 0.03],
37
+ "load_ratios": [0.35, 0.35, 0.15, 0.15], # front-heavy
38
+ "load_std": [0.03, 0.03, 0.02, 0.02],
39
+ "thermal_center": (3, 4),
40
+ "thermal_spread": 2.5,
41
+ },
42
+ 2: { # Backward lean
43
+ "name": "backward_lean",
44
+ "accel_mean": [-1.8, 0.0, 9.6], # tilted backward ~10Β°
45
+ "accel_std": [0.06, 0.05, 0.06],
46
+ "gyro_mean": [0.0, -0.2, 0.0],
47
+ "gyro_std": [0.03, 0.04, 0.03],
48
+ "load_ratios": [0.15, 0.15, 0.35, 0.35], # rear-heavy
49
+ "load_std": [0.02, 0.02, 0.03, 0.03],
50
+ "thermal_center": (5, 4),
51
+ "thermal_spread": 2.0,
52
+ },
53
+ 3: { # Left lean
54
+ "name": "left_lean",
55
+ "accel_mean": [0.0, -2.0, 9.6], # tilted left ~12Β°
56
+ "accel_std": [0.05, 0.07, 0.06],
57
+ "gyro_mean": [0.2, 0.0, 0.0],
58
+ "gyro_std": [0.04, 0.03, 0.03],
59
+ "load_ratios": [0.35, 0.15, 0.35, 0.15], # left-heavy
60
+ "load_std": [0.03, 0.02, 0.03, 0.02],
61
+ "thermal_center": (4, 3),
62
+ "thermal_spread": 2.0,
63
+ },
64
+ 4: { # Right lean
65
+ "name": "right_lean",
66
+ "accel_mean": [0.0, 2.0, 9.6], # tilted right ~12Β°
67
+ "accel_std": [0.05, 0.07, 0.06],
68
+ "gyro_mean": [-0.2, 0.0, 0.0],
69
+ "gyro_std": [0.04, 0.03, 0.03],
70
+ "load_ratios": [0.15, 0.35, 0.15, 0.35], # right-heavy
71
+ "load_std": [0.02, 0.03, 0.02, 0.03],
72
+ "thermal_center": (4, 5),
73
+ "thermal_spread": 2.0,
74
+ },
75
+ 5: { # Slouch (worst posture β€” spine C-curve)
76
+ "name": "slouch",
77
+ "accel_mean": [3.2, 0.0, 9.2], # deep forward tilt ~20Β°
78
+ "accel_std": [0.1, 0.06, 0.1],
79
+ "gyro_mean": [0.0, 0.5, 0.0], # slow drift
80
+ "gyro_std": [0.04, 0.06, 0.04],
81
+ "load_ratios": [0.30, 0.30, 0.20, 0.20], # front-heavy + reclined
82
+ "load_std": [0.04, 0.04, 0.03, 0.03],
83
+ "thermal_center": (3, 4),
84
+ "thermal_spread": 3.0,
85
+ },
86
+ 6: { # Absent (empty chair)
87
+ "name": "absent",
88
+ "accel_mean": [0.0, 0.0, 9.81], # pure gravity
89
+ "accel_std": [0.01, 0.01, 0.01], # very low noise
90
+ "gyro_mean": [0.0, 0.0, 0.0],
91
+ "gyro_std": [0.01, 0.01, 0.01],
92
+ "load_ratios": [0.0, 0.0, 0.0, 0.0], # no weight
93
+ "load_std": [0.005, 0.005, 0.005, 0.005],
94
+ "thermal_center": (4, 4),
95
+ "thermal_spread": 0.5,
96
+ },
97
+ }
98
+
99
+
100
+ def generate_imu_data(posture_id: int, n_samples: int,
101
+ user_weight_kg: float = 70.0,
102
+ noise_scale: float = 1.0,
103
+ rng: Optional[np.random.Generator] = None) -> np.ndarray:
104
+ """
105
+ Generate synthetic IMU data for a given posture.
106
+
107
+ Args:
108
+ posture_id: 0-6 posture class
109
+ n_samples: number of time steps
110
+ user_weight_kg: affects micro-movement amplitude
111
+ noise_scale: multiplier for sensor noise (1.0 = realistic MPU6050)
112
+ rng: random generator for reproducibility
113
+
114
+ Returns:
115
+ imu_data: shape (n_samples, 6) β€” [ax, ay, az, gx, gy, gz]
116
+ """
117
+ if rng is None:
118
+ rng = np.random.default_rng(42)
119
+
120
+ sig = POSTURE_SIGNATURES[posture_id]
121
+
122
+ # Base signal + Gaussian noise + slow drift (realistic sensor behavior)
123
+ accel = np.tile(sig["accel_mean"], (n_samples, 1))
124
+ accel += rng.normal(0, sig["accel_std"], (n_samples, 3)) * noise_scale
125
+
126
+ # Add micro-movements (breathing, fidgeting) β€” frequency ~0.3Hz
127
+ t = np.arange(n_samples) / 50.0
128
+ breathing = 0.02 * np.sin(2 * np.pi * 0.3 * t)
129
+ accel[:, 0] += breathing * (user_weight_kg / 70.0)
130
+ accel[:, 2] += breathing * 0.5
131
+
132
+ # Gyroscope
133
+ gyro = np.tile(sig["gyro_mean"], (n_samples, 1))
134
+ gyro += rng.normal(0, sig["gyro_std"], (n_samples, 3)) * noise_scale
135
+
136
+ # Add slow drift (realistic IMU behavior)
137
+ drift = np.cumsum(rng.normal(0, 0.0005, (n_samples, 3)), axis=0)
138
+ gyro += drift
139
+
140
+ return np.concatenate([accel, gyro], axis=1).astype(np.float32)
141
+
142
+
143
+ def generate_load_cell_data(posture_id: int, n_samples: int,
144
+ user_weight_kg: float = 70.0,
145
+ noise_scale: float = 1.0,
146
+ rng: Optional[np.random.Generator] = None) -> np.ndarray:
147
+ """
148
+ Generate synthetic load cell data for a given posture.
149
+
150
+ Args:
151
+ posture_id: 0-6 posture class
152
+ n_samples: number of time steps
153
+ user_weight_kg: total body weight
154
+ noise_scale: HX711 noise multiplier
155
+ rng: random generator
156
+
157
+ Returns:
158
+ load_data: shape (n_samples, 4) β€” [FL, FR, RL, RR] in kg
159
+ """
160
+ if rng is None:
161
+ rng = np.random.default_rng(42)
162
+
163
+ sig = POSTURE_SIGNATURES[posture_id]
164
+ ratios = np.array(sig["load_ratios"])
165
+
166
+ # Base weight distribution
167
+ base_loads = ratios * user_weight_kg
168
+
169
+ # Temporal variation + noise
170
+ loads = np.tile(base_loads, (n_samples, 1))
171
+ loads += rng.normal(0, sig["load_std"], (n_samples, 4)) * user_weight_kg * noise_scale
172
+
173
+ # Add slow weight shifting (postural micro-adjustments ~0.1Hz)
174
+ t = np.arange(n_samples) / 10.0 # load cells at 10 Hz
175
+ shift = 0.5 * np.sin(2 * np.pi * 0.05 * t)
176
+ loads[:, 0] += shift
177
+ loads[:, 1] -= shift
178
+
179
+ # Ensure non-negative
180
+ loads = np.maximum(loads, 0)
181
+
182
+ return loads.astype(np.float32)
183
+
184
+
185
+ def generate_thermal_data(posture_id: int, n_samples: int,
186
+ ambient_temp: float = 22.0,
187
+ body_temp: float = 34.0,
188
+ rng: Optional[np.random.Generator] = None) -> np.ndarray:
189
+ """
190
+ Generate synthetic AMG8833 thermal grid data.
191
+
192
+ Args:
193
+ posture_id: 0-6 posture class
194
+ n_samples: number of frames (at 1 Hz)
195
+ ambient_temp: room temperature
196
+ body_temp: skin surface temperature
197
+ rng: random generator
198
+
199
+ Returns:
200
+ thermal_data: shape (n_samples, 8, 8) β€” temperature grid in Β°C
201
+ """
202
+ if rng is None:
203
+ rng = np.random.default_rng(42)
204
+
205
+ sig = POSTURE_SIGNATURES[posture_id]
206
+ cy, cx = sig["thermal_center"]
207
+ spread = sig["thermal_spread"]
208
+
209
+ # Create 2D Gaussian heat map (person's thermal signature)
210
+ yy, xx = np.meshgrid(np.arange(8), np.arange(8), indexing='ij')
211
+
212
+ frames = []
213
+ for _ in range(n_samples):
214
+ if posture_id == 6: # absent β€” ambient only
215
+ frame = ambient_temp + rng.normal(0, 0.3, (8, 8))
216
+ else:
217
+ # Gaussian blob centered at posture-specific position
218
+ dist = np.sqrt((xx - cx)**2 + (yy - cy)**2)
219
+ heat = (body_temp - ambient_temp) * np.exp(-dist**2 / (2 * spread**2))
220
+ frame = ambient_temp + heat + rng.normal(0, 0.5, (8, 8))
221
+
222
+ frames.append(frame)
223
+
224
+ return np.array(frames, dtype=np.float32)
225
+
226
+
227
+ def generate_dataset(n_subjects: int = 20,
228
+ samples_per_posture_per_subject: int = 500,
229
+ posture_classes: int = 7,
230
+ seed: int = 42) -> Dict:
231
+ """
232
+ Generate a complete synthetic dataset with multiple virtual subjects.
233
+ Each subject has a unique weight and sitting style.
234
+
235
+ Returns:
236
+ dict with keys: imu_data, load_data, thermal_data, labels, subject_ids,
237
+ subject_weights, feature_names
238
+ """
239
+ rng = np.random.default_rng(seed)
240
+
241
+ all_imu = []
242
+ all_load = []
243
+ all_thermal = []
244
+ all_labels = []
245
+ all_subjects = []
246
+ all_weights = []
247
+
248
+ for subj_id in range(n_subjects):
249
+ # Each subject has unique characteristics
250
+ weight = rng.normal(70, 15) # kg, mean=70, std=15
251
+ weight = np.clip(weight, 40, 120)
252
+ noise_scale = rng.uniform(0.8, 1.2) # individual sensor noise variation
253
+
254
+ for posture_id in range(posture_classes):
255
+ n = samples_per_posture_per_subject
256
+
257
+ imu = generate_imu_data(posture_id, n, weight, noise_scale, rng)
258
+ load = generate_load_cell_data(posture_id, n, weight, noise_scale, rng)
259
+ thermal = generate_thermal_data(posture_id, max(1, n // 50), rng=rng)
260
+
261
+ all_imu.append(imu)
262
+ all_load.append(load)
263
+ all_thermal.append(thermal)
264
+ all_labels.append(np.full(n, posture_id, dtype=np.int32))
265
+ all_subjects.append(np.full(n, subj_id, dtype=np.int32))
266
+ all_weights.append(weight)
267
+
268
+ return {
269
+ "imu_data": np.concatenate(all_imu, axis=0),
270
+ "load_data": np.concatenate(all_load, axis=0),
271
+ "thermal_data": np.concatenate(all_thermal, axis=0),
272
+ "labels": np.concatenate(all_labels, axis=0),
273
+ "subject_ids": np.concatenate(all_subjects, axis=0),
274
+ "subject_weights": np.array(all_weights),
275
+ "n_subjects": n_subjects,
276
+ "n_postures": posture_classes,
277
+ }
278
+
279
+
280
+ def subject_based_split(dataset: Dict, train_ratio: float = 0.7, seed: int = 42):
281
+ """
282
+ Split dataset by subject (NOT by sample) to prevent data leakage.
283
+ This is critical β€” per SSL-Wearables paper, random splits inflate accuracy 5-10%.
284
+
285
+ Returns:
286
+ train_dataset, test_dataset (same dict structure)
287
+ """
288
+ rng = np.random.default_rng(seed)
289
+ n_subjects = dataset["n_subjects"]
290
+
291
+ subject_ids = np.arange(n_subjects)
292
+ rng.shuffle(subject_ids)
293
+
294
+ n_train = int(n_subjects * train_ratio)
295
+ train_subjects = set(subject_ids[:n_train])
296
+ test_subjects = set(subject_ids[n_train:])
297
+
298
+ train_mask = np.isin(dataset["subject_ids"], list(train_subjects))
299
+ test_mask = np.isin(dataset["subject_ids"], list(test_subjects))
300
+
301
+ def apply_mask(d, mask):
302
+ return {
303
+ "imu_data": d["imu_data"][mask],
304
+ "load_data": d["load_data"][mask],
305
+ "labels": d["labels"][mask],
306
+ "subject_ids": d["subject_ids"][mask],
307
+ }
308
+
309
+ print(f"Subject-based split: {len(train_subjects)} train / {len(test_subjects)} test subjects")
310
+ print(f"Samples: {train_mask.sum()} train / {test_mask.sum()} test")
311
+
312
+ return apply_mask(dataset, train_mask), apply_mask(dataset, test_mask)
313
+
314
+
315
+ if __name__ == "__main__":
316
+ print("Generating synthetic SmartChair dataset...")
317
+ dataset = generate_dataset(n_subjects=20, samples_per_posture_per_subject=500)
318
+
319
+ print(f"IMU shape: {dataset['imu_data'].shape}")
320
+ print(f"Load shape: {dataset['load_data'].shape}")
321
+ print(f"Labels shape: {dataset['labels'].shape}")
322
+ print(f"Subjects shape: {dataset['subject_ids'].shape}")
323
+ print(f"Class distribution: {np.bincount(dataset['labels'])}")
324
+
325
+ train, test = subject_based_split(dataset)
326
+ print(f"\nTrain: {train['imu_data'].shape[0]} samples")
327
+ print(f"Test: {test['imu_data'].shape[0]} samples")
smart_chair/firmware/__init__.py ADDED
File without changes
smart_chair/firmware/esp32_firmware.ino ADDED
@@ -0,0 +1,563 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /*
2
+ * SmartChair ESP32-S3 Firmware
3
+ * ─────────────────────────────────────────────────────────────────────────────
4
+ *
5
+ * Hardware:
6
+ * - ESP32-S3 (Xtensa LX7 dual-core, 240MHz, 8MB PSRAM)
7
+ * - MPU6050 IMU (I2C, 50Hz sampling)
8
+ * - 4Γ— HX711 Load Cell Amplifiers (10 SPS each)
9
+ * - AMG8833 8Γ—8 Thermal Grid (I2C, 1Hz)
10
+ * - WS2812B Status LED
11
+ * - Vibration Motor (GPIO PWM)
12
+ *
13
+ * ML Inference:
14
+ * - TFLite Micro INT8 posture classifier (<50KB, <30ms inference)
15
+ * - OR: micromlgen Random Forest (~5KB, <5ms inference)
16
+ *
17
+ * Communication:
18
+ * - UART to Raspberry Pi (for complex inference + cloud)
19
+ * - WiFi for direct MQTT (optional, if no RPi)
20
+ * - BLE for mobile app connection
21
+ *
22
+ * Based on: TinyNav ESP32 deployment (arxiv:2603.11071)
23
+ */
24
+
25
+ #include <Wire.h>
26
+ #include <WiFi.h>
27
+ #include <PubSubClient.h>
28
+ #include <ArduinoJson.h>
29
+
30
+ // ─── TFLite Micro (uncomment if using neural network) ────────────────────────
31
+ // #include "tensorflow/lite/micro/all_ops_resolver.h"
32
+ // #include "tensorflow/lite/micro/micro_interpreter.h"
33
+ // #include "tensorflow/lite/micro/micro_error_reporter.h"
34
+ // #include "model_data.h" // Generated: xxd -i posture_model.tflite > model_data.h
35
+
36
+ // ─── micromlgen (uncomment if using Random Forest) ───────────────────────────
37
+ // #include "posture_classifier.h" // Generated by micromlgen
38
+
39
+ // ═══════════════════════════════════════════════════════════════════════════════
40
+ // CONFIGURATION
41
+ // ═══════════════════════════════════════════════════════════════════════════════
42
+
43
+ // WiFi
44
+ const char* WIFI_SSID = "YOUR_WIFI_SSID";
45
+ const char* WIFI_PASS = "YOUR_WIFI_PASSWORD";
46
+
47
+ // MQTT (AWS IoT Core or local Mosquitto)
48
+ const char* MQTT_BROKER = "YOUR_AWS_IOT_ENDPOINT.iot.region.amazonaws.com";
49
+ const int MQTT_PORT = 8883;
50
+ const char* DEVICE_ID = "chair_001";
51
+
52
+ // Pin assignments
53
+ #define IMU_SDA 21
54
+ #define IMU_SCL 22
55
+ #define HX711_DOUT_FL 32
56
+ #define HX711_SCK_FL 33
57
+ #define HX711_DOUT_FR 25
58
+ #define HX711_SCK_FR 26
59
+ #define HX711_DOUT_RL 27
60
+ #define HX711_SCK_RL 14
61
+ #define HX711_DOUT_RR 12
62
+ #define HX711_SCK_RR 13
63
+ #define LED_PIN 48 // WS2812B
64
+ #define VIBRATION_PIN 4 // PWM motor
65
+ #define THERMAL_SDA 21 // shared I2C bus
66
+ #define THERMAL_SCL 22
67
+
68
+ // Sensor sampling
69
+ #define IMU_SAMPLE_HZ 50
70
+ #define LOAD_SAMPLE_HZ 10
71
+ #define THERMAL_SAMPLE_HZ 1
72
+ #define INFERENCE_INTERVAL_MS 2560 // 2.56s window (128 samples @ 50Hz)
73
+
74
+ // ML Model
75
+ #define WINDOW_SIZE 128
76
+ #define NUM_FEATURES 11 // 6 IMU + 4 load + 1 total_weight (for tabular)
77
+ #define NUM_CLASSES 7
78
+ #define TFLITE_ARENA_KB 50
79
+
80
+ // ═══════════════════════════════════════════════════════════════════════════════
81
+ // GLOBAL STATE
82
+ // ═══════════════════════════════════════════════════════════════════════════════
83
+
84
+ // Sensor buffers
85
+ float imu_buffer[WINDOW_SIZE][6]; // [ax, ay, az, gx, gy, gz]
86
+ float load_buffer[WINDOW_SIZE][4]; // [FL, FR, RL, RR]
87
+ int buffer_idx = 0;
88
+ bool buffer_full = false;
89
+
90
+ // Current readings
91
+ float accel[3] = {0, 0, 9.81};
92
+ float gyro[3] = {0, 0, 0};
93
+ float load_cells[4] = {0, 0, 0, 0};
94
+ float thermal_max = 22.0;
95
+
96
+ // Inference state
97
+ int current_posture = 0; // 0-6
98
+ float posture_confidence = 0.0;
99
+ unsigned long last_inference_ms = 0;
100
+ unsigned long session_start_ms = 0;
101
+
102
+ // Posture names
103
+ const char* POSTURE_NAMES[] = {
104
+ "upright", "forward_lean", "backward_lean",
105
+ "left_lean", "right_lean", "slouch", "absent"
106
+ };
107
+
108
+ // MQTT
109
+ WiFiClient espClient;
110
+ PubSubClient mqtt(espClient);
111
+
112
+ // ═══════════════════════════════════════════════════════════════════════════════
113
+ // SENSOR READING
114
+ // ═══════════════════════════════════════════════════════════════════════════════
115
+
116
+ void initMPU6050() {
117
+ Wire.begin(IMU_SDA, IMU_SCL);
118
+ Wire.beginTransmission(0x68); // MPU6050 address
119
+ Wire.write(0x6B); // PWR_MGMT_1 register
120
+ Wire.write(0x00); // Wake up
121
+ Wire.endTransmission(true);
122
+
123
+ // Set accelerometer range to Β±4g
124
+ Wire.beginTransmission(0x68);
125
+ Wire.write(0x1C);
126
+ Wire.write(0x08); // Β±4g
127
+ Wire.endTransmission(true);
128
+
129
+ // Set gyroscope range to Β±500Β°/s
130
+ Wire.beginTransmission(0x68);
131
+ Wire.write(0x1B);
132
+ Wire.write(0x08); // Β±500Β°/s
133
+ Wire.endTransmission(true);
134
+
135
+ // Set sample rate to 50Hz
136
+ Wire.beginTransmission(0x68);
137
+ Wire.write(0x19);
138
+ Wire.write(0x09); // 1000/(1+9) = 100Hz internal, DLPF = 50Hz
139
+ Wire.endTransmission(true);
140
+
141
+ Serial.println("[IMU] MPU6050 initialized @ 50Hz, Β±4g, Β±500Β°/s");
142
+ }
143
+
144
+ void readMPU6050() {
145
+ Wire.beginTransmission(0x68);
146
+ Wire.write(0x3B); // Starting register
147
+ Wire.endTransmission(false);
148
+ Wire.requestFrom(0x68, 14, true);
149
+
150
+ // Accelerometer (m/sΒ²)
151
+ accel[0] = (Wire.read() << 8 | Wire.read()) / 8192.0 * 9.81; // Β±4g
152
+ accel[1] = (Wire.read() << 8 | Wire.read()) / 8192.0 * 9.81;
153
+ accel[2] = (Wire.read() << 8 | Wire.read()) / 8192.0 * 9.81;
154
+
155
+ // Temperature (skip)
156
+ Wire.read(); Wire.read();
157
+
158
+ // Gyroscope (rad/s)
159
+ gyro[0] = (Wire.read() << 8 | Wire.read()) / 65.5 * (M_PI / 180.0); // Β±500Β°/s
160
+ gyro[1] = (Wire.read() << 8 | Wire.read()) / 65.5 * (M_PI / 180.0);
161
+ gyro[2] = (Wire.read() << 8 | Wire.read()) / 65.5 * (M_PI / 180.0);
162
+ }
163
+
164
+ // ─── HX711 Load Cell Reading ─────────────────────────────────────────────────
165
+
166
+ long readHX711(int dout_pin, int sck_pin) {
167
+ // Wait for HX711 to be ready
168
+ while (digitalRead(dout_pin));
169
+
170
+ long value = 0;
171
+ for (int i = 0; i < 24; i++) {
172
+ digitalWrite(sck_pin, HIGH);
173
+ delayMicroseconds(1);
174
+ value = (value << 1) | digitalRead(dout_pin);
175
+ digitalWrite(sck_pin, LOW);
176
+ delayMicroseconds(1);
177
+ }
178
+
179
+ // 25th pulse: gain = 128 (channel A)
180
+ digitalWrite(sck_pin, HIGH);
181
+ delayMicroseconds(1);
182
+ digitalWrite(sck_pin, LOW);
183
+ delayMicroseconds(1);
184
+
185
+ // Sign extend
186
+ if (value & 0x800000) value |= 0xFF000000;
187
+
188
+ return value;
189
+ }
190
+
191
+ void readLoadCells() {
192
+ static float calibration[4] = {420.0, 420.0, 420.0, 420.0}; // calibration factors
193
+ static long tare[4] = {0, 0, 0, 0};
194
+
195
+ long raw_fl = readHX711(HX711_DOUT_FL, HX711_SCK_FL);
196
+ long raw_fr = readHX711(HX711_DOUT_FR, HX711_SCK_FR);
197
+ long raw_rl = readHX711(HX711_DOUT_RL, HX711_SCK_RL);
198
+ long raw_rr = readHX711(HX711_DOUT_RR, HX711_SCK_RR);
199
+
200
+ load_cells[0] = (raw_fl - tare[0]) / calibration[0]; // FL in kg
201
+ load_cells[1] = (raw_fr - tare[1]) / calibration[1]; // FR in kg
202
+ load_cells[2] = (raw_rl - tare[2]) / calibration[2]; // RL in kg
203
+ load_cells[3] = (raw_rr - tare[3]) / calibration[3]; // RR in kg
204
+
205
+ // Clamp to reasonable range
206
+ for (int i = 0; i < 4; i++) {
207
+ load_cells[i] = max(0.0f, min(200.0f, load_cells[i]));
208
+ }
209
+ }
210
+
211
+ // ─── Thermal Presence Detection ──────────────────────────────────────────────
212
+
213
+ bool readThermalPresence() {
214
+ // AMG8833 at I2C address 0x69
215
+ Wire.beginTransmission(0x69);
216
+ Wire.write(0x80); // Pixel data start register
217
+ Wire.endTransmission(false);
218
+
219
+ float max_temp = 0;
220
+ for (int i = 0; i < 64; i++) {
221
+ Wire.requestFrom(0x69, 2);
222
+ int16_t raw = Wire.read() | (Wire.read() << 8);
223
+ float temp = raw * 0.25; // 0.25Β°C resolution
224
+ if (temp > max_temp) max_temp = temp;
225
+ }
226
+
227
+ thermal_max = max_temp;
228
+ return max_temp > 28.0; // Human presence threshold
229
+ }
230
+
231
+ // ═══════════════════════════════════════════════════════════════════════════════
232
+ // FEATURE EXTRACTION (on-device)
233
+ // ═══════════════════════════════════════════════════════════════════════════════
234
+
235
+ void extractFeatures(float* features) {
236
+ // Tabular features for Random Forest / ensemble classifier
237
+ // 11 features: 6 IMU stats + 4 load cell ratios + 1 total weight
238
+
239
+ float acc_mean[3] = {0}, gyro_mean[3] = {0};
240
+ float total_weight_sum = 0;
241
+
242
+ for (int i = 0; i < WINDOW_SIZE; i++) {
243
+ for (int j = 0; j < 3; j++) {
244
+ acc_mean[j] += imu_buffer[i][j];
245
+ gyro_mean[j] += imu_buffer[i][j + 3];
246
+ }
247
+ for (int j = 0; j < 4; j++) {
248
+ total_weight_sum += load_buffer[i][j];
249
+ }
250
+ }
251
+
252
+ for (int j = 0; j < 3; j++) {
253
+ acc_mean[j] /= WINDOW_SIZE;
254
+ gyro_mean[j] /= WINDOW_SIZE;
255
+ }
256
+
257
+ float total_weight = total_weight_sum / WINDOW_SIZE;
258
+
259
+ // Compute pitch and roll (degrees)
260
+ float pitch = atan2(acc_mean[0], sqrt(acc_mean[1]*acc_mean[1] + acc_mean[2]*acc_mean[2])) * 180.0 / M_PI;
261
+ float roll = atan2(acc_mean[1], sqrt(acc_mean[0]*acc_mean[0] + acc_mean[2]*acc_mean[2])) * 180.0 / M_PI;
262
+
263
+ // Load cell ratios (last reading)
264
+ float lc_total = load_cells[0] + load_cells[1] + load_cells[2] + load_cells[3] + 0.001;
265
+
266
+ // Pack features
267
+ features[0] = pitch;
268
+ features[1] = roll;
269
+ features[2] = acc_mean[2]; // vertical acceleration
270
+ features[3] = gyro_mean[0]; // roll rate
271
+ features[4] = gyro_mean[1]; // pitch rate
272
+ features[5] = gyro_mean[2]; // yaw rate
273
+ features[6] = load_cells[0] / lc_total; // FL ratio
274
+ features[7] = load_cells[1] / lc_total; // FR ratio
275
+ features[8] = load_cells[2] / lc_total; // RL ratio
276
+ features[9] = load_cells[3] / lc_total; // RR ratio
277
+ features[10] = total_weight;
278
+ }
279
+
280
+ // ═══════════════════════════════════════════════════════════════════════════════
281
+ // ML INFERENCE
282
+ // ═══════════════════════════════════════════════════════════════════════════════
283
+
284
+ /*
285
+ * Option A: TFLite Micro (neural network)
286
+ * Uncomment the TFLite includes above and this function
287
+ */
288
+ /*
289
+ static uint8_t tensor_arena[TFLITE_ARENA_KB * 1024];
290
+
291
+ void runTFLiteInference(float* features) {
292
+ static bool initialized = false;
293
+ static tflite::MicroInterpreter* interpreter = nullptr;
294
+
295
+ if (!initialized) {
296
+ static tflite::AllOpsResolver resolver;
297
+ const tflite::Model* model = tflite::GetModel(g_model);
298
+ static tflite::MicroInterpreter static_interpreter(
299
+ model, resolver, tensor_arena, TFLITE_ARENA_KB * 1024);
300
+ interpreter = &static_interpreter;
301
+ interpreter->AllocateTensors();
302
+ initialized = true;
303
+ }
304
+
305
+ // Copy features to input tensor
306
+ TfLiteTensor* input = interpreter->input(0);
307
+ for (int i = 0; i < NUM_FEATURES; i++) {
308
+ input->data.f[i] = features[i]; // or quantize for INT8
309
+ }
310
+
311
+ // Run inference
312
+ interpreter->Invoke();
313
+
314
+ // Get output
315
+ TfLiteTensor* output = interpreter->output(0);
316
+ float max_prob = -1;
317
+ for (int i = 0; i < NUM_CLASSES; i++) {
318
+ float prob = output->data.f[i];
319
+ if (prob > max_prob) {
320
+ max_prob = prob;
321
+ current_posture = i;
322
+ }
323
+ }
324
+ posture_confidence = max_prob;
325
+ }
326
+ */
327
+
328
+ /*
329
+ * Option B: micromlgen Random Forest (lighter, no framework needed)
330
+ * Uncomment posture_classifier.h include and this function
331
+ */
332
+ /*
333
+ void runRFInference(float* features) {
334
+ current_posture = posture_classifier_predict(features);
335
+ posture_confidence = 0.95; // RF doesn't provide per-sample probabilities
336
+ }
337
+ */
338
+
339
+ // Rule-based fallback (works without ML model, for testing)
340
+ void runRuleBasedInference(float* features) {
341
+ float pitch = features[0];
342
+ float roll = features[1];
343
+ float total_weight = features[10];
344
+
345
+ // No one sitting
346
+ if (total_weight < 5.0 || !readThermalPresence()) {
347
+ current_posture = 6; // absent
348
+ posture_confidence = 0.99;
349
+ return;
350
+ }
351
+
352
+ // Rule-based posture detection from angles
353
+ if (abs(pitch) < 10 && abs(roll) < 10) {
354
+ current_posture = 0; // upright
355
+ posture_confidence = 0.85;
356
+ } else if (pitch > 20) {
357
+ current_posture = 5; // slouch (deep forward)
358
+ posture_confidence = 0.80;
359
+ } else if (pitch > 10) {
360
+ current_posture = 1; // forward lean
361
+ posture_confidence = 0.75;
362
+ } else if (pitch < -10) {
363
+ current_posture = 2; // backward lean
364
+ posture_confidence = 0.75;
365
+ } else if (roll < -10) {
366
+ current_posture = 3; // left lean
367
+ posture_confidence = 0.70;
368
+ } else if (roll > 10) {
369
+ current_posture = 4; // right lean
370
+ posture_confidence = 0.70;
371
+ }
372
+ }
373
+
374
+ // ═══════════════════════════════════════════════════════════════════════════════
375
+ // MQTT PUBLISHING
376
+ // ═══════════════════════════════════════════════════════════════════════════════
377
+
378
+ void publishTelemetry() {
379
+ StaticJsonDocument<512> doc;
380
+
381
+ doc["device_id"] = DEVICE_ID;
382
+ doc["timestamp"] = millis();
383
+ doc["posture"] = current_posture;
384
+ doc["posture_name"] = POSTURE_NAMES[current_posture];
385
+ doc["confidence"] = posture_confidence;
386
+
387
+ float lc_total = load_cells[0] + load_cells[1] + load_cells[2] + load_cells[3];
388
+ float cop_x = (load_cells[1] + load_cells[3] - load_cells[0] - load_cells[2]) / (lc_total + 0.001);
389
+ float cop_y = (load_cells[0] + load_cells[1] - load_cells[2] - load_cells[3]) / (lc_total + 0.001);
390
+
391
+ doc["cop_x"] = cop_x;
392
+ doc["cop_y"] = cop_y;
393
+ doc["weight_kg"] = lc_total;
394
+ doc["thermal_present"] = thermal_max > 28.0;
395
+ doc["session_min"] = (millis() - session_start_ms) / 60000.0;
396
+
397
+ char buffer[512];
398
+ serializeJson(doc, buffer);
399
+
400
+ // Publish to MQTT or send via UART to RPi
401
+ if (mqtt.connected()) {
402
+ char topic[64];
403
+ snprintf(topic, sizeof(topic), "smartchair/%s/telemetry", DEVICE_ID);
404
+ mqtt.publish(topic, buffer);
405
+ }
406
+
407
+ // Always send to RPi via UART for complex processing
408
+ Serial.println(buffer);
409
+ }
410
+
411
+ // ═══════════════════════════════════════════════════════════════════════════════
412
+ // HAPTIC & VISUAL FEEDBACK
413
+ // ═══════════════════════════════════════════════════════════════════════════════
414
+
415
+ void updateFeedback() {
416
+ // LED color based on posture quality
417
+ // Green = good, Yellow = moderate, Red = bad
418
+ switch(current_posture) {
419
+ case 0: // upright
420
+ // setLED(0, 255, 0); // green
421
+ break;
422
+ case 1: // forward lean
423
+ case 2: // backward lean
424
+ // setLED(255, 165, 0); // orange
425
+ break;
426
+ case 3: // left lean
427
+ case 4: // right lean
428
+ // setLED(255, 255, 0); // yellow
429
+ break;
430
+ case 5: // slouch
431
+ // setLED(255, 0, 0); // red
432
+ // Gentle vibration alert
433
+ analogWrite(VIBRATION_PIN, 128);
434
+ delay(200);
435
+ analogWrite(VIBRATION_PIN, 0);
436
+ break;
437
+ }
438
+ }
439
+
440
+ // ═══════════════════════════════════════════════════════════════════════════════
441
+ // MAIN SETUP & LOOP
442
+ // ═══════════════════════════════════════════════════════════════════════════════
443
+
444
+ void setup() {
445
+ Serial.begin(115200);
446
+ Serial.println("\n╔══════════════════════════════════╗");
447
+ Serial.println("β•‘ SmartChair ESP32-S3 Firmware β•‘");
448
+ Serial.println("β•‘ v1.0 β€” Posture Intelligence β•‘");
449
+ Serial.println("β•šβ•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•\n");
450
+
451
+ // Initialize I2C
452
+ Wire.begin(IMU_SDA, IMU_SCL);
453
+ Wire.setClock(400000); // 400kHz I2C
454
+
455
+ // Initialize sensors
456
+ initMPU6050();
457
+
458
+ // HX711 pins
459
+ pinMode(HX711_DOUT_FL, INPUT);
460
+ pinMode(HX711_SCK_FL, OUTPUT);
461
+ pinMode(HX711_DOUT_FR, INPUT);
462
+ pinMode(HX711_SCK_FR, OUTPUT);
463
+ pinMode(HX711_DOUT_RL, INPUT);
464
+ pinMode(HX711_SCK_RL, OUTPUT);
465
+ pinMode(HX711_DOUT_RR, INPUT);
466
+ pinMode(HX711_SCK_RR, OUTPUT);
467
+
468
+ // Feedback pins
469
+ pinMode(VIBRATION_PIN, OUTPUT);
470
+
471
+ // WiFi
472
+ WiFi.begin(WIFI_SSID, WIFI_PASS);
473
+ int wifi_attempts = 0;
474
+ while (WiFi.status() != WL_CONNECTED && wifi_attempts < 20) {
475
+ delay(500);
476
+ Serial.print(".");
477
+ wifi_attempts++;
478
+ }
479
+
480
+ if (WiFi.status() == WL_CONNECTED) {
481
+ Serial.printf("\n[WiFi] Connected: %s\n", WiFi.localIP().toString().c_str());
482
+
483
+ // MQTT setup
484
+ mqtt.setServer(MQTT_BROKER, MQTT_PORT);
485
+ if (mqtt.connect(DEVICE_ID)) {
486
+ Serial.println("[MQTT] Connected to broker");
487
+ }
488
+ } else {
489
+ Serial.println("\n[WiFi] Offline mode β€” data sent via UART only");
490
+ }
491
+
492
+ session_start_ms = millis();
493
+ Serial.println("[Ready] SmartChair sensor fusion active\n");
494
+ }
495
+
496
+ void loop() {
497
+ unsigned long now = millis();
498
+
499
+ // ── IMU Sampling (50 Hz) ─────────────────────────────────────────────
500
+ static unsigned long last_imu = 0;
501
+ if (now - last_imu >= 20) { // 20ms = 50Hz
502
+ readMPU6050();
503
+
504
+ imu_buffer[buffer_idx][0] = accel[0];
505
+ imu_buffer[buffer_idx][1] = accel[1];
506
+ imu_buffer[buffer_idx][2] = accel[2];
507
+ imu_buffer[buffer_idx][3] = gyro[0];
508
+ imu_buffer[buffer_idx][4] = gyro[1];
509
+ imu_buffer[buffer_idx][5] = gyro[2];
510
+
511
+ last_imu = now;
512
+ }
513
+
514
+ // ── Load Cell Sampling (10 Hz) ───────────────────────────────────────
515
+ static unsigned long last_load = 0;
516
+ if (now - last_load >= 100) { // 100ms = 10Hz
517
+ readLoadCells();
518
+
519
+ load_buffer[buffer_idx][0] = load_cells[0];
520
+ load_buffer[buffer_idx][1] = load_cells[1];
521
+ load_buffer[buffer_idx][2] = load_cells[2];
522
+ load_buffer[buffer_idx][3] = load_cells[3];
523
+
524
+ last_load = now;
525
+ }
526
+
527
+ // ── Buffer Management ────────────────────────────────────────────────
528
+ buffer_idx++;
529
+ if (buffer_idx >= WINDOW_SIZE) {
530
+ buffer_idx = 0;
531
+ buffer_full = true;
532
+ }
533
+
534
+ // ── ML Inference (every 2.56 seconds) ────────────────────────────────
535
+ if (buffer_full && (now - last_inference_ms >= INFERENCE_INTERVAL_MS)) {
536
+ float features[NUM_FEATURES];
537
+ extractFeatures(features);
538
+
539
+ // Choose inference method:
540
+ // runTFLiteInference(features); // Option A: Neural network
541
+ // runRFInference(features); // Option B: Random Forest
542
+ runRuleBasedInference(features); // Option C: Rule-based fallback
543
+
544
+ updateFeedback();
545
+
546
+ last_inference_ms = now;
547
+ }
548
+
549
+ // ── Telemetry Publishing (every 5 seconds) ───────────────────────────
550
+ static unsigned long last_publish = 0;
551
+ if (now - last_publish >= 5000) {
552
+ publishTelemetry();
553
+ last_publish = now;
554
+ }
555
+
556
+ // ── MQTT Maintenance ─────────────────────────────────────────────────
557
+ if (mqtt.connected()) {
558
+ mqtt.loop();
559
+ }
560
+
561
+ // Small delay to prevent watchdog timeout
562
+ delay(1);
563
+ }
smart_chair/main.py ADDED
@@ -0,0 +1,416 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ SmartChair Main Orchestrator β€” Integrates all subsystems.
3
+
4
+ This is the Raspberry Pi main loop that:
5
+ 1. Receives sensor data from ESP32 via UART
6
+ 2. Runs posture classification (ensemble or MLSTM-FCN)
7
+ 3. Computes RULA risk score
8
+ 4. Detects fatigue and drowsiness
9
+ 5. Recognizes the current user
10
+ 6. Manages break recommendations
11
+ 7. Suggests exercises
12
+ 8. Updates gamification scores
13
+ 9. Publishes to AWS IoT Core
14
+ 10. Feeds the dashboard
15
+
16
+ Architecture:
17
+ ESP32 β†’ UART (115200) β†’ RPi Main Loop β†’ AWS IoT Core
18
+ β†’ Local Dashboard (Flask/Streamlit)
19
+ β†’ SQLite (local history)
20
+ """
21
+
22
+ import json
23
+ import time
24
+ import numpy as np
25
+ from datetime import datetime
26
+ from typing import Dict, Optional
27
+
28
+ # SmartChair modules
29
+ import sys
30
+ sys.path.insert(0, '/app/smart_chair')
31
+ sys.path.insert(0, '/app')
32
+
33
+ from config.settings import *
34
+ from utils.feature_engineering import compute_imu_angles, compute_center_of_pressure
35
+ from ml_models.posture_classifier import EnsemblePostureClassifier
36
+ from ml_models.spine_risk_predictor import RULAScorer, SpineRiskPredictor, FatigueDetector, InjuryRiskAlertSystem
37
+ from ml_models.user_recognition import MultiUserRecognizer, PersonalizedSittingModel
38
+ from ml_models.break_recommendation import AdaptiveBreakEngine, ExerciseSuggestionEngine
39
+ from ml_models.gamification import PostureScorer, GamificationEngine
40
+ from cloud.aws_iot_integration import AWSIoTClient, TelemetryPayload, AlertPayload
41
+
42
+
43
+ class SmartChairSystem:
44
+ """
45
+ Main system orchestrator. Coordinates all subsystems in real-time.
46
+ """
47
+
48
+ def __init__(self, device_id: str = "chair_001"):
49
+ print("╔══════════════════════════════════════════════╗")
50
+ print("β•‘ SmartChair AI System β€” Initializing... β•‘")
51
+ print("β•šβ•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•")
52
+
53
+ self.device_id = device_id
54
+ self.current_user = "unknown"
55
+ self.session_start = datetime.now()
56
+ self.current_posture = "upright"
57
+ self.current_posture_id = 0
58
+ self.posture_start_time = datetime.now()
59
+
60
+ # ── Initialize Subsystems ──────────────────────────────────────
61
+ print("[1/8] Loading posture classifier...")
62
+ self.classifier = EnsemblePostureClassifier()
63
+
64
+ print("[2/8] Initializing RULA scorer...")
65
+ self.rula_scorer = RULAScorer()
66
+
67
+ print("[3/8] Initializing spine risk predictor...")
68
+ self.risk_predictor = SpineRiskPredictor()
69
+
70
+ print("[4/8] Initializing fatigue detector...")
71
+ self.fatigue_detector = FatigueDetector()
72
+
73
+ print("[5/8] Initializing user recognizer...")
74
+ self.user_recognizer = MultiUserRecognizer()
75
+ self.user_models = {} # user_id β†’ PersonalizedSittingModel
76
+
77
+ print("[6/8] Initializing break engine...")
78
+ self.break_engine = AdaptiveBreakEngine()
79
+ self.exercise_engine = ExerciseSuggestionEngine()
80
+
81
+ print("[7/8] Initializing gamification...")
82
+ self.posture_scorer = PostureScorer()
83
+ self.gamification = {} # user_id β†’ GamificationEngine
84
+
85
+ print("[8/8] Initializing injury alert system...")
86
+ self.alert_system = InjuryRiskAlertSystem()
87
+
88
+ # AWS IoT (optional)
89
+ self.iot_client = AWSIoTClient(device_id)
90
+
91
+ print("\nβœ… SmartChair AI System ready!\n")
92
+
93
+ def process_sensor_reading(self, imu_data: np.ndarray, load_data: np.ndarray,
94
+ thermal_present: bool = True) -> Dict:
95
+ """
96
+ Process a single sensor reading through the full pipeline.
97
+ Called at the inference rate (every ~2.56 seconds).
98
+
99
+ Args:
100
+ imu_data: shape (128, 6) β€” window of IMU readings
101
+ load_data: shape (128, 4) β€” window of load cell readings
102
+ thermal_present: whether thermal sensor detects a person
103
+
104
+ Returns:
105
+ Full system state dict with all subsystem outputs
106
+ """
107
+ now = datetime.now()
108
+ result = {"timestamp": now.isoformat(), "device_id": self.device_id}
109
+
110
+ # ── 1. Human Presence Detection ───────────────────────────────
111
+ total_weight = np.mean(np.sum(load_data, axis=1))
112
+ if not thermal_present or total_weight < 5.0:
113
+ self.current_posture = "absent"
114
+ self.current_posture_id = 6
115
+ result["posture"] = {"class": 6, "name": "absent", "confidence": 0.99}
116
+ return result
117
+
118
+ # ── 2. Posture Classification ──────────��──────────────────────
119
+ if self.classifier.is_trained:
120
+ posture_id = self.classifier.predict(imu_data, load_data)
121
+ proba = self.classifier.predict_proba(imu_data, load_data)
122
+ confidence = float(np.max(proba))
123
+ else:
124
+ # Rule-based fallback
125
+ pitch, roll = compute_imu_angles(imu_data[:, :3])
126
+ avg_pitch = np.mean(pitch)
127
+ avg_roll = np.mean(roll)
128
+ posture_id, confidence = self._rule_based_classify(avg_pitch, avg_roll, total_weight)
129
+ proba = np.zeros(NUM_POSTURE_CLASSES)
130
+ proba[posture_id] = confidence
131
+
132
+ posture_name = POSTURE_CLASSES[posture_id]
133
+
134
+ # Track posture change timing
135
+ if posture_id != self.current_posture_id:
136
+ self.posture_start_time = now
137
+
138
+ self.current_posture_id = posture_id
139
+ self.current_posture = posture_name
140
+
141
+ posture_duration_min = (now - self.posture_start_time).total_seconds() / 60
142
+
143
+ result["posture"] = {
144
+ "class": int(posture_id),
145
+ "name": posture_name,
146
+ "confidence": round(confidence, 3),
147
+ "duration_minutes": round(posture_duration_min, 1),
148
+ "probabilities": {POSTURE_CLASSES[i]: round(float(p), 3) for i, p in enumerate(proba)},
149
+ }
150
+
151
+ # ── 3. RULA Ergonomic Risk Score ──────────────────────────────
152
+ pitch, roll = compute_imu_angles(imu_data[:, :3])
153
+ avg_pitch = float(np.mean(pitch))
154
+ avg_roll = float(np.mean(roll))
155
+ avg_yaw_rate = float(np.mean(imu_data[:, 5]))
156
+
157
+ rula = self.rula_scorer.compute_rula_score(
158
+ avg_pitch, avg_roll, avg_yaw_rate,
159
+ posture_duration_min, is_static=True
160
+ )
161
+ result["rula"] = rula
162
+
163
+ # ── 4. Spine Risk Prediction ──────────────────────────────────
164
+ self.risk_predictor.record_posture(posture_name, rula["rula_score"])
165
+ session_risk = self.risk_predictor.compute_session_risk(lookback_minutes=60)
166
+ result["spine_risk"] = session_risk
167
+
168
+ # ── 5. Fatigue & Drowsiness Detection ─────────────────────────
169
+ for i in range(min(10, len(load_data))):
170
+ self.fatigue_detector.update(load_data[i], imu_data[i * 5] if i * 5 < len(imu_data) else imu_data[-1])
171
+
172
+ fatigue = self.fatigue_detector.analyze_fatigue()
173
+ result["fatigue"] = fatigue
174
+
175
+ # ── 6. User Recognition ───────────────────────────────────────
176
+ if self.user_recognizer.is_trained:
177
+ user_result = self.user_recognizer.recognize(load_data[:50])
178
+ self.current_user = user_result["user_id"]
179
+ result["user"] = user_result
180
+ else:
181
+ result["user"] = {"user_id": "unknown", "confidence": 0}
182
+
183
+ # ── 7. Break Recommendation ───────────────────────────────────
184
+ self.break_engine.update_posture(posture_name, rula["rula_score"])
185
+
186
+ personal_interval = None
187
+ if self.current_user in self.user_models:
188
+ personal_interval = self.user_models[self.current_user].get_optimal_break_interval()
189
+
190
+ break_rec = self.break_engine.compute_urgency(
191
+ fatigue_level=fatigue["fatigue_level"],
192
+ current_rula=rula["rula_score"],
193
+ personal_interval=personal_interval,
194
+ )
195
+ result["break_recommendation"] = break_rec
196
+
197
+ # ── 8. Exercise Suggestions (when break is needed) ────────────
198
+ if break_rec["urgency_level"] in ["moderate", "urgent"]:
199
+ session_min = (now - self.session_start).total_seconds() / 60
200
+ exercises = self.exercise_engine.suggest_exercises(
201
+ dominant_posture=posture_name,
202
+ rula_score=rula["rula_score"],
203
+ session_duration_min=session_min,
204
+ break_type="micro" if break_rec["urgency_level"] == "moderate" else "full",
205
+ )
206
+ result["exercise_suggestions"] = exercises
207
+
208
+ # ── 9. Gamification Update ────────────────────────────────────
209
+ self.posture_scorer.record_posture(posture_name, 2.56 / 60) # ~2.56s per reading
210
+
211
+ if self.current_user != "unknown" and self.current_user in self.gamification:
212
+ if posture_name == "upright":
213
+ self.gamification[self.current_user].award_posture_points(2.56 / 60)
214
+
215
+ daily_score = self.posture_scorer.compute_daily_score()
216
+ result["posture_score"] = daily_score
217
+
218
+ # ── 10. Injury Risk Alerts ────────────────────────────────────
219
+ acute_alert = self.alert_system.check_acute_risk(
220
+ rula["rula_score"], posture_name, posture_duration_min
221
+ )
222
+ if acute_alert:
223
+ result["alert"] = acute_alert
224
+ # Publish alert to cloud
225
+ self._publish_alert(acute_alert)
226
+
227
+ # ── 11. Publish to Cloud ──────────────────────────────────────
228
+ self._publish_telemetry(result)
229
+
230
+ return result
231
+
232
+ def _rule_based_classify(self, pitch: float, roll: float, weight: float):
233
+ """Fallback rule-based classification when ML model not loaded."""
234
+ if weight < 5.0:
235
+ return 6, 0.99 # absent
236
+ if abs(pitch) < 10 and abs(roll) < 10:
237
+ return 0, 0.85 # upright
238
+ if pitch > 20:
239
+ return 5, 0.80 # slouch
240
+ if pitch > 10:
241
+ return 1, 0.75 # forward lean
242
+ if pitch < -10:
243
+ return 2, 0.75 # backward lean
244
+ if roll < -10:
245
+ return 3, 0.70 # left lean
246
+ if roll > 10:
247
+ return 4, 0.70 # right lean
248
+ return 0, 0.60 # default upright
249
+
250
+ def _publish_telemetry(self, result: Dict):
251
+ """Publish processed data to AWS IoT Core."""
252
+ payload = TelemetryPayload(
253
+ device_id=self.device_id,
254
+ timestamp=int(time.time() * 1000),
255
+ user_id=self.current_user,
256
+ posture_class=result["posture"]["class"],
257
+ posture_name=result["posture"]["name"],
258
+ posture_confidence=result["posture"]["confidence"],
259
+ rula_score=result["rula"]["rula_score"],
260
+ risk_level=result["rula"]["risk_level"],
261
+ fatigue_level=result["fatigue"]["fatigue_level"],
262
+ session_duration_min=result["break_recommendation"]["session_duration_min"],
263
+ )
264
+ self.iot_client.publish_telemetry(payload)
265
+
266
+ def _publish_alert(self, alert: Dict):
267
+ """Publish alert to AWS IoT Core."""
268
+ payload = AlertPayload(
269
+ device_id=self.device_id,
270
+ timestamp=int(time.time() * 1000),
271
+ alert_type=alert["type"],
272
+ severity=alert["severity"],
273
+ message=alert["message"],
274
+ action=alert.get("action", ""),
275
+ user_id=self.current_user,
276
+ )
277
+ self.iot_client.publish_alert(payload)
278
+
279
+ def enroll_user(self, user_id: str, sit_events: list):
280
+ """Enroll a new user for recognition."""
281
+ result = self.user_recognizer.enroll_user(user_id, sit_events)
282
+ if result["status"] == "enrolled":
283
+ self.user_models[user_id] = PersonalizedSittingModel(user_id)
284
+ self.gamification[user_id] = GamificationEngine(user_id)
285
+ return result
286
+
287
+ def get_dashboard_data(self) -> Dict:
288
+ """Get all data needed for the dashboard."""
289
+ return {
290
+ "device_id": self.device_id,
291
+ "current_user": self.current_user,
292
+ "current_posture": self.current_posture,
293
+ "session_start": self.session_start.isoformat(),
294
+ "session_duration_min": (datetime.now() - self.session_start).total_seconds() / 60,
295
+ "daily_score": self.posture_scorer.compute_daily_score(),
296
+ "weekly_score": self.posture_scorer.compute_weekly_score(),
297
+ "trend_data": self.posture_scorer.get_trend_data(days=30),
298
+ "fatigue": self.fatigue_detector.analyze_fatigue(),
299
+ "spine_risk": self.risk_predictor.compute_session_risk(),
300
+ "active_alerts": self.alert_system.get_active_alerts(),
301
+ "gamification": {
302
+ uid: engine.get_profile()
303
+ for uid, engine in self.gamification.items()
304
+ },
305
+ }
306
+
307
+
308
+ def run_demo():
309
+ """
310
+ Demo: Run SmartChair system with synthetic data.
311
+ Shows all 12 features working together.
312
+ """
313
+ from data_collection.synthetic_data_generator import (
314
+ generate_imu_data, generate_load_cell_data, generate_dataset, subject_based_split
315
+ )
316
+
317
+ print("\n" + "=" * 70)
318
+ print("SMARTCHAIR AI SYSTEM β€” FULL PIPELINE DEMO")
319
+ print("=" * 70)
320
+
321
+ # Initialize system
322
+ system = SmartChairSystem(device_id="demo_chair")
323
+
324
+ # ── Step 1: Train posture classifier ──────────────────────────────
325
+ print("\nπŸ“Š STEP 1: Training posture classifier...")
326
+ dataset = generate_dataset(n_subjects=15, samples_per_posture_per_subject=300)
327
+ train, test = subject_based_split(dataset)
328
+
329
+ results = system.classifier.train(
330
+ train["imu_data"], train["load_data"], train["labels"],
331
+ test["imu_data"], test["load_data"], test["labels"]
332
+ )
333
+
334
+ # ── Step 2: Enroll users ──��───────────────────────────────────────
335
+ print("\nπŸ‘€ STEP 2: Enrolling users...")
336
+ rng = np.random.default_rng(123)
337
+
338
+ for user_name, weight in [("Alice", 55), ("Bob", 85), ("Charlie", 70)]:
339
+ sit_events = []
340
+ for _ in range(5):
341
+ load = generate_load_cell_data(0, 100, weight, rng=rng)
342
+ sit_events.append(load)
343
+
344
+ result = system.enroll_user(user_name, sit_events)
345
+ print(f" {user_name}: {result['status']} ({result.get('avg_weight_kg', 0)} kg)")
346
+
347
+ # ── Step 3: Simulate a sitting session ────────────────────────────
348
+ print("\nπŸͺ‘ STEP 3: Simulating sitting session...")
349
+
350
+ # Simulate 10 readings with different postures
351
+ posture_sequence = [0, 0, 0, 1, 1, 5, 5, 5, 0, 0] # upright β†’ forward β†’ slouch β†’ upright
352
+ posture_names_seq = [POSTURE_CLASSES[p] for p in posture_sequence]
353
+
354
+ print(f"\n Simulated posture sequence: {posture_names_seq}")
355
+ print("-" * 70)
356
+
357
+ for i, posture_id in enumerate(posture_sequence):
358
+ # Generate sensor data for this posture
359
+ imu = generate_imu_data(posture_id, WINDOW_SIZE, user_weight_kg=70, rng=rng)
360
+ load = generate_load_cell_data(posture_id, WINDOW_SIZE, user_weight_kg=70, rng=rng)
361
+
362
+ # Process through full pipeline
363
+ result = system.process_sensor_reading(imu, load, thermal_present=True)
364
+
365
+ # Print summary
366
+ print(f"\n Reading {i+1}/{len(posture_sequence)}:")
367
+ print(f" β”œβ”€ Posture: {result['posture']['name']} (confidence: {result['posture']['confidence']:.2%})")
368
+ print(f" β”œβ”€ RULA Score: {result['rula']['rula_score']}/7 ({result['rula']['risk_level']})")
369
+ print(f" β”œβ”€ Fatigue: {result['fatigue']['fatigue_level']}")
370
+ print(f" β”œβ”€ Break: {result['break_recommendation']['urgency_level']} (score: {result['break_recommendation']['urgency_score']:.0f}/100)")
371
+
372
+ if "exercise_suggestions" in result:
373
+ exercises = [e["name"] for e in result["exercise_suggestions"]]
374
+ print(f" β”œβ”€ Exercises: {exercises}")
375
+
376
+ if "alert" in result:
377
+ print(f" └─ 🚨 ALERT: {result['alert']['message']}")
378
+
379
+ # ── Step 4: Show dashboard data ───────────────────────────────────
380
+ print("\n\nπŸ“ˆ STEP 4: Dashboard Summary")
381
+ print("-" * 70)
382
+ dashboard = system.get_dashboard_data()
383
+
384
+ print(f" Current User: {dashboard['current_user']}")
385
+ print(f" Session Duration: {dashboard['session_duration_min']:.1f} min")
386
+ print(f" Daily Score: {dashboard['daily_score']}")
387
+ print(f" Active Alerts: {len(dashboard['active_alerts'])}")
388
+ print(f" Spine Risk: {dashboard['spine_risk']}")
389
+
390
+ print("\n" + "=" * 70)
391
+ print("βœ… DEMO COMPLETE β€” All 12 features operational!")
392
+ print("=" * 70)
393
+
394
+ # Summary of all features
395
+ print("""
396
+ β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
397
+ β”‚ SMARTCHAIR FEATURE STATUS β”‚
398
+ β”œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€
399
+ β”‚ βœ… 1. Real-time posture classification (ML) β”‚
400
+ β”‚ βœ… 2. Long-term spine risk prediction β”‚
401
+ β”‚ βœ… 3. Personalized sitting behaviour model β”‚
402
+ β”‚ βœ… 4. Smart micro-break recommendations (adaptive) β”‚
403
+ β”‚ βœ… 5. Exercise suggestion system β”‚
404
+ β”‚ βœ… 6. Hybrid sensor fusion (IMU + Load Cell + Thermal) β”‚
405
+ β”‚ βœ… 7. Fatigue & drowsiness detection β”‚
406
+ β”‚ βœ… 8. Daily/weekly posture score dashboard β”‚
407
+ β”‚ βœ… 9. Injury risk alert system β”‚
408
+ β”‚ βœ… 10. Cloud integration (AWS IoT Core) β”‚
409
+ β”‚ βœ… 11. Multi-user recognition (weight signature) β”‚
410
+ β”‚ βœ… 12. Gamification system β”‚
411
+ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
412
+ """)
413
+
414
+
415
+ if __name__ == "__main__":
416
+ run_demo()
smart_chair/ml_models/__init__.py ADDED
File without changes
smart_chair/ml_models/break_recommendation.py ADDED
@@ -0,0 +1,489 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ SmartChair Smart Break Recommendation & Exercise Suggestion Engine.
3
+
4
+ 1. Adaptive Micro-Break Recommendation
5
+ β†’ NOT a fixed timer β€” adapts based on:
6
+ * Current posture quality
7
+ * Cumulative risk score
8
+ * User's personal fatigue pattern
9
+ * Time since last break
10
+ * Current fatigue level
11
+
12
+ 2. Exercise Suggestion System
13
+ β†’ Based on detected posture issues
14
+ β†’ Maps specific postural problems to targeted exercises
15
+ β†’ Progressive difficulty based on user fitness level
16
+ """
17
+
18
+ import numpy as np
19
+ from datetime import datetime, timedelta
20
+ from typing import Dict, List, Optional, Tuple
21
+ from collections import deque
22
+
23
+ import sys
24
+ sys.path.insert(0, '/app/smart_chair')
25
+ from config.settings import *
26
+
27
+
28
+ # ═══════════════════════════════════════════════════════════════════════════════
29
+ # 1. ADAPTIVE MICRO-BREAK RECOMMENDATION ENGINE
30
+ # ═══════════════════════════════════════════════════════════════════════════════
31
+
32
+ class AdaptiveBreakEngine:
33
+ """
34
+ Smart break recommendation that adapts to the user's real-time state.
35
+
36
+ Unlike fixed timers (e.g., "break every 30 min"), this engine:
37
+ - Triggers earlier when posture is bad
38
+ - Delays when user is in flow state with good posture
39
+ - Escalates urgency as risk accumulates
40
+ - Learns optimal timing from user's fatigue patterns
41
+
42
+ Algorithm: Score-based urgency calculator
43
+ urgency = Ξ£(weighted_factors) β†’ {gentle, moderate, urgent}
44
+ """
45
+
46
+ def __init__(self, base_interval_min: float = 30.0):
47
+ self.base_interval = base_interval_min
48
+ self.last_break_time = None
49
+ self.break_history = deque(maxlen=100)
50
+ self.current_session_start = datetime.now()
51
+ self.posture_quality_buffer = deque(maxlen=300) # 5 min at 1 Hz
52
+
53
+ # Adaptive weights (tune these based on user feedback)
54
+ self.weights = {
55
+ 'time_since_break': 0.3,
56
+ 'posture_quality': 0.25,
57
+ 'fatigue_level': 0.2,
58
+ 'rula_score': 0.15,
59
+ 'session_duration': 0.1,
60
+ }
61
+
62
+ def update_posture(self, posture_name: str, rula_score: int):
63
+ """Feed current posture for continuous monitoring."""
64
+ score = POSTURE_SCORE_WEIGHTS.get(posture_name, 0.5)
65
+ self.posture_quality_buffer.append({
66
+ 'timestamp': datetime.now(),
67
+ 'quality': score,
68
+ 'rula': rula_score,
69
+ })
70
+
71
+ def record_break(self, break_type: str = "micro", duration_seconds: int = 120):
72
+ """Record that user took a break."""
73
+ now = datetime.now()
74
+ self.break_history.append({
75
+ 'timestamp': now,
76
+ 'type': break_type,
77
+ 'duration': duration_seconds,
78
+ })
79
+ self.last_break_time = now
80
+
81
+ def compute_urgency(self, fatigue_level: str = "none",
82
+ current_rula: int = 1,
83
+ personal_interval: Optional[float] = None) -> Dict:
84
+ """
85
+ Compute break urgency score (0-100) and recommendation.
86
+
87
+ Args:
88
+ fatigue_level: from FatigueDetector ("none", "low", "moderate", "high")
89
+ current_rula: current RULA score (1-7)
90
+ personal_interval: user's personalized break interval (from PersonalizedSittingModel)
91
+
92
+ Returns:
93
+ dict with urgency_score, level, recommendation, optimal_break_time
94
+ """
95
+ now = datetime.now()
96
+ effective_interval = personal_interval or self.base_interval
97
+
98
+ # ── Factor 1: Time since last break ───────────────────────────────
99
+ if self.last_break_time:
100
+ minutes_since_break = (now - self.last_break_time).total_seconds() / 60
101
+ else:
102
+ minutes_since_break = (now - self.current_session_start).total_seconds() / 60
103
+
104
+ time_factor = min(100, (minutes_since_break / effective_interval) * 60)
105
+
106
+ # ── Factor 2: Recent posture quality ──────────────────────────────
107
+ if self.posture_quality_buffer:
108
+ recent_quality = np.mean([p['quality'] for p in self.posture_quality_buffer])
109
+ posture_factor = (1 - recent_quality) * 100 # bad posture β†’ high urgency
110
+ else:
111
+ posture_factor = 30 # neutral default
112
+
113
+ # ── Factor 3: Fatigue level ───────────────────────────────────────
114
+ fatigue_map = {"none": 0, "low": 25, "moderate": 60, "high": 95}
115
+ fatigue_factor = fatigue_map.get(fatigue_level, 30)
116
+
117
+ # ── Factor 4: RULA score ──────────────────────────────────────────
118
+ rula_factor = (current_rula - 1) / 6 * 100 # normalize 1-7 to 0-100
119
+
120
+ # ── Factor 5: Total session duration ──────────────────────────────
121
+ session_min = (now - self.current_session_start).total_seconds() / 60
122
+ session_factor = min(100, (session_min / MAX_SITTING_MINUTES) * 60)
123
+
124
+ # ── Weighted sum ──────────────────────────────────────────────────
125
+ urgency = (
126
+ self.weights['time_since_break'] * time_factor +
127
+ self.weights['posture_quality'] * posture_factor +
128
+ self.weights['fatigue_level'] * fatigue_factor +
129
+ self.weights['rula_score'] * rula_factor +
130
+ self.weights['session_duration'] * session_factor
131
+ )
132
+
133
+ urgency = min(100, max(0, urgency))
134
+
135
+ # ── Decision ──────────────────────────────────────────────────────
136
+ if urgency >= 70:
137
+ level = "urgent"
138
+ recommendation = "🚨 Take a break NOW. Stand up and move for 2 minutes."
139
+ optimal_time = now # immediate
140
+ elif urgency >= 40:
141
+ level = "moderate"
142
+ remaining_min = max(5, effective_interval - minutes_since_break)
143
+ recommendation = f"⚑ Break recommended in ~{remaining_min:.0f} minutes. Start planning a pause."
144
+ optimal_time = now + timedelta(minutes=remaining_min)
145
+ else:
146
+ level = "gentle"
147
+ remaining_min = max(10, effective_interval - minutes_since_break)
148
+ recommendation = f"βœ… Good for now. Next break in ~{remaining_min:.0f} minutes."
149
+ optimal_time = now + timedelta(minutes=remaining_min)
150
+
151
+ return {
152
+ "urgency_score": round(urgency, 1),
153
+ "urgency_level": level,
154
+ "recommendation": recommendation,
155
+ "optimal_break_time": optimal_time.isoformat(),
156
+ "minutes_since_break": round(minutes_since_break, 1),
157
+ "session_duration_min": round(session_min, 1),
158
+ "factors": {
159
+ "time": round(time_factor, 1),
160
+ "posture": round(posture_factor, 1),
161
+ "fatigue": round(fatigue_factor, 1),
162
+ "rula": round(rula_factor, 1),
163
+ "session": round(session_factor, 1),
164
+ }
165
+ }
166
+
167
+
168
+ # ═══════════════════════════════════════════════════════════════════════════════
169
+ # 2. EXERCISE SUGGESTION SYSTEM
170
+ # ═══════════════════════════════════════════════════════════════════════════════
171
+
172
+ # Exercises mapped to specific posture problems
173
+ EXERCISE_DATABASE = {
174
+ # ── For Forward Lean / Slouch (tight hip flexors, weak upper back) ────
175
+ "forward_lean": [
176
+ {
177
+ "name": "Chin Tucks",
178
+ "description": "Sit tall, pull chin straight back (making a double chin). Hold 5 seconds.",
179
+ "duration_seconds": 30,
180
+ "reps": 10,
181
+ "difficulty": "easy",
182
+ "target_muscles": ["deep neck flexors", "upper cervical extensors"],
183
+ "benefit": "Corrects forward head posture, reduces neck strain",
184
+ },
185
+ {
186
+ "name": "Seated Cat-Cow",
187
+ "description": "Sit on edge of chair. Arch back (cow), then round back (cat). Slow and controlled.",
188
+ "duration_seconds": 45,
189
+ "reps": 8,
190
+ "difficulty": "easy",
191
+ "target_muscles": ["erector spinae", "rectus abdominis"],
192
+ "benefit": "Mobilizes thoracic spine, releases compressed discs",
193
+ },
194
+ {
195
+ "name": "Chest Opener Stretch",
196
+ "description": "Clasp hands behind back, squeeze shoulder blades together, lift hands slightly.",
197
+ "duration_seconds": 30,
198
+ "reps": 3,
199
+ "difficulty": "easy",
200
+ "target_muscles": ["pectoralis major", "anterior deltoid"],
201
+ "benefit": "Opens chest, counteracts hunched posture",
202
+ },
203
+ {
204
+ "name": "Standing Hip Flexor Stretch",
205
+ "description": "Step one foot forward into lunge. Push hips forward, keep back straight.",
206
+ "duration_seconds": 60,
207
+ "reps": 2,
208
+ "difficulty": "moderate",
209
+ "target_muscles": ["iliopsoas", "rectus femoris"],
210
+ "benefit": "Releases tight hip flexors from prolonged sitting",
211
+ },
212
+ ],
213
+
214
+ # ── For Slouch (weak core, posterior chain) ────────────────────────────
215
+ "slouch": [
216
+ {
217
+ "name": "Seated Pelvic Tilts",
218
+ "description": "Sit tall. Tilt pelvis forward (arch lower back), then backward (flatten). Slowly alternate.",
219
+ "duration_seconds": 30,
220
+ "reps": 10,
221
+ "difficulty": "easy",
222
+ "target_muscles": ["transverse abdominis", "multifidus"],
223
+ "benefit": "Activates deep core stabilizers, corrects lumbar lordosis",
224
+ },
225
+ {
226
+ "name": "Bracing Drill",
227
+ "description": "Brace your core as if someone is about to punch your stomach. Hold 10 seconds, breathe normally.",
228
+ "duration_seconds": 40,
229
+ "reps": 5,
230
+ "difficulty": "easy",
231
+ "target_muscles": ["transverse abdominis", "obliques"],
232
+ "benefit": "Builds endurance in spinal stabilizers",
233
+ },
234
+ {
235
+ "name": "Wall Angels",
236
+ "description": "Stand with back flat against wall. Raise arms to 'goal post' position, slide up and down.",
237
+ "duration_seconds": 60,
238
+ "reps": 10,
239
+ "difficulty": "moderate",
240
+ "target_muscles": ["lower trapezius", "serratus anterior", "rotator cuff"],
241
+ "benefit": "Strengthens mid-back, improves scapular control",
242
+ },
243
+ {
244
+ "name": "Glute Bridge",
245
+ "description": "Lie on back, knees bent. Squeeze glutes and lift hips. Hold 3 seconds at top.",
246
+ "duration_seconds": 60,
247
+ "reps": 12,
248
+ "difficulty": "moderate",
249
+ "target_muscles": ["gluteus maximus", "hamstrings"],
250
+ "benefit": "Activates posterior chain, counteracts hip flexor dominance",
251
+ },
252
+ ],
253
+
254
+ # ── For Left/Right Lean (lateral imbalance) ───────────────────────────
255
+ "lateral_lean": [
256
+ {
257
+ "name": "Seated Side Stretch",
258
+ "description": "Raise one arm overhead, lean to the opposite side. Hold 15 seconds each side.",
259
+ "duration_seconds": 60,
260
+ "reps": 3,
261
+ "difficulty": "easy",
262
+ "target_muscles": ["quadratus lumborum", "obliques", "latissimus dorsi"],
263
+ "benefit": "Releases lateral spine compression, improves symmetry",
264
+ },
265
+ {
266
+ "name": "Single-Leg Balance",
267
+ "description": "Stand on one leg for 30 seconds. Switch legs. Close eyes for challenge.",
268
+ "duration_seconds": 60,
269
+ "reps": 2,
270
+ "difficulty": "moderate",
271
+ "target_muscles": ["gluteus medius", "core stabilizers"],
272
+ "benefit": "Corrects lateral pelvic tilt, improves proprioception",
273
+ },
274
+ {
275
+ "name": "Thread the Needle",
276
+ "description": "On all fours, reach one arm under body and rotate. Follow with reaching to sky.",
277
+ "duration_seconds": 45,
278
+ "reps": 6,
279
+ "difficulty": "moderate",
280
+ "target_muscles": ["thoracic rotators", "obliques"],
281
+ "benefit": "Improves thoracic rotation, reduces asymmetric loading",
282
+ },
283
+ ],
284
+
285
+ # ── For Backward Lean (weak anterior core, tight hamstrings) ──────────
286
+ "backward_lean": [
287
+ {
288
+ "name": "Seated Hamstring Stretch",
289
+ "description": "Extend one leg, flex foot. Lean forward from hips with straight back.",
290
+ "duration_seconds": 30,
291
+ "reps": 3,
292
+ "difficulty": "easy",
293
+ "target_muscles": ["hamstrings", "gastrocnemius"],
294
+ "benefit": "Releases posterior chain tension causing backward pelvic tilt",
295
+ },
296
+ {
297
+ "name": "Dead Bug",
298
+ "description": "Lie on back, arms up. Extend opposite arm and leg slowly. Return and switch.",
299
+ "duration_seconds": 60,
300
+ "reps": 10,
301
+ "difficulty": "moderate",
302
+ "target_muscles": ["transverse abdominis", "rectus abdominis"],
303
+ "benefit": "Strengthens anterior core without spinal compression",
304
+ },
305
+ ],
306
+
307
+ # ── General / Break Exercises ─────────────────────────────────────────
308
+ "general": [
309
+ {
310
+ "name": "Standing Desk Stretch",
311
+ "description": "Stand up. Reach arms overhead, interlace fingers, stretch upward. Hold 10 seconds.",
312
+ "duration_seconds": 20,
313
+ "reps": 3,
314
+ "difficulty": "easy",
315
+ "target_muscles": ["full spine extensors", "shoulders"],
316
+ "benefit": "Decompresses spine after sitting, increases blood flow",
317
+ },
318
+ {
319
+ "name": "Neck Rolls",
320
+ "description": "Slowly roll head in a circle. 5 clockwise, 5 counter-clockwise.",
321
+ "duration_seconds": 30,
322
+ "reps": 10,
323
+ "difficulty": "easy",
324
+ "target_muscles": ["sternocleidomastoid", "upper trapezius"],
325
+ "benefit": "Releases neck tension from screen viewing",
326
+ },
327
+ {
328
+ "name": "Eye 20-20-20 Rule",
329
+ "description": "Look at something 20 feet away for 20 seconds. Blink frequently.",
330
+ "duration_seconds": 20,
331
+ "reps": 1,
332
+ "difficulty": "easy",
333
+ "target_muscles": ["ciliary muscle", "extraocular muscles"],
334
+ "benefit": "Reduces eye strain and digital fatigue",
335
+ },
336
+ {
337
+ "name": "Calf Raises",
338
+ "description": "Stand behind chair for balance. Rise onto toes, hold 2 seconds, lower.",
339
+ "duration_seconds": 30,
340
+ "reps": 15,
341
+ "difficulty": "easy",
342
+ "target_muscles": ["gastrocnemius", "soleus"],
343
+ "benefit": "Promotes blood circulation, prevents deep vein thrombosis",
344
+ },
345
+ {
346
+ "name": "Desk Push-ups",
347
+ "description": "Place hands on desk edge. Step back. Do push-ups at an angle.",
348
+ "duration_seconds": 30,
349
+ "reps": 10,
350
+ "difficulty": "moderate",
351
+ "target_muscles": ["pectoralis major", "triceps", "core"],
352
+ "benefit": "Full body activation, increases alertness",
353
+ },
354
+ ],
355
+ }
356
+
357
+
358
+ class ExerciseSuggestionEngine:
359
+ """
360
+ Suggests targeted exercises based on detected posture issues.
361
+
362
+ Logic:
363
+ 1. Analyze recent posture history to identify dominant problems
364
+ 2. Map problems to specific corrective exercises
365
+ 3. Adjust difficulty based on user fitness level
366
+ 4. Track exercise compliance and effectiveness
367
+ """
368
+
369
+ def __init__(self):
370
+ self.exercise_log = deque(maxlen=500)
371
+ self.user_fitness_level = "easy" # easy, moderate, hard
372
+ self.completed_exercises = {} # exercise_name β†’ count
373
+
374
+ def suggest_exercises(self,
375
+ dominant_posture: str,
376
+ rula_score: int,
377
+ session_duration_min: float,
378
+ break_type: str = "micro",
379
+ max_exercises: int = 3) -> List[Dict]:
380
+ """
381
+ Generate exercise recommendations.
382
+
383
+ Args:
384
+ dominant_posture: most common bad posture in recent window
385
+ rula_score: current risk level
386
+ session_duration_min: how long user has been sitting
387
+ break_type: "micro" (2 min) or "full" (5+ min)
388
+ max_exercises: maximum exercises to suggest
389
+
390
+ Returns:
391
+ list of exercise dicts
392
+ """
393
+ suggestions = []
394
+
395
+ # Map posture to exercise category
396
+ posture_map = {
397
+ "forward_lean": "forward_lean",
398
+ "slouch": "slouch",
399
+ "backward_lean": "backward_lean",
400
+ "left_lean": "lateral_lean",
401
+ "right_lean": "lateral_lean",
402
+ }
403
+
404
+ category = posture_map.get(dominant_posture, "general")
405
+
406
+ # Get exercises for this category + general
407
+ targeted = EXERCISE_DATABASE.get(category, [])
408
+ general = EXERCISE_DATABASE.get("general", [])
409
+
410
+ # Filter by fitness level
411
+ available = [e for e in targeted if self._difficulty_matches(e["difficulty"])]
412
+ available_general = [e for e in general if self._difficulty_matches(e["difficulty"])]
413
+
414
+ # Select exercises based on break duration
415
+ if break_type == "micro":
416
+ max_duration = 120 # 2 minutes
417
+ else:
418
+ max_duration = 300 # 5 minutes
419
+
420
+ # Prioritize targeted exercises
421
+ total_duration = 0
422
+ for ex in available:
423
+ if total_duration + ex["duration_seconds"] <= max_duration:
424
+ suggestions.append(ex)
425
+ total_duration += ex["duration_seconds"]
426
+ if len(suggestions) >= max_exercises:
427
+ break
428
+
429
+ # Fill remaining time with general exercises
430
+ if len(suggestions) < max_exercises:
431
+ for ex in available_general:
432
+ if ex not in suggestions and total_duration + ex["duration_seconds"] <= max_duration:
433
+ suggestions.append(ex)
434
+ total_duration += ex["duration_seconds"]
435
+ if len(suggestions) >= max_exercises:
436
+ break
437
+
438
+ # Add context
439
+ for i, ex in enumerate(suggestions):
440
+ ex = ex.copy()
441
+ ex["priority"] = i + 1
442
+ ex["reason"] = f"Targets {dominant_posture} posture correction" if i < len(available) else "General wellness"
443
+ suggestions[i] = ex
444
+
445
+ return suggestions
446
+
447
+ def record_exercise_completed(self, exercise_name: str, rating: int = 3):
448
+ """Record that user completed an exercise. Rating 1-5."""
449
+ self.exercise_log.append({
450
+ 'timestamp': datetime.now(),
451
+ 'exercise': exercise_name,
452
+ 'rating': rating,
453
+ })
454
+ self.completed_exercises[exercise_name] = \
455
+ self.completed_exercises.get(exercise_name, 0) + 1
456
+
457
+ # Auto-adjust fitness level
458
+ total_completed = sum(self.completed_exercises.values())
459
+ if total_completed > 50 and self.user_fitness_level == "easy":
460
+ self.user_fitness_level = "moderate"
461
+ elif total_completed > 200 and self.user_fitness_level == "moderate":
462
+ self.user_fitness_level = "hard"
463
+
464
+ def _difficulty_matches(self, exercise_difficulty: str) -> bool:
465
+ """Check if exercise difficulty matches user level."""
466
+ levels = ["easy", "moderate", "hard"]
467
+ user_idx = levels.index(self.user_fitness_level)
468
+ ex_idx = levels.index(exercise_difficulty)
469
+ return ex_idx <= user_idx + 1 # allow one level above
470
+
471
+ def get_exercise_stats(self) -> Dict:
472
+ """Get exercise compliance statistics."""
473
+ if not self.exercise_log:
474
+ return {"total_exercises": 0, "compliance_rate": 0}
475
+
476
+ total = len(self.exercise_log)
477
+ avg_rating = np.mean([e['rating'] for e in self.exercise_log])
478
+
479
+ # Favorite exercise
480
+ favorite = max(self.completed_exercises, key=self.completed_exercises.get) \
481
+ if self.completed_exercises else None
482
+
483
+ return {
484
+ "total_exercises_completed": total,
485
+ "avg_rating": round(avg_rating, 1),
486
+ "favorite_exercise": favorite,
487
+ "fitness_level": self.user_fitness_level,
488
+ "exercise_counts": dict(self.completed_exercises),
489
+ }
smart_chair/ml_models/gamification.py ADDED
@@ -0,0 +1,315 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ SmartChair Gamification & Posture Scoring System.
3
+
4
+ Engagement mechanics:
5
+ - Points: Earned for good posture, breaks taken, exercises completed
6
+ - Streaks: Consecutive days meeting posture goals
7
+ - Badges: Achievement milestones
8
+ - Daily/Weekly scores: Aggregate posture quality metrics
9
+ - Leaderboard: Multi-user competition (family/office)
10
+
11
+ Based on health behavior change literature:
12
+ - HealthGuru (arxiv:2502.13920): contextual engagement
13
+ - PhysioLLM (arxiv:2406.19283): personalized health coaching
14
+ """
15
+
16
+ import numpy as np
17
+ from datetime import datetime, timedelta, date
18
+ from typing import Dict, List, Optional
19
+ from collections import defaultdict
20
+
21
+ import sys
22
+ sys.path.insert(0, '/app/smart_chair')
23
+ from config.settings import *
24
+
25
+
26
+ class PostureScorer:
27
+ """
28
+ Computes daily and weekly posture scores (0-100).
29
+
30
+ Score = weighted average of time spent in each posture class.
31
+ upright = 100%, mild lean = 60%, slouch = 20%, absent = excluded
32
+ """
33
+
34
+ def __init__(self):
35
+ self.daily_records = defaultdict(list) # date β†’ list of (posture, duration_min)
36
+ self.weekly_scores = []
37
+
38
+ def record_posture(self, posture_name: str, duration_minutes: float,
39
+ timestamp: Optional[datetime] = None):
40
+ """Record posture observation."""
41
+ if timestamp is None:
42
+ timestamp = datetime.now()
43
+
44
+ day = timestamp.date()
45
+ self.daily_records[day].append({
46
+ 'posture': posture_name,
47
+ 'duration': duration_minutes,
48
+ 'hour': timestamp.hour,
49
+ })
50
+
51
+ def compute_daily_score(self, target_date: Optional[date] = None) -> Dict:
52
+ """
53
+ Compute posture score for a specific day.
54
+
55
+ Score formula: Ξ£(duration_i Γ— weight_i) / Ξ£(duration_i) Γ— 100
56
+ """
57
+ if target_date is None:
58
+ target_date = datetime.now().date()
59
+
60
+ records = self.daily_records.get(target_date, [])
61
+ if not records:
62
+ return {"date": str(target_date), "score": 0, "total_minutes": 0, "status": "no_data"}
63
+
64
+ total_weighted = 0
65
+ total_minutes = 0
66
+ posture_breakdown = defaultdict(float)
67
+ hourly_quality = defaultdict(list)
68
+
69
+ for r in records:
70
+ weight = POSTURE_SCORE_WEIGHTS.get(r['posture'], 0.5)
71
+ if r['posture'] == 'absent':
72
+ continue # don't count absent time
73
+
74
+ total_weighted += r['duration'] * weight
75
+ total_minutes += r['duration']
76
+ posture_breakdown[r['posture']] += r['duration']
77
+ hourly_quality[r['hour']].append(weight)
78
+
79
+ score = (total_weighted / total_minutes * 100) if total_minutes > 0 else 0
80
+
81
+ # Best and worst hours
82
+ hourly_avg = {h: np.mean(q) * 100 for h, q in hourly_quality.items()}
83
+ best_hour = max(hourly_avg, key=hourly_avg.get) if hourly_avg else None
84
+ worst_hour = min(hourly_avg, key=hourly_avg.get) if hourly_avg else None
85
+
86
+ return {
87
+ "date": str(target_date),
88
+ "score": round(score, 1),
89
+ "total_sitting_minutes": round(total_minutes, 1),
90
+ "posture_breakdown_minutes": dict(posture_breakdown),
91
+ "best_hour": best_hour,
92
+ "worst_hour": worst_hour,
93
+ "hourly_scores": {str(h): round(s, 1) for h, s in sorted(hourly_avg.items())},
94
+ }
95
+
96
+ def compute_weekly_score(self) -> Dict:
97
+ """Compute average score for the past 7 days."""
98
+ today = datetime.now().date()
99
+ daily_scores = []
100
+
101
+ for i in range(7):
102
+ day = today - timedelta(days=i)
103
+ result = self.compute_daily_score(day)
104
+ if result["score"] > 0:
105
+ daily_scores.append(result)
106
+
107
+ if not daily_scores:
108
+ return {"week_score": 0, "days_with_data": 0}
109
+
110
+ avg_score = np.mean([d["score"] for d in daily_scores])
111
+
112
+ # Trend analysis
113
+ if len(daily_scores) >= 3:
114
+ recent = np.mean([d["score"] for d in daily_scores[:3]])
115
+ older = np.mean([d["score"] for d in daily_scores[3:]])
116
+ trend = "improving" if recent > older + 3 else "declining" if recent < older - 3 else "stable"
117
+ else:
118
+ trend = "insufficient_data"
119
+
120
+ return {
121
+ "week_score": round(avg_score, 1),
122
+ "days_with_data": len(daily_scores),
123
+ "trend": trend,
124
+ "daily_scores": daily_scores,
125
+ "best_day": max(daily_scores, key=lambda x: x["score"]),
126
+ "worst_day": min(daily_scores, key=lambda x: x["score"]),
127
+ }
128
+
129
+ def get_trend_data(self, days: int = 30) -> Dict:
130
+ """Get score trend data for dashboard graphs."""
131
+ today = datetime.now().date()
132
+ scores = []
133
+
134
+ for i in range(days):
135
+ day = today - timedelta(days=days - 1 - i)
136
+ result = self.compute_daily_score(day)
137
+ scores.append({
138
+ "date": str(day),
139
+ "score": result["score"],
140
+ "minutes": result.get("total_sitting_minutes", 0),
141
+ })
142
+
143
+ return {
144
+ "period_days": days,
145
+ "scores": scores,
146
+ "avg_score": round(np.mean([s["score"] for s in scores if s["score"] > 0]) if any(s["score"] > 0 for s in scores) else 0, 1),
147
+ "max_score": round(max(s["score"] for s in scores), 1) if scores else 0,
148
+ "min_score": round(min(s["score"] for s in scores if s["score"] > 0), 1) if any(s["score"] > 0 for s in scores) else 0,
149
+ }
150
+
151
+
152
+ class GamificationEngine:
153
+ """
154
+ Full gamification system for engagement and posture improvement.
155
+
156
+ Points: 10/min good posture, 50/break taken, 100/exercise done
157
+ Streaks: Consecutive days with score > 70
158
+ Badges: Milestone achievements
159
+ Level: Based on total points
160
+ """
161
+
162
+ def __init__(self, user_id: str):
163
+ self.user_id = user_id
164
+ self.total_points = 0
165
+ self.daily_points = defaultdict(int)
166
+ self.streak_days = 0
167
+ self.longest_streak = 0
168
+ self.badges = set()
169
+ self.level = 1
170
+ self.history = []
171
+
172
+ # Daily goals
173
+ self.daily_goals = {
174
+ "good_posture_minutes": 240, # 4 hours
175
+ "breaks_taken": 8,
176
+ "exercises_completed": 3,
177
+ "target_score": 70,
178
+ }
179
+
180
+ def award_posture_points(self, good_posture_minutes: float):
181
+ """Award points for good posture time."""
182
+ points = int(good_posture_minutes * POINTS_PER_GOOD_POSTURE_MINUTE)
183
+ self._add_points(points, "good_posture")
184
+
185
+ def award_break_points(self):
186
+ """Award points for taking a break."""
187
+ self._add_points(POINTS_PER_BREAK_TAKEN, "break_taken")
188
+
189
+ def award_exercise_points(self, exercise_name: str):
190
+ """Award points for completing an exercise."""
191
+ self._add_points(POINTS_PER_EXERCISE_DONE, "exercise_completed")
192
+
193
+ def _add_points(self, points: int, reason: str):
194
+ """Add points and check for level-up and badges."""
195
+ # Streak bonus
196
+ if self.streak_days >= 3:
197
+ bonus = int(points * (STREAK_BONUS_MULTIPLIER - 1))
198
+ points += bonus
199
+
200
+ self.total_points += points
201
+ today = datetime.now().date()
202
+ self.daily_points[today] += points
203
+
204
+ self.history.append({
205
+ 'timestamp': datetime.now().isoformat(),
206
+ 'points': points,
207
+ 'reason': reason,
208
+ 'total': self.total_points,
209
+ })
210
+
211
+ # Check level up (every 1000 points)
212
+ new_level = 1 + self.total_points // 1000
213
+ if new_level > self.level:
214
+ self.level = new_level
215
+
216
+ # Check badges
217
+ self._check_badges()
218
+
219
+ def update_streak(self, daily_score: float):
220
+ """Update streak based on daily score."""
221
+ if daily_score >= self.daily_goals["target_score"]:
222
+ self.streak_days += 1
223
+ self.longest_streak = max(self.longest_streak, self.streak_days)
224
+ else:
225
+ self.streak_days = 0
226
+
227
+ def _check_badges(self):
228
+ """Check and award badges based on thresholds."""
229
+ for badge_name, threshold in BADGE_THRESHOLDS.items():
230
+ if badge_name not in self.badges and self.total_points >= threshold:
231
+ self.badges.add(badge_name)
232
+
233
+ def get_daily_progress(self, posture_score: float, breaks_taken: int,
234
+ exercises_done: int, good_posture_min: float) -> Dict:
235
+ """Get progress toward daily goals."""
236
+ return {
237
+ "posture_score": {
238
+ "current": round(posture_score, 1),
239
+ "goal": self.daily_goals["target_score"],
240
+ "met": posture_score >= self.daily_goals["target_score"],
241
+ "pct": round(min(100, posture_score / self.daily_goals["target_score"] * 100), 1),
242
+ },
243
+ "good_posture_minutes": {
244
+ "current": round(good_posture_min, 1),
245
+ "goal": self.daily_goals["good_posture_minutes"],
246
+ "met": good_posture_min >= self.daily_goals["good_posture_minutes"],
247
+ "pct": round(min(100, good_posture_min / self.daily_goals["good_posture_minutes"] * 100), 1),
248
+ },
249
+ "breaks": {
250
+ "current": breaks_taken,
251
+ "goal": self.daily_goals["breaks_taken"],
252
+ "met": breaks_taken >= self.daily_goals["breaks_taken"],
253
+ "pct": round(min(100, breaks_taken / self.daily_goals["breaks_taken"] * 100), 1),
254
+ },
255
+ "exercises": {
256
+ "current": exercises_done,
257
+ "goal": self.daily_goals["exercises_completed"],
258
+ "met": exercises_done >= self.daily_goals["exercises_completed"],
259
+ "pct": round(min(100, exercises_done / self.daily_goals["exercises_completed"] * 100), 1),
260
+ },
261
+ }
262
+
263
+ def get_profile(self) -> Dict:
264
+ """Get complete gamification profile."""
265
+ return {
266
+ "user_id": self.user_id,
267
+ "level": self.level,
268
+ "total_points": self.total_points,
269
+ "streak_days": self.streak_days,
270
+ "longest_streak": self.longest_streak,
271
+ "badges": sorted(list(self.badges)),
272
+ "badges_remaining": [
273
+ {"name": b, "points_needed": t - self.total_points}
274
+ for b, t in sorted(BADGE_THRESHOLDS.items(), key=lambda x: x[1])
275
+ if b not in self.badges
276
+ ],
277
+ "points_to_next_level": 1000 - (self.total_points % 1000),
278
+ "recent_activity": self.history[-10:] if self.history else [],
279
+ }
280
+
281
+ def get_leaderboard_entry(self) -> Dict:
282
+ """Get this user's leaderboard entry."""
283
+ return {
284
+ "user_id": self.user_id,
285
+ "total_points": self.total_points,
286
+ "level": self.level,
287
+ "streak": self.streak_days,
288
+ "badges_count": len(self.badges),
289
+ }
290
+
291
+
292
+ class Leaderboard:
293
+ """Multi-user leaderboard for family/office competition."""
294
+
295
+ def __init__(self):
296
+ self.users = {} # user_id β†’ GamificationEngine
297
+
298
+ def add_user(self, engine: GamificationEngine):
299
+ self.users[engine.user_id] = engine
300
+
301
+ def get_rankings(self, sort_by: str = "total_points") -> List[Dict]:
302
+ """Get ranked list of all users."""
303
+ entries = [engine.get_leaderboard_entry() for engine in self.users.values()]
304
+ entries.sort(key=lambda x: x[sort_by], reverse=True)
305
+
306
+ for i, entry in enumerate(entries):
307
+ entry["rank"] = i + 1
308
+
309
+ return entries
310
+
311
+ def get_weekly_winner(self) -> Optional[str]:
312
+ """Determine this week's posture champion."""
313
+ if not self.users:
314
+ return None
315
+ return max(self.users.values(), key=lambda e: e.total_points).user_id
smart_chair/ml_models/posture_classifier.py ADDED
@@ -0,0 +1,350 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ SmartChair Posture Classification β€” Dual-path architecture.
3
+
4
+ Path 1: Ensemble Voting (SVM + DT + MLP + XGBoost) on tabular features
5
+ β†’ Based on SitPose (arxiv:2412.12216), F1=98.2%
6
+ β†’ Runs on ESP32 via micromlgen C export
7
+
8
+ Path 2: MLSTM-FCN on sequential windowed data
9
+ β†’ Based on arxiv:1801.04503 + FusionActNet (arxiv:2310.02011)
10
+ β†’ Runs on ESP32-S3 via TFLite INT8
11
+
12
+ Both paths are trained and the best is selected for deployment.
13
+ """
14
+
15
+ import numpy as np
16
+ import joblib
17
+ from sklearn.ensemble import VotingClassifier, RandomForestClassifier
18
+ from sklearn.svm import SVC
19
+ from sklearn.tree import DecisionTreeClassifier
20
+ from sklearn.neural_network import MLPClassifier
21
+ from sklearn.metrics import classification_report, confusion_matrix, f1_score
22
+ from sklearn.preprocessing import StandardScaler
23
+ import xgboost as xgb
24
+
25
+ import sys
26
+ sys.path.insert(0, '/app/smart_chair')
27
+ from utils.feature_engineering import (
28
+ extract_tabular_features, extract_sequential_features,
29
+ create_sliding_windows, normalize_sensors
30
+ )
31
+ from config.settings import *
32
+
33
+
34
+ class EnsemblePostureClassifier:
35
+ """
36
+ Soft-voting ensemble: SVM + DecisionTree + MLP + XGBoost.
37
+ Best for tabular features extracted from sensor windows.
38
+ Target: 95.48%+ accuracy (user requirement).
39
+
40
+ Based on SitPose recipe: SVM(C=10,rbf) + DT(depth=10) + MLP(64,32).
41
+ Added XGBoost as 4th learner for additional improvement.
42
+ """
43
+
44
+ def __init__(self):
45
+ self.scaler = StandardScaler()
46
+ self.model = VotingClassifier(
47
+ estimators=[
48
+ ('svm', SVC(
49
+ probability=True,
50
+ kernel=ENSEMBLE_SVM_KERNEL,
51
+ C=ENSEMBLE_SVM_C,
52
+ gamma='scale',
53
+ class_weight='balanced'
54
+ )),
55
+ ('dt', DecisionTreeClassifier(
56
+ max_depth=ENSEMBLE_DT_MAX_DEPTH,
57
+ class_weight='balanced'
58
+ )),
59
+ ('mlp', MLPClassifier(
60
+ hidden_layer_sizes=ENSEMBLE_MLP_HIDDEN,
61
+ max_iter=ENSEMBLE_MLP_MAX_ITER,
62
+ early_stopping=True,
63
+ validation_fraction=0.1,
64
+ random_state=42
65
+ )),
66
+ ('xgb', xgb.XGBClassifier(
67
+ n_estimators=100,
68
+ max_depth=6,
69
+ learning_rate=0.1,
70
+ eval_metric='mlogloss',
71
+ random_state=42
72
+ )),
73
+ ('rf', RandomForestClassifier(
74
+ n_estimators=100,
75
+ max_depth=12,
76
+ class_weight='balanced',
77
+ random_state=42
78
+ )),
79
+ ],
80
+ voting='soft',
81
+ n_jobs=-1
82
+ )
83
+ self.is_trained = False
84
+ self.feature_names = None
85
+
86
+ def prepare_features(self, imu_data, load_data, window_size=WINDOW_SIZE, stride=WINDOW_STRIDE):
87
+ """
88
+ Extract tabular features from sliding windows of sensor data.
89
+
90
+ Args:
91
+ imu_data: shape (N, 6)
92
+ load_data: shape (N, 4)
93
+ window_size: samples per window
94
+ stride: window step
95
+
96
+ Returns:
97
+ features: shape (num_windows, n_features)
98
+ """
99
+ n_windows = (len(imu_data) - window_size) // stride
100
+ features_list = []
101
+
102
+ for i in range(0, len(imu_data) - window_size, stride):
103
+ imu_window = imu_data[i:i + window_size]
104
+ load_window = load_data[i:i + window_size]
105
+ feat = extract_tabular_features(imu_window, load_window, window_size)
106
+ features_list.append(feat)
107
+
108
+ return np.array(features_list)
109
+
110
+ def train(self, imu_train, load_train, labels_train,
111
+ imu_val=None, load_val=None, labels_val=None):
112
+ """
113
+ Train the ensemble classifier.
114
+
115
+ Args:
116
+ imu_train: shape (N, 6) β€” continuous IMU data
117
+ load_train: shape (N, 4) β€” continuous load cell data
118
+ labels_train: shape (N,) β€” per-sample labels
119
+ """
120
+ print("=" * 60)
121
+ print("ENSEMBLE POSTURE CLASSIFIER β€” TRAINING")
122
+ print("=" * 60)
123
+
124
+ # Create windows and extract features
125
+ print("\n[1/4] Extracting tabular features from training windows...")
126
+ X_windows, y_windows = create_sliding_windows(
127
+ np.concatenate([imu_train, load_train], axis=1),
128
+ labels_train,
129
+ window_size=WINDOW_SIZE, stride=WINDOW_STRIDE
130
+ )
131
+
132
+ X_features = []
133
+ for w in X_windows:
134
+ imu_w = w[:, :6]
135
+ load_w = w[:, 6:10]
136
+ X_features.append(extract_tabular_features(imu_w, load_w))
137
+ X_features = np.array(X_features)
138
+
139
+ print(f" Training samples: {len(X_features)}, Features: {X_features.shape[1]}")
140
+ print(f" Class distribution: {np.bincount(y_windows)}")
141
+
142
+ # Handle NaN/Inf
143
+ X_features = np.nan_to_num(X_features, nan=0, posinf=0, neginf=0)
144
+
145
+ # Scale features
146
+ print("\n[2/4] Scaling features...")
147
+ X_scaled = self.scaler.fit_transform(X_features)
148
+
149
+ # Train ensemble
150
+ print("\n[3/4] Training ensemble (SVM + DT + MLP + XGBoost + RF)...")
151
+ self.model.fit(X_scaled, y_windows)
152
+ self.is_trained = True
153
+
154
+ # Evaluate on training set
155
+ train_pred = self.model.predict(X_scaled)
156
+ train_f1 = f1_score(y_windows, train_pred, average='weighted')
157
+ print(f"\n Training F1 (weighted): {train_f1:.4f}")
158
+
159
+ # Evaluate on validation set if provided
160
+ if imu_val is not None:
161
+ print("\n[4/4] Evaluating on validation set...")
162
+ X_val_windows, y_val_windows = create_sliding_windows(
163
+ np.concatenate([imu_val, load_val], axis=1),
164
+ labels_val,
165
+ window_size=WINDOW_SIZE, stride=WINDOW_STRIDE
166
+ )
167
+
168
+ X_val_features = []
169
+ for w in X_val_windows:
170
+ X_val_features.append(extract_tabular_features(w[:, :6], w[:, 6:10]))
171
+ X_val_features = np.nan_to_num(np.array(X_val_features), nan=0, posinf=0, neginf=0)
172
+ X_val_scaled = self.scaler.transform(X_val_features)
173
+
174
+ val_pred = self.model.predict(X_val_scaled)
175
+ val_f1 = f1_score(y_val_windows, val_pred, average='weighted')
176
+
177
+ print(f"\n Validation F1 (weighted): {val_f1:.4f}")
178
+ print(f"\n Classification Report:")
179
+ print(classification_report(
180
+ y_val_windows, val_pred,
181
+ target_names=[POSTURE_CLASSES[i] for i in sorted(POSTURE_CLASSES.keys())]
182
+ ))
183
+ print(f"\n Confusion Matrix:")
184
+ print(confusion_matrix(y_val_windows, val_pred))
185
+
186
+ return {"train_f1": train_f1, "val_f1": val_f1}
187
+
188
+ return {"train_f1": train_f1}
189
+
190
+ def predict(self, imu_window, load_window):
191
+ """Single window inference."""
192
+ feat = extract_tabular_features(imu_window, load_window)
193
+ feat = np.nan_to_num(feat.reshape(1, -1), nan=0, posinf=0, neginf=0)
194
+ feat_scaled = self.scaler.transform(feat)
195
+ return self.model.predict(feat_scaled)[0]
196
+
197
+ def predict_proba(self, imu_window, load_window):
198
+ """Single window inference with probability."""
199
+ feat = extract_tabular_features(imu_window, load_window)
200
+ feat = np.nan_to_num(feat.reshape(1, -1), nan=0, posinf=0, neginf=0)
201
+ feat_scaled = self.scaler.transform(feat)
202
+ return self.model.predict_proba(feat_scaled)[0]
203
+
204
+ def save(self, path):
205
+ """Save model + scaler."""
206
+ joblib.dump({
207
+ 'model': self.model,
208
+ 'scaler': self.scaler,
209
+ 'posture_classes': POSTURE_CLASSES,
210
+ }, path)
211
+ print(f"Model saved to {path}")
212
+
213
+ def load(self, path):
214
+ """Load model + scaler."""
215
+ data = joblib.load(path)
216
+ self.model = data['model']
217
+ self.scaler = data['scaler']
218
+ self.is_trained = True
219
+ print(f"Model loaded from {path}")
220
+
221
+
222
+ def build_mlstm_fcn(n_timesteps=WINDOW_SIZE, n_channels=INPUT_CHANNELS,
223
+ n_classes=NUM_POSTURE_CLASSES):
224
+ """
225
+ Build MLSTM-FCN model for sequential sensor data.
226
+ Architecture from arxiv:1801.04503 with Squeeze-Excite blocks.
227
+
228
+ Input: (batch, timesteps, channels) β€” windowed sensor data
229
+ Output: (batch, n_classes) β€” posture probabilities
230
+ """
231
+ import tensorflow as tf
232
+ from tensorflow.keras import layers, Model
233
+
234
+ inputs = layers.Input(shape=(n_timesteps, n_channels), name='sensor_input')
235
+
236
+ # ── FCN Branch with Squeeze-Excite ────────────────────────────────────
237
+ x = inputs
238
+ for filters, kernel in zip(MLSTM_FCN_FILTERS, MLSTM_FCN_KERNELS):
239
+ x = layers.Conv1D(filters, kernel, padding='same')(x)
240
+ x = layers.BatchNormalization(momentum=0.99, epsilon=0.001)(x)
241
+ x = layers.ReLU()(x)
242
+
243
+ # Squeeze-Excite block (channel attention)
244
+ se = layers.GlobalAveragePooling1D()(x)
245
+ se = layers.Dense(filters // MLSTM_FCN_SE_RATIO, activation='relu')(se)
246
+ se = layers.Dense(filters, activation='sigmoid')(se)
247
+ se = layers.Reshape((1, filters))(se)
248
+ x = layers.Multiply()([x, se])
249
+
250
+ fcn_out = layers.GlobalAveragePooling1D(name='fcn_features')(x)
251
+
252
+ # ── LSTM Branch (dimension shuffle) ───────────────────────────────────
253
+ # Permute: (batch, timesteps, channels) β†’ (batch, channels, timesteps)
254
+ # LSTM sees each channel as a "timestep" over all time positions
255
+ shuffled = layers.Permute((2, 1))(inputs)
256
+ lstm_out = layers.LSTM(MLSTM_FCN_LSTM_UNITS, name='lstm_features')(shuffled)
257
+ lstm_out = layers.Dropout(MLSTM_FCN_DROPOUT)(lstm_out)
258
+
259
+ # ── Merge ─────────────────────────────────────────────────────────────
260
+ merged = layers.Concatenate(name='fused_features')([fcn_out, lstm_out])
261
+
262
+ # Classification head
263
+ output = layers.Dense(n_classes, activation='softmax', name='posture_output')(merged)
264
+
265
+ model = Model(inputs, output, name='MLSTM_FCN_Posture')
266
+ model.compile(
267
+ optimizer=tf.keras.optimizers.Adam(learning_rate=TRAIN_LR),
268
+ loss='sparse_categorical_crossentropy',
269
+ metrics=['accuracy']
270
+ )
271
+
272
+ return model
273
+
274
+
275
+ def train_mlstm_fcn(model, X_train, y_train, X_val, y_val):
276
+ """
277
+ Train MLSTM-FCN with early stopping and learning rate reduction.
278
+ """
279
+ import tensorflow as tf
280
+
281
+ callbacks = [
282
+ tf.keras.callbacks.EarlyStopping(
283
+ monitor='val_accuracy', patience=EARLY_STOPPING_PATIENCE,
284
+ restore_best_weights=True, mode='max'
285
+ ),
286
+ tf.keras.callbacks.ReduceLROnPlateau(
287
+ monitor='val_loss', factor=0.5, patience=5, min_lr=1e-6
288
+ ),
289
+ ]
290
+
291
+ history = model.fit(
292
+ X_train, y_train,
293
+ validation_data=(X_val, y_val),
294
+ epochs=TRAIN_EPOCHS,
295
+ batch_size=TRAIN_BATCH_SIZE,
296
+ callbacks=callbacks,
297
+ verbose=1
298
+ )
299
+
300
+ return history
301
+
302
+
303
+ def convert_to_tflite(model, representative_data, output_path='posture_model.tflite'):
304
+ """
305
+ Convert Keras model to INT8 TFLite for ESP32-S3 deployment.
306
+ Based on TinyNav (arxiv:2603.11071) quantization recipe.
307
+ """
308
+ import tensorflow as tf
309
+
310
+ def representative_dataset():
311
+ for sample in representative_data[:200]:
312
+ yield [sample[np.newaxis].astype(np.float32)]
313
+
314
+ converter = tf.lite.TFLiteConverter.from_keras_model(model)
315
+ converter.optimizations = [tf.lite.Optimize.DEFAULT]
316
+ converter.representative_dataset = representative_dataset
317
+ converter.target_spec.supported_ops = [tf.lite.OpsSet.TFLITE_BUILTINS_INT8]
318
+ converter.inference_input_type = tf.int8
319
+ converter.inference_output_type = tf.int8
320
+
321
+ tflite_model = converter.convert()
322
+
323
+ with open(output_path, 'wb') as f:
324
+ f.write(tflite_model)
325
+
326
+ print(f"TFLite model saved: {output_path} ({len(tflite_model)/1024:.1f} KB)")
327
+ return tflite_model
328
+
329
+
330
+ if __name__ == "__main__":
331
+ from data_collection.synthetic_data_generator import generate_dataset, subject_based_split
332
+
333
+ # Generate synthetic data
334
+ print("Generating synthetic dataset...")
335
+ dataset = generate_dataset(n_subjects=20, samples_per_posture_per_subject=500)
336
+ train, test = subject_based_split(dataset)
337
+
338
+ # Train Ensemble Classifier
339
+ print("\n" + "=" * 60)
340
+ print("TRAINING ENSEMBLE CLASSIFIER")
341
+ print("=" * 60)
342
+
343
+ clf = EnsemblePostureClassifier()
344
+ results = clf.train(
345
+ train["imu_data"], train["load_data"], train["labels"],
346
+ test["imu_data"], test["load_data"], test["labels"]
347
+ )
348
+
349
+ print(f"\nFinal Results β€” Ensemble: {results}")
350
+ clf.save("/app/smart_chair/models/ensemble_posture.joblib")
smart_chair/ml_models/spine_risk_predictor.py ADDED
@@ -0,0 +1,555 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ SmartChair Spine Risk Prediction & Fatigue Detection.
3
+
4
+ 1. RULA-based Ergonomic Risk Score (real-time)
5
+ β†’ Based on DULA/DEBA (arxiv:2205.03491): differentiable RULA scoring
6
+ β†’ Maps IMU angles to 1-7 risk scale per RULA standard
7
+
8
+ 2. Cumulative Spine Risk Prediction (long-term)
9
+ β†’ Accumulates posture history β†’ predicts injury risk trajectory
10
+ β†’ Uses exponential decay weighting (recent bad posture = higher risk)
11
+
12
+ 3. Fatigue & Drowsiness Detection (pressure patterns)
13
+ β†’ Based on CoP variance trends over 30-min windows
14
+ β†’ Increasing variability = restlessness β†’ fatigue
15
+ β†’ Decreasing micro-movement = drowsiness/falling asleep
16
+ """
17
+
18
+ import numpy as np
19
+ from collections import deque
20
+ from datetime import datetime, timedelta
21
+ from typing import Dict, List, Optional, Tuple
22
+
23
+ import sys
24
+ sys.path.insert(0, '/app/smart_chair')
25
+ from utils.feature_engineering import compute_imu_angles, compute_center_of_pressure
26
+ from config.settings import *
27
+
28
+
29
+ # ═══════════════════════════════════════════════════════════════════════════════
30
+ # 1. RULA-BASED REAL-TIME ERGONOMIC RISK SCORE
31
+ # ═══════════════════════════════════════════════════════════════════════════════
32
+
33
+ class RULAScorer:
34
+ """
35
+ Rapid Upper Limb Assessment (RULA) adapted for seated posture.
36
+ Maps trunk pitch/roll angles to a 1-7 risk score.
37
+
38
+ Score interpretation (EN ISO 11226 + RULA):
39
+ 1-2: Acceptable posture
40
+ 3-4: Investigate further, possible intervention needed
41
+ 5-6: Investigate and implement changes soon
42
+ 7: Investigate and implement changes immediately
43
+ """
44
+
45
+ def __init__(self):
46
+ self.trunk_angle_thresholds = RULA_TRUNK_ANGLES # [10, 20, 60] degrees
47
+ self.lateral_threshold = RULA_LATERAL_THRESHOLD # 15 degrees
48
+
49
+ def score_trunk(self, pitch_deg: float) -> int:
50
+ """Score trunk forward/backward flexion (1-4 base score)."""
51
+ deviation = abs(pitch_deg)
52
+ if deviation < self.trunk_angle_thresholds[0]: # < 10Β°
53
+ return 1
54
+ elif deviation < self.trunk_angle_thresholds[1]: # 10-20Β°
55
+ return 2
56
+ elif deviation < self.trunk_angle_thresholds[2]: # 20-60Β°
57
+ return 3
58
+ else: # > 60Β°
59
+ return 4
60
+
61
+ def score_lateral(self, roll_deg: float) -> int:
62
+ """Score lateral trunk deviation (0-1 modifier)."""
63
+ return 1 if abs(roll_deg) > self.lateral_threshold else 0
64
+
65
+ def score_twist(self, yaw_rate: float) -> int:
66
+ """Score trunk twist from gyroscope yaw rate (0-1 modifier)."""
67
+ return 1 if abs(yaw_rate) > 0.5 else 0 # rad/s threshold
68
+
69
+ def compute_rula_score(self, pitch_deg: float, roll_deg: float,
70
+ yaw_rate: float = 0.0,
71
+ duration_minutes: float = 0.0,
72
+ is_static: bool = True) -> Dict:
73
+ """
74
+ Compute full RULA score for seated posture.
75
+
76
+ Args:
77
+ pitch_deg: trunk forward/backward angle (from IMU)
78
+ roll_deg: trunk lateral angle (from IMU)
79
+ yaw_rate: trunk rotation rate (from gyroscope z-axis)
80
+ duration_minutes: how long in this posture
81
+ is_static: True if posture held > 1 minute
82
+
83
+ Returns:
84
+ dict with score, risk_level, recommendations
85
+ """
86
+ # Base trunk score
87
+ trunk_score = self.score_trunk(pitch_deg)
88
+ trunk_score += self.score_lateral(roll_deg)
89
+ trunk_score += self.score_twist(yaw_rate)
90
+
91
+ # Static posture modifier (held > 1 min adds +1)
92
+ if is_static and duration_minutes > 1:
93
+ trunk_score += 1
94
+
95
+ # Duration escalation (long-held bad posture is worse)
96
+ if duration_minutes > 30:
97
+ trunk_score += 1
98
+ if duration_minutes > 60:
99
+ trunk_score += 1
100
+
101
+ # Clamp to 1-7
102
+ final_score = max(1, min(7, trunk_score))
103
+
104
+ return {
105
+ "rula_score": final_score,
106
+ "risk_level": RISK_LEVELS.get(final_score, "unknown"),
107
+ "trunk_base": self.score_trunk(pitch_deg),
108
+ "lateral_mod": self.score_lateral(roll_deg),
109
+ "twist_mod": self.score_twist(yaw_rate),
110
+ "duration_minutes": duration_minutes,
111
+ "pitch_deg": pitch_deg,
112
+ "roll_deg": roll_deg,
113
+ "recommendations": self._get_recommendations(final_score, pitch_deg, roll_deg)
114
+ }
115
+
116
+ def _get_recommendations(self, score: int, pitch: float, roll: float) -> List[str]:
117
+ """Generate actionable recommendations based on risk score."""
118
+ recs = []
119
+ if score <= 2:
120
+ recs.append("Good posture! Keep it up.")
121
+ if score >= 3:
122
+ recs.append("Adjust your sitting position β€” try moving back in the chair.")
123
+ if abs(pitch) > 20:
124
+ recs.append(f"Forward lean detected ({abs(pitch):.0f}Β°). Sit upright and engage core.")
125
+ if abs(roll) > 15:
126
+ side = "left" if roll < 0 else "right"
127
+ recs.append(f"Leaning {side} ({abs(roll):.0f}Β°). Center your weight evenly.")
128
+ if score >= 5:
129
+ recs.append("⚠️ HIGH RISK: Take a break immediately and do spine stretches.")
130
+ if score >= 7:
131
+ recs.append("🚨 CRITICAL: Prolonged poor posture detected. Stand up NOW.")
132
+ return recs
133
+
134
+
135
+ # ═══════════════════════════════════════════════════════════════════════════════
136
+ # 2. CUMULATIVE SPINE RISK PREDICTOR
137
+ # ═══════════════════════════════════════════════════════════════════════════════
138
+
139
+ class SpineRiskPredictor:
140
+ """
141
+ Long-term spine risk prediction using accumulated session data.
142
+
143
+ Model: Exponential decay weighted accumulator.
144
+ Recent bad posture has more impact than historical bad posture.
145
+
146
+ Risk score = Ξ£ (posture_penalty Γ— decay^(time_elapsed)) / normalization
147
+
148
+ Outputs:
149
+ - Daily risk score (0-100)
150
+ - Weekly trend (improving / stable / deteriorating)
151
+ - Predicted injury risk category
152
+ """
153
+
154
+ def __init__(self, decay_rate: float = 0.95):
155
+ self.decay_rate = decay_rate
156
+ self.history = deque(maxlen=7 * 24 * 60) # 1 week of per-minute records
157
+ self.daily_scores = deque(maxlen=30) # 30 days of daily scores
158
+
159
+ # Posture penalty weights (higher = worse for spine)
160
+ self.posture_penalties = {
161
+ "upright": 0.0,
162
+ "forward_lean": 0.6,
163
+ "backward_lean": 0.3,
164
+ "left_lean": 0.4,
165
+ "right_lean": 0.4,
166
+ "slouch": 1.0, # worst posture for spine
167
+ "absent": 0.0,
168
+ }
169
+
170
+ def record_posture(self, posture_name: str, rula_score: int,
171
+ duration_minutes: float = 1.0, timestamp: Optional[datetime] = None):
172
+ """Record a posture observation."""
173
+ if timestamp is None:
174
+ timestamp = datetime.now()
175
+
176
+ self.history.append({
177
+ "timestamp": timestamp,
178
+ "posture": posture_name,
179
+ "rula_score": rula_score,
180
+ "duration": duration_minutes,
181
+ "penalty": self.posture_penalties.get(posture_name, 0.5),
182
+ })
183
+
184
+ def compute_session_risk(self, lookback_minutes: int = 60) -> Dict:
185
+ """
186
+ Compute risk score for the current session.
187
+
188
+ Returns:
189
+ dict with risk_score (0-100), trend, breakdown
190
+ """
191
+ now = datetime.now()
192
+ cutoff = now - timedelta(minutes=lookback_minutes)
193
+
194
+ recent = [r for r in self.history if r["timestamp"] >= cutoff]
195
+
196
+ if not recent:
197
+ return {"risk_score": 0, "trend": "no_data", "minutes_analyzed": 0}
198
+
199
+ # Weighted risk accumulation
200
+ total_risk = 0
201
+ total_weight = 0
202
+ posture_breakdown = {}
203
+
204
+ for record in recent:
205
+ elapsed = (now - record["timestamp"]).total_seconds() / 60
206
+ weight = self.decay_rate ** elapsed # exponential decay
207
+ risk = record["penalty"] * record["rula_score"] * record["duration"]
208
+
209
+ total_risk += risk * weight
210
+ total_weight += weight * record["duration"]
211
+
212
+ p = record["posture"]
213
+ posture_breakdown[p] = posture_breakdown.get(p, 0) + record["duration"]
214
+
215
+ # Normalize to 0-100 scale
216
+ max_possible_risk = 7 * 1.0 * lookback_minutes # max RULA Γ— max penalty Γ— all minutes
217
+ risk_score = min(100, (total_risk / (total_weight + 1e-6)) * 100 / 7)
218
+
219
+ return {
220
+ "risk_score": round(risk_score, 1),
221
+ "risk_category": self._categorize_risk(risk_score),
222
+ "minutes_analyzed": sum(r["duration"] for r in recent),
223
+ "posture_breakdown": posture_breakdown,
224
+ "dominant_posture": max(posture_breakdown, key=posture_breakdown.get) if posture_breakdown else "unknown",
225
+ }
226
+
227
+ def compute_daily_score(self) -> float:
228
+ """Compute and store daily posture score (0-100, higher = better)."""
229
+ session = self.compute_session_risk(lookback_minutes=24*60)
230
+ # Invert: low risk = high score
231
+ daily_score = max(0, 100 - session["risk_score"])
232
+ self.daily_scores.append({
233
+ "date": datetime.now().date(),
234
+ "score": daily_score,
235
+ })
236
+ return daily_score
237
+
238
+ def get_weekly_trend(self) -> Dict:
239
+ """Analyze 7-day trend."""
240
+ if len(self.daily_scores) < 2:
241
+ return {"trend": "insufficient_data", "scores": []}
242
+
243
+ scores = [s["score"] for s in self.daily_scores]
244
+ recent_3 = np.mean(scores[-3:]) if len(scores) >= 3 else scores[-1]
245
+ older_3 = np.mean(scores[-6:-3]) if len(scores) >= 6 else scores[0]
246
+
247
+ if recent_3 > older_3 + 5:
248
+ trend = "improving"
249
+ elif recent_3 < older_3 - 5:
250
+ trend = "deteriorating"
251
+ else:
252
+ trend = "stable"
253
+
254
+ return {
255
+ "trend": trend,
256
+ "current_avg": round(recent_3, 1),
257
+ "previous_avg": round(older_3, 1),
258
+ "scores": [{"date": str(s["date"]), "score": s["score"]} for s in self.daily_scores],
259
+ }
260
+
261
+ def predict_injury_risk(self, weeks_ahead: int = 4) -> Dict:
262
+ """
263
+ Predict future injury risk based on current trajectory.
264
+
265
+ Uses linear extrapolation of daily scores to estimate
266
+ when risk crosses critical thresholds.
267
+ """
268
+ if len(self.daily_scores) < 7:
269
+ return {"prediction": "need_more_data", "days_collected": len(self.daily_scores)}
270
+
271
+ scores = [s["score"] for s in self.daily_scores]
272
+ x = np.arange(len(scores))
273
+ slope, intercept = np.polyfit(x, scores, 1)
274
+
275
+ # Project forward
276
+ future_x = np.arange(len(scores), len(scores) + weeks_ahead * 7)
277
+ projected = slope * future_x + intercept
278
+
279
+ # Find when score drops below thresholds
280
+ alerts = []
281
+ if slope < -0.5: # declining at > 0.5 points/day
282
+ days_to_danger = int((40 - intercept) / slope) if slope != 0 else None
283
+ if days_to_danger and 0 < days_to_danger < weeks_ahead * 7:
284
+ alerts.append(f"⚠️ At current rate, posture score will reach DANGER zone in {days_to_danger} days")
285
+
286
+ return {
287
+ "slope": round(slope, 3), # points per day
288
+ "direction": "improving" if slope > 0 else "declining",
289
+ "projected_4week_score": round(projected[-1], 1),
290
+ "current_score": round(scores[-1], 1),
291
+ "alerts": alerts,
292
+ "recommendation": "Maintain current habits" if slope >= 0 else
293
+ "Increase break frequency and do corrective exercises"
294
+ }
295
+
296
+ def _categorize_risk(self, risk_score: float) -> str:
297
+ if risk_score < 20: return "low"
298
+ elif risk_score < 40: return "moderate"
299
+ elif risk_score < 60: return "elevated"
300
+ elif risk_score < 80: return "high"
301
+ else: return "critical"
302
+
303
+
304
+ # ═══════════════════════════════════════════════════════════════════════════════
305
+ # 3. FATIGUE & DROWSINESS DETECTION
306
+ # ═══════════════════════════════════════════════════════════════════════════════
307
+
308
+ class FatigueDetector:
309
+ """
310
+ Detects physical fatigue and drowsiness from pressure/load cell patterns.
311
+
312
+ Fatigue indicators (from literature):
313
+ 1. Increasing CoP variance (restlessness, frequent shifting)
314
+ 2. Decreasing micro-movement amplitude (stillness = drowsiness)
315
+ 3. Gradual forward lean (torso muscle fatigue β†’ slouch)
316
+ 4. Weight redistribution pattern changes
317
+
318
+ Uses rolling window analysis over 30 minutes.
319
+ """
320
+
321
+ def __init__(self, window_minutes: int = FATIGUE_WINDOW_MINUTES):
322
+ self.window_minutes = window_minutes
323
+ self.cop_history = deque(maxlen=window_minutes * 60 * 10) # 10 Hz
324
+ self.movement_history = deque(maxlen=window_minutes * 60 * 50) # 50 Hz IMU
325
+ self.posture_sequence = deque(maxlen=window_minutes) # per-minute
326
+ self.alert_cooldown_seconds = 300 # 5 min between alerts
327
+ self.last_alert_time = None
328
+
329
+ def update(self, load_cells: np.ndarray, imu_data: np.ndarray,
330
+ timestamp: Optional[datetime] = None):
331
+ """
332
+ Add new sensor readings. Call at sensor sampling rate.
333
+
334
+ Args:
335
+ load_cells: shape (4,) β€” [FL, FR, RL, RR]
336
+ imu_data: shape (6,) β€” [ax, ay, az, gx, gy, gz]
337
+ """
338
+ if timestamp is None:
339
+ timestamp = datetime.now()
340
+
341
+ # Compute CoP
342
+ total = np.sum(load_cells) + 1e-6
343
+ cop_x = (load_cells[1] + load_cells[3] - load_cells[0] - load_cells[2]) / total
344
+ cop_y = (load_cells[0] + load_cells[1] - load_cells[2] - load_cells[3]) / total
345
+
346
+ self.cop_history.append({
347
+ "timestamp": timestamp,
348
+ "cop_x": cop_x,
349
+ "cop_y": cop_y,
350
+ "total_weight": total,
351
+ })
352
+
353
+ # Compute acceleration magnitude (micro-movement indicator)
354
+ accel_mag = np.linalg.norm(imu_data[:3])
355
+ self.movement_history.append({
356
+ "timestamp": timestamp,
357
+ "accel_mag": accel_mag,
358
+ "gyro_mag": np.linalg.norm(imu_data[3:]),
359
+ })
360
+
361
+ def analyze_fatigue(self) -> Dict:
362
+ """
363
+ Analyze current fatigue state from accumulated sensor history.
364
+
365
+ Returns:
366
+ dict with fatigue_level, drowsiness_risk, indicators
367
+ """
368
+ if len(self.cop_history) < 100:
369
+ return {"fatigue_level": "unknown", "drowsiness_risk": "unknown",
370
+ "reason": "insufficient_data"}
371
+
372
+ # ── CoP Variance Analysis (restlessness) ──────────────────────────
373
+ cop_x_values = [r["cop_x"] for r in self.cop_history]
374
+ cop_y_values = [r["cop_y"] for r in self.cop_history]
375
+
376
+ # Split into 5-minute windows
377
+ n = len(cop_x_values)
378
+ window_5min = min(3000, n // 3) # ~5 min at 10 Hz
379
+
380
+ early_cop_var = np.var(cop_x_values[:window_5min]) + np.var(cop_y_values[:window_5min])
381
+ recent_cop_var = np.var(cop_x_values[-window_5min:]) + np.var(cop_y_values[-window_5min:])
382
+
383
+ cop_trend = recent_cop_var / (early_cop_var + 1e-6)
384
+
385
+ # ── Micro-Movement Analysis (drowsiness) ─────────────────────────
386
+ accel_values = [r["accel_mag"] for r in self.movement_history]
387
+ n_mov = len(accel_values)
388
+ window_2min = min(6000, n_mov // 3) # ~2 min at 50 Hz
389
+
390
+ recent_movement = np.std(accel_values[-window_2min:])
391
+ early_movement = np.std(accel_values[:window_2min])
392
+
393
+ movement_decline = recent_movement / (early_movement + 1e-6)
394
+
395
+ # ── Weight Shift Analysis ─────────────────────────────────────────
396
+ weights = [r["total_weight"] for r in self.cop_history]
397
+ weight_stability = np.std(weights[-window_5min:])
398
+
399
+ # ── Fatigue Classification ────────────────────────────────────────
400
+ fatigue_score = 0
401
+ indicators = []
402
+
403
+ # Restlessness (high CoP variance trend)
404
+ if cop_trend > 2.0:
405
+ fatigue_score += 3
406
+ indicators.append(f"Restlessness: CoP variance increased {cop_trend:.1f}x")
407
+ elif cop_trend > 1.5:
408
+ fatigue_score += 1
409
+ indicators.append(f"Mild restlessness: CoP variance increased {cop_trend:.1f}x")
410
+
411
+ # Drowsiness (decreasing movement)
412
+ if movement_decline < 0.3:
413
+ fatigue_score += 4
414
+ indicators.append(f"⚠️ Possible drowsiness: movement dropped to {movement_decline:.1%} of baseline")
415
+ elif movement_decline < 0.6:
416
+ fatigue_score += 2
417
+ indicators.append(f"Reduced activity: movement at {movement_decline:.1%} of baseline")
418
+
419
+ # Weight instability
420
+ if weight_stability > 2.0:
421
+ fatigue_score += 1
422
+ indicators.append("Frequent weight shifting detected")
423
+
424
+ # Classify
425
+ if fatigue_score >= 5:
426
+ fatigue_level = "high"
427
+ elif fatigue_score >= 3:
428
+ fatigue_level = "moderate"
429
+ elif fatigue_score >= 1:
430
+ fatigue_level = "low"
431
+ else:
432
+ fatigue_level = "none"
433
+
434
+ # Drowsiness specifically
435
+ drowsiness_risk = "high" if movement_decline < 0.3 else \
436
+ "moderate" if movement_decline < 0.5 else "low"
437
+
438
+ return {
439
+ "fatigue_level": fatigue_level,
440
+ "fatigue_score": fatigue_score,
441
+ "drowsiness_risk": drowsiness_risk,
442
+ "cop_variance_trend": round(cop_trend, 2),
443
+ "movement_decline_ratio": round(movement_decline, 3),
444
+ "indicators": indicators,
445
+ "recommendation": self._get_fatigue_recommendation(fatigue_level, drowsiness_risk),
446
+ "minutes_analyzed": len(self.cop_history) / 600, # at 10 Hz
447
+ }
448
+
449
+ def _get_fatigue_recommendation(self, fatigue: str, drowsiness: str) -> str:
450
+ if drowsiness == "high":
451
+ return "🚨 DROWSINESS ALERT: Stand up, walk, and get fresh air immediately!"
452
+ if fatigue == "high":
453
+ return "⚠️ High fatigue detected. Take a 5-minute active break with stretching."
454
+ if fatigue == "moderate":
455
+ return "Moderate fatigue. Consider a micro-break in the next 10 minutes."
456
+ return "Looking good! No fatigue issues detected."
457
+
458
+ def should_alert(self) -> bool:
459
+ """Check if enough time has passed since last alert."""
460
+ if self.last_alert_time is None:
461
+ return True
462
+ elapsed = (datetime.now() - self.last_alert_time).total_seconds()
463
+ return elapsed > self.alert_cooldown_seconds
464
+
465
+ def send_alert(self):
466
+ """Mark alert as sent."""
467
+ self.last_alert_time = datetime.now()
468
+
469
+
470
+ # ═══════════════════════════════════════════════════════════════════════════════
471
+ # 4. INJURY RISK ALERT SYSTEM
472
+ # ═══════════════════════════════════════════════════════════════════════════════
473
+
474
+ class InjuryRiskAlertSystem:
475
+ """
476
+ Monitors long-term postural habits and generates injury risk alerts.
477
+
478
+ Alert types:
479
+ - Acute: Dangerous posture held too long (immediate)
480
+ - Chronic: Pattern of bad posture over days/weeks
481
+ - Trend: Deteriorating posture score trajectory
482
+ """
483
+
484
+ def __init__(self):
485
+ self.alerts = deque(maxlen=1000)
486
+ self.acknowledged = set()
487
+
488
+ def check_acute_risk(self, rula_score: int, posture_name: str,
489
+ duration_minutes: float) -> Optional[Dict]:
490
+ """Check for immediate dangerous posture."""
491
+ alert = None
492
+
493
+ if rula_score >= 6 and duration_minutes > 5:
494
+ alert = {
495
+ "type": "acute",
496
+ "severity": "critical",
497
+ "message": f"🚨 CRITICAL: {posture_name} posture (RULA={rula_score}) "
498
+ f"held for {duration_minutes:.0f} min. Risk of acute back strain.",
499
+ "action": "Stand up and do gentle back extension stretches immediately.",
500
+ "timestamp": datetime.now().isoformat(),
501
+ }
502
+ elif rula_score >= 4 and duration_minutes > 20:
503
+ alert = {
504
+ "type": "acute",
505
+ "severity": "warning",
506
+ "message": f"⚠️ WARNING: {posture_name} posture (RULA={rula_score}) "
507
+ f"for {duration_minutes:.0f} min. Spine compression accumulating.",
508
+ "action": "Adjust posture and take a micro-break within 10 minutes.",
509
+ "timestamp": datetime.now().isoformat(),
510
+ }
511
+
512
+ if alert:
513
+ self.alerts.append(alert)
514
+ return alert
515
+
516
+ def check_chronic_risk(self, daily_scores: List[float]) -> Optional[Dict]:
517
+ """Check for chronic poor posture patterns."""
518
+ if len(daily_scores) < 7:
519
+ return None
520
+
521
+ week_avg = np.mean(daily_scores[-7:])
522
+
523
+ if week_avg < 30:
524
+ alert = {
525
+ "type": "chronic",
526
+ "severity": "critical",
527
+ "message": f"🚨 CHRONIC RISK: Weekly posture score = {week_avg:.0f}/100. "
528
+ f"Persistent poor posture increases herniated disc risk by 4x.",
529
+ "action": "Schedule ergonomic assessment. Consider standing desk alternation.",
530
+ "timestamp": datetime.now().isoformat(),
531
+ }
532
+ self.alerts.append(alert)
533
+ return alert
534
+
535
+ if week_avg < 50:
536
+ alert = {
537
+ "type": "chronic",
538
+ "severity": "warning",
539
+ "message": f"⚠️ CONCERN: Weekly posture score = {week_avg:.0f}/100. "
540
+ f"Consistent moderate risk detected.",
541
+ "action": "Increase break frequency and add core strengthening exercises.",
542
+ "timestamp": datetime.now().isoformat(),
543
+ }
544
+ self.alerts.append(alert)
545
+ return alert
546
+
547
+ return None
548
+
549
+ def get_active_alerts(self) -> List[Dict]:
550
+ """Get all unacknowledged alerts."""
551
+ return [a for i, a in enumerate(self.alerts) if i not in self.acknowledged]
552
+
553
+ def acknowledge_alert(self, alert_index: int):
554
+ """Mark alert as acknowledged."""
555
+ self.acknowledged.add(alert_index)
smart_chair/ml_models/user_recognition.py ADDED
@@ -0,0 +1,409 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ SmartChair Multi-User Recognition via Weight Signature Patterns.
3
+
4
+ Based on pressure biometrics literature + AuthentiSense (arxiv:2302.02740):
5
+ - Uses load cell weight distribution as biometric signature
6
+ - Few-shot learning: 5 sit-down events per user for enrollment
7
+ - k-NN matching with Mahalanobis distance for robustness
8
+ - Siamese network option for improved accuracy with more data
9
+
10
+ Weight signature features:
11
+ - Total weight (strongest discriminator, Β±3kg tolerance)
12
+ - Front/back weight ratio (sitting style)
13
+ - Left/right weight ratio (postural asymmetry)
14
+ - CoP pattern during sit-down transition (unique to individual)
15
+ """
16
+
17
+ import numpy as np
18
+ from collections import defaultdict
19
+ from typing import Dict, List, Optional, Tuple
20
+ from sklearn.neighbors import KNeighborsClassifier
21
+ from sklearn.preprocessing import StandardScaler
22
+ import joblib
23
+
24
+ import sys
25
+ sys.path.insert(0, '/app/smart_chair')
26
+ from utils.feature_engineering import compute_center_of_pressure
27
+ from config.settings import *
28
+
29
+
30
+ class WeightSignature:
31
+ """Represents a user's unique weight distribution pattern."""
32
+
33
+ def __init__(self, load_readings: np.ndarray):
34
+ """
35
+ Extract weight signature from a sequence of load cell readings.
36
+
37
+ Args:
38
+ load_readings: shape (N, 4) β€” [FL, FR, RL, RR] over ~10 seconds of sitting
39
+ """
40
+ total = np.sum(load_readings, axis=1, keepdims=True) + 1e-6
41
+ normalized = load_readings / total
42
+
43
+ self.total_weight = np.mean(total)
44
+ self.total_weight_std = np.std(total)
45
+
46
+ # Per-cell ratios (unique to how a person sits)
47
+ self.fl_ratio = np.mean(normalized[:, 0])
48
+ self.fr_ratio = np.mean(normalized[:, 1])
49
+ self.rl_ratio = np.mean(normalized[:, 2])
50
+ self.rr_ratio = np.mean(normalized[:, 3])
51
+
52
+ # Ratio variabilities (how stable their sitting is)
53
+ self.fl_std = np.std(normalized[:, 0])
54
+ self.fr_std = np.std(normalized[:, 1])
55
+ self.rl_std = np.std(normalized[:, 2])
56
+ self.rr_std = np.std(normalized[:, 3])
57
+
58
+ # Derived ratios
59
+ front = normalized[:, 0] + normalized[:, 1]
60
+ back = normalized[:, 2] + normalized[:, 3]
61
+ left = normalized[:, 0] + normalized[:, 2]
62
+ right = normalized[:, 1] + normalized[:, 3]
63
+
64
+ self.front_back_ratio = np.mean(front / (back + 1e-6))
65
+ self.left_right_ratio = np.mean(left / (right + 1e-6))
66
+
67
+ # CoP signature
68
+ cop_x, cop_y = compute_center_of_pressure(load_readings)
69
+ self.cop_x_mean = np.mean(cop_x)
70
+ self.cop_y_mean = np.mean(cop_y)
71
+ self.cop_x_std = np.std(cop_x)
72
+ self.cop_y_std = np.std(cop_y)
73
+
74
+ # Sit-down dynamics (how quickly weight stabilizes)
75
+ if len(load_readings) > 20:
76
+ weight_diff = np.diff(total.flatten())
77
+ self.sitdown_speed = np.mean(np.abs(weight_diff[:20]))
78
+ self.stability_time = np.argmax(np.abs(weight_diff) < 0.5) if np.any(np.abs(weight_diff) < 0.5) else len(weight_diff)
79
+ else:
80
+ self.sitdown_speed = 0
81
+ self.stability_time = 0
82
+
83
+ def to_vector(self) -> np.ndarray:
84
+ """Convert to feature vector for ML models."""
85
+ return np.array([
86
+ self.total_weight,
87
+ self.fl_ratio, self.fr_ratio, self.rl_ratio, self.rr_ratio,
88
+ self.fl_std, self.fr_std, self.rl_std, self.rr_std,
89
+ self.front_back_ratio, self.left_right_ratio,
90
+ self.cop_x_mean, self.cop_y_mean,
91
+ self.cop_x_std, self.cop_y_std,
92
+ self.sitdown_speed, self.stability_time,
93
+ ], dtype=np.float32)
94
+
95
+ @staticmethod
96
+ def feature_names() -> List[str]:
97
+ return [
98
+ "total_weight",
99
+ "fl_ratio", "fr_ratio", "rl_ratio", "rr_ratio",
100
+ "fl_std", "fr_std", "rl_std", "rr_std",
101
+ "front_back_ratio", "left_right_ratio",
102
+ "cop_x_mean", "cop_y_mean",
103
+ "cop_x_std", "cop_y_std",
104
+ "sitdown_speed", "stability_time",
105
+ ]
106
+
107
+
108
+ class MultiUserRecognizer:
109
+ """
110
+ Recognizes seated users from load cell weight distribution patterns.
111
+
112
+ Enrollment: User sits down 5 times, system captures weight signatures.
113
+ Recognition: Compare new sitting event to enrolled profiles.
114
+
115
+ Uses k-NN with k=3 (from AuthentiSense recipe for few-shot biometrics).
116
+ Falls back to weight-based nearest-neighbor if k-NN confidence is low.
117
+ """
118
+
119
+ def __init__(self, n_enrollment_samples: int = USER_ENROLLMENT_SAMPLES):
120
+ self.n_enrollment_samples = n_enrollment_samples
121
+ self.enrolled_users = {} # user_id β†’ list of WeightSignatures
122
+ self.knn_model = None
123
+ self.scaler = StandardScaler()
124
+ self.is_trained = False
125
+ self.confidence_threshold = 0.6 # minimum confidence for recognition
126
+
127
+ def enroll_user(self, user_id: str, load_readings_list: List[np.ndarray]) -> Dict:
128
+ """
129
+ Enroll a new user with multiple sit-down events.
130
+
131
+ Args:
132
+ user_id: unique identifier (e.g., "user_alice")
133
+ load_readings_list: list of (N, 4) arrays, one per sit-down event
134
+
135
+ Returns:
136
+ enrollment status dict
137
+ """
138
+ if len(load_readings_list) < self.n_enrollment_samples:
139
+ return {
140
+ "status": "incomplete",
141
+ "message": f"Need {self.n_enrollment_samples} sit-down samples, "
142
+ f"got {len(load_readings_list)}",
143
+ "user_id": user_id,
144
+ }
145
+
146
+ signatures = [WeightSignature(readings) for readings in load_readings_list]
147
+ self.enrolled_users[user_id] = signatures
148
+
149
+ # Retrain classifier
150
+ self._train_knn()
151
+
152
+ avg_weight = np.mean([s.total_weight for s in signatures])
153
+ return {
154
+ "status": "enrolled",
155
+ "user_id": user_id,
156
+ "n_samples": len(signatures),
157
+ "avg_weight_kg": round(float(avg_weight), 1),
158
+ "total_enrolled": len(self.enrolled_users),
159
+ }
160
+
161
+ def recognize(self, load_readings: np.ndarray) -> Dict:
162
+ """
163
+ Identify which enrolled user is sitting.
164
+
165
+ Args:
166
+ load_readings: shape (N, 4) β€” recent load cell data (~10 sec)
167
+
168
+ Returns:
169
+ dict with recognized user_id, confidence, and top candidates
170
+ """
171
+ if not self.is_trained:
172
+ return {"user_id": "unknown", "confidence": 0, "reason": "no_enrolled_users"}
173
+
174
+ sig = WeightSignature(load_readings)
175
+ features = sig.to_vector().reshape(1, -1)
176
+ features_scaled = self.scaler.transform(features)
177
+
178
+ # k-NN prediction with probability
179
+ proba = self.knn_model.predict_proba(features_scaled)[0]
180
+ predicted_class = self.knn_model.classes_[np.argmax(proba)]
181
+ confidence = np.max(proba)
182
+
183
+ # Weight-based sanity check
184
+ weight_match = self._weight_based_match(sig.total_weight)
185
+
186
+ # Final decision
187
+ if confidence >= self.confidence_threshold:
188
+ user_id = predicted_class
189
+ method = "knn"
190
+ elif weight_match["confidence"] > confidence:
191
+ user_id = weight_match["user_id"]
192
+ confidence = weight_match["confidence"]
193
+ method = "weight_fallback"
194
+ else:
195
+ user_id = "unknown"
196
+ method = "below_threshold"
197
+
198
+ # Top candidates
199
+ top_k = sorted(zip(self.knn_model.classes_, proba), key=lambda x: -x[1])[:3]
200
+
201
+ return {
202
+ "user_id": user_id,
203
+ "confidence": round(float(confidence), 3),
204
+ "method": method,
205
+ "top_candidates": [{"id": uid, "probability": round(float(p), 3)} for uid, p in top_k],
206
+ "detected_weight_kg": round(float(sig.total_weight), 1),
207
+ }
208
+
209
+ def _weight_based_match(self, weight: float) -> Dict:
210
+ """Simple weight-based user matching as fallback."""
211
+ best_match = "unknown"
212
+ best_diff = float('inf')
213
+
214
+ for user_id, signatures in self.enrolled_users.items():
215
+ avg_weight = np.mean([s.total_weight for s in signatures])
216
+ diff = abs(weight - avg_weight)
217
+
218
+ if diff < best_diff:
219
+ best_diff = diff
220
+ best_match = user_id
221
+
222
+ # Confidence based on weight difference (3kg tolerance)
223
+ if best_diff < USER_WEIGHT_TOLERANCE_KG:
224
+ confidence = 1.0 - (best_diff / USER_WEIGHT_TOLERANCE_KG) * 0.5
225
+ else:
226
+ confidence = max(0, 0.5 - (best_diff - USER_WEIGHT_TOLERANCE_KG) / 20)
227
+
228
+ return {"user_id": best_match, "confidence": confidence, "weight_diff_kg": best_diff}
229
+
230
+ def _train_knn(self):
231
+ """Train k-NN model on all enrolled user signatures."""
232
+ if len(self.enrolled_users) < 2:
233
+ # Need at least 2 users for classification
234
+ if len(self.enrolled_users) == 1:
235
+ # Single user mode
236
+ self.is_trained = False
237
+ return
238
+ return
239
+
240
+ X, y = [], []
241
+ for user_id, signatures in self.enrolled_users.items():
242
+ for sig in signatures:
243
+ X.append(sig.to_vector())
244
+ y.append(user_id)
245
+
246
+ X = np.array(X)
247
+ y = np.array(y)
248
+
249
+ # Scale features
250
+ X_scaled = self.scaler.fit_transform(X)
251
+
252
+ # Train k-NN (k=3, as per AuthentiSense for few-shot)
253
+ k = min(3, len(X) // len(self.enrolled_users))
254
+ k = max(1, k)
255
+
256
+ self.knn_model = KNeighborsClassifier(
257
+ n_neighbors=k,
258
+ metric='mahalanobis' if len(X) > X.shape[1] else 'euclidean',
259
+ metric_params={'V': np.cov(X_scaled.T)} if len(X) > X.shape[1] else None,
260
+ weights='distance',
261
+ )
262
+ self.knn_model.fit(X_scaled, y)
263
+ self.is_trained = True
264
+
265
+ def remove_user(self, user_id: str) -> bool:
266
+ """Remove a user from the system."""
267
+ if user_id in self.enrolled_users:
268
+ del self.enrolled_users[user_id]
269
+ if len(self.enrolled_users) >= 2:
270
+ self._train_knn()
271
+ else:
272
+ self.is_trained = False
273
+ return True
274
+ return False
275
+
276
+ def save(self, path: str):
277
+ """Save recognizer state."""
278
+ joblib.dump({
279
+ 'enrolled_users': self.enrolled_users,
280
+ 'scaler': self.scaler,
281
+ 'knn_model': self.knn_model,
282
+ 'is_trained': self.is_trained,
283
+ }, path)
284
+
285
+ def load(self, path: str):
286
+ """Load recognizer state."""
287
+ data = joblib.load(path)
288
+ self.enrolled_users = data['enrolled_users']
289
+ self.scaler = data['scaler']
290
+ self.knn_model = data['knn_model']
291
+ self.is_trained = data['is_trained']
292
+
293
+
294
+ class PersonalizedSittingModel:
295
+ """
296
+ Learns individual user's sitting patterns over time.
297
+
298
+ Tracks:
299
+ - Preferred postures by time of day
300
+ - Typical session duration
301
+ - Break compliance patterns
302
+ - Fatigue onset timing
303
+ - Posture quality trends
304
+
305
+ Used for: personalized break timing, adaptive alerts, trend analysis.
306
+ """
307
+
308
+ def __init__(self, user_id: str):
309
+ self.user_id = user_id
310
+ self.sessions = [] # list of session records
311
+ self.hourly_posture_dist = defaultdict(lambda: defaultdict(float)) # hour β†’ posture β†’ minutes
312
+ self.break_compliance = [] # (recommended, taken) booleans
313
+ self.fatigue_onsets = [] # minutes into session when fatigue detected
314
+
315
+ def record_session(self, start_time, end_time, posture_timeline: List[Dict],
316
+ breaks_taken: int, fatigue_onset_min: Optional[float] = None):
317
+ """
318
+ Record a complete sitting session.
319
+
320
+ Args:
321
+ posture_timeline: list of {"posture": str, "duration_min": float, "rula_score": int}
322
+ """
323
+ duration = (end_time - start_time).total_seconds() / 60
324
+
325
+ # Session summary
326
+ good_posture_min = sum(
327
+ p["duration_min"] for p in posture_timeline
328
+ if p["posture"] in ["upright"]
329
+ )
330
+
331
+ session = {
332
+ "start": start_time,
333
+ "end": end_time,
334
+ "duration_min": duration,
335
+ "good_posture_pct": good_posture_min / duration * 100 if duration > 0 else 0,
336
+ "breaks_taken": breaks_taken,
337
+ "posture_timeline": posture_timeline,
338
+ }
339
+ self.sessions.append(session)
340
+
341
+ # Update hourly distribution
342
+ for p in posture_timeline:
343
+ hour = start_time.hour # simplified β€” could interpolate
344
+ self.hourly_posture_dist[hour][p["posture"]] += p["duration_min"]
345
+
346
+ # Fatigue timing
347
+ if fatigue_onset_min is not None:
348
+ self.fatigue_onsets.append(fatigue_onset_min)
349
+
350
+ def get_typical_fatigue_onset(self) -> Optional[float]:
351
+ """Predict when this user typically gets fatigued."""
352
+ if len(self.fatigue_onsets) < 3:
353
+ return None
354
+ return np.median(self.fatigue_onsets)
355
+
356
+ def get_optimal_break_interval(self) -> float:
357
+ """
358
+ Calculate personalized break interval based on user's fatigue patterns.
359
+
360
+ Returns: recommended minutes between breaks
361
+ """
362
+ typical_fatigue = self.get_typical_fatigue_onset()
363
+
364
+ if typical_fatigue is not None:
365
+ # Break should come BEFORE fatigue (80% of fatigue onset time)
366
+ return max(MIN_BREAK_INTERVAL_MINUTES, min(MAX_SITTING_MINUTES, typical_fatigue * 0.8))
367
+
368
+ # Default: start conservative, adjust as data accumulates
369
+ return 30.0
370
+
371
+ def get_worst_time_of_day(self) -> Optional[int]:
372
+ """Find the hour of day when posture is typically worst."""
373
+ if not self.hourly_posture_dist:
374
+ return None
375
+
376
+ worst_hour = None
377
+ worst_score = float('inf')
378
+
379
+ for hour, postures in self.hourly_posture_dist.items():
380
+ total = sum(postures.values())
381
+ if total < 10:
382
+ continue
383
+ good = postures.get("upright", 0)
384
+ score = good / total
385
+ if score < worst_score:
386
+ worst_score = score
387
+ worst_hour = hour
388
+
389
+ return worst_hour
390
+
391
+ def get_profile_summary(self) -> Dict:
392
+ """Get comprehensive user profile summary."""
393
+ if not self.sessions:
394
+ return {"status": "no_data", "user_id": self.user_id}
395
+
396
+ total_sessions = len(self.sessions)
397
+ avg_duration = np.mean([s["duration_min"] for s in self.sessions])
398
+ avg_good_pct = np.mean([s["good_posture_pct"] for s in self.sessions])
399
+
400
+ return {
401
+ "user_id": self.user_id,
402
+ "total_sessions": total_sessions,
403
+ "avg_session_duration_min": round(avg_duration, 1),
404
+ "avg_good_posture_pct": round(avg_good_pct, 1),
405
+ "typical_fatigue_onset_min": round(self.get_typical_fatigue_onset() or 0, 1),
406
+ "recommended_break_interval_min": round(self.get_optimal_break_interval(), 1),
407
+ "worst_time_of_day": self.get_worst_time_of_day(),
408
+ "total_sitting_hours": round(sum(s["duration_min"] for s in self.sessions) / 60, 1),
409
+ }
smart_chair/utils/__init__.py ADDED
File without changes
smart_chair/utils/feature_engineering.py ADDED
@@ -0,0 +1,226 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Feature Engineering Pipeline for SmartChair Sensor Fusion.
3
+ Extracts features from IMU (6-axis), Load Cells (4), and Thermal (8x8) sensors.
4
+
5
+ References:
6
+ - SitPose (arxiv:2412.12216): Joint angle features β†’ 98.2% F1
7
+ - SSL-Wearables (arxiv:2206.02909): Hand-crafted features for RF baseline
8
+ - FusionActNet (arxiv:2310.02011): Separable temporal features
9
+ """
10
+
11
+ import numpy as np
12
+ from itertools import combinations
13
+ from scipy import stats as scipy_stats
14
+ from scipy.fft import rfft, rfftfreq
15
+
16
+
17
+ def compute_imu_angles(accel_xyz):
18
+ """
19
+ Compute pitch and roll from accelerometer data.
20
+ Pitch = rotation around Y-axis (forward/backward lean)
21
+ Roll = rotation around X-axis (left/right lean)
22
+
23
+ Args:
24
+ accel_xyz: array of shape (N, 3) β€” [ax, ay, az] in m/sΒ²
25
+
26
+ Returns:
27
+ pitch, roll in degrees, each shape (N,)
28
+ """
29
+ ax, ay, az = accel_xyz[:, 0], accel_xyz[:, 1], accel_xyz[:, 2]
30
+ pitch = np.degrees(np.arctan2(ax, np.sqrt(ay**2 + az**2)))
31
+ roll = np.degrees(np.arctan2(ay, np.sqrt(ax**2 + az**2)))
32
+ return pitch, roll
33
+
34
+
35
+ def compute_center_of_pressure(load_cells):
36
+ """
37
+ Compute Center of Pressure (CoP) from 4 load cells at chair corners.
38
+
39
+ Args:
40
+ load_cells: array shape (N, 4) β€” [FL, FR, RL, RR] in kg
41
+
42
+ Returns:
43
+ cop_x, cop_y β€” normalized CoP coordinates in [-1, 1]
44
+ """
45
+ fl, fr, rl, rr = load_cells[:, 0], load_cells[:, 1], load_cells[:, 2], load_cells[:, 3]
46
+ total = fl + fr + rl + rr + 1e-6 # avoid division by zero
47
+
48
+ # CoP_x: positive = right, negative = left
49
+ cop_x = (fr + rr - fl - rl) / total
50
+ # CoP_y: positive = front, negative = rear
51
+ cop_y = (fl + fr - rl - rr) / total
52
+
53
+ return cop_x, cop_y
54
+
55
+
56
+ def extract_tabular_features(imu_data, load_data, window_size=128):
57
+ """
58
+ Extract hand-crafted features for ensemble classifier (SitPose recipe).
59
+ Operates on a single window of data.
60
+
61
+ Args:
62
+ imu_data: shape (window_size, 6) β€” [ax, ay, az, gx, gy, gz]
63
+ load_data: shape (window_size, 4) β€” [FL, FR, RL, RR]
64
+
65
+ Returns:
66
+ feature_vector: 1D numpy array of extracted features
67
+ """
68
+ features = []
69
+
70
+ # ── IMU Features ──────────────────────────────────────────────────────
71
+ # Per-axis statistics (from SSL-Wearables paper)
72
+ for ch in range(imu_data.shape[1]):
73
+ sig = imu_data[:, ch]
74
+ features.extend([
75
+ np.mean(sig),
76
+ np.std(sig),
77
+ np.ptp(sig), # range (peak-to-peak)
78
+ np.median(sig),
79
+ scipy_stats.kurtosis(sig),
80
+ scipy_stats.skew(sig),
81
+ ])
82
+ # Percentiles
83
+ features.extend(np.percentile(sig, [10, 25, 50, 75, 90]).tolist())
84
+
85
+ # Euclidean norm of acceleration
86
+ accel_norm = np.linalg.norm(imu_data[:, :3], axis=1)
87
+ features.extend([
88
+ np.mean(accel_norm),
89
+ np.std(accel_norm),
90
+ np.ptp(accel_norm),
91
+ np.median(np.abs(accel_norm - np.median(accel_norm))), # MAD
92
+ scipy_stats.kurtosis(accel_norm),
93
+ scipy_stats.skew(accel_norm),
94
+ ])
95
+
96
+ # FFT features (top 2 frequency components + spectral power in 4 bands)
97
+ fft_vals = np.abs(rfft(accel_norm))
98
+ freqs = rfftfreq(len(accel_norm), d=1.0/50) # 50 Hz
99
+ sorted_idx = np.argsort(fft_vals)[::-1]
100
+ features.extend([
101
+ freqs[sorted_idx[0]] if len(sorted_idx) > 0 else 0,
102
+ fft_vals[sorted_idx[0]] if len(sorted_idx) > 0 else 0,
103
+ freqs[sorted_idx[1]] if len(sorted_idx) > 1 else 0,
104
+ fft_vals[sorted_idx[1]] if len(sorted_idx) > 1 else 0,
105
+ ])
106
+ # Spectral power in 4 bands: [0-3Hz], [3-6Hz], [6-12Hz], [12-25Hz]
107
+ band_edges = [0, 3, 6, 12, 25]
108
+ for i in range(len(band_edges) - 1):
109
+ mask = (freqs >= band_edges[i]) & (freqs < band_edges[i+1])
110
+ features.append(np.sum(fft_vals[mask]**2) if np.any(mask) else 0)
111
+
112
+ # Cross-axis correlations
113
+ for i, j in combinations(range(imu_data.shape[1]), 2):
114
+ corr = np.corrcoef(imu_data[:, i], imu_data[:, j])[0, 1]
115
+ features.append(corr if not np.isnan(corr) else 0)
116
+
117
+ # IMU angles (pitch, roll)
118
+ pitch, roll = compute_imu_angles(imu_data[:, :3])
119
+ features.extend([
120
+ np.mean(pitch), np.std(pitch), np.mean(roll), np.std(roll),
121
+ np.max(np.abs(pitch)), np.max(np.abs(roll)),
122
+ ])
123
+
124
+ # ── Load Cell Features ────────────────────────────────────────────────
125
+ total_weight = np.sum(load_data, axis=1)
126
+ features.append(np.mean(total_weight))
127
+
128
+ # Normalized per-cell percentages
129
+ norm_load = load_data / (total_weight[:, np.newaxis] + 1e-6)
130
+ for ch in range(4):
131
+ features.extend([
132
+ np.mean(norm_load[:, ch]),
133
+ np.std(norm_load[:, ch]),
134
+ ])
135
+
136
+ # Center of Pressure
137
+ cop_x, cop_y = compute_center_of_pressure(load_data)
138
+ features.extend([
139
+ np.mean(cop_x), np.std(cop_x),
140
+ np.mean(cop_y), np.std(cop_y),
141
+ np.ptp(cop_x), np.ptp(cop_y), # CoP sway range
142
+ ])
143
+
144
+ # Weight distribution variance (asymmetry indicator)
145
+ features.append(np.mean(np.var(norm_load, axis=1)))
146
+
147
+ # Front/back and left/right ratios
148
+ front = norm_load[:, 0] + norm_load[:, 1]
149
+ back = norm_load[:, 2] + norm_load[:, 3]
150
+ left = norm_load[:, 0] + norm_load[:, 2]
151
+ right = norm_load[:, 1] + norm_load[:, 3]
152
+ features.extend([
153
+ np.mean(front / (back + 1e-6)),
154
+ np.mean(left / (right + 1e-6)),
155
+ ])
156
+
157
+ return np.array(features, dtype=np.float32)
158
+
159
+
160
+ def extract_sequential_features(imu_data, load_data):
161
+ """
162
+ Prepare sequential (windowed) data for MLSTM-FCN / 1D-CNN.
163
+ Combines all sensor channels into a unified time-series tensor.
164
+
165
+ Args:
166
+ imu_data: shape (window_size, 6) β€” raw IMU
167
+ load_data: shape (window_size, 4) β€” raw load cells
168
+
169
+ Returns:
170
+ tensor: shape (window_size, 14) β€” all channels concatenated
171
+ Channels: [ax, ay, az, gx, gy, gz, pitch, roll, FL, FR, RL, RR, cop_x, cop_y]
172
+ """
173
+ # Compute derived channels
174
+ pitch, roll = compute_imu_angles(imu_data[:, :3])
175
+ cop_x, cop_y = compute_center_of_pressure(load_data)
176
+
177
+ # Stack all channels
178
+ derived = np.column_stack([pitch, roll, cop_x, cop_y])
179
+ tensor = np.concatenate([imu_data, load_data, derived], axis=1)
180
+
181
+ return tensor.astype(np.float32)
182
+
183
+
184
+ def create_sliding_windows(data, labels, window_size=128, stride=64):
185
+ """
186
+ Create sliding windows from continuous sensor data.
187
+ Label is assigned by majority vote within window.
188
+
189
+ Args:
190
+ data: shape (N, channels) β€” continuous sensor data
191
+ labels: shape (N,) β€” per-sample labels
192
+ window_size: samples per window
193
+ stride: step between windows
194
+
195
+ Returns:
196
+ X: shape (num_windows, window_size, channels)
197
+ y: shape (num_windows,)
198
+ """
199
+ X, y = [], []
200
+ for i in range(0, len(data) - window_size, stride):
201
+ window_data = data[i:i + window_size]
202
+ window_labels = labels[i:i + window_size]
203
+
204
+ X.append(window_data)
205
+ y.append(np.bincount(window_labels.astype(int)).argmax())
206
+
207
+ return np.array(X), np.array(y)
208
+
209
+
210
+ def normalize_sensors(data, scaler=None):
211
+ """
212
+ Per-channel z-score normalization.
213
+ Returns normalized data and fitted scaler.
214
+ """
215
+ from sklearn.preprocessing import StandardScaler
216
+
217
+ original_shape = data.shape
218
+ flat = data.reshape(-1, data.shape[-1])
219
+
220
+ if scaler is None:
221
+ scaler = StandardScaler()
222
+ flat_norm = scaler.fit_transform(flat)
223
+ else:
224
+ flat_norm = scaler.transform(flat)
225
+
226
+ return flat_norm.reshape(original_shape), scaler