# Project export: StrokeHub

This document was generated by HackStack to give an AI agent context about a hackathon project. Sections are labeled with their provenance; content marked as truncated was cut to keep this document small.

## Project metadata

- Hackathon: UC Berkeley AI Hackathon 2026
- Tagline: StrokeHub gamifies stroke rehab with games like Pong and Flappy Bird. Accelerometer and grip-sensor data are analyzed by a Random Forest model to assess upper-limb recovery and personalize difficulty.
- Devpost: https://devpost.com/software/rehabpong
- GitHub: https://github.com/thienngh/strokehub
- Team: 1 GitHub contributor(s) — Timmy Nguyen (5 commits)

## Devpost submission (written by the team)

### Inspiration

Our inspiration for StrokeHub came from a personal experience. Earlier this year, one of our family members suffered a stroke. Watching their recovery made us realize that rehabilitation is a long journey requiring patience, repetition, and determination. Many of the prescribed exercises were repetitive, and it was hard to stay motivated between therapy appointments. We wondered if rehabilitation could feel less like a chore and more like something patients would actually want to do every day. That experience inspired us to create StrokeHub, combining AI, wearable sensors, and gamification to make upper-limb rehabilitation more engaging while providing objective data to track recovery.

### What it does

Traditional motor assessments can feel repetitive, clinical, and difficult to track over time. StrokeHub explores a more interactive approach: using games to create structured movement tasks while collecting real-time sensor data. During gameplay, the player controls the game using arm or forearm movement while maintaining grip on an FSR sensor. The system records movement, grip, game performance, stability, smoothness, camera-based motion agreement, and difficulty progression. After each trial, StrokeHub summarizes the session and uses machine learning to classify gameplay-based motor-control patterns. StrokeHub combines skin-contact sensing and visual motion capture. The phone accelerometer provides direct movement data from the user’s arm, while the camera runs in shadow mode as a visual validation layer. This creates a sensor-fusion approach that improves confidence in the movement feedback without relying completely on one signal source.

### How we built it

We built the hardware around an ESP32, which reads the grip sensor in real time and streams the data to a Python application. Movement dynamics are captured by a phone accelerometer worn on the arm. Using Python and Pygame, we developed two rehabilitation games: accelPong and neuroFlap. We integrated MediaPipe Pose with a webcam to estimate upper-limb joint positions and analyze movement quality. Finally, we combined gameplay statistics, pose estimation, and sensor data into a dashboard that summarizes each rehabilitation session. Our project brought together embedded systems, computer vision, AI, and game development into one unified rehabilitation platform.

### Challenges we ran into

Hardware issues regarding the biosensors we utilized forced us to shift focus multiple times, hindering our progress and wasting multiple hours. Training a defensible model on limited data. With a small, prototype dataset we leaned on interpretable trial-level features and rule-based labels for a Random Forest, and were careful to treat it as a proof of concept rather than a validated classifier.

### Accomplishments we're proud of

We integrated embedded sensors, computer vision, a trained machine-learning model, two playable games, and a session dashboard into one working end-to-end system within the hackathon. We turned repetitive rehab exercises into something genuinely playable, while still capturing objective movement data underneath the game.

### What we learned

Throughout this project, we learned that building healthcare technology requires balancing technical innovation with real-world usability. A machine-learning model is only as useful as the quality of the data behind it, so we spent significant time understanding sensor reliability, signal processing, and how different movement metrics reflect motor function. We also learned how valuable sensor fusion can be. Combining accelerometer data, grip-force measurements, and computer vision provided a more complete picture of upper-limb movement than any single sensor alone. Integrating embedded hardware, AI, computer vision, and game development into one system taught us the importance of communication between software and hardware components. Most importantly, we gained a deeper appreciation for the challenges stroke survivors face during recovery. Rehabilitation is not only a physical challenge but also a motivational one. This experience reinforced our belief that thoughtful technology and engaging design can help make long-term therapy more accessible, measurable, and enjoyable.

### What's next

for SrokeHub Our next goal is to transform StrokeHub from a prototype into a platform that can support long-term rehabilitation. Future improvements include: Personalized AI coaching that adapts exercises based on patient progress. Cloud-based progress tracking for patients and therapists. Additional rehabilitation games targeting different motor skills. Clinician dashboards for remote monitoring. Clinical validation with rehabilitation professionals and stroke survivors. Integration with more wearable sensors to provide even richer movement analysis. Ultimately, we hope StrokeHub can help make stroke rehabilitation more engaging, accessible, and data-driven while empowering patients to stay motivated while giving clinicians better tools to monitor recovery.

## README (from the GitHub repository)

# StrokeHub

**StrokeHub** is a rehab-style game platform developed by **Thien “Timmy” Nguyen Huu, Charles Zhang, Ryunosuke Suzuki, and Lily Duong**. The system turns movement and grip tracking into interactive gameplay using phone accelerometer motion, FSR grip sensing, camera-based motion capture, and machine learning to provide personalized feedback on motor-control patterns during simple games like **Flappy Bird** and **Pong**.

StrokeHub was built as a hackathon proof of concept for game-based movement assessment and feedback. It is designed to make motor-control tracking more engaging, accessible, and easier to understand.

