| |
| """Hardware self-check for the 32-ch ADS1299 cap — the objective way to judge board quality |
| (instead of guessing from off-head stray coupling). Each test collects RAW data (NO CAR, NO |
| filter) and produces a plot + a printed verdict. |
| |
| noise input-referred NOISE FLOOR. Setup: short all electrodes together / to REF |
| (or bundle+wrap in foil). Good ADS1299 @gain24 ≈ 1 µV RMS input-referred. |
| Plot: per-channel RMS + amplitude spectral density (µV/√Hz). |
| |
| dc DC OFFSET & RAILING. Setup: on the head, normal wear. |
| How big are the per-channel DC offsets, and how often do channels hit the |
| ±187.5 mV rail? (dry-electrode polarisation — the thing that railed a recording.) |
| Plot: per-channel DC (mV) vs the rail, and % of samples clipped. |
| |
| crosstalk CHANNEL-TO-CHANNEL LEAKAGE. Setup: drive ONE electrode with a clean tone |
| (function generator / phone tone through a wire), leave the rest. Measures how |
| much of that tone leaks into the OTHER channels. Good design < 1 % (−40 dB). |
| Plot: per-channel leakage (dB) relative to the source channel. |
| |
| watch LIVE per-channel state, refreshed continuously. For localising an |
| INTERMITTENT fault: start it, then wiggle the electrode, the lead, and the |
| connector in turn and see exactly which one makes the channel flip. Also the |
| readout for the swap test (move the suspect lead to a known-good input: if |
| the fault follows the lead the lead is bad, if it stays on the input the |
| board/connector is bad). |
| |
| mains 50 Hz MAINS PICKUP (CMRR proxy). Setup: normal wear. |
| How much 50 Hz each channel picks up — a practical proxy for interference |
| rejection. (True CMRR needs a tied-input common-mode source; noted below.) |
| Plot: per-channel 50 Hz amplitude + share of total power. |
| |
| python src/acquisition/hardware_check.py --test noise |
| python src/acquisition/hardware_check.py --test dc --seconds 20 |
| python src/acquisition/hardware_check.py --test crosstalk --source-ch C3 --probe-hz 10 |
| python src/acquisition/hardware_check.py --test mains |
| python src/acquisition/hardware_check.py --test noise --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)) |
| sys.path.insert(0, str(HERE)) |
| from common.montage import CAP32_CHANNELS as CH, ADC_MICROVOLTS_PER_COUNT |
|
|
| NCH = len(CH) |
| FULLSCALE_UV = (2 ** 23) * ADC_MICROVOLTS_PER_COUNT |
| RESULTS = HERE.parents[1] / "results" |
|
|
| SETUP = { |
| "watch": "接上帽子即可。开始后依次轻拨:电极 → 靠近电极的导线 → 导线中段 → 板子接插件," |
| "看哪一步会让通道状态跳变。", |
| "noise": "把所有电极短接在一起(或接到 REF/地),最好用锡纸包住。测放大器本底噪声。", |
| "dc": "正常戴在头上。测各通道直流偏置和顶轨(rail)频率。", |
| "crosstalk": "只在一个电极上加一个干净的正弦(信号发生器/手机播放音调经导线接入),其余不动。", |
| "mains": "正常戴在头上(或拿在手里)。测各通道 50Hz 拾取。", |
| } |
|
|
|
|
| |
| def collect_raw(host, port, fs, seconds): |
| """RAW µV per channel — NO CAR, NO filter (parse_packet output directly).""" |
| 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) |
| buf, end = [], time.time() + seconds |
| try: |
| for pkt in src.frames(): |
| p = parse_packet(pkt) |
| if p is not None: |
| buf.append(p[0]) |
| if time.time() >= end: |
| break |
| except socket.timeout: |
| pass |
| finally: |
| try: |
| src.send(EEG_MODE) |
| except OSError: |
| pass |
| return np.array(buf, dtype=np.float64).T if buf else None |
|
|
|
|
| def amp_spectrum(x, fs): |
| """(freqs, A) — single-sided Hann amplitude (µV) per channel.""" |
| n = x.shape[1]; w = np.hanning(n) |
| A = np.abs(np.fft.rfft((x - x.mean(1, keepdims=True)) * w, axis=1)) * (2.0 / w.sum()) |
| return np.fft.rfftfreq(n, 1 / fs), A |
|
|
|
|
| def bandpass(x, fs, lo, hi): |
| from scipy.signal import butter, filtfilt |
| b, a = butter(4, [lo / (fs / 2), min(hi, fs / 2 * 0.99) / (fs / 2)], "bandpass") |
| return filtfilt(b, a, x, axis=1) |
|
|
|
|
| def _figpre(): |
| import matplotlib; matplotlib.use("Agg") |
| import matplotlib.pyplot as plt |
| return plt |
|
|
|
|
| def _barcolors(vals, good, warn, reverse=False): |
| out = [] |
| for v in vals: |
| ok = v <= good if not reverse else v >= good |
| mid = v <= warn if not reverse else v >= warn |
| out.append("#2e9e5b" if ok else ("#c58a00" if mid else "#d1495b")) |
| return out |
|
|
|
|
| |
| def test_noise(x, fs, out, demo=False): |
| from scipy.signal import welch |
| xb = bandpass(x, fs, 1.0, min(45.0, fs / 2 * 0.98)) |
| rms = xb.std(axis=1) |
| f, P = welch(x - x.mean(1, keepdims=True), fs=fs, nperseg=min(x.shape[1], int(fs * 4)), axis=1) |
| asd = np.sqrt(P) |
| med = float(np.median(rms)) |
| plt = _figpre() |
| fig, ax = plt.subplots(1, 2, figsize=(13, 4.6)); fig.patch.set_facecolor("white") |
| ax[0].bar(range(NCH), rms, color=_barcolors(rms, 3, 8)) |
| ax[0].axhline(1.0, color="#2e9e5b", ls="--", lw=1, label="ADS1299 ~1 µV (ideal)") |
| ax[0].set_xticks(range(NCH)); ax[0].set_xticklabels(CH, rotation=90, fontsize=6) |
| ax[0].set_ylabel("RMS 1–45 Hz (µV)"); ax[0].set_title(f"Noise floor per channel (median {med:.1f} µV)") |
| ax[0].legend(fontsize=8) |
| m = f >= 0.5 |
| ax[1].loglog(f[m], asd[:, m].T, color="#9aa3b2", lw=0.5, alpha=0.5) |
| ax[1].loglog(f[m], np.median(asd[:, m], 0), color="#2b6cb0", lw=2, label="median") |
| ax[1].axhline(1.0 / np.sqrt(fs / 2), color="#2e9e5b", ls="--", lw=1) |
| ax[1].set_xlabel("Hz"); ax[1].set_ylabel("ASD (µV/√Hz)"); ax[1].set_title("Amplitude spectral density") |
| ax[1].legend(fontsize=8); ax[1].grid(alpha=0.2, which="both") |
| fig.suptitle("Hardware check — NOISE FLOOR (short inputs)" + (" [DEMO]" if demo else ""), |
| fontweight="bold") |
| _save(fig, out) |
| print(f" median noise {med:.1f} µV RMS; noisy (>8µV): {[CH[i] for i in range(NCH) if rms[i]>8]}") |
| print(f" → good boards land ~1–3 µV RMS with shorted inputs; >8 µV = noisy channel/contact.") |
|
|
|
|
| def test_dc(x, fs, out, demo=False): |
| dc = x.mean(axis=1) / 1000.0 |
| railfrac = (np.abs(x) > 0.97 * FULLSCALE_UV).mean(axis=1) * 100.0 |
| plt = _figpre() |
| fig, ax = plt.subplots(1, 2, figsize=(13, 4.6)); fig.patch.set_facecolor("white") |
| ax[0].axhspan(187.5, 260, color="#f3d6d6"); ax[0].axhspan(-260, -187.5, color="#f3d6d6") |
| ax[0].bar(range(NCH), dc, color=_barcolors(np.abs(dc), 50, 150)) |
| ax[0].axhline(187.5, color="#d1495b", lw=1); ax[0].axhline(-187.5, color="#d1495b", lw=1) |
| ax[0].set_ylim(-260, 260); ax[0].set_xticks(range(NCH)); ax[0].set_xticklabels(CH, rotation=90, fontsize=6) |
| ax[0].set_ylabel("DC offset (mV)"); ax[0].set_title("Per-channel DC offset vs ±187.5 mV rail") |
| ax[1].bar(range(NCH), railfrac, color=_barcolors(railfrac, 0.1, 2)) |
| ax[1].set_xticks(range(NCH)); ax[1].set_xticklabels(CH, rotation=90, fontsize=6) |
| ax[1].set_ylabel("% samples clipped"); ax[1].set_title("Railing / saturation") |
| fig.suptitle("Hardware check — DC OFFSET & RAILING (on head)" + (" [DEMO]" if demo else ""), |
| fontweight="bold") |
| _save(fig, out) |
| railed = [CH[i] for i in range(NCH) if railfrac[i] > 1] |
| print(f" DC offset |median| {np.median(np.abs(dc)):.0f} mV; railing channels: {railed or 'none'}") |
| print(" → big DC offsets are mostly dry-electrode polarisation, not necessarily a board fault;") |
| print(" but a railed channel clips in recordings (use 1 Hz high-pass / re-seat that electrode).") |
|
|
|
|
| def test_crosstalk(x, fs, out, source=None, probe_hz=None, demo=False): |
| f, A = amp_spectrum(x, fs) |
| band = (f >= 3) & (f <= 45) & (np.abs(f - 50) > 1.5) |
| if probe_hz is None: |
| k = np.arange(len(f))[band][np.argmax(A[:, band].max(0))] |
| else: |
| k = int(np.argmin(np.abs(f - probe_hz))) |
| src_i = CH.index(source) if source in CH else int(np.argmax(A[:, k])) |
| leak = A[:, k] / max(A[src_i, k], 1e-9) |
| leak_db = 20 * np.log10(np.clip(leak, 1e-6, None)) |
| plt = _figpre() |
| fig, ax = plt.subplots(figsize=(12, 4.6)); fig.patch.set_facecolor("white") |
| cols = ["#2b6cb0" if i == src_i else |
| ("#2e9e5b" if leak[i] < 0.01 else "#c58a00" if leak[i] < 0.05 else "#d1495b") |
| for i in range(NCH)] |
| ax.bar(range(NCH), leak_db, color=cols) |
| ax.axhline(-40, color="#2e9e5b", ls="--", lw=1, label="−40 dB (1% leakage)") |
| ax.set_xticks(range(NCH)); ax.set_xticklabels(CH, rotation=90, fontsize=7) |
| ax.set_ylabel("leakage vs source (dB)") |
| ax.set_title(f"Crosstalk from {CH[src_i]} @ {f[k]:.1f} Hz (blue = source)") |
| ax.legend(fontsize=9) |
| fig.suptitle("Hardware check — CROSSTALK (drive one electrode)" + (" [DEMO]" if demo else ""), |
| fontweight="bold") |
| _save(fig, out) |
| others = np.delete(leak, src_i) |
| print(f" source {CH[src_i]} @ {f[k]:.1f} Hz; worst other-channel leakage " |
| f"{20*np.log10(others.max()):.0f} dB ({others.max()*100:.1f}%), median " |
| f"{20*np.log10(np.median(others)):.0f} dB") |
| print(" → < −40 dB (1%) is good isolation; > −26 dB (5%) suggests poor layout/shielding.") |
|
|
|
|
| def test_mains(x, fs, out, demo=False): |
| f, A = amp_spectrum(x, fs) |
| k50 = int(np.argmin(np.abs(f - 50))) |
| a50 = A[:, k50] |
| rms = x.std(axis=1) |
| share = a50 / (rms + 1e-9) * 100 |
| plt = _figpre() |
| fig, ax = plt.subplots(1, 2, figsize=(13, 4.6)); fig.patch.set_facecolor("white") |
| ax[0].bar(range(NCH), a50, color=_barcolors(a50, 5, 20)) |
| ax[0].set_xticks(range(NCH)); ax[0].set_xticklabels(CH, rotation=90, fontsize=6) |
| ax[0].set_ylabel("50 Hz amplitude (µV)"); ax[0].set_title(f"Mains pickup (median {np.median(a50):.1f} µV)") |
| ax[1].bar(range(NCH), share, color=_barcolors(share, 10, 30)) |
| ax[1].set_xticks(range(NCH)); ax[1].set_xticklabels(CH, rotation=90, fontsize=6) |
| ax[1].set_ylabel("50 Hz share of RMS (%)"); ax[1].set_title("How much of the signal is mains") |
| fig.suptitle("Hardware check — 50 Hz MAINS PICKUP" + (" [DEMO]" if demo else ""), |
| fontweight="bold") |
| _save(fig, out) |
| print(f" 50 Hz median {np.median(a50):.1f} µV; worst {CH[int(np.argmax(a50))]} {a50.max():.0f} µV") |
| print(" → high, uniform 50 Hz across channels = common-mode (helped by CAR + notch).") |
| print(" True CMRR needs a tied-input common-mode source; this is the practical proxy.") |
|
|
|
|
| def _save(fig, out): |
| import matplotlib.pyplot as plt |
| 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) |
|
|
|
|
| |
| def watch_live(host, port, fs, channels, win_s=1.0): |
| """Continuous per-channel state — for tracking down an intermittent connection.""" |
| from udp_lsl_bridge import UdpSource, parse_packet, board_init, drain, EEG_MODE |
| idx = [(c, CH.index(c)) for c in channels if c in CH] |
| src = UdpSource(host, port); board_init(src, fs); time.sleep(1.0); drain(src) |
| src.sock.settimeout(2.0) |
| buf, w = [], int(win_s * fs) |
| print("\n盯着这些通道,边摇边看 (Ctrl-C 停止):", [c for c, _ in idx]) |
| print(f"{'':10s}" + "".join(f"{c:>14}" for c, _ in idx)) |
| flips = {c: 0 for c, _ in idx}; last = {c: None for c, _ in idx} |
| t0 = time.time() |
| try: |
| for pkt in src.frames(): |
| p = parse_packet(pkt) |
| if p is not None: |
| buf.append(p[0]) |
| if len(buf) >= w: |
| X = np.array(buf[-w:], float).T; buf = buf[-w:] |
| cells = [] |
| for c, i in idx: |
| v = X[i] |
| rail = np.mean(np.abs(v) > 0.97 * FULLSCALE_UV) > 0.5 |
| state = "RAIL" if rail else ("flat" if v.std() < 1 else "OK") |
| if last[c] is not None and state != last[c]: |
| flips[c] += 1 |
| last[c] = state |
| col = "\033[31m" if state == "RAIL" else ("\033[33m" if state == "flat" else "\033[32m") |
| cells.append(f"{col}{state:>6}{RESET}{v.std():6.0f}µV") |
| print(f"[{time.time()-t0:6.1f}s]" + "".join(cells) + |
| (" 跳变: " + " ".join(f"{c}×{n}" for c, n in flips.items() if n) if any(flips.values()) else "")) |
| buf = [] |
| except KeyboardInterrupt: |
| pass |
| finally: |
| try: |
| src.send(EEG_MODE) |
| except OSError: |
| pass |
| print("\n各通道状态跳变次数:", {c: n for c, n in flips.items()}) |
| print("→ 摇到哪一段时跳变最多,断点就在那一段") |
|
|
|
|
| |
| def demo_data(fs, seconds, test): |
| n = int(seconds * fs); t = np.arange(n) / fs; rng = np.random.default_rng(1) |
| x = rng.normal(0, 2.0, (NCH, n)) |
| x[7] += rng.normal(0, 14, n); x[22] += rng.normal(0, 11, n) |
| if test == "dc": |
| x *= 1000 |
| x += (rng.uniform(-120000, 120000, NCH))[:, None] |
| x[14] += 240000 |
| if test == "mains" or test == "dc": |
| for c in range(NCH): |
| x[c] += rng.uniform(3, 40) * np.sin(2 * np.pi * 50 * t + rng.uniform(0, 6)) |
| if test == "dc": |
| x = np.clip(x, -FULLSCALE_UV, FULLSCALE_UV) |
| if test == "crosstalk": |
| src = CH.index("C3") |
| tone = 500 * np.sin(2 * np.pi * 10 * t) |
| x[src] += tone |
| for c in range(NCH): |
| if c != src: |
| x[c] += rng.uniform(0.003, 0.03) * tone |
| return x |
|
|
|
|
| def main(): |
| ap = argparse.ArgumentParser(description=__doc__, |
| formatter_class=argparse.RawDescriptionHelpFormatter) |
| ap.add_argument("--test", required=True, |
| choices=["noise", "dc", "crosstalk", "mains", "watch"]) |
| ap.add_argument("--channels", nargs="+", default=["F7", "F8", "F3", "C3"], |
| help="watch 模式:要盯的通道") |
| 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("--seconds", type=float, default=15) |
| ap.add_argument("--source-ch", default=None); ap.add_argument("--probe-hz", type=float, default=None) |
| ap.add_argument("--demo", action="store_true") |
| ap.add_argument("--out", default=None) |
| args = ap.parse_args() |
| out = args.out or str(RESULTS / f"hw_{args.test}.png") |
|
|
| if args.test == "watch": |
| watch_live(args.host, args.port, args.sfreq, args.channels) |
| return |
| if args.demo: |
| x = demo_data(args.sfreq, args.seconds, args.test) |
| else: |
| print(f"\n【{args.test}】设置要求: {SETUP[args.test]}") |
| try: |
| input("设置好后按回车开始采集(Ctrl-C 取消)…") |
| except (KeyboardInterrupt, EOFError): |
| return |
| x = collect_raw(args.host, args.port, args.sfreq, args.seconds) |
| if x is None: |
| print("无数据 — 连到 ESPBCI 且 IP=192.168.4.2 了吗?"); return |
| print(f"collected {x.shape[1]} samples ({x.shape[1]/args.sfreq:.1f}s) @ {args.sfreq} Hz\n") |
|
|
| fn = {"noise": test_noise, "dc": test_dc, "mains": test_mains} |
| if args.test == "crosstalk": |
| test_crosstalk(x, args.sfreq, out, args.source_ch, args.probe_hz, demo=args.demo) |
| else: |
| fn[args.test](x, args.sfreq, out, demo=args.demo) |
|
|
|
|
| if __name__ == "__main__": |
| main() |
|
|