{
 "cells": [
  {
   "cell_type": "markdown",
   "id": "d8960e56",
   "metadata": {},
   "source": [
    "# Motor Imagery Decoder — Train OFFLINE, Evaluate ONLINE (FES vs NOFES)\n",
    "\n",
    "For each subject × offline-session, train a CSP + LDA classifier on the OFFLINE recording, then apply it to the two matched ONLINE sessions.\n",
    "\n",
    "**Pair 1:** train on `S001 OFFLINE_FES` → test on `S002 ONLINE_FES` and `S003 ONLINE_NOFES`\n",
    "**Pair 2:** train on `S004 OFFLINE_NOFES` → test on `S006 ONLINE_FES` and `S005 ONLINE_NOFES`\n",
    "\n",
    "**Metrics reported per (subject × pair × condition):**\n",
    "1. **Classification accuracy** — fraction of cued trials correctly classified\n",
    "2. **Classification amplitude** — mean |LDA decision-function value|\n",
    "3. **SNR** — (a) Fisher ratio of the LDA projection on online data, and (b) mu-band power ratio REST / MI over motor channels C3/Cz/C4"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": 1,
   "id": "578c9128",
   "metadata": {
    "execution": {
     "iopub.execute_input": "2026-04-22T03:09:19.274466Z",
     "iopub.status.busy": "2026-04-22T03:09:19.273226Z",
     "iopub.status.idle": "2026-04-22T03:09:19.280048Z",
     "shell.execute_reply": "2026-04-22T03:09:19.279206Z"
    }
   },
   "outputs": [],
   "source": [
    "# Install dependencies if needed\n",
    "# !pip install pyxdf mne scipy numpy matplotlib"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": 2,
   "id": "857b22c0",
   "metadata": {
    "execution": {
     "iopub.execute_input": "2026-04-22T03:09:19.283002Z",
     "iopub.status.busy": "2026-04-22T03:09:19.282756Z",
     "iopub.status.idle": "2026-04-22T03:09:20.342432Z",
     "shell.execute_reply": "2026-04-22T03:09:20.341961Z"
    }
   },
   "outputs": [],
   "source": [
    "import os\n",
    "import re\n",
    "import glob\n",
    "import numpy as np\n",
    "import matplotlib.pyplot as plt\n",
    "from matplotlib.patches import Patch\n",
    "import pyxdf\n",
    "from scipy.signal import welch, butter, filtfilt, iirnotch\n",
    "from scipy.linalg import eigh\n",
    "\n",
    "plt.rcParams.update({'font.size': 11, 'figure.dpi': 120})"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "fe68bf0e",
   "metadata": {},
   "source": [
    "## Configuration"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": 3,
   "id": "dc4b2c55",
   "metadata": {
    "execution": {
     "iopub.execute_input": "2026-04-22T03:09:20.343870Z",
     "iopub.status.busy": "2026-04-22T03:09:20.343760Z",
     "iopub.status.idle": "2026-04-22T03:09:20.346854Z",
     "shell.execute_reply": "2026-04-22T03:09:20.346506Z"
    }
   },
   "outputs": [],
   "source": [
    "DATA_DIR = os.path.join(os.path.dirname(os.path.abspath('__file__')), 'Group 2 - Glove')\n",
    "\n",
    "# Marker codes (from experiment trigger table)\n",
    "MI_BEGIN       = 200\n",
    "MI_END         = 220\n",
    "MI_EARLYSTOP   = 240   # online only: live classifier fired → successful MI detection\n",
    "REST_BEGIN     = 100\n",
    "REST_END       = 120\n",
    "REST_EARLYSTOP = 140   # online only: live classifier fired → successful REST detection\n",
    "ROBOT_BEGIN    = 300\n",
    "ROBOT_END      = 320\n",
    "\n",
    "TARGET_MARKERS = [100, 120, 140, 200, 220, 240]\n",
    "\n",
    "T_PRE  = -1.0\n",
    "T_POST =  5.0\n",
    "\n",
    "# ── Preprocessing ────────────────────────────────────────────────────────────\n",
    "NOTCH_FREQ     = 60.0\n",
    "NOTCH_Q        = 30\n",
    "BP_LO, BP_HI   = 8.0, 30.0\n",
    "USE_CAR        = True\n",
    "PTP_REJECT_UV  = 100.0\n",
    "\n",
    "N_CSP = 4\n",
    "\n",
    "NON_EEG = {'AUX1', 'AUX2', 'AUX3', 'AUX7', 'AUX8', 'AUX9', 'TRIGGER'}\n",
    "RENAME  = {'FP1':'Fp1','FPZ':'Fpz','FP2':'Fp2','FZ':'Fz','CZ':'Cz',\n",
    "           'PZ':'Pz','POZ':'POz','OZ':'Oz'}\n",
    "\n",
    "MOTOR_CH = ['C3', 'Cz', 'C4']\n",
    "MU_BAND  = (8, 13)\n",
    "\n",
    "# Sessions are stored with compound key \"{ses_id}_{kind}_{stim}\" and looked up\n",
    "# positionally: idx=0 is the first session of that (kind, stim) type sorted by\n",
    "# key, idx=1 is the second. This handles subjects like 002 where OFFLINE and\n",
    "# ONLINE sessions share the same ses-Sxxx number in the filename.\n",
    "PAIRS = [\n",
    "    {'name':  'Pair1 (train=OFFLINE_FES)',\n",
    "     'train':        {'kind': 'OFFLINE', 'stim': 'FES',   'idx': 0},\n",
    "     'online_fes':   {'kind': 'ONLINE',  'stim': 'FES',   'idx': 0},\n",
    "     'online_nofes': {'kind': 'ONLINE',  'stim': 'NOFES', 'idx': 0},\n",
    "    },\n",
    "    {'name':  'Pair2 (train=OFFLINE_NOFES)',\n",
    "     'train':        {'kind': 'OFFLINE', 'stim': 'NOFES', 'idx': 0},\n",
    "     'online_fes':   {'kind': 'ONLINE',  'stim': 'FES',   'idx': 1},\n",
    "     'online_nofes': {'kind': 'ONLINE',  'stim': 'NOFES', 'idx': 1},\n",
    "    },\n",
    "]"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "21a40df3",
   "metadata": {},
   "source": [
    "## Helper Functions"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": 4,
   "id": "e798b039",
   "metadata": {
    "execution": {
     "iopub.execute_input": "2026-04-22T03:09:20.348424Z",
     "iopub.status.busy": "2026-04-22T03:09:20.348357Z",
     "iopub.status.idle": "2026-04-22T03:09:20.361153Z",
     "shell.execute_reply": "2026-04-22T03:09:20.360830Z"
    }
   },
   "outputs": [],
   "source": [
    "# ── XDF loading + session parsing ─────────────────────────────────────────────\n",
    "\n",
    "def get_channel_names_from_xdf(eeg_stream):\n",
    "    ch_desc  = eeg_stream['info']['desc'][0]\n",
    "    channels = ch_desc.get('channels', [{}])[0].get('channel', [])\n",
    "    return [ch['label'][0] for ch in channels]\n",
    "\n",
    "\n",
    "_SESSION_RE = re.compile(r'ses-(S\\d+)(O[A-Z]*LINE)_(FES|NOFES)')\n",
    "_SUBJ_RE    = re.compile(r'SUBJ_(\\d+)')\n",
    "\n",
    "def parse_session(path):\n",
    "    base   = os.path.basename(path)\n",
    "    m_subj = _SUBJ_RE.search(base)\n",
    "    m_ses  = _SESSION_RE.search(base)\n",
    "    if not (m_subj and m_ses):\n",
    "        return None\n",
    "    ses_id, raw_kind, stim = m_ses.group(1), m_ses.group(2), m_ses.group(3)\n",
    "    kind = 'OFFLINE' if 'OFF' in raw_kind else 'ONLINE'\n",
    "    return m_subj.group(1), ses_id, kind, stim\n",
    "\n",
    "\n",
    "def load_xdf_file(filepath):\n",
    "    streams, _ = pyxdf.load_xdf(filepath)\n",
    "\n",
    "    eeg_stream = marker_stream = None\n",
    "    for s in streams:\n",
    "        stype = s['info']['type'][0].lower()\n",
    "        if stype == 'eeg':       eeg_stream    = s\n",
    "        elif stype == 'markers': marker_stream = s\n",
    "    if eeg_stream is None or marker_stream is None:\n",
    "        eeg_stream    = streams[0]\n",
    "        marker_stream = streams[1] if len(streams) > 1 else None\n",
    "\n",
    "    eeg_timestamps = np.array(eeg_stream['time_stamps'])\n",
    "    eeg_data       = np.array(eeg_stream['time_series']).T\n",
    "    channel_names  = get_channel_names_from_xdf(eeg_stream)\n",
    "    sfreq          = float(eeg_stream['info']['nominal_srate'][0])\n",
    "\n",
    "    valid_idx     = [i for i, ch in enumerate(channel_names) if ch not in NON_EEG]\n",
    "    channel_names = [channel_names[i] for i in valid_idx]\n",
    "    eeg_data      = eeg_data[valid_idx, :]\n",
    "    channel_names = [RENAME.get(ch, ch) for ch in channel_names]\n",
    "\n",
    "    ts_arr      = np.asarray(marker_stream['time_series'], dtype=float)\n",
    "    marker_data = ts_arr[:, 0].astype(int)\n",
    "    marker_ts   = ts_arr[:, 1]\n",
    "    keep        = np.isin(marker_data, TARGET_MARKERS)\n",
    "    return eeg_data, eeg_timestamps, marker_data[keep], marker_ts[keep], channel_names, sfreq\n",
    "\n",
    "\n",
    "# ── Preprocessing primitives ─────────────────────────────────────────────────\n",
    "\n",
    "def notch_filter(data, freq, sfreq, Q=NOTCH_Q):\n",
    "    b, a = iirnotch(freq, Q, fs=sfreq)\n",
    "    return filtfilt(b, a, data, axis=-1)\n",
    "\n",
    "\n",
    "def car(data):\n",
    "    return data - data.mean(axis=0, keepdims=True)\n",
    "\n",
    "\n",
    "def bandpass(data, lo, hi, sfreq, order=4):\n",
    "    nyq  = sfreq / 2.0\n",
    "    b, a = butter(order, [max(lo, 0.5) / nyq, min(hi, nyq - 0.1) / nyq], btype='band')\n",
    "    return filtfilt(b, a, data, axis=-1)\n",
    "\n",
    "\n",
    "def reject_by_ptp(X, thresh_uv=PTP_REJECT_UV):\n",
    "    if X.size == 0:\n",
    "        return np.zeros(0, dtype=bool)\n",
    "    ptp = X.max(axis=-1) - X.min(axis=-1)\n",
    "    return ptp.max(axis=-1) < thresh_uv\n",
    "\n",
    "\n",
    "def extract_epochs(eeg_data, eeg_ts, marker_data, marker_ts, sfreq, begin_code,\n",
    "                   t_pre=T_PRE, t_post=T_POST):\n",
    "    epochs = []\n",
    "    n_pre  = int(abs(t_pre) * sfreq)\n",
    "    for bi in np.where(marker_data == begin_code)[0]:\n",
    "        t_start = marker_ts[bi]\n",
    "        i0 = np.searchsorted(eeg_ts, t_start + t_pre)\n",
    "        i1 = np.searchsorted(eeg_ts, t_start + t_post)\n",
    "        if i0 < 0 or i1 > eeg_data.shape[1]:\n",
    "            continue\n",
    "        ep = eeg_data[:, i0:i1].copy()\n",
    "        if ep.shape[1] > n_pre:\n",
    "            ep -= ep[:, :n_pre].mean(axis=1, keepdims=True)\n",
    "        epochs.append(ep)\n",
    "    if not epochs:\n",
    "        return np.empty((0, eeg_data.shape[0], 0))\n",
    "    min_len = min(e.shape[-1] for e in epochs)\n",
    "    return np.stack([e[:, :min_len] for e in epochs])\n",
    "\n",
    "\n",
    "def marker_accuracy(marker_data):\n",
    "    \"\"\"Online accuracy from EARLYSTOP markers: (MI_EARLYSTOP + REST_EARLYSTOP) / total trials.\n",
    "    Returns None for offline sessions (no EARLYSTOP markers present).\n",
    "    \"\"\"\n",
    "    n_mi_trials   = int((marker_data == MI_BEGIN).sum())\n",
    "    n_rest_trials = int((marker_data == REST_BEGIN).sum())\n",
    "    n_total = n_mi_trials + n_rest_trials\n",
    "    if n_total == 0:\n",
    "        return None\n",
    "    n_mi_correct   = int((marker_data == MI_EARLYSTOP).sum())\n",
    "    n_rest_correct = int((marker_data == REST_EARLYSTOP).sum())\n",
    "    # If no EARLYSTOP markers present at all, this is an offline session\n",
    "    if n_mi_correct + n_rest_correct == 0:\n",
    "        return None\n",
    "    return (n_mi_correct + n_rest_correct) / n_total\n",
    "\n",
    "\n",
    "def load_session_epochs(filepath):\n",
    "    \"\"\"Preprocessing pipeline: notch → CAR → bandpass → epoch → PTP-reject.\n",
    "    Returns X, y, ch_names, sfreq, n_rejected, mk_acc\n",
    "    where mk_acc = marker-based online accuracy (None for offline sessions).\n",
    "    \"\"\"\n",
    "    eeg, eeg_ts, mk, mk_ts, ch_names, sfreq = load_xdf_file(filepath)\n",
    "\n",
    "    # Marker-based accuracy before any EEG processing (counts all trials as seen by the live system)\n",
    "    mk_acc = marker_accuracy(mk)\n",
    "\n",
    "    eeg = notch_filter(eeg, NOTCH_FREQ, sfreq)\n",
    "    if USE_CAR:\n",
    "        eeg = car(eeg)\n",
    "    eeg_bp = bandpass(eeg, BP_LO, BP_HI, sfreq)\n",
    "\n",
    "    mi   = extract_epochs(eeg_bp, eeg_ts, mk, mk_ts, sfreq, MI_BEGIN)\n",
    "    rest = extract_epochs(eeg_bp, eeg_ts, mk, mk_ts, sfreq, REST_BEGIN)\n",
    "\n",
    "    n_pre = int(abs(T_PRE) * sfreq)\n",
    "    if mi.shape[-1]   > n_pre: mi   = mi[..., n_pre:]\n",
    "    if rest.shape[-1] > n_pre: rest = rest[..., n_pre:]\n",
    "\n",
    "    n = min(mi.shape[-1], rest.shape[-1]) if (mi.size and rest.size) else 0\n",
    "    mi, rest = mi[..., :n], rest[..., :n]\n",
    "\n",
    "    n0_mi, n0_rest = len(mi), len(rest)\n",
    "    mi   = mi[reject_by_ptp(mi)]\n",
    "    rest = rest[reject_by_ptp(rest)]\n",
    "    n_rejected = (n0_mi - len(mi)) + (n0_rest - len(rest))\n",
    "\n",
    "    X = np.concatenate([mi, rest], axis=0) if (len(mi) or len(rest)) else np.empty((0, len(ch_names), 0))\n",
    "    y = np.concatenate([np.ones(len(mi), int), np.zeros(len(rest), int)])\n",
    "    return X, y, ch_names, sfreq, n_rejected, mk_acc\n",
    "\n",
    "\n",
    "# ── CSP + LDA (2-class, numpy/scipy only) ────────────────────────────────────\n",
    "\n",
    "def _mean_cov(X):\n",
    "    covs = np.einsum('ijk,ilk->ijl', X, X)\n",
    "    covs /= np.trace(covs, axis1=1, axis2=2)[:, None, None]\n",
    "    return covs.mean(axis=0)\n",
    "\n",
    "\n",
    "class CSPLDA:\n",
    "    \"\"\"CSP log-var features + LDA. Ramoser 2000; Blankertz 2008.\n",
    "    Ledoit-Wolf shrinkage keeps the generalized eigenproblem well-posed after CAR.\n",
    "    \"\"\"\n",
    "\n",
    "    def __init__(self, n_csp=N_CSP, cov_shrink=0.05, lda_reg=1e-4):\n",
    "        self.n_csp      = n_csp\n",
    "        self.cov_shrink = cov_shrink\n",
    "        self.lda_reg    = lda_reg\n",
    "\n",
    "    def fit(self, X, y):\n",
    "        assert set(np.unique(y)) == {0, 1}\n",
    "        C1 = _mean_cov(X[y == 1])\n",
    "        C0 = _mean_cov(X[y == 0])\n",
    "        n_ch = C1.shape[0]\n",
    "        s = self.cov_shrink\n",
    "        C1 = (1 - s) * C1 + s * (np.trace(C1) / n_ch) * np.eye(n_ch)\n",
    "        C0 = (1 - s) * C0 + s * (np.trace(C0) / n_ch) * np.eye(n_ch)\n",
    "        evals, evecs = eigh(C1, C0 + C1)\n",
    "        order  = np.argsort(evals)\n",
    "        k      = self.n_csp // 2\n",
    "        self.filters_ = np.concatenate([evecs[:, order[:k]],\n",
    "                                        evecs[:, order[-k:]]], axis=1).T\n",
    "        F = self._features(X)\n",
    "        mu1, mu0 = F[y == 1].mean(0), F[y == 0].mean(0)\n",
    "        Sw = np.cov(F[y == 1].T, ddof=1) + np.cov(F[y == 0].T, ddof=1)\n",
    "        Sw += self.lda_reg * np.eye(Sw.shape[0])\n",
    "        self.coef_      = np.linalg.solve(Sw, mu1 - mu0)\n",
    "        self.intercept_ = -self.coef_ @ ((mu1 + mu0) / 2)\n",
    "        return self\n",
    "\n",
    "    def _features(self, X):\n",
    "        Z   = np.einsum('fc,ncs->nfs', self.filters_, X)\n",
    "        var = Z.var(axis=-1, ddof=1)\n",
    "        return np.log(var / var.sum(axis=1, keepdims=True))\n",
    "\n",
    "    def decision_function(self, X):\n",
    "        return self._features(X) @ self.coef_ + self.intercept_\n",
    "\n",
    "    def predict(self, X):\n",
    "        return (self.decision_function(X) > 0).astype(int)\n",
    "\n",
    "\n",
    "# ── Evaluation metrics ───────────────────────────────────────────────────────\n",
    "\n",
    "def evaluate(clf, X, y):\n",
    "    margin = clf.decision_function(X)\n",
    "    pred   = (margin > 0).astype(int)\n",
    "    amp    = np.abs(margin).mean()\n",
    "    m1, m0 = margin[y == 1], margin[y == 0]\n",
    "    fisher = (m1.mean() - m0.mean()) ** 2 / (m1.var(ddof=1) + m0.var(ddof=1) + 1e-30)\n",
    "    return dict(amp=amp, fisher=fisher, margin=margin, y=y, pred=pred)\n",
    "\n",
    "\n",
    "def spectral_snr(X, y, ch_idx, sfreq, band=MU_BAND):\n",
    "    def band_pwr(sig):\n",
    "        f, p = welch(sig, fs=sfreq,\n",
    "                     nperseg=min(int(sfreq * 2), sig.shape[-1]),\n",
    "                     noverlap=int(sfreq), axis=-1)\n",
    "        m = (f >= band[0]) & (f < band[1])\n",
    "        return np.trapezoid(p[..., m], f[m], axis=-1).mean()\n",
    "    return band_pwr(X[y == 0][:, ch_idx, :]) / (band_pwr(X[y == 1][:, ch_idx, :]) + 1e-30)"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "98d225db",
   "metadata": {},
   "source": [
    "## Load Data"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": 5,
   "id": "d266216b",
   "metadata": {
    "execution": {
     "iopub.execute_input": "2026-04-22T03:09:20.362577Z",
     "iopub.status.busy": "2026-04-22T03:09:20.362504Z",
     "iopub.status.idle": "2026-04-22T03:09:59.345624Z",
     "shell.execute_reply": "2026-04-22T03:09:59.271366Z"
    }
   },
   "outputs": [
    {
     "name": "stdout",
     "output_type": "stream",
     "text": [
      "Found 24 XDF file(s).\n",
      "Preprocessing: notch 60 Hz → CAR → bandpass 8–30 Hz → baseline-correct → PTP-reject @ 100 µV\n",
      "\n"
     ]
    },
    {
     "name": "stdout",
     "output_type": "stream",
     "text": [
      "  002/S001  OFFLINE FES    n= 85  (MI=43, REST=42)  rej=5\n"
     ]
    },
    {
     "name": "stdout",
     "output_type": "stream",
     "text": [
      "  002/S002  ONLINE  FES    n= 53  (MI=27, REST=26)  rej=7  mk_acc=0.883\n"
     ]
    },
    {
     "name": "stdout",
     "output_type": "stream",
     "text": [
      "  002/S003  ONLINE  NOFES  n= 52  (MI=26, REST=26)  rej=8  mk_acc=0.833\n"
     ]
    },
    {
     "name": "stdout",
     "output_type": "stream",
     "text": [
      "  002/S004  OFFLINE NOFES  n= 90  (MI=45, REST=45)  rej=0\n"
     ]
    },
    {
     "name": "stdout",
     "output_type": "stream",
     "text": [
      "  002/S005  ONLINE  NOFES  n= 60  (MI=30, REST=30)  rej=0  mk_acc=0.850\n"
     ]
    },
    {
     "name": "stdout",
     "output_type": "stream",
     "text": [
      "  002/S006  ONLINE  FES    n= 56  (MI=27, REST=29)  rej=4  mk_acc=0.917\n"
     ]
    },
    {
     "name": "stdout",
     "output_type": "stream",
     "text": [
      "  003/S001  OFFLINE FES    n= 89  (MI=44, REST=45)  rej=1\n"
     ]
    },
    {
     "name": "stdout",
     "output_type": "stream",
     "text": [
      "  003/S002  ONLINE  FES    n= 59  (MI=29, REST=30)  rej=1  mk_acc=0.750\n"
     ]
    },
    {
     "name": "stdout",
     "output_type": "stream",
     "text": [
      "  003/S003  ONLINE  NOFES  n= 38  (MI=17, REST=21)  rej=0  mk_acc=0.763\n"
     ]
    },
    {
     "name": "stdout",
     "output_type": "stream",
     "text": [
      "  003/S004  OFFLINE NOFES  n= 86  (MI=42, REST=44)  rej=4\n"
     ]
    },
    {
     "name": "stdout",
     "output_type": "stream",
     "text": [
      "  003/S005  ONLINE  NOFES  n= 43  (MI=19, REST=24)  rej=17  mk_acc=0.717\n"
     ]
    },
    {
     "name": "stdout",
     "output_type": "stream",
     "text": [
      "  003/S006  ONLINE  FES    n= 52  (MI=23, REST=29)  rej=8  mk_acc=0.767\n"
     ]
    },
    {
     "name": "stdout",
     "output_type": "stream",
     "text": [
      "  005/S001  OFFLINE FES    n= 90  (MI=45, REST=45)  rej=0\n"
     ]
    },
    {
     "name": "stdout",
     "output_type": "stream",
     "text": [
      "  005/S002  ONLINE  FES    n= 60  (MI=30, REST=30)  rej=0  mk_acc=0.800\n"
     ]
    },
    {
     "name": "stdout",
     "output_type": "stream",
     "text": [
      "  005/S003  ONLINE  NOFES  n= 59  (MI=29, REST=30)  rej=1  mk_acc=0.933\n"
     ]
    },
    {
     "name": "stdout",
     "output_type": "stream",
     "text": [
      "  005/S004  OFFLINE NOFES  n= 89  (MI=44, REST=45)  rej=1\n"
     ]
    },
    {
     "name": "stdout",
     "output_type": "stream",
     "text": [
      "  005/S005  ONLINE  NOFES  n= 58  (MI=28, REST=30)  rej=2  mk_acc=0.783\n"
     ]
    },
    {
     "name": "stdout",
     "output_type": "stream",
     "text": [
      "  005/S006  ONLINE  FES    n= 59  (MI=30, REST=29)  rej=1  mk_acc=0.917\n"
     ]
    },
    {
     "name": "stdout",
     "output_type": "stream",
     "text": [
      "  009/S001  OFFLINE FES    n= 57  (MI=33, REST=24)  rej=33\n"
     ]
    },
    {
     "name": "stdout",
     "output_type": "stream",
     "text": [
      "  009/S002  ONLINE  FES    n= 42  (MI=21, REST=21)  rej=18  mk_acc=0.717\n"
     ]
    },
    {
     "name": "stdout",
     "output_type": "stream",
     "text": [
      "  009/S003  ONLINE  NOFES  n=  1  (MI=1, REST=0)  rej=59  mk_acc=0.717\n"
     ]
    },
    {
     "name": "stdout",
     "output_type": "stream",
     "text": [
      "  009/S004  OFFLINE NOFES  n= 86  (MI=42, REST=44)  rej=4\n"
     ]
    },
    {
     "name": "stdout",
     "output_type": "stream",
     "text": [
      "  009/S005  ONLINE  NOFES  n= 60  (MI=30, REST=30)  rej=0  mk_acc=0.850\n"
     ]
    },
    {
     "name": "stdout",
     "output_type": "stream",
     "text": [
      "  009/S006  ONLINE  FES    n= 50  (MI=26, REST=24)  rej=10  mk_acc=0.817\n",
      "\n",
      "Loaded 4 subject(s): ['002', '003', '005', '009']  |  total artifact-rejected epochs: 184\n"
     ]
    }
   ],
   "source": [
    "xdf_files = sorted(glob.glob(os.path.join(DATA_DIR, '*.xdf')))\n",
    "print(f'Found {len(xdf_files)} XDF file(s).')\n",
    "print(f'Preprocessing: notch {NOTCH_FREQ:.0f} Hz → '\n",
    "      f'{\"CAR → \" if USE_CAR else \"\"}bandpass {BP_LO:.0f}–{BP_HI:.0f} Hz → '\n",
    "      f'baseline-correct → PTP-reject @ {PTP_REJECT_UV:.0f} µV\\n')\n",
    "\n",
    "sessions  = {}\n",
    "total_rej = 0\n",
    "\n",
    "for fp in xdf_files:\n",
    "    meta = parse_session(fp)\n",
    "    if meta is None:\n",
    "        print(f'  SKIP (unparsed): {os.path.basename(fp)}')\n",
    "        continue\n",
    "    subj, ses_id, kind, stim = meta\n",
    "    try:\n",
    "        X, y, ch_names, sfreq, n_rej, mk_acc = load_session_epochs(fp)\n",
    "    except Exception as e:\n",
    "        print(f'  ERROR {os.path.basename(fp)}: {e}')\n",
    "        continue\n",
    "\n",
    "    # Compound key prevents collision when multiple session types share the same ses_id\n",
    "    key = f'{ses_id}_{kind}_{stim}'\n",
    "    sessions.setdefault(subj, {})[key] = dict(\n",
    "        X=X, y=y, kind=kind, stim=stim,\n",
    "        ch_names=ch_names, sfreq=sfreq,\n",
    "        mk_acc=mk_acc, file=os.path.basename(fp))\n",
    "    total_rej += n_rej\n",
    "\n",
    "    acc_str = f'  mk_acc={mk_acc:.3f}' if mk_acc is not None else ''\n",
    "    print(f'  {subj}/{ses_id}  {kind:<7} {stim:<5}  '\n",
    "          f'n={len(y):3d}  (MI={int(y.sum())}, REST={int((1-y).sum())})  '\n",
    "          f'rej={n_rej}{acc_str}')\n",
    "\n",
    "subjects = sorted(sessions.keys())\n",
    "print(f'\\nLoaded {len(subjects)} subject(s): {subjects}  |  '\n",
    "      f'total artifact-rejected epochs: {total_rej}')\n",
    "\n",
    "\n",
    "def pick_session(subj_ses, kind, stim, idx):\n",
    "    \"\"\"Return the idx-th session matching (kind, stim), sorted by key. None if not enough.\"\"\"\n",
    "    matches = sorted(\n",
    "        (v for v in subj_ses.values() if v['kind'] == kind and v['stim'] == stim),\n",
    "        key=lambda v: v['file']   # alphabetical file order = recording order\n",
    "    )\n",
    "    return matches[idx] if idx < len(matches) else None"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "7b8c8bea",
   "metadata": {},
   "source": [
    "## Verify Session Layout"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": 6,
   "id": "611baf23",
   "metadata": {
    "execution": {
     "iopub.execute_input": "2026-04-22T03:09:59.485105Z",
     "iopub.status.busy": "2026-04-22T03:09:59.481587Z",
     "iopub.status.idle": "2026-04-22T03:09:59.616890Z",
     "shell.execute_reply": "2026-04-22T03:09:59.612929Z"
    }
   },
   "outputs": [
    {
     "name": "stdout",
     "output_type": "stream",
     "text": [
      "Channels (32): ['Fp1', 'Fpz', 'Fp2', 'F7', 'F3', 'Fz', 'F4', 'F8', 'FC5', 'FC1', 'FC2', 'FC6', 'M1', 'T7', 'C3', 'Cz', 'C4', 'T8', 'M2', 'CP5', 'CP1', 'CP2', 'CP6', 'P7', 'P3', 'Pz', 'P4', 'P8', 'POz', 'O1', 'Oz', 'O2']\n",
      "Sampling rate: 512.0 Hz\n",
      "Motor channels ['C3', 'Cz', 'C4'] → indices [14, 15, 16]\n"
     ]
    }
   ],
   "source": [
    "# Verify channel layout is consistent across sessions, locate motor channels\n",
    "ref_subj = subjects[0]\n",
    "ref_ses  = next(iter(sessions[ref_subj].values()))\n",
    "channel_names_global = ref_ses['ch_names']\n",
    "sfreq_global         = ref_ses['sfreq']\n",
    "\n",
    "mismatches = [f'{subj}/{sid}' for subj in subjects for sid, s in sessions[subj].items()\n",
    "              if s['ch_names'] != channel_names_global]\n",
    "if mismatches:\n",
    "    print('!! channel mismatch in:', mismatches)\n",
    "\n",
    "motor_idx_global = [channel_names_global.index(c) for c in MOTOR_CH\n",
    "                    if c in channel_names_global]\n",
    "\n",
    "print(f'Channels ({len(channel_names_global)}): {channel_names_global}')\n",
    "print(f'Sampling rate: {sfreq_global} Hz')\n",
    "print(f'Motor channels {MOTOR_CH} → indices {motor_idx_global}')"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "70922abb",
   "metadata": {},
   "source": [
    "## Train CSP + LDA on OFFLINE, Evaluate on ONLINE"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": 7,
   "id": "f5e80da3",
   "metadata": {
    "execution": {
     "iopub.execute_input": "2026-04-22T03:09:59.637480Z",
     "iopub.status.busy": "2026-04-22T03:09:59.637343Z",
     "iopub.status.idle": "2026-04-22T03:10:03.257068Z",
     "shell.execute_reply": "2026-04-22T03:10:03.255453Z"
    }
   },
   "outputs": [
    {
     "name": "stdout",
     "output_type": "stream",
     "text": [
      "[009] Pair1 (train=OFFLINE_FES) / NOFES: only 1 clean epochs — acc=0.717 (marker-based), skipping EEG metrics\n",
      "\n",
      "Subj  Pair                         Cond      n  trainAcc   mkAcc   |marg|   Fisher    muSNR\n",
      "-------------------------------------------------------------------------------------------\n",
      "002   Pair1 (train=OFFLINE_FES)    FES      53     0.659   0.883    0.778    0.288    1.197\n",
      "002   Pair1 (train=OFFLINE_FES)    NOFES    52     0.659   0.833    1.162    0.073    1.452\n",
      "002   Pair2 (train=OFFLINE_NOFES)  FES      56     0.811   0.917    1.012    3.133    1.665\n",
      "002   Pair2 (train=OFFLINE_NOFES)  NOFES    60     0.811   0.850    0.756    0.695    1.576\n",
      "003   Pair1 (train=OFFLINE_FES)    FES      59     0.843   0.750    0.879    0.017    1.347\n",
      "003   Pair1 (train=OFFLINE_FES)    NOFES    38     0.843   0.763    0.910    0.683    1.219\n",
      "003   Pair2 (train=OFFLINE_NOFES)  FES      52     0.907   0.767    1.166    0.000    1.273\n",
      "003   Pair2 (train=OFFLINE_NOFES)  NOFES    43     0.907   0.717    1.071    0.029    1.296\n",
      "005   Pair1 (train=OFFLINE_FES)    FES      60     0.978   0.800    3.346    2.498    2.695\n",
      "005   Pair1 (train=OFFLINE_FES)    NOFES    59     0.978   0.933    3.620    3.029    2.399\n",
      "005   Pair2 (train=OFFLINE_NOFES)  FES      59     1.000   0.917    5.740    6.291    2.911\n",
      "005   Pair2 (train=OFFLINE_NOFES)  NOFES    58     1.000   0.783    5.261    4.325    2.287\n",
      "009   Pair1 (train=OFFLINE_FES)    FES      42     0.667   0.717    0.304    0.040    1.326\n",
      "009   Pair2 (train=OFFLINE_NOFES)  FES      50     1.000   0.817    4.673    6.192    1.594\n",
      "009   Pair2 (train=OFFLINE_NOFES)  NOFES    60     1.000   0.850    3.754    8.959    2.000\n"
     ]
    }
   ],
   "source": [
    "MIN_TEST_TRIALS = 10\n",
    "\n",
    "results = []\n",
    "\n",
    "for subj in subjects:\n",
    "    subj_ses = sessions[subj]\n",
    "\n",
    "    for pair in PAIRS:\n",
    "        train = pick_session(subj_ses, pair['train']['kind'],  pair['train']['stim'],  pair['train']['idx'])\n",
    "        te_f  = pick_session(subj_ses, pair['online_fes']['kind'],   pair['online_fes']['stim'],   pair['online_fes']['idx'])\n",
    "        te_n  = pick_session(subj_ses, pair['online_nofes']['kind'], pair['online_nofes']['stim'], pair['online_nofes']['idx'])\n",
    "\n",
    "        missing = [role for role, ses in [('train', train), ('online_fes', te_f), ('online_nofes', te_n)] if ses is None]\n",
    "        if missing:\n",
    "            print(f'[{subj}] {pair[\"name\"]}: no session found for {missing} — skipping')\n",
    "            continue\n",
    "\n",
    "        if set(np.unique(train['y'])) != {0, 1}:\n",
    "            print(f'[{subj}] {pair[\"name\"]}: training set lacks both classes — skipping')\n",
    "            continue\n",
    "\n",
    "        clf       = CSPLDA(n_csp=N_CSP).fit(train['X'], train['y'])\n",
    "        train_acc = (clf.predict(train['X']) == train['y']).mean()\n",
    "\n",
    "        for te, cond_label in [(te_f, 'FES'), (te_n, 'NOFES')]:\n",
    "            acc = te['mk_acc']\n",
    "            if acc is None:\n",
    "                print(f'[{subj}] {pair[\"name\"]} / {cond_label}: no EARLYSTOP markers — skipping')\n",
    "                continue\n",
    "\n",
    "            if len(te['y']) < MIN_TEST_TRIALS or set(np.unique(te['y'])) != {0, 1}:\n",
    "                print(f'[{subj}] {pair[\"name\"]} / {cond_label}: only {len(te[\"y\"])} clean epochs — '\n",
    "                      f'acc={acc:.3f} (marker-based), skipping EEG metrics')\n",
    "                continue\n",
    "\n",
    "            res   = evaluate(clf, te['X'], te['y'])\n",
    "            snr_s = spectral_snr(te['X'], te['y'], motor_idx_global, te['sfreq'])\n",
    "\n",
    "            results.append(dict(\n",
    "                subject=subj, pair=pair['name'], condition=cond_label,\n",
    "                train_file=train['file'], test_file=te['file'],\n",
    "                train_acc=train_acc, n_test=len(te['y']),\n",
    "                acc=acc,\n",
    "                amp=res['amp'], fisher=res['fisher'], mu_snr=snr_s,\n",
    "                margin=res['margin'], y_test=res['y'], pred=res['pred'],\n",
    "            ))\n",
    "\n",
    "hdr = (f'{\"Subj\":<5} {\"Pair\":<28} {\"Cond\":<6} {\"n\":>4} '\n",
    "       f'{\"trainAcc\":>9} {\"mkAcc\":>7} {\"|marg|\":>8} {\"Fisher\":>8} {\"muSNR\":>8}')\n",
    "print('\\n' + hdr)\n",
    "print('-' * len(hdr))\n",
    "for r in results:\n",
    "    print(f'{r[\"subject\"]:<5} {r[\"pair\"]:<28} {r[\"condition\"]:<6} {r[\"n_test\"]:>4} '\n",
    "          f'{r[\"train_acc\"]:>9.3f} {r[\"acc\"]:>7.3f} {r[\"amp\"]:>8.3f} '\n",
    "          f'{r[\"fisher\"]:>8.3f} {r[\"mu_snr\"]:>8.3f}')"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "2ab81600",
   "metadata": {},
   "source": [
    "---\n",
    "## Figure 1 — Per-metric comparison (FES vs NOFES)"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": 8,
   "id": "d53e63b9",
   "metadata": {
    "execution": {
     "iopub.execute_input": "2026-04-22T03:10:03.262351Z",
     "iopub.status.busy": "2026-04-22T03:10:03.262228Z",
     "iopub.status.idle": "2026-04-22T03:10:04.250444Z",
     "shell.execute_reply": "2026-04-22T03:10:04.249983Z"
    }
   },
   "outputs": [
    {
     "data": {
      "image/png": "[encoded data, 94188 characters]",
      "text/plain": [
       "<Figure size 1680x1080 with 4 Axes>"
      ]
     },
     "metadata": {},
     "output_type": "display_data"
    },
    {
     "name": "stdout",
     "output_type": "stream",
     "text": [
      "Saved: fes_vs_nofes_metrics.png\n"
     ]
    }
   ],
   "source": [
    "METRICS = [\n",
    "    ('acc',    'Classification accuracy',                            '0–1'),\n",
    "    ('amp',    'Classification amplitude (mean |decision fn|)',      'a.u.'),\n",
    "    ('fisher', 'Fisher ratio on LDA projection (test-set SNR)',      'a.u.'),\n",
    "    ('mu_snr', 'μ-band power ratio  REST / MI  @ C3/Cz/C4',          'ratio'),\n",
    "]\n",
    "\n",
    "cond_color = {'FES': '#E05C2A', 'NOFES': '#2A7BE0'}\n",
    "\n",
    "fig, axes = plt.subplots(2, 2, figsize=(14, 9))\n",
    "fig.suptitle('Online decoding: FES vs NOFES feedback  (per subject × offline-trained model)',\n",
    "             fontsize=13, fontweight='bold', y=1.00)\n",
    "\n",
    "for ax, (key, title, unit) in zip(axes.ravel(), METRICS):\n",
    "    labels, vals, colors = [], [], []\n",
    "    for subj in subjects:\n",
    "        for pair in PAIRS:\n",
    "            tag = pair['name'].split()[0]   # \"Pair1\" / \"Pair2\"\n",
    "            for cond in ('FES', 'NOFES'):\n",
    "                row = next((r for r in results\n",
    "                            if r['subject']==subj and r['pair']==pair['name']\n",
    "                            and r['condition']==cond), None)\n",
    "                if row is None: continue\n",
    "                labels.append(f'{subj}\\n{tag}\\n{cond}')\n",
    "                vals.append(row[key])\n",
    "                colors.append(cond_color[cond])\n",
    "    x = np.arange(len(vals))\n",
    "    ax.bar(x, vals, color=colors, edgecolor='white', zorder=2)\n",
    "    ax.set_xticks(x); ax.set_xticklabels(labels, fontsize=7.5)\n",
    "    ax.set_title(f'{title}  ({unit})', fontsize=11, fontweight='bold')\n",
    "    ax.grid(axis='y', alpha=0.3)\n",
    "    ax.spines[['top','right']].set_visible(False)\n",
    "    if key == 'acc':\n",
    "        ax.axhline(0.5, color='gray', linestyle='--', lw=0.8, alpha=0.6)\n",
    "\n",
    "fig.legend(handles=[Patch(color=cond_color['FES'],   label='ONLINE_FES'),\n",
    "                    Patch(color=cond_color['NOFES'], label='ONLINE_NOFES')],\n",
    "           loc='upper right', ncol=2, bbox_to_anchor=(0.98, 1.0))\n",
    "plt.tight_layout()\n",
    "plt.savefig('fes_vs_nofes_metrics.png', dpi=150, bbox_inches='tight')\n",
    "plt.show()\n",
    "print('Saved: fes_vs_nofes_metrics.png')"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "248740bd",
   "metadata": {},
   "source": [
    "---\n",
    "## Figure 2 — LDA decision-function distributions\n",
    "\n",
    "Visualizes classification amplitude and separability directly: wider FES vs NOFES spread between MI and REST curves = higher Fisher ratio and larger mean |margin|."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": 9,
   "id": "393042a0",
   "metadata": {
    "execution": {
     "iopub.execute_input": "2026-04-22T03:10:04.254162Z",
     "iopub.status.busy": "2026-04-22T03:10:04.254059Z",
     "iopub.status.idle": "2026-04-22T03:10:05.210590Z",
     "shell.execute_reply": "2026-04-22T03:10:05.210168Z"
    }
   },
   "outputs": [
    {
     "data": {
      "image/png": "[encoded data, 239988 characters]",
      "text/plain": [
       "<Figure size 1440x1536 with 8 Axes>"
      ]
     },
     "metadata": {},
     "output_type": "display_data"
    },
    {
     "name": "stdout",
     "output_type": "stream",
     "text": [
      "Saved: decision_margin_distributions.png\n"
     ]
    }
   ],
   "source": [
    "fig, axes = plt.subplots(len(subjects), len(PAIRS),\n",
    "                         figsize=(6 * len(PAIRS), 3.2 * len(subjects)),\n",
    "                         sharex=True, squeeze=False)\n",
    "fig.suptitle('LDA decision-function distributions on ONLINE sessions\\n'\n",
    "             '(class separation ↔ classification amplitude & SNR)',\n",
    "             fontsize=12, fontweight='bold', y=1.01)\n",
    "\n",
    "for i, subj in enumerate(subjects):\n",
    "    for j, pair in enumerate(PAIRS):\n",
    "        ax = axes[i][j]\n",
    "        for cond, color in cond_color.items():\n",
    "            row = next((r for r in results\n",
    "                        if r['subject']==subj and r['pair']==pair['name']\n",
    "                        and r['condition']==cond), None)\n",
    "            if row is None: continue\n",
    "            m_mi   = row['margin'][row['y_test'] == 1]\n",
    "            m_rest = row['margin'][row['y_test'] == 0]\n",
    "            ax.hist(m_mi,   bins=15, alpha=0.5,  color=color,\n",
    "                    label=f'{cond} MI',   density=True)\n",
    "            ax.hist(m_rest, bins=15, alpha=0.25, color=color, hatch='///',\n",
    "                    edgecolor=color, label=f'{cond} REST', density=True)\n",
    "        ax.axvline(0, color='k', lw=0.8)\n",
    "        ax.set_title(f'{subj}  |  {pair[\"name\"]}', fontsize=10, fontweight='bold')\n",
    "        if j == 0:                     ax.set_ylabel('Density')\n",
    "        if i == len(subjects) - 1:     ax.set_xlabel('LDA decision function')\n",
    "        ax.spines[['top','right']].set_visible(False)\n",
    "        if i == 0 and j == 0:\n",
    "            ax.legend(fontsize=6.5, loc='upper left', ncol=2)\n",
    "\n",
    "plt.tight_layout()\n",
    "plt.savefig('decision_margin_distributions.png', dpi=150, bbox_inches='tight')\n",
    "plt.show()\n",
    "print('Saved: decision_margin_distributions.png')"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "fcb6d19d",
   "metadata": {},
   "source": [
    "---\n",
    "## Figure 3 — Paired Δ (FES − NOFES) per metric\n",
    "\n",
    "Within each (subject × pair), FES and NOFES sessions use the same offline-trained model. Positive bars mean FES > NOFES; negative means NOFES > FES. This removes the offline-model-quality confound and isolates the effect of feedback type."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": 10,
   "id": "75df404b",
   "metadata": {
    "execution": {
     "iopub.execute_input": "2026-04-22T03:10:05.215990Z",
     "iopub.status.busy": "2026-04-22T03:10:05.215881Z",
     "iopub.status.idle": "2026-04-22T03:10:05.485686Z",
     "shell.execute_reply": "2026-04-22T03:10:05.485120Z"
    }
   },
   "outputs": [
    {
     "data": {
      "image/png": "[encoded data, 62084 characters]",
      "text/plain": [
       "<Figure size 1920x540 with 4 Axes>"
      ]
     },
     "metadata": {},
     "output_type": "display_data"
    },
    {
     "name": "stdout",
     "output_type": "stream",
     "text": [
      "Saved: fes_minus_nofes_delta.png\n"
     ]
    }
   ],
   "source": [
    "fig, axes = plt.subplots(1, 4, figsize=(16, 4.5))\n",
    "fig.suptitle('Within-pair Δ = FES − NOFES  (positive → FES better)',\n",
    "             fontsize=12, fontweight='bold', y=1.03)\n",
    "\n",
    "for ax, (key, title, _) in zip(axes, METRICS):\n",
    "    labels, deltas = [], []\n",
    "    for subj in subjects:\n",
    "        for pair in PAIRS:\n",
    "            fes = next((r for r in results if r['subject']==subj and r['pair']==pair['name']\n",
    "                        and r['condition']=='FES'),   None)\n",
    "            nof = next((r for r in results if r['subject']==subj and r['pair']==pair['name']\n",
    "                        and r['condition']=='NOFES'), None)\n",
    "            if fes is None or nof is None: continue\n",
    "            deltas.append(fes[key] - nof[key])\n",
    "            labels.append(f'{subj}\\n{pair[\"name\"].split()[0]}')\n",
    "\n",
    "    colors = ['#E05C2A' if d > 0 else '#2A7BE0' for d in deltas]\n",
    "    ax.bar(np.arange(len(deltas)), deltas, color=colors, edgecolor='white', zorder=2)\n",
    "    ax.axhline(0, color='k', lw=0.8)\n",
    "    ax.set_xticks(np.arange(len(deltas)))\n",
    "    ax.set_xticklabels(labels, fontsize=8)\n",
    "    ax.set_title(f'Δ {title.split(\"(\")[0].strip()}', fontsize=10, fontweight='bold')\n",
    "    ax.grid(axis='y', alpha=0.3)\n",
    "    ax.spines[['top','right']].set_visible(False)\n",
    "\n",
    "plt.tight_layout()\n",
    "plt.savefig('fes_minus_nofes_delta.png', dpi=150, bbox_inches='tight')\n",
    "plt.show()\n",
    "print('Saved: fes_minus_nofes_delta.png')"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "b3db60ba",
   "metadata": {},
   "source": [
    "---\n",
    "## Summary Statistics"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": 11,
   "id": "cf55268e",
   "metadata": {
    "execution": {
     "iopub.execute_input": "2026-04-22T03:10:05.496520Z",
     "iopub.status.busy": "2026-04-22T03:10:05.496412Z",
     "iopub.status.idle": "2026-04-22T03:10:05.506439Z",
     "shell.execute_reply": "2026-04-22T03:10:05.506014Z"
    }
   },
   "outputs": [
    {
     "name": "stdout",
     "output_type": "stream",
     "text": [
      "=== Aggregate across 7 complete (subject × pair) comparisons ===\n",
      "\n",
      "Metric                              FES (mean ± sd)      NOFES (mean ± sd)     paired Δ\n",
      "---------------------------------------------------------------------------------------\n",
      "Classification accuracy           0.836 ±  0.070       0.819 ±  0.071        +0.017\n",
      "Classification amplitude          2.513 ±  2.062       2.362 ±  1.813        +0.151\n",
      "Fisher ratio (test SNR)           2.631 ±  2.757       2.542 ±  3.263        +0.090\n",
      "μ-band SNR  (REST/MI)             1.812 ±  0.700       1.747 ±  0.480        +0.065\n",
      "\n",
      "  acc         FES > NOFES in 4/7 comparisons  (NOFES > FES in 3)\n",
      "  |margin|    FES > NOFES in 4/7 comparisons  (NOFES > FES in 3)\n",
      "  Fisher      FES > NOFES in 3/7 comparisons  (NOFES > FES in 4)\n",
      "  μ-SNR       FES > NOFES in 4/7 comparisons  (NOFES > FES in 3)\n"
     ]
    }
   ],
   "source": [
    "# Build only pairs where BOTH FES and NOFES survived evaluation\n",
    "paired = []\n",
    "for subj in subjects:\n",
    "    for pair in PAIRS:\n",
    "        fes = next((r for r in results if r['subject']==subj and r['pair']==pair['name']\n",
    "                    and r['condition']=='FES'),   None)\n",
    "        nof = next((r for r in results if r['subject']==subj and r['pair']==pair['name']\n",
    "                    and r['condition']=='NOFES'), None)\n",
    "        if fes and nof:\n",
    "            paired.append((fes, nof))\n",
    "\n",
    "print(f'=== Aggregate across {len(paired)} complete (subject × pair) comparisons ===\\n')\n",
    "\n",
    "hdr = f'{\"Metric\":<28} {\"FES (mean ± sd)\":>22} {\"NOFES (mean ± sd)\":>22} {\"paired Δ\":>12}'\n",
    "print(hdr); print('-' * len(hdr))\n",
    "for k, label in [('acc',    'Classification accuracy'),\n",
    "                 ('amp',    'Classification amplitude'),\n",
    "                 ('fisher', 'Fisher ratio (test SNR)'),\n",
    "                 ('mu_snr', 'μ-band SNR  (REST/MI)')]:\n",
    "    fes_v = np.array([f[k] for f,_ in paired])\n",
    "    nof_v = np.array([n[k] for _,n in paired])\n",
    "    delta = fes_v - nof_v\n",
    "    sd = lambda a: a.std(ddof=1) if len(a) > 1 else 0.0\n",
    "    print(f'{label:<28} {fes_v.mean():>10.3f} ± {sd(fes_v):>6.3f}  '\n",
    "          f'{nof_v.mean():>10.3f} ± {sd(nof_v):>6.3f}  {delta.mean():>+12.3f}')\n",
    "\n",
    "# Sign test (simple)\n",
    "print()\n",
    "for k, label in [('acc','acc'), ('amp','|margin|'), ('fisher','Fisher'), ('mu_snr','μ-SNR')]:\n",
    "    d = np.array([f[k] - n[k] for f,n in paired])\n",
    "    n_pos = int((d > 0).sum()); n_neg = int((d < 0).sum())\n",
    "    print(f'  {label:<10}  FES > NOFES in {n_pos}/{len(d)} comparisons  (NOFES > FES in {n_neg})')"
   ]
  }
 ],
 "metadata": {
  "kernelspec": {
   "display_name": "venv",
   "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.7"
  }
 },
 "nbformat": 4,
 "nbformat_minor": 5
}