> **Disclaimer:** StrokeHub is not a medical diagnostic tool. It does not diagnose stroke, Parkinson’s disease, tremor disorders, or any neurological condition. It provides gameplay-based movement and grip feedback only.

---

## Team

StrokeHub was developed by:

* Thien “Timmy” Nguyen Huu
* Charles Zhang
* Ryunosuke Suzuki
* Lily Duong

---
Notes: All the file names are Nervaflex because that was intended in the first place, but now they are all part of the StrokeHub now. 
---

## Overview

Traditional motor assessments can feel repetitive, clinical, and difficult to track over time. StrokeHub explores a more interactive approach: using games to create structured movement tasks while collecting real-time sensor data.

During gameplay, the player controls the game using arm or forearm movement while maintaining grip on an FSR sensor. The system records movement, grip, game performance, stability, smoothness, camera-based motion agreement, and difficulty progression. After each trial, StrokeHub summarizes the session and uses machine learning to classify gameplay-based motor-control patterns.

StrokeHub combines **skin-contact sensing** and **visual motion capture**. The phone accelerometer provides direct movement data from the user’s arm, while the camera runs in shadow mode as a visual validation layer. This creates a sensor-fusion approach that improves confidence in the movement feedback without relying completely on one signal source.

---

## Key Features

* Two interactive assessment games:

  * **NeuroFlap**: a Flappy Bird-style movement game
  * **AccelPong**: a Pong-style reaction and movement game
* Phone accelerometer-based arm movement tracking
* FSR grip sensing through ESP32
* Camera-based motion capture running in shadow mode
* Sensor fusion between phone accelerometer motion, FSR grip sensing, and camera pose tracking
* Camera confidence scoring based on visibility, motion agreement, and pose-tracking confidence
* Optional 10% camera contribution to the final result when tracking quality is high
* Personalized calibration for movement range and grip strength
* Frame-by-frame gameplay and sensor data logging
* Trial-level feature extraction for machine learning
* Random Forest-based multi-label classification
* Applied AI model for motion capture, sensor fusion, and gameplay-based data analysis
* User-friendly session feedback and score breakdown
* Expandable design for future EMG integration

---

## How It Works

StrokeHub uses gameplay as a structured motor-control task.

1. The player chooses a game.
2. The player calibrates their arm movement range and grip range.
3. The game begins.
4. Phone accelerometer data controls the game character or paddle.
5. The FSR sensor measures grip engagement.
6. The camera runs in shadow mode to visually track arm movement.
7. The system logs sensor, camera, and gameplay data every frame.
8. When the trial ends, the data is saved to CSV.
9. StrokeHub extracts trial-level features.
10. A machine learning model predicts movement and grip pattern labels.
11. A sensor-fusion layer checks agreement between phone motion and camera motion.
12. The player receives a readable performance summary.

---

## Current Sensors

### Phone Accelerometer

The phone accelerometer is used to estimate arm or forearm movement. During calibration, the player records their lowest and highest comfortable movement range. During gameplay, live accelerometer data is mapped into a normalized movement score.

The phone accelerometer is the primary movement signal because it is attached directly to the user’s arm or forearm.

### FSR Grip Sensor

The FSR sensor measures relative grip pressure. It is used to track whether the player maintains grip during the game and whether grip control changes over time.

Grip data helps determine whether the player is actively engaging with the task and whether grip stability changes during gameplay.

### Camera Capture Motion

StrokeHub also includes a camera-based motion capture section that runs in **shadow mode**. In this mode, the webcam does not replace the phone accelerometer or FSR grip sensor. Instead, it acts as a visual validation layer that tracks arm movement through pose estimation and compares it with the phone-based motion signal.

The goal of shadow mode is to improve confidence in the movement measurement by checking whether the visual motion captured by the camera agrees with the skin-contact motion captured by the phone accelerometer.

When camera visibility is high and the camera-phone motion agreement is strong, the camera score can contribute up to **10%** of the final result. If visibility is poor, tracking confidence is low, or the camera and phone signals do not match well, the camera contribution is reduced or ignored.

This allows StrokeHub to combine three complementary sensing methods:

* **Phone accelerometer:** skin-contact motion tracking
* **Camera capture:** visual motion tracking
* **FSR grip sensor:** grip engagement and grip stability

Together, these signals create a sensor-fusion approach for more reliable movement feedback.

### Future EMG Support

StrokeHub is designed to support EMG integration later. Future EMG features may include muscle activation level, activation timing, fatigue-like trends, and effort-to-movement ratio.

Current EMG-related columns are planned as placeholders and can be added without redesigning the full machine learning pipeline.

---

## Games

### neuroFlap

neuroFlap is a Flappy Bird-style game where arm movement controls the bird’s vertical position. The player must guide the bird through pipes while maintaining grip control.

Tracked metrics include:

* Pipes passed
* Survival time
* Movement speed
* Movement smoothness
* Movement stability
* Grip control
* Difficulty progression
* Camera-phone movement agreement

### accelPong

accelPong is a Pong-style game where arm movement controls the paddle. The player must return the ball while maintaining grip and responding to changing ball movement.

Tracked metrics include:

* Paddle hits
* Misses
* Reaction-style movement response
* Movement range
* Movement smoothness
* Grip stability
* Game difficulty progression
* Camera-phone movement agreement

