File size: 9,268 Bytes
d37c079
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
#!/usr/bin/env python
"""Eyes-open / eyes-closed ALPHA test — the single best full-chain acceptance test for an
EEG cap, needing NO extra equipment. It uses the Berger effect as a biological reference:

    close your eyes  ->  posterior (O1/O2/Oz/PO/P) 8-12 Hz ALPHA power shoots up
    open  your eyes  ->  it drops again

This is universal and reproducible, so it IS the standard. If this cap shows a strong,
POSTERIOR-dominant alpha increase on eye-closure, then the whole acquisition chain works:
electrodes make contact, the amplifier and referencing are sane, the montage/wiring maps
occipital electrodes to the back of the head, and the µV/Hz scaling is in the right ballpark.

The script records alternating eyes-open / eyes-closed blocks (it prompts you), then:
  * posterior mean spectrum, open vs closed  (an alpha bump should appear when closed)
  * scalp topomap of the closed/open alpha ratio  (should be posterior-dominant)
  * per-channel closed/open alpha ratio bar
and prints a PASS/FAIL verdict.

  python src/acquisition/alpha_check.py                 # real cap (prompts you)
  python src/acquisition/alpha_check.py --blocks 3 --secs 12
  python src/acquisition/alpha_check.py --demo          # no hardware: validate plots
"""
from __future__ import annotations

import argparse
import socket
import sys
import time
from pathlib import Path

import numpy as np

HERE = Path(__file__).resolve().parent
sys.path.insert(0, str(HERE.parent))                          # src/
sys.path.insert(0, str(HERE))                                 # src/acquisition/
from common.montage import CAP32_CHANNELS as CH               # noqa: E402

NCH = len(CH)
RESULTS = HERE.parents[1] / "results"
ALPHA = (8.0, 12.0)
POSTERIOR = ["O1", "O2", "OZ", "PO3", "PO4", "PZ", "P3", "P4", "P7", "P8"]
FRONTAL = ["FP1", "FP2", "AF3", "AF4", "F3", "F4", "FZ"]


# ----------------------------------------------------------------- acquisition
def _grab(src, parse_packet, fs, secs):
    buf, end = [], time.time() + secs
    for pkt in src.frames():
        p = parse_packet(pkt)
        if p is not None:
            buf.append(p[0])
        if time.time() >= end:
            break
    return np.array(buf, dtype=np.float64).T if buf else None


def collect_blocks(host, port, fs, n_blocks, secs, settle=1.5):
    """Alternate eyes-open / eyes-closed blocks, prompting the user. RAW µV (no CAR/filter)."""
    from udp_lsl_bridge import UdpSource, parse_packet, board_init, EEG_MODE
    src = UdpSource(host, port); board_init(src, fs); time.sleep(0.4)
    src.sock.settimeout(2.0)
    out = {"open": [], "closed": []}
    schedule = []
    for _ in range(n_blocks):
        schedule += [("open", secs), ("closed", secs)]
    try:
        for cond, dur in schedule:
            word = "闭眼" if cond == "closed" else "睁眼(看屏幕十字)"
            print(f"\n>>> 请【{word}】保持 {dur:.0f}s", flush=True)
            for k in (3, 2, 1):
                print(f"    {k}…", end="", flush=True); time.sleep(0.6)
            print(" 开始")
            _grab(src, parse_packet, fs, settle)              # discard transient
            x = _grab(src, parse_packet, fs, dur)
            if x is not None:
                out[cond].append(x)
    except (KeyboardInterrupt, socket.timeout):
        pass
    finally:
        try:
            src.send(EEG_MODE)
        except OSError:
            pass
    return out


# ----------------------------------------------------------------- analysis
def band_power(x, fs, lo, hi):
    from scipy.signal import welch
    f, P = welch(x, fs=fs, nperseg=min(x.shape[1], int(fs * 2)), axis=1)
    return f, P, P[:, (f >= lo) & (f <= hi)].mean(1)          # (freqs, PSD, per-ch band power)


