{
 "cells": [
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "# HW 2 – EMG Analysis\n",
    "**EE374N / 385J – Neural Engineering**\n",
    "\n",
    "This notebook covers the EMG signal analysis homework using surface EMG data recorded from four electrodes on the forearm during three hand/wrist movements."
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "## Setup & Imports"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": 28,
   "metadata": {
    "execution": {
     "iopub.execute_input": "2026-03-04T06:31:57.995449Z",
     "iopub.status.busy": "2026-03-04T06:31:57.995233Z",
     "iopub.status.idle": "2026-03-04T06:31:58.476368Z",
     "shell.execute_reply": "2026-03-04T06:31:58.476045Z"
    }
   },
   "outputs": [],
   "source": [
    "import numpy as np\n",
    "import scipy.io\n",
    "import matplotlib.pyplot as plt\n",
    "import matplotlib.patches as mpatches\n",
    "\n",
    "%matplotlib inline\n",
    "plt.rcParams['figure.dpi'] = 120\n",
    "plt.rcParams['font.size'] = 10"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "## Load Data"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": 29,
   "metadata": {
    "execution": {
     "iopub.execute_input": "2026-03-04T06:31:58.477974Z",
     "iopub.status.busy": "2026-03-04T06:31:58.477864Z",
     "iopub.status.idle": "2026-03-04T06:31:58.641958Z",
     "shell.execute_reply": "2026-03-04T06:31:58.641687Z"
    }
   },
   "outputs": [
    {
     "name": "stdout",
     "output_type": "stream",
     "text": [
      "Subject 1: 6 runs\n",
      "Subject 2: 6 runs\n",
      "\n",
      "Run 1 – EMG shape : (127488, 4)\n",
      "Run 1 – Fs        : 512 Hz\n",
      "Run 1 – Channels  : ['ExtProx', 'ExtDist', 'FlxProx', 'FlxDist']\n",
      "Run 1 – Duration  : 249.0 s\n",
      "\n",
      "Unique event types: [  100   101   102   200   201   202   300   301   302   768  1000 32766]\n"
     ]
    }
   ],
   "source": [
    "def load_subject(path):\n",
    "    \"\"\"Load a subject .mat file and return a list of run dicts.\n",
    "    \n",
    "    Each run dict has:\n",
    "        emg    : (n_samples, 4) float array\n",
    "        fs     : scalar int – sampling rate (Hz)\n",
    "        labels : list of 4 channel-label strings\n",
    "        typ    : (n_events,) int array – event type codes\n",
    "        pos    : (n_events,) int array – event sample positions\n",
    "    \"\"\"\n",
    "    mat = scipy.io.loadmat(path, squeeze_me=False)\n",
    "    run_data = mat['subject']['run'][0, 0]   # shape (1, n_runs)\n",
    "    n_runs = run_data.shape[1]\n",
    "\n",
    "    runs = []\n",
    "    for i in range(n_runs):\n",
    "        r = run_data[0, i]\n",
    "        hdr = r['header'][0, 0]\n",
    "\n",
    "        fs = int(hdr['fs'][0, 0])\n",
    "        labels = [str(lbl[0]) for lbl in hdr['Label'][0]]\n",
    "\n",
    "        event_tuple = hdr['EVENT'].item()   # (TYP_array, POS_array)\n",
    "        typ = event_tuple[0].flatten().astype(int)\n",
    "        pos = event_tuple[1].flatten().astype(int)\n",
    "\n",
    "        runs.append(dict(\n",
    "            emg=r['emg'],\n",
    "            fs=fs,\n",
    "            labels=labels,\n",
    "            typ=typ,\n",
    "            pos=pos,\n",
    "        ))\n",
    "    return runs\n",
    "\n",
    "\n",
    "runs_s1 = load_subject('subject1.mat')\n",
    "runs_s2 = load_subject('subject2.mat')\n",
    "\n",
    "print(f\"Subject 1: {len(runs_s1)} runs\")\n",
    "print(f\"Subject 2: {len(runs_s2)} runs\")\n",
    "\n",
    "# Inspect the first run\n",
    "run = runs_s1[0]\n",
    "print(f\"\\nRun 1 – EMG shape : {run['emg'].shape}\")\n",
    "print(f\"Run 1 – Fs        : {run['fs']} Hz\")\n",
    "print(f\"Run 1 – Channels  : {run['labels']}\")\n",
    "print(f\"Run 1 – Duration  : {run['emg'].shape[0] / run['fs']:.1f} s\")\n",
    "print(f\"\\nUnique event types: {np.unique(run['typ'])}\")"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "### Event code key (from PDF Table 1 / Fig. 3)\n",
    "\n",
    "| Code | Trigger | Description |\n",
    "|------|---------|-------------|\n",
    "| 32766 | Run Start/End | Marks start and end of a run |\n",
    "| 1000 | Trial Start | Start of inter-trial rest period (1–1.5 s) |\n",
    "| 768 | Fixation | Fixation cue presented for 1 s |\n",
    "| **100 / 200 / 300** | **Task Cue** | Cue for **Pinch / Point / Grasp** presented for 2 s |\n",
    "| 101 / 201 / 301 | Task Start | Task execution begins |\n",
    "| 102 / 202 / 302 | Task End | Task execution ends (~2.5 s after start) |\n",
    "\n",
    "For 2.1.1 the **Task Cue** events (100/200/300) are used as trigger markers."
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "## 2.1.1 – First Trial of Run 1, Subject 1: Raw EMG + Trigger Labels"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": 30,
   "metadata": {
    "execution": {
     "iopub.execute_input": "2026-03-04T06:31:58.661192Z",
     "iopub.status.busy": "2026-03-04T06:31:58.661079Z",
     "iopub.status.idle": "2026-03-04T06:31:58.978181Z",
     "shell.execute_reply": "2026-03-04T06:31:58.977896Z"
    }
   },
   "outputs": [
    {
     "data": {
      "image/png": "[encoded data, 170260 characters]",
      "text/plain": [
       "<Figure size 1440x960 with 4 Axes>"
      ]
     },
     "metadata": {},
     "output_type": "display_data"
    },
    {
     "name": "stdout",
     "output_type": "stream",
     "text": [
      "Movement performed in the first trial: Point (Task Cue code = 200)\n"
     ]
    }
   ],
   "source": [
    "run    = runs_s1[0]           # subject 1, run 1\n",
    "emg    = run['emg']\n",
    "fs     = run['fs']\n",
    "labels = run['labels']\n",
    "typ    = run['typ']\n",
    "pos    = run['pos']\n",
    "t      = np.arange(emg.shape[0]) / fs\n",
    "\n",
    "# ── isolate the first trial ────────────────────────────────────────────────────\n",
    "# A trial runs from its Trial Start (1000) to its Task End (102/202/302).\n",
    "# The very first 1000 after the run-start (32766) begins the first trial.\n",
    "trial_starts = pos[typ == 1000]\n",
    "task_ends    = pos[(typ == 102) | (typ == 202) | (typ == 302)]\n",
    "\n",
    "t0 = trial_starts[0]   # first Trial Start sample\n",
    "t1 = task_ends[0]      # first Task End sample\n",
    "\n",
    "# Slice EMG and events to the first trial window\n",
    "emg_trial = emg[t0:t1+1, :]\n",
    "t_trial   = (np.arange(emg_trial.shape[0]) + t0) / fs   # absolute time (s)\n",
    "\n",
    "# All events that fall within the first trial\n",
    "mask_ev  = (pos >= t0) & (pos <= t1)\n",
    "ev_typ   = typ[mask_ev]\n",
    "ev_pos   = pos[mask_ev]\n",
    "\n",
    "# Trigger display config\n",
    "TRIGGER_INFO = {\n",
    "    1000: ('Trial Start',  'gray',        ':'),\n",
    "    768:  ('Fixation',     'purple',      '--'),\n",
    "    100:  ('Pinch Cue',    'tab:blue',    '--'),\n",
    "    200:  ('Point Cue',    'tab:orange',  '--'),\n",
    "    300:  ('Grasp Cue',    'tab:green',   '--'),\n",
    "    101:  ('Pinch Start',  'tab:blue',    '-'),\n",
    "    201:  ('Point Start',  'tab:orange',  '-'),\n",
    "    301:  ('Grasp Start',  'tab:green',   '-'),\n",
    "    102:  ('Pinch End',    'tab:blue',    '-.'),\n",
    "    202:  ('Point End',    'tab:orange',  '-.'),\n",
    "    302:  ('Grasp End',    'tab:green',   '-.'),\n",
    "}\n",
    "\n",
    "# ── plot ───────────────────────────────────────────────────────────────────────\n",
    "n_ch = emg_trial.shape[1]\n",
    "fig, axes = plt.subplots(n_ch, 1, figsize=(12, 8), sharex=True)\n",
    "fig.suptitle('Subject 1 – Run 1 – First Trial  |  Raw EMG (4 channels)', fontsize=12, fontweight='bold')\n",
    "\n",
    "# Collect legend handles (one per unique trigger in this trial)\n",
    "legend_handles = {}\n",
    "\n",
    "for ch_idx, (ax, ch_label) in enumerate(zip(axes, labels)):\n",
    "    ax.plot(t_trial, emg_trial[:, ch_idx], linewidth=0.7, color='dimgray')\n",
    "    ax.set_ylabel(f'{ch_label}\\n(µV)', fontsize=9)\n",
    "    ax.set_ylim(-2000, 2000)\n",
    "    ax.margins(x=0.01)\n",
    "\n",
    "    for code, samp in zip(ev_typ, ev_pos):\n",
    "        if code not in TRIGGER_INFO:\n",
    "            continue\n",
    "        label_str, colour, ls = TRIGGER_INFO[code]\n",
    "        t_ev = samp / fs\n",
    "        line = ax.axvline(x=t_ev, color=colour, linestyle=ls, linewidth=1.2, alpha=0.85)\n",
    "\n",
    "        # Annotate on the top subplot only\n",
    "        if ch_idx == 0:\n",
    "            ymin, ymax = ax.get_ylim()\n",
    "            ax.text(t_ev + 0.05, ymax * 0.95, label_str,\n",
    "                    color=colour, fontsize=7.5, rotation=90, va='top', ha='left')\n",
    "            if label_str not in legend_handles:\n",
    "                legend_handles[label_str] = plt.Line2D([], [], color=colour, linestyle=ls,\n",
    "                                                        linewidth=1.2, label=label_str)\n",
    "\n",
    "axes[-1].set_xlabel('Time (s)', fontsize=10)\n",
    "axes[0].legend(handles=list(legend_handles.values()), fontsize=7.5,\n",
    "               loc='upper right', framealpha=0.8, title='Triggers')\n",
    "\n",
    "plt.tight_layout()\n",
    "plt.savefig('2_1_1_first_trial.png', bbox_inches='tight')\n",
    "plt.show()\n",
    "\n",
    "# Identify the movement class\n",
    "task_cue_in_trial = ev_typ[(ev_typ == 100) | (ev_typ == 200) | (ev_typ == 300)]\n",
    "class_map = {100: 'Pinch', 200: 'Point', 300: 'Grasp'}\n",
    "movement = class_map.get(int(task_cue_in_trial[0]), 'Unknown') if len(task_cue_in_trial) else 'Unknown'\n",
    "print(f\"Movement performed in the first trial: {movement} (Task Cue code = {int(task_cue_in_trial[0])})\")"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "## PSD: Rest vs Task – Distal Flexor (First Trial, Run 1, Subject 1)\n",
    "\n",
    "- **Rest period**: Fixation → Task Cue (768 → 100/200/300)\n",
    "- **Task period**: Task Start → Task End (101/201/301 → 102/202/302)"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": 31,
   "metadata": {},
   "outputs": [
    {
     "name": "stdout",
     "output_type": "stream",
     "text": [
      "Rest segment: samples 1971–2491  (1.02 s, 520 pts)\n",
      "Task segment: samples 3521–4807  (2.51 s, 1286 pts)\n"
     ]
    },
    {
     "data": {
      "image/png": "[encoded data, 123176 characters]",
      "text/plain": [
       "<Figure size 1080x600 with 1 Axes>"
      ]
     },
     "metadata": {},
     "output_type": "display_data"
    }
   ],
   "source": [
    "from scipy.signal import welch\n",
    "\n",
    "# ── Channel index for Distal Flexor ───────────────────────────────────────────\n",
    "ch_flxdist = labels.index('FlxDist')   # should be 3\n",
    "\n",
    "# ── Identify rest and task sample boundaries from the first trial events ──────\n",
    "# Rest  = Fixation (768) → Task Cue (100/200/300)\n",
    "# Task  = Task Start (101/201/301) → Task End (102/202/302)\n",
    "\n",
    "fix_idx   = np.where(ev_typ == 768)[0][0]\n",
    "cue_idx   = np.where((ev_typ == 100) | (ev_typ == 200) | (ev_typ == 300))[0][0]\n",
    "start_idx = np.where((ev_typ == 101) | (ev_typ == 201) | (ev_typ == 301))[0][0]\n",
    "end_idx   = np.where((ev_typ == 102) | (ev_typ == 202) | (ev_typ == 302))[0][0]\n",
    "\n",
    "rest_start = ev_pos[fix_idx]\n",
    "rest_end   = ev_pos[cue_idx]\n",
    "task_start = ev_pos[start_idx]\n",
    "task_end   = ev_pos[end_idx]\n",
    "\n",
    "emg_rest = emg[rest_start:rest_end, ch_flxdist]\n",
    "emg_task = emg[task_start:task_end, ch_flxdist]\n",
    "\n",
    "print(f\"Rest segment: samples {rest_start}–{rest_end}  \"\n",
    "      f\"({len(emg_rest)/fs:.2f} s, {len(emg_rest)} pts)\")\n",
    "print(f\"Task segment: samples {task_start}–{task_end}  \"\n",
    "      f\"({len(emg_task)/fs:.2f} s, {len(emg_task)} pts)\")\n",
    "\n",
    "# ── Compute PSDs (Welch's method) ─────────────────────────────────────────────\n",
    "nperseg = min(256, len(emg_rest), len(emg_task))   # keep segments short enough\n",
    "f_rest, psd_rest = welch(emg_rest, fs=fs, nperseg=nperseg)\n",
    "f_task, psd_task = welch(emg_task, fs=fs, nperseg=nperseg)\n",
    "\n",
    "# Convert to dB/Hz:  10 * log10(PSD)\n",
    "psd_rest_db = 10 * np.log10(psd_rest)\n",
    "psd_task_db = 10 * np.log10(psd_task)\n",
    "\n",
    "# ── Plot ───────────────────────────────────────────────────────────────────────\n",
    "fig, ax = plt.subplots(figsize=(9, 5))\n",
    "ax.plot(f_rest, psd_rest_db, label='Rest (Fixation → Cue)', linewidth=1.5)\n",
    "ax.plot(f_task, psd_task_db, label=f'Task – {movement} (Start → End)', linewidth=1.5)\n",
    "\n",
    "ax.set_xlabel('Frequency (Hz)')\n",
    "ax.set_ylabel('PSD (dB/Hz)')\n",
    "ax.set_title(f'PSD – Distal Flexor (FlxDist) | First Trial, Run 1, Subject 1\\n'\n",
    "             f'Movement: {movement}', fontweight='bold')\n",
    "ax.legend()\n",
    "ax.set_xlim(0, fs / 2)\n",
    "ax.grid(True, which='both', alpha=0.3)\n",
    "\n",
    "plt.tight_layout()\n",
    "plt.savefig('psd_rest_vs_task_flxdist.png', bbox_inches='tight')\n",
    "plt.show()"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": 32,
   "metadata": {},
   "outputs": [
    {
     "data": {
      "image/png": "[encoded data, 114148 characters]",
      "text/plain": [
       "<Figure size 1080x600 with 1 Axes>"
      ]
     },
     "metadata": {},
     "output_type": "display_data"
    },
    {
     "data": {
      "image/png": "[encoded data, 126404 characters]",
      "text/plain": [
       "<Figure size 1080x600 with 1 Axes>"
      ]
     },
     "metadata": {},
     "output_type": "display_data"
    },
    {
     "data": {
      "image/png": "[encoded data, 126072 characters]",
      "text/plain": [
       "<Figure size 1080x600 with 1 Axes>"
      ]
     },
     "metadata": {},
     "output_type": "display_data"
    }
   ],
   "source": [
    "# ── PSD: Rest vs Task for the remaining 3 channels ───────────────────────────\n",
    "other_channels = [ch for ch in range(len(labels)) if ch != ch_flxdist]\n",
    "\n",
    "for ch in other_channels:\n",
    "    emg_rest_ch = emg[rest_start:rest_end, ch]\n",
    "    emg_task_ch = emg[task_start:task_end, ch]\n",
    "\n",
    "    nperseg_ch = min(256, len(emg_rest_ch), len(emg_task_ch))\n",
    "    f_r, psd_r = welch(emg_rest_ch, fs=fs, nperseg=nperseg_ch)\n",
    "    f_t, psd_t = welch(emg_task_ch, fs=fs, nperseg=nperseg_ch)\n",
    "\n",
    "    psd_r_db = 10 * np.log10(psd_r)\n",
    "    psd_t_db = 10 * np.log10(psd_t)\n",
    "\n",
    "    fig, ax = plt.subplots(figsize=(9, 5))\n",
    "    ax.plot(f_r, psd_r_db, label='Rest (Fixation → Cue)', linewidth=1.5)\n",
    "    ax.plot(f_t, psd_t_db, label=f'Task – {movement} (Start → End)', linewidth=1.5)\n",
    "\n",
    "    ax.set_xlabel('Frequency (Hz)')\n",
    "    ax.set_ylabel('PSD (dB/Hz)')\n",
    "    ax.set_title(f'PSD – {labels[ch]} | First Trial, Run 1, Subject 1\\n'\n",
    "                 f'Movement: {movement}', fontweight='bold')\n",
    "    ax.legend()\n",
    "    ax.set_xlim(0, fs / 2)\n",
    "    ax.grid(True, which='both', alpha=0.3)\n",
    "\n",
    "    plt.tight_layout()\n",
    "    plt.savefig(f'psd_rest_vs_task_{labels[ch]}.png', bbox_inches='tight')\n",
    "    plt.show()"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": 33,
   "metadata": {},
   "outputs": [
    {
     "name": "stdout",
     "output_type": "stream",
     "text": [
      "Subject 1: filtered 6 runs  (fs=512 Hz, channels=['ExtProx', 'ExtDist', 'FlxProx', 'FlxDist'])\n",
      "Subject 2: filtered 6 runs  (fs=512 Hz, channels=['ExtProx', 'ExtDist', 'FlxProx', 'FlxDist'])\n",
      "\n",
      "Access via filtered_data[subject][run], e.g. filtered_data[0][0]['emg'].shape = (127488, 4)\n",
      "filtered_data[0][1]['emg'].shape = (125440, 4)\n",
      "filtered_data[1][0]['emg'].shape = (127488, 4)\n",
      "filtered_data[1][1]['emg'].shape = (126464, 4)\n"
     ]
    }
   ],
   "source": [
    "from scipy.signal import butter, sosfiltfilt\n",
    "\n",
    "# ── Bandpass filter 50-200 Hz ─────────────────────────────────────────────────\n",
    "def bandpass_filter(emg, fs, lo=50, hi=200, order=4):\n",
    "    \"\"\"Zero-phase Butterworth bandpass filter.\"\"\"\n",
    "    sos = butter(order, [lo, hi], btype='bandpass', fs=fs, output='sos')\n",
    "    return sosfiltfilt(sos, emg, axis=0)\n",
    "\n",
    "# ── Load & filter all runs for both subjects ─────────────────────────────────\n",
    "# Result: filtered_data[subject_idx][run_idx] is a dict with\n",
    "#   'emg'    : (n_samples, 4) filtered EMG\n",
    "#   'fs'     : sampling rate\n",
    "#   'labels' : channel names\n",
    "#   'typ'    : event type codes\n",
    "#   'pos'    : event sample positions\n",
    "\n",
    "all_runs = [runs_s1, runs_s2]\n",
    "filtered_data = []\n",
    "\n",
    "for subj_idx, runs in enumerate(all_runs, start=1):\n",
    "    subj_runs = []\n",
    "    for run_idx, run in enumerate(runs):\n",
    "        emg_filt = bandpass_filter(run['emg'], run['fs'])\n",
    "        subj_runs.append(dict(\n",
    "            emg=emg_filt,\n",
    "            fs=run['fs'],\n",
    "            labels=run['labels'],\n",
    "            typ=run['typ'],\n",
    "            pos=run['pos'],\n",
    "        ))\n",
    "    filtered_data.append(subj_runs)\n",
    "    print(f\"Subject {subj_idx}: filtered {len(subj_runs)} runs  \"\n",
    "          f\"(fs={subj_runs[0]['fs']} Hz, channels={subj_runs[0]['labels']})\")\n",
    "\n",
    "print(f\"\\nAccess via filtered_data[subject][run], e.g. \"\n",
    "      f\"filtered_data[0][0]['emg'].shape = {filtered_data[0][0]['emg'].shape}\")\n",
    "print(f\"filtered_data[0][1]['emg'].shape = {filtered_data[0][1]['emg'].shape}\")\n",
    "print(f\"filtered_data[1][0]['emg'].shape = {filtered_data[1][0]['emg'].shape}\")\n",
    "print(f\"filtered_data[1][1]['emg'].shape = {filtered_data[1][1]['emg'].shape}\")"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": 34,
   "metadata": {},
   "outputs": [
    {
     "data": {
      "image/png": "[encoded data, 375852 characters]",
      "text/plain": [
       "<Figure size 1920x1152 with 12 Axes>"
      ]
     },
     "metadata": {},
     "output_type": "display_data"
    }
   ],
   "source": [
    "# ── Filtered EMG: sample trials across tasks & channels ──────────────────────\n",
    "# Show 3 trials (one per movement class) from Subject 1, Run 1\n",
    "# All 4 channels plotted for each trial.\n",
    "\n",
    "fd_run = filtered_data[0][0]          # Subject 1, Run 1, filtered\n",
    "emg_f  = fd_run['emg']\n",
    "fs_f   = fd_run['fs']\n",
    "typ_f  = fd_run['typ']\n",
    "pos_f  = fd_run['pos']\n",
    "lab_f  = fd_run['labels']\n",
    "\n",
    "class_map   = {100: 'Pinch', 200: 'Point', 300: 'Grasp'}\n",
    "cue_codes   = [100, 200, 300]\n",
    "start_codes = [101, 201, 301]\n",
    "end_codes   = [102, 202, 302]\n",
    "\n",
    "# Pick the first trial of each movement class and record its sequential trial number\n",
    "trial_starts_all = pos_f[typ_f == 1000]\n",
    "trial_picks = {}   # {movement_name: (trial_start_1000, task_end, trial_num)}\n",
    "for cue, sc, ec in zip(cue_codes, start_codes, end_codes):\n",
    "    cue_positions = pos_f[typ_f == cue]\n",
    "    if len(cue_positions) == 0:\n",
    "        continue\n",
    "    first_cue_pos = cue_positions[0]\n",
    "    t0 = trial_starts_all[trial_starts_all < first_cue_pos][-1]\n",
    "    trial_num = np.searchsorted(trial_starts_all, t0) + 1   # 1-based\n",
    "    te_positions = pos_f[typ_f == ec]\n",
    "    t1 = te_positions[te_positions > first_cue_pos][0]\n",
    "    trial_picks[class_map[cue]] = (t0, t1, trial_num)\n",
    "\n",
    "n_moves = len(trial_picks)\n",
    "n_chs   = len(lab_f)\n",
    "\n",
    "fig, axes = plt.subplots(n_moves, n_chs, figsize=(16, 3.2 * n_moves),\n",
    "                         sharex=False, squeeze=False)\n",
    "fig.suptitle('Bandpass-Filtered EMG (50–200 Hz) – Sample Trials, Subject 1 Run 1',\n",
    "             fontsize=13, fontweight='bold', y=1.01)\n",
    "\n",
    "for row, (move_name, (s0, s1, tnum)) in enumerate(trial_picks.items()):\n",
    "    seg = emg_f[s0:s1+1, :]\n",
    "    t_seg = np.arange(seg.shape[0]) / fs_f\n",
    "\n",
    "    mask = (pos_f >= s0) & (pos_f <= s1)\n",
    "    ev_t = typ_f[mask]\n",
    "    ev_p = pos_f[mask]\n",
    "\n",
    "    for col in range(n_chs):\n",
    "        ax = axes[row, col]\n",
    "        ax.plot(t_seg, seg[:, col], linewidth=0.6, color='k')\n",
    "        ax.set_ylabel('µV', fontsize=9)\n",
    "        ax.set_title(f'{lab_f[col]} – {move_name} (Trial {tnum})', fontsize=10)\n",
    "        ax.margins(x=0.01)\n",
    "        ax.grid(True, alpha=0.25)\n",
    "\n",
    "        for code, samp in zip(ev_t, ev_p):\n",
    "            rel = (samp - s0) / fs_f\n",
    "            if code in (101, 201, 301):\n",
    "                ax.axvline(rel, color='green', ls='--', lw=1, alpha=0.8, label='Task Start')\n",
    "            elif code in (102, 202, 302):\n",
    "                ax.axvline(rel, color='red', ls='--', lw=1, alpha=0.8, label='Task End')\n",
    "\n",
    "        handles, lbls = ax.get_legend_handles_labels()\n",
    "        by_label = dict(zip(lbls, handles))\n",
    "        if by_label:\n",
    "            ax.legend(by_label.values(), by_label.keys(), fontsize=7, loc='upper right')\n",
    "\n",
    "for col in range(n_chs):\n",
    "    axes[-1, col].set_xlabel('Time (s)')\n",
    "\n",
    "plt.tight_layout()\n",
    "plt.savefig('filtered_emg_sample_trials.png', bbox_inches='tight')\n",
    "plt.show()"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": 35,
   "metadata": {},
   "outputs": [
    {
     "data": {
      "image/png": "[encoded data, 473812 characters]",
      "text/plain": [
       "<Figure size 1920x1152 with 12 Axes>"
      ]
     },
     "metadata": {},
     "output_type": "display_data"
    },
    {
     "data": {
      "image/png": "[encoded data, 484056 characters]",
      "text/plain": [
       "<Figure size 1920x1152 with 12 Axes>"
      ]
     },
     "metadata": {},
     "output_type": "display_data"
    }
   ],
   "source": [
    "# ── PSD of filtered EMG: same sample trials (Rest vs Task), all 4 channels ───\n",
    "from scipy.signal import welch\n",
    "\n",
    "for subj_idx in range(2):\n",
    "    fd_run_s = filtered_data[subj_idx][0]    # Run 1 for each subject, filtered\n",
    "    emg_f_s  = fd_run_s['emg']\n",
    "    fs_f_s   = fd_run_s['fs']\n",
    "    typ_f_s  = fd_run_s['typ']\n",
    "    pos_f_s  = fd_run_s['pos']\n",
    "    lab_f_s  = fd_run_s['labels']\n",
    "\n",
    "    trial_starts_all_s = pos_f_s[typ_f_s == 1000]\n",
    "    trial_picks_s = {}\n",
    "    for cue, sc, ec in zip(cue_codes, start_codes, end_codes):\n",
    "        cue_positions = pos_f_s[typ_f_s == cue]\n",
    "        if len(cue_positions) == 0:\n",
    "            continue\n",
    "        first_cue_pos = cue_positions[0]\n",
    "        t0 = trial_starts_all_s[trial_starts_all_s < first_cue_pos][-1]\n",
    "        trial_num = np.searchsorted(trial_starts_all_s, t0) + 1\n",
    "        te_positions = pos_f_s[typ_f_s == ec]\n",
    "        t1 = te_positions[te_positions > first_cue_pos][0]\n",
    "        trial_picks_s[class_map[cue]] = (t0, t1, trial_num)\n",
    "\n",
    "    n_moves_s = len(trial_picks_s)\n",
    "    n_chs_s   = len(lab_f_s)\n",
    "\n",
    "    fig, axes = plt.subplots(n_moves_s, n_chs_s, figsize=(16, 3.2 * n_moves_s),\n",
    "                             sharex=False, squeeze=False)\n",
    "    fig.suptitle(f'PSD of Filtered EMG (50–200 Hz) – Rest vs Task, Subject {subj_idx + 1} Run 1',\n",
    "                 fontsize=13, fontweight='bold', y=1.01)\n",
    "\n",
    "    for row, (move_name, (s0, s1, tnum)) in enumerate(trial_picks_s.items()):\n",
    "        mask = (pos_f_s >= s0) & (pos_f_s <= s1)\n",
    "        ev_t = typ_f_s[mask]\n",
    "        ev_p = pos_f_s[mask]\n",
    "\n",
    "        fix_pos = ev_p[ev_t == 768][0]\n",
    "        cue_pos = ev_p[(ev_t == 100) | (ev_t == 200) | (ev_t == 300)][0]\n",
    "        tsk_pos = ev_p[(ev_t == 101) | (ev_t == 201) | (ev_t == 301)][0]\n",
    "        end_pos = ev_p[(ev_t == 102) | (ev_t == 202) | (ev_t == 302)][0]\n",
    "\n",
    "        for col in range(n_chs_s):\n",
    "            rest_seg = emg_f_s[fix_pos:cue_pos, col]\n",
    "            task_seg = emg_f_s[tsk_pos:end_pos, col]\n",
    "\n",
    "            nperseg_psd = min(256, len(rest_seg), len(task_seg))\n",
    "            f_r, psd_r = welch(rest_seg, fs=fs_f_s, nperseg=nperseg_psd)\n",
    "            f_t, psd_t = welch(task_seg, fs=fs_f_s, nperseg=nperseg_psd)\n",
    "\n",
    "            ax = axes[row, col]\n",
    "            ax.plot(f_r, 10 * np.log10(psd_r), label='Rest', linewidth=1.3)\n",
    "            ax.plot(f_t, 10 * np.log10(psd_t), label='Task', linewidth=1.3)\n",
    "            ax.set_title(f'{lab_f_s[col]} – {move_name} (Trial {tnum})', fontsize=10)\n",
    "            ax.set_ylabel('PSD (dB/Hz)', fontsize=9)\n",
    "            ax.set_xlim(0, fs_f_s / 2)\n",
    "            ax.grid(True, which='both', alpha=0.3)\n",
    "            ax.legend(fontsize=7, loc='upper right')\n",
    "\n",
    "    for col in range(n_chs_s):\n",
    "        axes[-1, col].set_xlabel('Frequency (Hz)')\n",
    "\n",
    "    plt.tight_layout()\n",
    "    plt.savefig(f'psd_filtered_sample_trials_S{subj_idx + 1}.png', bbox_inches='tight')\n",
    "    plt.show()"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": 36,
   "metadata": {},
   "outputs": [
    {
     "name": "stdout",
     "output_type": "stream",
     "text": [
      "Subject 1: 180 trials\n",
      "Subject 2: 180 trials\n",
      "\n",
      "Sample lengths (Subject 1, first 6 trials):\n",
      "  Trial 1: Point  – 1286 samples/channel\n",
      "  Trial 2: Pinch  – 1294 samples/channel\n",
      "  Trial 3: Pinch  – 1283 samples/channel\n",
      "  Trial 4: Grasp  – 1283 samples/channel\n",
      "  Trial 5: Pinch  – 1284 samples/channel\n",
      "  Trial 6: Point  – 1283 samples/channel\n"
     ]
    }
   ],
   "source": [
    "# ── Extract per-trial task segments (Task Start → Task End) ───────────────────\n",
    "# For each subject: a list of (emg_channels, task_label) tuples.\n",
    "#   emg_channels : list of 4 arrays, one per channel (variable length)\n",
    "#   task_label   : str – 'Pinch', 'Point', or 'Grasp'\n",
    "# Length = total trials across all runs.\n",
    "\n",
    "task_class = {101: 'Pinch', 201: 'Point', 301: 'Grasp'}\n",
    "task_start_codes = {101, 201, 301}\n",
    "task_end_codes   = {102, 202, 302}\n",
    "\n",
    "trials_s1 = []\n",
    "trials_s2 = []\n",
    "\n",
    "for subj_trials, subj_runs in [(trials_s1, filtered_data[0]),\n",
    "                                (trials_s2, filtered_data[1])]:\n",
    "    for run in subj_runs:\n",
    "        typ_r = run['typ']\n",
    "        pos_r = run['pos']\n",
    "        emg_r = run['emg']\n",
    "\n",
    "        starts = pos_r[np.isin(typ_r, list(task_start_codes))]\n",
    "        ends   = pos_r[np.isin(typ_r, list(task_end_codes))]\n",
    "        codes  = typ_r[np.isin(typ_r, list(task_start_codes))]\n",
    "\n",
    "        for s_pos, e_pos, s_code in zip(starts, ends, codes):\n",
    "            channels = [emg_r[s_pos:e_pos, ch] for ch in range(4)]\n",
    "            subj_trials.append((channels, task_class[s_code]))\n",
    "\n",
    "print(f\"Subject 1: {len(trials_s1)} trials\")\n",
    "print(f\"Subject 2: {len(trials_s2)} trials\")\n",
    "print(f\"\\nSample lengths (Subject 1, first 6 trials):\")\n",
    "for i, (chs, lbl) in enumerate(trials_s1[:6]):\n",
    "    print(f\"  Trial {i+1}: {lbl:6s} – {len(chs[0])} samples/channel\")"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "## 2.2 Feature Extraction\n",
    "\n",
    "**Features**: MAV (Mean Absolute Value) and SSC (Slope Sign Changes), computed over sliding windows.\n",
    "\n",
    "Window size and overlap will be optimized via run-wise cross-validation in §2.4. For the scatter plots below, a preliminary window of 200 samples with 50 % overlap is used."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": 37,
   "metadata": {},
   "outputs": [
    {
     "name": "stdout",
     "output_type": "stream",
     "text": [
      "extract_features() defined.\n"
     ]
    }
   ],
   "source": [
    "# ── 2.2.1  Feature functions ──────────────────────────────────────────────────\n",
    "def extract_features(signal, win_len, overlap_frac):\n",
    "    \"\"\"Compute MAV and SSC (Slope Sign Changes) over sliding windows on a 1-D signal.\"\"\"\n",
    "    step = max(1, int(win_len * (1 - overlap_frac)))\n",
    "    n_win = max(0, (len(signal) - win_len) // step + 1)\n",
    "    if n_win == 0:\n",
    "        return np.array([]), np.array([])\n",
    "    mav = np.empty(n_win)\n",
    "    ssc = np.empty(n_win)\n",
    "    for i in range(n_win):\n",
    "        s = i * step\n",
    "        w = signal[s:s + win_len]\n",
    "        mav[i] = np.mean(np.abs(w))\n",
    "        # SSC: count sign changes in the first-order difference\n",
    "        diff = np.diff(w)\n",
    "        ssc[i] = np.sum(diff[:-1] * diff[1:] < 0)\n",
    "    return mav, ssc\n",
    "\n",
    "print(\"extract_features() defined.\")"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": 38,
   "metadata": {},
   "outputs": [
    {
     "name": "stdout",
     "output_type": "stream",
     "text": [
      "Feature windows (win=350, overlap=75%):\n",
      "  Subject 1 – Pinch: 660 windows\n",
      "  Subject 1 – Point: 660 windows\n",
      "  Subject 1 – Grasp: 660 windows\n",
      "  Subject 2 – Pinch: 660 windows\n",
      "  Subject 2 – Point: 660 windows\n",
      "  Subject 2 – Grasp: 660 windows\n"
     ]
    }
   ],
   "source": [
    "# ── 2.2.2  Compute features for all subjects, classes, sensors ───────────────\n",
    "WIN_VIS = 350     # samples – preliminary; optimal found via CV in §2.4\n",
    "OVL_VIS = 0.75\n",
    "\n",
    "classes   = ['Pinch', 'Point', 'Grasp']\n",
    "ch_names  = filtered_data[0][0]['labels']\n",
    "\n",
    "# feat_data[subj][cls][ch] = {'mav': array, 'ssc': array}\n",
    "feat_data = []\n",
    "for subj_idx, trials in enumerate([trials_s1, trials_s2]):\n",
    "    subj = {cls: {ch: {'mav': [], 'ssc': []} for ch in range(4)} for cls in classes}\n",
    "    for channels, label in trials:\n",
    "        for ch in range(4):\n",
    "            m, s = extract_features(channels[ch], WIN_VIS, OVL_VIS)\n",
    "            if len(m) > 0:\n",
    "                subj[label][ch]['mav'].extend(m)\n",
    "                subj[label][ch]['ssc'].extend(s) \n",
    "    for cls in classes:\n",
    "        for ch in range(4):\n",
    "            subj[cls][ch]['mav'] = np.array(subj[cls][ch]['mav'])\n",
    "            subj[cls][ch]['ssc'] = np.array(subj[cls][ch]['ssc'])\n",
    "    feat_data.append(subj)\n",
    "\n",
    "print(f\"Feature windows (win={WIN_VIS}, overlap={OVL_VIS:.0%}):\")\n",
    "for s in range(2):\n",
    "    for cls in classes:\n",
    "        n = len(feat_data[s][cls][0]['mav'])\n",
    "        print(f\"  Subject {s+1} – {cls}: {n} windows\")"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": 39,
   "metadata": {},
   "outputs": [
    {
     "data": {
      "image/png": "[encoded data, 1120164 characters]",
      "text/plain": [
       "<Figure size 1440x2160 with 8 Axes>"
      ]
     },
     "metadata": {},
     "output_type": "display_data"
    }
   ],
   "source": [
    "# ── 2.2.3  Scatter plots: MAV vs SSC per sensor, both subjects ───────────────\n",
    "colors_cls = {'Pinch': 'tab:blue', 'Point': 'tab:orange', 'Grasp': 'tab:green'}\n",
    "\n",
    "fig, axes = plt.subplots(4, 2, figsize=(12, 18), squeeze=False)\n",
    "fig.suptitle(f'MAV vs SSC Feature Space  (win = {WIN_VIS}, overlap = {OVL_VIS:.0%})',\n",
    "             fontsize=14, fontweight='bold')\n",
    "\n",
    "for subj in range(2):\n",
    "    for ch in range(4):\n",
    "        row = subj * 2 + ch // 2\n",
    "        col = ch % 2\n",
    "        ax = axes[row, col]\n",
    "        for cls in classes:\n",
    "            ax.scatter(feat_data[subj][cls][ch]['mav'],\n",
    "                       feat_data[subj][cls][ch]['ssc'],\n",
    "                       c=colors_cls[cls], label=cls,\n",
    "                       alpha=0.25, s=8, edgecolors='none')\n",
    "        ax.set_xlabel('MAV (µV)')\n",
    "        ax.set_ylabel('SSC (count)')\n",
    "        ax.set_title(f'{ch_names[ch]} – Subject {subj + 1}', fontsize=10)\n",
    "        if ch == 0:\n",
    "            ax.legend(fontsize=8, markerscale=3)\n",
    "        ax.grid(True, alpha=0.3)\n",
    "\n",
    "plt.tight_layout()\n",
    "plt.savefig('2_2_scatter_mav_ssc.png', bbox_inches='tight')\n",
    "plt.show()"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "## 2.3 Average Patterns\n",
    "\n",
    "Pipeline for MAV envelope (**Subject 1, Proximal Flexor only**):\n",
    "\n",
    "1. Bandpass-filtered EMG (already computed)\n",
    "2. Rectify (take absolute value)\n",
    "3. Lowpass filter at 5 Hz (temporal smoothing ≈ moving average)\n",
    "4. Extract task segments (Task Start → Task End)\n",
    "5. Align and average across trials of the same movement class"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": 40,
   "metadata": {},
   "outputs": [
    {
     "name": "stdout",
     "output_type": "stream",
     "text": [
      "Pinch: 60 trials, truncated to 1282 samples (2.50 s)\n",
      "Point: 60 trials, truncated to 1283 samples (2.51 s)\n",
      "Grasp: 60 trials, truncated to 1282 samples (2.50 s)\n"
     ]
    }
   ],
   "source": [
    "# ── 2.3.1  Compute MAV envelopes: |bandpassed EMG| → lowpass 5 Hz ────────────\n",
    "ch_flxprox = ch_names.index('FlxProx')\n",
    "\n",
    "def mav_envelope(signal, fs, cutoff=5, order=4):\n",
    "    \"\"\"Rectify + lowpass filter to get a smooth MAV envelope.\"\"\"\n",
    "    rectified = np.abs(signal)\n",
    "    sos = butter(order, cutoff, btype='low', fs=fs, output='sos')\n",
    "    return sosfiltfilt(sos, rectified)\n",
    "\n",
    "task_class_env = {101: 'Pinch', 201: 'Point', 301: 'Grasp'}\n",
    "mav_trials_env = {'Pinch': [], 'Point': [], 'Grasp': []}\n",
    "\n",
    "for run in filtered_data[0]:                       # Subject 1 only\n",
    "    env = mav_envelope(run['emg'][:, ch_flxprox], run['fs'])\n",
    "    typ_r, pos_r = run['typ'], run['pos']\n",
    "\n",
    "    starts = pos_r[np.isin(typ_r, [101, 201, 301])]\n",
    "    ends   = pos_r[np.isin(typ_r, [102, 202, 302])]\n",
    "    codes  = typ_r[np.isin(typ_r, [101, 201, 301])]\n",
    "\n",
    "    for s_pos, e_pos, s_code in zip(starts, ends, codes):\n",
    "        mav_trials_env[task_class_env[s_code]].append(env[s_pos:e_pos])\n",
    "\n",
    "# Average across trials (truncate to shortest trial per class)\n",
    "fs_s1   = filtered_data[0][0]['fs']\n",
    "avg_mav = {}\n",
    "for cls in classes:\n",
    "    min_len = min(len(t) for t in mav_trials_env[cls])\n",
    "    stacked = np.array([t[:min_len] for t in mav_trials_env[cls]])\n",
    "    avg_mav[cls] = stacked.mean(axis=0)\n",
    "    print(f\"{cls}: {len(mav_trials_env[cls])} trials, \"\n",
    "          f\"truncated to {min_len} samples ({min_len / fs_s1:.2f} s)\")"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": 41,
   "metadata": {},
   "outputs": [
    {
     "data": {
      "image/png": "[encoded data, 298576 characters]",
      "text/plain": [
       "<Figure size 1200x1200 with 3 Axes>"
      ]
     },
     "metadata": {},
     "output_type": "display_data"
    }
   ],
   "source": [
    "# ── 2.3.2  Plot 5 random trials + average MAV for each movement ──────────────\n",
    "np.random.seed(42)\n",
    "\n",
    "fig, axes = plt.subplots(3, 1, figsize=(10, 10), sharey=True)\n",
    "fig.suptitle('MAV Envelope – FlxProx, Subject 1  (5 random trials + average)',\n",
    "             fontsize=13, fontweight='bold')\n",
    "\n",
    "for ax, cls in zip(axes, classes):\n",
    "    n_tr    = len(mav_trials_env[cls])\n",
    "    idxs    = np.random.choice(n_tr, size=min(5, n_tr), replace=False)\n",
    "    min_len = len(avg_mav[cls])\n",
    "    t_ax    = np.arange(min_len) / fs_s1\n",
    "\n",
    "    for idx in idxs:\n",
    "        ax.plot(t_ax, mav_trials_env[cls][idx][:min_len],\n",
    "                alpha=0.45, linewidth=0.8)\n",
    "    ax.plot(t_ax, avg_mav[cls], color='black', linewidth=2.5, label='Average')\n",
    "\n",
    "    ax.set_title(cls, fontsize=11)\n",
    "    ax.set_xlabel('Time from Task Start (s)')\n",
    "    ax.set_ylabel('MAV Envelope (µV)')\n",
    "    ax.legend(fontsize=8)\n",
    "    ax.grid(True, alpha=0.3)\n",
    "\n",
    "plt.tight_layout()\n",
    "plt.savefig('2_3_avg_mav_patterns.png', bbox_inches='tight')\n",
    "plt.show()"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "### Comments on Average Patterns\n",
    "\n",
    "**2.3.2 – Individual trials vs. average**: The individual trials show substantial variability in amplitude, but the overall temporal shape is consistent within each movement class — a burst of activity shortly after task onset that decays towards task end. The average pattern captures this common waveform while the per-trial noise cancels out, demonstrating the SNR improvement from trial averaging.\n",
    "\n",
    "**2.3.3 – Comparison with 2.2 feature distributions**: The MAV feature distributions in the 2.2 scatter plots represent *windowed* MAV values pooled over all time points during a task, collapsing temporal structure. The average patterns here reveal that MAV is not constant during a movement — it rises and falls. This temporal variation explains the spread in the scatter plots: windows taken near peak contraction produce high MAV values, while windows near the edges of the task produce lower values, broadening the per-class distributions. SSC, meanwhile, captures waveform complexity rather than amplitude — it remains relatively stable across the task window but differs between classes due to distinct motor-unit firing patterns."
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "## 2.4 Classification\n",
    "\n",
    "**Setup**: 3-class classification (Pinch / Point / Grasp) using MAV + SSC features from all 4 channels (8 features per window).\n",
    "\n",
    "**Cross-validation**: Run-wise leave-one-out on the **first 5 runs**, with grid search over:\n",
    "- **Window sizes**: 50 – 350 samples (step 25)\n",
    "- **Overlap fractions**: 0 %, 25 %, 50 %, 75 %\n",
    "- **Classifiers**: Linear LDA, plus three 1-D CNN architectures of increasing depth:\n",
    "  - **CNN-S** (shallow): 1 conv layer (16 filters)\n",
    "  - **CNN-M** (medium): 2 conv layers (16 → 32 filters)\n",
    "  - **CNN-L** (deep): 3 conv layers (16 → 32 → 64 filters)\n",
    "\n",
    "All CNNs use kernel size 3, BatchNorm + ReLU after each conv, adaptive average pooling, and a final linear layer. Trained with Adam (lr = 1e-3) for 80 epochs per fold.\n",
    "\n",
    "**Advantage of run-wise CV**: Using entire runs as CV folds prevents temporal-correlation leakage. Adjacent windows within the same run are highly correlated; if they appeared in both train and test sets, accuracy estimates would be inflated. Run-wise CV better predicts performance on new, unseen recording sessions — the realistic deployment scenario for a real-time BCI."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": 42,
   "metadata": {},
   "outputs": [
    {
     "name": "stdout",
     "output_type": "stream",
     "text": [
      "Using device: cuda\n",
      "  GPU: NVIDIA GeForce RTX 3060 Ti\n"
     ]
    },
    {
     "data": {
      "application/vnd.jupyter.widget-view+json": {
       "model_id": "d1d8c51484ce438b85289be14c420dd8",
       "version_major": 2,
       "version_minor": 0
      },
      "text/plain": [
       "[Part A] Subject 1:   0%|          | 0/52 [00:00<?, ?combo/s]"
      ]
     },
     "metadata": {},
     "output_type": "display_data"
    },
    {
     "name": "stdout",
     "output_type": "stream",
     "text": [
      "Subject 1 – Best: win=350 ms, overlap=50%, LDA, accuracy = 0.816 ± 0.076\n"
     ]
    },
    {
     "data": {
      "application/vnd.jupyter.widget-view+json": {
       "model_id": "5835e75f1415466191da611e983963a5",
       "version_major": 2,
       "version_minor": 0
      },
      "text/plain": [
       "[Part A] Subject 2:   0%|          | 0/52 [00:00<?, ?combo/s]"
      ]
     },
     "metadata": {},
     "output_type": "display_data"
    },
    {
     "name": "stdout",
     "output_type": "stream",
     "text": [
      "Subject 2 – Best: win=325 ms, overlap=0%, LDA, accuracy = 0.959 ± 0.033\n"
     ]
    },
    {
     "data": {
      "image/png": "[encoded data, 281484 characters]",
      "text/plain": [
       "<Figure size 2640x1200 with 16 Axes>"
      ]
     },
     "metadata": {},
     "output_type": "display_data"
    }
   ],
   "source": [
    "# ── 2.4.1  Setup + Part A: LDA vs KNN on extracted features ──────────────────\n",
    "import torch\n",
    "import torch.nn as nn\n",
    "from torch.amp import autocast, GradScaler\n",
    "from sklearn.discriminant_analysis import LinearDiscriminantAnalysis\n",
    "from sklearn.neighbors import KNeighborsClassifier\n",
    "from sklearn.preprocessing import StandardScaler\n",
    "from tqdm.notebook import tqdm\n",
    "\n",
    "device = torch.device('cuda' if torch.cuda.is_available() else 'cpu')\n",
    "print(f\"Using device: {device}\")\n",
    "if device.type == 'cuda':\n",
    "    print(f\"  GPU: {torch.cuda.get_device_name(0)}\")\n",
    "    torch.backends.cudnn.benchmark = True\n",
    "\n",
    "use_amp = device.type == 'cuda'\n",
    "\n",
    "win_sizes     = [50, 75, 100, 125, 150, 175, 200, 225, 250, 275, 300, 325, 350]  # milliseconds\n",
    "overlap_fracs = [0, 0.25, 0.5, 0.75]\n",
    "k_values      = [1, 3, 5, 7, 11, 15]\n",
    "\n",
    "FS = 512  # Hz\n",
    "\n",
    "def ms_to_samples(ms, fs=FS):\n",
    "    return max(1, round(ms * fs / 1000))\n",
    "\n",
    "\n",
    "def extract_run_Xy(run, win_ms, ovl_frac, raw=False):\n",
    "    \"\"\"Extract windowed data from one run.\n",
    "\n",
    "    win_ms    : window length in milliseconds\n",
    "    raw=False : X is (n_windows, 8)            — [MAV×4, SSC×4] feature vectors\n",
    "    raw=True  : X is (n_windows, 4, win_samp)  — raw EMG, channels-first\n",
    "    \"\"\"\n",
    "    typ_r, pos_r, emg_r = run['typ'], run['pos'], run['emg']\n",
    "    task_code = {101: 0, 201: 1, 301: 2}\n",
    "    win_samp = ms_to_samples(win_ms)\n",
    "    step     = max(1, int(win_samp * (1 - ovl_frac)))\n",
    "\n",
    "    s_mask = np.isin(typ_r, [101, 201, 301])\n",
    "    e_mask = np.isin(typ_r, [102, 202, 302])\n",
    "    starts, ends, codes = pos_r[s_mask], pos_r[e_mask], typ_r[s_mask]\n",
    "\n",
    "    X_list, y_list = [], []\n",
    "    for s_pos, e_pos, s_code in zip(starts, ends, codes):\n",
    "        seg   = emg_r[s_pos:e_pos, :]\n",
    "        n_win = max(0, (len(seg) - win_samp) // step + 1)\n",
    "        if n_win == 0:\n",
    "            continue\n",
    "\n",
    "        if raw:\n",
    "            for w in range(n_win):\n",
    "                s = w * step\n",
    "                X_list.append(seg[s:s + win_samp, :].T.astype(np.float32))  # (4, win_samp)\n",
    "                y_list.append(task_code[s_code])\n",
    "        else:\n",
    "            ch_feats, n_win_min = [], n_win\n",
    "            for ch in range(4):\n",
    "                m, r = extract_features(seg[:, ch], win_samp, ovl_frac)\n",
    "                ch_feats.append((m, r))\n",
    "                n_win_min = min(n_win_min, len(m))\n",
    "            for w in range(n_win_min):\n",
    "                row = ([ch_feats[c][0][w] for c in range(4)] +\n",
    "                       [ch_feats[c][1][w] for c in range(4)])\n",
    "                X_list.append(row)\n",
    "                y_list.append(task_code[s_code])\n",
    "\n",
    "    if X_list:\n",
    "        return np.array(X_list), np.array(y_list)\n",
    "    shape = (0, 4, win_samp) if raw else (0, 8)\n",
    "    return np.empty(shape, dtype=np.float32), np.array([], dtype=int)\n",
    "\n",
    "\n",
    "# ── Part A grid search ────────────────────────────────────────────────────────\n",
    "results_feat = {}\n",
    "\n",
    "for subj_idx, subj_runs in enumerate(filtered_data):\n",
    "    n_cv    = min(5, len(subj_runs))\n",
    "    cv_runs = subj_runs[:n_cv]\n",
    "    results = []\n",
    "    n_total = len(win_sizes) * len(overlap_fracs)\n",
    "\n",
    "    pbar = tqdm(total=n_total, desc=f'[Part A] Subject {subj_idx + 1}', unit='combo')\n",
    "\n",
    "    for win_ms in win_sizes:\n",
    "        for ovl in overlap_fracs:\n",
    "            pbar.set_postfix(win=f'{win_ms}ms', ovl=f'{ovl:.0%}')\n",
    "            run_Xy = [extract_run_Xy(r, win_ms, ovl, raw=False) for r in cv_runs]\n",
    "\n",
    "            clf_accs = {'LDA': [], **{f'KNN-{k}': [] for k in k_values}}\n",
    "\n",
    "            for test_r in range(n_cv):\n",
    "                train_idx = [i for i in range(n_cv) if i != test_r]\n",
    "                Xtr_parts = [run_Xy[i][0] for i in train_idx if len(run_Xy[i][0]) > 0]\n",
    "                ytr_parts = [run_Xy[i][1] for i in train_idx if len(run_Xy[i][1]) > 0]\n",
    "                if not Xtr_parts:\n",
    "                    continue\n",
    "                X_train = np.vstack(Xtr_parts)\n",
    "                y_train = np.concatenate(ytr_parts)\n",
    "                X_test, y_test = run_Xy[test_r]\n",
    "                if len(X_test) == 0:\n",
    "                    continue\n",
    "\n",
    "                scaler  = StandardScaler()\n",
    "                X_tr_s  = scaler.fit_transform(X_train)\n",
    "                X_te_s  = scaler.transform(X_test)\n",
    "\n",
    "                try:\n",
    "                    lda = LinearDiscriminantAnalysis()\n",
    "                    lda.fit(X_tr_s, y_train)\n",
    "                    clf_accs['LDA'].append(lda.score(X_te_s, y_test))\n",
    "                except Exception:\n",
    "                    pass\n",
    "\n",
    "                for k in k_values:\n",
    "                    try:\n",
    "                        knn = KNeighborsClassifier(n_neighbors=k)\n",
    "                        knn.fit(X_tr_s, y_train)\n",
    "                        clf_accs[f'KNN-{k}'].append(knn.score(X_te_s, y_test))\n",
    "                    except Exception:\n",
    "                        pass\n",
    "\n",
    "            for clf_name, accs in clf_accs.items():\n",
    "                if accs:\n",
    "                    results.append((win_ms, ovl, clf_name,\n",
    "                                    np.mean(accs), np.std(accs)))\n",
    "            pbar.update(1)\n",
    "\n",
    "    pbar.close()\n",
    "    results_feat[subj_idx] = results\n",
    "    best = max(results, key=lambda x: x[3])\n",
    "    print(f\"Subject {subj_idx + 1} – Best: win={best[0]} ms, overlap={best[1]:.0%}, \"\n",
    "          f\"{best[2]}, accuracy = {best[3]:.3f} ± {best[4]:.3f}\")\n",
    "\n",
    "\n",
    "# ── Part A heatmaps (LDA + representative K values) ──────────────────────────\n",
    "clf_show = ['LDA', 'KNN-1', 'KNN-5', 'KNN-11']\n",
    "\n",
    "fig, axes = plt.subplots(2, 4, figsize=(22, 10))\n",
    "fig.suptitle('Part A – Feature CV Accuracy: Window × Overlap\\n'\n",
    "             '(LDA & KNN on MAV+SSC features, run-wise CV)',\n",
    "             fontsize=13, fontweight='bold')\n",
    "\n",
    "for subj_idx in range(2):\n",
    "    for col, clf_name in enumerate(clf_show):\n",
    "        ax = axes[subj_idx, col]\n",
    "        acc_grid = np.full((len(win_sizes), len(overlap_fracs)), np.nan)\n",
    "        for w, o, c, a, s in results_feat[subj_idx]:\n",
    "            if c == clf_name:\n",
    "                acc_grid[win_sizes.index(w), overlap_fracs.index(o)] = a\n",
    "\n",
    "        im = ax.imshow(acc_grid, aspect='auto', cmap='RdYlGn',\n",
    "                       vmin=0.3, vmax=1.0, origin='lower')\n",
    "        ax.set_xticks(range(len(overlap_fracs)))\n",
    "        ax.set_xticklabels([f'{o:.0%}' for o in overlap_fracs])\n",
    "        ax.set_yticks(range(len(win_sizes)))\n",
    "        ax.set_yticklabels(win_sizes)\n",
    "        ax.set_xlabel('Overlap')\n",
    "        ax.set_ylabel('Window Size (ms)')\n",
    "        ax.set_title(f'Subject {subj_idx + 1} – {clf_name}', fontsize=10)\n",
    "\n",
    "        for wi in range(len(win_sizes)):\n",
    "            for oi in range(len(overlap_fracs)):\n",
    "                v = acc_grid[wi, oi]\n",
    "                if not np.isnan(v):\n",
    "                    ax.text(oi, wi, f'{v:.2f}', ha='center', va='center',\n",
    "                            fontsize=5.5, color='black')\n",
    "        fig.colorbar(im, ax=ax, shrink=0.8, label='Accuracy')\n",
    "\n",
    "plt.tight_layout()\n",
    "plt.savefig('2_4a_cv_heatmap_features.png', bbox_inches='tight')\n",
    "plt.show()"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": 43,
   "metadata": {},
   "outputs": [
    {
     "data": {
      "image/png": "[encoded data, 419068 characters]",
      "text/plain": [
       "<Figure size 2160x2400 with 24 Axes>"
      ]
     },
     "metadata": {},
     "output_type": "display_data"
    }
   ],
   "source": [
    "# ── All KNN heatmaps: 4×3 grid (2 rows per subject, 3 k values per row) ──────\n",
    "fig, axes = plt.subplots(4, 3, figsize=(18, 20))\n",
    "fig.suptitle('Part A – KNN CV Accuracy: Window × Overlap (all k values)',\n",
    "             fontsize=14, fontweight='bold')\n",
    "\n",
    "for subj_idx in range(2):\n",
    "    for ki, k in enumerate(k_values):        # k_values = [1,3,5,7,11,15]\n",
    "        row = subj_idx * 2 + ki // 3\n",
    "        col = ki % 3\n",
    "        ax  = axes[row, col]\n",
    "\n",
    "        acc_grid = np.full((len(win_sizes), len(overlap_fracs)), np.nan)\n",
    "        for w, o, c, a, s in results_feat[subj_idx]:\n",
    "            if c == f'KNN-{k}':\n",
    "                acc_grid[win_sizes.index(w), overlap_fracs.index(o)] = a\n",
    "\n",
    "        im = ax.imshow(acc_grid, aspect='auto', cmap='RdYlGn',\n",
    "                       vmin=0.3, vmax=1.0, origin='lower')\n",
    "        ax.set_xticks(range(len(overlap_fracs)))\n",
    "        ax.set_xticklabels([f'{o:.0%}' for o in overlap_fracs])\n",
    "        ax.set_yticks(range(len(win_sizes)))\n",
    "        ax.set_yticklabels(win_sizes)\n",
    "        ax.set_xlabel('Overlap')\n",
    "        ax.set_ylabel('Window Size (ms)')\n",
    "        ax.set_title(f'Subject {subj_idx + 1} – KNN-{k}', fontsize=10)\n",
    "\n",
    "        for wi in range(len(win_sizes)):\n",
    "            for oi in range(len(overlap_fracs)):\n",
    "                v = acc_grid[wi, oi]\n",
    "                if not np.isnan(v):\n",
    "                    ax.text(oi, wi, f'{v:.2f}', ha='center', va='center',\n",
    "                            fontsize=5.5, color='black')\n",
    "        fig.colorbar(im, ax=ax, shrink=0.8, label='Accuracy')\n",
    "\n",
    "plt.tight_layout()\n",
    "plt.savefig('2_4a_knn_all_heatmaps.png', bbox_inches='tight')\n",
    "plt.show()"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "# ── 2.4.2  Part B: CNN on raw EMG windows ────────────────────────────────────\n",
    "#\n",
    "# Architecture: direct implementation of Fig. 4 from\n",
    "#   Côté-Allard et al. (2019). \"Deep Learning for Electromyographic Hand\n",
    "#   Gesture Signal Classification Using Transfer Learning.\"\n",
    "#   IEEE Trans. Neural Syst. Rehabil. Eng., 27, 760–771.\n",
    "#\n",
    "# Layer sequence (from Fig. 4):\n",
    "#   C1 : Conv2d(1→32, 3×5) → BN → PReLU → Dropout2d(0.5) → MaxPool(1×3)\n",
    "#   C3 : Conv2d(32→64, 3×5) → BN → PReLU → Dropout2d(0.5) → MaxPool(1×3)\n",
    "#   FC : Flatten → Linear(500) → BN → PReLU → Dropout(0.5) → Linear(n_classes)\n",
    "#\n",
    "# Adaptations for this dataset:\n",
    "#   - 4 electrode channels instead of 8 (padding keeps spatial dim intact)\n",
    "#   - LazyLinear for the first FC layer to handle variable win_len across the grid\n",
    "\n",
    "class EMG_CNN_Raw(nn.Module):\n",
    "    \"\"\"Fig. 4 architecture from Côté-Allard et al. (2019).\n",
    "    Input: (batch, 4, win_samp)  →  unsqueezed to (batch, 1, 4, win_samp).\n",
    "    \"\"\"\n",
    "    def __init__(self, n_classes=3):\n",
    "        super().__init__()\n",
    "\n",
    "        self.conv1 = nn.Sequential(\n",
    "            nn.Conv2d(1, 32, kernel_size=(3, 5), padding=(1, 2)),\n",
    "            nn.BatchNorm2d(32),\n",
    "            nn.PReLU(),\n",
    "            nn.Dropout2d(0.5),\n",
    "            nn.MaxPool2d(kernel_size=(1, 3)),\n",
    "        )\n",
    "        self.conv2 = nn.Sequential(\n",
    "            nn.Conv2d(32, 64, kernel_size=(3, 5), padding=(1, 2)),\n",
    "            nn.BatchNorm2d(64),\n",
    "            nn.PReLU(),\n",
    "            nn.Dropout2d(0.5),\n",
    "            nn.MaxPool2d(kernel_size=(1, 3)),\n",
    "        )\n",
    "        self.fc = nn.Sequential(\n",
    "            nn.Flatten(),\n",
    "            nn.LazyLinear(500),\n",
    "            nn.BatchNorm1d(500),\n",
    "            nn.PReLU(),\n",
    "            nn.Dropout(0.5),\n",
    "            nn.Linear(500, n_classes),\n",
    "        )\n",
    "\n",
    "    def forward(self, x):\n",
    "        x = x.unsqueeze(1)           # (batch, 1, 4, win_samp)\n",
    "        x = self.conv1(x)\n",
    "        x = self.conv2(x)\n",
    "        return self.fc(x)\n",
    "\n",
    "\n",
    "def train_cnn_raw(X_train, y_train, X_test, y_test,\n",
    "                  n_epochs=80, lr=1e-3):\n",
    "    \"\"\"Train Côté-Allard CNN.  X: (n, 4, win_samp), y: (n,).\"\"\"\n",
    "    mu    = X_train.mean(axis=(0, 2), keepdims=True)\n",
    "    sigma = X_train.std(axis=(0, 2),  keepdims=True) + 1e-8\n",
    "    Xtr = torch.tensor((X_train - mu) / sigma, dtype=torch.float32, device=device)\n",
    "    ytr = torch.tensor(y_train, dtype=torch.long,  device=device)\n",
    "    Xte = torch.tensor((X_test  - mu) / sigma, dtype=torch.float32, device=device)\n",
    "    yte = torch.tensor(y_test,  dtype=torch.long,  device=device)\n",
    "\n",
    "    model = EMG_CNN_Raw().to(device)\n",
    "\n",
    "    with torch.no_grad():\n",
    "        _ = model(Xtr[:2])\n",
    "\n",
    "    opt     = torch.optim.Adam(model.parameters(), lr=lr)\n",
    "    loss_fn = nn.CrossEntropyLoss()\n",
    "    scaler  = GradScaler('cuda', enabled=use_amp)\n",
    "\n",
    "    model.train()\n",
    "    for _ in range(n_epochs):\n",
    "        opt.zero_grad(set_to_none=True)\n",
    "        with autocast(device_type=device.type, enabled=use_amp):\n",
    "            loss = loss_fn(model(Xtr), ytr)\n",
    "        scaler.scale(loss).backward()\n",
    "        scaler.step(opt)\n",
    "        scaler.update()\n",
    "\n",
    "    model.eval()\n",
    "    with torch.no_grad(), autocast(device_type=device.type, enabled=use_amp):\n",
    "        preds = model(Xte).argmax(dim=1)\n",
    "    return (preds == yte).float().mean().item()\n",
    "\n",
    "\n",
    "# ── Part B grid search ────────────────────────────────────────────────────────\n",
    "results_cnn = {}\n",
    "\n",
    "for subj_idx, subj_runs in enumerate(filtered_data):\n",
    "    n_cv    = min(5, len(subj_runs))\n",
    "    cv_runs = subj_runs[:n_cv]\n",
    "    results = []\n",
    "    n_total = len(win_sizes) * len(overlap_fracs)\n",
    "\n",
    "    pbar = tqdm(total=n_total, desc=f'[Part B] Subject {subj_idx + 1}', unit='combo')\n",
    "\n",
    "    for win_ms in win_sizes:\n",
    "        for ovl in overlap_fracs:\n",
    "            pbar.set_postfix(win=f'{win_ms}ms', ovl=f'{ovl:.0%}')\n",
    "            run_Xy = [extract_run_Xy(r, win_ms, ovl, raw=True) for r in cv_runs]\n",
    "\n",
    "            accs = []\n",
    "            for test_r in range(n_cv):\n",
    "                train_idx = [i for i in range(n_cv) if i != test_r]\n",
    "                Xtr_parts = [run_Xy[i][0] for i in train_idx if len(run_Xy[i][0]) > 0]\n",
    "                ytr_parts = [run_Xy[i][1] for i in train_idx if len(run_Xy[i][1]) > 0]\n",
    "                if not Xtr_parts:\n",
    "                    continue\n",
    "                X_train = np.vstack(Xtr_parts)\n",
    "                y_train = np.concatenate(ytr_parts)\n",
    "                X_test, y_test = run_Xy[test_r]\n",
    "                if len(X_test) == 0:\n",
    "                    continue\n",
    "                try:\n",
    "                    acc = train_cnn_raw(X_train, y_train, X_test, y_test)\n",
    "                    accs.append(acc)\n",
    "                except Exception:\n",
    "                    pass\n",
    "            if accs:\n",
    "                results.append((win_ms, ovl, np.mean(accs), np.std(accs)))\n",
    "            pbar.update(1)\n",
    "\n",
    "    pbar.close()\n",
    "    if device.type == 'cuda':\n",
    "        torch.cuda.empty_cache()\n",
    "\n",
    "    results_cnn[subj_idx] = results\n",
    "    best = max(results, key=lambda x: x[2])\n",
    "    print(f\"Subject {subj_idx + 1} – Best: win={best[0]} ms, overlap={best[1]:.0%}, \"\n",
    "          f\"accuracy = {best[2]:.3f} ± {best[3]:.3f}\")\n",
    "\n",
    "\n",
    "# ── Part B heatmaps ───────────────────────────────────────────────────────────\n",
    "fig, axes = plt.subplots(1, 2, figsize=(12, 6))\n",
    "fig.suptitle('Part B – Raw EMG CNN CV Accuracy: Window × Overlap\\n'\n",
    "             '(Côté-Allard et al. 2019 Fig. 4, dropout=0.5, run-wise CV)',\n",
    "             fontsize=13, fontweight='bold')\n",
    "\n",
    "for subj_idx in range(2):\n",
    "    ax = axes[subj_idx]\n",
    "    acc_grid = np.full((len(win_sizes), len(overlap_fracs)), np.nan)\n",
    "    for w, o, a, s in results_cnn[subj_idx]:\n",
    "        acc_grid[win_sizes.index(w), overlap_fracs.index(o)] = a\n",
    "\n",
    "    im = ax.imshow(acc_grid, aspect='auto', cmap='RdYlGn',\n",
    "                   vmin=0.3, vmax=1.0, origin='lower')\n",
    "    ax.set_xticks(range(len(overlap_fracs)))\n",
    "    ax.set_xticklabels([f'{o:.0%}' for o in overlap_fracs])\n",
    "    ax.set_yticks(range(len(win_sizes)))\n",
    "    ax.set_yticklabels(win_sizes)\n",
    "    ax.set_xlabel('Overlap')\n",
    "    ax.set_ylabel('Window Size (ms)')\n",
    "    ax.set_title(f'Subject {subj_idx + 1}', fontsize=11)\n",
    "\n",
    "    for wi in range(len(win_sizes)):\n",
    "        for oi in range(len(overlap_fracs)):\n",
    "            v = acc_grid[wi, oi]\n",
    "            if not np.isnan(v):\n",
    "                ax.text(oi, wi, f'{v:.2f}', ha='center', va='center',\n",
    "                        fontsize=5.5, color='black')\n",
    "    fig.colorbar(im, ax=ax, shrink=0.8, label='Accuracy')\n",
    "\n",
    "plt.tight_layout()\n",
    "plt.savefig('2_4b_cv_heatmap_cnn_raw.png', bbox_inches='tight')\n",
    "plt.show()"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": 49,
   "metadata": {},
   "outputs": [
    {
     "name": "stdout",
     "output_type": "stream",
     "text": [
      "Subject 1 – CNN held-out accuracy on Run 6\n",
      "  Window: 250 ms (128 samples)  |  Overlap: 75%\n",
      "  Train windows : 5550\n",
      "  Test windows  : 1110\n",
      "  Accuracy      : 0.9117  (91.2%)\n"
     ]
    }
   ],
   "source": [
    "# ── Held-out test: Subject 1 CNN on Run 6 (win=250 ms, overlap=75%) ──────────\n",
    "# Run 6 (index 5) was excluded from the CV grid search — clean held-out set.\n",
    "# win=250 ms chosen as best-performing config for Subject 1 in Part B CV.\n",
    "\n",
    "WIN_TEST = 250   # ms\n",
    "OVL_TEST = 0.75\n",
    "\n",
    "s1_runs = filtered_data[0]\n",
    "\n",
    "run_Xy_train = [extract_run_Xy(r, WIN_TEST, OVL_TEST, raw=True) for r in s1_runs[:5]]\n",
    "X_train = np.vstack([xy[0] for xy in run_Xy_train])\n",
    "y_train = np.concatenate([xy[1] for xy in run_Xy_train])\n",
    "\n",
    "X_test, y_test = extract_run_Xy(s1_runs[5], WIN_TEST, OVL_TEST, raw=True)\n",
    "\n",
    "acc = train_cnn_raw(X_train, y_train, X_test, y_test)\n",
    "\n",
    "print(f\"Subject 1 – CNN held-out accuracy on Run 6\")\n",
    "print(f\"  Window: {WIN_TEST} ms ({ms_to_samples(WIN_TEST)} samples)  |  Overlap: {OVL_TEST:.0%}\")\n",
    "print(f\"  Train windows : {len(X_train)}\")\n",
    "print(f\"  Test windows  : {len(X_test)}\")\n",
    "print(f\"  Accuracy      : {acc:.4f}  ({acc*100:.1f}%)\")"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": 50,
   "metadata": {},
   "outputs": [
    {
     "name": "stdout",
     "output_type": "stream",
     "text": [
      "Subject 2 – CNN held-out accuracy on Run 6\n",
      "  Window: 250 ms (128 samples)  |  Overlap: 75%\n",
      "  Train windows : 5550\n",
      "  Test windows  : 1110\n",
      "  Accuracy      : 0.9901  (99.0%)\n"
     ]
    }
   ],
   "source": [
    "# ── Held-out test: Subject 2 CNN on Run 6 (win=250 ms, overlap=75%) ──────────\n",
    "# Run 6 (index 5) was excluded from the CV grid search — clean held-out set.\n",
    "# win=250 ms chosen as best-performing config for Subject 2 in Part B CV.\n",
    "\n",
    "WIN_TEST = 250   # ms\n",
    "OVL_TEST = 0.75\n",
    "\n",
    "s2_runs = filtered_data[1]\n",
    "\n",
    "run_Xy_train = [extract_run_Xy(r, WIN_TEST, OVL_TEST, raw=True) for r in s2_runs[:5]]\n",
    "X_train = np.vstack([xy[0] for xy in run_Xy_train])\n",
    "y_train = np.concatenate([xy[1] for xy in run_Xy_train])\n",
    "\n",
    "X_test, y_test = extract_run_Xy(s2_runs[5], WIN_TEST, OVL_TEST, raw=True)\n",
    "\n",
    "acc = train_cnn_raw(X_train, y_train, X_test, y_test)\n",
    "\n",
    "print(f\"Subject 2 – CNN held-out accuracy on Run 6\")\n",
    "print(f\"  Window: {WIN_TEST} ms ({ms_to_samples(WIN_TEST)} samples)  |  Overlap: {OVL_TEST:.0%}\")\n",
    "print(f\"  Train windows : {len(X_train)}\")\n",
    "print(f\"  Test windows  : {len(X_test)}\")\n",
    "print(f\"  Accuracy      : {acc:.4f}  ({acc*100:.1f}%)\")"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": 51,
   "metadata": {},
   "outputs": [
    {
     "name": "stdout",
     "output_type": "stream",
     "text": [
      "Subject 1 – LDA held-out accuracy on Run 6\n",
      "  Window: 350 ms (179 samples)  |  Overlap: 75%\n",
      "  Train windows : 3900\n",
      "  Test windows  : 780\n",
      "  Accuracy      : 0.8179  (81.8%)\n"
     ]
    }
   ],
   "source": [
    "# ── Held-out test: Subject 1 LDA on Run 6 (win=350 ms, overlap=75%) ──────────\n",
    "# Run 6 (index 5) was excluded from the CV grid search — clean held-out set.\n",
    "\n",
    "WIN_TEST = 350   # ms\n",
    "OVL_TEST = 0.75\n",
    "\n",
    "s1_runs = filtered_data[0]\n",
    "\n",
    "run_Xy = [extract_run_Xy(r, WIN_TEST, OVL_TEST, raw=False) for r in s1_runs[:5]]\n",
    "X_train = np.vstack([xy[0] for xy in run_Xy])\n",
    "y_train = np.concatenate([xy[1] for xy in run_Xy])\n",
    "\n",
    "X_test, y_test = extract_run_Xy(s1_runs[5], WIN_TEST, OVL_TEST, raw=False)\n",
    "\n",
    "scaler  = StandardScaler()\n",
    "X_tr_s  = scaler.fit_transform(X_train)\n",
    "X_te_s  = scaler.transform(X_test)\n",
    "\n",
    "lda = LinearDiscriminantAnalysis()\n",
    "lda.fit(X_tr_s, y_train)\n",
    "acc = lda.score(X_te_s, y_test)\n",
    "\n",
    "print(f\"Subject 1 – LDA held-out accuracy on Run 6\")\n",
    "print(f\"  Window: {WIN_TEST} ms ({ms_to_samples(WIN_TEST)} samples)  |  Overlap: {OVL_TEST:.0%}\")\n",
    "print(f\"  Train windows : {len(X_train)}\")\n",
    "print(f\"  Test windows  : {len(X_test)}\")\n",
    "print(f\"  Accuracy      : {acc:.4f}  ({acc*100:.1f}%)\")"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": 52,
   "metadata": {},
   "outputs": [
    {
     "name": "stdout",
     "output_type": "stream",
     "text": [
      "Subject 2 – LDA held-out accuracy on Run 6\n",
      "  Window: 350 ms (179 samples)  |  Overlap: 75%\n",
      "  Train windows : 3900\n",
      "  Test windows  : 780\n",
      "  Accuracy      : 0.9821  (98.2%)\n"
     ]
    }
   ],
   "source": [
    "# ── Held-out test: Subject 2 LDA on Run 6 (win=350 ms, overlap=75%) ──────────\n",
    "# Run 6 (index 5) was excluded from the CV grid search — clean held-out set.\n",
    "\n",
    "WIN_TEST = 350   # ms\n",
    "OVL_TEST = 0.75\n",
    "\n",
    "s2_runs = filtered_data[1]\n",
    "\n",
    "run_Xy = [extract_run_Xy(r, WIN_TEST, OVL_TEST, raw=False) for r in s2_runs[:5]]\n",
    "X_train = np.vstack([xy[0] for xy in run_Xy])\n",
    "y_train = np.concatenate([xy[1] for xy in run_Xy])\n",
    "\n",
    "X_test, y_test = extract_run_Xy(s2_runs[5], WIN_TEST, OVL_TEST, raw=False)\n",
    "\n",
    "scaler  = StandardScaler()\n",
    "X_tr_s  = scaler.fit_transform(X_train)\n",
    "X_te_s  = scaler.transform(X_test)\n",
    "\n",
    "lda = LinearDiscriminantAnalysis()\n",
    "lda.fit(X_tr_s, y_train)\n",
    "acc = lda.score(X_te_s, y_test)\n",
    "\n",
    "print(f\"Subject 2 – LDA held-out accuracy on Run 6\")\n",
    "print(f\"  Window: {WIN_TEST} ms ({ms_to_samples(WIN_TEST)} samples)  |  Overlap: {OVL_TEST:.0%}\")\n",
    "print(f\"  Train windows : {len(X_train)}\")\n",
    "print(f\"  Test windows  : {len(X_test)}\")\n",
    "print(f\"  Accuracy      : {acc:.4f}  ({acc*100:.1f}%)\")"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "# Test against run 6 of opposite subject.\n",
    "\n",
    "WIN_TEST = 350   # ms\n",
    "OVL_TEST = 0.75\n",
    "\n",
    "s1_runs = filtered_data[0]\n",
    "s2_runs = filtered_data[1]\n",
    "\n",
    "run_Xy1 = [extract_run_Xy(r, WIN_TEST, OVL_TEST, raw=False) for r in s1_runs[:5]]\n",
    "X_train1 = np.vstack([xy[0] for xy in run_Xy1])\n",
    "y_train1 = np.concatenate([xy[1] for xy in run_Xy1])\n",
    "\n",
    "run_Xy2 = [extract_run_Xy(r, WIN_TEST, OVL_TEST, raw=False) for r in s2_runs[:5]]\n",
    "X_train2 = np.vstack([xy[0] for xy in run_Xy2])\n",
    "y_train2 = np.concatenate([xy[1] for xy in run_Xy2])\n",
    "\n",
    "X_test1, y_test1 = extract_run_Xy(s1_runs[5], WIN_TEST, OVL_TEST, raw=False)\n",
    "X_test2, y_test2 = extract_run_Xy(s2_runs[5], WIN_TEST, OVL_TEST, raw=False)\n",
    "\n",
    "scaler  = StandardScaler()\n",
    "X_tr_s1  = scaler.fit_transform(X_train1)\n",
    "X_te_s1  = scaler.transform(X_test1)\n",
    "\n",
    "X_tr_s2  = scaler.fit_transform(X_train2)\n",
    "X_te_s2  = scaler.transform(X_test2)\n",
    "\n",
    "lda1 = LinearDiscriminantAnalysis()\n",
    "lda1.fit(X_tr_s1, y_train1)\n",
    "acc = lda1.score(X_te_s2, y_test2)\n",
    "\n",
    "lda2 = LinearDiscriminantAnalysis()\n",
    "lda2.fit(X_tr_s2, y_train2)\n",
    "acc = lda2.score(X_te_s1, y_test1)\n",
    "\n",
    "print(f\"Subject 1 – LDA held-out accuracy on Run 6\")\n",
    "print(f\"  Window: {WIN_TEST} ms ({ms_to_samples(WIN_TEST)} samples)  |  Overlap: {OVL_TEST:.0%}\")\n",
    "print(f\"  Train windows : {len(X_train)}\")\n",
    "print(f\"  Test windows  : {len(X_test)}\")\n",
    "print(f\"  Accuracy      : {acc:.4f}  ({acc*100:.1f}%)\")"
   ]
  }
 ],
 "metadata": {
  "kernelspec": {
   "display_name": "venv (3.13.12)",
   "language": "python",
   "name": "python3"
  },
  "language_info": {
   "codemirror_mode": {
    "name": "ipython",
    "version": 3
   },
   "file_extension": ".py",
   "mimetype": "text/x-python",
   "name": "python",
   "nbconvert_exporter": "python",
   "pygments_lexer": "ipython3",
   "version": "3.13.12"
  }
 },
 "nbformat": 4,
 "nbformat_minor": 4
}