---

## Camera Shadow Mode

Camera shadow mode is a secondary motion-capture system that runs alongside the main phone accelerometer and FSR sensor setup.

The camera uses pose tracking to estimate arm position visually. This visual signal is then compared with the phone accelerometer signal. If both signals agree, the system gains more confidence that the measured movement reflects the player’s actual arm motion.

Camera shadow mode is useful because phone and camera sensing each have different strengths:

| Signal              | Strength                            | Limitation                                               |
| ------------------- | ----------------------------------- | -------------------------------------------------------- |
| Phone accelerometer | Direct skin-contact motion tracking | Can be affected by phone placement                       |
| Camera capture      | Visual confirmation of arm movement | Can be affected by lighting, occlusion, and camera angle |
| FSR grip sensor     | Measures grip engagement            | Does not measure arm position                            |


[README truncated for size]

## Detected evidence (automated analysis)

Indexed codebase: 8 recognized source files, 149 KB.
- HTML (language) — detected in the code
- Python (language) — detected in the code

## Codebase structure (from repository index)

### Files (11 of 11)

```
analyze_nervaflex_trials.py
flappy_motion_sensor.py
microcontroller_setup/microcontroller_setup.ino
nervaflex_rf_model.pkl
predict_nervaflex_study.py
README.md
requirements.txt
save_trials.py
train_nervaflex_model.py
trials/README.md
webcam_tracker.html
```

### Dependencies

- requirements.txt: joblib@>=1.3, numpy@>=1.24, pandas@>=2.0, pygame@>=2.5, pyserial@>=3.5, scikit-learn@>=1.3, scipy@>=1.10

### Recent commits (newest first)

- Rename games in README
- Update text from 'StrokeLess' to 'StrokeHub'
- Update README.md
- Add note about file names in README
- Add files via upload
- Revise README.md for detailed project documentation
- Initial commit

## Key source files (fetched from GitHub, selected and truncated for size)

### requirements.txt

```
joblib>=1.3
numpy>=1.24
pandas>=2.0
pygame>=2.5
pyserial>=3.5
scikit-learn>=1.3
scipy>=1.10

```

### predict_nervaflex_study.py

```python
#!/usr/bin/env python3
"""Generate research-only gameplay-pattern predictions for all trials."""

import argparse
import csv
from pathlib import Path

import joblib

from train_nervaflex_model import extract_trial_features, load_data


MOVEMENT_LABELS = ["limited_movement", "unstable_movement", "high_tremor"]
GRIP_LABELS = ["low_grip", "unstable_grip", "grip_loss"]


def positive_probabilities(bundle, model_input):
    model = bundle["model"]
    labels = bundle["label_columns"]
    predictions = model.predict(model_input)[0]
    probability_arrays = model.predict_proba(model_input)
    estimators = model.named_steps["classifier"].estimators_

    output = {}
    for index, label in enumerate(labels):
        classes = list(estimators[index].classes_)
        if 1 in classes:
            probability = float(
                probability_arrays[index][0][classes.index(1)]
            )
        else:
            probability = float(classes[0] == 1)
        output[label] = (int(predictions[index]), probability)
    return output


def score_trial(row, outputs):
    if row["game"] == "flappy":
        game_score = 100.0 * min(float(row["final_flappy_score"]) / 10.0, 1.0)
    else:
        game_score = 100.0 * min(float(row["final_pong_hits"]) / 15.0, 1.0)

    movement_score = 100.0 * (
        1.0 - sum(outputs[label][1] for label in MOVEMENT_LABELS)
        / len(MOVEMENT_LABELS)
    )
    grip_score = 100.0 * (
        1.0 - sum(outputs[label][1] for label in GRIP_LABELS)
        / len(GRIP_LABELS)
    )
    final_score = 0.40 * game_score + 0.30 * movement_score + 0.30 * grip_score
    if final_score >= 80:
        band = "strong_gameplay_motor_control"
    elif final_score >= 60:
        band = "moderate_gameplay_motor_control"
    else:
        band = "gameplay_patterns_need_improvement"
    return game_score, movement_score, grip_score, final_score, band


def main():
    project_dir = Path(__file__).resolve().parent
    parser = argparse.ArgumentParser(description="Score all NervaFlex trials")
    parser.add_argument(
        "--input",
        type=Path,
        default=project_dir / "trials" / "nervaflex_ml_trials.csv",
    )
    parser.add_argument(
        "--model",
        type=Path,
        default=project_dir / "nervaflex_rf_model.pkl",
    )
    parser.add_argument(
        "--output",
        type=Path,
        default=project_dir / "trials" / "nervaflex_trial_predictions.csv",
    )
    args = parser.parse_args()

    bundle = joblib.load(args.model)
    features = extract_trial_features(load_data(args.input))
    results = []

    for _, row in features.iterrows():
        model_input = row.to_frame().T.reindex(columns=bundle["model_features"])
        outputs = positive_probabilities(bundle, model_input)
        game, movement, grip, final, band = score_trial(row, outputs)
        result = {
            "trial_id": row["trial_id"],
            "attempt_number": row["attempt_number"],
            "scenario_label": row["scenario_label"],
            "game": row["game"],
            "outcome": row["outcome"],
            "game_score": round(game, 3),
            "movement_score": round(movement, 3),
            "grip_score": round(grip, 3),
            "final_score": round(final, 3),
            "result_band": band,
        }
        for label in bundle["label_columns"]:
            prediction, probability = outputs[label]
            result[f"predicted_{label}"] = prediction
            result[f"probability_{label}"] = round(probability, 6)
        results.append(result)

    args.output.parent.mkdir(parents=True, exist_ok=True)
    with args.output.open("w", newline="") as csv_file:
        writer = csv.DictWriter(csv_file, fieldnames=list(results[0].keys()))
        writer.writeheader()
        writer.writerows(results)
    print(f"Scored {len(results)} trials")
    print(f"Saved {args.output}")
    print("Research-only gameplay classification; not medical diagnosis.")


if __name__ == "__main__":
    main()

```