def analyse(blocks, fs, out, demo=False):
    import matplotlib; matplotlib.use("Agg")
    import matplotlib.pyplot as plt
    import mne
    if not blocks["open"] or not blocks["closed"]:
        print("缺少 open 或 closed 数据段"); return
    xo = np.concatenate(blocks["open"], axis=1)
    xc = np.concatenate(blocks["closed"], axis=1)
    f, Po, ao = band_power(xo, fs, *ALPHA)
    _, Pc, ac = band_power(xc, fs, *ALPHA)
    ratio = ac / np.clip(ao, 1e-9, None)                      # closed/open alpha, per channel
    post = [CH.index(c) for c in POSTERIOR if c in CH]
    front = [CH.index(c) for c in FRONTAL if c in CH]
    r_post = float(np.median(ratio[post]))
    r_front = float(np.median(ratio[front]))

    info = mne.create_info(list(CH), fs, "eeg")
    info.set_montage(mne.channels.make_standard_montage("standard_1020"),
                     match_case=False, on_missing="ignore", verbose="ERROR")

    fig = plt.figure(figsize=(14, 4.6)); fig.patch.set_facecolor("white")
    gs = fig.add_gridspec(1, 3, width_ratios=[1.2, 1, 1.4])

    ax0 = fig.add_subplot(gs[0])                              # posterior spectrum
    m = (f >= 2) & (f <= 30)
    ax0.plot(f[m], Po[post].mean(0)[m], color="#6b7480", lw=2, label="eyes OPEN")
    ax0.plot(f[m], Pc[post].mean(0)[m], color="#2b6cb0", lw=2, label="eyes CLOSED")
    ax0.axvspan(*ALPHA, color="#2e9e5b", alpha=0.12)
    ax0.set_xlabel("Hz"); ax0.set_ylabel("PSD (µV²/Hz)")
    ax0.set_title("Posterior spectrum — alpha bump on closing?"); ax0.legend(fontsize=9)

    ax1 = fig.add_subplot(gs[1])                              # topomap of alpha ratio
    rr = np.clip(ratio, 0.3, 5.0)
    im, _ = mne.viz.plot_topomap(rr, info, axes=ax1, cmap="RdBu_r",
                                 vlim=(0.3, 3.0), show=False, contours=4)
    ax1.set_title("closed/open alpha ratio\n(posterior-dominant?)", fontsize=10)
    fig.colorbar(im, ax=ax1, shrink=0.7)

    ax2 = fig.add_subplot(gs[2])                              # per-channel ratio
    cols = ["#2b6cb0" if CH[i] in POSTERIOR else ("#c58a00" if CH[i] in FRONTAL else "#9aa3b2")
            for i in range(NCH)]
    ax2.bar(range(NCH), ratio, color=cols)
    ax2.axhline(1, color="#333", lw=0.8); ax2.axhline(2, color="#2e9e5b", ls="--", lw=1)
    ax2.set_xticks(range(NCH)); ax2.set_xticklabels(CH, rotation=90, fontsize=6)
    ax2.set_ylabel("closed/open alpha"); ax2.set_title("Per-channel (blue=posterior, amber=frontal)")

    fig.suptitle("Acceptance — EYES-OPEN/CLOSED ALPHA (Berger effect)" + ("  [DEMO]" if demo else ""),
                 fontweight="bold")
    fig.tight_layout(); RESULTS.mkdir(parents=True, exist_ok=True)
    fig.savefig(out, dpi=130, bbox_inches="tight", facecolor="white"); plt.close(fig)
    print("saved", out)

    print(f"\n  posterior alpha closed/open = {r_post:.2f}×   frontal = {r_front:.2f}×")
    if r_post >= 2.0 and r_post > 1.5 * max(r_front, 1e-6):
        print("  ✅ PASS: 明显的枕区 α 反应且以后脑为主 —— 整条采集链工作正常。")
    elif r_post >= 1.3:
        print("  ⚠ 弱 α 反应 —— 接触/放松不足,或后部电极座不好。多测几组、闭眼放松再试。")
    else:
        print("  ❌ 无 α 反应 —— 后部电极没接触好 / 接线映射可疑 / 参考异常。先查通道映射与接触。")


# ----------------------------------------------------------------- demo
def demo_blocks(fs, n_blocks, secs):
    rng = np.random.default_rng(2)
    post = set(CH.index(c) for c in POSTERIOR if c in CH)

    def block(closed):
        n = int(secs * fs); t = np.arange(n) / fs
        x = np.empty((NCH, n))
        for c in range(NCH):
            pink = np.cumsum(rng.normal(0, 1, n)); pink -= pink.mean(); pink *= 6 / (pink.std() + 1e-9)
            a = (18 if closed else 4) if c in post else 3      # posterior alpha up when closed
            x[c] = a * np.sin(2 * np.pi * 10 * t + rng.uniform(0, 6)) + rng.normal(0, 4, n) + pink
        return x

    out = {"open": [], "closed": []}
    for _ in range(n_blocks):
        out["open"].append(block(False)); out["closed"].append(block(True))
    return out


def main():
    ap = argparse.ArgumentParser(description=__doc__,
                                 formatter_class=argparse.RawDescriptionHelpFormatter)
    ap.add_argument("--host", default="192.168.4.1"); ap.add_argument("--port", type=int, default=8086)
    ap.add_argument("--sfreq", type=int, default=250)
    ap.add_argument("--blocks", type=int, default=3, help="open/closed pairs")
    ap.add_argument("--secs", type=float, default=12.0, help="seconds per block")
    ap.add_argument("--demo", action="store_true")
    ap.add_argument("--out", default=str(RESULTS / "acceptance_alpha.png"))
    args = ap.parse_args()

    if args.demo:
        blocks = demo_blocks(args.sfreq, args.blocks, args.secs)
    else:
        print("即将交替【睁眼/闭眼】各若干段。闭眼时请放松、别用力眯眼(会有肌电)。")
        blocks = collect_blocks(args.host, args.port, args.sfreq, args.blocks, args.secs)
        no = sum(b.shape[1] for b in blocks["open"]) if blocks["open"] else 0
        nc = sum(b.shape[1] for b in blocks["closed"]) if blocks["closed"] else 0
        if not no or not nc:
            print("无数据 — 连到 ESPBCI 且 IP=192.168.4.2 了吗?"); return
        print(f"\ncollected open {no} / closed {nc} samples @ {args.sfreq} Hz")
    analyse(blocks, args.sfreq, args.out, demo=args.demo)


if __name__ == "__main__":
    main()