### analyze_nervaflex_trials.py

```python
#!/usr/bin/env python3
import argparse
import csv
import math
import statistics
from collections import defaultdict
from pathlib import Path


ABORTED_OUTCOMES = {"User quit", "Window closed"}


def number(row, key, default=0.0):
    try:
        return float(row.get(key, default) or default)
    except ValueError:
        return default


def percentile(values, fraction):
    if not values:
        return 0.0
    ordered = sorted(values)
    position = fraction * (len(ordered) - 1)
    lower = int(math.floor(position))
    upper = int(math.ceil(position))
    if lower == upper:
        return ordered[lower]
    return ordered[lower] + (ordered[upper] - ordered[lower]) * (position - lower)


def mean(values):
    return statistics.fmean(values) if values else 0.0


def stdev(values):
    return statistics.pstdev(values) if len(values) > 1 else 0.0


def rms(values):
    return math.sqrt(mean([value * value for value in values])) if values else 0.0


def slope(xs, ys):
    if len(xs) < 2:
        return 0.0
    x_mean, y_mean = mean(xs), mean(ys)
    denominator = sum((x - x_mean) ** 2 for x in xs)
    if denominator == 0:
        return 0.0
    return sum((x - x_mean) * (y - y_mean) for x, y in zip(xs, ys)) / denominator


def extract_trial(rows):
    first = rows[0]
    grip = [number(row, "grip_percent") for row in rows]
    paused = [number(row, "paused") for row in rows]
    tremor = [number(row, "tremor_px") for row in rows]
    speed = [number(row, "speed_px_s") for row in rows]
    smoothness = [number(row, "smoothness") for row in rows]
    tilt = [number(row, "tilt") for row in rows]
    difficulty = [number(row, "difficulty") for row in rows]
    acceleration = [
        math.sqrt(
            number(row, "accel_x_g") ** 2
            + number(row, "accel_y_g") ** 2
            + number(row, "accel_z_g") ** 2
        )
        for row in rows
    ]

    return {
        "trial_id": first["trial_id"],
        "attempt_number": int(number(first, "attempt_number")),
        "trial_started_at": first["trial_started_at"],
        "game": first["game"],
        "outcome": first["outcome"],
        "completed": int(first["outcome"] not in ABORTED_OUTCOMES),
        "sample_count": len(rows),
        "duration_s": number(first, "trial_duration_s"),
        "assessment_score": number(first, "final_assessment_score"),
        "flappy_score": int(number(first, "final_flappy_score")),
        "pong_hits": int(number(first, "final_pong_hits")),
        "pong_misses": int(number(first, "final_pong_misses")),
        "grip_pause_count": int(number(first, "grip_pause_count")),
        "grip_mean_percent": mean(grip),
        "grip_median_percent": statistics.median(grip),
        "grip_p95_percent": percentile(grip, 0.95),
        "grip_max_percent": max(grip, default=0.0),
        "grip_active_fraction": mean([value > 0 for value in grip]),
        "pause_fraction": mean(paused),
        "tremor_rms_px": rms(tremor),
        "tremor_p95_px": percentile(tremor, 0.95),
        "speed_mean_px_s": mean(speed),
        "speed_p95_px_s": percentile(speed, 0.95),
        "smoothness_mean": mean(smoothness),
        "tilt_range": max(tilt, default=0.0) - min(tilt, default=0.0),
        "tilt_stdev": stdev(tilt),
        "accel_magnitude_mean_g": mean(acceleration),
        "accel_magnitude_stdev_g": stdev(acceleration),
        "difficulty_mean": mean(difficulty),
    }


def fmt(value, digits=2):
    return f"{value:.{digits}f}"


def build_report(features, source_path):
    completed = [trial for trial in features if trial["completed"]]
    lines = [
        "NervaFlex Research-Only Motor Performance Report",
        "=" * 52,
        "",
        "DIAGNOSTIC CONCLUSION: NOT DETERMINABLE",
        "This dataset cannot diagnose stroke, tremor type, Parkinson's disease,",
        "or another neurological condition.",
        "",
        f"Source: {source_path}",
        f"Recorded attempts: {len(features)}",
        f"Completed attempts: {len(completed)}",
        f"Aborted attempts: {len(features) - len(completed)}",
        "Known independent participants: 1 (inferred from current collection)",
        "Clinician-provided diagnostic labels: 0",
        "EMG channels: 0",
        "",
        "Observed within-user performance",
        "-" * 32,
    ]

    if not completed:
        lines.append("No completed trials are available.")
        return "\n".join(lines) + "\n"

    lines.extend([
        f"Assessment score: mean {fmt(mean([t['assessment_score'] for t in completed]))}/10, "
        f"range {fmt(min(t['assessment_score'] for t in completed))}-"
        f"{fmt(max(t['assessment_score'] for t in completed))}",
        f"Grip strength: mean {fmt(mean([t['grip_mean_percent'] for t in completed]))}%, "
        f"mean trial maximum {fmt(mean([t['grip_max_percent'] for t in completed]))}%",
        f"Tremor proxy: mean RMS {fmt(mean([t['tremor_rms_px'] for t in completed]))} pixels",
        f"Smoothness: mean {fmt(100 * mean([t['smoothness_mean'] for t in completed]))}%",
        f"Active grip time: mean {fmt(100 * mean([t['grip_active_fraction'] for t in completed]))}%",
        f"Paused time: mean {fmt(100 * mean([t['pause_fraction'] for t in completed]))}%",
        "",
        "Per-game trend model",
        "-" * 20,
    ])

    for game in sorted({trial["game"] for trial in completed}):
        game_trials = sorted(
            [trial for trial in completed if trial["game"] == game],
            key=lambda trial: trial["attempt_number"],
        )
        attempts = [trial["attempt_number"] for trial in game_trials]
        scores = [trial["assessment_score"] for trial in game_trials]
        score_slope = slope(attempts, scores)
        lines.append(
            f"{game}: {len(game_trials)} trials; score trend "
            f"{score_slope:+.3f} points per attempt"
        )

    lines.extend([
        "",
        "Interpretation limits",
        "-" * 21,
        "- The trend regression descr
[truncated — 2444 more characters]
```

### save_trials.py

```python
#!/usr/bin/env python3
import argparse
import csv
import json
import sys
import termios
import time
import tty
from datetime import datetime
from pathlib import Path
from urllib.error import URLError
from urllib.request import urlopen

import numpy as np
import serial
from scipy.signal import butter, sosfiltfilt, welch


TRIAL_DURATION_S = 20
FSR_CALIBRATION_S = 2
FSR_MIN_ZERO_THRESHOLD = 500
FSR_MAX_ZERO_THRESHOLD = 1000
FSR_REST_MARGIN = 100
FSR_MAX_RAW = 4000
TREMOR_LOW_HZ = 3.0
TREMOR_HIGH_HZ = 12.0


def request_json(base_url, path, attempts=3):
    last_error = None
    for attempt in range(attempts):
        try:
            with urlopen(f"{base_url}{path}", timeout=5) as response:
                return json.load(response)
        except (URLError, TimeoutError, OSError) as error:
            last_error = error
            if attempt + 1 < attempts:
                time.sleep(1)
    raise last_error


def control(base_url, command):
    result = request_json(base_url, f"/control?cmd={command}")
    if not result.get("result"):
        raise RuntimeError(f"phyphox rejected the {command!r} command")


def get_acceleration(base_url):
    query = "/get?acc_time=full&accX=full&accY=full&accZ=full"
    data = request_json(base_url, query)["buffer"]

    required = ("acc_time", "accX", "accY", "accZ")
    missing = [name for name in required if name not in data]
    if missing:
        raise RuntimeError(
            "Missing phyphox buffers: "
            + ", ".join(missing)
            + ". Select 'Acceleration (without g)' in phyphox."
        )

    arrays = [np.asarray(data[name]["buffer"], dtype=float) for name in required]
    length = min(map(len, arrays))
    if length < 10:
        raise RuntimeError("Not enough accelerometer samples were received")

    return tuple(array[:length] for array in arrays)


def calculate_tremor(t, x, y, z):
    valid = np.isfinite(t) & np.isfinite(x) & np.isfinite(y) & np.isfinite(z)
    t, x, y, z = t[valid], x[valid], y[valid], z[valid]

    keep = np.concatenate(([True], np.diff(t) > 0))
    t, x, y, z = t[keep], x[keep], y[keep], z[keep]

    sample_rate = 1.0 / np.median(np.diff(t))
    uniform_t = np.arange(t[0], t[-1], 1.0 / sample_rate)
    axes = np.vstack(
        [np.interp(uniform_t, t, values) for values in (x, y, z)]
    )

    high_hz = min(TREMOR_HIGH_HZ, sample_rate * 0.45)
    if high_hz <= TREMOR_LOW_HZ:
        raise RuntimeError(f"Sampling rate {sample_rate:.1f} Hz is too low")

    sos = butter(
        4,
        [TREMOR_LOW_HZ, high_hz],
        btype="bandpass",
        fs=sample_rate,
        output="sos",
    )
    filtered = np.vstack([sosfiltfilt(sos, axis) for axis in axes])
    tremor_magnitude = np.sqrt(np.sum(filtered**2, axis=0))
    acceleration_magnitude = np.sqrt(np.sum(axes**2, axis=0))

    segment_length = min(1024, filtered.shape[1])
    frequencies, power_x = welch(filtered[0], sample_rate, nperseg=segment_length)
    _, power_y = welch(filtered[1], sample_rate, nperseg=segment_length)
    _, power_z = welch(filtered[2], sample_rate, nperseg=segment_length)
    total_power = power_x + power_y + power_z
    tremor_band = (frequencies >= TREMOR_LOW_HZ) & (frequencies <= high_hz)
    dominant_hz = frequencies[tremor_band][np.argmax(total_power[tremor_band])]

    return (
        uniform_t - uniform_t[0],
        axes,
        acceleration_magnitude,
        filtered,
        tremor_magnitude,
        sample_rate,
        dominant_hz,
    )


def read_fsr_sample(device):
    line = device.readline().decode("ascii", errors="ignore").strip()
    if not line:
        return None

    fields = line.split(",")
    try:
        if fields[0] == "FSR" and len(fields) == 3:
            return int(fields[2])
        if len(fields) == 2:
            return int(fields[1])
    except ValueError:
        return None
    return None


def calibrate_fsr(device):
    print(f"Keep the grip pad untouched for {FSR_CALIBRATION_S} seconds...")
    device.reset_input_buffer()
    samples = []
    end_time = time.monotonic() + FSR_CALIBRATION_S
    while time.monotonic() < end_time:
        value = read_fsr_sample(device)
        if value is not None:
            samples.append(value)

    if not samples:
        raise RuntimeError("No FSR data received from the ESP32")

    rest_limit = int(np.percentile(samples, 99))
    threshold = max(FSR_MIN_ZERO_THRESHOLD, rest_limit + FSR_REST_MARGIN)
    threshold = min(threshold, FSR_MAX_ZERO_THRESHOLD)
    print(
        f"FSR calibrated: resting values up to {threshold} count as zero grip"
    )
    return threshold


def record_fsr(device, duration_s):
    device.reset_input_buffer()
    start_time = time.monotonic()
    times = []
    values = []

    while time.monotonic() - start_time < duration_s:
        value = read_fsr_sample(device)
        if value is not None:
            times.append(time.monotonic() - start_time)
            values.append(value)

    if not values:
        raise RuntimeError("No FSR samples were recorded")

    return np.asarray(times), np.asarray(values, dtype=float)


def save_trial(output_dir, trial_number, results, fsr_data, fsr_threshold):
    t, axes, accel_mag, filtered, tremor_mag, sample_rate, dominant_hz = results
    fsr_time, fsr_raw_samples = fsr_data
    fsr_raw = np.interp(t, fsr_time, fsr_raw_samples)
    fsr_calibrated = np.clip(fsr_raw - fsr_threshold, 0, None)
    grip_percent = np.clip(
        100.0 * fsr_calibrated / (FSR_MAX_RAW - fsr_threshold),
        0,
        100,
    )
    timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
    path = output_dir / f"iphone_trial_{trial_number:03d}_{timestamp}.csv"

    header = [
        "time_s",
        "fsr_raw",
        "fsr_zero_threshold",
        "grip_calibrated",
        "grip_percent",
        "accel_x_m_s2",
        "accel_y_m_s2",
        "accel_z_m_s2",
        "accel_magnitude_m_s2",
        "tremor_x_m_s2",
        "tremor_y_m_s2",
        "tremor_z_m_s2",
        "tremor_magnit
[truncated — 4445 more characters]
```

### webcam_tracker.html

```html
﻿<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Webcam forearm tracker</title>
<style>
  :root { color-scheme: light; }
  body { font-family: system-ui, -apple-system, "Segoe UI", sans-serif;
    max-width: 820px; margin: 32px auto; padding: 0 20px; line-height: 1.6; color: #151515; background: #ffffff; }
  h1 { font-size: 22px; font-weight: 500; }
  p.sub { color: #3f3f3f; margin-top: -8px; }
  .controls { display: flex; gap: 12px; flex-wrap: wrap; align-items: center; margin: 20px 0; }
  button, select { font-size: 15px; padding: 9px 16px; border-radius: 8px;
    border: 1px solid #777; background: #f4f4f2; color: #151515; cursor: pointer; }
  button:hover { background: #eaeae6; }
  button:disabled { opacity: 0.45; cursor: default; }
  .status { font-size: 15px; padding: 10px 14px; border-radius: 8px;
    background: #f0f0ee; border: 1px solid #ccc; color: #151515; margin-bottom: 18px; }
  .status.ok { background: #e7f3ea; border-color: #9bc8a7; color: #183d24; }
  .status.err { background: #f7e9e9; border-color: #d59797; color: #5b1515; }
  .stage { display: flex; gap: 20px; align-items: flex-start; flex-wrap: wrap; }
  .mirror { position: relative; transform: scaleX(-1); width: 480px; max-width: 100%; }
  .cameraColumn { width: 480px; max-width: 100%; }
  .brand { text-align: center; font-size: 28px; font-weight: 700; letter-spacing: 0.04em;
    color: #1767e8; margin-top: 14px; }
  .telemetry { display: none; }
  video, canvas { width: 100%; display: block; border-radius: 10px; }
  canvas { position: absolute; top: 0; left: 0; }
  .panel { flex: 1; min-width: 200px; }
  .metric { margin-bottom: 14px; }
  .metric .label { font-size: 13px; color: #3f3f3f; text-transform: uppercase; letter-spacing: 0.05em; }
  .metric .value { font-size: 32px; font-weight: 500; font-variant-numeric: tabular-nums;
    font-family: ui-monospace, "SF Mono", Menlo, monospace; }
  .bar { width: 40px; height: 240px; border: 1px solid #aaa; border-radius: 8px;
    position: relative; background: #f4f4f2; }
  .bar .paddle { position: absolute; left: 3px; right: 3px; height: 36px;
    background: #2f7d52; border-radius: 5px; top: 0; }
  .barwrap { display: flex; gap: 10px; align-items: center; }
  .hint { font-size: 13px; color: #4a4a4a; }
</style>
</head>
<body>
  <h1>Webcam forearm tracker</h1>
  <p class="sub">Tracks the forearm angle (elbow to wrist) and shows the value that will drive the paddle. Raise and lower your forearm and confirm it follows.</p>

  <div class="controls">
    <button id="start">Start camera + tracking</button>
    <label>Arm:
      <select id="side">
        <option value="16">Right (elbow 14, wrist 16)</option>
        <option value="15">Left (elbow 13, wrist 15)</option>
      </select>
    </label>
    <button id="setTop" class="telemetry" disabled>Set top</button>
    <button id="setBottom" class="telemetry" disabled>Set bottom</button>
  </div>

  <div id="status" class="status">Not started. Serve this over http://localhost and open in Chrome, then click start and allow the camera.</div>

  <div class="stage">
    <div class="cameraColumn">
      <div class="mirror">
        <video id="video" autoplay playsinline muted></video>
        <canvas id="overlay"></canvas>
      </div>
      <div class="brand">StrokeHub</div>
    </div>
    <div class="panel telemetry">
      <div class="metric"><div class="label">Forearm angle (&deg;)</div><div class="value" id="angle">--</div></div>
      <div class="metric"><div class="label">Elbow angle (&deg;)</div><div class="value" id="elbowAngle">--</div></div>
      <div class="metric"><div class="label">Shoulder elevation (&deg;)</div><div class="value" id="shoulderAngle">--</div></div>
      <div class="metric"><div class="label">Tracking visibility</div><div class="value" id="vis">--</div></div>
      <div class="barwrap">
        <div class="bar"><div class="paddle" id="paddle"></div></div>
        <div>
          <div class="label" style="font-size:13px;color:#3f3f3f;">Paddle preview</div>
          <div class="hint">Set top (raised) &amp; bottom (lowered)<br>to map your forearm range onto the paddle.</div>
        </div>
      </div>
    </div>
  </div>

<script type="module">
  // Keep the import and the /wasm path on the SAME version. If the model fetch fails,
  // download it locally (see notes) and set modelAssetPath to a relative path like "pose_landmarker_lite.task".
  import { PoseLandmarker, FilesetResolver } from "https://cdn.jsdelivr.net/npm/@mediapipe/tasks-vision@0.10.3";

  const statusEl = document.getElementById("status");
  const video = document.getElementById("video");
  const canvas = document.getElementById("overlay");
  const ctx = canvas.getContext("2d");
  const angleEl = document.getElementById("angle");
  const elbowAngleEl = document.getElementById("elbowAngle");
  const shoulderAngleEl = document.getElementById("shoulderAngle");
  const visEl = document.getElementById("vis");
  const paddleEl = document.getElementById("paddle");
  const sideSel = document.getElementById("side");
  const startBtn = document.getElementById("start");
  const setTopBtn = document.getElementById("setTop");
  const setBottomBtn = document.getElementById("setBottom");

  let landmarker = null;
  let running = false;
  let lastTime = -1;
  let topA = null, bottomA = null;   // calibrated forearm angles: raised (top) and lowered (bottom)
  let currentA = null;
  let lastPostTime = 0;

  function angleBetween(ax, ay, bx, by) {
    const denominator = Math.hypot(ax, ay) * Math.hypot(bx, by);
    if (!denominator) return null;
    const cosine = Math.max(-1, Math.min(1, (ax * bx + ay * by) / denominator));
    return Math.acos(cosine) * 180 / Math.PI;
  }

  function postCameraSample(sample, now) {
    if (now - lastPostTime < 50) return; // Up to 20 study samples/second.
    lastPostTime = now;
    fetch("/camera", {
      method: "POST",
      hea
[truncated — 5976 more characters]
```

### train_nervaflex_model.py

```python
#!/usr/bin/env python3
"""Train a proof-of-concept NervaFlex gameplay-pattern classifier.

This model classifies rule-defined movement and grip patterns. It is not a
clinical diagnostic model and must not be used to diagnose a health condition.
"""

import argparse
from pathlib import Path

import joblib
import numpy as np
import pandas as pd
from sklearn.compose import ColumnTransformer
from sklearn.ensemble import RandomForestClassifier
from sklearn.impute import SimpleImputer
from sklearn.metrics import classification_report
from sklearn.model_selection import train_test_split
from sklearn.multioutput import MultiOutputClassifier
from sklearn.pipeline import Pipeline
from sklearn.preprocessing import OneHotEncoder


EMG_FEATURES = [
    "mean_emg_activation",
    "peak_emg_activation",
    "emg_onset_time",
    "emg_variance",
    "fatigue_like_emg_slope",
    "effort_to_movement_ratio",
]

CAMERA_FEATURES = [
    "camera_available",
    "camera_tracking_percent",
    "camera_forearm_range_deg",
    "camera_forearm_mean_deg",
    "camera_forearm_std_deg",
    "camera_forearm_min_deg",
    "camera_forearm_max_deg",
    "camera_elbow_range_deg",
    "camera_elbow_mean_deg",
    "camera_elbow_std_deg",
    "camera_shoulder_range_deg",
    "camera_shoulder_mean_deg",
    "camera_shoulder_std_deg",
    "camera_visibility_mean",
    "camera_visibility_min",
    "camera_iphone_angle_correlation",
]

MODEL_FEATURES = [
    "game",
    "outcome",
    "trial_duration_s",
    "final_assessment_score",
    "tilt_range",
    "tilt_mean",
    "tilt_std",
    "tilt_min",
    "tilt_max",
    "avg_grip_percent",
    "max_grip_percent",
    "min_grip_percent",
    "grip_std",
    "grip_active_percent",
    "paused_percent",
    "avg_smoothness",
    "min_smoothness",
    "avg_tremor_px",
    "max_tremor_px",
    "avg_speed_px_s",
    "max_speed_px_s",
    "max_difficulty",
    "final_flappy_score",
    "final_pong_hits",
    "final_pong_misses",
    *CAMERA_FEATURES,
    *EMG_FEATURES,
]

LABEL_COLUMNS = [
    "limited_movement",
    "low_grip",
    "unstable_grip",
    "grip_loss",
    "unstable_movement",
    "high_tremor",
    "sensor_control_good",
    "good_control",
]

CATEGORICAL_FEATURES = ["game", "outcome"]
NUMERIC_FEATURES = [
    column for column in MODEL_FEATURES if column not in CATEGORICAL_FEATURES
]


def load_data(csv_path):
    """Load frame-level data and add safe placeholders for key identifiers."""
    data = pd.read_csv(csv_path, low_memory=False)
    if data.empty:
        raise ValueError(f"No rows found in {csv_path}")

    if "trial_id" not in data.columns:
        if "attempt_number" in data.columns:
            data["trial_id"] = "attempt_" + data["attempt_number"].astype(str)
        else:
            data["trial_id"] = "trial_0001"
    data["trial_id"] = data["trial_id"].fillna("unknown_trial").astype(str)
    return data


def _numeric(group, column):
    if column not in group.columns:
        return pd.Series(dtype=float)
    return pd.to_numeric(group[column], errors="coerce").dropna()


def _first_text(group, column, default="unknown"):
    if column not in group.columns:
        return default
    values = group[column].dropna()
    return str(values.iloc[0]) if not values.empty else default


def _first_number(group, column, default=np.nan):
    values = _numeric(group, column)
    return float(values.iloc[0]) if not values.empty else default


def _stat(values, operation, default=np.nan):
    if values.empty:
        return default
    return float(getattr(values, operation)())


def extract_trial_features(frame_data):
    """Convert frame-level rows into one feature row for each trial_id."""
    trials = []

    for trial_id, group in frame_data.groupby("trial_id", sort=False):
        tilt = _numeric(group, "tilt")
        grip = _numeric(group, "grip_percent")
        paused = _numeric(group, "paused")
        smoothness = _numeric(group, "smoothness")
        tremor = _numeric(group, "tremor_px")
        speed = _numeric(group, "speed_px_s")
        difficulty = _numeric(group, "difficulty")
        time_values = _numeric(group, "time_s")
        camera_tracking = _numeric(group, "camera_tracking")
        camera_forearm = _numeric(group, "camera_forearm_angle_deg")
        camera_elbow = _numeric(group, "camera_elbow_angle_deg")
        camera_shoulder = _numeric(group, "camera_shoulder_elevation_deg")
        camera_visibility = _numeric(group, "camera_visibility")

        duration = _first_number(group, "trial_duration_s")
        if np.isnan(duration) and not time_values.empty:
            duration = float(time_values.max() - time_values.min())

        trial = {
            "trial_id": trial_id,
            "attempt_number": _first_number(group, "attempt_number"),
            "trial_started_at": _first_text(group, "trial_started_at", "unknown"),
            "scenario_label": _first_text(group, "scenario_label", "unlabeled"),
            "game": _first_text(group, "game"),
            "outcome": _first_text(group, "outcome"),
            "trial_duration_s": duration,
            "final_assessment_score": _first_number(
                group, "final_assessment_score"
            ),
            "tilt_range": (
                float(tilt.max() - tilt.min()) if not tilt.empty else np.nan
            ),
            "tilt_mean": _stat(tilt, "mean"),
            "tilt_std": _stat(tilt, "std", 0.0),
            "tilt_min": _stat(tilt, "min"),
            "tilt_max": _stat(tilt, "max"),
            "avg_grip_percent": _stat(grip, "mean"),
            "max_grip_percent": _stat(grip, "max"),
            "min_grip_percent": _stat(grip, "min"),
            "grip_std": _stat(grip, "std", 0.0),
            "grip_active_percent": (
                float((grip > 0).mean() * 100) if not grip.empty else np.nan
            ),
            "paused_percent": (
                float((paused > 0).mean() * 100) if not paused.empty else np.nan
            ),
            "avg_smooth
[truncated — 8547 more characters]
```