From 7b9785a91e98b07543b630e31c567cafd75069b3 Mon Sep 17 00:00:00 2001 From: Dano Morrison Date: Mon, 6 Jul 2026 18:00:22 -0700 Subject: [PATCH 01/29] =?UTF-8?q?Fix=20broken=20Clean=E2=86=92Analyze=20ro?= =?UTF-8?q?und-trip:=20persist=20cleaned=20epochs=20to=20host=20disk?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The "Clean Data" flow told Pyodide to `raw_epochs.save("")`, but the worker's filesystem is MEMFS (in-memory) and cannot reach host disk — so the cleaned `-epo.fif` never landed on disk and the Analyze screen's `readWorkspaceCleanedEEGData` picker found nothing in a fresh workspace. This repairs that flow with a MEMFS→host write-back bridge, the one slice the epoch-review plan (§6d / Open Question 8) declares resolved and standalone. Chain: Python saves to MEMFS `/tmp/-cleaned-epo.fif` → the worker reads the bytes and posts them back zero-copy (transferable ArrayBuffer) on a new `savedEpochs` dataKey → `pyodideMessageEpic` writes them to `Data//EEG/-cleaned-epo.fif` via a new `fs:writeCleanedEpochs` IPC channel that mirrors `fs:storePyodideImagePng`. - src/main/index.ts: new `fs:writeCleanedEpochs` handler (binary renderer→disk write) - src/preload/index.ts + electron.d.ts: `writeCleanedEpochs` bridge + type - webworker.js: `readFileAfter` read-back path posts MEMFS bytes as a transferable - webworker/index.ts: `saveEpochs(worker, subject)` now saves to MEMFS (overwrite=True) and requests read-back - pyodideEpics.ts: route `savedEpochs` → `writeCleanedEpochs`, with a success/error toast Output path is bit-identical to what the Analyze picker globs (`epo.fif` suffix). Also tempers docs/epoch-review-ui-plan.md against source. typecheck: clean · eslint: clean · build: green --- docs/epoch-review-ui-plan.md | 178 ++++++++++++++++++---- src/main/index.ts | 22 +++ src/preload/index.ts | 7 + src/renderer/epics/pyodideEpics.ts | 27 +++- src/renderer/types/electron.d.ts | 5 + src/renderer/utils/webworker/index.ts | 24 ++- src/renderer/utils/webworker/webworker.js | 13 +- 7 files changed, 222 insertions(+), 54 deletions(-) diff --git a/docs/epoch-review-ui-plan.md b/docs/epoch-review-ui-plan.md index 20a0ee6e..92cf78ba 100644 --- a/docs/epoch-review-ui-plan.md +++ b/docs/epoch-review-ui-plan.md @@ -45,12 +45,18 @@ That environment has hard constraints: and (b) can't even cross the worker boundary — a `PyProxy`/Figure isn't `structuredClone`-able, so `postMessage` throws `DataCloneError`. -**This is not an MNE-version problem.** We are already on **MNE 1.12.1** (current). -The recent errors (`plot_psd` → `compute_psd`, `events=None` → `events=False`) are -just the code catching up to the modern MNE API; bumping MNE won't remove them. -The interactive-GUI problem is architectural: **native Matplotlib GUIs don't exist -in WASM, and no MNE version changes that.** The only path to interactive epoch -review is to build the UI ourselves — which is what this plan is for. +**This is not an MNE-version problem — it's architectural.** We install the latest +MNE from PyPI (`internals/scripts/InstallMNE.mjs` lists `mne` **unpinned** in +`PYPI_PACKAGES`, so `postinstall` grabs whatever is current — a modern 1.x). The +recent errors (`plot_psd` → `compute_psd`, `events=None` → `events=False`) were +just the code catching up to the modern MNE API; no MNE version brings back native +Matplotlib GUIs in WASM. The only path to interactive epoch review is to build the +UI ourselves — which is what this plan is for. + +> **Note (grounding):** MNE is unpinned today. If exact-reproducibility of the +> cleaned output matters (design goal 3), consider pinning the MNE version in +> `InstallMNE.mjs` so a silent upstream bump can't change epoching/averaging +> behavior out from under the tests. --- @@ -133,14 +139,56 @@ Four concerns. Each has real design choices (deferred to the planning loop). We stop shipping a *GUI object* and instead ship the **raw numbers**, then render them ourselves. Needed from Python per "clean" request: - Epoch data array: `epochs.get_data()` → shape `(n_epochs, n_channels, n_times)`. -- Metadata: `ch_names`, `sfreq`, `times`, per-epoch condition (from the marker - registry), and the **auto-reject flags + `drop_log`** for suggestions. - -**Transport is the crux** (and the lesson from this whole debugging session): -don't JSON-serialize float arrays, and don't return a `PyProxy`. Get the numpy -buffer as a **`Float32Array` / `ArrayBuffer`** and send it as a **transferable** -over `postMessage` (zero-copy). Small metadata rides as JSON alongside. This -sidesteps the serialization wall entirely — buffers cross cleanly; Figures don't. + (Returns float64; see the transport note below on downcasting to Float32.) +- Metadata: `ch_names`, `sfreq`, `times`, **per-epoch condition codes** + (`epochs.events[:, -1]` — the numeric marker codes, *labeled* via the marker + registry's `codeToLabel`, not stored on the registry itself), and the + **auto-reject flags + `drop_log`** for suggestions. + +> **Grounding — exclude the Marker/stim channel from the arrays.** `get_data()` +> returns *all* channels, including the trailing `Marker` channel that carries the +> numeric event codes. The existing channel picker already drops it +> (`requestChannelInfo` runs `[ch for ch in clean_epochs.ch_names if ch != +> 'Marker']`); `get_epochs_arrays` must do the same, or the reviewer renders the +> raw code channel as a bogus "trace" and `ch_names` / the channel count are +> off-by-one. (The per-epoch condition codes still come from `epochs.events[:, +> -1]`, which is unaffected by dropping the channel.) + +> **Grounding correction — the registry gives labels, not colors.** +> `buildMarkerRegistry` (`markerRegistry.ts`) returns only `{ codeToLabel, +> eventId }`. There is **no color** on it. Condition colors today are hardcoded +> RGB palettes inside `utils.py` (`plot_topo`, `plot_conditions`). So condition +> coloring in the React UI needs a **new color source** — decide whether to (a) +> define a palette UI-side keyed by condition label, or (b) promote the `utils.py` +> palette into a single shared source both Python plots and the React reviewer +> read from (preferred, avoids the ERP/topo legend and the reviewer disagreeing on +> which color is which condition). Track this as a small design decision, not a +> reuse of existing code. + +**Transport is the crux** (the lesson of this whole debugging session): don't +JSON-serialize float arrays and don't return a `PyProxy`. Get the numpy buffer as +an **`ArrayBuffer`** and post it as a **transferable** (zero-copy); the metadata +above rides as JSON on the same message. Buffers cross cleanly; Figures don't. + +> **Downcasting to Float32 is safe here — and only here.** `get_data()` is +> float64; halving it to Float32 loses precision, but this buffer is *display +> only*. The apply path (§6d) is **index-based** (`epochs.drop(indices)`) — the +> epoch samples never round-trip out of Python and back, so the cleaned `.fif` +> stays bit-identical to what MNE would produce (design goal 3). Keep it that way: +> never reconstruct epochs from the Float32 buffer on the Python side. + +**This means extending the worker message contract, not just adding a Python +helper (grounded in `webworker.js` today).** The current handler is +one-size-fits-all: it runs `data`, and if the result has `.toJs` it converts the +`PyProxy` and posts `{ results, plotKey, dataKey }` **with no transfer list** — so +it can't ship a zero-copy buffer. Phase 0 must: +- Return the numpy buffer as an `ArrayBuffer` (e.g. `arr.tobytes()` off the + `PyProxy`, or via the emscripten heap) and `self.postMessage({ buffer, meta, + dataKey }, [buffer])` — the `[buffer]` transfer list is mandatory, or the buffer + is structured-cloned (a copy). +- Route it on a **new `dataKey`** (e.g. `'epochArrays'`) so it bypasses the + existing PyProxy→`toJs`→SVG-string switch in `pyodideMessageEpic` (which assumes + results are plain JS or an SVG string). ### 6b. Rendering Muse is tiny (4 ch × ~256 samples × dozens of epochs), but we must not design for @@ -162,10 +210,40 @@ Muse is tiny (4 ch × ~256 samples × dozens of epochs), but we must not design ### 6d. Apply path: renderer → Python - Collect rejected epoch indices + bad channels → dispatch an action → epic posts to the worker → Python `epochs.drop(indices)` + set `info['bads']` → save - `-cleaned-epo.fif` via the existing MEMFS/`saveEpochs` path. + `-cleaned-epo.fif`. - The result must be **bit-identical to what MNE's GUI would have produced** — the UI changes, the science does not. +**Persistence is a real gap — the Clean→Analyze round-trip is broken today.** +Traced end to end against `webworker/`, `src/main/index.ts`, `src/preload/index.ts`: +- **Save side:** `saveEpochs` (`webworker/index.ts`) posts `raw_epochs.save("")`, but Pyodide's worker FS is **MEMFS (in-memory)** — there is **no + NODEFS/`syncfs`/mount anywhere in `webworker/`**, so the write lands in the WASM + virtual FS, never on host disk. +- **Load side:** the Analyze screen re-reads *host disk* both times — at + `componentDidMount` it calls `readWorkspaceCleanedEEGData` (main-process `fs`) to + populate the picker, then on selection `LoadCleanedEpochs` → `writeEpochsToMemfs` + → `fs:readFileAsBytes` stages host bytes into MEMFS. It never reuses in-session + MEMFS epochs. +- **The gap:** nothing ever writes the cleaned `.fif` *to* host disk. Main has + write IPC (`fs:storePyodideImagePng`, `eeg:createWriteStream`, + `fs:storeAggregatedBehaviorData`) but **none writes an epoch `.fif` from the + worker**, so a fresh workspace has nothing for Analyze to load. The fix is a + **new MEMFS→host write-back bridge**, and because the worker can't reach + `ipcMain` it must cross every process boundary (see the `electron-ipc-channel` + skill) — don't collapse it to "postMessage → handler". The full chain: Python + `save()` to a MEMFS path → worker `pyodide.FS.readFile(path)` + `postMessage` the + bytes back on a **new `dataKey`** (e.g. `'savedEpochs'`, routed in + `pyodideMessageEpic` beside the existing `epochsInfo`/`channelInfo` cases — which + return no transfer list today, so shipping bytes zero-copy means extending that + reply path too) → a new epic → a new `window.electronAPI` method → the preload + bridge → a new main handler. **Reuse anchor, not new machinery:** + `fs:storePyodideImagePng` (`src/main/index.ts` line 315) is already this pattern — + `(title, imageTitle, rawData: ArrayBuffer)` → `Buffer.from(rawData)` → + `fs.writeFile` to disk; the write-back handler should mirror *it* (a binary + renderer→disk write), not `fs:readFileAsBytes`. This is not a "persist" nicety — + **it repairs a currently broken flow**. + ### 6e. The onboarding layer (the differentiator) - Plain-language explanations of epochs and each artifact type. - **Guided mode** (default for newcomers): step through auto-flagged epochs with @@ -180,21 +258,36 @@ Muse is tiny (4 ch × ~256 samples × dozens of epochs), but we must not design - **Worker protocol** (`pyodide-mne` skill): today's plots use fire-and-forget `postMessage` + `plotKey`/`dataKey` reply routing through `pyodideMessageEpic`. - A data-heavy request/response (fetch epoch arrays, get result back, then let the - user act) pushes toward finally doing the **`runPython` RPC** already tracked in - `TODOS.md` ("Optional Full Pyodide worker RPC"). This feature is the strongest - reason yet to build it — worth deciding early. + Note the `dataKey` pattern **already does request/response round-trips** + (`epochsInfo` → `SetEpochInfo`, `channelInfo` → `SetChannelInfo`), so fetching + epoch arrays and applying rejection can be built on it directly — a `runPython` + RPC is **not a hard prerequisite**. The `TODOS.md` item ("(Optional) Full + Pyodide worker RPC") itself says it's only worth doing "if the FIFO sequencing + ever actually bites." Where it *would* help this feature: an `await`-able RPC + lets an apply-then-refresh sequence (drop epochs → save → re-fetch stats/ERP) + read real results instead of relying on worker FIFO ordering across several + fire-and-forget messages. Treat it as an ergonomics/scaling call, not a blocker + (see Open Question 2). - **New Python helpers** (`webworker/utils.py`): `get_epochs_arrays(epochs)` → buffer + metadata; `apply_rejection(epochs, drop_indices, bad_channels)`. Keep them native-testable (the `tests/analysis/` pattern) so cleaning logic is verified against real MNE in CI. - **New epic(s)** in `pyodideEpics.ts` + **new actions** (fetch/set epoch data, - apply rejection). Reuse `buildMarkerRegistry` for condition labels/colors. -- **New React component** (`EpochReviewer` or similar) replacing the plot area in - `CleanComponent`. Styling: shadcn/ui + Tailwind, brand teal, student tone. + apply rejection). Reuse `buildMarkerRegistry` for condition **labels** (code↔label + only, no colors — see §6a for where condition colors must come from). +- **New React component** (`EpochReviewer` or similar) **added to** + `CleanComponent`. Correction: `CleanComponent` has **no plot area** to replace — + today it renders only subject/recording ` setChannel(Number(e.target.value))} + > + {meta.ch_names.map((name, i) => ( + + ))} + + + + +
+ +
+ + {/* Legend: one swatch + condition code + included count. */} +
+ {uniqueSortedCodes.map((code) => { + const count = groups.get(code)?.length ?? 0; + return ( + + + Condition {code} ({count}) + + ); + })} +
+ +

+ Averaged over {includedCount} epochs — reject noisy ones to clean it up +

+ + ); +} diff --git a/src/renderer/components/CleanComponent/__tests__/EpochReviewer.test.tsx b/src/renderer/components/CleanComponent/__tests__/EpochReviewer.test.tsx new file mode 100644 index 00000000..df6f674b --- /dev/null +++ b/src/renderer/components/CleanComponent/__tests__/EpochReviewer.test.tsx @@ -0,0 +1,69 @@ +import React from 'react'; +import { beforeAll, describe, expect, it, vi } from 'vitest'; +import { fireEvent, render, screen } from '@testing-library/react'; +import EpochReviewer from '../EpochReviewer'; +import type { EpochArraysMeta } from '../../../actions'; + +// jsdom does not implement .getContext; stub it so the draw effect is a +// no-op (this test exercises the DOM overlay, not the canvas pixels). +beforeAll(() => { + vi.spyOn(HTMLCanvasElement.prototype, 'getContext').mockReturnValue( + null as unknown as CanvasRenderingContext2D + ); +}); + +// Minimal 3-epoch / 2-channel / 4-sample dataset. Buffer is C-order +// [epoch][channel][time]; exact values don't matter for the click test. +function makeEpochArrays(): { buffer: ArrayBuffer; meta: EpochArraysMeta } { + const nEpochs = 3; + const nChannels = 2; + const nTimes = 4; + const data = new Float32Array(nEpochs * nChannels * nTimes); + for (let i = 0; i < data.length; i += 1) { + data[i] = Math.sin(i); + } + const meta: EpochArraysMeta = { + n_epochs: nEpochs, + n_channels: nChannels, + n_times: nTimes, + ch_names: ['Fp1', 'Fp2'], + sfreq: 256, + times: [-0.1, 0, 0.1, 0.2], + event_codes: [1, 2, 1], + drop_log: [[], [], []], + }; + return { buffer: data.buffer, meta }; +} + +describe('EpochReviewer', () => { + it('calls onToggleEpoch with the absolute index when a column overlay is clicked', () => { + const onToggleEpoch = vi.fn(); + render( + + ); + + // The overlay for epoch 1 is labelled "Reject epoch 1". + const target = screen.getByLabelText('Reject epoch 1'); + fireEvent.click(target); + + expect(onToggleEpoch).toHaveBeenCalledTimes(1); + expect(onToggleEpoch).toHaveBeenCalledWith(1); + }); + + it('labels an already-rejected column as Restore', () => { + const onToggleEpoch = vi.fn(); + render( + + ); + + expect(screen.getByLabelText('Restore epoch 0')).toBeInTheDocument(); + }); +}); diff --git a/src/renderer/components/CleanComponent/__tests__/epochArrays.test.ts b/src/renderer/components/CleanComponent/__tests__/epochArrays.test.ts index 3df5ec02..38e2ef3b 100644 --- a/src/renderer/components/CleanComponent/__tests__/epochArrays.test.ts +++ b/src/renderer/components/CleanComponent/__tests__/epochArrays.test.ts @@ -10,6 +10,7 @@ import { conditionIndexForCode, downsampleMinMax, epochChannelSeries, + meanTrace, } from '../epochArrays'; const makeMeta = (over: Partial = {}): EpochArraysMeta => ({ @@ -122,3 +123,70 @@ describe('conditionIndexForCode', () => { expect(conditionIndexForCode(99, [1, 2, 5])).toBe(0); }); }); + +describe('meanTrace', () => { + // n_epochs=3, n_channels=2, n_times=2 in C-order [epoch][channel][time]. + // channel 0 across epochs = [2,4], [4,8], [6,12] -> mean over all 3 = [4, 8]. + // channel 1 across epochs = [1,1], [3,3], [5,5] -> mean over all 3 = [3, 3]. + const makeErpFixture = () => { + const meta = makeMeta({ + n_epochs: 3, + n_channels: 2, + n_times: 2, + times: [0, 0.01], + event_codes: [1, 2, 1], + drop_log: [[], [], []], + }); + const data = Float32Array.from([ + 2, + 4, // epoch 0, channel 0 + 1, + 1, // epoch 0, channel 1 + 4, + 8, // epoch 1, channel 0 + 3, + 3, // epoch 1, channel 1 + 6, + 12, // epoch 2, channel 0 + 5, + 5, // epoch 2, channel 1 + ]); + return { meta, buffer: data.buffer }; + }; + + it('averages a channel over all epochs (exact mean)', () => { + const { meta, buffer } = makeErpFixture(); + expect(Array.from(meanTrace(buffer, meta, [0, 1, 2], 0))).toEqual([4, 8]); + }); + + it('averages the correct subset of epochs', () => { + const { meta, buffer } = makeErpFixture(); + // mean of epoch 0 ([2,4]) and epoch 2 ([6,12]) = [4, 8]. + expect(Array.from(meanTrace(buffer, meta, [0, 2], 0))).toEqual([4, 8]); + // mean of epoch 0 ([2,4]) and epoch 1 ([4,8]) = [3, 6]. + expect(Array.from(meanTrace(buffer, meta, [0, 1], 0))).toEqual([3, 6]); + }); + + it('returns a length-n_times all-zero trace for an empty selection', () => { + const { meta, buffer } = makeErpFixture(); + const out = meanTrace(buffer, meta, [], 0); + expect(out.length).toBe(meta.n_times); + expect(Array.from(out)).toEqual([0, 0]); + }); + + it('returns exactly that epoch series for a single-epoch selection', () => { + const { meta, buffer } = makeErpFixture(); + const single = Array.from(meanTrace(buffer, meta, [1], 0)); + const series = Array.from(epochChannelSeries(buffer, meta, 1, 0)); + expect(single).toEqual(series); + expect(single).toEqual([4, 8]); + }); + + it('reads the requested channel (channel 1 differs from channel 0)', () => { + const { meta, buffer } = makeErpFixture(); + expect(Array.from(meanTrace(buffer, meta, [0, 1, 2], 1))).toEqual([3, 3]); + expect(Array.from(meanTrace(buffer, meta, [0, 1, 2], 0))).not.toEqual([ + 3, 3, + ]); + }); +}); diff --git a/src/renderer/components/CleanComponent/epochArrays.ts b/src/renderer/components/CleanComponent/epochArrays.ts index 438d2a36..4e163c5b 100644 --- a/src/renderer/components/CleanComponent/epochArrays.ts +++ b/src/renderer/components/CleanComponent/epochArrays.ts @@ -69,3 +69,31 @@ export function conditionIndexForCode( const i = uniqueSortedCodes.indexOf(code); return i < 0 ? 0 : i; } + +// Mean waveform for one channel, averaged over the given epoch indices — the +// client-side ERP primitive for the live preview (recomputed on every reject +// toggle; equivalent to np.mean over those epochs for that channel). The epochs +// are already baseline-corrected + filtered at epoching time, so a plain mean +// matches MNE's evoked average to float32 display precision. Empty selection +// returns a zero-filled trace (length n_times) so the pane renders a flat line. +export function meanTrace( + buffer: ArrayBuffer, + meta: EpochArraysMeta, + epochIndices: number[], + channel: number +): Float32Array { + const out = new Float32Array(meta.n_times); + if (epochIndices.length === 0) { + return out; + } + for (const e of epochIndices) { + const series = epochChannelSeries(buffer, meta, e, channel); + for (let t = 0; t < meta.n_times; t += 1) { + out[t] += series[t]; + } + } + for (let t = 0; t < meta.n_times; t += 1) { + out[t] /= epochIndices.length; + } + return out; +} diff --git a/src/renderer/components/CleanComponent/index.tsx b/src/renderer/components/CleanComponent/index.tsx index 768890d9..7abd4638 100644 --- a/src/renderer/components/CleanComponent/index.tsx +++ b/src/renderer/components/CleanComponent/index.tsx @@ -7,6 +7,7 @@ import { EXPERIMENTS, DEVICES } from '../../constants/constants'; import { readWorkspaceRawEEGData } from '../../utils/filesystem/storage'; import CleanSidebar from './CleanSidebar'; import EpochReviewer from './EpochReviewer'; +import LiveErpPane from './LiveErpPane'; import { PyodideActions, ExperimentActions, @@ -39,6 +40,8 @@ interface State { selectedSubject: string; selectedFilePaths: Array; isSidebarVisible: boolean; + // ABSOLUTE epoch indices the student has marked for rejection. + rejectedEpochs: Set; } export default class Clean extends Component { @@ -52,11 +55,13 @@ export default class Clean extends Component { selectedFilePaths: [], selectedSubject: props.subject, isSidebarVisible: false, + rejectedEpochs: new Set(), }; this.handleRecordingChange = this.handleRecordingChange.bind(this); this.handleLoadData = this.handleLoadData.bind(this); this.handleSidebarToggle = this.handleSidebarToggle.bind(this); this.handleSubjectChange = this.handleSubjectChange.bind(this); + this.handleToggleEpoch = this.handleToggleEpoch.bind(this); this.icons = props.type === EXPERIMENTS.N170 ? ['😊', '🏠', '✕', '📖'] @@ -102,6 +107,20 @@ export default class Clean extends Component { handleLoadData() { this.props.ExperimentActions.SetSubject(this.state.selectedSubject); this.props.PyodideActions.LoadEpochs(this.state.selectedFilePaths); + // A fresh dataset invalidates any previously selected epoch indices. + this.setState({ rejectedEpochs: new Set() }); + } + + handleToggleEpoch(index: number) { + this.setState((prev) => { + const next = new Set(prev.rejectedEpochs); + if (next.has(index)) { + next.delete(index); + } else { + next.add(index); + } + return { rejectedEpochs: next }; + }); } handleSidebarToggle() { @@ -205,7 +224,15 @@ export default class Clean extends Component { variant="default" className="w-full" disabled={isNil(this.props.epochsInfo)} - onClick={() => this.props.PyodideActions.CleanEpochs()} + onClick={() => { + this.props.PyodideActions.CleanEpochs({ + dropIndices: Array.from(this.state.rejectedEpochs), + badChannels: [], + }); + // After Clean, raw_epochs is re-fetched with fewer epochs, + // so the old absolute indices no longer apply. + this.setState({ rejectedEpochs: new Set() }); + }} > Clean Data @@ -216,8 +243,16 @@ export default class Clean extends Component { {this.renderAnalyzeButton()} -
- +
+ +
diff --git a/src/renderer/epics/pyodideEpics.ts b/src/renderer/epics/pyodideEpics.ts index 8e8696dc..1eb875ea 100644 --- a/src/renderer/epics/pyodideEpics.ts +++ b/src/renderer/epics/pyodideEpics.ts @@ -15,7 +15,7 @@ import { requestEpochsInfo, requestChannelInfo, requestEpochArrays, - cleanEpochsPlot, + applyRejection, plotPSD, plotERP, plotTopoMap, @@ -215,14 +215,21 @@ const cleanEpochsEpic: Epic = ( ) => action$.pipe( filter(isActionOf(PyodideActions.CleanEpochs)), - mergeMap(async () => { - await cleanEpochsPlot(state$.value.pyodide.worker!); - return saveEpochs( - state$.value.pyodide.worker!, - state$.value.experiment.subject + pluck('payload'), + mergeMap(async ({ dropIndices, badChannels }) => { + const worker = state$.value.pyodide.worker!; + // Worker runs these FIFO: drop/flag -> save cleaned .fif -> re-fetch arrays. + applyRejection( + worker, + PYODIDE_VARIABLE_NAMES.RAW_EPOCHS, + dropIndices, + badChannels ); + saveEpochs(worker, state$.value.experiment.subject); + requestEpochArrays(worker, PYODIDE_VARIABLE_NAMES.RAW_EPOCHS); + return PYODIDE_VARIABLE_NAMES.RAW_EPOCHS; }), - map(() => PyodideActions.GetEpochsInfo(PYODIDE_VARIABLE_NAMES.RAW_EPOCHS)) + map((varName) => PyodideActions.GetEpochsInfo(varName)) ); const getEpochsInfoEpic: Epic< diff --git a/src/renderer/utils/webworker/index.ts b/src/renderer/utils/webworker/index.ts index edf16622..3c31bfd2 100644 --- a/src/renderer/utils/webworker/index.ts +++ b/src/renderer/utils/webworker/index.ts @@ -139,6 +139,22 @@ export const requestEpochArrays = (worker: Worker, variableName: string) => { }); }; +// Apply the user's rejection to raw_epochs in Python: drop the marked epoch +// indices + set bad channels, mutating in place. Trailing ';' suppresses the +// return value — apply_rejection returns the Epochs object, which cannot cross +// postMessage (PyProxy is not structured-cloneable). Fire-and-forget; the epic +// then triggers saveEpochs + requestEpochArrays, which the worker runs in order. +export const applyRejection = ( + worker: Worker, + variableName: string, + dropIndices: number[], + badChannels: string[] +) => { + worker.postMessage({ + data: `apply_rejection(${variableName}, ${JSON.stringify(dropIndices)}, ${JSON.stringify(badChannels)});`, + }); +}; + // ----------------------------- // Plot functions diff --git a/src/renderer/utils/webworker/utils.py b/src/renderer/utils/webworker/utils.py index 20d13914..4d289166 100644 --- a/src/renderer/utils/webworker/utils.py +++ b/src/renderer/utils/webworker/utils.py @@ -312,3 +312,21 @@ def get_epochs_info(epochs): return [*[{x: len(epochs[x])} for x in epochs.event_id], {"Drop Percentage": round(epochs.drop_log_stats(), 2)}, {"Total Epochs": len(epochs.events)}] + + +def apply_rejection(epochs, drop_indices, bad_channels): + """Drop the given epoch indices and mark bad channels, mutating epochs in place. + + drop_indices : list[int] -- 0-based indices into the CURRENT epochs (same + order as get_epochs_arrays produced), the epochs the user marked bad. + bad_channels : list[str] -- channel names to add to info['bads']. + + The result is exactly what MNE produces from epochs.drop(...) / info['bads'] — + the science is unchanged; only the UI that chooses the indices is new. + Returns epochs (the same, mutated object). + """ + if bad_channels: + epochs.info['bads'] = list(bad_channels) + if drop_indices: + epochs.drop(list(drop_indices)) + return epochs diff --git a/tests/analysis/test_apply_rejection.py b/tests/analysis/test_apply_rejection.py new file mode 100644 index 00000000..6d296f06 --- /dev/null +++ b/tests/analysis/test_apply_rejection.py @@ -0,0 +1,70 @@ +"""Native-MNE tests for the epoch-rejection step (Phase 1 epoch review). + +Verifies `utils.apply_rejection` drops the user-selected epoch indices and marks +bad channels producing a result that is bit-identical to what MNE itself would +produce from the same `epochs.drop(...)` / `info['bads']` operations. The UI that +chooses the indices is new; the science is unchanged. +""" +import numpy as np + +import utils # src/renderer/utils/webworker/utils.py (see conftest) +from synthetic import ( + generate_recording, + TARGET_CODE, + STANDARD_CODE, +) + +EVENT_ID = {"STANDARD": STANDARD_CODE, "TARGET": TARGET_CODE} +TMIN, TMAX = -0.1, 0.8 + + +def _build_epochs(): + csv, _ = generate_recording() + raw = utils.load_data(csv_strings=[csv]) + raw.filter(1, 30, method="iir", verbose=False) + return utils.get_raw_epochs(raw, EVENT_ID, TMIN, TMAX) + + +def test_bit_identical_to_manual_mne(): + epochs = _build_epochs() + assert len(epochs) > 3 + + bad = [epochs.ch_names[0]] + drops = [0, 2] + + manual = epochs.copy() + manual.info["bads"] = list(bad) + manual.drop(list(drops)) + + actual = epochs.copy() + utils.apply_rejection(actual, drops, bad) + + assert np.array_equal(actual.get_data(), manual.get_data()) + assert actual.info["bads"] == manual.info["bads"] + assert len(actual) == len(manual) + + +def test_drop_count(): + epochs = _build_epochs() + n0 = len(epochs.copy()) + assert n0 > 4 + + rejected = utils.apply_rejection(epochs.copy(), [1, 3], []) + assert len(rejected) == n0 - 2 + + +def test_empty_args_are_no_ops(): + epochs = _build_epochs() + before_len = len(epochs) + before_data = epochs.get_data() + + result = utils.apply_rejection(epochs.copy(), [], []) + + assert len(result) == before_len + assert np.array_equal(result.get_data(), before_data) + assert result.info["bads"] == [] + + +def test_returns_the_same_object(): + e = _build_epochs().copy() + assert utils.apply_rejection(e, [], []) is e From 637096244847f15d9b9f8b05a0cecb2b9b636e14 Mon Sep 17 00:00:00 2001 From: Dano Morrison Date: Tue, 7 Jul 2026 10:33:06 -0700 Subject: [PATCH 05/29] Lock epoch-review Phase 2 decisions (OQ4/OQ5/New-A/New-B) Auto-flag = dev-gated one-click button + expandable threshold settings; suggestions computed in Python (peak-to-peak), pre-marked but overridable. Bad-channel flagging enabled on all devices with a warning Dialog on the 2nd bad channel of a 4-ch dataset. Condition legends use codeToLabel. --- docs/epoch-review-ui-plan.md | 47 ++++++++++++++++++++++++++++++------ 1 file changed, 39 insertions(+), 8 deletions(-) diff --git a/docs/epoch-review-ui-plan.md b/docs/epoch-review-ui-plan.md index 43d4ee95..f1bd788f 100644 --- a/docs/epoch-review-ui-plan.md +++ b/docs/epoch-review-ui-plan.md @@ -45,9 +45,32 @@ temper→forge; Phases 2–4 remain roadmap. - **Delivery → two sequential PRs:** Phase 0 (transport + static render), then Phase 1 (interaction + apply + live ERP). +### Phase 2 decisions (2026-07-07) + +- **OQ4 (auto-flag UX) → one-click button + expandable settings, dev-gated.** An + "Auto-flag artifacts" button a newcomer can just press; an expandable settings + panel exposes the peak-to-peak threshold(s) for users who want to control/learn + more. The button is **dev-configurable per experiment** (a constant set keyed by + the `EXPERIMENTS` enum, gated on `CleanComponent`'s `type` prop) so devs opt + experiments in/out. +- **New-A (where auto-flag runs) → Python/MNE.** A worker round-trip + `suggest_rejections(epochs, threshold)` computes peak-to-peak per epoch in Python + and returns suggested indices + reasons (new `suggestedRejections` dataKey, + extends the OQ2 pattern). Suggestions **pre-mark** epochs in the reviewer and are + fully **overridable**; the actual drop still goes through the Phase-1 + `apply_rejection` path so the saved `.fif` stays MNE-exact. +- **OQ5 (bad channels on low-channel devices) → enabled everywhere, with a guard.** + Click a channel label to toggle it bad; bad channels flow to `apply_rejection`'s + `bad_channels` param (already built in Phase 1). On a 4-channel dataset, a + **warning Dialog** appears when the user marks a 2nd bad channel, nudging them to + re-collect if signal quality is that poor (informational — they can proceed). +- **New-B (condition legend) → human-readable labels via `codeToLabel`.** Plumb the + marker registry's `codeToLabel` (built from `state.experiment.params.stimuli`) + into the reviewer + Live ERP legends instead of raw numeric codes. +- **Delivery → one PR (Phase 2)**, stacked on Phase 1. + Remaining open (not forge-blocking; decide when their phase comes): OQ3 (onboarding -depth), OQ4 (auto-reject UX/thresholds), OQ5 (bad channels on 4-ch Muse), OQ7 -(static fallback). +depth, Phase 3), OQ7 (static fallback). --- @@ -342,10 +365,11 @@ Traced end to end against `webworker/`, `src/main/index.ts`, `src/preload/index. `runPython` RPC (reinforced by OQ6's client-side live ERP needing no round-trip). 3. **Onboarding depth** — Is guided mode the default? How much curriculum (tooltips only vs. a real walkthrough)? -4. **Auto-rejection** — Expose peak-to-peak thresholds to the user, or keep them - as invisible "suggestions"? What defaults? -5. **Bad channels on Muse** — dropping 1 of 4 channels is drastic; do we support - channel rejection for low-channel devices, or epochs-only there? +4. **Auto-rejection — RESOLVED (§0, Phase 2): one-click "Auto-flag" button + + expandable threshold settings, dev-gated per experiment; suggestions computed in + Python (peak-to-peak) and pre-marked but overridable.** +5. **Bad channels on Muse — RESOLVED (§0, Phase 2): enabled on all devices; a + warning Dialog fires when marking a 2nd bad channel on a 4-channel dataset.** 6. **Live ERP preview — RESOLVED (§0): in scope for v1 (Phase 1), computed client-side** over the Phase-0 buffer (Flavor 1 only; real-time-during-experiment is a separate future non-Python effort). @@ -396,8 +420,15 @@ Traced end to end against `webworker/`, `src/main/index.ts`, `src/preload/index. - Reaches functional parity-plus with the *essential* MNE workflow. - **Note:** the write-back bridge (§6d) already landed standalone in PR #222, so Phase 1 just points the apply action at a bridge that works. -- **Phase 2 — Full parity.** Bad-channel flagging, condition coloring/legend, - auto-flag suggestions from `drop_log`/peak-to-peak. +- **Phase 2 — Full parity (PR 4). LOCKED (§0 Phase 2 decisions).** + - **Bad-channel flagging:** click a channel label → toggle bad; bad channels flow + to the existing `apply_rejection` `bad_channels` param. Warning Dialog on the 2nd + bad channel of a 4-ch dataset (OQ5). + - **Auto-flag (OQ4 + New-A):** dev-gated "Auto-flag artifacts" button + expandable + threshold settings → `suggest_rejections(epochs, threshold)` in Python (peak-to- + peak, native-testable) → `suggestedRejections` dataKey → pre-marks epochs in the + reviewer (overridable) with reasons shown. + - **Condition legend (New-B):** real labels via `codeToLabel`. - **Phase 3 — Onboarding layer.** Explanations, guided mode, artifact tutorials, live ERP preview. - **Phase 4 — Polish & generalize.** N-channel devices (Neurosity/LSL), From a0894845114999d0afcb37b2200ef97a05a9c822 Mon Sep 17 00:00:00 2001 From: Dano Morrison Date: Tue, 7 Jul 2026 10:46:41 -0700 Subject: [PATCH 06/29] Epoch reviewer Phase 2: bad-channel flagging, auto-flag, condition labels MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Full-parity phase (docs/epoch-review-ui-plan.md §0/§9). Bad-channel flagging (OQ5): - Click a channel label to toggle it bad (struck-through red label + red lane wash across all epochs). Flows into the Phase-1 apply path: CleanEpochs({dropIndices, badChannels}) → apply_rejection sets info['bads']. - shadcn Dialog warns when marking a 2nd bad channel on a 4-channel (Muse) dataset — informational, user can proceed. Auto-flag (OQ4 + New-A, Python/MNE): - Dev-gated "Auto-flag artifacts" button (AUTO_FLAG_EXPERIMENTS set, keyed by the EXPERIMENTS enum) + expandable peak-to-peak threshold settings. - suggest_rejections(epochs, threshold_uv) in utils.py (peak-to-peak per epoch, Marker excluded; advisory — drops nothing), native-tested. New 'suggestedRejections' dataKey (fire-and-forget, no runPython RPC) → SetSuggestedRejections → CleanComponent PRE-MARKS suggested epochs (additive union, fully overridable) and shows reasons. Actual drop still via apply_rejection, so the saved .fif stays MNE-exact. Condition legend (New-B): - EpochReviewer + LiveErpPane legends use human-readable labels via buildMarkerRegistry(stimuli).codeToLabel (fallback to "Condition {code}"). typecheck 0 · eslint 0 errors · vitest 44/44 · native pytest 17/17 · build green --- src/renderer/actions/pyodideActions.ts | 17 ++ .../CleanComponent/EpochReviewer.tsx | 114 +++++++++-- .../components/CleanComponent/LiveErpPane.tsx | 5 +- .../__tests__/EpochReviewer.test.tsx | 4 + .../components/CleanComponent/index.tsx | 183 +++++++++++++++++- src/renderer/constants/constants.ts | 12 ++ src/renderer/containers/CleanContainer.ts | 1 + src/renderer/epics/pyodideEpics.ts | 34 +++- src/renderer/reducers/pyodideReducer.ts | 17 +- src/renderer/utils/webworker/index.ts | 13 ++ src/renderer/utils/webworker/utils.py | 36 ++++ tests/analysis/test_suggest_rejections.py | 68 +++++++ 12 files changed, 477 insertions(+), 27 deletions(-) create mode 100644 tests/analysis/test_suggest_rejections.py diff --git a/src/renderer/actions/pyodideActions.ts b/src/renderer/actions/pyodideActions.ts index 41f2d276..22c89280 100644 --- a/src/renderer/actions/pyodideActions.ts +++ b/src/renderer/actions/pyodideActions.ts @@ -14,6 +14,15 @@ export interface EpochArraysMeta { drop_log: string[][]; } +// Auto-flag: a single artifact suggestion returned by Python's +// suggest_rejections(epochs, threshold_uv) — one epoch/channel over threshold. +export interface SuggestedRejection { + index: number; + channel: string; + peak_uv: number; + reason: string; +} + // ------------------------------------------------------------------------- // Actions @@ -56,6 +65,14 @@ export const PyodideActions = { // eslint-disable-next-line @typescript-eslint/no-explicit-any ReceiveError: createAction('RECEIVE_ERROR'), // Worker error event — shape is dynamic SetWorkerReady: createAction('SET_WORKER_READY'), + // Auto-flag: request suggestions (payload = peak-to-peak threshold in µV). + GetSuggestedRejections: createAction( + 'GET_SUGGESTED_REJECTIONS' + ), + SetSuggestedRejections: createAction< + SuggestedRejection[], + 'SET_SUGGESTED_REJECTIONS' + >('SET_SUGGESTED_REJECTIONS'), } as const; export type PyodideActionType = ActionType< diff --git a/src/renderer/components/CleanComponent/EpochReviewer.tsx b/src/renderer/components/CleanComponent/EpochReviewer.tsx index 06184e6b..5a92e8c3 100644 --- a/src/renderer/components/CleanComponent/EpochReviewer.tsx +++ b/src/renderer/components/CleanComponent/EpochReviewer.tsx @@ -27,6 +27,12 @@ import { // each epoch click-to-reject (rejected epochs grey out); Prev/Next page through // all epochs; amp +/- scale trace amplitude. Rendering stays Canvas 2D + a DOM // overlay for labels/hit-targets (no canvas hit-testing). +// +// Interactive (Phase 2): channel labels in the left gutter are click-to-flag +// bad-channel toggles — a flagged channel's LANE is washed translucent red +// across ALL epoch columns (and its label renders struck-through/red) so bad +// channels read at a glance. An optional condition legend maps numeric event +// codes to human-readable labels via `codeToLabel`. // --------------------------------------------------------------------------- interface Props { @@ -35,6 +41,12 @@ interface Props { rejected: Set; // Toggle a single ABSOLUTE epoch index in/out of the rejected set. onToggleEpoch: (index: number) => void; + // Channel names the student has flagged as "bad" (controlled by the parent). + badChannels: Set; + // Toggle a single channel name in/out of the bad-channel set. + onToggleChannel: (name: string) => void; + // Optional map from numeric event code to a human-readable condition label. + codeToLabel?: Record; } // Logical canvas size (scaled up for devicePixelRatio at draw time). @@ -56,11 +68,16 @@ const GAIN_MAX = 20; const REJECTED_TRACE_COLOR = 'rgba(120, 120, 120, 0.5)'; const REJECTED_FILL_COLOR = 'rgba(120, 120, 120, 0.15)'; +// Translucent wash over a flagged bad channel's lane (drawn across all epochs). +const BAD_CHANNEL_FILL_COLOR = 'rgba(200, 60, 60, 0.10)'; export default function EpochReviewer({ epochArrays, rejected, onToggleEpoch, + badChannels, + onToggleChannel, + codeToLabel, }: Props): JSX.Element { const canvasRef = useRef(null); // First epoch of the current page (absolute index). @@ -100,7 +117,7 @@ export default function EpochReviewer({ ctx.clearRect(0, 0, CANVAS_WIDTH, CANVAS_HEIGHT); const { buffer } = epochArrays; - const { n_epochs, n_channels, n_times, event_codes } = meta; + const { n_epochs, n_channels, n_times, event_codes, ch_names } = meta; const plotWidth = CANVAS_WIDTH - LABEL_GUTTER; const plotHeight = CANVAS_HEIGHT - BOTTOM_GUTTER; @@ -120,6 +137,20 @@ export default function EpochReviewer({ } } + // Translucent red wash over flagged bad-channel lanes — spans every epoch + // column (drawn under traces) so bad channels read at a glance. + for (let ch = 0; ch < n_channels; ch += 1) { + if (badChannels.has(ch_names[ch])) { + ctx.fillStyle = BAD_CHANNEL_FILL_COLOR; + ctx.fillRect( + LABEL_GUTTER, + ch * laneHeight, + CANVAS_WIDTH - LABEL_GUTTER, + laneHeight + ); + } + } + // Faint lane dividers (channels). ctx.strokeStyle = 'rgba(0, 0, 0, 0.08)'; ctx.lineWidth = 1; @@ -235,7 +266,7 @@ export default function EpochReviewer({ ctx.stroke(); } } - }, [epochArrays, meta, rejected, clampedStart, perPage, gain]); + }, [epochArrays, meta, rejected, clampedStart, perPage, gain, badChannels]); // Empty state — friendly, brand-styled, student-facing. if (!epochArrays || !meta || meta.n_epochs === 0) { @@ -252,6 +283,12 @@ export default function EpochReviewer({ const firstShown = clampedStart + 1; const lastShown = clampedStart + visibleCount; + // Unique condition codes (sorted) for the legend — mirrors the canvas draw's + // deterministic per-condition coloring. + const uniqueSortedCodes = [...new Set(meta.event_codes)].sort( + (a, b) => a - b + ); + return (
@@ -306,22 +343,41 @@ export default function EpochReviewer({ style={{ width: CANVAS_WIDTH, height: CANVAS_HEIGHT }} /> - {/* Channel labels (left gutter). */} - {meta.ch_names.map((name, ch) => ( -
- {name} -
- ))} + {/* Channel labels (left gutter) double as click-to-flag bad-channel + toggles. A flagged channel renders struck-through + red and its lane + is washed red across every epoch column (see canvas draw). */} + {meta.ch_names.map((name, ch) => { + const isBad = badChannels.has(name); + return ( +
onToggleChannel(name)} + onKeyDown={(e) => { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + onToggleChannel(name); + } + }} + > + {name} +
+ ); + })} {/* Transparent click targets — one per visible epoch column. Clicking toggles that ABSOLUTE epoch index in/out of the rejected set. */} @@ -375,6 +431,28 @@ export default function EpochReviewer({ })}
+ {/* Condition legend: one swatch + human-readable label per unique code. */} + {uniqueSortedCodes.length > 0 && ( +
+ {uniqueSortedCodes.map((code) => ( + + + {codeToLabel?.[code] ?? `Condition ${code}`} + + ))} +
+ )} +

showing {firstShown}–{lastShown} of {meta.n_epochs} epochs {rejected.size > 0 && ` · ${rejected.size} marked for rejection`} diff --git a/src/renderer/components/CleanComponent/LiveErpPane.tsx b/src/renderer/components/CleanComponent/LiveErpPane.tsx index 0c95ddbb..b336d5a2 100644 --- a/src/renderer/components/CleanComponent/LiveErpPane.tsx +++ b/src/renderer/components/CleanComponent/LiveErpPane.tsx @@ -24,6 +24,8 @@ interface Props { epochArrays: { buffer: ArrayBuffer; meta: EpochArraysMeta } | null; // ABSOLUTE epoch indices marked for rejection (excluded from the average). rejected: Set; + // Optional map from numeric event code to a human-readable condition label. + codeToLabel?: Record; } const CANVAS_WIDTH = 640; @@ -36,6 +38,7 @@ const PAD_BOTTOM = 12; export default function LiveErpPane({ epochArrays, rejected, + codeToLabel, }: Props): JSX.Element { const canvasRef = useRef(null); const [channel, setChannel] = useState(0); @@ -249,7 +252,7 @@ export default function LiveErpPane({ ), }} /> - Condition {code} ({count}) + {codeToLabel?.[code] ?? `Condition ${code}`} ({count}) ); })} diff --git a/src/renderer/components/CleanComponent/__tests__/EpochReviewer.test.tsx b/src/renderer/components/CleanComponent/__tests__/EpochReviewer.test.tsx index df6f674b..301f9445 100644 --- a/src/renderer/components/CleanComponent/__tests__/EpochReviewer.test.tsx +++ b/src/renderer/components/CleanComponent/__tests__/EpochReviewer.test.tsx @@ -43,6 +43,8 @@ describe('EpochReviewer', () => { epochArrays={makeEpochArrays()} rejected={new Set()} onToggleEpoch={onToggleEpoch} + badChannels={new Set()} + onToggleChannel={vi.fn()} /> ); @@ -61,6 +63,8 @@ describe('EpochReviewer', () => { epochArrays={makeEpochArrays()} rejected={new Set([0])} onToggleEpoch={onToggleEpoch} + badChannels={new Set()} + onToggleChannel={vi.fn()} /> ); diff --git a/src/renderer/components/CleanComponent/index.tsx b/src/renderer/components/CleanComponent/index.tsx index 7abd4638..f68737a5 100644 --- a/src/renderer/components/CleanComponent/index.tsx +++ b/src/renderer/components/CleanComponent/index.tsx @@ -3,7 +3,21 @@ import path from 'pathe'; import { Link } from 'react-router-dom'; import { isNil, isString } from 'lodash'; import { Button } from '../ui/button'; -import { EXPERIMENTS, DEVICES } from '../../constants/constants'; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogDescription, +} from '../ui/dialog'; +import { + EXPERIMENTS, + DEVICES, + AUTO_FLAG_EXPERIMENTS, + DEFAULT_PTP_THRESHOLD_UV, +} from '../../constants/constants'; +import { ExperimentParameters } from '../../constants/interfaces'; +import { buildMarkerRegistry } from '../../utils/eeg/markerRegistry'; import { readWorkspaceRawEEGData } from '../../utils/filesystem/storage'; import CleanSidebar from './CleanSidebar'; import EpochReviewer from './EpochReviewer'; @@ -12,6 +26,7 @@ import { PyodideActions, ExperimentActions, EpochArraysMeta, + SuggestedRejection, } from '../../actions'; export interface Props { @@ -26,6 +41,8 @@ export interface Props { ExperimentActions: typeof ExperimentActions; subject: string; session: number; + params: ExperimentParameters | null; + suggestedRejections: SuggestedRejection[]; } interface DropdownOption { @@ -42,6 +59,14 @@ interface State { isSidebarVisible: boolean; // ABSOLUTE epoch indices the student has marked for rejection. rejectedEpochs: Set; + // Channel names the student has flagged as bad (dropped across all epochs). + badChannels: Set; + // Peak-to-peak threshold (µV) used by the auto-flag request. + autoFlagThreshold: number; + // Whether the auto-flag threshold settings panel is open. + showAutoFlagSettings: boolean; + // Whether the "too many bad channels" warning dialog is open. + showChannelWarning: boolean; } export default class Clean extends Component { @@ -56,12 +81,19 @@ export default class Clean extends Component { selectedSubject: props.subject, isSidebarVisible: false, rejectedEpochs: new Set(), + badChannels: new Set(), + autoFlagThreshold: DEFAULT_PTP_THRESHOLD_UV, + showAutoFlagSettings: false, + showChannelWarning: false, }; this.handleRecordingChange = this.handleRecordingChange.bind(this); this.handleLoadData = this.handleLoadData.bind(this); this.handleSidebarToggle = this.handleSidebarToggle.bind(this); this.handleSubjectChange = this.handleSubjectChange.bind(this); this.handleToggleEpoch = this.handleToggleEpoch.bind(this); + this.handleToggleChannel = this.handleToggleChannel.bind(this); + this.handleAutoFlag = this.handleAutoFlag.bind(this); + this.handleThresholdChange = this.handleThresholdChange.bind(this); this.icons = props.type === EXPERIMENTS.N170 ? ['😊', '🏠', '✕', '📖'] @@ -107,8 +139,22 @@ export default class Clean extends Component { handleLoadData() { this.props.ExperimentActions.SetSubject(this.state.selectedSubject); this.props.PyodideActions.LoadEpochs(this.state.selectedFilePaths); - // A fresh dataset invalidates any previously selected epoch indices. - this.setState({ rejectedEpochs: new Set() }); + // A fresh dataset invalidates any previously selected epoch indices and + // bad-channel selections. + this.setState({ rejectedEpochs: new Set(), badChannels: new Set() }); + } + + componentDidUpdate(prevProps: Props) { + if (prevProps.suggestedRejections !== this.props.suggestedRejections) { + const suggested = this.props.suggestedRejections; + if (suggested.length > 0) { + this.setState((prev) => { + const next = new Set(prev.rejectedEpochs); + for (const s of suggested) next.add(s.index); + return { rejectedEpochs: next }; + }); + } + } } handleToggleEpoch(index: number) { @@ -123,6 +169,39 @@ export default class Clean extends Component { }); } + handleToggleChannel(name: string) { + this.setState((prev) => { + const next = new Set(prev.badChannels); + const adding = !next.has(name); + if (adding) { + next.add(name); + } else { + next.delete(name); + } + const nCh = this.props.epochArrays?.meta.n_channels ?? 0; + // Dropping >1 of a 4-channel (Muse) recording loses a lot of signal — + // warn (informational; they can still proceed). + const warn = adding && next.size > 1 && nCh === 4; + return { + badChannels: next, + showChannelWarning: warn || prev.showChannelWarning, + }; + }); + } + + handleAutoFlag() { + this.props.PyodideActions.GetSuggestedRejections( + this.state.autoFlagThreshold + ); + } + + handleThresholdChange(e: React.ChangeEvent) { + const parsed = parseFloat(e.target.value); + if (!Number.isNaN(parsed)) { + this.setState({ autoFlagThreshold: parsed }); + } + } + handleSidebarToggle() { this.setState({ isSidebarVisible: !this.state.isSidebarVisible }); } @@ -169,6 +248,14 @@ export default class Clean extends Component { return this.state.selectedSubject === subjectFromFilepath; }); + const codeToLabel = buildMarkerRegistry( + this.props.params?.stimuli ?? [] + ).codeToLabel; + const showAutoFlag = AUTO_FLAG_EXPERIMENTS.has( + this.props.type as EXPERIMENTS + ); + const { suggestedRejections } = this.props; + return (

{this.state.isSidebarVisible && ( @@ -227,16 +314,75 @@ export default class Clean extends Component { onClick={() => { this.props.PyodideActions.CleanEpochs({ dropIndices: Array.from(this.state.rejectedEpochs), - badChannels: [], + badChannels: Array.from(this.state.badChannels), }); // After Clean, raw_epochs is re-fetched with fewer epochs, // so the old absolute indices no longer apply. - this.setState({ rejectedEpochs: new Set() }); + this.setState({ + rejectedEpochs: new Set(), + badChannels: new Set(), + }); }} > Clean Data
+ {showAutoFlag && ( +
+
+ + +
+ {this.state.showAutoFlagSettings && ( +
+ +

+ Flag epochs whose peak-to-peak amplitude exceeds this. + Higher = fewer flags. +

+
+ )} + {suggestedRejections.length > 0 && ( +
+

+ Flagged {suggestedRejections.length}{' '} + {suggestedRejections.length === 1 ? 'epoch' : 'epochs'} +

+
    + {suggestedRejections.slice(0, 3).map((s, i) => ( +
  • + {s.reason} +
  • + ))} +
+
+ )} +
+ )}
{this.renderEpochLabels()} @@ -248,13 +394,40 @@ export default class Clean extends Component { epochArrays={this.props.epochArrays} rejected={this.state.rejectedEpochs} onToggleEpoch={this.handleToggleEpoch} + badChannels={this.state.badChannels} + onToggleChannel={this.handleToggleChannel} + codeToLabel={codeToLabel} />
+ this.setState({ showChannelWarning: o })} + > + + + That's a lot of channels to drop + + You've marked more than one bad channel on a 4-channel + recording. That removes a big chunk of your data — if the signal + is really this noisy, consider collecting another dataset. + + +
+ +
+
+
); } diff --git a/src/renderer/constants/constants.ts b/src/renderer/constants/constants.ts index f2e32f8d..f1355ea6 100644 --- a/src/renderer/constants/constants.ts +++ b/src/renderer/constants/constants.ts @@ -9,6 +9,18 @@ export enum EXPERIMENTS { // SSVEP = 'Steady-state Visual Evoked Potential', } +// Experiments where the "Auto-flag artifacts" button is offered on the Clean +// screen. Dev-configurable — add/remove experiments here to opt them in/out. +export const AUTO_FLAG_EXPERIMENTS = new Set([ + EXPERIMENTS.N170, + EXPERIMENTS.STROOP, + EXPERIMENTS.MULTI, + EXPERIMENTS.SEARCH, +]); + +// Default peak-to-peak artifact threshold (microvolts) for auto-flag. +export const DEFAULT_PTP_THRESHOLD_UV = 100; + export const SCREENS = { HOME: { route: '/', title: 'HOME', order: 0 }, BANK: { route: '/home', title: 'HOME', order: 0 }, diff --git a/src/renderer/containers/CleanContainer.ts b/src/renderer/containers/CleanContainer.ts index 33cd55af..8d03efab 100644 --- a/src/renderer/containers/CleanContainer.ts +++ b/src/renderer/containers/CleanContainer.ts @@ -11,6 +11,7 @@ function mapStateToProps(state: RootState) { subject: state.experiment.subject, group: state.experiment.group, session: state.experiment.session, + params: state.experiment.params, deviceType: state.device.deviceType, ...state.pyodide, }; diff --git a/src/renderer/epics/pyodideEpics.ts b/src/renderer/epics/pyodideEpics.ts index 1eb875ea..58e5afa5 100644 --- a/src/renderer/epics/pyodideEpics.ts +++ b/src/renderer/epics/pyodideEpics.ts @@ -3,7 +3,12 @@ import { EMPTY, from, fromEvent, Observable, of } from 'rxjs'; import { map, mergeMap, tap, pluck, filter, catchError } from 'rxjs/operators'; import { toast } from 'react-toastify'; import { isActionOf } from '../utils/redux'; -import { PyodideActions, PyodideActionType, EpochArraysMeta } from '../actions'; +import { + PyodideActions, + PyodideActionType, + EpochArraysMeta, + SuggestedRejection, +} from '../actions'; import { RootState } from '../reducers'; import { buildMarkerRegistry } from '../utils/eeg/markerRegistry'; import { @@ -15,6 +20,7 @@ import { requestEpochsInfo, requestChannelInfo, requestEpochArrays, + requestSuggestRejections, applyRejection, plotPSD, plotERP, @@ -115,6 +121,11 @@ const pyodideMessageEpic: Epic< }) ); } + if (dataKey === 'suggestedRejections') { + return of( + PyodideActions.SetSuggestedRejections(results as SuggestedRejection[]) + ); + } if (dataKey === 'savedEpochs') { const savedEpochsBuffer = e.data.buffer as ArrayBuffer; const { title, subject } = state$.value.experiment; @@ -257,6 +268,26 @@ const getChannelInfoEpic: Epic< mergeMap(() => EMPTY) ); +const getSuggestedRejectionsEpic: Epic< + PyodideActionType, + PyodideActionType, + RootState +> = (action$, state$) => + action$.pipe( + filter(isActionOf(PyodideActions.GetSuggestedRejections)), + pluck('payload'), + // Fire-and-forget: result returns via pyodideMessageEpic → + // SetSuggestedRejections (dataKey 'suggestedRejections'). + tap((threshold) => + requestSuggestRejections( + state$.value.pyodide.worker!, + PYODIDE_VARIABLE_NAMES.RAW_EPOCHS, + threshold + ) + ), + mergeMap(() => EMPTY) + ); + const loadPSDEpic: Epic = ( action$, state$ @@ -308,6 +339,7 @@ export default combineEpics( cleanEpochsEpic, getEpochsInfoEpic, getChannelInfoEpic, + getSuggestedRejectionsEpic, loadPSDEpic, loadTopoEpic, loadERPEpic diff --git a/src/renderer/reducers/pyodideReducer.ts b/src/renderer/reducers/pyodideReducer.ts index c3d7e3a2..e43e55ea 100644 --- a/src/renderer/reducers/pyodideReducer.ts +++ b/src/renderer/reducers/pyodideReducer.ts @@ -1,5 +1,10 @@ import { createReducer } from '@reduxjs/toolkit'; -import { PyodideActions, ExperimentActions, EpochArraysMeta } from '../actions'; +import { + PyodideActions, + ExperimentActions, + EpochArraysMeta, + SuggestedRejection, +} from '../actions'; export interface PyodideStateType { readonly epochsInfo: Array<{ @@ -25,6 +30,7 @@ export interface PyodideStateType { | null | undefined; readonly epochArrays: { buffer: ArrayBuffer; meta: EpochArraysMeta } | null; + readonly suggestedRejections: SuggestedRejection[]; readonly worker: Worker | null; readonly isWorkerReady: boolean; } @@ -36,6 +42,7 @@ const initialState: PyodideStateType = { topoPlot: null, erpPlot: null, epochArrays: null, + suggestedRejections: [], worker: null, isWorkerReady: false, }; @@ -79,8 +86,13 @@ export default createReducer(initialState, (builder) => }; }) .addCase(PyodideActions.SetEpochArrays, (state, action) => { - return { ...state, epochArrays: action.payload }; + // New epoch arrays → any prior auto-flag suggestions are stale. + return { ...state, epochArrays: action.payload, suggestedRejections: [] }; }) + .addCase(PyodideActions.SetSuggestedRejections, (state, action) => ({ + ...state, + suggestedRejections: action.payload, + })) .addCase(PyodideActions.SetWorkerReady, (state) => { return { ...state, isWorkerReady: true }; }) @@ -90,6 +102,7 @@ export default createReducer(initialState, (builder) => epochsInfo: [], channelInfo: [], epochArrays: null, + suggestedRejections: [], psdPlot: null, topoPlot: null, erpPlot: null, diff --git a/src/renderer/utils/webworker/index.ts b/src/renderer/utils/webworker/index.ts index 3c31bfd2..d91f85bc 100644 --- a/src/renderer/utils/webworker/index.ts +++ b/src/renderer/utils/webworker/index.ts @@ -139,6 +139,19 @@ export const requestEpochArrays = (worker: Worker, variableName: string) => { }); }; +// Auto-flag: ask Python which epochs look like artifacts (peak-to-peak > +// thresholdUv). Fire-and-forget; result returns on dataKey 'suggestedRejections'. +export const requestSuggestRejections = ( + worker: Worker, + variableName: string, + thresholdUv: number +) => { + worker.postMessage({ + data: `suggest_rejections(${variableName}, ${thresholdUv})`, + dataKey: 'suggestedRejections', + }); +}; + // Apply the user's rejection to raw_epochs in Python: drop the marked epoch // indices + set bad channels, mutating in place. Trailing ';' suppresses the // return value — apply_rejection returns the Epochs object, which cannot cross diff --git a/src/renderer/utils/webworker/utils.py b/src/renderer/utils/webworker/utils.py index 4d289166..4657f94d 100644 --- a/src/renderer/utils/webworker/utils.py +++ b/src/renderer/utils/webworker/utils.py @@ -330,3 +330,39 @@ def apply_rejection(epochs, drop_indices, bad_channels): if drop_indices: epochs.drop(list(drop_indices)) return epochs + + +def suggest_rejections(epochs, threshold_uv): + """Suggest artifact epochs by peak-to-peak amplitude (does NOT drop anything). + + For each epoch, compute the per-channel peak-to-peak (max-min over time) on the + EEG channels only (Marker/stim excluded), take the worst channel, and if it + exceeds threshold_uv microvolts, suggest that epoch. Advisory only — the UI + pre-marks these but the user can override; the real drop goes through + apply_rejection so the saved data stays MNE-exact. + + Returns list[dict] with keys: index (int), channel (str), peak_uv (float, + rounded 1dp), reason (str). MNE data is in volts; threshold_uv is microvolts. + """ + # NOTE: peak_uv is in microvolts; index is 0-based into the CURRENT epochs + # (same order as get_epochs_arrays produced). + picks = pick_types(epochs.info, eeg=True) + data = epochs.get_data(picks=picks) # (n_epochs, n_channels, n_times), volts + ch_names = [epochs.ch_names[i] for i in picks] + ptp = data.max(axis=2) - data.min(axis=2) # (n_epochs, n_channels), volts + thresh_v = threshold_uv * 1e-6 + suggestions = [] + for e in range(ptp.shape[0]): + worst = int(ptp[e].argmax()) + peak_v = float(ptp[e, worst]) + if peak_v > thresh_v: + peak_uv = round(peak_v * 1e6, 1) + suggestions.append({ + "index": int(e), + "channel": ch_names[worst], + "peak_uv": peak_uv, + "reason": "peak-to-peak {}µV on {}".format( + round(peak_v * 1e6), ch_names[worst] + ), + }) + return suggestions diff --git a/tests/analysis/test_suggest_rejections.py b/tests/analysis/test_suggest_rejections.py new file mode 100644 index 00000000..36bf132a --- /dev/null +++ b/tests/analysis/test_suggest_rejections.py @@ -0,0 +1,68 @@ +"""Native-MNE tests for the artifact-suggestion step (Phase 2 epoch review). + +Verifies `utils.suggest_rejections` flags epochs whose worst EEG-channel +peak-to-peak amplitude exceeds a microvolt threshold. It is advisory only — it +never drops anything (the real drop goes through `apply_rejection`) — so these +tests only check which epochs it *suggests*, and the shape of each suggestion. +""" +import utils # src/renderer/utils/webworker/utils.py (see conftest) +from synthetic import ( + generate_recording, + TARGET_CODE, + STANDARD_CODE, +) + +EVENT_ID = {"STANDARD": STANDARD_CODE, "TARGET": TARGET_CODE} +TMIN, TMAX = -0.1, 0.8 + + +def _build_epochs(): + csv, _ = generate_recording() + raw = utils.load_data(csv_strings=[csv]) + raw.filter(1, 30, method="iir", verbose=False) + return utils.get_raw_epochs(raw, EVENT_ID, TMIN, TMAX) + + +def test_threshold_gating(): + epochs = _build_epochs() + assert len(epochs) > 3 + + # Very high threshold: nothing is anomalous. + assert utils.suggest_rejections(epochs, 1e9) == [] + + # Very low threshold: every epoch is flagged. + suggestions = utils.suggest_rejections(epochs, 0) + assert len(suggestions) == len(epochs) + for s in suggestions: + assert set(s.keys()) == {"index", "channel", "peak_uv", "reason"} + assert isinstance(s["index"], int) + assert 0 <= s["index"] < len(epochs) + assert s["channel"] in epochs.ch_names + assert isinstance(s["peak_uv"], float) + assert s["peak_uv"] > 0 + assert isinstance(s["reason"], str) + + +def test_detects_injected_artifact(): + epochs = _build_epochs() + assert len(epochs) > 3 + + ep = epochs.copy() + k = 2 + # 5 mV = 5000 µV peak-to-peak bump on one EEG channel of one epoch. + data = ep._data + data[k, 0, :] += 5e-3 + + # Threshold between the normal ptp and the artifact. + suggestions = utils.suggest_rejections(ep, 1000) + indices = [s["index"] for s in suggestions] + + assert k in indices + flagged = next(s for s in suggestions if s["index"] == k) + assert flagged["peak_uv"] > 1000 + + +def test_marker_channel_excluded(): + epochs = _build_epochs() + suggestions = utils.suggest_rejections(epochs, 0) + assert all(s["channel"] != "Marker" for s in suggestions) From 330f9121b4f9dbc126776bc217a0df0b571fe146 Mon Sep 17 00:00:00 2001 From: Dano Morrison Date: Tue, 7 Jul 2026 10:54:53 -0700 Subject: [PATCH 07/29] TODOS: capture epoch-reviewer Phase 3/4 + Phase 2 polish follow-ups MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 3 (onboarding layer, OQ3 open — route via office-hours/ceo-review) and Phase 4 (N-channel/WebGL, a11y, keyboard, perf; OQ7 open) added under Deferred. Three non-blocking Phase 2 review notes (bad-channel display exclude='bads', additive auto-flag re-merge, threshold min guard) added under tech debt. --- TODOS.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/TODOS.md b/TODOS.md index 1896c35a..168dd44e 100644 --- a/TODOS.md +++ b/TODOS.md @@ -15,12 +15,15 @@ Deferred and in-flight work. Keep this current — when something ships, delete - [ ] External / generic LSL stream support (any EEG device). - [ ] Lab.js cleanup — remove jspsych, type lab.js data. Not on the content critical path; users never see it. - [ ] Lesson surface beyond markdown — block-based programming, embedded notebooks (the CLAUDE.md extensibility horizon). +- [ ] **Epoch reviewer Phase 3 — onboarding layer.** Plan: `docs/epoch-review-ui-plan.md` §9. Plain-language explanations of epochs + each artifact type, a **guided mode** (step through auto-flagged epochs with "why we flagged this," student confirms/overrides), channel legend tied to head position (Muse 10-20), student-facing tone. Builds on the Phase 0-2 reviewer (PRs #223/#224/#225). **Open question OQ3 (onboarding depth) is still unresolved** — how much curriculum (tooltips only vs. a real walkthrough), guided-mode-as-default? This is product-shaped, not architecture — take it through `/office-hours` or `/plan-ceo-review` before forging. Note it overlaps the "in-app lesson surface" critical-path work above; sequence deliberately. +- [ ] **Epoch reviewer Phase 4 — polish & generalize.** Plan: `docs/epoch-review-ui-plan.md` §9. N-channel devices (Neurosity 8-ch, external LSL 32-64-ch) — the renderer already windows/downsamples but needs real testing at scale and possibly the WebGL swap behind `drawEpochs` (OQ1 left that a later swap). Accessibility, keyboard-first flow, performance. Also OQ7 (keep a read-only static-SVG fallback for environments where the interactive UI can't run?) is still open. ## Known issues / tech debt - [ ] **(Optional) Full Pyodide worker RPC** — the analysis/Clean pipeline crash is now **fixed** (harvested from PR #194): a `dataKey` routing pattern parallel to `plotKey` — the worker echoes `dataKey` + PyProxy-converted results, and `pyodideMessageEpic` routes `epochsInfo`→`SetEpochInfo` / `channelInfo`→`SetChannelInfo`; the info epics are fire-and-forget. This unblocks the pipeline without the bigger refactor. The deeper latent issue remains, though: `worker.postMessage` returns `undefined` on *post*, so the `await`s in `webworker/index.ts` are no-ops and cross-message sequencing still relies on worker FIFO. A true `runPython(worker, code, ctx?)` RPC — `Map` + one `message` listener, worker echoes `id` — would let epics `await` real results and delete the `plotKey`/`dataKey` switch entirely. Only worth doing if the FIFO sequencing ever actually bites; not urgent now. - [ ] Pyodide-fidelity smoke test — analysis pipeline is tested against native MNE, not yet under Pyodide/WASM (see `.llms/learnings.md`). **In progress:** the epoch-review Phase 0 (see `docs/epoch-review-ui-plan.md` §0) adds a *narrow* Pyodide test for the `get_epochs_arrays` float32 buffer path (byteLength, decode-vs-native, transfer detaches source); the full-pipeline Pyodide job remains deferred. - [ ] Pre-existing TypeScript errors (not regressions): `experimentEpics.ts` (RxJS operator types), `routes.tsx` (Redux container prop types). +- [ ] **Epoch reviewer Phase 2 polish (from PR #225 review).** Three non-blocking behaviors flagged during the Phase 2 forge: (1) `get_epochs_arrays`/`suggest_rejections` use `pick_types(eeg=True)` whose MNE default is `exclude='bads'`, so after a Clean that flags a bad channel the re-fetched reviewer omits that channel from the display (saved `.fif` is unaffected) — decide whether to keep bad channels visible-but-greyed (`exclude=[]`) instead of vanishing; (2) re-running "Auto-flag" with the same threshold re-adds suggestions the user had manually unclicked (additive union merge in `CleanComponent.componentDidUpdate`); (3) the auto-flag threshold `` has no min guard (0 µV flags everything). All in `src/renderer/components/CleanComponent/` + `webworker/utils.py`. ## Done recently From b9a5387ede7eebd8117f8fae53b734a8b7fb6b8d Mon Sep 17 00:00:00 2001 From: jdpigeon Date: Tue, 7 Jul 2026 17:19:12 -0400 Subject: [PATCH 08/29] Epoch reviewer Phase 2: address PR #225 review comments - Strip process-narrating / Phase-history comments (EpochReviewer top block, pyodideActions, pyodideEpics, webworker index, EpochReviewer color/legend); keep only definition-level intent comments. - Invert auto-flag registration: replace the central AUTO_FLAG_EXPERIMENTS set with an opt-out hideAutoFlagEpochs field on ExperimentParameters (default on), colocated with the experiment definition. - Memoize buildMarkerRegistry by stimuli reference so the code->label map isn't rebuilt on every CleanComponent render. - Record the repo's comment-style preference in .llms/learnings.md. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_019uiGzUcGN7j6qDWrfH5MCr --- .llms/learnings.md | 10 ++++++ src/renderer/actions/pyodideActions.ts | 1 - .../CleanComponent/EpochReviewer.tsx | 33 +++---------------- .../components/CleanComponent/index.tsx | 17 +++++----- src/renderer/constants/constants.ts | 10 ------ src/renderer/constants/interfaces.ts | 1 + src/renderer/epics/pyodideEpics.ts | 2 -- src/renderer/utils/webworker/index.ts | 2 -- 8 files changed, 24 insertions(+), 52 deletions(-) diff --git a/.llms/learnings.md b/.llms/learnings.md index 9443e174..3c25c157 100644 --- a/.llms/learnings.md +++ b/.llms/learnings.md @@ -9,6 +9,16 @@ Format: brief heading + explanation + (optional) relevant file paths. +## Comment style: keep them on definitions, not inside logic + +This repo prefers minimal comments. Write them on **function, prop, or data-structure +definitions** (docstrings / JSDoc, a one-liner over a type field, an interface member) — +they describe intent and behavior that outlives the implementation. Avoid comments +*inside* function bodies that narrate the process step-by-step, restate the code, or +tag "Phase N"/PR history; that knowledge belongs in the definition's summary, the +relevant skill (e.g. the pyodide skill), or git history. A component's top-of-file +comment should concisely state what it is and does — not a diagram of every branch. + ## Markers: device-agnostic injection via the EEGDriver interface Marker injection used to be Muse-only and lived in the UI (`RunComponent.eventCallback` diff --git a/src/renderer/actions/pyodideActions.ts b/src/renderer/actions/pyodideActions.ts index 22c89280..04b41d1c 100644 --- a/src/renderer/actions/pyodideActions.ts +++ b/src/renderer/actions/pyodideActions.ts @@ -65,7 +65,6 @@ export const PyodideActions = { // eslint-disable-next-line @typescript-eslint/no-explicit-any ReceiveError: createAction('RECEIVE_ERROR'), // Worker error event — shape is dynamic SetWorkerReady: createAction('SET_WORKER_READY'), - // Auto-flag: request suggestions (payload = peak-to-peak threshold in µV). GetSuggestedRejections: createAction( 'GET_SUGGESTED_REJECTIONS' ), diff --git a/src/renderer/components/CleanComponent/EpochReviewer.tsx b/src/renderer/components/CleanComponent/EpochReviewer.tsx index 5a92e8c3..ea5bc2b8 100644 --- a/src/renderer/components/CleanComponent/EpochReviewer.tsx +++ b/src/renderer/components/CleanComponent/EpochReviewer.tsx @@ -8,32 +8,10 @@ import { epochChannelSeries, } from './epochArrays'; -// --------------------------------------------------------------------------- -// Canvas layout (matches MNE's epochs.plot): epochs run ACROSS (x), channels -// are STACKED vertically (y). One trace per (epoch, channel) cell. -// -// [◀ Prev] [Next ▶] [amp -] [amp +] -// epoch 0 epoch 1 epoch 2 ... -// ┌────────────┬────────────┬────────────┐ -// ch 0 │ ~~~~~~~~ │ ░░grey░░✕░░ │ ~~~~~~~~ │ channel lane -// ├────────────┼────────────┼────────────┤ (rejected column -// ch 1 │ ~~~~~~~~ │ ░░░░░░░░░░ │ ~~~~~~~~ │ is greyed out) -// ├────────────┼────────────┼────────────┤ -// ch 2 │ ~~~~~~~~ │ ░░░░░░░░░░ │ ~~~~~~~~ │ -// └────────────┴────────────┴────────────┘ -// epoch 0 epoch 1 epoch 2 (bottom index labels) -// -// Interactive (Phase 1): a transparent DOM overlay div per visible column makes -// each epoch click-to-reject (rejected epochs grey out); Prev/Next page through -// all epochs; amp +/- scale trace amplitude. Rendering stays Canvas 2D + a DOM -// overlay for labels/hit-targets (no canvas hit-testing). -// -// Interactive (Phase 2): channel labels in the left gutter are click-to-flag -// bad-channel toggles — a flagged channel's LANE is washed translucent red -// across ALL epoch columns (and its label renders struck-through/red) so bad -// channels read at a glance. An optional condition legend maps numeric event -// codes to human-readable labels via `codeToLabel`. -// --------------------------------------------------------------------------- +// Interactive epoch reviewer: epochs run across (x), channels stacked (y). +// Click an epoch column to reject it; click a channel label to flag it bad +// (its lane washes red across all epochs). Prev/Next paginate, amp +/- scales +// traces. Canvas 2D for traces, a DOM overlay for labels/click targets. interface Props { epochArrays: { buffer: ArrayBuffer; meta: EpochArraysMeta } | null; @@ -68,7 +46,6 @@ const GAIN_MAX = 20; const REJECTED_TRACE_COLOR = 'rgba(120, 120, 120, 0.5)'; const REJECTED_FILL_COLOR = 'rgba(120, 120, 120, 0.15)'; -// Translucent wash over a flagged bad channel's lane (drawn across all epochs). const BAD_CHANNEL_FILL_COLOR = 'rgba(200, 60, 60, 0.10)'; export default function EpochReviewer({ @@ -283,8 +260,6 @@ export default function EpochReviewer({ const firstShown = clampedStart + 1; const lastShown = clampedStart + visibleCount; - // Unique condition codes (sorted) for the legend — mirrors the canvas draw's - // deterministic per-condition coloring. const uniqueSortedCodes = [...new Set(meta.event_codes)].sort( (a, b) => a - b ); diff --git a/src/renderer/components/CleanComponent/index.tsx b/src/renderer/components/CleanComponent/index.tsx index f68737a5..e338358d 100644 --- a/src/renderer/components/CleanComponent/index.tsx +++ b/src/renderer/components/CleanComponent/index.tsx @@ -1,7 +1,7 @@ import React, { Component } from 'react'; import path from 'pathe'; import { Link } from 'react-router-dom'; -import { isNil, isString } from 'lodash'; +import { isNil, isString, memoize } from 'lodash'; import { Button } from '../ui/button'; import { Dialog, @@ -13,7 +13,6 @@ import { import { EXPERIMENTS, DEVICES, - AUTO_FLAG_EXPERIMENTS, DEFAULT_PTP_THRESHOLD_UV, } from '../../constants/constants'; import { ExperimentParameters } from '../../constants/interfaces'; @@ -29,6 +28,12 @@ import { SuggestedRejection, } from '../../actions'; +// Memoized by stimuli reference so we don't rebuild the registry every render. +const codeToLabelFor = memoize( + (stimuli: ExperimentParameters['stimuli']) => + buildMarkerRegistry(stimuli).codeToLabel +); + export interface Props { type?: EXPERIMENTS; title: string; @@ -248,12 +253,8 @@ export default class Clean extends Component { return this.state.selectedSubject === subjectFromFilepath; }); - const codeToLabel = buildMarkerRegistry( - this.props.params?.stimuli ?? [] - ).codeToLabel; - const showAutoFlag = AUTO_FLAG_EXPERIMENTS.has( - this.props.type as EXPERIMENTS - ); + const codeToLabel = codeToLabelFor(this.props.params?.stimuli); + const showAutoFlag = !this.props.params?.hideAutoFlagEpochs; const { suggestedRejections } = this.props; return ( diff --git a/src/renderer/constants/constants.ts b/src/renderer/constants/constants.ts index f1355ea6..e30d9614 100644 --- a/src/renderer/constants/constants.ts +++ b/src/renderer/constants/constants.ts @@ -9,16 +9,6 @@ export enum EXPERIMENTS { // SSVEP = 'Steady-state Visual Evoked Potential', } -// Experiments where the "Auto-flag artifacts" button is offered on the Clean -// screen. Dev-configurable — add/remove experiments here to opt them in/out. -export const AUTO_FLAG_EXPERIMENTS = new Set([ - EXPERIMENTS.N170, - EXPERIMENTS.STROOP, - EXPERIMENTS.MULTI, - EXPERIMENTS.SEARCH, -]); - -// Default peak-to-peak artifact threshold (microvolts) for auto-flag. export const DEFAULT_PTP_THRESHOLD_UV = 100; export const SCREENS = { diff --git a/src/renderer/constants/interfaces.ts b/src/renderer/constants/interfaces.ts index a64bfa9c..7ce9b7da 100644 --- a/src/renderer/constants/interfaces.ts +++ b/src/renderer/constants/interfaces.ts @@ -31,6 +31,7 @@ export type ExperimentParameters = { sampleType: string; selfPaced?: boolean; showProgressBar: boolean; + hideAutoFlagEpochs?: boolean; stimuli?: Stimulus[]; taskHelp?: string; trialDuration: number; diff --git a/src/renderer/epics/pyodideEpics.ts b/src/renderer/epics/pyodideEpics.ts index 58e5afa5..041e2182 100644 --- a/src/renderer/epics/pyodideEpics.ts +++ b/src/renderer/epics/pyodideEpics.ts @@ -276,8 +276,6 @@ const getSuggestedRejectionsEpic: Epic< action$.pipe( filter(isActionOf(PyodideActions.GetSuggestedRejections)), pluck('payload'), - // Fire-and-forget: result returns via pyodideMessageEpic → - // SetSuggestedRejections (dataKey 'suggestedRejections'). tap((threshold) => requestSuggestRejections( state$.value.pyodide.worker!, diff --git a/src/renderer/utils/webworker/index.ts b/src/renderer/utils/webworker/index.ts index d91f85bc..5ab9a36c 100644 --- a/src/renderer/utils/webworker/index.ts +++ b/src/renderer/utils/webworker/index.ts @@ -139,8 +139,6 @@ export const requestEpochArrays = (worker: Worker, variableName: string) => { }); }; -// Auto-flag: ask Python which epochs look like artifacts (peak-to-peak > -// thresholdUv). Fire-and-forget; result returns on dataKey 'suggestedRejections'. export const requestSuggestRejections = ( worker: Worker, variableName: string, From 5d4376cd77ee95ed7d66e1beab9b8eb376235a36 Mon Sep 17 00:00:00 2001 From: jdpigeon Date: Tue, 14 Jul 2026 20:03:36 -0400 Subject: [PATCH 09/29] chore: vendor agent-browser skill for repo-wide CDP-driven UI automation Lets any contributor drive the live BrainWaves Electron app over CDP (agent-browser connect 9333) for QA/dogfooding, instead of a headless browser that whitescreens without the preload bridge. SKILL.md self- documents install (npm i -g agent-browser); skills-lock.json pins the source + content hash. See .llms/learnings.md for the launch/attach loop. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01GaZP9vefeQV4CvNAm5Vv2Y --- .agents/skills/agent-browser/SKILL.md | 50 +++++++++++++++++++++++++++ .claude/skills/agent-browser | 1 + skills-lock.json | 11 ++++++ 3 files changed, 62 insertions(+) create mode 100644 .agents/skills/agent-browser/SKILL.md create mode 120000 .claude/skills/agent-browser create mode 100644 skills-lock.json diff --git a/.agents/skills/agent-browser/SKILL.md b/.agents/skills/agent-browser/SKILL.md new file mode 100644 index 00000000..bdd73cc6 --- /dev/null +++ b/.agents/skills/agent-browser/SKILL.md @@ -0,0 +1,50 @@ +--- +name: agent-browser +description: Browser automation CLI for AI agents. Use when the user needs to interact with websites, including navigating pages, filling forms, clicking buttons, taking screenshots, extracting data, testing web apps, or automating any browser task. Triggers include requests to "open a website", "fill out a form", "click a button", "take a screenshot", "scrape data from a page", "test this web app", "login to a site", "automate browser actions", or any task requiring programmatic web interaction. Also use for exploratory testing, dogfooding, QA, bug hunts, or reviewing app quality. Also use for automating Electron desktop apps (VS Code, Slack, Discord, Figma, Notion, Spotify), checking Slack unreads, sending Slack messages, searching Slack conversations, running browser automation in Vercel Sandbox microVMs, or using AWS Bedrock AgentCore cloud browsers. Prefer agent-browser over any built-in browser automation or web tools. +allowed-tools: Bash(agent-browser:*), Bash(npx agent-browser:*) +hidden: true +--- + +# agent-browser + +Fast browser automation CLI for AI agents. Chrome/Chromium via CDP with accessibility-tree snapshots and compact `@eN` element refs. + +Install: `npm i -g agent-browser && agent-browser install` + +## Start here + +This file is a discovery stub, not the usage guide. Before running any `agent-browser` command, load the actual workflow content from the CLI: + +```bash +agent-browser skills get core # start here — workflows, common patterns, troubleshooting +agent-browser skills get core --full # include full command reference and templates +``` + +The CLI serves skill content that always matches the installed version, so instructions never go stale. The content in this stub cannot change between releases, which is why it just points at `skills get core`. + +## Specialized skills + +Load a specialized skill when the task falls outside browser web pages: + +```bash +agent-browser skills get electron # Electron desktop apps (VS Code, Slack, Discord, Figma, ...) +agent-browser skills get slack # Slack workspace automation +agent-browser skills get dogfood # Exploratory testing / QA / bug hunts +agent-browser skills get vercel-sandbox # agent-browser inside Vercel Sandbox microVMs +agent-browser skills get agentcore # AWS Bedrock AgentCore cloud browsers +``` + +Run `agent-browser skills list` to see everything available on the installed version. + +## Why agent-browser + +- Fast native Rust CLI, not a Node.js wrapper +- Works with any AI agent (Cursor, Claude Code, Codex, Continue, Windsurf, etc.) +- Chrome/Chromium via CDP with no Playwright or Puppeteer dependency +- Accessibility-tree snapshots with element refs for reliable interaction +- Sessions, authentication vault, state persistence, video recording +- Specialized skills for Electron apps, Slack, exploratory testing, cloud providers + +## Observability Dashboard + +The dashboard runs independently of browser sessions on port 4848 and can also be opened through a proxied or forwarded URL such as `https://dashboard.agent-browser.localhost`. Agents should stay on the dashboard origin: session tabs, status, and stream traffic are proxied internally, so session ports do not need to be exposed. diff --git a/.claude/skills/agent-browser b/.claude/skills/agent-browser new file mode 120000 index 00000000..e298b7be --- /dev/null +++ b/.claude/skills/agent-browser @@ -0,0 +1 @@ +../../.agents/skills/agent-browser \ No newline at end of file diff --git a/skills-lock.json b/skills-lock.json new file mode 100644 index 00000000..ffcd9c72 --- /dev/null +++ b/skills-lock.json @@ -0,0 +1,11 @@ +{ + "version": 1, + "skills": { + "agent-browser": { + "source": "vercel-labs/agent-browser", + "sourceType": "github", + "skillPath": "skills/agent-browser/SKILL.md", + "computedHash": "ecc7641aea05f85ca3b11e7759d32aaf52fe05946ab4b63739c7bf78a41237a2" + } + } +} From beb16203e55dbb1687583b47e1f7a02de1b67d01 Mon Sep 17 00:00:00 2001 From: jdpigeon Date: Wed, 15 Jul 2026 17:12:23 -0400 Subject: [PATCH 10/29] =?UTF-8?q?fix(analysis):=20scale=20Muse=20=C2=B5V?= =?UTF-8?q?=E2=86=92V=20on=20load=20so=20auto-flag=20stops=20rejecting=20e?= =?UTF-8?q?very=20epoch?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit load_data built the MNE RawArray straight from the µV CSV values without the µV→V scale MNE expects, so peak-to-peak came back ~1e6× inflated (tens of millions of "µV") and suggest_rejections flagged all epochs → blank ERPs. - Scale eeg rows ×1e-6 in load_data, keyed on ch_type=='eeg' (stim/Marker row untouched; marker codes stay numeric). - Emit µV (×1e6) at the two display boundaries — the epoch-viewer buffer (get_epochs_arrays) and the ERP plot (already labeled uV) — so the viewer and plot show the same numbers as before while MNE-internal units are now correct. - Regression guard: assert normal ptp lands 1–1000µV (not ~1e7). - Fix test_detects_injected_artifact: it added a DC offset (ptp-neutral) and only "passed" because the units bug flagged everything; now injects a real single-sample spike. QA plan 6e (T1). Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01GTrEip1cj1QLTU48qr9jLw --- src/renderer/utils/webworker/utils.py | 15 +++++++++++++-- tests/analysis/test_epoch_arrays.py | 7 ++++--- tests/analysis/test_suggest_rejections.py | 23 +++++++++++++++++++++-- 3 files changed, 38 insertions(+), 7 deletions(-) diff --git a/src/renderer/utils/webworker/utils.py b/src/renderer/utils/webworker/utils.py index 4657f94d..d567c59c 100644 --- a/src/renderer/utils/webworker/utils.py +++ b/src/renderer/utils/webworker/utils.py @@ -89,6 +89,13 @@ def load_data(sfreq=128., replace_ch_names=None, csv_strings=None): # get data and exclude Aux channel data = data.values[:, ch_ind].T + # Muse CSV amplitudes are in microvolts, but MNE treats eeg channel + # data as volts. Scale eeg rows uV -> V so peak-to-peak lands in the + # real ~tens-of-uV range; leave the stim/Marker row (numeric event + # codes) untouched. + eeg_mask = np.array([t == 'eeg' for t in ch_types]) + data[eeg_mask] = data[eeg_mask] * 1e-6 + # create MNE object info = create_info(ch_names=ch_names, ch_types=ch_types, sfreq=sfreq) @@ -209,7 +216,9 @@ def plot_conditions(epochs, ch_ind=0, conditions=OrderedDict(), ci=97.5, if palette is None: palette = _DEFAULT_CONDITION_PALETTE - X = epochs.get_data() + # get_data() is volts (load_data scales eeg uV -> V); the y-axis is labeled + # uV, so convert back to microvolts for display. + X = epochs.get_data() * 1e6 times = epochs.times y = pd.Series(epochs.events[:, -1]) fig, ax = plt.subplots() @@ -284,7 +293,9 @@ def get_epochs_arrays(epochs, out_path): # EEG only — the Marker channel is type 'stim' (set in load_data), so # pick_types(eeg=True) drops it while keeping the EEG channels in order. picks = pick_types(epochs.info, eeg=True) - data = epochs.get_data(picks=picks) # (n_epochs, n_channels, n_times) + # get_data() is volts (load_data scales eeg uV -> V). This buffer drives the + # epoch viewer, which works in microvolts, so convert back to uV here. + data = epochs.get_data(picks=picks) * 1e6 # (n_epochs, n_channels, n_times) data = np.ascontiguousarray(data.astype(np.float32)) with open(out_path, 'wb') as f: diff --git a/tests/analysis/test_epoch_arrays.py b/tests/analysis/test_epoch_arrays.py index 7bdc5e1c..7c18df3a 100644 --- a/tests/analysis/test_epoch_arrays.py +++ b/tests/analysis/test_epoch_arrays.py @@ -45,10 +45,11 @@ def test_buffer_matches_epoch_data(tmp_path): meta["n_epochs"], meta["n_channels"], meta["n_times"] ) - expected = epochs.get_data( + # get_epochs_arrays emits microvolts (volts * 1e6) for the epoch viewer. + expected = (epochs.get_data( picks=mne.pick_types(epochs.info, eeg=True) - ).astype(np.float32) - assert np.allclose(arr, expected, atol=1e-5) + ) * 1e6).astype(np.float32) + assert np.allclose(arr, expected, rtol=1e-5, atol=1e-3) def test_metadata_lengths_and_event_codes(tmp_path): diff --git a/tests/analysis/test_suggest_rejections.py b/tests/analysis/test_suggest_rejections.py index 36bf132a..63e5b7ae 100644 --- a/tests/analysis/test_suggest_rejections.py +++ b/tests/analysis/test_suggest_rejections.py @@ -49,9 +49,11 @@ def test_detects_injected_artifact(): ep = epochs.copy() k = 2 - # 5 mV = 5000 µV peak-to-peak bump on one EEG channel of one epoch. + # A single-sample 5 mV (5000 µV) spike on one EEG channel of one epoch. + # It must be a spike, not a DC offset added to every sample — offsetting + # the whole trace leaves peak-to-peak unchanged and nothing gets flagged. data = ep._data - data[k, 0, :] += 5e-3 + data[k, 0, data.shape[2] // 2] += 5e-3 # Threshold between the normal ptp and the artifact. suggestions = utils.suggest_rejections(ep, 1000) @@ -62,6 +64,23 @@ def test_detects_injected_artifact(): assert flagged["peak_uv"] > 1000 +def test_ptp_in_physiological_range(): + """Regression guard for the µV→V units bug (QA plan 6e). + + load_data must scale Muse µV amplitudes into MNE volts. If it doesn't, + peak-to-peak comes back inflated ~1e6× (tens of millions of "µV"), which + made auto-flag reject every epoch. Assert normal-recording ptp lands in a + physiological band so that regression can't return silently. + """ + epochs = _build_epochs() + # threshold 0 → one suggestion per epoch, each carrying its worst ptp. + peaks = [s["peak_uv"] for s in utils.suggest_rejections(epochs, 0)] + assert peaks, "expected at least one epoch" + # Synthetic noise is a few µV; real Muse is tens. A correct scale keeps + # every epoch well under 1000 µV — the units bug put these at ~1e7. + assert all(1.0 < p < 1000.0 for p in peaks), f"ptp out of range: {peaks}" + + def test_marker_channel_excluded(): epochs = _build_epochs() suggestions = utils.suggest_rejections(epochs, 0) From 68a410b79beb9cf1216a858eaa76519bc8ab462a Mon Sep 17 00:00:00 2001 From: jdpigeon Date: Wed, 15 Jul 2026 17:13:20 -0400 Subject: [PATCH 11/29] fix(fs): open workspace folder via absolute path in main process MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit openWorkspaceDir passed a relative "BrainWaves_Workspaces/" to shell.showItemInFolder, which silently no-ops on non-absolute paths — the Home screen "Go to Folder" button did nothing. Add a shell:openWorkspaceDir handler that resolves the absolute path with getWorkspaceDir and opens it via shell.openPath; point the renderer at it. QA plan 9a (T4). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GTrEip1cj1QLTU48qr9jLw --- src/main/index.ts | 7 +++++++ src/preload/index.ts | 3 +++ src/renderer/utils/filesystem/storage.ts | 2 +- 3 files changed, 11 insertions(+), 1 deletion(-) diff --git a/src/main/index.ts b/src/main/index.ts index 3b61e786..f3d5555d 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -160,6 +160,13 @@ ipcMain.handle('shell:showItemInFolder', (_event, fullPath) => shell.showItemInFolder(fullPath) ); +// Open a workspace folder. Resolve the absolute path here — shell.openPath (and +// showItemInFolder) silently no-op on a relative path, which is why the old +// renderer-side path.join('BrainWaves_Workspaces', title) did nothing. +ipcMain.handle('shell:openWorkspaceDir', (_event, title: string) => + shell.openPath(getWorkspaceDir(title)) +); + ipcMain.handle('shell:moveItemToTrash', (_event, fullPath) => shell.trashItem(fullPath) ); diff --git a/src/preload/index.ts b/src/preload/index.ts index c09590bd..fa29f35a 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -52,6 +52,9 @@ contextBridge.exposeInMainWorld('electronAPI', { moveItemToTrash: (fullPath: string) => ipcRenderer.invoke('shell:moveItemToTrash', fullPath), + openWorkspaceDir: (title: string): Promise<string> => + ipcRenderer.invoke('shell:openWorkspaceDir', title), + // ------------------------------------------------------------------ // Filesystem — workspace management // ------------------------------------------------------------------ diff --git a/src/renderer/utils/filesystem/storage.ts b/src/renderer/utils/filesystem/storage.ts index 57894eab..da835037 100644 --- a/src/renderer/utils/filesystem/storage.ts +++ b/src/renderer/utils/filesystem/storage.ts @@ -27,7 +27,7 @@ export const createWorkspaceDir = (title: string): Promise<void> => api().createWorkspaceDir(title); export const openWorkspaceDir = (title: string): Promise<void> => - api().showItemInFolder(path.join('BrainWaves_Workspaces', title)); + api().openWorkspaceDir(title); // ----------------------------------------------------------------------------------------------- // Storing From 7fdccfbaf5863ac3403b0a194a3bf8aed5501cc8 Mon Sep 17 00:00:00 2001 From: jdpigeon <morrisondano@gmail.com> Date: Wed, 15 Jul 2026 17:15:33 -0400 Subject: [PATCH 12/29] fix(experiment): default EEG on + warn before a run with no brain data MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit isEEGEnabled defaulted to false — nonsensical for an EEG app, and a run would silently record behavior only with no warning. Flip the default to true, and guard handleStartExperiment: if EEG is disabled, or enabled but no device is connected, confirm ("...records responses but no brain data. Continue anyway?") before starting, reusing the existing showMessageBox pattern. QA plan 7 (T3). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GTrEip1cj1QLTU48qr9jLw --- .../CollectComponent/RunComponent.tsx | 31 +++++++++++++++++-- .../__tests__/experimentReducer.test.ts | 9 ++++++ src/renderer/reducers/experimentReducer.ts | 3 +- 3 files changed, 40 insertions(+), 3 deletions(-) create mode 100644 src/renderer/reducers/__tests__/experimentReducer.test.ts diff --git a/src/renderer/components/CollectComponent/RunComponent.tsx b/src/renderer/components/CollectComponent/RunComponent.tsx index 01688e24..b68743cf 100644 --- a/src/renderer/components/CollectComponent/RunComponent.tsx +++ b/src/renderer/components/CollectComponent/RunComponent.tsx @@ -4,7 +4,7 @@ import { Link } from 'react-router-dom'; import InputCollect from '../InputCollect'; import { injectMarker } from '../../utils/eeg'; import { sendMarker } from '../../utils/eeg/lslBridge'; -import { EXPERIMENTS } from '../../constants/constants'; +import { EXPERIMENTS, CONNECTION_STATUS } from '../../constants/constants'; import { ExperimentWindow } from '../ExperimentWindow'; import { checkFileExists, getImages } from '../../utils/filesystem/storage'; import { @@ -23,6 +23,7 @@ interface Props { group: string; session: number; isEEGEnabled: boolean; + connectionStatus: CONNECTION_STATUS; ExperimentActions: typeof globalExperimentActions; } @@ -36,6 +37,7 @@ const Run: React.FC<Props> = ({ group, session, isEEGEnabled, + connectionStatus, ExperimentActions, }) => { const [isInputCollectOpen, setIsInputCollectOpen] = useState( @@ -43,6 +45,23 @@ const Run: React.FC<Props> = ({ ); const handleStartExperiment = useCallback(async () => { + // Warn before a run that won't capture brain data: EEG turned off, or on + // but no device connected. Either way it silently records behavior only. + const eegConnected = + isEEGEnabled && connectionStatus === CONNECTION_STATUS.CONNECTED; + if (!eegConnected) { + const message = isEEGEnabled + ? 'No EEG device is connected. This run will record responses but no brain data. Continue anyway?' + : 'EEG is disabled. This run will record responses but no brain data. Continue anyway?'; + const response = await window.electronAPI.showMessageBox({ + buttons: ['No', 'Yes'], + message, + }); + if (response.response !== 1) { + return; + } + } + const filename = `${subject}-${group}-${session}-behavior.csv`; const fileExists = await checkFileExists(title, subject, filename); if (fileExists) { @@ -58,7 +77,15 @@ const Run: React.FC<Props> = ({ } else { ExperimentActions.Start(); } - }, [subject, group, session, title, ExperimentActions]); + }, [ + subject, + group, + session, + title, + isEEGEnabled, + connectionStatus, + ExperimentActions, + ]); const handleCloseInputCollect = useCallback( (newSubject: string, newGroup: string, newSession: number) => { diff --git a/src/renderer/reducers/__tests__/experimentReducer.test.ts b/src/renderer/reducers/__tests__/experimentReducer.test.ts new file mode 100644 index 00000000..40efe63c --- /dev/null +++ b/src/renderer/reducers/__tests__/experimentReducer.test.ts @@ -0,0 +1,9 @@ +import { describe, it, expect } from 'vitest'; +import experimentReducer from '../experimentReducer'; + +describe('experimentReducer', () => { + it('defaults isEEGEnabled to true — EEG-on is the app default, opt out for behavior-only', () => { + const state = experimentReducer(undefined, { type: '@@INIT' }); + expect(state.isEEGEnabled).toBe(true); + }); +}); diff --git a/src/renderer/reducers/experimentReducer.ts b/src/renderer/reducers/experimentReducer.ts index 1a1f4a3f..70088466 100644 --- a/src/renderer/reducers/experimentReducer.ts +++ b/src/renderer/reducers/experimentReducer.ts @@ -35,7 +35,8 @@ const initialState: ExperimentStateType = { group: '', session: 1, isRunning: false, - isEEGEnabled: false, + // EEG-on is the app's whole point; opt out for behavior-only runs, not in. + isEEGEnabled: true, dateModified: null, }; From c1cb40d9ffbdee93ed90ad43f20af63212d54e9b Mon Sep 17 00:00:00 2001 From: jdpigeon <morrisondano@gmail.com> Date: Wed, 15 Jul 2026 17:16:23 -0400 Subject: [PATCH 13/29] fix(connect): remove Emotiv-era USB receiver step from Muse connect flow MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Muse is Bluetooth — there is no USB dongle. Drop the COMPUTER_CONNECTABILITY "Insert the USB Receiver" step so "Turn your headset on" → Next goes straight to searching for devices. QA plan 8 (T6). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GTrEip1cj1QLTU48qr9jLw --- .../CollectComponent/ConnectModal.tsx | 39 ------------------- 1 file changed, 39 deletions(-) diff --git a/src/renderer/components/CollectComponent/ConnectModal.tsx b/src/renderer/components/CollectComponent/ConnectModal.tsx index e4aaea01..1fbfca1f 100644 --- a/src/renderer/components/CollectComponent/ConnectModal.tsx +++ b/src/renderer/components/CollectComponent/ConnectModal.tsx @@ -42,7 +42,6 @@ interface State { enum INSTRUCTION_PROGRESS { SEARCHING, TURN_ON, - COMPUTER_CONNECTABILITY, } export default class ConnectModal extends Component<Props, State> { @@ -242,44 +241,6 @@ export default class ConnectModal extends Component<Props, State> { Back </Button> )} - <Button - variant="default" - className="w-full" - onClick={() => - this.handleinstructionProgress( - INSTRUCTION_PROGRESS.COMPUTER_CONNECTABILITY - ) - } - > - Next - </Button> - </div> - </> - ); - } - if ( - this.state.instructionProgress === - INSTRUCTION_PROGRESS.COMPUTER_CONNECTABILITY - ) { - return ( - <> - <h2>Insert the USB Receiver</h2> - <p> - Insert the USB receiver into a USB port on your computer. Ensure - that the LED on the receiver is continously lit or flickering - rapidly. If it is blinking slowly or not illuminated, remove and - reinsert the receiver - </p> - <div className="flex gap-2 mt-4"> - <Button - variant="secondary" - className="w-full" - onClick={() => - this.handleinstructionProgress(INSTRUCTION_PROGRESS.TURN_ON) - } - > - Back - </Button> <Button variant="default" className="w-full" From 69396e45fb1d36baf4ba119398b54ab462e6fffa Mon Sep 17 00:00:00 2001 From: jdpigeon <morrisondano@gmail.com> Date: Wed, 15 Jul 2026 17:22:39 -0400 Subject: [PATCH 14/29] feat(analysis): show real condition names (Faces/Houses) instead of STIMULUS_n MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit buildMarkerRegistry now derives display labels from the experiment's own stimulus.condition, so Clean counts/legend and the ERP read "Face"/"House" instead of the generic STIMULUS_1/2. Numeric codes are untouched (they still drive the CSV Marker column and MNE event_id — labels are display-only). Falls back to STIMULUS_n when a stimulus has no condition, and refuses to collapse two codes under one label (would silently merge conditions in analysis). The Clean legend and Python ERP legend pick the names up automatically (both read off the registry / epochs.event_id). QA plan 6b (T7). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GTrEip1cj1QLTU48qr9jLw --- .../eeg/__tests__/markerRegistry.test.ts | 26 +++++++++++++++ src/renderer/utils/eeg/markerRegistry.ts | 33 +++++++++++++++---- 2 files changed, 53 insertions(+), 6 deletions(-) diff --git a/src/renderer/utils/eeg/__tests__/markerRegistry.test.ts b/src/renderer/utils/eeg/__tests__/markerRegistry.test.ts index 49aef16c..86343207 100644 --- a/src/renderer/utils/eeg/__tests__/markerRegistry.test.ts +++ b/src/renderer/utils/eeg/__tests__/markerRegistry.test.ts @@ -11,6 +11,11 @@ import { EVENTS } from '../../../constants/constants'; import type { Stimulus } from '../../../constants/interfaces'; const stim = (title: string, type: EVENTS): Stimulus => ({ title, type }); +const stimC = (title: string, type: EVENTS, condition: string): Stimulus => ({ + title, + type, + condition, +}); describe('buildMarkerRegistry', () => { it('maps event_id values to the numeric codes written to the CSV', () => { @@ -43,6 +48,27 @@ describe('buildMarkerRegistry', () => { } }); + it('uses the experiment condition name as the display label when present', () => { + const { eventId, codeToLabel } = buildMarkerRegistry([ + stimC('Face1', EVENTS.STIMULUS_1, 'Face'), + stimC('Face2', EVENTS.STIMULUS_1, 'Face'), + stimC('House1', EVENTS.STIMULUS_2, 'House'), + ]); + // Codes are unchanged (they still drive CSV + MNE); only labels are human. + expect(eventId).toEqual({ Face: 1, House: 2 }); + expect(codeToLabel).toEqual({ 1: 'Face', 2: 'House' }); + }); + + it('falls back to the neutral label if two codes share a condition name', () => { + const { codeToLabel } = buildMarkerRegistry([ + stimC('A', EVENTS.STIMULUS_1, 'Same'), + stimC('B', EVENTS.STIMULUS_2, 'Same'), + ]); + // Never collapse two codes under one label — the second gets STIMULUS_2. + expect(codeToLabel[1]).toBe('Same'); + expect(codeToLabel[2]).toBe('STIMULUS_2'); + }); + it('returns empty maps when there are no stimuli', () => { expect(buildMarkerRegistry([])).toEqual({ codeToLabel: {}, eventId: {} }); expect(buildMarkerRegistry()).toEqual({ codeToLabel: {}, eventId: {} }); diff --git a/src/renderer/utils/eeg/markerRegistry.ts b/src/renderer/utils/eeg/markerRegistry.ts index f82954e5..48caeb2e 100644 --- a/src/renderer/utils/eeg/markerRegistry.ts +++ b/src/renderer/utils/eeg/markerRegistry.ts @@ -42,21 +42,42 @@ export interface MarkerRegistry { * Build the registry for an experiment from the distinct numeric event codes * present in its stimuli. Multiple stimuli may share a code (e.g. every "Face" * image is STIMULUS_1) — they collapse to one condition entry, which is exactly - * what MNE wants. Unknown codes fall back to an `EVENT_<n>` label so nothing is - * silently lost. + * what MNE wants. + * + * The display label prefers the experiment's own condition name + * (`stimulus.condition`, e.g. "Face"/"House") so the Clean counts, legend, and + * ERP read the real conditions instead of the generic STIMULUS_n. It falls back + * to the neutral STIMULUS_n label, then `EVENT_<n>`, so nothing is silently + * lost. Labels are display-only — the numeric codes still drive the CSV and + * MNE's event_id (eventId VALUES are the codes). A condition name is only used + * if it's unique across codes; two codes are never collapsed under one label + * (that would silently merge conditions in analysis). */ export const buildMarkerRegistry = ( stimuli: Stimulus[] = [] ): MarkerRegistry => { - const codes = new Set<number>(); + // First distinct condition name seen for each code (codes may repeat across + // many stimuli that all share the same condition). + const conditionForCode = new Map<number, string>(); for (const stimulus of stimuli) { - if (typeof stimulus.type === 'number') codes.add(stimulus.type); + if (typeof stimulus.type !== 'number') continue; + if (!conditionForCode.has(stimulus.type) && stimulus.condition) { + conditionForCode.set(stimulus.type, stimulus.condition); + } else if (!conditionForCode.has(stimulus.type)) { + conditionForCode.set(stimulus.type, ''); + } } const codeToLabel: Record<number, string> = {}; const eventId: Record<string, number> = {}; - for (const code of codes) { - const label = CODE_TO_LABEL[code] ?? `EVENT_${code}`; + const usedLabels = new Set<string>(); + for (const [code, condition] of conditionForCode) { + const neutral = CODE_TO_LABEL[code] ?? `EVENT_${code}`; + // Use the condition name only if it's non-empty and not already claimed by + // another code; otherwise fall back to the guaranteed-unique neutral label. + const label = + condition && !usedLabels.has(condition) ? condition : neutral; + usedLabels.add(label); codeToLabel[code] = label; eventId[label] = code; } From 4247b151c49a6a0447a4fdd0d21b46a69a55ad66 Mon Sep 17 00:00:00 2001 From: jdpigeon <morrisondano@gmail.com> Date: Wed, 15 Jul 2026 17:23:35 -0400 Subject: [PATCH 15/29] fix(analysis): toast when cleaned-epochs save comes back empty The savedEpochs branch wrote whatever buffer arrived and only caught write errors. An empty/dropped buffer wrote nothing and errored nothing, leaving the Analyze picker mysteriously empty. Guard: if the buffer is missing or zero-length, surface an error toast instead of failing silently. QA plan 9b (T5). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GTrEip1cj1QLTU48qr9jLw --- src/renderer/epics/pyodideEpics.ts | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/src/renderer/epics/pyodideEpics.ts b/src/renderer/epics/pyodideEpics.ts index 041e2182..5e8699ef 100644 --- a/src/renderer/epics/pyodideEpics.ts +++ b/src/renderer/epics/pyodideEpics.ts @@ -127,7 +127,15 @@ const pyodideMessageEpic: Epic< ); } if (dataKey === 'savedEpochs') { - const savedEpochsBuffer = e.data.buffer as ArrayBuffer; + const savedEpochsBuffer = e.data.buffer as ArrayBuffer | undefined; + // Surface a dropped/empty save instead of writing nothing silently — + // that path left the Analyze picker mysteriously empty with no error. + if (!savedEpochsBuffer || savedEpochsBuffer.byteLength === 0) { + toast.error( + 'Could not save cleaned data — the recording came back empty. Nothing was written.' + ); + return EMPTY; + } const { title, subject } = state$.value.experiment; return from( window.electronAPI.writeCleanedEpochs( From 1e38197fa201627c8fcc7ecf0853c5ed2797e921 Mon Sep 17 00:00:00 2001 From: jdpigeon <morrisondano@gmail.com> Date: Wed, 15 Jul 2026 17:28:30 -0400 Subject: [PATCH 16/29] fix(clean): warn before rejecting every epoch (zero-epoch guard) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Cleaning with all epochs flagged produced an empty, unanalyzable dataset (and previously wrote a degenerate .fif with no error) — the source of the empty Analyze picker after auto-flag over-rejected. handleCleanData now confirms ("This will reject all N epochs, leaving nothing to analyze") before dropping when the reject set covers every epoch. Note: this file also carries in-progress epoch-reviewer Phase 2 Clean-screen changes that were already staged in the working tree; they are committed here because they could not be separated non-interactively. The T2 guard is the handleCleanData method + its wiring. QA plan 6e/9b (T2). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GTrEip1cj1QLTU48qr9jLw --- .../components/CleanComponent/index.tsx | 376 ++++++++++-------- 1 file changed, 216 insertions(+), 160 deletions(-) diff --git a/src/renderer/components/CleanComponent/index.tsx b/src/renderer/components/CleanComponent/index.tsx index e338358d..b8a2f1d6 100644 --- a/src/renderer/components/CleanComponent/index.tsx +++ b/src/renderer/components/CleanComponent/index.tsx @@ -57,6 +57,8 @@ interface DropdownOption { } interface State { + // Which screen is showing: dataset picker vs. the interactive editor. + view: 'select' | 'review'; subjects: Array<DropdownOption>; eegFilePaths: Array<DropdownOption>; selectedSubject: string; @@ -80,6 +82,7 @@ export default class Clean extends Component<Props, State> { constructor(props: Props) { super(props); this.state = { + view: 'select', subjects: [], eegFilePaths: [{ key: '', text: '', value: '' }], selectedFilePaths: [], @@ -98,6 +101,7 @@ export default class Clean extends Component<Props, State> { this.handleToggleEpoch = this.handleToggleEpoch.bind(this); this.handleToggleChannel = this.handleToggleChannel.bind(this); this.handleAutoFlag = this.handleAutoFlag.bind(this); + this.handleCleanData = this.handleCleanData.bind(this); this.handleThresholdChange = this.handleThresholdChange.bind(this); this.icons = props.type === EXPERIMENTS.N170 @@ -144,9 +148,13 @@ export default class Clean extends Component<Props, State> { handleLoadData() { this.props.ExperimentActions.SetSubject(this.state.selectedSubject); this.props.PyodideActions.LoadEpochs(this.state.selectedFilePaths); - // A fresh dataset invalidates any previously selected epoch indices and - // bad-channel selections. - this.setState({ rejectedEpochs: new Set(), badChannels: new Set() }); + // Launch the editor; a fresh dataset invalidates any previously selected + // epoch indices and bad-channel selections. + this.setState({ + view: 'review', + rejectedEpochs: new Set(), + badChannels: new Set(), + }); } componentDidUpdate(prevProps: Props) { @@ -200,6 +208,32 @@ export default class Clean extends Component<Props, State> { ); } + async handleCleanData() { + const total = this.props.epochArrays?.meta.n_epochs ?? 0; + const nDropped = this.state.rejectedEpochs.size; + // Rejecting every epoch produces an empty dataset that can't be analyzed + // (and previously wrote a degenerate .fif with no error). Warn first. + if (total > 0 && nDropped >= total) { + const response = await window.electronAPI.showMessageBox({ + buttons: ['Cancel', 'Reject all anyway'], + message: `This will reject all ${total} epochs, leaving nothing to analyze. Are you sure?`, + }); + if (response.response !== 1) { + return; + } + } + this.props.PyodideActions.CleanEpochs({ + dropIndices: Array.from(this.state.rejectedEpochs), + badChannels: Array.from(this.state.badChannels), + }); + // After Clean, raw_epochs is re-fetched with fewer epochs, so the old + // absolute indices no longer apply. + this.setState({ + rejectedEpochs: new Set(), + badChannels: new Set(), + }); + } + handleThresholdChange(e: React.ChangeEvent<HTMLInputElement>) { const parsed = parseFloat(e.target.value); if (!Number.isNaN(parsed)) { @@ -211,23 +245,22 @@ export default class Clean extends Component<Props, State> { this.setState({ isSidebarVisible: !this.state.isSidebarVisible }); } - renderEpochLabels() { - if ( - !isNil(this.props.epochsInfo) && - this.state.selectedFilePaths.length >= 1 - ) { - return ( - <div className="text-left"> - {this.props.epochsInfo.map((infoObj, index) => ( - <div key={String(infoObj.name)} className="mb-2"> - <span>{this.icons[index]}</span> {infoObj.name} - <p>{infoObj.value}</p> - </div> - ))} - </div> - ); + renderStats() { + const { epochsInfo } = this.props; + if (isNil(epochsInfo) || epochsInfo.length === 0) { + return null; } - return <div />; + return ( + <div className="flex flex-wrap gap-x-6 gap-y-1 text-sm"> + {epochsInfo.map((infoObj, index) => ( + <span key={String(infoObj.name)} className="whitespace-nowrap"> + <span className="mr-1">{this.icons[index]}</span> + <span className="text-gray-500">{infoObj.name}:</span>{' '} + <span className="font-medium">{infoObj.value}</span> + </span> + ))} + </div> + ); } renderAnalyzeButton() { @@ -244,6 +277,166 @@ export default class Clean extends Component<Props, State> { return null; } + renderSelect(filteredFilePaths: DropdownOption[]) { + return ( + <div className="max-w-2xl text-left"> + <h1>Clean</h1> + <h4 className="mt-2">Select & Clean</h4> + <p> + Ready to clean some data? Pick a subject and one or more EEG + recordings, then launch the editor. + </p> + <h4 className="mt-4">Select Subject</h4> + <select + className="w-full border border-gray-300 rounded p-1 mb-2" + value={this.state.selectedSubject} + onChange={this.handleSubjectChange} + > + {this.state.subjects.map((s) => ( + <option key={s.key} value={s.value}> + {s.text} + </option> + ))} + </select> + <h4>Select Recordings</h4> + <select + multiple + className="w-full border border-gray-300 rounded p-1" + value={this.state.selectedFilePaths} + onChange={this.handleRecordingChange} + > + {filteredFilePaths.map((fp) => ( + <option key={fp.key} value={fp.value}> + {fp.text} + </option> + ))} + </select> + <Button + variant="default" + className="mt-4 w-full" + disabled={this.state.selectedFilePaths.length === 0} + onClick={this.handleLoadData} + > + Load Dataset → + </Button> + </div> + ); + } + + renderReview( + codeToLabel: Record<number, string>, + showAutoFlag: boolean, + suggestedRejections: SuggestedRejection[] + ) { + const hasEpochs = !isNil(this.props.epochArrays); + const nRecordings = this.state.selectedFilePaths.length; + return ( + <> + <div className="flex items-center gap-3 mb-4"> + <Button + variant="ghost" + onClick={() => this.setState({ view: 'select' })} + > + ← Datasets + </Button> + <h1 className="m-0">Clean</h1> + <span className="text-sm text-gray-500"> + {this.state.selectedSubject} · {nRecordings} recording + {nRecordings === 1 ? '' : 's'} + </span> + </div> + + <div className="flex flex-wrap items-center gap-2 mb-3"> + <Button + variant="default" + disabled={isNil(this.props.epochsInfo)} + onClick={this.handleCleanData} + > + Clean Data + </Button> + {showAutoFlag && ( + <> + <Button + variant="secondary" + disabled={isNil(this.props.epochsInfo)} + onClick={this.handleAutoFlag} + > + Auto-flag artifacts + </Button> + <Button + variant="ghost" + aria-label="Auto-flag settings" + onClick={() => + this.setState((prev) => ({ + showAutoFlagSettings: !prev.showAutoFlagSettings, + })) + } + > + ⚙︎ + </Button> + </> + )} + <div className="ml-auto">{this.renderAnalyzeButton()}</div> + </div> + + {showAutoFlag && this.state.showAutoFlagSettings && ( + <div className="mb-3 text-left"> + <label className="text-sm font-medium"> + Peak-to-peak threshold (µV) + <input + type="number" + className="ml-2 w-24 border border-gray-300 rounded p-1" + value={this.state.autoFlagThreshold} + onChange={this.handleThresholdChange} + /> + </label> + <p className="text-xs text-gray-500 mt-1"> + Flag epochs whose peak-to-peak amplitude exceeds this. Higher = + fewer flags. + </p> + </div> + )} + {suggestedRejections.length > 0 && ( + <div className="mb-3 text-left text-sm text-brand"> + <p className="font-medium"> + Flagged {suggestedRejections.length}{' '} + {suggestedRejections.length === 1 ? 'epoch' : 'epochs'} + </p> + <ul className="text-xs text-gray-600 list-disc list-inside"> + {suggestedRejections.slice(0, 3).map((s, i) => ( + <li key={`${s.index}-${s.channel}-${i}`}>{s.reason}</li> + ))} + </ul> + </div> + )} + + <div className="mb-4">{this.renderStats()}</div> + + {hasEpochs ? ( + <div className="flex flex-wrap gap-6"> + <EpochReviewer + epochArrays={this.props.epochArrays} + rejected={this.state.rejectedEpochs} + onToggleEpoch={this.handleToggleEpoch} + badChannels={this.state.badChannels} + onToggleChannel={this.handleToggleChannel} + codeToLabel={codeToLabel} + /> + <LiveErpPane + epochArrays={this.props.epochArrays} + rejected={this.state.rejectedEpochs} + codeToLabel={codeToLabel} + /> + </div> + ) : ( + <div className="flex h-40 items-center justify-center rounded-lg border border-dashed border-brand/40 bg-white/50 text-brand"> + Loading your epochs… 🧠 + </div> + )} + </> + ); + } + render() { const filteredFilePaths = this.state.eegFilePaths.filter((filepath) => { const strVal = filepath.value; @@ -264,147 +457,10 @@ export default class Clean extends Component<Props, State> { <CleanSidebar handleClose={this.handleSidebarToggle} /> </div> )} - <div className="flex-1 p-[3%]"> - <div className="flex items-center mb-4"> - <h1>Clean</h1> - </div> - <div className="flex gap-4"> - <div className="w-6/12 text-left"> - <h1>Select & Clean</h1> - <p> - Ready to clean some data? Select a subject and one or more EEG - recordings, then launch the editor - </p> - <h4>Select Subject</h4> - <select - className="w-full border border-gray-300 rounded p-1 mb-2" - value={this.state.selectedSubject} - onChange={this.handleSubjectChange} - > - {this.state.subjects.map((s) => ( - <option key={s.key} value={s.value}> - {s.text} - </option> - ))} - </select> - <h4>Select Recordings</h4> - <select - multiple - className="w-full border border-gray-300 rounded p-1" - value={this.state.selectedFilePaths} - onChange={this.handleRecordingChange} - > - {filteredFilePaths.map((fp) => ( - <option key={fp.key} value={fp.value}> - {fp.text} - </option> - ))} - </select> - <div className="flex gap-2 mt-4"> - <Button - variant="secondary" - className="w-full" - onClick={this.handleLoadData} - > - Load Dataset - </Button> - <Button - variant="default" - className="w-full" - disabled={isNil(this.props.epochsInfo)} - onClick={() => { - this.props.PyodideActions.CleanEpochs({ - dropIndices: Array.from(this.state.rejectedEpochs), - badChannels: Array.from(this.state.badChannels), - }); - // After Clean, raw_epochs is re-fetched with fewer epochs, - // so the old absolute indices no longer apply. - this.setState({ - rejectedEpochs: new Set(), - badChannels: new Set(), - }); - }} - > - Clean Data - </Button> - </div> - {showAutoFlag && ( - <div className="mt-4"> - <div className="flex items-center gap-2"> - <Button - variant="secondary" - disabled={isNil(this.props.epochsInfo)} - onClick={this.handleAutoFlag} - > - Auto-flag artifacts - </Button> - <Button - variant="ghost" - aria-label="Auto-flag settings" - onClick={() => - this.setState((prev) => ({ - showAutoFlagSettings: !prev.showAutoFlagSettings, - })) - } - > - ⚙︎ - </Button> - </div> - {this.state.showAutoFlagSettings && ( - <div className="mt-2 text-left"> - <label className="text-sm font-medium"> - Peak-to-peak threshold (µV) - <input - type="number" - className="ml-2 w-24 border border-gray-300 rounded p-1" - value={this.state.autoFlagThreshold} - onChange={this.handleThresholdChange} - /> - </label> - <p className="text-xs text-gray-500 mt-1"> - Flag epochs whose peak-to-peak amplitude exceeds this. - Higher = fewer flags. - </p> - </div> - )} - {suggestedRejections.length > 0 && ( - <div className="mt-2 text-left text-sm text-brand"> - <p className="font-medium"> - Flagged {suggestedRejections.length}{' '} - {suggestedRejections.length === 1 ? 'epoch' : 'epochs'} - </p> - <ul className="text-xs text-gray-600 list-disc list-inside"> - {suggestedRejections.slice(0, 3).map((s, i) => ( - <li key={`${s.index}-${s.channel}-${i}`}> - {s.reason} - </li> - ))} - </ul> - </div> - )} - </div> - )} - </div> - <div className="w-4/12"> - {this.renderEpochLabels()} - {this.renderAnalyzeButton()} - </div> - </div> - <div className="mt-4 flex flex-wrap gap-6"> - <EpochReviewer - epochArrays={this.props.epochArrays} - rejected={this.state.rejectedEpochs} - onToggleEpoch={this.handleToggleEpoch} - badChannels={this.state.badChannels} - onToggleChannel={this.handleToggleChannel} - codeToLabel={codeToLabel} - /> - <LiveErpPane - epochArrays={this.props.epochArrays} - rejected={this.state.rejectedEpochs} - codeToLabel={codeToLabel} - /> - </div> + <div className="flex-1 p-[3%] overflow-y-auto"> + {this.state.view === 'select' + ? this.renderSelect(filteredFilePaths) + : this.renderReview(codeToLabel, showAutoFlag, suggestedRejections)} </div> <Dialog open={this.state.showChannelWarning} From 8d15e448dd5f3c8b4a0e5d4df6e2762988db9ca0 Mon Sep 17 00:00:00 2001 From: jdpigeon <morrisondano@gmail.com> Date: Wed, 15 Jul 2026 17:32:12 -0400 Subject: [PATCH 17/29] feat(clean): icon-sized settings gear + sensitivity slider for auto-flag MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 6a: the ⚙ auto-flag settings button now uses Button size="icon" (h-9 w-9), matching the height of the adjacent Clean Data / Auto-flag buttons. - 6g: replace the raw µV number input with a range slider labeled by effect ("More flags ← → Fewer flags"), showing the current µV inline and via aria-valuetext. Bounds come from getPtpThresholdPreset(device) — a single validated range today, with a per-device seam for later (no fabricated per-hardware ceilings). Only meaningful now that units are fixed (T1). QA plan 6a/6g. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GTrEip1cj1QLTU48qr9jLw --- .../components/CleanComponent/index.tsx | 59 +++++++++++++------ src/renderer/constants/constants.ts | 23 ++++++++ 2 files changed, 63 insertions(+), 19 deletions(-) diff --git a/src/renderer/components/CleanComponent/index.tsx b/src/renderer/components/CleanComponent/index.tsx index b8a2f1d6..c7dad115 100644 --- a/src/renderer/components/CleanComponent/index.tsx +++ b/src/renderer/components/CleanComponent/index.tsx @@ -13,7 +13,7 @@ import { import { EXPERIMENTS, DEVICES, - DEFAULT_PTP_THRESHOLD_UV, + getPtpThresholdPreset, } from '../../constants/constants'; import { ExperimentParameters } from '../../constants/interfaces'; import { buildMarkerRegistry } from '../../utils/eeg/markerRegistry'; @@ -90,7 +90,7 @@ export default class Clean extends Component<Props, State> { isSidebarVisible: false, rejectedEpochs: new Set(), badChannels: new Set(), - autoFlagThreshold: DEFAULT_PTP_THRESHOLD_UV, + autoFlagThreshold: getPtpThresholdPreset(props.deviceType).default, showAutoFlagSettings: false, showChannelWarning: false, }; @@ -365,6 +365,7 @@ export default class Clean extends Component<Props, State> { </Button> <Button variant="ghost" + size="icon" aria-label="Auto-flag settings" onClick={() => this.setState((prev) => ({ @@ -379,23 +380,43 @@ export default class Clean extends Component<Props, State> { <div className="ml-auto">{this.renderAnalyzeButton()}</div> </div> - {showAutoFlag && this.state.showAutoFlagSettings && ( - <div className="mb-3 text-left"> - <label className="text-sm font-medium"> - Peak-to-peak threshold (µV) - <input - type="number" - className="ml-2 w-24 border border-gray-300 rounded p-1" - value={this.state.autoFlagThreshold} - onChange={this.handleThresholdChange} - /> - </label> - <p className="text-xs text-gray-500 mt-1"> - Flag epochs whose peak-to-peak amplitude exceeds this. Higher = - fewer flags. - </p> - </div> - )} + {showAutoFlag && + this.state.showAutoFlagSettings && + (() => { + const preset = getPtpThresholdPreset(this.props.deviceType); + return ( + <div className="mb-3 text-left"> + <label + className="text-sm font-medium block" + htmlFor="autoflag-sensitivity" + > + Auto-flag threshold + </label> + <div className="flex items-center gap-2 mt-1"> + <span className="text-xs text-gray-500">More flags</span> + <input + id="autoflag-sensitivity" + type="range" + min={preset.min} + max={preset.max} + step={preset.step} + value={this.state.autoFlagThreshold} + aria-valuetext={`${this.state.autoFlagThreshold} µV peak-to-peak`} + onChange={this.handleThresholdChange} + className="flex-1" + /> + <span className="text-xs text-gray-500">Fewer flags</span> + </div> + <p className="text-xs text-gray-500 mt-1"> + Flag epochs whose peak-to-peak amplitude exceeds{' '} + <span className="font-medium"> + {this.state.autoFlagThreshold} µV + </span> + . + </p> + </div> + ); + })()} {suggestedRejections.length > 0 && ( <div className="mb-3 text-left text-sm text-brand"> <p className="font-medium"> diff --git a/src/renderer/constants/constants.ts b/src/renderer/constants/constants.ts index e30d9614..fe0ba1c0 100644 --- a/src/renderer/constants/constants.ts +++ b/src/renderer/constants/constants.ts @@ -11,6 +11,29 @@ export enum EXPERIMENTS { export const DEFAULT_PTP_THRESHOLD_UV = 100; +/** + * Slider bounds for the Clean auto-flag peak-to-peak threshold, in microvolts. + * Students set sensitivity on a slider instead of typing a raw µV number. The + * range is deliberately generous around real Muse ptp (tens of µV; artifacts + * well over 100). Per-device tuning plugs in via getPtpThresholdPreset — today + * every device shares one validated range (no fabricated per-hardware ceilings). + */ +export interface PtpThresholdPreset { + min: number; + max: number; + step: number; + default: number; +} + +export const getPtpThresholdPreset = ( + _device: DEVICES +): PtpThresholdPreset => ({ + min: 20, + max: 400, + step: 10, + default: DEFAULT_PTP_THRESHOLD_UV, +}); + export const SCREENS = { HOME: { route: '/', title: 'HOME', order: 0 }, BANK: { route: '/home', title: 'HOME', order: 0 }, From b68096a37c5320ecb78493d2ab2aa527ea7772b5 Mon Sep 17 00:00:00 2001 From: jdpigeon <morrisondano@gmail.com> Date: Wed, 15 Jul 2026 17:33:30 -0400 Subject: [PATCH 18/29] feat(collect): show completion panel with forward CTA after a run MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When a run finished, RunComponent silently re-rendered the identical pre-run landing — no confirmation it recorded, and the only way forward (top nav) was undiscoverable. Track hasFinished and, once a run ends, show a completion panel ("Recording complete") with a primary "Clean your data →" link to the Clean screen and a "Run again" secondary. Closes the Collect dead-end. QA plan 5. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GTrEip1cj1QLTU48qr9jLw --- .../CollectComponent/RunComponent.tsx | 33 +++++++++++++++++-- 1 file changed, 31 insertions(+), 2 deletions(-) diff --git a/src/renderer/components/CollectComponent/RunComponent.tsx b/src/renderer/components/CollectComponent/RunComponent.tsx index b68743cf..9b88ce37 100644 --- a/src/renderer/components/CollectComponent/RunComponent.tsx +++ b/src/renderer/components/CollectComponent/RunComponent.tsx @@ -4,7 +4,11 @@ import { Link } from 'react-router-dom'; import InputCollect from '../InputCollect'; import { injectMarker } from '../../utils/eeg'; import { sendMarker } from '../../utils/eeg/lslBridge'; -import { EXPERIMENTS, CONNECTION_STATUS } from '../../constants/constants'; +import { + EXPERIMENTS, + CONNECTION_STATUS, + SCREENS, +} from '../../constants/constants'; import { ExperimentWindow } from '../ExperimentWindow'; import { checkFileExists, getImages } from '../../utils/filesystem/storage'; import { @@ -43,6 +47,9 @@ const Run: React.FC<Props> = ({ const [isInputCollectOpen, setIsInputCollectOpen] = useState( subject.length === 0 ); + // A run finished this session — show a completion panel that points forward to + // Clean, instead of silently dropping back to the identical pre-run landing. + const [hasFinished, setHasFinished] = useState(false); const handleStartExperiment = useCallback(async () => { // Warn before a run that won't capture brain data: EEG turned off, or on @@ -116,17 +123,39 @@ const Run: React.FC<Props> = ({ const onFinish = useCallback( (csv) => { ExperimentActions.Stop({ data: csv }); + setHasFinished(true); }, [ExperimentActions] ); + const handleRunAgain = useCallback(() => { + setHasFinished(false); + }, []); + return ( <div className="h-screen p-[3%] bg-gradient-to-b from-[#f9f9f9] to-[#f0f0ff]" data-tid="container" > <div className="h-full"> - {!isRunning && ( + {!isRunning && hasFinished && ( + <div className="flex flex-col items-center justify-center h-full text-center gap-4"> + <h1 className="m-0">Recording complete 🎉</h1> + <p className="text-gray-600"> + Saved <b>{subject}</b>'s data. Ready to clean and analyze it? + </p> + <div className="flex gap-3 mt-2"> + <Button asChild variant="default"> + <Link to={SCREENS.CLEAN.route}>Clean your data →</Link> + </Button> + <Button variant="secondary" onClick={handleRunAgain}> + Run again + </Button> + </div> + </div> + )} + + {!isRunning && !hasFinished && ( <div className="h-screen p-[3%] bg-gradient-to-b from-[#f9f9f9] to-[#f0f0ff]"> <div className="text-left"> <h1>{title}</h1> From 5f2fc1e8196d2861f1d5b877595c4692bb8bcf05 Mon Sep 17 00:00:00 2001 From: jdpigeon <morrisondano@gmail.com> Date: Wed, 15 Jul 2026 17:36:33 -0400 Subject: [PATCH 19/29] feat(explore): device-agnostic signal help + settling reassurance MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 3: replace the Emotiv saline-soak help tip with device-agnostic guidance (clean bare skin, sweep hair) that also explains sensor settling; rename the HELP_STEP.SIGNAL_SALINE step to SIGNAL_SETTLING. Mount the HelpButton + HelpSidebar on the Explore screen (previously Collect-only). - 1 (lightweight): show an amber role="status" settling banner for the first ~45s after connecting ("a red, jumpy signal is normal while sensors make contact… watch it turn green"), auto-dismissed by a timer and manually closeable. No change to the signal-quality math — the honest red state still surfaces after the window. Full amber per-dot settling state (DF2) deferred to a live design-review pass. QA plan 3 + 1 (lightweight). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GTrEip1cj1QLTU48qr9jLw --- .../CollectComponent/HelpSidebar.tsx | 8 +-- .../components/EEGExplorationComponent.tsx | 67 ++++++++++++++++++- 2 files changed, 69 insertions(+), 6 deletions(-) diff --git a/src/renderer/components/CollectComponent/HelpSidebar.tsx b/src/renderer/components/CollectComponent/HelpSidebar.tsx index 7c633277..c0785594 100644 --- a/src/renderer/components/CollectComponent/HelpSidebar.tsx +++ b/src/renderer/components/CollectComponent/HelpSidebar.tsx @@ -4,7 +4,7 @@ import { Button } from '../ui/button'; enum HELP_STEP { MENU, SIGNAL_EXPLANATION, - SIGNAL_SALINE, + SIGNAL_SETTLING, SIGNAL_CONTACT, SIGNAL_MOVEMENT, LEARN_BRAIN, @@ -123,10 +123,10 @@ export class HelpSidebar extends Component<Props, State> { 'Improve the signal quality', 'In order to collect quality data, you want to make sure that all electrodes have a strong connection' ); - case HELP_STEP.SIGNAL_SALINE: + case HELP_STEP.SIGNAL_SETTLING: return this.renderHelp( - 'Tip #1: Saturate the sensors in saline', - 'Make sure the sensors are thoroughly soaked with saline solution. They should be wet to the touch' + 'Tip #1: Good skin contact (and give it a minute)', + "The sensors read best against clean, bare skin — sweep hair out from under them and wipe away any makeup or lotion. When you first put the headset on the signal often looks red and jumpy: that's normal while the sensors settle into contact. Sit still and it should calm down and turn green within a minute." ); case HELP_STEP.SIGNAL_CONTACT: return this.renderHelp( diff --git a/src/renderer/components/EEGExplorationComponent.tsx b/src/renderer/components/EEGExplorationComponent.tsx index ae0a2178..89f0a15a 100644 --- a/src/renderer/components/EEGExplorationComponent.tsx +++ b/src/renderer/components/EEGExplorationComponent.tsx @@ -11,6 +11,7 @@ import eegImage from '../assets/common/EEG.png'; import SignalQualityIndicatorComponent from './SignalQualityIndicatorComponent'; import ViewerComponent from './ViewerComponent'; import ConnectModal from './CollectComponent/ConnectModal'; +import { HelpSidebar, HelpButton } from './CollectComponent/HelpSidebar'; import { DeviceActions } from '../actions'; import { Device, DeviceInfo, SignalQualityData } from '../constants/interfaces'; import type { DiscoveredStream } from '../../shared/lslTypes'; @@ -28,13 +29,23 @@ interface Props { interface State { isConnectModalOpen: boolean; + isHelpVisible: boolean; + showSettlingBanner: boolean; } +// How long after connecting we reassure the user that a red/jumpy signal is +// just the sensors settling into contact (no change to the quality math). +const SETTLING_WINDOW_MS = 45_000; + export default class Home extends Component<Props, State> { + private settleTimer: ReturnType<typeof setTimeout> | null = null; + constructor(props: Props) { super(props); this.state = { isConnectModalOpen: false, + isHelpVisible: false, + showSettlingBanner: false, }; this.handleConnectModalClose = this.handleConnectModalClose.bind(this); this.handleStartConnect = this.handleStartConnect.bind(this); @@ -48,8 +59,25 @@ export default class Home extends Component<Props, State> { ) { this.setState({ isConnectModalOpen: false }); } + // On the transition into CONNECTED, show the settling reassurance and + // auto-dismiss it after the window. + if ( + prevProps.connectionStatus !== CONNECTION_STATUS.CONNECTED && + this.props.connectionStatus === CONNECTION_STATUS.CONNECTED + ) { + this.setState({ showSettlingBanner: true }); + if (this.settleTimer) clearTimeout(this.settleTimer); + this.settleTimer = setTimeout( + () => this.setState({ showSettlingBanner: false }), + SETTLING_WINDOW_MS + ); + } }; + componentWillUnmount() { + if (this.settleTimer) clearTimeout(this.settleTimer); + } + handleStartConnect() { this.setState({ isConnectModalOpen: true }); this.props.DeviceActions.SetDeviceAvailability( @@ -72,8 +100,29 @@ export default class Home extends Component<Props, State> { <div className="flex items-center h-[90%]"> {this.props.connectionStatus === CONNECTION_STATUS.CONNECTED && this.props.signalQualityObservable && ( - <div className="flex w-full"> - <div className="w-2/5"> + <div className="w-full"> + {this.state.showSettlingBanner && ( + <div + role="status" + aria-live="polite" + className="mb-3 flex items-center justify-between gap-3 rounded border border-amber-300 bg-amber-50 px-4 py-2 text-sm text-amber-900" + > + <span> + Sensors settling — a red, jumpy signal is normal for the + first minute while they make contact. Sit still and watch it + turn green. + </span> + <button + aria-label="Dismiss" + className="shrink-0 font-bold" + onClick={() => this.setState({ showSettlingBanner: false })} + > + ✕ + </button> + </div> + )} + <div className="flex w-full"> + <div className="w-2/5"> <SignalQualityIndicatorComponent signalQualityObservable={this.props.signalQualityObservable} plottingInterval={PLOTTING_INTERVAL} @@ -91,6 +140,7 @@ export default class Home extends Component<Props, State> { plottingInterval={PLOTTING_INTERVAL} /> </div> + </div> </div> )} {this.props.connectionStatus !== CONNECTION_STATUS.CONNECTED && ( @@ -122,6 +172,19 @@ export default class Home extends Component<Props, State> { /> </div> )} + {this.state.isHelpVisible ? ( + <div className="fixed top-0 right-0 z-50 h-full w-80 shadow-lg"> + <HelpSidebar + handleClose={() => this.setState({ isHelpVisible: false })} + /> + </div> + ) : ( + <div className="fixed bottom-6 right-6 z-40"> + <HelpButton + onClick={() => this.setState({ isHelpVisible: true })} + /> + </div> + )} </div> ); } From 45afe4b04cdac6f744f23de04efd108cb0a4790f Mon Sep 17 00:00:00 2001 From: jdpigeon <morrisondano@gmail.com> Date: Wed, 15 Jul 2026 17:40:38 -0400 Subject: [PATCH 20/29] =?UTF-8?q?refactor(collect):=20compact=20card=20lan?= =?UTF-8?q?ding=20=E2=80=94=20fix=20empty-bar=20edit=20button,=20dedupe=20?= =?UTF-8?q?wrapper?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Concrete markup fixes for the pre-run landing (QA item 4), short of the full two-column DF1 redesign (deferred to a live design-review): - The "mystery empty bar" was a `w-full` button holding only a pencil emoji; now a small ghost icon button beside the title. - Collapse the redundant nested h-screen/gradient wrapper (the outer container already provides it) into a centered flex. - Wrap the subject/group/session summary in a compact shadcn Card, right-size the Run Experiment button (no longer w-full), and drop the <h1>{title}</h1> that duplicated the nav breadcrumb. QA plan 4 (markup subset; DF1 live-signal column deferred). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GTrEip1cj1QLTU48qr9jLw --- .../CollectComponent/RunComponent.tsx | 63 ++++++++++--------- 1 file changed, 33 insertions(+), 30 deletions(-) diff --git a/src/renderer/components/CollectComponent/RunComponent.tsx b/src/renderer/components/CollectComponent/RunComponent.tsx index 9b88ce37..64cacb38 100644 --- a/src/renderer/components/CollectComponent/RunComponent.tsx +++ b/src/renderer/components/CollectComponent/RunComponent.tsx @@ -1,5 +1,6 @@ import React, { useCallback, useState } from 'react'; import { Button } from '../ui/button'; +import { Card, CardHeader, CardContent } from '../ui/card'; import { Link } from 'react-router-dom'; import InputCollect from '../InputCollect'; import { injectMarker } from '../../utils/eeg'; @@ -156,36 +157,38 @@ const Run: React.FC<Props> = ({ )} {!isRunning && !hasFinished && ( - <div className="h-screen p-[3%] bg-gradient-to-b from-[#f9f9f9] to-[#f0f0ff]"> - <div className="text-left"> - <h1>{title}</h1> - <button - className="flex justify-end w-full" - onClick={() => setIsInputCollectOpen(true)} - aria-label="Edit" - > - ✏ - </button> - <div> - Subject ID: <b>{subject}</b> - </div> - <div> - Group Name: <b>{group}</b> - </div> - <div> - Session Number: <b>{session}</b> - </div> - <div className="mt-6"> - <Button - variant="default" - className="w-full" - onClick={handleStartExperiment} - disabled={!subject} - > - Run Experiment - </Button> - </div> - </div> + <div className="flex items-center justify-center h-full"> + <Card className="w-full max-w-md"> + <CardHeader> + <div className="flex items-center justify-between"> + <h2 className="m-0 text-lg font-semibold">Ready to run</h2> + <Button + variant="ghost" + size="icon" + aria-label="Edit subject details" + onClick={() => setIsInputCollectOpen(true)} + > + ✏ + </Button> + </div> + </CardHeader> + <CardContent className="space-y-1"> + <div> + Subject ID: <b>{subject}</b> + </div> + <div> + Group Name: <b>{group}</b> + </div> + <div> + Session Number: <b>{session}</b> + </div> + <div className="mt-6"> + <Button onClick={handleStartExperiment} disabled={!subject}> + Run Experiment + </Button> + </div> + </CardContent> + </Card> </div> )} From 376740f5317c422275c4cf5631b5f1547ba05c4a Mon Sep 17 00:00:00 2001 From: jdpigeon <morrisondano@gmail.com> Date: Wed, 15 Jul 2026 21:04:00 -0400 Subject: [PATCH 21/29] feat(analyze): warm empty state for the clean-dataset picker MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When no cleaned data exists, the "Select Clean Datasets" picker rendered an empty box with no guidance — the confusing blank Analyze screen from the QA walkthrough. Show a warm message ("No cleaned data yet — clean a recording first…") with a "Go to Clean →" link instead. QA plan 9b / T11 (Analyze empty state). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GTrEip1cj1QLTU48qr9jLw --- src/renderer/components/AnalyzeComponent.tsx | 46 +++++++++++++------- 1 file changed, 31 insertions(+), 15 deletions(-) diff --git a/src/renderer/components/AnalyzeComponent.tsx b/src/renderer/components/AnalyzeComponent.tsx index c486dfaf..69ea8e96 100644 --- a/src/renderer/components/AnalyzeComponent.tsx +++ b/src/renderer/components/AnalyzeComponent.tsx @@ -1,4 +1,5 @@ import React, { Component } from 'react'; +import { Link } from 'react-router-dom'; import { Button } from './ui/button'; import { isNil } from 'lodash'; import Plot from 'react-plotly.js'; @@ -7,6 +8,7 @@ import { DEVICES, MUSE_CHANNELS, EXPERIMENTS, + SCREENS, } from '../constants/constants'; import { readWorkspaceCleanedEEGData, @@ -376,22 +378,36 @@ export default class Analyze extends Component<Props, State> { EEG differs between electrodes </p> <h4>Select Clean Datasets</h4> - <select - multiple - className="w-full border border-gray-300 rounded p-1" - value={this.state.selectedFilePaths} - onChange={this.handleDatasetChange} - > - {this.state.eegFilePaths.map((eegFilePath) => ( - <option - key={eegFilePath.key} - value={String(eegFilePath.value)} + {this.state.eegFilePaths.some((f) => f.key !== '') ? ( + <> + <select + multiple + className="w-full border border-gray-300 rounded p-1" + value={this.state.selectedFilePaths} + onChange={this.handleDatasetChange} > - {eegFilePath.text} - </option> - ))} - </select> - {this.renderEpochLabels()} + {this.state.eegFilePaths.map((eegFilePath) => ( + <option + key={eegFilePath.key} + value={String(eegFilePath.value)} + > + {eegFilePath.text} + </option> + ))} + </select> + {this.renderEpochLabels()} + </> + ) : ( + <div className="rounded border border-dashed border-gray-300 p-4 text-sm text-gray-600"> + <p className="mb-2"> + No cleaned data yet — clean a recording first, then it'll + show up here to analyze. + </p> + <Button asChild variant="secondary" size="sm"> + <Link to={SCREENS.CLEAN.route}>Go to Clean →</Link> + </Button> + </div> + )} </div> <div className="w-2/3 p-2"> <PyodidePlotWidget From 5e3084370d28555739ae0032448fe84480fa8364 Mon Sep 17 00:00:00 2001 From: jdpigeon <morrisondano@gmail.com> Date: Wed, 15 Jul 2026 21:05:02 -0400 Subject: [PATCH 22/29] docs(qa-plan): mark implemented tasks + record deferred items MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Checks off T1–T7, 6a/6g, #3/#4-markup/#5, lightweight #1, and the Analyze empty state (commits beb1620..376740f), and documents why #2/#6c (profile-first), #4-DF1 + DF2-full (visual/design-review), and #6d (Phase-3 product) are deferred. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GTrEip1cj1QLTU48qr9jLw --- docs/faces-houses-qa-plan.md | 603 +++++++++++++++++++++++++++++++++++ 1 file changed, 603 insertions(+) create mode 100644 docs/faces-houses-qa-plan.md diff --git a/docs/faces-houses-qa-plan.md b/docs/faces-houses-qa-plan.md new file mode 100644 index 00000000..a81b9d1c --- /dev/null +++ b/docs/faces-houses-qa-plan.md @@ -0,0 +1,603 @@ +# Faces/Houses flow — QA walkthrough plan + +Running plan assembled during a live QA walkthrough of the Faces/Houses experiment +flow (Dano driving, 2026-07-15). **This is a backlog, not applied work** — a future +session implements these. Each item: what Dano observed → where it lives in code → +proposed fix shape. Grounding is pre-work so the implementing session starts warm. + +Ties into TODOS.md **"Full experiment-flow QA pass"** (Now / Summer 2026). + +--- + +## Locked decisions & sequencing (`/plan-eng-review`, 2026-07-15) + +**D1 — units fix scope (6e): unconditional µV→V, defer LSL units.** Scale the **eeg** +channels ×1e-6 in `load_data`, keyed on `ch_type == 'eeg'` (NOT positional `data[:-1]` — +explicit over clever; stim channel stays untouched). Correct for Muse (the shipping +device). External-LSL unit-correctness is an explicit TODO (see NOT in scope), not silent. + +**D2 — sequence: data-integrity first, then QA in order, no revalidation pass.** +1. **First: correctness bugs** — 6e (units + zero-epoch guard), 7 (EEG default + no-EEG + warning), 9a (folder), 9b (empty-Analyze guard/toast), 8 (USB removal). +2. **Then: work the remaining QA items in order** (1 → 2 → 3 → 4 → 5 → 6a-d → 6g). + No dedicated re-QA gate between the two — proceed straight through. + +Note (context, not a blocker): #7 means EEG defaulted off, so some downstream observations +(6c rendering, 9b) *may* have been influenced by a behavior-only run. Not gating on +re-validation per D2 — the impl session confirms as it goes. + +--- + +## 1. Explore EEG: forgiving onboarding for electrode settling time + +**Observed (Explore EEG Data screen, Muse just placed on head):** the raw trace is a +wall of scary red lines. This is the normal skin-conductance / electrode settling +artifact that resolves after the electrodes sit on the head for a bit — but a +first-time user has no way to know that. Reads as "broken / bad device." + +**Where it lives:** +- Signal-quality color is std-dev based, per channel: + `src/renderer/utils/eeg/pipes.ts` (`parseMuseSignalQuality`) maps std → GREAT/OK/BAD/DISCONNECTED. + Thresholds `src/renderer/constants/constants.ts` `SIGNAL_QUALITY_THRESHOLDS` + (BAD ≥ 15, OK ≥ 10, GREAT ≥ 1.5). At settling, std is high → everything BAD (red). +- Rendered by `SignalQualityIndicatorComponent.tsx` (head diagram fill) and the raw + trace in `ViewerComponent.tsx`. +- Screen: `EEGExplorationComponent.tsx` (also gates the pre-connect vs connected view). +- Same red-on-settle problem almost certainly shows in the **connect / pre-test flow** + (`CollectComponent/ConnectModal.tsx`, `PreTestComponent.tsx`) — check there too. + +**Proposed fix shape (decide in impl session):** +- Add a settling window: track time-since-connect; for the first ~30–60s show a + friendly banner/overlay ("Electrodes are settling — red is normal for the first + minute while the sensors make contact. Sit still and watch it turn green.") with + optional countdown or a "still settling…" amber state instead of raw BAD-red. +- Lightweight version (ponytail): banner + timer on the Explore + connect screens, no + change to the quality math. Heavier: an explicit `SETTLING` signal-quality state + derived from time-since-connect that suppresses BAD styling early on. +- Keep the honest state reachable — if it's *still* red after the window, that's a real + bad-contact signal and should surface. + +**Open question:** settle window length, and whether "settling" is time-based only or +also "std is trending down." Product-shaped — likely a quick /office-hours call. + +--- + +## 2. Explore EEG: raw trace rendering is chunky / stutters + +**Observed:** the scrolling EEG trace stutters and looks chunky. Long-standing, not new. +Suspected to be the SVG line-drawing approach. + +**Where it lives:** +- Trace renders inside a bundled `<webview>` page driven by + `src/renderer/components/d3Classes/EEGViewer.js` — d3 into an **SVG `<path>`** + (`append('path')`, line 188), with `downsampling = 2` and `simplify-js` on incoming + epochs. Host component: `ViewerComponent.tsx` (feeds epochs to the webview). +- Redrawing/growing SVG paths every plotting interval is the likely stutter source + (SVG path re-layout, not GPU-accelerated). + +**Proposed fix shape (decide in impl session):** +- Cheapest first: profile whether the jank is redraw cost vs. epoch cadence. Tune + `downsampling`, `simplify-js` tolerance, and the d3 transition/`plottingInterval` + before any rewrite. +- If SVG is genuinely the ceiling: swap the trace to `<canvas>` (2D) or WebGL. Note the + epoch-review reviewer has the *same* SVG-vs-WebGL tension logged (see + `docs/epoch-review-ui-plan.md` OQ1 / TODOS Phase 4 `drawEpochs`) — worth solving both + with one rendering approach rather than twice. + +**Ponytail flag:** don't jump to WebGL first. Profile + tune the existing SVG path; only +rewrite if measurement says SVG is the wall. + +--- + +## 3. Generalize the signal-quality help content + surface it on Explore EEG + +**Observed:** the Collect screen already has good signal-quality explanation content, +but the tips are written for the **old Emotiv device** and only exist on Collect. This +should be (a) generalized to be device-agnostic and (b) also shown on the Explore EEG +screen. Ties into #1 — the settling explanation and these tips are the same "help me +get a good signal" surface. + +**Where it lives:** +- Content + stepper: `src/renderer/components/CollectComponent/HelpSidebar.tsx` + (`HELP_STEP` enum + `renderHelpContent`). Exports `HelpSidebar` (full panel) and + `HelpButton` (toggle). +- Mounted only in the Collect flow: `CollectComponent/PreTestComponent.tsx` + (lines 125–168). `HelpButton` alone also appears in `AnalyzeComponent.tsx:514`. +- **Not present** on Explore EEG (`EEGExplorationComponent.tsx`) — that's the gap. +- File already carries a TODO: *"Refactor this into a more reusable Sidebar component + that can be used in Collect, Clean, and Analyze screen"* — this item cashes that in. + +**Emotiv-specific content to fix (device-agnostic rewrite):** +- `HELP_STEP.SIGNAL_SALINE` — "Saturate the sensors in saline" is Emotiv gel/saline-cap + language. Muse uses dry/conductive electrodes — no saline soak. Drop or replace with + Muse-appropriate guidance (skin contact, sweep hair, dampen skin if very dry). +- `SIGNAL_CONTACT` — "reference electrodes right behind the ears" *is* Muse-accurate + (TP9/TP10 sit behind the ears), keep it. Audit the rest for Emotiv assumptions. +- Consider driving tips off the connected `deviceType` (Muse vs Neurosity vs external + LSL) so the copy matches the hardware, rather than one hard-coded device. + +**Proposed fix shape:** +- Extract `HelpSidebar` into a reusable, device-aware help panel (honors the existing + refactor TODO). Add device-agnostic settling copy (folds in #1's explanation). +- Mount the `HelpButton` + panel on `EEGExplorationComponent.tsx` so Explore gets the + same signal-quality help. +- Ponytail: don't build a full per-device content system if two shared strings + one + Muse-specific override covers today's devices. Generalize only as far as the current + device set needs. + +## 4. Collect "Run Experiment" landing — ugly, wastes space, redesign + +**Observed (step 2 COLLECT, pre-run landing):** the screen looks poorly designed. A +mystery empty input bar under the title, metadata stacked as plain text, a giant +full-width button, then a huge empty lower half. Wants efficient use of space. + +**Where it lives:** `src/renderer/components/CollectComponent/RunComponent.tsx` +(the `!isRunning` branch, lines 102–134). + +**Concrete problems found in the markup:** +- **The "empty input bar" is actually the edit button.** Line 106–112: + `<button className="flex justify-end w-full">✏</button>` — a full-width button holding + only a right-aligned pencil emoji. It renders as an empty full-width bar that reads as + a broken/empty text field. Should be a small icon button beside the title or the + subject metadata, not a `w-full` bar. +- **Metadata is unstyled stacked text** (Subject ID / Group Name / Session Number) — + no card, no grouping, weak hierarchy. +- **`Run Experiment` is `w-full`** (line 125) — oversized primary action. +- **Everything is top-left with a vast empty lower half** — no layout using the space + (e.g. centered card, or a two-column with device/signal status). +- **Redundant nested wrapper:** the outer `<div>` (line 97) *and* the inner `!isRunning` + `<div>` (line 103) both set `h-screen p-[3%] bg-gradient-to-b …` — double gradient + + double screen-height padding. Cheap cleanup, collapse to one. +- **Title duplicates the nav breadcrumb** — `<h1>{title}</h1>` repeats + "Faces_and_Houses" already shown in the top nav dropdown. Consider dropping or + repurposing. + +**Proposed fix shape:** +- Rebuild as a compact centered card: title (or drop it), a clean subject/group/session + summary block with a small inline edit (pencil) affordance, a right-sized primary + "Run Experiment" button. Fill the space intentionally (center the card, or add + device/signal-quality status alongside so the operator confirms readiness before run). +- Collapse the redundant `h-screen`/gradient wrapper. +- Design-shaped — worth a `/design-review` or `/design-consultation` pass in the impl + session rather than eyeballing spacing. + +**Ponytail:** this is one small component. Don't introduce a layout system — a single +centered `Card` (shadcn `Card` already exists) + right-sized button covers it. + +## 5. No forward breadcrumb after finishing a run — flow dead-ends on Collect + +**Observed:** completed the experiment and landed back on the same "Run Experiment" +screen. Nothing indicates the run is done or points forward to Clean. The only way to +advance is the top workflow nav (3 CLEAN) — undiscoverable for a first-timer; the flow +dead-ends. + +**Where it lives:** +- Run finish path: `RunComponent.tsx` `onFinish` → `ExperimentActions.Stop({data})` + (line 89–94). When `isRunning` flips false, it re-renders the *same* pre-run landing + (the `!isRunning` branch, line 102) — there is **no post-completion state**. It looks + identical to before the run, so "did it even record?" is unclear too. +- Forward nav today: the workflow steps in + `src/renderer/components/SecondaryNavComponent/index.tsx` (`onStepClick` per step) → + Clean route is `SCREENS.CLEAN.route` (`routes.tsx`, `constants.ts`). That's the only + COLLECT→CLEAN path. + +**Proposed fix shape:** +- Give `RunComponent` a distinct **post-run / completion state** (track "a run finished + this session", e.g. off the `Stop` result / recorded file) that: + - confirms the recording succeeded (filename, trial/marker count if cheap), and + - shows a primary **"Clean your data →"** CTA that navigates to `SCREENS.CLEAN.route` + (reuse the same routing the secondary nav step uses), plus a secondary "Run again." +- This is the same "guide the operator to the next step" gap that #4's redesign touches + — do them together in the Collect/Run pass. + +**Ponytail:** don't build a wizard. One completion panel with a "Clean your data →" +link-button (react-router `Link` is already imported in `RunComponent`) closes the gap. + +## 6. Clean screen — multiple issues (layout, labels, viewer, and a real units bug) + +**Screen:** `src/renderer/components/CleanComponent/index.tsx` (+ `webworker/utils.py`). +Dano's walkthrough surfaced four UX asks plus, on auto-flag, a genuine data bug. + +### 6a. Settings gear icon is mis-sized +The ⚙︎ auto-flag settings button (`CleanComponent/index.tsx` ~line 352–359) looks +too-big / not-big-enough next to "Clean Data" and "Auto-flag artifacts". Size it to +match the adjacent buttons (icon-button sizing / same height). + +### 6b. Condition labels show STIMULUS_1 / STIMULUS_2 instead of real names (Faces, Houses) +Counts, legends, and the ERP all read "STIMULUS_1 / STIMULUS_2". Should show the +experiment's real condition names ("Faces", "Houses"). +- Source of the generic labels: `src/renderer/utils/eeg/markerRegistry.ts` — + `CODE_TO_LABEL` is hard-coded `STIMULUS_1..4` (deliberately "condition-neutral"). + `buildMarkerRegistry` only ever emits those. +- Fix shape: let the registry derive a human label from the experiment's stimulus + metadata (per-experiment code→name map, e.g. Faces_and_Houses: 1→Faces, 2→Houses), + falling back to `STIMULUS_n` when unknown. Keep the numeric code contract intact + (labels are display-only; codes still drive CSV + MNE `event_id`). Thread the real + label into the Clean counts/legend/ERP. +- Ponytail: don't build an i18n/label system — a per-experiment `{code: name}` map on + the experiment object, read by `buildMarkerRegistry`, covers it. + +### 6c. Epoch viewer is too small and renders poorly +The Epochs panel gets too little real estate and the traces render badly (same +SVG-trace rendering concern as #2). Ties to the epoch-reviewer WebGL/`drawEpochs` +question already logged (`docs/epoch-review-ui-plan.md` OQ1, TODOS Phase 4). + +### 6d. Re-layout to prioritize Epochs + add a reject-onboarding flow +Give the Epochs viewer much more space, and preempt an onboarding flow that teaches +students how to read epochs and reject bad ones. **This is already scoped** as +**Epoch reviewer Phase 3 — onboarding layer** in TODOS.md (plain-language epoch/artifact +explanations, guided mode, channel legend tied to head position). This walkthrough is +direct evidence for prioritizing it. Route the product questions (onboarding depth, +guided-mode-as-default — TODOS OQ3) through /office-hours or /plan-ceo-review. + +### 6e. 🔴 BUG: Auto-flag rejects ALL epochs — µV→V units error (NOT threshold too low) +**Observed:** "Auto-flag artifacts" flagged all 16/16 epochs → ERP averages over 0 +epochs (blank). Flag reasons: **peak-to-peak 65,715,724 µV / 66,228,063 µV / +89,980,195 µV**. Dano's read was "thresholds too low"; the real cause is deeper. + +**Root cause (grounded):** 65 million µV = ~65 **volts** peak-to-peak is physically +impossible for EEG (real Muse ptp is tens of µV; the amp rails well under 1 mV). The +value is inflated ~1e6×: +- `webworker/utils.py` `load_data` (lines ~90–95) builds `RawArray(data=data, …)` + straight from the CSV values with **no µV→V scaling**. MNE treats EEG channel data as + **volts**, but the Muse CSV is in **µV**. So a real ~65 µV excursion is loaded as + ~65 "volts". +- `suggest_rejections` (utils.py ~line 350–359) computes ptp in MNE's unit (thinks + volts) then `* 1e6` to report µV → 65 → 65,000,000 µV. Threshold default + `DEFAULT_PTP_THRESHOLD_UV = 100` (`constants.ts:12`) → `thresh_v = 1e-4 V`, but ptp is + ~65 in MNE units, so **every** epoch exceeds it. + +**Fix shape:** scale CSV µV → V on load in `load_data` — multiply the **eeg** channels +by `1e-6` before/at `RawArray` (do NOT scale the last `stim`/Marker channel; markers are +numeric codes — see `.llms/learnings.md`). After the fix, ptp ≈ 65 µV, the 100 µV +default behaves sanely, and auto-flag stops nuking everything. **Verify against the +golden test** (`tests/analysis/test_erp_roundtrip.py`) — a global scale factor won't fail +its relative-amplitude assertions, so add an absolute-µV sanity check so this can't +regress silently. Also audit downstream consumers that assume the current (wrong) scale: +epoch viewer amplitude, `amp +/−`, ERP y-axis, `SetEpochInfo` payloads. + +**Do not** just raise `DEFAULT_PTP_THRESHOLD_UV` — that hides a real correctness bug and +would let genuine artifacts through once the units are fixed. + +### 6f. ⚠ Flagged console error (see-something-say-something) +The Clean screen logs `The specified value "NaN" cannot be parsed, or is out of range` +(repeating). Likely a DOM/SVG/input attribute being set to `NaN` — plausibly the epoch +viewer or the `amp`/threshold `<input>` receiving `NaN`. Not user-reported; flagging so +the impl session tracks it down (may share a cause with 6c/6e). + +### 6g. Auto-flag threshold: slider with device presets, not a raw µV number input +**Observed:** the threshold is a bare absolute-µV number `<input>` — students have no +idea what number to type. Make it a streamlined **slider** with sensible values +**pre-selected per device** (Muse vs Neurosity vs external LSL), so users adjust +sensitivity intuitively instead of guessing microvolts. + +**Where it lives:** `CleanComponent/index.tsx` — `autoFlagThreshold` state (default +`DEFAULT_PTP_THRESHOLD_UV = 100`, `constants.ts:12`) rendered as a numeric `<input>` in +the auto-flag settings panel (~lines 369–378); parsed in the `!Number.isNaN` handler +(~line 206–213). shadcn has no Slider yet — add `@radix-ui/react-slider` (same family as +the already-installed Select) or a styled native `<input type="range">`. + +**Fix shape:** +- Replace the number input with a slider (labeled low/high sensitivity, showing the + current µV under the hood but not requiring users to reason in µV). +- Seed the default + range from the connected device — a per-device preset map + (reasonable ptp ceilings differ by hardware). Fall back to `DEFAULT_PTP_THRESHOLD_UV`. +- **Depends on 6e:** presets are only meaningful once the µV→V units bug is fixed — + otherwise "100 µV" doesn't correspond to reality. Sequence 6e before tuning presets. + +**Ponytail:** a native `<input type="range">` + a small `{device: {min,max,default}}` +map covers this — no slider component library required unless the design calls for it. + +## 7. EEG disabled by default + no warning when running without EEG + +**Observed:** ran an experiment with EEG not connected, was **not notified**, and found +"Enable EEG" was **off by default** — nonsensical for an EEG app. Result: a run that +records behavior but no brain data, silently. + +**Where it lives:** +- Default: `src/renderer/reducers/experimentReducer.ts:38` — initial state + `isEEGEnabled: false`. Toggled via `SetEEGEnabled` (`experimentActions.ts:38`); the + toggle UI is the "Enable EEG" switch in `SecondaryNavComponent` SettingsDropdown + (wired from the workflow screens, e.g. `DesignComponent`). +- Cascade when `isEEGEnabled === false`: + - `CollectComponent.componentDidMount` skips auto-connect (only connects if EEG on). + - `RunComponent`: `isRunComponentOpen` initializes to `!isEEGEnabled` → opens straight + to the Run screen, bypassing connect/pre-test. + - `RunComponent.eventCallback` gates on `isEEGEnabled` → **no markers injected**, so + even if a device were streaming, this path wouldn't tag events. + - Net: no device, no markers, no warning — the operator only finds out later (empty + EEG, e.g. the Clean screen). + +**Proposed fix shape:** +- **Flip the default to `isEEGEnabled: true`** (one line in `experimentReducer.ts`). + EEG-on is the app's whole point; opt *out* for the behavior-only case, not opt in. +- **Warn before a no-EEG run.** When starting a run with EEG enabled but no device + connected — or with EEG disabled — surface a clear confirm ("EEG is not connected — + this run will record responses but no brain data. Continue?"). `RunComponent`'s + `handleStartExperiment` already does a `showMessageBox` confirm for file-overwrite; + reuse that pattern for the no-EEG case. +- Consider persisting the user's EEG-enabled choice rather than resetting per session. + +**Ponytail:** the default flip is one line; the guard is one extra `showMessageBox` +branch in the existing `handleStartExperiment`. No new state machine needed. + +## 8. Remove the "Insert the USB Receiver" modal step (Emotiv-era, irrelevant for Muse) + +**Observed:** the connect flow still has an "Insert the USB Receiver" step. Muse is +Bluetooth — no dongle. Remove it. + +**Where it lives:** `src/renderer/components/CollectComponent/ConnectModal.tsx`. +Local `enum INSTRUCTION_PROGRESS { SEARCHING, TURN_ON, COMPUTER_CONNECTABILITY }` +(line 42). Flow: `TURN_ON` ("headset on & charged") → **`COMPUTER_CONNECTABILITY`** +("Insert the USB Receiver", lines ~260–293) → `handleSearch()` → device list. + +**Exact removal (trivial, ready to implement):** +1. Delete the `COMPUTER_CONNECTABILITY` render block (lines ~260–293). +2. In the `TURN_ON` step, change **Next** (lines ~245–255) from + `handleinstructionProgress(INSTRUCTION_PROGRESS.COMPUTER_CONNECTABILITY)` to call + `this.handleSearch` directly (what the USB step's Next did) — so "headset on" flows + straight to searching. +3. Remove `COMPUTER_CONNECTABILITY` from the enum. +4. Leave the device-list "Back" / "Don't see your device?" targets (`…Progress(1)` = + `TURN_ON`) as-is — still valid. + +**Note:** this is small and low-risk — a good candidate to just land in the impl session +(clean tree → one atomic commit). No product decision needed. + +## 9. Analyze screen empty after Clean→Analyze + "Go to Folder" broken (two real bugs) + +### 9a. 🔴 BUG: "Go to Folder" on Home does nothing — relative path to shell API +**Observed:** the "Go to Folder" button on the Home screen doesn't open the workspace, +blocking any manual check of what was written. + +**Root cause (grounded):** `src/renderer/utils/filesystem/storage.ts:29` +``` +openWorkspaceDir = (title) => api().showItemInFolder(path.join('BrainWaves_Workspaces', title)) +``` +passes a **relative** path `"BrainWaves_Workspaces/<title>"` to +`shell.showItemInFolder` (`src/main/index.ts:159`). `shell.showItemInFolder` requires an +**absolute** path — given a relative, non-existent one it silently no-ops. The real +workspace dir is absolute: `path.join(os.homedir(), 'BrainWaves_Workspaces', title)` +(`main/index.ts:116–118`, `getWorkspaceDir`). Called from +`HomeComponent/index.tsx:250`. + +**Fix shape:** resolve the absolute path in main, not the renderer. Add a +`shell:openWorkspaceDir` handler that does `shell.openPath(getWorkspaceDir(title))` +(opening the folder is better UX than `showItemInFolder`, which highlights the item in +the parent). Point `openWorkspaceDir` at it. Removes the relative-path guesswork and the +duplicated `'BrainWaves_Workspaces'` literal. Small, isolated fix. + +### 9b. 🔴 BUG: Analyze "Select Clean Datasets" is empty after cleaning +**Observed:** cleaned the dataset, clicked Analyze, and the "Select Clean Datasets" +picker is empty — nothing to analyze. + +**Grounding — the write/read paths MATCH, so the file wasn't written (not a scan miss):** +- Write: `fs:writeCleanedEpochs` (`main/index.ts:332`) → + `<workspace>/Data/<subject>/EEG/<subject>-cleaned-epo.fif`. +- Read: `fs:readWorkspaceCleanedEEGData` (`main/index.ts:246`) scans the workspace + recursively for the `epo.fif` suffix — `-cleaned-epo.fif` matches. So the picker would + list the file **if it existed**. Empty ⇒ no cleaned `.fif` on disk. +- Save trigger: the worker must emit a `dataKey === 'savedEpochs'` message with a buffer; + only then does `pyodideEpics.ts:129–145` call `writeCleanedEpochs` (+ "Cleaned data + saved" toast). If that message/buffer never arrives, nothing is written and nothing + errors. + +**Two suspects for the impl session (verify, don't assume):** +1. **Consequence of the units bug (6e):** on this run auto-flag had marked **all 16/16 + epochs** for rejection. If Dano clicked "Clean Data" with everything flagged, the drop + removes every epoch → the saved Epochs object is **empty**, which may fail to write a + valid `.fif` (or write a degenerate one that the scan/parse skips) — with no user- + facing error. Fix 6e first, then re-test a normal clean (few rejections). Also: **guard + against cleaning to zero epochs** (warn / block "Clean Data" when the drop set == all + epochs) so a student can't silently destroy their dataset. +2. **The known worker-RPC FIFO fragility** (see `.llms/learnings.md` / TODOS "Full Pyodide + worker RPC"): `worker.postMessage` awaits are no-ops, so the drop→save→re-fetch + sequence relies on FIFO ordering. If the save step's buffer is dropped/empty, the + `savedEpochs` branch silently writes nothing. Add an explicit failure surface — if + `savedEpochs` never arrives (or arrives empty), toast an error instead of failing + silent. + +**Common thread with the whole walkthrough:** silent failure. Collect ran with no EEG and +no warning (#7); auto-flag nuked everything from a units bug (#6e); cleaning to zero +epochs produced an unanalyzable dataset with no error here. The impl session should add +user-facing guards/toasts at each of these "you just destroyed your data" moments. + +<!-- append new walkthrough items below as Dano surfaces them --> + +--- + +## Test coverage (from /plan-eng-review) + +Frameworks: **pytest** (`tests/analysis/`, native MNE — the data path) + **vitest** (JS). +Most UX items are visual → verified via `/qa` or manual, no unit test. The code-logic +items need tests: + +``` +CODE PATHS (testable) STATUS +[6e] webworker/utils.py load_data — µV→V scale + ├── [GAP → CRITICAL] eeg channels scaled ×1e-6 regression test REQUIRED (iron rule) + │ assert ptp of a known recording lands ~20–150µV, NOT ~1e6× + │ (same tests/analysis suite as jitter/dropped-sample — prior learning) + └── [GAP] stim/Marker channel NOT scaled assert marker codes unchanged (still 1,2) +[6e/6d] Clean "Clean Data" zero-epoch guard + └── [GAP] dropSet == all epochs → block/warn unit/integration: no empty .fif written +[7] experimentReducer isEEGEnabled default + ├── [GAP] initial state === true trivial unit test + └── [GAP] start-run-without-device → warn path RunComponent handleStartExperiment branch +[6b] buildMarkerRegistry — condition names + └── [GAP] type→stimulus.condition label ("Faces") unit test in markerRegistry test +[9a] openWorkspaceDir absolute path manual verify (shell call); low ROI to unit +[9b] savedEpochs never-arrives → error toast [GAP] integration: assert toast on empty save + +COVERAGE: data-path logic largely UNTESTED for these paths. 6e regression = mandatory. +``` + +## Failure modes (new/changed codepaths) + +| Codepath | Realistic failure | Test? | Error handling? | User sees | +|----------|-------------------|-------|-----------------|-----------| +| 6e µV→V scale | scale applied to stim row too → marker codes corrupted | ADD | n/a | **silent** wrong ERPs — **critical gap** until test added | +| 6e zero-epoch clean | user drops all epochs → empty/invalid `.fif` | ADD | ADD guard | today: silent; after fix: warned | +| 7 no-EEG run | run with device disconnected | ADD | ADD confirm | today: **silent** no-data run — critical gap | +| 9b cleaned-save | worker `savedEpochs` never arrives / empty buffer | ADD | ADD toast | today: **silent** empty Analyze — critical gap | +| 9a folder open | relative path → shell no-op | manual | already logs | today: silent no-op | + +**Critical gaps (silent failure + no test + no handling, today):** 6e stim-scaling, +7 no-EEG run, 9b cleaned-save. All three get a guard/toast in the correctness PR. + +## NOT in scope (deferred, with rationale) + +- **External-LSL unit correctness** — the µV→V fix assumes µV CSV (true for Muse). LSL + streams may carry V or arbitrary units; proper per-stream unit metadata is its own task + (touches write path + IPC). Deferred per D1; TODO below. +- **Epoch-viewer WebGL rewrite (2 / 6c)** — profile + tune SVG first; rewrite only if + measured as the ceiling. Unify with epoch-reviewer `drawEpochs` (Phase 4) if it happens. +- **Full per-experiment label i18n (6b)** — wire `stimulus.condition` only; no label system. +- **Reject-onboarding curriculum depth (6d)** — product decision (Epoch reviewer Phase 3 + / TODOS OQ3); route through /office-hours, not this backlog. +- **Worker-RPC refactor (9b suspect 2)** — the real `runPython` RPC is already a standing + TODO; here we only add a failure toast, not the refactor. + +## What already exists (reuse, don't rebuild) + +- 6b: `Stimulus.condition` / `condition_first_title` already carry "Faces"/"Houses"; + `buildMarkerRegistry(stimuli)` already receives them. +- 7: `showMessageBox` confirm pattern already in `RunComponent.handleStartExperiment`. +- 5: react-router `Link` already imported in `RunComponent`. +- 9a: `getWorkspaceDir` already resolves the absolute workspace path. +- 4: shadcn `Card` exists. 3: HelpSidebar refactor already a standing TODO. + +## Implementation Tasks + +**Implementation status (2026-07-15, autonomous pass on `epoch-reviewer-phase2`).** +12 atomic commits `beb1620..376740f`. All 48 JS + 18 Python tests green. + +- [x] **T1 (P1)** — webworker/utils.py — scale eeg channels µV→V in `load_data` (key on `ch_type=='eeg'`) — `beb1620` + - Also emits µV at the two display boundaries (`get_epochs_arrays`, ERP plot) so + the viewer/plot show the same numbers; regression guard asserts ptp 1–1000µV; + fixed `test_detects_injected_artifact` (was a ptp-neutral DC offset that only + "passed" because the units bug flagged everything). +- [x] **T2 (P1)** — Clean — guard "Clean Data" when drop set == all epochs (confirm before drop) — `1e38197` +- [x] **T3 (P1)** — experiment — `isEEGEnabled` default `true` + warn on no-EEG-connected run — `7fdccfb` +- [x] **T4 (P1)** — main — `openWorkspaceDir` uses absolute path (`shell.openPath(getWorkspaceDir(title))`) — `68a410b` +- [x] **T5 (P2)** — error toast when worker `savedEpochs` arrives empty/missing — `4247b15` + - Empty-buffer backstop (trivial branch, no integration test — real prevention is T2). +- [x] **T6 (P2)** — ConnectModal — remove USB-receiver step (`COMPUTER_CONNECTABILITY`) — `c1cb40d` +- [x] **T7 (P2)** — markerRegistry — map `type → stimulus.condition` for display labels — `69396e4` + - Real names are "Face"/"House" (the source `condition`); Clean legend + Python ERP + pick them up automatically. Guards against two codes collapsing under one label. +- [~] **T8 (P2/P3)** — UX polish, in order. **Done:** 1-lite (settling banner, `5f2fc1e`), + 3 (help de-Emotiv + Explore mount, `5f2fc1e`), 4-markup (empty-bar button, dedupe + wrapper, Card landing, `45afe4b`), 5 (forward CTA, `b68096a`), 6a (gear icon-size, + `8d15e44`), 6g (sensitivity slider, `8d15e44`), 6f (very likely resolved — the only + `type="number"` input, the sole source of that DOM `"NaN"` error, was replaced by the + 6g slider; needs live confirmation). Analyze warm empty state (T11 subset, `376740f`). + **Deferred (need live app / device / design-review — see below):** 2 (trace perf — + profile first), 4-DF1 (two-column live-signal column), 6c (epoch-viewer perf/space), + 6d (Clean re-layout + onboarding, Phase 3 / office-hours), DF2-full (amber per-dot + state — touches quality math), T13 a11y (settling banner/slider/gear done; rest with + the deferred visual work). + +### Deferred — why, and what unblocks them +- **#2 trace perf / #6c epoch-viewer perf** — the plan itself says *profile the SVG/ + canvas first, rewrite only if measured as the ceiling*. Needs the live Electron app + + a streaming device to profile; can't be done blind. Unify with epoch-reviewer + `drawEpochs` (Phase 4) if a rewrite happens. +- **#4 DF1 two-column live-signal column** — the markup fixes shipped; the full + two-column layout with a live signal-quality head requires wiring the signal + observable into `RunComponent` **and** visual verification. Do it in a live + `/design-review` pass (the plan flagged this item as design-review-shaped). +- **#6d Clean re-layout + reject-onboarding** — product-shaped (Epoch reviewer Phase 3); + route onboarding depth / guided-mode through `/office-hours` per the plan. +- **DF2 full amber per-dot settling** — the lightweight time-based banner shipped; the + per-dot amber state touches `pipes.ts` quality math and needs device verification. + +--- + +## Design decisions (`/plan-design-review`, 2026-07-15) + +App-type: **APP UI** (data-dense EEG workspace) — calm hierarchy, utility copy, minimal +chrome, cards only when the card *is* the interaction. Calibrated against `.llms/CLAUDE.md` +styling (teal `#007c70`, gold `#ffc107`, signal colors, shadcn, student-fun tone). + +**DF1 — Collect "Run Experiment" landing (item 4): two-column, function-filled.** +Left column: run summary (subject/group/session) with a small inline edit pencil + a +right-sized primary "Run Experiment" button. Right column: **live signal-quality head + +connection status**, so the operator confirms the device is ready before launching. +Kills the empty lower half with function, not a decorative floating card. Bonus: this is +the visual home for the no-EEG guard (#7) — readiness is *seen*, not just enforced. + +**DF2 — Explore settling onboarding (item 1): amber settling state + auto-dismiss banner.** +For the first ~45s after connect, the head-diagram dots show a neutral **amber "settling"** +state (not red), with a non-blocking `role="status"` banner: "Sensors settling — this is +normal. Sit still and watch them turn green." Auto-dismiss when settled. After the window, +real quality colors return and genuine bad-contact still surfaces red. No blocking modal / +forced tour (App UI rule). Settling ≠ bad: during the window the signal isn't valid yet, so +amber is *more* accurate than red, not a lie. + +### Interaction states table (Pass 2 — the biggest gap; maps to the silent-failure through-line) + +| Feature | Loading | Empty | Error | Success | Settling/partial | +|---|---|---|---|---|---| +| Explore signal | connecting spinner | — | device lost → toast + reconnect CTA | live quality colors | **amber settling + banner (DF2)** | +| Collect run | — | subject blank → button disabled + inline hint | no-EEG connected → confirm dialog (#7) | run launches | device connecting → status in right column (DF1) | +| Post-run (item 5) | "Recording…" | — | save failed → error toast + retry | **completion panel + "Clean your data →"** | — | +| Clean epochs | loading spinner | no epochs → warm "No epochs yet — collect data first" | units/parse error → toast | epochs render | zero-epoch clean → **warn before drop (6e/T2)** | +| Analyze picker | — | no datasets → warm "No cleaned data yet — clean a recording first · [Go to Clean]" (9b) | save-never-arrived → toast (T5) | datasets listed | — | + +Empty states are features: each carries warmth + a primary action, not "No items found." + +### Layout / copy specifications (smaller items) + +- **6a gear:** icon-button sized to match adjacent button height (same `h-*` as "Clean + Data"); keep `aria-label`. It's a settings toggle, not a primary action — secondary weight. +- **6b labels + emoji:** once real names land (`stimulus.condition`), legend/counts read + **😊 Faces / 🏠 Houses**. Keep the emoji — fun student tone, and it's always paired with a + text label (not emoji-as-bullet, so not AI-slop). The text label is also the a11y + equivalent for the emoji. +- **6d Clean re-layout hierarchy:** Epochs is the **primary** region — give it the larger + share (~60% width + more height); Live ERP is **secondary**. Group the epoch controls + (Prev/Next, amp ±, channel) into one toolbar. The reject-onboarding (Phase 3) is a + **dismissible first-run coach layer**, never a blocking modal. +- **6g slider:** labeled by sensitivity (low↔high), current µV shown as `aria-valuetext`, + not the primary label; native keyboard arrows suffice. + +### Accessibility specs (Pass 6 — Electron fixed-window, so a11y > responsive) + +- Settling banner: `role="status"` `aria-live="polite"` — announces settle progress without + stealing focus. Amber vs green must clear 3:1 non-text contrast; never color-alone (banner + text carries meaning). +- ERP legend uses **red vs green** — the worst colorblind pairing. Keep the text/emoji + labels beside the swatches (already present) so condition is never color-only. +- Slider + gear: keyboard-operable, visible focus ring, ≥44px (or match button height). +- Primary buttons: white-on-teal `#007c70` meets 4.5:1 — fine. Body text ≥16px. + +## Design Implementation Tasks + +- [ ] **T9 (P2)** — Collect — rebuild landing as two-column (summary+edit / live signal+status) per DF1 + - Surfaced by: DF1 (item 4). Files: `RunComponent.tsx`. Verify: `/qa` + operator sees signal before Run +- [ ] **T10 (P2)** — Explore — amber settling state + `role=status` auto-dismiss banner per DF2 + - Surfaced by: DF2 (item 1). Files: `EEGExplorationComponent.tsx`, `pipes.ts`/signal components, `ConnectModal`/`PreTestComponent`. Verify: amber first ~45s, real colors after, bad-contact still red +- [ ] **T11 (P2)** — Clean/Analyze/Collect — warm empty + error states per interaction table + - Surfaced by: Pass 2. Files: `CleanComponent`, `AnalyzeComponent`, `RunComponent`. Verify: each empty/error state shows warmth + primary action +- [ ] **T12 (P3)** — Clean — re-layout: Epochs primary (~60%+height), ERP secondary, grouped toolbar + - Surfaced by: 6d. Files: `CleanComponent/index.tsx`. Verify: `/design-review` after build +- [ ] **T13 (P3)** — a11y pass — settling banner/slider/gear/legend per a11y specs + - Surfaced by: Pass 6. Files: touched components. Verify: keyboard nav + contrast check + +--- + +## GSTACK REVIEW REPORT + +| Review | Trigger | Why | Runs | Status | Findings | +|--------|---------|-----|------|--------|----------| +| CEO Review | `/plan-ceo-review` | Scope & strategy | 0 | — | — | +| Codex Review | `/codex review` | Independent 2nd opinion | 0 | — | — | +| Eng Review | `/plan-eng-review` | Architecture & tests (required) | 1 | issues_open | 2 decisions locked, 3 critical failure-mode gaps | +| Design Review | `/plan-design-review` | UI/UX gaps | 1 | issues_open | 3/10 → 8/10, 2 forks locked, states table added | +| DX Review | `/plan-devex-review` | Developer experience gaps | 0 | — | — | + +- **UNRESOLVED:** 0 (Eng: D1 units, D2 sequencing. Design: DF1 Collect layout, DF2 settling). +- **CRITICAL GAPS:** 3 silent-failure paths (6e stim-scaling, 7 no-EEG run, 9b cleaned-save) — each gets a guard/toast + test in the correctness PR (T1–T3, T5). +- **CROSS-MODEL:** the design states-table and DF1 reinforce the eng review's #7 guard — readiness is now both enforced (dialog) and visible (signal column). +- **VERDICT:** ENG + DESIGN CLEARED — ready to implement. Data-integrity PR (T1–T4) first, then QA items in order (D2); design items (T9–T13) fold into the UX phase (T8). From 7f789e4cc79fbe4446fcd0f398d2e09e828affbf Mon Sep 17 00:00:00 2001 From: jdpigeon <morrisondano@gmail.com> Date: Thu, 16 Jul 2026 18:11:09 -0400 Subject: [PATCH 23/29] feat(webworker): enhance message processing and load cleaned epochs - Refactored the web worker's message handling to ensure asynchronous commands are processed in sequence, preventing overlap and ensuring Python globals are available before subsequent commands. - Introduced a new function `load_clean_epochs` in `utils.py` to load and concatenate cleaned .fif epoch files from MEMFS paths, improving data handling. - Updated the `loadCleanedEpochs` function in `index.ts` to utilize the new `load_clean_epochs` function for better clarity and functionality. - Adjusted message posting logic to prevent sending non-structured-cloneable MNE objects back to the main thread. QA plan updates included for the new functionality. --- src/renderer/utils/webworker/index.ts | 11 +++++++--- src/renderer/utils/webworker/utils.py | 18 ++++++++++++++-- src/renderer/utils/webworker/webworker.js | 26 +++++++++++++++++------ 3 files changed, 44 insertions(+), 11 deletions(-) diff --git a/src/renderer/utils/webworker/index.ts b/src/renderer/utils/webworker/index.ts index 5ab9a36c..8f67d85f 100644 --- a/src/renderer/utils/webworker/index.ts +++ b/src/renderer/utils/webworker/index.ts @@ -51,7 +51,7 @@ export const loadCleanedEpochs = ( worker.postMessage({ fsFiles, data: [ - `clean_epochs = concatenate_epochs([read_epochs(file) for file in ${JSON.stringify(memfsPaths)}])`, + `clean_epochs = load_clean_epochs(${JSON.stringify(memfsPaths)})`, `conditions = OrderedDict({key: [value] for (key, value) in clean_epochs.event_id.items()})`, ].join('\n'), }); @@ -162,7 +162,10 @@ export const applyRejection = ( badChannels: string[] ) => { worker.postMessage({ - data: `apply_rejection(${variableName}, ${JSON.stringify(dropIndices)}, ${JSON.stringify(badChannels)});`, + data: [ + `apply_rejection(${variableName}, ${JSON.stringify(dropIndices)}, ${JSON.stringify(badChannels)})`, + 'None', + ].join('\n'), }); }; @@ -248,7 +251,9 @@ export const plotERP = async (worker: Worker, channelIndex: number) => { export const saveEpochs = (worker: Worker, subject: string) => { const memfsPath = `/tmp/${subject}-cleaned-epo.fif`; worker.postMessage({ - data: `raw_epochs.save("${memfsPath}", overwrite=True)`, + // save() returns the Epochs/filename object — append None so only the MEMFS + // bytes (readFileAfter) cross postMessage, not the Python return value. + data: [`raw_epochs.save("${memfsPath}", overwrite=True)`, 'None'].join('\n'), dataKey: 'savedEpochs', readFileAfter: memfsPath, }); diff --git a/src/renderer/utils/webworker/utils.py b/src/renderer/utils/webworker/utils.py index d567c59c..ab69c3af 100644 --- a/src/renderer/utils/webworker/utils.py +++ b/src/renderer/utils/webworker/utils.py @@ -4,8 +4,8 @@ from matplotlib import pyplot as plt import pandas as pd # maybe we can remove this dependency -from mne import (concatenate_raws, create_info, viz, find_events, Epochs, - pick_types) +from mne import (concatenate_raws, concatenate_epochs, create_info, viz, + find_events, Epochs, pick_types, read_epochs) from mne.io import RawArray from io import StringIO @@ -144,6 +144,20 @@ def get_raw_epochs(raw, event_id, tmin, tmax, baseline=None, reject=None, verbose=False, picks=picks) +def load_clean_epochs(file_paths): + """Load cleaned .fif epoch files from MEMFS paths into one Epochs object. + + Analyze stages host .fif bytes at /tmp/... in the worker MEMFS before calling + this. A single file is returned as-is; multiple recordings are concatenated. + """ + epochs_list = [ + read_epochs(path, preload=True, verbose=False) for path in file_paths + ] + if len(epochs_list) == 1: + return epochs_list[0] + return concatenate_epochs(epochs_list, verbose=False) + + def plot_topo(epochs, conditions=OrderedDict(), palette=None): if palette is None: palette = _DEFAULT_CONDITION_PALETTE diff --git a/src/renderer/utils/webworker/webworker.js b/src/renderer/utils/webworker/webworker.js index db6f065b..b30349d9 100644 --- a/src/renderer/utils/webworker/webworker.js +++ b/src/renderer/utils/webworker/webworker.js @@ -90,7 +90,11 @@ const pyodideReadyPromise = (async () => { return pyodide; })(); -self.onmessage = async (event) => { +// Async onmessage handlers can overlap; chain work so Python globals like +// clean_epochs exist before follow-up commands (GetEpochsInfo, LoadTopo, etc.). +let workChain = Promise.resolve(); + +async function processMessage(event) { // Propagate init failures back to the main thread rather than hanging silently. let pyodide; try { @@ -130,14 +134,24 @@ self.onmessage = async (event) => { // can be written to host disk. Pyodide's MEMFS can't reach the host FS itself. if (readFileAfter) { const fileBytes = pyodide.FS.readFile(readFileAfter); // Uint8Array - self.postMessage( - { buffer: fileBytes.buffer, results, plotKey, dataKey }, - [fileBytes.buffer] - ); + const payload = { buffer: fileBytes.buffer, plotKey, dataKey }; + // epochArrays needs metadata alongside the buffer; savedEpochs only needs bytes. + if (dataKey === 'epochArrays') { + payload.results = results; + } + self.postMessage(payload, [fileBytes.buffer]); return; } - self.postMessage({ results, plotKey, dataKey }); + // Fire-and-forget commands (no plotKey/dataKey/readFileAfter) must not post + // MNE objects back — they aren't structured-cloneable even after toJs. + if (plotKey || dataKey) { + self.postMessage({ results, plotKey, dataKey }); + } } catch (error) { self.postMessage({ error: error.message, plotKey, dataKey }); } +} + +self.onmessage = (event) => { + workChain = workChain.then(() => processMessage(event)); }; From a97bd04c7200d0bc0b34e98f6e7cb73dacfe02bb Mon Sep 17 00:00:00 2001 From: Dano Morrison <danomorrison@meta.com> Date: Thu, 6 Aug 2026 15:57:30 -0700 Subject: [PATCH 24/29] Remove stale planning docs and dead code MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Sweep of debris left after the epoch-reviewer stack landed. Dead code (zero callers, verified post-merge): - plotTestPlot — debug scaffolding that rendered a four-point zigzag titled "Test Plot" into the real plotKey:'topo' UI slot. Its only caller was swapped for plotTopoMap in #220. - cleanEpochsPlot — rendered a matplotlib epochs figure in WASM then immediately plt.close()'d it; never routed to the UI. The Epoch Reviewer replaced its last caller. - parseSingleQuoteJSON — obsoleted by the worker's toJs() dict_converter. Docs: - Delete docs/lsl-implementation-plan.md. LSL shipped in #204/#221; every file in its "New files to create" inventory exists. Durable knowledge already lives in .llms/learnings.md; remaining per-platform checks are in TODOS.md. - device-connectivity.md said "currently: Muse only" — false since the EEGDriver registry (Muse, Neurosity, LSL). Corrected and pointed at the driver contract; the Muse-specific flow diagrams are retained as such. - epoch-review-ui-plan.md still called itself "Preliminary ... not yet an implementation spec" after Phases 0-2 merged. Status updated. Stale known-issues (fixed, but still instructing readers to ignore errors in two files — which would mask real regressions): - Drop the "Pre-existing TypeScript errors" entry from .llms/learnings.md and TODOS.md. experimentEpics.ts now carries explicit eslint-disable + typed workarounds, routes.tsx is clean v6, and CI's typecheck passes. - deviceActions.ts: drop a "(Phase 3)" reference to the deleted LSL plan. --- .llms/learnings.md | 5 - TODOS.md | 1 - docs/device-connectivity.md | 8 +- docs/epoch-review-ui-plan.md | 12 +- docs/lsl-implementation-plan.md | 477 ---------------------- src/renderer/actions/deviceActions.ts | 2 +- src/renderer/utils/webworker/functions.ts | 3 - src/renderer/utils/webworker/index.ts | 31 -- 8 files changed, 15 insertions(+), 524 deletions(-) delete mode 100644 docs/lsl-implementation-plan.md diff --git a/.llms/learnings.md b/.llms/learnings.md index 3c25c157..b1eca16b 100644 --- a/.llms/learnings.md +++ b/.llms/learnings.md @@ -183,8 +183,3 @@ Build note: with `module: ESNext` source but CommonJS main output, a guarded `re Experiments emit `this.data.correct_response` as a real boolean (`true`/`false`) and `response_given` as `'yes'`/`'no'` (see `src/renderer/utils/labjs/functions.ts`). But all consumers in `src/renderer/utils/behavior/compute.js` read data **after** it's been written to CSV and re-parsed, so every value is a **string**. That's why existing code gates on `row.correct_response === 'true'` and `row.response_given === 'yes'`, and parses numbers with `parseFloat`. Trap: a new metric written naively (`row.correct_response === true`, or arithmetic on an unparsed string) will silently return `false`/`0`/`NaN` for post-CSV data — and may *work* on pre-CSV in-memory data, so it passes a quick test and fails in production. Always compare against the string `'true'`/`'yes'` and `parseFloat` before doing math. - -## Pre-existing TypeScript errors (do not treat as regressions) - -- `src/renderer/epics/experimentEpics.ts` (lines 170, 205) — RxJS operator type mismatch -- `src/renderer/routes.tsx` (lines 15-17) — Redux container component prop types diff --git a/TODOS.md b/TODOS.md index 168dd44e..ba554d62 100644 --- a/TODOS.md +++ b/TODOS.md @@ -22,7 +22,6 @@ Deferred and in-flight work. Keep this current — when something ships, delete - [ ] **(Optional) Full Pyodide worker RPC** — the analysis/Clean pipeline crash is now **fixed** (harvested from PR #194): a `dataKey` routing pattern parallel to `plotKey` — the worker echoes `dataKey` + PyProxy-converted results, and `pyodideMessageEpic` routes `epochsInfo`→`SetEpochInfo` / `channelInfo`→`SetChannelInfo`; the info epics are fire-and-forget. This unblocks the pipeline without the bigger refactor. The deeper latent issue remains, though: `worker.postMessage` returns `undefined` on *post*, so the `await`s in `webworker/index.ts` are no-ops and cross-message sequencing still relies on worker FIFO. A true `runPython(worker, code, ctx?)` RPC — `Map<id,{resolve,reject}>` + one `message` listener, worker echoes `id` — would let epics `await` real results and delete the `plotKey`/`dataKey` switch entirely. Only worth doing if the FIFO sequencing ever actually bites; not urgent now. - [ ] Pyodide-fidelity smoke test — analysis pipeline is tested against native MNE, not yet under Pyodide/WASM (see `.llms/learnings.md`). **In progress:** the epoch-review Phase 0 (see `docs/epoch-review-ui-plan.md` §0) adds a *narrow* Pyodide test for the `get_epochs_arrays` float32 buffer path (byteLength, decode-vs-native, transfer detaches source); the full-pipeline Pyodide job remains deferred. -- [ ] Pre-existing TypeScript errors (not regressions): `experimentEpics.ts` (RxJS operator types), `routes.tsx` (Redux container prop types). - [ ] **Epoch reviewer Phase 2 polish (from PR #225 review).** Three non-blocking behaviors flagged during the Phase 2 forge: (1) `get_epochs_arrays`/`suggest_rejections` use `pick_types(eeg=True)` whose MNE default is `exclude='bads'`, so after a Clean that flags a bad channel the re-fetched reviewer omits that channel from the display (saved `.fif` is unaffected) — decide whether to keep bad channels visible-but-greyed (`exclude=[]`) instead of vanishing; (2) re-running "Auto-flag" with the same threshold re-adds suggestions the user had manually unclicked (additive union merge in `CleanComponent.componentDidUpdate`); (3) the auto-flag threshold `<input>` has no min guard (0 µV flags everything). All in `src/renderer/components/CleanComponent/` + `webworker/utils.py`. ## Done recently diff --git a/docs/device-connectivity.md b/docs/device-connectivity.md index c072b2b8..60ba16fa 100644 --- a/docs/device-connectivity.md +++ b/docs/device-connectivity.md @@ -1,6 +1,10 @@ # Device Connectivity -How BrainWaves discovers and connects to EEG devices (currently: Muse only). +How BrainWaves discovers and connects to EEG devices. Supported devices are Muse +and Neurosity (first-party drivers) plus external LSL inlet streams; the flow +diagrams below trace the **Muse** path specifically, since it is the most involved +(Web Bluetooth device selection). The driver interface itself is device-agnostic — +see the `redux-observable-epochs` skill for the `EEGDriver` contract. --- @@ -12,7 +16,7 @@ Device connectivity spans three layers: |---|---|---| | **UI** | `CollectComponent/`, `EEGExplorationComponent` | Trigger search, display state, handle user selection | | **Epics** | `epics/deviceEpics.ts` | Orchestrate async device lifecycle via RxJS | -| **Driver** | `utils/eeg/muse.ts` | Web Bluetooth API calls via `muse-js` | +| **Driver** | `utils/eeg/index.ts` (registry) → `muse.ts`, `neurosity.ts`, `lslInlet.ts` | Per-device acquisition behind the shared `EEGDriver` interface (`utils/eeg/types.ts`) | All device state lives in Redux (`reducers/deviceReducer.ts`). Epics react to dispatched actions and fire new actions as side effects. diff --git a/docs/epoch-review-ui-plan.md b/docs/epoch-review-ui-plan.md index f1bd788f..0b7b6dd4 100644 --- a/docs/epoch-review-ui-plan.md +++ b/docs/epoch-review-ui-plan.md @@ -1,10 +1,14 @@ -# Interactive Epoch Review UI — Preliminary Plan +# Interactive Epoch Review UI — Plan -**Status:** Preliminary / discussion. Written to seed a planning loop — vision and -context first, deep technical decisions deliberately left open (flagged as Open -Questions). Not yet an implementation spec. +**Status:** Phases 0–2 are **implemented and merged** (PRs #223, #224, #225) — +Canvas epoch renderer, interactive rejection, live ERP, apply/save, bad-channel +flagging, auto-flag suggestions, condition labels. Phases 3 (onboarding layer) and +4 (polish/generalize) remain roadmap; see `TODOS.md` for the Phase 2 polish +follow-ups from the #225 review. Sections 1–5 below are retained as the design +rationale behind what shipped. **Owner:** Dano · **Drafted:** 2026-07-06 · **Locked:** 2026-07-07 (plan-eng-review) +· **Phases 0–2 landed:** 2026-08-06 --- diff --git a/docs/lsl-implementation-plan.md b/docs/lsl-implementation-plan.md deleted file mode 100644 index 186961fa..00000000 --- a/docs/lsl-implementation-plan.md +++ /dev/null @@ -1,477 +0,0 @@ -# LSL Integration Plan — BrainWaves - -## Executive Summary - -This document describes the architecture for adding Lab Streaming Layer (LSL) support to BrainWaves. The design supports connectivity to multiple device types (Muse, Neurosity, and arbitrary third-party LSL devices), real-time EEG visualization, and stimulus marker emission from lab.js experiments — all through a unified data pipeline. - -This plan is grounded in the actual codebase (`device-lsl` branch). It supersedes the original research-agent draft, which was written without source access. - ---- - -## Current State (device-lsl branch) - -The Muse Web Bluetooth connectivity issues documented in `docs/device-connectivity.md` are **already fixed** on this branch: -- `select-bluetooth-device` handler registered in `src/main/index.ts:459` (auto-selects first Muse) -- `cachedDevice` pattern in `src/renderer/utils/eeg/muse.ts:36` (avoids redundant `requestDevice` call) -- `bluetooth:cancelSearch` IPC implemented in both preload and main - -What does NOT yet exist: any LSL plumbing. Everything below is net-new work. - ---- - -## Architecture Overview - -``` -┌──────────────────────────── Renderer ──────────────────────────────┐ -│ │ -│ muse.ts / future neurosity.ts Redux + RxJS Epics │ -│ ┌───────────────────────────┐ ┌──────────────────────┐ │ -│ │ getMuse / connectToMuse │──raw──►│ deviceEpics.ts │ │ -│ │ createRawMuseObservable() │ │ → rawObservable │ │ -│ └───────────────────────────┘ │ → signalQuality │ │ -│ │ → epochBatcher epic │──┐ │ -│ └──────────────────────┘ │ │ -│ │ │ -│ RunComponent.tsx │ │ -│ ┌───────────────────────────┐ │ │ -│ │ injectMuseMarker() (existing, keep) │ │ -│ │ window.electronAPI │ │ │ -│ │ .sendLSLMarker() (new) │────────────────────────────────┐ │ │ -│ └───────────────────────────┘ ipc: lsl:sendMarker │ │ │ -│ │ │ │ -│ ipc: lsl:sendEpoch ◄──┘ │ │ -│ │ │ -│ ConnectModal / future LSL stream browser │ │ -│ ipc: lsl:discoverStreams (invoke) │ │ -│ ipc: lsl:subscribeStream │ │ -│ ipc: lsl:unsubscribeStream │ │ -│ ipc: lsl:inletData│ -│ (main→renderer)│ -└──────────────────────────────────────────────────────────────────┴─┘ - │ -┌──────────────────────────── Main Process ──────────────────────── ▼─┐ -│ │ -│ src/main/index.ts │ -│ imports LSLOutletManager, LSLInletManager │ -│ │ -│ src/main/lsl/outlets.ts src/main/lsl/inlets.ts │ -│ ┌───────────────────────┐ ┌───────────────────────┐ │ -│ │ LSLOutletManager │ │ LSLInletManager │ │ -│ │ per-device EEG outlet│ │ resolveStreams() │ │ -│ │ marker outlet │ │ create/poll inlets │ │ -│ │ (irregular, string) │ │ forward via IPC │ │ -│ └───────────────────────┘ └───────────────────────┘ │ -│ │ -│ ◄──── LSL network (UDP multicast) ────► │ -└──────────────────────────────────────────────────────────────────────┘ -``` - ---- - -## Key Decisions and Rationale - -### 1. BLE acquisition stays in the renderer via Web Bluetooth - -Keep muse-js and @neurosity/sdk in the renderer. Do not migrate to noble/bleat in main. - -- Web Bluetooth is actively maintained by Chromium; noble is effectively abandoned -- Electron ships Chromium, so Web Bluetooth works on macOS, Windows, and Linux with no native build deps -- The Neurosity SDK targets Web Bluetooth for its BLE transport; noble is not supported -- IPC overhead with epoch batching (~8–16 messages/sec) is negligible -- Noble requires platform-specific system libraries and `electron-rebuild` for each target - -### 2. LSL runs exclusively in the main process - -All LSL outlet/inlet operations happen in `src/main/lsl/` using `node-labstreaminglayer`. - -- LSL bindings use native liblsl via Node FFI; sandboxed renderers cannot load native modules -- Centralized LSL in main creates a single lifecycle management point -- `node-labstreaminglayer` (EdgeBCI) is the most complete Node binding — supports outlets AND inlets - -### 3. Neurosity's built-in device-side LSL is not used - -We manage our own outlets for all devices. - -- The Crown's embedded LSL is marked experimental with timing variability in their own docs -- Running both device-side and app-side LSL causes duplicate streams in LabRecorder -- Our outlet manager ensures consistent stream metadata and naming across all device types - -### 4. Existing muse.ts and epics are modified, not replaced - -No new "MuseAdapter class". Instead: -- `src/renderer/utils/eeg/muse.ts` gains an epoch-batching utility function -- A new epic in `deviceEpics.ts` subscribes to `rawObservable` and pipes batched epochs over IPC -- The existing connect/search/signal-quality flow is unchanged - ---- - -## IPC Channels - -All channels are registered in `src/preload/index.ts` via `contextBridge.exposeInMainWorld('electronAPI', {...})` and handled in `src/main/index.ts` via `ipcMain.handle` / `ipcMain.on`. - -| Channel | Direction | Payload | Rate | -|---|---|---|---| -| `lsl:sendEpoch` | renderer → main | `LSLEpoch` | ~8–16 msg/sec per device | -| `lsl:sendMarker` | renderer → main | `LSLMarker` | Event-driven | -| `lsl:inletData` | main → renderer | `LSLInletEpoch` | ~16–60 msg/sec per stream | -| `lsl:discoverStreams` | renderer → main (invoke) | — | On demand | -| `lsl:subscribeStream` | renderer → main | `{ uid: string }` | Per subscription | -| `lsl:unsubscribeStream` | renderer → main | `{ uid: string }` | Per teardown | -| `lsl:inletDisconnected` | main → renderer | `{ uid: string }` | On loss | -| `lsl:outletStatus` | main → renderer | `{ deviceId, status }` | On outlet change | - ---- - -## Shared Types - -Create **`src/shared/lslTypes.ts`** (new file). These types are imported by both `src/main/lsl/` and `src/renderer/`. - -To enable this, add a `@shared` alias to **both** the `main` and `renderer` Vite config blocks in `vite.config.ts`: - -```ts -// vite.config.ts — add to main.resolve.alias AND renderer.resolve.alias -'@shared': path.resolve(__dirname, 'src/shared'), -``` - -```typescript -// src/shared/lslTypes.ts - -export interface LSLEpoch { - deviceId: string; - deviceType: 'muse' | 'neurosity'; - samples: number[][]; // [sampleIndex][channelIndex], µV - timestamps: number[]; // one per sample (ms, performance.now()) - channelNames: string[]; - sampleRate: number; -} - -export interface LSLMarker { - label: string; // e.g. 'stimulus_onset', '1', '2' - rendererTimestamp: number; // performance.now() at event time -} - -export interface DiscoveredStream { - uid: string; - name: string; - type: string; // 'EEG', 'Markers', etc. - channelCount: number; - sampleRate: number; - sourceId: string; -} - -export interface LSLInletEpoch { - uid: string; - samples: number[][]; - timestamps: number[]; -} -``` - ---- - -## Constants and Enums - -Update **`src/renderer/constants/constants.ts`**: - -```ts -export enum DEVICES { - NONE = 'NONE', - MUSE = 'MUSE', - NEUROSITY = 'NEUROSITY', // add in Phase 2 - LSL = 'LSL', // add in Phase 3 (external inlet) - GANGLION = 'GANGLION', -} -``` - ---- - -## Component Specifications - -### Epoch Batcher (Renderer — `src/renderer/utils/eeg/lslBridge.ts`, new file) - -A thin helper module that: -- Exports `batchSamplesToEpoch(rawObservable, deviceId, deviceType, channelNames, sampleRate)` — returns a new Observable that buffers N samples (`bufferCount(32)`) into `LSLEpoch` objects -- Exports `sendEpoch(epoch: LSLEpoch)` — calls `window.electronAPI.sendLSLEpoch(epoch)` -- Exports `sendMarker(marker: LSLMarker)` — calls `window.electronAPI.sendLSLMarker(marker)` - -The buffer size of 32 gives ~125ms latency at 256 Hz and ~8 IPC messages/sec — negligible overhead. - -### New epic in `deviceEpics.ts` - -Add `lslForwardEpic` that: -1. Filters on `DeviceActions.SetRawObservable` -2. Gets device metadata from `state$.value.device.connectedDevice` -3. Pipes `rawObservable` through `batchSamplesToEpoch(...)` -4. Uses `tap(sendEpoch)` to forward each epoch over IPC -5. Completes on `DeviceActions.Cleanup` - -This runs alongside — not instead of — the existing `setRawObservableEpic` and `setSignalQualityObservableEpic`. - -### Marker Bridge (Renderer — `src/renderer/components/CollectComponent/RunComponent.tsx`) - -`RunComponent.tsx` already calls `injectMuseMarker(event, time)` inside a callback. In Phase 4: -- **Keep** the `injectMuseMarker` call (keeps marker-in-raw-EEG behavior for CSV recording) -- **Add** `sendMarker({ label: event, rendererTimestamp: performance.now() })` alongside it -- This makes the marker system device-agnostic — no change required to muse.ts - -### LSL Outlet Manager (Main — `src/main/lsl/outlets.ts`, new file) - -```ts -import { StreamInfo, StreamOutlet, cf_int32 } from 'node-labstreaminglayer'; - -class LSLOutletManager { - private outlets = new Map<string, StreamOutlet>(); - private markerOutlet: StreamOutlet | null = null; - - createDeviceOutlet(deviceId: string, channelNames: string[], sampleRate: number) { ... } - pushEpoch(deviceId: string, epoch: LSLEpoch) { ... } // calls outlet.pushChunk() - destroyDeviceOutlet(deviceId: string) { ... } - - createMarkerOutlet() { ... } // name='ExperimentMarkers', type='Markers', channels=1, IRREGULAR_RATE, string format - pushMarker(label: string) { ... } // calls markerOutlet.pushSample([label]) - - destroyAll() { ... } -} - -export const lslOutlets = new LSLOutletManager(); -``` - -Imported by `src/main/index.ts`. IPC handlers call `lslOutlets.createDeviceOutlet(...)` on `lsl:outletCreate` and `lslOutlets.pushEpoch(...)` on `lsl:sendEpoch`. - -### LSL Inlet Manager (Main — `src/main/lsl/inlets.ts`, new file) - -```ts -class LSLInletManager { - private inlets = new Map<string, { inlet: StreamInlet; timer: NodeJS.Timeout }>(); - - async discoverStreams(): Promise<DiscoveredStream[]> { ... } // resolveStreams(1.0) - subscribeStream(uid: string, onData: (epoch: LSLInletEpoch) => void) { ... } - unsubscribeStream(uid: string) { ... } - destroyAll() { ... } -} -``` - -The poll loop calls `inlet.pullChunk(timeout=0.0)` at ~60 Hz per subscription and invokes `onData`. `onData` sends `lsl:inletData` via `mainWindow.webContents.send(...)`. - ---- - -## Build Configuration Changes - -### `vite.config.ts` - -1. Add `@shared` alias to both `main.resolve.alias` and `renderer.resolve.alias` -2. Native modules in main are automatically externalized by electron-vite — no special config needed for `node-labstreaminglayer` - -### `package.json` (electron-builder section) - -Add `asarUnpack` for native `.node` files — they cannot be loaded from inside an ASAR archive: - -```json -"build": { - "asarUnpack": ["**/*.node"], - ... -} -``` - -`node-labstreaminglayer` ships prebuilt liblsl binaries in its `material/liblsl-release/` directory. These get included via the existing `"node_modules/**/*"` entry in `files`. Test packaging early (Phase 1) to confirm binary resolution works. - -### `postinstall` / `electron-rebuild` - -`electron-builder install-app-deps` (already in `postinstall`) handles rebuilding native modules for Electron's Node ABI. No changes needed to the script. - ---- - -## Pre-existing Bug to Fix in Phase 1 - -**`src/renderer/epics/experimentEpics.ts:79`** hardcodes `MUSE_CHANNELS`: - -```ts -writeHeader(streamId, MUSE_CHANNELS); // BUG: wrong for Neurosity or LSL inlets -``` - -Change to: - -```ts -writeHeader(streamId, state$.value.device.connectedDevice?.channels ?? MUSE_CHANNELS); -``` - ---- - -## Implementation Phases - -### Phase 1: Muse → LSL Outlet - -**Goal:** Muse EEG data flows through the full pipeline and appears as a stream in LabRecorder. - -**Prerequisite:** Muse Web Bluetooth fixes are already merged on `device-lsl`. ✓ - -**Steps:** - -1. **Install `node-labstreaminglayer`** - ```bash - npm install node-labstreaminglayer - npm run postinstall # runs electron-builder install-app-deps to rebuild native module - ``` - Verify the package loads in the main process: add a quick `require('node-labstreaminglayer')` test in `src/main/index.ts` and run `npm run dev`. - -2. **Add `@shared` alias to `vite.config.ts`** (both `main` and `renderer` blocks). - -3. **Create `src/shared/lslTypes.ts`** with `LSLEpoch`, `LSLMarker`, `DiscoveredStream`, `LSLInletEpoch`. - -4. **Create `src/main/lsl/outlets.ts`** with `LSLOutletManager`. Wire the `lsl:sendEpoch` IPC handler in `src/main/index.ts`. - -5. **Create `src/renderer/utils/eeg/lslBridge.ts`** with `batchSamplesToEpoch` and `sendEpoch`. - -6. **Add `lslForwardEpic` to `src/renderer/epics/deviceEpics.ts`**. Register it in `combineEpics` in `src/renderer/epics/index.ts`. - -7. **Add LSL IPC methods to `src/preload/index.ts`**: - ```ts - sendLSLEpoch: (epoch: LSLEpoch) => ipcRenderer.send('lsl:sendEpoch', epoch), - sendLSLMarker: (marker: LSLMarker) => ipcRenderer.send('lsl:sendMarker', marker), - discoverLSLStreams: () => ipcRenderer.invoke('lsl:discoverStreams'), - ``` - Also add TypeScript declarations for the new methods (the existing `window.electronAPI` object is not yet typed — add a `src/renderer/types/electron.d.ts` declaration file). - -8. **Fix the `MUSE_CHANNELS` hardcoding** in `experimentEpics.ts`. - -9. **Add `asarUnpack: ["**/*.node"]`** to `package.json` build config. - -10. **Test:** connect a Muse, run LabRecorder on the same machine, confirm the EEG stream appears with correct channel count and sample rate. - ---- - -### Phase 2: Neurosity SDK - -**Goal:** Neurosity Crown connects and streams to its own LSL outlet alongside Muse. - -**Steps:** - -1. **Install `@neurosity/sdk`** - ```bash - npm install @neurosity/sdk - ``` - Note: Neurosity SDK uses Web Bluetooth — no native build step needed. - -2. **Add `NEUROSITY = 'NEUROSITY'` to `DEVICES` enum** in `constants.ts`. - -3. **Create `src/renderer/utils/eeg/neurosity.ts`** mirroring the interface of `muse.ts`: - - `getNeurosity()` — initiates Web Bluetooth scan for Crown - - `connectToNeurosity(device)` → returns `DeviceInfo { name, samplingRate: 256, channels: [...] }` - - `createRawNeurosityObservable()` — wraps `neurosity.brainwaves('raw')`, maps Crown epoch format to the same `EEGData` shape as `createRawMuseObservable()` - - `disconnectFromNeurosity()` - -4. **Update `deviceEpics.ts`** to route based on `deviceType` (Muse vs Neurosity) when calling connect/disconnect/raw observable functions. The existing epic shape stays the same — just add conditionals. - -5. **`lslForwardEpic` already handles Neurosity** because it reads `deviceType` from Redux state and passes it through to `LSLEpoch`. The outlet manager creates a separate outlet per `deviceId`. - -6. **Test:** simultaneous Muse + Neurosity streams visible in LabRecorder. - ---- - -### Phase 3: LSL Inlet Manager + External Device Visualization - -**Goal:** Users can discover and visualize any LSL stream on the local network (OpenBCI, g.tec, BrainFlow, pylsl test scripts), even without a BLE device. - -**Steps:** - -1. **Create `src/main/lsl/inlets.ts`** with `LSLInletManager` (discover, subscribe, poll, forward). - -2. **Wire inlet IPC handlers** in `src/main/index.ts`: - - `ipcMain.handle('lsl:discoverStreams', ...)` → returns `DiscoveredStream[]` - - `ipcMain.on('lsl:subscribeStream', ...)` → starts poll loop, sends `lsl:inletData` - - `ipcMain.on('lsl:unsubscribeStream', ...)` → stops poll loop - -3. **Add inlet IPC to preload** (`subscribeLSLStream`, `unsubscribeLSLStream`, `onLSLInletData`). - -4. **Build a stream discovery UI** — add a new tab or section in `ConnectModal.tsx` for "External LSL Device". It calls `discoverLSLStreams()`, shows results, and lets the user subscribe. - -5. **Add `LSL = 'LSL'` to `DEVICES` enum** and add a new Redux action `SetLSLInletStream` that stores the `DiscoveredStream` info in `deviceReducer` as the `connectedDevice`. - -6. **Wire inlet data to `rawObservable`** — when an inlet is subscribed, create an RxJS Subject in the renderer that emits `EEGData` for each `lsl:inletData` message, then dispatch `SetRawObservable` with it. Signal quality viz will work automatically. - -7. **Test with BrainFlow or `pylsl`** test sender script. - ---- - -### Phase 4: Stimulus Markers via LSL - -**Goal:** lab.js experiment events appear as a dedicated Markers stream in LabRecorder, aligned with the EEG stream. - -**Steps:** - -1. **Create the marker outlet** in `LSLOutletManager`: - - `StreamInfo`: name `'BrainWavesMarkers'`, type `'Markers'`, 1 channel, `IRREGULAR_RATE`, format `string` - - Create on app startup (not per-device) - -2. **Wire `lsl:sendMarker` IPC handler** in `src/main/index.ts` → calls `lslOutlets.pushMarker(label)`. - -3. **Update `RunComponent.tsx`** to call `window.electronAPI.sendLSLMarker({ label: event, rendererTimestamp: performance.now() })` alongside the existing `injectMuseMarker(event, time)` call. - - Keep `injectMuseMarker` — it embeds markers in the raw EEG CSV, which the existing Pyodide analysis pipeline depends on. - -4. **Implement clock sync** (optional, needed if sub-5ms precision required): - - Periodically send a round-trip IPC ping: renderer records `t0 = performance.now()`, main records `lsl_local_clock()`, renderer records `t1`. Offset ≈ `lsl_local_clock() - (t0 + t1) / 2`. - - Store offset in a ref; pass it in `LSLMarker` so main can correct the LSL timestamp. - - For most ERP paradigms, raw IPC jitter (1–5ms) is acceptable and this step can be deferred. - -5. **Test:** run Stroop or N170 experiment with LabRecorder, load XDF in MNE Python, verify marker latencies align with EEG epochs. - ---- - -### Phase 5: Production Hardening - -- **Backpressure for high-density inlets**: for 64+ channel streams at 1kHz+, decimate in main before forwarding to renderer. Full-rate stays on LSL network for LabRecorder. -- **Graceful error handling**: BLE disconnects, LSL network loss, inlet timeouts, `node-labstreaminglayer` FFI errors. -- **Platform testing**: macOS arm64, macOS x64, Windows x64. Confirm liblsl binary path resolves correctly post-packaging. -- **Electron packaging verification**: `npm run package`, install the `.dmg`/`.exe`, run with LabRecorder. -- **Linux Web Bluetooth**: `--enable-experimental-web-platform-features` is already set in `src/main/index.ts:23`. Verify BLE works end-to-end on Ubuntu. - ---- - -## Risks and Mitigations - -| Risk | Severity | Mitigation | -|---|---|---| -| `node-labstreaminglayer` is low-traffic (~7 downloads/week) with possible undiscovered Electron-specific bugs | Medium | Pin version. Test Phase 1 against real hardware before building further. If FFI proves unstable, fallback: Python sidecar process using `pylsl` with a WebSocket bridge. | -| liblsl binary path breaks after electron-builder packaging (ASAR) | High | Add `asarUnpack: ["**/*.node"]` in Phase 1. Test packaged build early — don't leave this for Phase 5. | -| IPC marker jitter exceeds tolerance for ERP analysis | Low | Document typical jitter (1–5ms). Add clock sync in Phase 4 if needed. | -| `@neurosity/sdk` Web Bluetooth API changes or breaks | Medium | SDK is MIT; fork if needed. Crown BLE protocol is documented. | -| High-channel-count LSL inlets (64ch, 1kHz) overwhelm renderer | Medium | Decimate in main process in Phase 5. | -| iOS / mobile pivot requires native BLE | Low (deferred) | Adapter pattern in `muse.ts` / `neurosity.ts` isolates BLE. Add native adapter without touching LSL/viz/marker code. | - ---- - -## File Inventory - -### New files to create - -| File | Purpose | -|---|---| -| `src/shared/lslTypes.ts` | Shared IPC payload types | -| `src/main/lsl/outlets.ts` | `LSLOutletManager` class | -| `src/main/lsl/inlets.ts` | `LSLInletManager` class (Phase 3) | -| `src/renderer/utils/eeg/lslBridge.ts` | Epoch batcher + IPC send helpers | -| `src/renderer/utils/eeg/neurosity.ts` | Neurosity device driver (Phase 2) | -| `src/renderer/types/electron.d.ts` | TypeScript declarations for `window.electronAPI` | - -### Files to modify - -| File | Change | -|---|---| -| `vite.config.ts` | Add `@shared` alias to `main` and `renderer` blocks | -| `package.json` | Add `asarUnpack: ["**/*.node"]` to build config | -| `src/preload/index.ts` | Add LSL IPC methods (`sendLSLEpoch`, `sendLSLMarker`, `discoverLSLStreams`, etc.) | -| `src/main/index.ts` | Import and initialize `LSLOutletManager`; register IPC handlers | -| `src/renderer/constants/constants.ts` | Add `NEUROSITY` and `LSL` to `DEVICES` enum | -| `src/renderer/epics/deviceEpics.ts` | Add `lslForwardEpic`; route Neurosity in Phase 2 | -| `src/renderer/epics/index.ts` | Register `lslForwardEpic` in `combineEpics` | -| `src/renderer/epics/experimentEpics.ts` | Fix `MUSE_CHANNELS` hardcoding (line 79) | -| `src/renderer/components/CollectComponent/RunComponent.tsx` | Add `sendLSLMarker` call alongside `injectMuseMarker` (Phase 4) | - -Test plan - - - x Connect a real Muse; confirm stream appears in LabRecorder with correct channel names and sample rate (still could interrogatee file for correctness. path is '/Users/dano/Documents/CurrentStudy/sub-P001/ses-S001/eeg/sub-P001_ses-S001_task-Default_run-001_eeg.xdf') - - Connect a Neurosity Crown; confirm stream appears in LabRecorder - - Run lab.js experiment; confirm markers are recorded synchronized with EEG - - Discover and subscribe to an external LSL stream; confirm live visualization - - Disconnect Muse mid-session; confirm toast + clean teardown - - npm run package on macOS arm64, macOS x64, Windows x64; confirm liblsl loads from the packaged build - - Ubuntu smoke test for Web Bluetooth (--enable-experimental-web-platform-features is already set) \ No newline at end of file diff --git a/src/renderer/actions/deviceActions.ts b/src/renderer/actions/deviceActions.ts index 69edf14c..7c2c451a 100644 --- a/src/renderer/actions/deviceActions.ts +++ b/src/renderer/actions/deviceActions.ts @@ -41,7 +41,7 @@ export const DeviceActions = { Cleanup: createAction<void, 'CLEANUP'>('CLEANUP'), DeviceLost: createAction<void, 'DEVICE_LOST'>('DEVICE_LOST'), - // External LSL inlet streams (Phase 3) + // External LSL inlet streams DiscoverLSLStreams: createAction<void, 'DISCOVER_LSL_STREAMS'>( 'DISCOVER_LSL_STREAMS' ), diff --git a/src/renderer/utils/webworker/functions.ts b/src/renderer/utils/webworker/functions.ts index eb149770..e078cfd3 100644 --- a/src/renderer/utils/webworker/functions.ts +++ b/src/renderer/utils/webworker/functions.ts @@ -1,8 +1,5 @@ // ------------------------------------------- // Helper & utility functions -export const parseSingleQuoteJSON = (string: string) => - JSON.parse(string.replace(/'/g, '"')); - export const formatFilePath = (filePath: string) => `"${filePath.replace(/\\/g, '/')}"`; diff --git a/src/renderer/utils/webworker/index.ts b/src/renderer/utils/webworker/index.ts index 8f67d85f..c6d153db 100644 --- a/src/renderer/utils/webworker/index.ts +++ b/src/renderer/utils/webworker/index.ts @@ -172,19 +172,6 @@ export const applyRejection = ( // ----------------------------- // Plot functions -export const cleanEpochsPlot = async (worker: Worker) => { - await worker.postMessage({ - // MNE 1.x validates `events` as bool|ndarray — events=None raises TypeError; - // False is the "no events overlaid" default. Also close the returned Figure - // so the worker doesn't try to structuredClone a PyProxy back (this plot is - // not routed to the UI; wiring it to a plotKey would be a separate feature). - data: [ - `_fig = raw_epochs.plot(scalings='auto', n_epochs=6, title="Clean Data", events=False)`, - `plt.close(_fig)`, - ].join('\n'), - }); -}; - export const plotPSD = async (worker: Worker) => { worker.postMessage({ plotKey: 'psd', @@ -213,24 +200,6 @@ export const plotTopoMap = async (worker: Worker) => { }); }; -export const plotTestPlot = async (worker: Worker | null) => { - if (!worker) return; - worker.postMessage({ - plotKey: 'topo', - data: [ - 'import io', - 'import matplotlib.pyplot as plt', - '_fig, _ax = plt.subplots()', - '_ax.plot([1, 2, 3, 4], [1, 4, 2, 3])', - '_ax.set_title("Test Plot")', - '_buf = io.BytesIO()', - '_fig.savefig(_buf, format="svg", bbox_inches="tight")', - 'plt.close(_fig)', - '_buf.getvalue().decode()', - ].join('\n'), - }); -}; - export const plotERP = async (worker: Worker, channelIndex: number) => { worker.postMessage({ plotKey: 'erp', From f3f432c4fd2bd583852984916ed94c5a6cf3603e Mon Sep 17 00:00:00 2001 From: Dano Morrison <danomorrison@meta.com> Date: Thu, 6 Aug 2026 16:15:19 -0700 Subject: [PATCH 25/29] Gate the release pipeline and add build-artifact tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Release workflow has never run — no tag has ever been pushed — and as written a tag published whatever compiled: no test, lint, or typecheck ran on the release path, and both workflows used 'npm install', so the tree CI passed on was not necessarily the tree that shipped. Release workflow: - New 'verify' job (test + typecheck + lint) that the publish matrix depends on. Nothing builds or uploads until it is green. - Split 'package-ci' into build -> verify artifacts -> publish, so a build with a missing Pyodide payload cannot be published. - npm ci, and fail-fast so a broken tag uploads as few half-good artifacts as possible across the three OS legs. test.yml: npm ci, plus the artifact check after Build. package.json: publish.releaseType 'draft'. electron-updater is wired to this repo, so a tag would otherwise push an update to installed clients the moment it built. Draft means artifacts are uploaded but a human publishes. New tests: - tests/pyodideAssets.test.ts — static guard on the contract between extraResources ('to': pyodide) and the main process protocol handler (path.join(process.resourcesPath, 'pyodide')), plus the dev-side path and the directory InstallMNE writes into. These live in three files with no shared constant; a mismatch is invisible in dev and broke prod completely once (see .llms/learnings.md). Needs no build, so it runs in the default suite. - tests/build.test.ts — post-build presence checks: the three bundles, the Pyodide runtime files, and every wheel named in the package manifest. Runs via 'npm run test:build' under its own config and is excluded from the default run, so a missing build is a loud ordering error, never a silent skip. TODOS: record the two things this still does not cover — an actual packaged-app launch smoke test, and the first release dry-run. --- .github/workflows/release.yml | 48 +++++++++++++++-- .github/workflows/test.yml | 9 +++- TODOS.md | 2 + package.json | 6 ++- tests/build.test.ts | 97 ++++++++++++++++++++++++++++++++++ tests/pyodideAssets.test.ts | 98 +++++++++++++++++++++++++++++++++++ vitest.build.config.ts | 13 +++++ vitest.config.ts | 4 +- 8 files changed, 270 insertions(+), 7 deletions(-) create mode 100644 tests/build.test.ts create mode 100644 tests/pyodideAssets.test.ts create mode 100644 vitest.build.config.ts diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 674e90f1..daa90e2f 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -6,10 +6,43 @@ on: - 'v*' jobs: + # Gate. A tag must not publish anything until the same checks CI runs on every + # push have passed on the tagged commit. Runs once rather than per-OS — test.yml + # already covers the cross-OS matrix — but nothing is built or uploaded until + # this job is green. + verify: + runs-on: ubuntu-latest + + steps: + - name: Check out Git repository + uses: actions/checkout@v4 + + - name: Install Node.js + uses: actions/setup-node@v4 + with: + node-version: 22 + cache: 'npm' + + - name: Install dependencies + run: npm ci + + - name: Test + run: npm test + + - name: Typecheck + run: npm run typecheck + + - name: Lint + run: npm run lint + release: + needs: verify runs-on: ${{ matrix.os }} strategy: + # Stop the other platforms as soon as one fails, so a broken tag uploads + # as few half-good artifacts as possible. + fail-fast: true matrix: os: [macos-latest, windows-latest, ubuntu-latest] @@ -24,11 +57,20 @@ jobs: cache: 'npm' - name: Install dependencies - run: npm install + run: npm ci + + - name: Build + run: npm run build + + # Between build and publish: refuse to ship a build whose Pyodide payload + # is missing. That failure is invisible in dev and breaks analysis entirely + # in the packaged app. + - name: Verify build artifacts + run: npm run test:build - - name: Build and publish + - name: Publish env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} USERNAME: ${{ secrets.USERNAME }} PASSWORD: ${{ secrets.PASSWORD }} - run: npm run package-ci + run: npx electron-builder build --publish always diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 994d35eb..c5d5745a 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -24,8 +24,10 @@ jobs: node-version: 22 cache: 'npm' + # npm ci, not npm install: installs exactly the locked tree, so the commit + # CI passes on is the commit that gets packaged. - name: Install dependencies - run: npm install + run: npm ci - name: Test run: npm test @@ -38,3 +40,8 @@ jobs: - name: Build run: npm run build + + # Must follow Build — asserts out/ and the Pyodide payload are actually + # on disk where the packaged app's protocol handler will look for them. + - name: Verify build artifacts + run: npm run test:build diff --git a/TODOS.md b/TODOS.md index ba554d62..1864a9eb 100644 --- a/TODOS.md +++ b/TODOS.md @@ -6,6 +6,8 @@ Deferred and in-flight work. Keep this current — when something ships, delete - [ ] **Lesson content from Steve Azeka** — owner: Dano. Set a date for the first sample lesson. Fallback: if no lesson by that date, Dano authors one throwaway stub to unblock the lesson surface. - [ ] **In-app lesson surface v1** — static markdown render only (no editing, no interactivity). Build against a dummy lesson; tune to real content once a sample lands. Confirm markdown is a rich-enough format with Azeka before building. +- [ ] **First release dry-run (`v1.0.0-rc.1`)** — the Release workflow has *never executed*; no tag has ever been pushed. It is now gated (verify job: test/typecheck/lint; artifact check between build and publish) and publishes as a **draft**, so a tag no longer auto-updates installed clients until someone publishes the draft by hand. Still unproven end to end: push an RC tag, watch all three OS legs build, download each artifact, launch it, and confirm the app opens and Pyodide reaches `ready`. Do this before any demo depends on a released build. +- [ ] **Packaged-app launch smoke test** — `tests/build.test.ts` asserts the build output and Pyodide payload are present on disk, and `tests/pyodideAssets.test.ts` guards the `extraResources` ↔ protocol-handler path contract statically. Neither actually *launches* the packaged app. The remaining gap is a real Electron smoke test (spawn the built app under `xvfb` on Linux, assert the window opens and the worker posts `ready`) — that is what would catch a prod-only Pyodide regression automatically instead of during a demo. The vendored `agent-browser` skill (CDP-driven) may be the cheapest route. - [ ] **Cross-platform LSL packaging verification** — `macOS arm64` is done + self-contained (see Done recently). Still needs each build machine: **macOS x64** (`patchDeps` only symlinks on arm64, so an Intel build uses node-labstreaminglayer's original x86_64 dylib — `otool -L` the packaged liblsl to confirm self-contained; the `afterPack` hook bundles whatever external deps it finds, arch-agnostic), **Windows x64** (bundled `lsl_amd64.dll` is a self-contained PE — confirm koffi loads it from the packaged app), **Linux/Ubuntu** (bundled `.so` is x86_64 ELF — `ldd` it + Web-Bluetooth smoke test, `--enable-experimental-web-platform-features` already set). ## Deferred (post-launch) diff --git a/package.json b/package.json index fa3d7ba0..55b6aa9b 100644 --- a/package.json +++ b/package.json @@ -22,9 +22,10 @@ "lint-fix": "npm run lint -- --fix", "postlint-fix": "prettier --single-quote --write '**/*.{ts,tsx,js,jsx,json,html,css,less,scss,yml}'", "test": "vitest run", + "test:build": "vitest run --config vitest.build.config.ts", "device-integration": "vitest run src/main/lsl src/renderer/utils/eeg", "test:watch": "vitest", - "test-all": "npm run lint && npm run typecheck && npm run build && npm test", + "test-all": "npm run lint && npm run typecheck && npm run build && npm test && npm run test:build", "tsc": "tsc --noEmit", "typecheck": "tsc --noEmit" }, @@ -85,7 +86,8 @@ "provider": "github", "owner": "makebrainwaves", "repo": "brainwaves", - "private": false + "private": false, + "releaseType": "draft" }, "extraResources": [ { diff --git a/tests/build.test.ts b/tests/build.test.ts new file mode 100644 index 00000000..030bd7df --- /dev/null +++ b/tests/build.test.ts @@ -0,0 +1,97 @@ +import fs from 'fs'; +import path from 'path'; +import { describe, it, expect } from 'vitest'; + +/** + * Post-build artifact checks. Runs against `out/` and the postinstall-downloaded + * Pyodide payload, so it requires `npm run build` (and a full `npm install`) + * first — see the `test:build` script and the CI step ordering. + * + * Purpose: catch the prod-only failure class documented in .llms/learnings.md, + * where the app builds and the unit suite passes but the packaged renderer + * cannot load Pyodide because an asset is missing from where the protocol + * handler looks. Path *spelling* is covered statically by pyodideAssets.test.ts; + * this file covers actual presence on disk. + */ + +// process.cwd(), not __dirname — test files are ESM, where __dirname is undefined. +const repoRoot = process.cwd(); +const out = (...p: string[]) => path.join(repoRoot, 'out', ...p); + +const packageJson = JSON.parse( + fs.readFileSync(path.join(repoRoot, 'package.json'), 'utf8') +); + +const webworkerSrc = path.join( + repoRoot, + ( + packageJson.build.extraResources as Array<{ from: string; to: string }> + ).find((entry) => entry.from.includes('webworker'))!.from +); + +describe('electron-vite build output', () => { + it.each([ + ['main', out('main/index.js')], + ['preload', out('preload/index.js')], + ['renderer', out('renderer/index.html')], + ])('emits the %s bundle', (_label, filePath) => { + expect( + fs.existsSync(filePath), + `${filePath} missing — run \`npm run build\` before \`npm run test:build\`` + ).toBe(true); + }); + + it('main entry point matches package.json "main"', () => { + expect(fs.existsSync(path.join(repoRoot, packageJson.main))).toBe(true); + }); +}); + +describe('Pyodide payload (postinstall)', () => { + // Required at runtime in prod: `indexURL` resolves the interpreter and stdlib + // through the pyodide:// handler, so a missing one breaks analysis silently. + it.each([ + 'pyodide.mjs', + 'pyodide.asm.js', + 'pyodide.asm.wasm', + 'python_stdlib.zip', + ])('ships the Pyodide runtime file %s', (filename) => { + const filePath = path.join(webworkerSrc, 'pyodide', filename); + expect( + fs.existsSync(filePath), + `${filePath} missing — run \`npm run install-pyodide\`` + ).toBe(true); + }); + + it('every wheel named in the package manifest exists on disk', () => { + const packagesDir = path.join(webworkerSrc, 'packages'); + const manifestPath = path.join(packagesDir, 'manifest.json'); + + expect( + fs.existsSync(manifestPath), + `${manifestPath} missing — run \`npm run install-pyodide\`` + ).toBe(true); + + const manifest: Record<string, { version: string; filename: string }> = + JSON.parse(fs.readFileSync(manifestPath, 'utf8')); + const entries = Object.entries(manifest); + + // micropip.install() is driven entirely off this manifest; a listed-but- + // absent wheel fails at MNE import time, deep inside the worker. + expect(entries.length).toBeGreaterThan(0); + + const missing = entries + .filter( + ([, { filename }]) => !fs.existsSync(path.join(packagesDir, filename)) + ) + .map(([pkg, { filename }]) => `${pkg} -> ${filename}`); + + expect(missing, 'manifest lists wheels that are not on disk').toEqual([]); + }); + + it('includes mne, the package the whole analysis path depends on', () => { + const manifest = JSON.parse( + fs.readFileSync(path.join(webworkerSrc, 'packages/manifest.json'), 'utf8') + ); + expect(Object.keys(manifest)).toContain('mne'); + }); +}); diff --git a/tests/pyodideAssets.test.ts b/tests/pyodideAssets.test.ts new file mode 100644 index 00000000..5162115c --- /dev/null +++ b/tests/pyodideAssets.test.ts @@ -0,0 +1,98 @@ +import fs from 'fs'; +import path from 'path'; +import { describe, it, expect } from 'vitest'; + +/** + * Guards the contract between where electron-builder *puts* the Pyodide payload + * and where the main process *looks* for it. + * + * These two live in different files with no shared constant, and a mismatch is + * invisible in dev (which reads from the source tree) while breaking prod + * completely — the renderer's web worker gets 404s for every wheel and analysis + * never starts. That exact mismatch shipped once; see the "Pyodide Asset Serving" + * entry in .llms/learnings.md. + * + * Static source checks, so they need no build and run in the default suite. + */ + +// process.cwd(), not __dirname — test files are ESM, where __dirname is undefined. +const repoRoot = process.cwd(); + +const packageJson = JSON.parse( + fs.readFileSync(path.join(repoRoot, 'package.json'), 'utf8') +); +const mainSource = fs.readFileSync( + path.join(repoRoot, 'src/main/index.ts'), + 'utf8' +); + +/** The `const pyodideRoot = is.dev ? ... : ...;` statement in src/main/index.ts. */ +const pyodideRootDecl = mainSource.match(/const pyodideRoot =[\s\S]*?;/)?.[0]; + +/** Strip leading `./` and trailing `/` so path spellings compare equal. */ +const normalize = (p: string) => p.replace(/^\.\//, '').replace(/\/$/, ''); + +describe('Pyodide asset path contract (package.json ↔ main process)', () => { + const extraResource = ( + packageJson.build.extraResources as Array<{ from: string; to: string }> + ).find((entry) => entry.from.includes('webworker')); + + it('package.json still ships the webworker payload via extraResources', () => { + expect(extraResource).toBeDefined(); + }); + + it('main process declares pyodideRoot with a dev and a prod branch', () => { + expect(pyodideRootDecl).toBeDefined(); + expect(pyodideRootDecl).toContain('is.dev'); + }); + + it('prod root matches the extraResources destination folder', () => { + // package.json copies webworker/src -> resources/<to>; the handler resolves + // path.join(process.resourcesPath, '<name>'). These must be the same name. + const prodName = pyodideRootDecl?.match( + /path\.join\(process\.resourcesPath,\s*'([^']+)'\)/ + )?.[1]; + + expect( + prodName, + 'could not find path.join(process.resourcesPath, ...) in the pyodideRoot declaration' + ).toBeDefined(); + expect(normalize(prodName as string)).toBe(normalize(extraResource!.to)); + }); + + it('dev root matches the extraResources source folder', () => { + const devPath = pyodideRootDecl?.match( + /path\.join\(app\.getAppPath\(\),\s*'([^']+)'\)/ + )?.[1]; + + expect( + devPath, + 'could not find path.join(app.getAppPath(), ...) in the pyodideRoot declaration' + ).toBeDefined(); + expect(normalize(devPath as string)).toBe(normalize(extraResource!.from)); + }); + + it('the dev root is the directory the install scripts write into', () => { + // InstallPyodide.mjs / InstallMNE.mjs are the only producers of this payload. + const installMne = fs.readFileSync( + path.join(repoRoot, 'internals/scripts/InstallMNE.mjs'), + 'utf8' + ); + const devPath = normalize( + pyodideRootDecl?.match( + /path\.join\(app\.getAppPath\(\),\s*'([^']+)'\)/ + )?.[1] as string + ); + + expect(installMne).toContain(`${devPath}/pyodide`); + expect(installMne).toContain(`${devPath}/packages`); + }); +}); + +describe('pyodide:// scheme registration', () => { + it('is registered as a privileged scheme before app ready', () => { + // Without this the worker cannot fetch over pyodide:// at all. + expect(mainSource).toContain("scheme: 'pyodide'"); + expect(mainSource).toContain("protocol.handle('pyodide'"); + }); +}); diff --git a/vitest.build.config.ts b/vitest.build.config.ts new file mode 100644 index 00000000..0c5ed6a1 --- /dev/null +++ b/vitest.build.config.ts @@ -0,0 +1,13 @@ +import { defineConfig } from 'vitest/config'; + +// Build-artifact checks (tests/build.test.ts) assert against `out/` and the +// postinstall-downloaded Pyodide payload, so they only mean anything after +// `npm run build`. They live in their own config — and are excluded from the +// default `npm test` run — so that running them without a build is a loud +// failure rather than a silently skipped test. +export default defineConfig({ + test: { + environment: 'node', + include: ['tests/build.test.ts'], + }, +}); diff --git a/vitest.config.ts b/vitest.config.ts index 7dd13c15..058776b2 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -1,4 +1,4 @@ -import { defineConfig } from 'vitest/config'; +import { defineConfig, configDefaults } from 'vitest/config'; import react from '@vitejs/plugin-react'; import path from 'path'; @@ -18,6 +18,8 @@ export default defineConfig({ globals: true, setupFiles: ['./src/test-setup.ts'], css: true, + // Needs a completed build; run via `npm run test:build` (vitest.build.config.ts). + exclude: [...configDefaults.exclude, 'tests/build.test.ts'], }, resolve: { alias: { From 2b339c78dfcdde4aabe18598238a6b0f15e9b1d6 Mon Sep 17 00:00:00 2001 From: Dano Morrison <danomorrison@meta.com> Date: Thu, 6 Aug 2026 18:39:19 -0700 Subject: [PATCH 26/29] Address review: cut ~1,200 lines of docs, dead config, and speculative code MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit docs + vendored skill - Delete docs/faces-houses-qa-plan.md (603 L) and docs/epoch-review-ui-plan.md. A manual QA script and a plan for work that just shipped; nothing reads either. Phase 3/4 already live in TODOS.md as their own bullets. - Delete the accidentally-vendored agent-browser skill (.agents/, skills-lock, and the .claude symlink) — a checked-in symlink is a Windows-clone hazard. path contract: detect -> impossible - New src/shared/pyodideAssets.ts exports PYODIDE_SOURCE_DIR and PYODIDE_RESOURCE_DIR; src/main/index.ts imports them instead of repeating literals. Deletes tests/pyodideAssets.test.ts, which regex-scraped main's source to compare a folder name. package.json is JSON and can't import, so that one pairing is asserted in build.check.ts. - Rename build.test.ts -> build.check.ts: the default glob never picks it up, so vitest.build.config.ts and the vitest.config exclude both go. - Drop the packageJson.main check (out/main/index.js already covers it). yagni - PtpThresholdPreset + getPtpThresholdPreset(_device) ignored its parameter and returned a literal -> PTP_THRESHOLD const. DEFAULT_PTP_THRESHOLD_UV folds in. - hideAutoFlagEpochs: no experiment sets it; renderReview's showAutoFlag param was always true. - EpochArraysMeta.sfreq/.drop_log and SuggestedRejection.channel/.peak_uv had zero readers outside fixtures. Dropped from utils.py too. - EpochReviewer amp +/- gain: each lane already autoscales, so gain only bought clipping. - EEGExplorationComponent settling banner: HelpSidebar says the same thing and this PR adds the Help entry point to that screen. native + stdlib - role=button/tabIndex/onKeyDown -> <button type="button"> in both places. - times.findIndex((t) => t >= 0) for the t=0 line. - conditionIndexForCode -> Math.max(0, codes.indexOf(code)) at three call sites. - Channel-count warning uses window.electronAPI.showMessageBox, matching handleCleanData, instead of a Radix Dialog plus a state field. shrink - Dropped downsampleMinMax's width >= length branch (only tests reached it), meanTrace's empty guard (LiveErpPane already skips empty groups), conditionPalette's double-modulo, utils.py's single-file short-circuit, and webworker's dataKey === 'epochArrays' special-case. - palette is now required in plot_topo/plot_conditions; both call sites inject it, so the copied _DEFAULT_CONDITION_PALETTE is gone. - LiveErpPane derives includedCount; EpochReviewer reuses visibleCount and hoists uniqueSortedCodes above the effect. - cleanEpochsEpic is tap + map, not async returning a value to map back. - Deleted experimentReducer.test.ts (a literal asserted against a literal). test_suggest_rejections no longer asserts on the dropped keys. The uV->V regression guard now computes peak-to-peak from the epochs directly, so it tests the data rather than the reporting payload. --- .agents/skills/agent-browser/SKILL.md | 50 -- .claude/skills/agent-browser | 1 - TODOS.md | 8 +- docs/epoch-review-ui-plan.md | 458 ------------- docs/faces-houses-qa-plan.md | 603 ------------------ package.json | 2 +- skills-lock.json | 11 - src/main/index.ts | 10 +- src/renderer/actions/pyodideActions.ts | 4 - .../CleanComponent/EpochReviewer.tsx | 102 +-- .../components/CleanComponent/LiveErpPane.tsx | 22 +- .../__tests__/EpochReviewer.test.tsx | 2 - .../__tests__/epochArrays.test.ts | 32 - .../components/CleanComponent/epochArrays.ts | 33 +- .../components/CleanComponent/index.tsx | 188 +++--- .../components/EEGExplorationComponent.tsx | 53 +- src/renderer/constants/constants.ts | 20 +- src/renderer/constants/interfaces.ts | 1 - src/renderer/epics/pyodideEpics.ts | 9 +- .../__tests__/experimentReducer.test.ts | 9 - src/renderer/utils/eeg/conditionPalette.ts | 5 +- src/renderer/utils/webworker/utils.py | 44 +- src/renderer/utils/webworker/webworker.js | 9 +- src/shared/pyodideAssets.ts | 13 + tests/analysis/test_suggest_rejections.py | 20 +- tests/{build.test.ts => build.check.ts} | 56 +- tests/pyodideAssets.test.ts | 98 --- vitest.build.config.ts | 13 - vitest.config.ts | 4 +- 29 files changed, 195 insertions(+), 1685 deletions(-) delete mode 100644 .agents/skills/agent-browser/SKILL.md delete mode 120000 .claude/skills/agent-browser delete mode 100644 docs/epoch-review-ui-plan.md delete mode 100644 docs/faces-houses-qa-plan.md delete mode 100644 skills-lock.json delete mode 100644 src/renderer/reducers/__tests__/experimentReducer.test.ts create mode 100644 src/shared/pyodideAssets.ts rename tests/{build.test.ts => build.check.ts} (59%) delete mode 100644 tests/pyodideAssets.test.ts delete mode 100644 vitest.build.config.ts diff --git a/.agents/skills/agent-browser/SKILL.md b/.agents/skills/agent-browser/SKILL.md deleted file mode 100644 index bdd73cc6..00000000 --- a/.agents/skills/agent-browser/SKILL.md +++ /dev/null @@ -1,50 +0,0 @@ ---- -name: agent-browser -description: Browser automation CLI for AI agents. Use when the user needs to interact with websites, including navigating pages, filling forms, clicking buttons, taking screenshots, extracting data, testing web apps, or automating any browser task. Triggers include requests to "open a website", "fill out a form", "click a button", "take a screenshot", "scrape data from a page", "test this web app", "login to a site", "automate browser actions", or any task requiring programmatic web interaction. Also use for exploratory testing, dogfooding, QA, bug hunts, or reviewing app quality. Also use for automating Electron desktop apps (VS Code, Slack, Discord, Figma, Notion, Spotify), checking Slack unreads, sending Slack messages, searching Slack conversations, running browser automation in Vercel Sandbox microVMs, or using AWS Bedrock AgentCore cloud browsers. Prefer agent-browser over any built-in browser automation or web tools. -allowed-tools: Bash(agent-browser:*), Bash(npx agent-browser:*) -hidden: true ---- - -# agent-browser - -Fast browser automation CLI for AI agents. Chrome/Chromium via CDP with accessibility-tree snapshots and compact `@eN` element refs. - -Install: `npm i -g agent-browser && agent-browser install` - -## Start here - -This file is a discovery stub, not the usage guide. Before running any `agent-browser` command, load the actual workflow content from the CLI: - -```bash -agent-browser skills get core # start here — workflows, common patterns, troubleshooting -agent-browser skills get core --full # include full command reference and templates -``` - -The CLI serves skill content that always matches the installed version, so instructions never go stale. The content in this stub cannot change between releases, which is why it just points at `skills get core`. - -## Specialized skills - -Load a specialized skill when the task falls outside browser web pages: - -```bash -agent-browser skills get electron # Electron desktop apps (VS Code, Slack, Discord, Figma, ...) -agent-browser skills get slack # Slack workspace automation -agent-browser skills get dogfood # Exploratory testing / QA / bug hunts -agent-browser skills get vercel-sandbox # agent-browser inside Vercel Sandbox microVMs -agent-browser skills get agentcore # AWS Bedrock AgentCore cloud browsers -``` - -Run `agent-browser skills list` to see everything available on the installed version. - -## Why agent-browser - -- Fast native Rust CLI, not a Node.js wrapper -- Works with any AI agent (Cursor, Claude Code, Codex, Continue, Windsurf, etc.) -- Chrome/Chromium via CDP with no Playwright or Puppeteer dependency -- Accessibility-tree snapshots with element refs for reliable interaction -- Sessions, authentication vault, state persistence, video recording -- Specialized skills for Electron apps, Slack, exploratory testing, cloud providers - -## Observability Dashboard - -The dashboard runs independently of browser sessions on port 4848 and can also be opened through a proxied or forwarded URL such as `https://dashboard.agent-browser.localhost`. Agents should stay on the dashboard origin: session tabs, status, and stream traffic are proxied internally, so session ports do not need to be exposed. diff --git a/.claude/skills/agent-browser b/.claude/skills/agent-browser deleted file mode 120000 index e298b7be..00000000 --- a/.claude/skills/agent-browser +++ /dev/null @@ -1 +0,0 @@ -../../.agents/skills/agent-browser \ No newline at end of file diff --git a/TODOS.md b/TODOS.md index 1864a9eb..4ca8ee16 100644 --- a/TODOS.md +++ b/TODOS.md @@ -7,7 +7,7 @@ Deferred and in-flight work. Keep this current — when something ships, delete - [ ] **Lesson content from Steve Azeka** — owner: Dano. Set a date for the first sample lesson. Fallback: if no lesson by that date, Dano authors one throwaway stub to unblock the lesson surface. - [ ] **In-app lesson surface v1** — static markdown render only (no editing, no interactivity). Build against a dummy lesson; tune to real content once a sample lands. Confirm markdown is a rich-enough format with Azeka before building. - [ ] **First release dry-run (`v1.0.0-rc.1`)** — the Release workflow has *never executed*; no tag has ever been pushed. It is now gated (verify job: test/typecheck/lint; artifact check between build and publish) and publishes as a **draft**, so a tag no longer auto-updates installed clients until someone publishes the draft by hand. Still unproven end to end: push an RC tag, watch all three OS legs build, download each artifact, launch it, and confirm the app opens and Pyodide reaches `ready`. Do this before any demo depends on a released build. -- [ ] **Packaged-app launch smoke test** — `tests/build.test.ts` asserts the build output and Pyodide payload are present on disk, and `tests/pyodideAssets.test.ts` guards the `extraResources` ↔ protocol-handler path contract statically. Neither actually *launches* the packaged app. The remaining gap is a real Electron smoke test (spawn the built app under `xvfb` on Linux, assert the window opens and the worker posts `ready`) — that is what would catch a prod-only Pyodide regression automatically instead of during a demo. The vendored `agent-browser` skill (CDP-driven) may be the cheapest route. +- [ ] **Packaged-app launch smoke test** — `tests/build.check.ts` asserts the build output and Pyodide payload are present on disk, but nothing *launches* the packaged app. The gap is a real Electron smoke test (spawn the built app under `xvfb` on Linux, assert the window opens and the worker posts `ready`) — that is what would catch a prod-only Pyodide regression automatically instead of during a demo. - [ ] **Cross-platform LSL packaging verification** — `macOS arm64` is done + self-contained (see Done recently). Still needs each build machine: **macOS x64** (`patchDeps` only symlinks on arm64, so an Intel build uses node-labstreaminglayer's original x86_64 dylib — `otool -L` the packaged liblsl to confirm self-contained; the `afterPack` hook bundles whatever external deps it finds, arch-agnostic), **Windows x64** (bundled `lsl_amd64.dll` is a self-contained PE — confirm koffi loads it from the packaged app), **Linux/Ubuntu** (bundled `.so` is x86_64 ELF — `ldd` it + Web-Bluetooth smoke test, `--enable-experimental-web-platform-features` already set). ## Deferred (post-launch) @@ -17,13 +17,13 @@ Deferred and in-flight work. Keep this current — when something ships, delete - [ ] External / generic LSL stream support (any EEG device). - [ ] Lab.js cleanup — remove jspsych, type lab.js data. Not on the content critical path; users never see it. - [ ] Lesson surface beyond markdown — block-based programming, embedded notebooks (the CLAUDE.md extensibility horizon). -- [ ] **Epoch reviewer Phase 3 — onboarding layer.** Plan: `docs/epoch-review-ui-plan.md` §9. Plain-language explanations of epochs + each artifact type, a **guided mode** (step through auto-flagged epochs with "why we flagged this," student confirms/overrides), channel legend tied to head position (Muse 10-20), student-facing tone. Builds on the Phase 0-2 reviewer (PRs #223/#224/#225). **Open question OQ3 (onboarding depth) is still unresolved** — how much curriculum (tooltips only vs. a real walkthrough), guided-mode-as-default? This is product-shaped, not architecture — take it through `/office-hours` or `/plan-ceo-review` before forging. Note it overlaps the "in-app lesson surface" critical-path work above; sequence deliberately. -- [ ] **Epoch reviewer Phase 4 — polish & generalize.** Plan: `docs/epoch-review-ui-plan.md` §9. N-channel devices (Neurosity 8-ch, external LSL 32-64-ch) — the renderer already windows/downsamples but needs real testing at scale and possibly the WebGL swap behind `drawEpochs` (OQ1 left that a later swap). Accessibility, keyboard-first flow, performance. Also OQ7 (keep a read-only static-SVG fallback for environments where the interactive UI can't run?) is still open. +- [ ] **Epoch reviewer Phase 3 — onboarding layer.** Plain-language explanations of epochs + each artifact type, a **guided mode** (step through auto-flagged epochs with "why we flagged this," student confirms/overrides), channel legend tied to head position (Muse 10-20), student-facing tone. Builds on the Phase 0-2 reviewer (PRs #223/#224/#225). **Open question OQ3 (onboarding depth) is still unresolved** — how much curriculum (tooltips only vs. a real walkthrough), guided-mode-as-default? This is product-shaped, not architecture — take it through `/office-hours` or `/plan-ceo-review` before forging. Note it overlaps the "in-app lesson surface" critical-path work above; sequence deliberately. +- [ ] **Epoch reviewer Phase 4 — polish & generalize.** N-channel devices (Neurosity 8-ch, external LSL 32-64-ch) — the renderer already windows/downsamples but needs real testing at scale and possibly the WebGL swap behind `drawEpochs` (OQ1 left that a later swap). Accessibility, keyboard-first flow, performance. Also OQ7 (keep a read-only static-SVG fallback for environments where the interactive UI can't run?) is still open. ## Known issues / tech debt - [ ] **(Optional) Full Pyodide worker RPC** — the analysis/Clean pipeline crash is now **fixed** (harvested from PR #194): a `dataKey` routing pattern parallel to `plotKey` — the worker echoes `dataKey` + PyProxy-converted results, and `pyodideMessageEpic` routes `epochsInfo`→`SetEpochInfo` / `channelInfo`→`SetChannelInfo`; the info epics are fire-and-forget. This unblocks the pipeline without the bigger refactor. The deeper latent issue remains, though: `worker.postMessage` returns `undefined` on *post*, so the `await`s in `webworker/index.ts` are no-ops and cross-message sequencing still relies on worker FIFO. A true `runPython(worker, code, ctx?)` RPC — `Map<id,{resolve,reject}>` + one `message` listener, worker echoes `id` — would let epics `await` real results and delete the `plotKey`/`dataKey` switch entirely. Only worth doing if the FIFO sequencing ever actually bites; not urgent now. -- [ ] Pyodide-fidelity smoke test — analysis pipeline is tested against native MNE, not yet under Pyodide/WASM (see `.llms/learnings.md`). **In progress:** the epoch-review Phase 0 (see `docs/epoch-review-ui-plan.md` §0) adds a *narrow* Pyodide test for the `get_epochs_arrays` float32 buffer path (byteLength, decode-vs-native, transfer detaches source); the full-pipeline Pyodide job remains deferred. +- [ ] Pyodide-fidelity smoke test — analysis pipeline is tested against native MNE, not yet under Pyodide/WASM (see `.llms/learnings.md`). **In progress:** the epoch-review Phase 0 adds a *narrow* Pyodide test for the `get_epochs_arrays` float32 buffer path (byteLength, decode-vs-native, transfer detaches source); the full-pipeline Pyodide job remains deferred. - [ ] **Epoch reviewer Phase 2 polish (from PR #225 review).** Three non-blocking behaviors flagged during the Phase 2 forge: (1) `get_epochs_arrays`/`suggest_rejections` use `pick_types(eeg=True)` whose MNE default is `exclude='bads'`, so after a Clean that flags a bad channel the re-fetched reviewer omits that channel from the display (saved `.fif` is unaffected) — decide whether to keep bad channels visible-but-greyed (`exclude=[]`) instead of vanishing; (2) re-running "Auto-flag" with the same threshold re-adds suggestions the user had manually unclicked (additive union merge in `CleanComponent.componentDidUpdate`); (3) the auto-flag threshold `<input>` has no min guard (0 µV flags everything). All in `src/renderer/components/CleanComponent/` + `webworker/utils.py`. ## Done recently diff --git a/docs/epoch-review-ui-plan.md b/docs/epoch-review-ui-plan.md deleted file mode 100644 index 0b7b6dd4..00000000 --- a/docs/epoch-review-ui-plan.md +++ /dev/null @@ -1,458 +0,0 @@ -# Interactive Epoch Review UI — Plan - -**Status:** Phases 0–2 are **implemented and merged** (PRs #223, #224, #225) — -Canvas epoch renderer, interactive rejection, live ERP, apply/save, bad-channel -flagging, auto-flag suggestions, condition labels. Phases 3 (onboarding layer) and -4 (polish/generalize) remain roadmap; see `TODOS.md` for the Phase 2 polish -follow-ups from the #225 review. Sections 1–5 below are retained as the design -rationale behind what shipped. - -**Owner:** Dano · **Drafted:** 2026-07-06 · **Locked:** 2026-07-07 (plan-eng-review) -· **Phases 0–2 landed:** 2026-08-06 - ---- - -## 0. Locked decisions (plan-eng-review, 2026-07-07) - -The forge-blocking open questions are now resolved. Phases 0–1 are ready for -temper→forge; Phases 2–4 remain roadmap. - -- **OQ1 (rendering) → Canvas 2D + DOM/SVG overlay.** Traces on `<canvas>` behind a - thin `drawEpochs(ctx, viewport)` boundary; interaction (epoch columns, channel - labels, selection, tooltips) on a DOM/SVG overlay. Viewport windowing from day 1 - (draw only the visible epoch window, like MNE's `n_epochs`); max/min-per-pixel - downsampling. WebGL is a later *swap* of `drawEpochs`, not a rewrite — justified - only at millions of points (all epochs × 32–64ch simultaneously). -- **OQ2 (transport) → extend the `dataKey` pattern; defer the `runPython` RPC.** - Add `epochArrays` (Phase 0) and `applyRejection` (Phase 1) dataKeys alongside the - proven `epochsInfo`/`channelInfo`/`savedEpochs` round-trips. No new sequencing - risk beyond what ships today. Matches `TODOS.md` ("RPC not urgent"). -- **OQ6 (live ERP preview) → IN SCOPE for v1 (Phase 1), computed client-side.** - Flavor 1 (live ERP *while cleaning*, not real-time-during-experiment — that's a - separate future effort, and won't be a Python/MNE path). Recompute the average in - the **renderer** over the epoch buffer Phase 0 already ships — a pure - `computeErp(buffer, selectedIndices, conditionCodes)` mean — on every reject - toggle. **Zero** new worker round-trips (instant UX, no race), which is *why* OQ2 - stays deferred. Python is authoritative only at final apply/save; the existing - matplotlib `plotERP` SVG remains the CI-banded "final" view. -- **OQ8 (persistence) → DONE.** MEMFS→host write-back bridge shipped in PR #222. -- **Color source (raised in review) → build the shared palette in Phase 0.** Promote - the hardcoded `utils.py` palette into one module both the Python plots and the - React reviewer read; Phase 0's static render uses it for condition coloring from - the start (so it isn't dead code). Resolves the §6a "registry gives labels, not - colors" gap and keeps the reviewer/topo/ERP legends agreeing. -- **Fidelity test (raised in review) → add a narrow Pyodide buffer smoke test in - Phase 0.** Native pytest can't exercise the Python→JS float32 buffer encode; add - one Pyodide test (byteLength, Float32 decode vs native, transfer detaches source) - to catch the silent blank-canvas failure. Starts the standing Pyodide-fidelity - TODO, scoped to just this path. -- **Delivery → two sequential PRs:** Phase 0 (transport + static render), then - Phase 1 (interaction + apply + live ERP). - -### Phase 2 decisions (2026-07-07) - -- **OQ4 (auto-flag UX) → one-click button + expandable settings, dev-gated.** An - "Auto-flag artifacts" button a newcomer can just press; an expandable settings - panel exposes the peak-to-peak threshold(s) for users who want to control/learn - more. The button is **dev-configurable per experiment** (a constant set keyed by - the `EXPERIMENTS` enum, gated on `CleanComponent`'s `type` prop) so devs opt - experiments in/out. -- **New-A (where auto-flag runs) → Python/MNE.** A worker round-trip - `suggest_rejections(epochs, threshold)` computes peak-to-peak per epoch in Python - and returns suggested indices + reasons (new `suggestedRejections` dataKey, - extends the OQ2 pattern). Suggestions **pre-mark** epochs in the reviewer and are - fully **overridable**; the actual drop still goes through the Phase-1 - `apply_rejection` path so the saved `.fif` stays MNE-exact. -- **OQ5 (bad channels on low-channel devices) → enabled everywhere, with a guard.** - Click a channel label to toggle it bad; bad channels flow to `apply_rejection`'s - `bad_channels` param (already built in Phase 1). On a 4-channel dataset, a - **warning Dialog** appears when the user marks a 2nd bad channel, nudging them to - re-collect if signal quality is that poor (informational — they can proceed). -- **New-B (condition legend) → human-readable labels via `codeToLabel`.** Plumb the - marker registry's `codeToLabel` (built from `state.experiment.params.stimuli`) - into the reviewer + Live ERP legends instead of raw numeric codes. -- **Delivery → one PR (Phase 2)**, stacked on Phase 1. - -Remaining open (not forge-blocking; decide when their phase comes): OQ3 (onboarding -depth, Phase 3), OQ7 (static fallback). - ---- - -## 1. Vision - -Replace MNE's native Matplotlib "clean epochs" GUI — which **cannot run in the -Pyodide/WASM port** — with a first-class, fully interactive React experience for -reviewing and cleaning epoched EEG. - -This is not just a feature port. It is a chance to make epoch review **an -onboarding experience to EEG signal analysis** as much as a tool. The target -user is a student who may be seeing epoched EEG for the first time. The UI should -teach *what an epoch is, what artifacts look like, and why we reject them* while -they do the work — in the app's lighthearted, student-friendly voice (per -`.llms/CLAUDE.md`). It must also be a genuinely capable tool an experienced user -trusts. Both, not either. - -It should be **fully featured and interactive** — at least at parity with what -MNE's GUI offered, and ideally beyond it (guided artifact detection, live ERP -feedback, explanations) because we control the whole surface. - ---- - -## 2. Why this exists — the WASM gap - -BrainWaves runs its analysis (MNE-Python) **in-browser via Pyodide/WASM inside a -web worker** (see the `pyodide-mne` skill and `docs/pyodide-in-electron-vite.md`). -That environment has hard constraints: - -- The worker uses Matplotlib's **`agg` backend** — headless, buffer-based. There - is **no GUI event loop and no window**. WebAgg (Matplotlib's browser backend) - does not work in a worker context at all. -- Every *static* plot we render today (PSD, topo map, ERP) works by having Python - `savefig()` an **SVG string**, tagging it with a `plotKey`, and shipping that - string back over the worker message channel to render in `PyodidePlotWidget`. -- `epochs.plot()` is the **only** analysis call that tried to be *interactive*. - It returns a live Matplotlib Figure object, which (a) has no GUI to display in, - and (b) can't even cross the worker boundary — a `PyProxy`/Figure isn't - `structuredClone`-able, so `postMessage` throws `DataCloneError`. - -**This is not an MNE-version problem — it's architectural.** We install the latest -MNE from PyPI (`internals/scripts/InstallMNE.mjs` lists `mne` **unpinned** in -`PYPI_PACKAGES`, so `postinstall` grabs whatever is current — a modern 1.x). The -recent errors (`plot_psd` → `compute_psd`, `events=None` → `events=False`) were -just the code catching up to the modern MNE API; no MNE version brings back native -Matplotlib GUIs in WASM. The only path to interactive epoch review is to build the -UI ourselves — which is what this plan is for. - -> **Note (grounding):** MNE is unpinned today. If exact-reproducibility of the -> cleaned output matters (design goal 3), consider pinning the MNE version in -> `InstallMNE.mjs` so a silent upstream bump can't change epoching/averaging -> behavior out from under the tests. - ---- - -## 3. What MNE's Matplotlib GUI did (the experience to replicate) - -`epochs.plot()` opened an interactive browser. Reference behavior: - -**Layout** -- Epochs laid out **side by side horizontally**; channels **stacked vertically**. -- Vertical divider lines between epochs; epoch index labels along the bottom. -- A configurable window of `n_epochs` visible at once (our call used 6). -- Epochs colored by **event/condition** (our numeric marker codes → conditions), - with a legend. -- Event markers drawn as vertical lines within each epoch. - -**Navigation** -- Scroll through epochs (←/→ / Page keys) and channels (↑/↓). -- Amplitude **scaling** up/down (`scalings='auto'` set the initial scale). -- Butterfly mode, DC-removal toggle, annotation toggle, help overlay. - -**The core action — rejection** -- **Click an epoch** → toggle it "bad" (visually greyed). On window close, the - marked epochs are **dropped** (`epochs.drop(indices)`). -- **Click a channel label** → mark the channel bad (`epochs.info['bads']`), - excluding it from downstream analysis. - -**Result** -- On close, the Epochs object is mutated: bad epochs dropped, bad channels - flagged. That cleaned object is what gets saved (`-cleaned-epo.fif`) and fed to - topo/PSD/ERP. - -**Adjacent MNE capability we should exploit** -- MNE can **auto-flag** epochs via peak-to-peak `reject` thresholds, recording - *why* each was dropped in the **`drop_log`**. This is gold for onboarding: - "we flagged these as likely blinks — here's the trace, do you agree?" - ---- - -## 4. What "cleaning" actually means (and a caveat) - -Cleaning = **removing artifact-contaminated epochs and bad channels** so the -average (ERP) reflects brain activity, not blinks/muscle/movement. Common -artifacts a student should learn to recognize: - -- **Eye blinks** — large, slow frontal deflections (AF7/AF8 on Muse). -- **Muscle/EMG** — high-frequency bursts. -- **Movement/drift** — slow baseline wander. -- **Electrode pop** — sudden step in one channel. - -**Caveat that raises the stakes:** filtering already happens at load (`filterIIR` -1–30 Hz). So *without* interactive rejection, today's "Clean Data" does no real -cleaning — it just re-saves every epoch. The interactive review **is** the -feature; there's no meaningful non-interactive fallback. (A read-only static SVG -of the epochs is possible as a stopgap, but it can't clean anything.) - ---- - -## 5. Design goals - -1. **Onboarding-first.** A newcomer should leave understanding epochs and - artifacts. Explanations, a guided mode, plain-language cues — not jargon walls. -2. **Fully featured & interactive.** Parity-plus with MNE's GUI: reject epochs, - flag channels, scroll, scale, zoom, condition coloring. -3. **A tool experts trust.** Auto-suggestions must be overridable; nothing hidden - or magic; the resulting cleaned data must be exactly what MNE would produce. -4. **Live feedback.** Ideally, show the **ERP updating as you reject** — the - single most powerful teaching moment (see the signal get cleaner). -5. **Hackable & extensible.** Fits the CLAUDE.md horizon (block-based programming, - embedded notebooks). Don't leak MNE/Pyodide abstractions into the UI. -6. **Device-agnostic.** Works for 4-ch Muse today, N-ch (Neurosity, external LSL) - tomorrow. - ---- - -## 6. Replicating it in React — the pieces - -Four concerns. Each has real design choices (deferred to the planning loop). - -### 6a. Data path: Python → renderer -We stop shipping a *GUI object* and instead ship the **raw numbers**, then render -them ourselves. Needed from Python per "clean" request: -- Epoch data array: `epochs.get_data()` → shape `(n_epochs, n_channels, n_times)`. - (Returns float64; see the transport note below on downcasting to Float32.) -- Metadata: `ch_names`, `sfreq`, `times`, **per-epoch condition codes** - (`epochs.events[:, -1]` — the numeric marker codes, *labeled* via the marker - registry's `codeToLabel`, not stored on the registry itself), and the - **auto-reject flags + `drop_log`** for suggestions. - -> **Grounding — exclude the Marker/stim channel from the arrays.** `get_data()` -> returns *all* channels, including the trailing `Marker` channel that carries the -> numeric event codes. The existing channel picker already drops it -> (`requestChannelInfo` runs `[ch for ch in clean_epochs.ch_names if ch != -> 'Marker']`); `get_epochs_arrays` must do the same, or the reviewer renders the -> raw code channel as a bogus "trace" and `ch_names` / the channel count are -> off-by-one. (The per-epoch condition codes still come from `epochs.events[:, -> -1]`, which is unaffected by dropping the channel.) - -> **Grounding correction — the registry gives labels, not colors.** -> `buildMarkerRegistry` (`markerRegistry.ts`) returns only `{ codeToLabel, -> eventId }`. There is **no color** on it. Condition colors today are hardcoded -> RGB palettes inside `utils.py` (`plot_topo`, `plot_conditions`). So condition -> coloring in the React UI needs a **new color source** — decide whether to (a) -> define a palette UI-side keyed by condition label, or (b) promote the `utils.py` -> palette into a single shared source both Python plots and the React reviewer -> read from (preferred, avoids the ERP/topo legend and the reviewer disagreeing on -> which color is which condition). Track this as a small design decision, not a -> reuse of existing code. - -**Transport is the crux** (the lesson of this whole debugging session): don't -JSON-serialize float arrays and don't return a `PyProxy`. Get the numpy buffer as -an **`ArrayBuffer`** and post it as a **transferable** (zero-copy); the metadata -above rides as JSON on the same message. Buffers cross cleanly; Figures don't. - -> **Downcasting to Float32 is safe here — and only here.** `get_data()` is -> float64; halving it to Float32 loses precision, but this buffer is *display -> only*. The apply path (§6d) is **index-based** (`epochs.drop(indices)`) — the -> epoch samples never round-trip out of Python and back, so the cleaned `.fif` -> stays bit-identical to what MNE would produce (design goal 3). Keep it that way: -> never reconstruct epochs from the Float32 buffer on the Python side. - -**This means extending the worker message contract, not just adding a Python -helper (grounded in `webworker.js` today).** The current handler is -one-size-fits-all: it runs `data`, and if the result has `.toJs` it converts the -`PyProxy` and posts `{ results, plotKey, dataKey }` **with no transfer list** — so -it can't ship a zero-copy buffer. Phase 0 must: -- Return the numpy buffer as an `ArrayBuffer` (e.g. `arr.tobytes()` off the - `PyProxy`, or via the emscripten heap) and `self.postMessage({ buffer, meta, - dataKey }, [buffer])` — the `[buffer]` transfer list is mandatory, or the buffer - is structured-cloned (a copy). -- Route it on a **new `dataKey`** (e.g. `'epochArrays'`) so it bypasses the - existing PyProxy→`toJs`→SVG-string switch in `pyodideMessageEpic` (which assumes - results are plain JS or an SVG string). - -### 6b. Rendering -Muse is tiny (4 ch × ~256 samples × dozens of epochs), but we must not design for -4 channels — Neurosity (8) and external LSL devices (32–64) are on the roadmap. -- **Canvas 2D** is the pragmatic default (smooth for thousands of points). -- **WebGL** if channel/epoch counts get large. -- **SVG/DOM overlay** on top for interaction targets (epoch columns, channel - labels, tooltips, selection highlights) — cheap hit-testing and accessibility. -- Likely a **hybrid**: canvas traces + DOM/SVG interaction layer. - -### 6c. Interaction model (parity-plus with MNE) -- Click/tap an epoch → toggle reject (clear visual state). -- Click a channel label → toggle bad channel. -- Scroll/scrub epochs and channels; amplitude scaling; horizontal zoom. -- Condition coloring + legend. -- **Beyond MNE:** hover tooltips ("this looks like a blink"), a "reject all - flagged" / "review flagged only" mode, keyboard-first flow, undo. - -### 6d. Apply path: renderer → Python -- Collect rejected epoch indices + bad channels → dispatch an action → epic posts - to the worker → Python `epochs.drop(indices)` + set `info['bads']` → save - `-cleaned-epo.fif`. -- The result must be **bit-identical to what MNE's GUI would have produced** — the - UI changes, the science does not. - -**Persistence is a real gap — the Clean→Analyze round-trip is broken today.** -Traced end to end against `webworker/`, `src/main/index.ts`, `src/preload/index.ts`: -- **Save side:** `saveEpochs` (`webworker/index.ts`) posts `raw_epochs.save("<host - OS path>")`, but Pyodide's worker FS is **MEMFS (in-memory)** — there is **no - NODEFS/`syncfs`/mount anywhere in `webworker/`**, so the write lands in the WASM - virtual FS, never on host disk. -- **Load side:** the Analyze screen re-reads *host disk* both times — at - `componentDidMount` it calls `readWorkspaceCleanedEEGData` (main-process `fs`) to - populate the picker, then on selection `LoadCleanedEpochs` → `writeEpochsToMemfs` - → `fs:readFileAsBytes` stages host bytes into MEMFS. It never reuses in-session - MEMFS epochs. -- **The gap:** nothing ever writes the cleaned `.fif` *to* host disk. Main has - write IPC (`fs:storePyodideImagePng`, `eeg:createWriteStream`, - `fs:storeAggregatedBehaviorData`) but **none writes an epoch `.fif` from the - worker**, so a fresh workspace has nothing for Analyze to load. The fix is a - **new MEMFS→host write-back bridge**, and because the worker can't reach - `ipcMain` it must cross every process boundary (see the `electron-ipc-channel` - skill) — don't collapse it to "postMessage → handler". The full chain: Python - `save()` to a MEMFS path → worker `pyodide.FS.readFile(path)` + `postMessage` the - bytes back on a **new `dataKey`** (e.g. `'savedEpochs'`, routed in - `pyodideMessageEpic` beside the existing `epochsInfo`/`channelInfo` cases — which - return no transfer list today, so shipping bytes zero-copy means extending that - reply path too) → a new epic → a new `window.electronAPI` method → the preload - bridge → a new main handler. **Reuse anchor, not new machinery:** - `fs:storePyodideImagePng` (`src/main/index.ts` line 315) is already this pattern — - `(title, imageTitle, rawData: ArrayBuffer)` → `Buffer.from(rawData)` → - `fs.writeFile` to disk; the write-back handler should mirror *it* (a binary - renderer→disk write), not `fs:readFileAsBytes`. This is not a "persist" nicety — - **it repairs a currently broken flow**. - -### 6e. The onboarding layer (the differentiator) -- Plain-language explanations of epochs and each artifact type. -- **Guided mode** (default for newcomers): step through auto-flagged epochs with - "why we flagged this," student confirms/overrides → teaches artifact spotting. -- Channel legend tied to head position (Muse 10-20: TP9/AF7/AF8/TP10). -- **Live ERP preview** pane that updates as epochs are rejected. -- Tone: encouraging, lighthearted, student-facing. - ---- - -## 7. Architecture fit & constraints - -- **Worker protocol** (`pyodide-mne` skill): today's plots use fire-and-forget - `postMessage` + `plotKey`/`dataKey` reply routing through `pyodideMessageEpic`. - Note the `dataKey` pattern **already does request/response round-trips** - (`epochsInfo` → `SetEpochInfo`, `channelInfo` → `SetChannelInfo`), so fetching - epoch arrays and applying rejection can be built on it directly — a `runPython` - RPC is **not a hard prerequisite**. The `TODOS.md` item ("(Optional) Full - Pyodide worker RPC") itself says it's only worth doing "if the FIFO sequencing - ever actually bites." Where it *would* help this feature: an `await`-able RPC - lets an apply-then-refresh sequence (drop epochs → save → re-fetch stats/ERP) - read real results instead of relying on worker FIFO ordering across several - fire-and-forget messages. Treat it as an ergonomics/scaling call, not a blocker - (see Open Question 2). -- **New Python helpers** (`webworker/utils.py`): `get_epochs_arrays(epochs)` → - buffer + metadata; `apply_rejection(epochs, drop_indices, bad_channels)`. Keep - them native-testable (the `tests/analysis/` pattern) so cleaning logic is - verified against real MNE in CI. -- **New epic(s)** in `pyodideEpics.ts` + **new actions** (fetch/set epoch data, - apply rejection). Reuse `buildMarkerRegistry` for condition **labels** (code↔label - only, no colors — see §6a for where condition colors must come from). -- **New React component** (`EpochReviewer` or similar) **added to** - `CleanComponent`. Correction: `CleanComponent` has **no plot area** to replace — - today it renders only subject/recording `<select>`s, Load/Clean buttons, and - epoch-count stats (`renderEpochLabels`). The static plots (`PyodidePlotWidget`, - which renders an SVG string via an `<img>`) live in `AnalyzeComponent` and - `HomeComponent`, not here. So the reviewer is a genuinely new interactive - surface on the Clean screen, wired to the `Load Dataset`/`Clean Data` flow that - already dispatches `LoadEpochs`/`CleanEpochs`. Styling: shadcn/ui + Tailwind, - brand teal, student tone. -- **Constraints to respect:** headless `agg` in the worker; no large-array JSON; - transferables for buffers (which means extending the worker's `postMessage` to - pass a transfer list — see 6a); keep main/renderer/worker separation clean; - don't leak Pyodide/MNE types into React. - ---- - -## 8. Open questions (for the planning loop) - -1. **Rendering tech — RESOLVED (§0): Canvas 2D + DOM/SVG overlay**, thin - `drawEpochs` boundary, viewport windowing. WebGL deferred to a later swap. -2. **RPC first? — RESOLVED (§0): no.** Extend the `dataKey` pattern; defer the - `runPython` RPC (reinforced by OQ6's client-side live ERP needing no round-trip). -3. **Onboarding depth** — Is guided mode the default? How much curriculum - (tooltips only vs. a real walkthrough)? -4. **Auto-rejection — RESOLVED (§0, Phase 2): one-click "Auto-flag" button + - expandable threshold settings, dev-gated per experiment; suggestions computed in - Python (peak-to-peak) and pre-marked but overridable.** -5. **Bad channels on Muse — RESOLVED (§0, Phase 2): enabled on all devices; a - warning Dialog fires when marking a 2nd bad channel on a 4-channel dataset.** -6. **Live ERP preview — RESOLVED (§0): in scope for v1 (Phase 1), computed - client-side** over the Phase-0 buffer (Flavor 1 only; real-time-during-experiment - is a separate future non-Python effort). -7. **Static fallback** — keep a read-only SVG epochs view for environments where - the interactive UI can't run, or all-in on the React UI? -8. **Save persistence — resolved (see §6d), not really open.** The Clean→Analyze - round-trip is broken today; the MEMFS→host write-back bridge fixes it. Only - residual decision: write to the existing - `Data/<subject>/EEG/<subject>-cleaned-epo.fif` path that - `readWorkspaceCleanedEEGData` scans for (`epo.fif` suffix, confirmed in - `src/main/index.ts`) so Analyze's picker finds it unchanged. - ---- - -## 9. Rough phases (to be firmed up in planning) - -- **Phase 0 — Transport & read-only render (PR 1). LOCKED.** - - **Python:** add `get_epochs_arrays(epochs)` to `utils.py` → returns a Float32 - buffer (`get_data()` cast to float32, C-contiguous) + metadata `{ch_names - (EEG-only, Marker excluded via `picks='eeg'`/explicit filter — one authority, - §Code-quality), sfreq, times, event_codes = epochs.events[:,-1], drop_log}`. - Native-testable (`tests/analysis/`): shape, Marker excluded, dtype, codes match. - - **Worker:** post the buffer on a new `epochArrays` dataKey with a real transfer - list (reuses the PR #222 transferable mechanism); metadata rides as JSON. - - **Epic/redux:** `pyodideMessageEpic` routes `epochArrays` → `SetEpochArrays`; - reducer stores buffer + metadata. - - **Shared palette module** (built now, §0): condition→color, imported by both - `utils.py` plots and the reviewer. - - **React:** `EpochReviewer` component — `<canvas>` traces behind - `drawEpochs(ctx, viewport)`, DOM/SVG overlay for labels, viewport windowing, - max/min downsampling, **condition coloring** from the shared palette. Wired into - `CleanComponent` after `Load Dataset`. Static (read-only) this phase. - - **Fidelity test (§0):** narrow Pyodide buffer smoke test. - - Proves the data path + rendering choice end to end. -- **Phase 1 — Core interaction + live ERP (PR 2). LOCKED.** - - **Interaction:** click/tap epoch column → toggle into a local - `Set<rejectedIndices>` (renderer state) with clear visual state; scroll/scrub, - amplitude scaling, horizontal zoom. - - **Live ERP preview (OQ6, client-side):** pure `computeErp(buffer, - selectedIndices, conditionCodes)` → per-channel/timepoint mean over - *non-rejected* epochs, redrawn on every toggle. Zero worker traffic; unit-tested - against `np.mean` (float32) + empty-selection guard. - - **Apply (once, at Clean Data):** add `apply_rejection(epochs, drop_indices, - bad_channels)` to `utils.py` (`epochs.drop` + `info['bads']`), posted on an - `applyRejection` dataKey → save `-cleaned-epo.fif` **via the write-back bridge - already shipped (§6d, PR #222)**. Native test asserts the result is - **bit-identical** to a manual MNE `drop`/`bads` on the same inputs (design goal 3). - - Reaches functional parity-plus with the *essential* MNE workflow. - - **Note:** the write-back bridge (§6d) already landed standalone in PR #222, so - Phase 1 just points the apply action at a bridge that works. -- **Phase 2 — Full parity (PR 4). LOCKED (§0 Phase 2 decisions).** - - **Bad-channel flagging:** click a channel label → toggle bad; bad channels flow - to the existing `apply_rejection` `bad_channels` param. Warning Dialog on the 2nd - bad channel of a 4-ch dataset (OQ5). - - **Auto-flag (OQ4 + New-A):** dev-gated "Auto-flag artifacts" button + expandable - threshold settings → `suggest_rejections(epochs, threshold)` in Python (peak-to- - peak, native-testable) → `suggestedRejections` dataKey → pre-marks epochs in the - reviewer (overridable) with reasons shown. - - **Condition legend (New-B):** real labels via `codeToLabel`. -- **Phase 3 — Onboarding layer.** Explanations, guided mode, artifact tutorials, - live ERP preview. -- **Phase 4 — Polish & generalize.** N-channel devices (Neurosity/LSL), - accessibility, keyboard flow, performance. - ---- - -## 10. Context references - -- **Skills:** `pyodide-mne` (worker↔Python protocol, plot routing, `pyodide://`), - `redux-observable-epochs` (epic anatomy, numeric marker-code contract), - `electron-ipc-architecture` + `electron-ipc-channel` (the four files a new IPC - channel must keep in sync — needed for the §6d write-back bridge). -- **Docs:** `docs/pyodide-in-electron-vite.md`, `docs/user-flow.md`. -- **Learnings** (`.llms/learnings.md`): agg backend / WebAgg-in-worker limits; - plot-result routing pattern; PyProxy serialization; marker registry / numeric - event codes; the analysis pipeline testability pattern. -- **Code today:** `src/renderer/utils/webworker/{index.ts,webworker.js,utils.py}` - (worker + Python), `src/renderer/epics/pyodideEpics.ts` (epics), - `src/renderer/components/CleanComponent/`, `PyodidePlotWidget.tsx` (existing - static-plot render path), `src/renderer/utils/eeg/markerRegistry.ts`. -- **Related TODO:** `TODOS.md` → "(Optional) Full Pyodide worker RPC" — an - `await`-able apply-then-refresh ergonomics win, **not** a prerequisite (§7, OQ2). diff --git a/docs/faces-houses-qa-plan.md b/docs/faces-houses-qa-plan.md deleted file mode 100644 index a81b9d1c..00000000 --- a/docs/faces-houses-qa-plan.md +++ /dev/null @@ -1,603 +0,0 @@ -# Faces/Houses flow — QA walkthrough plan - -Running plan assembled during a live QA walkthrough of the Faces/Houses experiment -flow (Dano driving, 2026-07-15). **This is a backlog, not applied work** — a future -session implements these. Each item: what Dano observed → where it lives in code → -proposed fix shape. Grounding is pre-work so the implementing session starts warm. - -Ties into TODOS.md **"Full experiment-flow QA pass"** (Now / Summer 2026). - ---- - -## Locked decisions & sequencing (`/plan-eng-review`, 2026-07-15) - -**D1 — units fix scope (6e): unconditional µV→V, defer LSL units.** Scale the **eeg** -channels ×1e-6 in `load_data`, keyed on `ch_type == 'eeg'` (NOT positional `data[:-1]` — -explicit over clever; stim channel stays untouched). Correct for Muse (the shipping -device). External-LSL unit-correctness is an explicit TODO (see NOT in scope), not silent. - -**D2 — sequence: data-integrity first, then QA in order, no revalidation pass.** -1. **First: correctness bugs** — 6e (units + zero-epoch guard), 7 (EEG default + no-EEG - warning), 9a (folder), 9b (empty-Analyze guard/toast), 8 (USB removal). -2. **Then: work the remaining QA items in order** (1 → 2 → 3 → 4 → 5 → 6a-d → 6g). - No dedicated re-QA gate between the two — proceed straight through. - -Note (context, not a blocker): #7 means EEG defaulted off, so some downstream observations -(6c rendering, 9b) *may* have been influenced by a behavior-only run. Not gating on -re-validation per D2 — the impl session confirms as it goes. - ---- - -## 1. Explore EEG: forgiving onboarding for electrode settling time - -**Observed (Explore EEG Data screen, Muse just placed on head):** the raw trace is a -wall of scary red lines. This is the normal skin-conductance / electrode settling -artifact that resolves after the electrodes sit on the head for a bit — but a -first-time user has no way to know that. Reads as "broken / bad device." - -**Where it lives:** -- Signal-quality color is std-dev based, per channel: - `src/renderer/utils/eeg/pipes.ts` (`parseMuseSignalQuality`) maps std → GREAT/OK/BAD/DISCONNECTED. - Thresholds `src/renderer/constants/constants.ts` `SIGNAL_QUALITY_THRESHOLDS` - (BAD ≥ 15, OK ≥ 10, GREAT ≥ 1.5). At settling, std is high → everything BAD (red). -- Rendered by `SignalQualityIndicatorComponent.tsx` (head diagram fill) and the raw - trace in `ViewerComponent.tsx`. -- Screen: `EEGExplorationComponent.tsx` (also gates the pre-connect vs connected view). -- Same red-on-settle problem almost certainly shows in the **connect / pre-test flow** - (`CollectComponent/ConnectModal.tsx`, `PreTestComponent.tsx`) — check there too. - -**Proposed fix shape (decide in impl session):** -- Add a settling window: track time-since-connect; for the first ~30–60s show a - friendly banner/overlay ("Electrodes are settling — red is normal for the first - minute while the sensors make contact. Sit still and watch it turn green.") with - optional countdown or a "still settling…" amber state instead of raw BAD-red. -- Lightweight version (ponytail): banner + timer on the Explore + connect screens, no - change to the quality math. Heavier: an explicit `SETTLING` signal-quality state - derived from time-since-connect that suppresses BAD styling early on. -- Keep the honest state reachable — if it's *still* red after the window, that's a real - bad-contact signal and should surface. - -**Open question:** settle window length, and whether "settling" is time-based only or -also "std is trending down." Product-shaped — likely a quick /office-hours call. - ---- - -## 2. Explore EEG: raw trace rendering is chunky / stutters - -**Observed:** the scrolling EEG trace stutters and looks chunky. Long-standing, not new. -Suspected to be the SVG line-drawing approach. - -**Where it lives:** -- Trace renders inside a bundled `<webview>` page driven by - `src/renderer/components/d3Classes/EEGViewer.js` — d3 into an **SVG `<path>`** - (`append('path')`, line 188), with `downsampling = 2` and `simplify-js` on incoming - epochs. Host component: `ViewerComponent.tsx` (feeds epochs to the webview). -- Redrawing/growing SVG paths every plotting interval is the likely stutter source - (SVG path re-layout, not GPU-accelerated). - -**Proposed fix shape (decide in impl session):** -- Cheapest first: profile whether the jank is redraw cost vs. epoch cadence. Tune - `downsampling`, `simplify-js` tolerance, and the d3 transition/`plottingInterval` - before any rewrite. -- If SVG is genuinely the ceiling: swap the trace to `<canvas>` (2D) or WebGL. Note the - epoch-review reviewer has the *same* SVG-vs-WebGL tension logged (see - `docs/epoch-review-ui-plan.md` OQ1 / TODOS Phase 4 `drawEpochs`) — worth solving both - with one rendering approach rather than twice. - -**Ponytail flag:** don't jump to WebGL first. Profile + tune the existing SVG path; only -rewrite if measurement says SVG is the wall. - ---- - -## 3. Generalize the signal-quality help content + surface it on Explore EEG - -**Observed:** the Collect screen already has good signal-quality explanation content, -but the tips are written for the **old Emotiv device** and only exist on Collect. This -should be (a) generalized to be device-agnostic and (b) also shown on the Explore EEG -screen. Ties into #1 — the settling explanation and these tips are the same "help me -get a good signal" surface. - -**Where it lives:** -- Content + stepper: `src/renderer/components/CollectComponent/HelpSidebar.tsx` - (`HELP_STEP` enum + `renderHelpContent`). Exports `HelpSidebar` (full panel) and - `HelpButton` (toggle). -- Mounted only in the Collect flow: `CollectComponent/PreTestComponent.tsx` - (lines 125–168). `HelpButton` alone also appears in `AnalyzeComponent.tsx:514`. -- **Not present** on Explore EEG (`EEGExplorationComponent.tsx`) — that's the gap. -- File already carries a TODO: *"Refactor this into a more reusable Sidebar component - that can be used in Collect, Clean, and Analyze screen"* — this item cashes that in. - -**Emotiv-specific content to fix (device-agnostic rewrite):** -- `HELP_STEP.SIGNAL_SALINE` — "Saturate the sensors in saline" is Emotiv gel/saline-cap - language. Muse uses dry/conductive electrodes — no saline soak. Drop or replace with - Muse-appropriate guidance (skin contact, sweep hair, dampen skin if very dry). -- `SIGNAL_CONTACT` — "reference electrodes right behind the ears" *is* Muse-accurate - (TP9/TP10 sit behind the ears), keep it. Audit the rest for Emotiv assumptions. -- Consider driving tips off the connected `deviceType` (Muse vs Neurosity vs external - LSL) so the copy matches the hardware, rather than one hard-coded device. - -**Proposed fix shape:** -- Extract `HelpSidebar` into a reusable, device-aware help panel (honors the existing - refactor TODO). Add device-agnostic settling copy (folds in #1's explanation). -- Mount the `HelpButton` + panel on `EEGExplorationComponent.tsx` so Explore gets the - same signal-quality help. -- Ponytail: don't build a full per-device content system if two shared strings + one - Muse-specific override covers today's devices. Generalize only as far as the current - device set needs. - -## 4. Collect "Run Experiment" landing — ugly, wastes space, redesign - -**Observed (step 2 COLLECT, pre-run landing):** the screen looks poorly designed. A -mystery empty input bar under the title, metadata stacked as plain text, a giant -full-width button, then a huge empty lower half. Wants efficient use of space. - -**Where it lives:** `src/renderer/components/CollectComponent/RunComponent.tsx` -(the `!isRunning` branch, lines 102–134). - -**Concrete problems found in the markup:** -- **The "empty input bar" is actually the edit button.** Line 106–112: - `<button className="flex justify-end w-full">✏</button>` — a full-width button holding - only a right-aligned pencil emoji. It renders as an empty full-width bar that reads as - a broken/empty text field. Should be a small icon button beside the title or the - subject metadata, not a `w-full` bar. -- **Metadata is unstyled stacked text** (Subject ID / Group Name / Session Number) — - no card, no grouping, weak hierarchy. -- **`Run Experiment` is `w-full`** (line 125) — oversized primary action. -- **Everything is top-left with a vast empty lower half** — no layout using the space - (e.g. centered card, or a two-column with device/signal status). -- **Redundant nested wrapper:** the outer `<div>` (line 97) *and* the inner `!isRunning` - `<div>` (line 103) both set `h-screen p-[3%] bg-gradient-to-b …` — double gradient + - double screen-height padding. Cheap cleanup, collapse to one. -- **Title duplicates the nav breadcrumb** — `<h1>{title}</h1>` repeats - "Faces_and_Houses" already shown in the top nav dropdown. Consider dropping or - repurposing. - -**Proposed fix shape:** -- Rebuild as a compact centered card: title (or drop it), a clean subject/group/session - summary block with a small inline edit (pencil) affordance, a right-sized primary - "Run Experiment" button. Fill the space intentionally (center the card, or add - device/signal-quality status alongside so the operator confirms readiness before run). -- Collapse the redundant `h-screen`/gradient wrapper. -- Design-shaped — worth a `/design-review` or `/design-consultation` pass in the impl - session rather than eyeballing spacing. - -**Ponytail:** this is one small component. Don't introduce a layout system — a single -centered `Card` (shadcn `Card` already exists) + right-sized button covers it. - -## 5. No forward breadcrumb after finishing a run — flow dead-ends on Collect - -**Observed:** completed the experiment and landed back on the same "Run Experiment" -screen. Nothing indicates the run is done or points forward to Clean. The only way to -advance is the top workflow nav (3 CLEAN) — undiscoverable for a first-timer; the flow -dead-ends. - -**Where it lives:** -- Run finish path: `RunComponent.tsx` `onFinish` → `ExperimentActions.Stop({data})` - (line 89–94). When `isRunning` flips false, it re-renders the *same* pre-run landing - (the `!isRunning` branch, line 102) — there is **no post-completion state**. It looks - identical to before the run, so "did it even record?" is unclear too. -- Forward nav today: the workflow steps in - `src/renderer/components/SecondaryNavComponent/index.tsx` (`onStepClick` per step) → - Clean route is `SCREENS.CLEAN.route` (`routes.tsx`, `constants.ts`). That's the only - COLLECT→CLEAN path. - -**Proposed fix shape:** -- Give `RunComponent` a distinct **post-run / completion state** (track "a run finished - this session", e.g. off the `Stop` result / recorded file) that: - - confirms the recording succeeded (filename, trial/marker count if cheap), and - - shows a primary **"Clean your data →"** CTA that navigates to `SCREENS.CLEAN.route` - (reuse the same routing the secondary nav step uses), plus a secondary "Run again." -- This is the same "guide the operator to the next step" gap that #4's redesign touches - — do them together in the Collect/Run pass. - -**Ponytail:** don't build a wizard. One completion panel with a "Clean your data →" -link-button (react-router `Link` is already imported in `RunComponent`) closes the gap. - -## 6. Clean screen — multiple issues (layout, labels, viewer, and a real units bug) - -**Screen:** `src/renderer/components/CleanComponent/index.tsx` (+ `webworker/utils.py`). -Dano's walkthrough surfaced four UX asks plus, on auto-flag, a genuine data bug. - -### 6a. Settings gear icon is mis-sized -The ⚙︎ auto-flag settings button (`CleanComponent/index.tsx` ~line 352–359) looks -too-big / not-big-enough next to "Clean Data" and "Auto-flag artifacts". Size it to -match the adjacent buttons (icon-button sizing / same height). - -### 6b. Condition labels show STIMULUS_1 / STIMULUS_2 instead of real names (Faces, Houses) -Counts, legends, and the ERP all read "STIMULUS_1 / STIMULUS_2". Should show the -experiment's real condition names ("Faces", "Houses"). -- Source of the generic labels: `src/renderer/utils/eeg/markerRegistry.ts` — - `CODE_TO_LABEL` is hard-coded `STIMULUS_1..4` (deliberately "condition-neutral"). - `buildMarkerRegistry` only ever emits those. -- Fix shape: let the registry derive a human label from the experiment's stimulus - metadata (per-experiment code→name map, e.g. Faces_and_Houses: 1→Faces, 2→Houses), - falling back to `STIMULUS_n` when unknown. Keep the numeric code contract intact - (labels are display-only; codes still drive CSV + MNE `event_id`). Thread the real - label into the Clean counts/legend/ERP. -- Ponytail: don't build an i18n/label system — a per-experiment `{code: name}` map on - the experiment object, read by `buildMarkerRegistry`, covers it. - -### 6c. Epoch viewer is too small and renders poorly -The Epochs panel gets too little real estate and the traces render badly (same -SVG-trace rendering concern as #2). Ties to the epoch-reviewer WebGL/`drawEpochs` -question already logged (`docs/epoch-review-ui-plan.md` OQ1, TODOS Phase 4). - -### 6d. Re-layout to prioritize Epochs + add a reject-onboarding flow -Give the Epochs viewer much more space, and preempt an onboarding flow that teaches -students how to read epochs and reject bad ones. **This is already scoped** as -**Epoch reviewer Phase 3 — onboarding layer** in TODOS.md (plain-language epoch/artifact -explanations, guided mode, channel legend tied to head position). This walkthrough is -direct evidence for prioritizing it. Route the product questions (onboarding depth, -guided-mode-as-default — TODOS OQ3) through /office-hours or /plan-ceo-review. - -### 6e. 🔴 BUG: Auto-flag rejects ALL epochs — µV→V units error (NOT threshold too low) -**Observed:** "Auto-flag artifacts" flagged all 16/16 epochs → ERP averages over 0 -epochs (blank). Flag reasons: **peak-to-peak 65,715,724 µV / 66,228,063 µV / -89,980,195 µV**. Dano's read was "thresholds too low"; the real cause is deeper. - -**Root cause (grounded):** 65 million µV = ~65 **volts** peak-to-peak is physically -impossible for EEG (real Muse ptp is tens of µV; the amp rails well under 1 mV). The -value is inflated ~1e6×: -- `webworker/utils.py` `load_data` (lines ~90–95) builds `RawArray(data=data, …)` - straight from the CSV values with **no µV→V scaling**. MNE treats EEG channel data as - **volts**, but the Muse CSV is in **µV**. So a real ~65 µV excursion is loaded as - ~65 "volts". -- `suggest_rejections` (utils.py ~line 350–359) computes ptp in MNE's unit (thinks - volts) then `* 1e6` to report µV → 65 → 65,000,000 µV. Threshold default - `DEFAULT_PTP_THRESHOLD_UV = 100` (`constants.ts:12`) → `thresh_v = 1e-4 V`, but ptp is - ~65 in MNE units, so **every** epoch exceeds it. - -**Fix shape:** scale CSV µV → V on load in `load_data` — multiply the **eeg** channels -by `1e-6` before/at `RawArray` (do NOT scale the last `stim`/Marker channel; markers are -numeric codes — see `.llms/learnings.md`). After the fix, ptp ≈ 65 µV, the 100 µV -default behaves sanely, and auto-flag stops nuking everything. **Verify against the -golden test** (`tests/analysis/test_erp_roundtrip.py`) — a global scale factor won't fail -its relative-amplitude assertions, so add an absolute-µV sanity check so this can't -regress silently. Also audit downstream consumers that assume the current (wrong) scale: -epoch viewer amplitude, `amp +/−`, ERP y-axis, `SetEpochInfo` payloads. - -**Do not** just raise `DEFAULT_PTP_THRESHOLD_UV` — that hides a real correctness bug and -would let genuine artifacts through once the units are fixed. - -### 6f. ⚠ Flagged console error (see-something-say-something) -The Clean screen logs `The specified value "NaN" cannot be parsed, or is out of range` -(repeating). Likely a DOM/SVG/input attribute being set to `NaN` — plausibly the epoch -viewer or the `amp`/threshold `<input>` receiving `NaN`. Not user-reported; flagging so -the impl session tracks it down (may share a cause with 6c/6e). - -### 6g. Auto-flag threshold: slider with device presets, not a raw µV number input -**Observed:** the threshold is a bare absolute-µV number `<input>` — students have no -idea what number to type. Make it a streamlined **slider** with sensible values -**pre-selected per device** (Muse vs Neurosity vs external LSL), so users adjust -sensitivity intuitively instead of guessing microvolts. - -**Where it lives:** `CleanComponent/index.tsx` — `autoFlagThreshold` state (default -`DEFAULT_PTP_THRESHOLD_UV = 100`, `constants.ts:12`) rendered as a numeric `<input>` in -the auto-flag settings panel (~lines 369–378); parsed in the `!Number.isNaN` handler -(~line 206–213). shadcn has no Slider yet — add `@radix-ui/react-slider` (same family as -the already-installed Select) or a styled native `<input type="range">`. - -**Fix shape:** -- Replace the number input with a slider (labeled low/high sensitivity, showing the - current µV under the hood but not requiring users to reason in µV). -- Seed the default + range from the connected device — a per-device preset map - (reasonable ptp ceilings differ by hardware). Fall back to `DEFAULT_PTP_THRESHOLD_UV`. -- **Depends on 6e:** presets are only meaningful once the µV→V units bug is fixed — - otherwise "100 µV" doesn't correspond to reality. Sequence 6e before tuning presets. - -**Ponytail:** a native `<input type="range">` + a small `{device: {min,max,default}}` -map covers this — no slider component library required unless the design calls for it. - -## 7. EEG disabled by default + no warning when running without EEG - -**Observed:** ran an experiment with EEG not connected, was **not notified**, and found -"Enable EEG" was **off by default** — nonsensical for an EEG app. Result: a run that -records behavior but no brain data, silently. - -**Where it lives:** -- Default: `src/renderer/reducers/experimentReducer.ts:38` — initial state - `isEEGEnabled: false`. Toggled via `SetEEGEnabled` (`experimentActions.ts:38`); the - toggle UI is the "Enable EEG" switch in `SecondaryNavComponent` SettingsDropdown - (wired from the workflow screens, e.g. `DesignComponent`). -- Cascade when `isEEGEnabled === false`: - - `CollectComponent.componentDidMount` skips auto-connect (only connects if EEG on). - - `RunComponent`: `isRunComponentOpen` initializes to `!isEEGEnabled` → opens straight - to the Run screen, bypassing connect/pre-test. - - `RunComponent.eventCallback` gates on `isEEGEnabled` → **no markers injected**, so - even if a device were streaming, this path wouldn't tag events. - - Net: no device, no markers, no warning — the operator only finds out later (empty - EEG, e.g. the Clean screen). - -**Proposed fix shape:** -- **Flip the default to `isEEGEnabled: true`** (one line in `experimentReducer.ts`). - EEG-on is the app's whole point; opt *out* for the behavior-only case, not opt in. -- **Warn before a no-EEG run.** When starting a run with EEG enabled but no device - connected — or with EEG disabled — surface a clear confirm ("EEG is not connected — - this run will record responses but no brain data. Continue?"). `RunComponent`'s - `handleStartExperiment` already does a `showMessageBox` confirm for file-overwrite; - reuse that pattern for the no-EEG case. -- Consider persisting the user's EEG-enabled choice rather than resetting per session. - -**Ponytail:** the default flip is one line; the guard is one extra `showMessageBox` -branch in the existing `handleStartExperiment`. No new state machine needed. - -## 8. Remove the "Insert the USB Receiver" modal step (Emotiv-era, irrelevant for Muse) - -**Observed:** the connect flow still has an "Insert the USB Receiver" step. Muse is -Bluetooth — no dongle. Remove it. - -**Where it lives:** `src/renderer/components/CollectComponent/ConnectModal.tsx`. -Local `enum INSTRUCTION_PROGRESS { SEARCHING, TURN_ON, COMPUTER_CONNECTABILITY }` -(line 42). Flow: `TURN_ON` ("headset on & charged") → **`COMPUTER_CONNECTABILITY`** -("Insert the USB Receiver", lines ~260–293) → `handleSearch()` → device list. - -**Exact removal (trivial, ready to implement):** -1. Delete the `COMPUTER_CONNECTABILITY` render block (lines ~260–293). -2. In the `TURN_ON` step, change **Next** (lines ~245–255) from - `handleinstructionProgress(INSTRUCTION_PROGRESS.COMPUTER_CONNECTABILITY)` to call - `this.handleSearch` directly (what the USB step's Next did) — so "headset on" flows - straight to searching. -3. Remove `COMPUTER_CONNECTABILITY` from the enum. -4. Leave the device-list "Back" / "Don't see your device?" targets (`…Progress(1)` = - `TURN_ON`) as-is — still valid. - -**Note:** this is small and low-risk — a good candidate to just land in the impl session -(clean tree → one atomic commit). No product decision needed. - -## 9. Analyze screen empty after Clean→Analyze + "Go to Folder" broken (two real bugs) - -### 9a. 🔴 BUG: "Go to Folder" on Home does nothing — relative path to shell API -**Observed:** the "Go to Folder" button on the Home screen doesn't open the workspace, -blocking any manual check of what was written. - -**Root cause (grounded):** `src/renderer/utils/filesystem/storage.ts:29` -``` -openWorkspaceDir = (title) => api().showItemInFolder(path.join('BrainWaves_Workspaces', title)) -``` -passes a **relative** path `"BrainWaves_Workspaces/<title>"` to -`shell.showItemInFolder` (`src/main/index.ts:159`). `shell.showItemInFolder` requires an -**absolute** path — given a relative, non-existent one it silently no-ops. The real -workspace dir is absolute: `path.join(os.homedir(), 'BrainWaves_Workspaces', title)` -(`main/index.ts:116–118`, `getWorkspaceDir`). Called from -`HomeComponent/index.tsx:250`. - -**Fix shape:** resolve the absolute path in main, not the renderer. Add a -`shell:openWorkspaceDir` handler that does `shell.openPath(getWorkspaceDir(title))` -(opening the folder is better UX than `showItemInFolder`, which highlights the item in -the parent). Point `openWorkspaceDir` at it. Removes the relative-path guesswork and the -duplicated `'BrainWaves_Workspaces'` literal. Small, isolated fix. - -### 9b. 🔴 BUG: Analyze "Select Clean Datasets" is empty after cleaning -**Observed:** cleaned the dataset, clicked Analyze, and the "Select Clean Datasets" -picker is empty — nothing to analyze. - -**Grounding — the write/read paths MATCH, so the file wasn't written (not a scan miss):** -- Write: `fs:writeCleanedEpochs` (`main/index.ts:332`) → - `<workspace>/Data/<subject>/EEG/<subject>-cleaned-epo.fif`. -- Read: `fs:readWorkspaceCleanedEEGData` (`main/index.ts:246`) scans the workspace - recursively for the `epo.fif` suffix — `-cleaned-epo.fif` matches. So the picker would - list the file **if it existed**. Empty ⇒ no cleaned `.fif` on disk. -- Save trigger: the worker must emit a `dataKey === 'savedEpochs'` message with a buffer; - only then does `pyodideEpics.ts:129–145` call `writeCleanedEpochs` (+ "Cleaned data - saved" toast). If that message/buffer never arrives, nothing is written and nothing - errors. - -**Two suspects for the impl session (verify, don't assume):** -1. **Consequence of the units bug (6e):** on this run auto-flag had marked **all 16/16 - epochs** for rejection. If Dano clicked "Clean Data" with everything flagged, the drop - removes every epoch → the saved Epochs object is **empty**, which may fail to write a - valid `.fif` (or write a degenerate one that the scan/parse skips) — with no user- - facing error. Fix 6e first, then re-test a normal clean (few rejections). Also: **guard - against cleaning to zero epochs** (warn / block "Clean Data" when the drop set == all - epochs) so a student can't silently destroy their dataset. -2. **The known worker-RPC FIFO fragility** (see `.llms/learnings.md` / TODOS "Full Pyodide - worker RPC"): `worker.postMessage` awaits are no-ops, so the drop→save→re-fetch - sequence relies on FIFO ordering. If the save step's buffer is dropped/empty, the - `savedEpochs` branch silently writes nothing. Add an explicit failure surface — if - `savedEpochs` never arrives (or arrives empty), toast an error instead of failing - silent. - -**Common thread with the whole walkthrough:** silent failure. Collect ran with no EEG and -no warning (#7); auto-flag nuked everything from a units bug (#6e); cleaning to zero -epochs produced an unanalyzable dataset with no error here. The impl session should add -user-facing guards/toasts at each of these "you just destroyed your data" moments. - -<!-- append new walkthrough items below as Dano surfaces them --> - ---- - -## Test coverage (from /plan-eng-review) - -Frameworks: **pytest** (`tests/analysis/`, native MNE — the data path) + **vitest** (JS). -Most UX items are visual → verified via `/qa` or manual, no unit test. The code-logic -items need tests: - -``` -CODE PATHS (testable) STATUS -[6e] webworker/utils.py load_data — µV→V scale - ├── [GAP → CRITICAL] eeg channels scaled ×1e-6 regression test REQUIRED (iron rule) - │ assert ptp of a known recording lands ~20–150µV, NOT ~1e6× - │ (same tests/analysis suite as jitter/dropped-sample — prior learning) - └── [GAP] stim/Marker channel NOT scaled assert marker codes unchanged (still 1,2) -[6e/6d] Clean "Clean Data" zero-epoch guard - └── [GAP] dropSet == all epochs → block/warn unit/integration: no empty .fif written -[7] experimentReducer isEEGEnabled default - ├── [GAP] initial state === true trivial unit test - └── [GAP] start-run-without-device → warn path RunComponent handleStartExperiment branch -[6b] buildMarkerRegistry — condition names - └── [GAP] type→stimulus.condition label ("Faces") unit test in markerRegistry test -[9a] openWorkspaceDir absolute path manual verify (shell call); low ROI to unit -[9b] savedEpochs never-arrives → error toast [GAP] integration: assert toast on empty save - -COVERAGE: data-path logic largely UNTESTED for these paths. 6e regression = mandatory. -``` - -## Failure modes (new/changed codepaths) - -| Codepath | Realistic failure | Test? | Error handling? | User sees | -|----------|-------------------|-------|-----------------|-----------| -| 6e µV→V scale | scale applied to stim row too → marker codes corrupted | ADD | n/a | **silent** wrong ERPs — **critical gap** until test added | -| 6e zero-epoch clean | user drops all epochs → empty/invalid `.fif` | ADD | ADD guard | today: silent; after fix: warned | -| 7 no-EEG run | run with device disconnected | ADD | ADD confirm | today: **silent** no-data run — critical gap | -| 9b cleaned-save | worker `savedEpochs` never arrives / empty buffer | ADD | ADD toast | today: **silent** empty Analyze — critical gap | -| 9a folder open | relative path → shell no-op | manual | already logs | today: silent no-op | - -**Critical gaps (silent failure + no test + no handling, today):** 6e stim-scaling, -7 no-EEG run, 9b cleaned-save. All three get a guard/toast in the correctness PR. - -## NOT in scope (deferred, with rationale) - -- **External-LSL unit correctness** — the µV→V fix assumes µV CSV (true for Muse). LSL - streams may carry V or arbitrary units; proper per-stream unit metadata is its own task - (touches write path + IPC). Deferred per D1; TODO below. -- **Epoch-viewer WebGL rewrite (2 / 6c)** — profile + tune SVG first; rewrite only if - measured as the ceiling. Unify with epoch-reviewer `drawEpochs` (Phase 4) if it happens. -- **Full per-experiment label i18n (6b)** — wire `stimulus.condition` only; no label system. -- **Reject-onboarding curriculum depth (6d)** — product decision (Epoch reviewer Phase 3 - / TODOS OQ3); route through /office-hours, not this backlog. -- **Worker-RPC refactor (9b suspect 2)** — the real `runPython` RPC is already a standing - TODO; here we only add a failure toast, not the refactor. - -## What already exists (reuse, don't rebuild) - -- 6b: `Stimulus.condition` / `condition_first_title` already carry "Faces"/"Houses"; - `buildMarkerRegistry(stimuli)` already receives them. -- 7: `showMessageBox` confirm pattern already in `RunComponent.handleStartExperiment`. -- 5: react-router `Link` already imported in `RunComponent`. -- 9a: `getWorkspaceDir` already resolves the absolute workspace path. -- 4: shadcn `Card` exists. 3: HelpSidebar refactor already a standing TODO. - -## Implementation Tasks - -**Implementation status (2026-07-15, autonomous pass on `epoch-reviewer-phase2`).** -12 atomic commits `beb1620..376740f`. All 48 JS + 18 Python tests green. - -- [x] **T1 (P1)** — webworker/utils.py — scale eeg channels µV→V in `load_data` (key on `ch_type=='eeg'`) — `beb1620` - - Also emits µV at the two display boundaries (`get_epochs_arrays`, ERP plot) so - the viewer/plot show the same numbers; regression guard asserts ptp 1–1000µV; - fixed `test_detects_injected_artifact` (was a ptp-neutral DC offset that only - "passed" because the units bug flagged everything). -- [x] **T2 (P1)** — Clean — guard "Clean Data" when drop set == all epochs (confirm before drop) — `1e38197` -- [x] **T3 (P1)** — experiment — `isEEGEnabled` default `true` + warn on no-EEG-connected run — `7fdccfb` -- [x] **T4 (P1)** — main — `openWorkspaceDir` uses absolute path (`shell.openPath(getWorkspaceDir(title))`) — `68a410b` -- [x] **T5 (P2)** — error toast when worker `savedEpochs` arrives empty/missing — `4247b15` - - Empty-buffer backstop (trivial branch, no integration test — real prevention is T2). -- [x] **T6 (P2)** — ConnectModal — remove USB-receiver step (`COMPUTER_CONNECTABILITY`) — `c1cb40d` -- [x] **T7 (P2)** — markerRegistry — map `type → stimulus.condition` for display labels — `69396e4` - - Real names are "Face"/"House" (the source `condition`); Clean legend + Python ERP - pick them up automatically. Guards against two codes collapsing under one label. -- [~] **T8 (P2/P3)** — UX polish, in order. **Done:** 1-lite (settling banner, `5f2fc1e`), - 3 (help de-Emotiv + Explore mount, `5f2fc1e`), 4-markup (empty-bar button, dedupe - wrapper, Card landing, `45afe4b`), 5 (forward CTA, `b68096a`), 6a (gear icon-size, - `8d15e44`), 6g (sensitivity slider, `8d15e44`), 6f (very likely resolved — the only - `type="number"` input, the sole source of that DOM `"NaN"` error, was replaced by the - 6g slider; needs live confirmation). Analyze warm empty state (T11 subset, `376740f`). - **Deferred (need live app / device / design-review — see below):** 2 (trace perf — - profile first), 4-DF1 (two-column live-signal column), 6c (epoch-viewer perf/space), - 6d (Clean re-layout + onboarding, Phase 3 / office-hours), DF2-full (amber per-dot - state — touches quality math), T13 a11y (settling banner/slider/gear done; rest with - the deferred visual work). - -### Deferred — why, and what unblocks them -- **#2 trace perf / #6c epoch-viewer perf** — the plan itself says *profile the SVG/ - canvas first, rewrite only if measured as the ceiling*. Needs the live Electron app + - a streaming device to profile; can't be done blind. Unify with epoch-reviewer - `drawEpochs` (Phase 4) if a rewrite happens. -- **#4 DF1 two-column live-signal column** — the markup fixes shipped; the full - two-column layout with a live signal-quality head requires wiring the signal - observable into `RunComponent` **and** visual verification. Do it in a live - `/design-review` pass (the plan flagged this item as design-review-shaped). -- **#6d Clean re-layout + reject-onboarding** — product-shaped (Epoch reviewer Phase 3); - route onboarding depth / guided-mode through `/office-hours` per the plan. -- **DF2 full amber per-dot settling** — the lightweight time-based banner shipped; the - per-dot amber state touches `pipes.ts` quality math and needs device verification. - ---- - -## Design decisions (`/plan-design-review`, 2026-07-15) - -App-type: **APP UI** (data-dense EEG workspace) — calm hierarchy, utility copy, minimal -chrome, cards only when the card *is* the interaction. Calibrated against `.llms/CLAUDE.md` -styling (teal `#007c70`, gold `#ffc107`, signal colors, shadcn, student-fun tone). - -**DF1 — Collect "Run Experiment" landing (item 4): two-column, function-filled.** -Left column: run summary (subject/group/session) with a small inline edit pencil + a -right-sized primary "Run Experiment" button. Right column: **live signal-quality head + -connection status**, so the operator confirms the device is ready before launching. -Kills the empty lower half with function, not a decorative floating card. Bonus: this is -the visual home for the no-EEG guard (#7) — readiness is *seen*, not just enforced. - -**DF2 — Explore settling onboarding (item 1): amber settling state + auto-dismiss banner.** -For the first ~45s after connect, the head-diagram dots show a neutral **amber "settling"** -state (not red), with a non-blocking `role="status"` banner: "Sensors settling — this is -normal. Sit still and watch them turn green." Auto-dismiss when settled. After the window, -real quality colors return and genuine bad-contact still surfaces red. No blocking modal / -forced tour (App UI rule). Settling ≠ bad: during the window the signal isn't valid yet, so -amber is *more* accurate than red, not a lie. - -### Interaction states table (Pass 2 — the biggest gap; maps to the silent-failure through-line) - -| Feature | Loading | Empty | Error | Success | Settling/partial | -|---|---|---|---|---|---| -| Explore signal | connecting spinner | — | device lost → toast + reconnect CTA | live quality colors | **amber settling + banner (DF2)** | -| Collect run | — | subject blank → button disabled + inline hint | no-EEG connected → confirm dialog (#7) | run launches | device connecting → status in right column (DF1) | -| Post-run (item 5) | "Recording…" | — | save failed → error toast + retry | **completion panel + "Clean your data →"** | — | -| Clean epochs | loading spinner | no epochs → warm "No epochs yet — collect data first" | units/parse error → toast | epochs render | zero-epoch clean → **warn before drop (6e/T2)** | -| Analyze picker | — | no datasets → warm "No cleaned data yet — clean a recording first · [Go to Clean]" (9b) | save-never-arrived → toast (T5) | datasets listed | — | - -Empty states are features: each carries warmth + a primary action, not "No items found." - -### Layout / copy specifications (smaller items) - -- **6a gear:** icon-button sized to match adjacent button height (same `h-*` as "Clean - Data"); keep `aria-label`. It's a settings toggle, not a primary action — secondary weight. -- **6b labels + emoji:** once real names land (`stimulus.condition`), legend/counts read - **😊 Faces / 🏠 Houses**. Keep the emoji — fun student tone, and it's always paired with a - text label (not emoji-as-bullet, so not AI-slop). The text label is also the a11y - equivalent for the emoji. -- **6d Clean re-layout hierarchy:** Epochs is the **primary** region — give it the larger - share (~60% width + more height); Live ERP is **secondary**. Group the epoch controls - (Prev/Next, amp ±, channel) into one toolbar. The reject-onboarding (Phase 3) is a - **dismissible first-run coach layer**, never a blocking modal. -- **6g slider:** labeled by sensitivity (low↔high), current µV shown as `aria-valuetext`, - not the primary label; native keyboard arrows suffice. - -### Accessibility specs (Pass 6 — Electron fixed-window, so a11y > responsive) - -- Settling banner: `role="status"` `aria-live="polite"` — announces settle progress without - stealing focus. Amber vs green must clear 3:1 non-text contrast; never color-alone (banner - text carries meaning). -- ERP legend uses **red vs green** — the worst colorblind pairing. Keep the text/emoji - labels beside the swatches (already present) so condition is never color-only. -- Slider + gear: keyboard-operable, visible focus ring, ≥44px (or match button height). -- Primary buttons: white-on-teal `#007c70` meets 4.5:1 — fine. Body text ≥16px. - -## Design Implementation Tasks - -- [ ] **T9 (P2)** — Collect — rebuild landing as two-column (summary+edit / live signal+status) per DF1 - - Surfaced by: DF1 (item 4). Files: `RunComponent.tsx`. Verify: `/qa` + operator sees signal before Run -- [ ] **T10 (P2)** — Explore — amber settling state + `role=status` auto-dismiss banner per DF2 - - Surfaced by: DF2 (item 1). Files: `EEGExplorationComponent.tsx`, `pipes.ts`/signal components, `ConnectModal`/`PreTestComponent`. Verify: amber first ~45s, real colors after, bad-contact still red -- [ ] **T11 (P2)** — Clean/Analyze/Collect — warm empty + error states per interaction table - - Surfaced by: Pass 2. Files: `CleanComponent`, `AnalyzeComponent`, `RunComponent`. Verify: each empty/error state shows warmth + primary action -- [ ] **T12 (P3)** — Clean — re-layout: Epochs primary (~60%+height), ERP secondary, grouped toolbar - - Surfaced by: 6d. Files: `CleanComponent/index.tsx`. Verify: `/design-review` after build -- [ ] **T13 (P3)** — a11y pass — settling banner/slider/gear/legend per a11y specs - - Surfaced by: Pass 6. Files: touched components. Verify: keyboard nav + contrast check - ---- - -## GSTACK REVIEW REPORT - -| Review | Trigger | Why | Runs | Status | Findings | -|--------|---------|-----|------|--------|----------| -| CEO Review | `/plan-ceo-review` | Scope & strategy | 0 | — | — | -| Codex Review | `/codex review` | Independent 2nd opinion | 0 | — | — | -| Eng Review | `/plan-eng-review` | Architecture & tests (required) | 1 | issues_open | 2 decisions locked, 3 critical failure-mode gaps | -| Design Review | `/plan-design-review` | UI/UX gaps | 1 | issues_open | 3/10 → 8/10, 2 forks locked, states table added | -| DX Review | `/plan-devex-review` | Developer experience gaps | 0 | — | — | - -- **UNRESOLVED:** 0 (Eng: D1 units, D2 sequencing. Design: DF1 Collect layout, DF2 settling). -- **CRITICAL GAPS:** 3 silent-failure paths (6e stim-scaling, 7 no-EEG run, 9b cleaned-save) — each gets a guard/toast + test in the correctness PR (T1–T3, T5). -- **CROSS-MODEL:** the design states-table and DF1 reinforce the eng review's #7 guard — readiness is now both enforced (dialog) and visible (signal column). -- **VERDICT:** ENG + DESIGN CLEARED — ready to implement. Data-integrity PR (T1–T4) first, then QA items in order (D2); design items (T9–T13) fold into the UX phase (T8). diff --git a/package.json b/package.json index 55b6aa9b..d00049ac 100644 --- a/package.json +++ b/package.json @@ -22,7 +22,7 @@ "lint-fix": "npm run lint -- --fix", "postlint-fix": "prettier --single-quote --write '**/*.{ts,tsx,js,jsx,json,html,css,less,scss,yml}'", "test": "vitest run", - "test:build": "vitest run --config vitest.build.config.ts", + "test:build": "vitest run tests/build.check.ts", "device-integration": "vitest run src/main/lsl src/renderer/utils/eeg", "test:watch": "vitest", "test-all": "npm run lint && npm run typecheck && npm run build && npm test && npm run test:build", diff --git a/skills-lock.json b/skills-lock.json deleted file mode 100644 index ffcd9c72..00000000 --- a/skills-lock.json +++ /dev/null @@ -1,11 +0,0 @@ -{ - "version": 1, - "skills": { - "agent-browser": { - "source": "vercel-labs/agent-browser", - "sourceType": "github", - "skillPath": "skills/agent-browser/SKILL.md", - "computedHash": "ecc7641aea05f85ca3b11e7759d32aaf52fe05946ab4b63739c7bf78a41237a2" - } - } -} diff --git a/src/main/index.ts b/src/main/index.ts index f3d5555d..a17d1a9b 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -25,6 +25,10 @@ import log from 'electron-log'; import { is, optimizer } from '@electron-toolkit/utils'; import MenuBuilder from './menu'; import { FILE_TYPES } from '../renderer/constants/constants'; +import { + PYODIDE_SOURCE_DIR, + PYODIDE_RESOURCE_DIR, +} from '../shared/pyodideAssets'; import { lslOutlets } from './lsl/outlets'; import { lslInlets } from './lsl/inlets'; import { isLSLAvailable } from './lsl/native'; @@ -739,11 +743,9 @@ app.on('before-quit', () => { app.whenReady().then(async () => { // Serve pyodide:// assets (whl files, manifest.json, etc.) directly from the // filesystem via Electron's protocol API — no network socket required. - // In dev: files are in src/renderer/utils/webworker/src/ - // In prod: files are copied to resources/pyodide/ by extraResources (package.json) const pyodideRoot = is.dev - ? path.join(app.getAppPath(), 'src/renderer/utils/webworker/src') - : path.join(process.resourcesPath, 'pyodide'); + ? path.join(app.getAppPath(), PYODIDE_SOURCE_DIR) + : path.join(process.resourcesPath, PYODIDE_RESOURCE_DIR); protocol.handle('pyodide', (request) => { const { pathname } = new URL(request.url); diff --git a/src/renderer/actions/pyodideActions.ts b/src/renderer/actions/pyodideActions.ts index 04b41d1c..f27905d8 100644 --- a/src/renderer/actions/pyodideActions.ts +++ b/src/renderer/actions/pyodideActions.ts @@ -8,18 +8,14 @@ export interface EpochArraysMeta { n_channels: number; n_times: number; ch_names: string[]; - sfreq: number; times: number[]; event_codes: number[]; - drop_log: string[][]; } // Auto-flag: a single artifact suggestion returned by Python's // suggest_rejections(epochs, threshold_uv) — one epoch/channel over threshold. export interface SuggestedRejection { index: number; - channel: string; - peak_uv: number; reason: string; } diff --git a/src/renderer/components/CleanComponent/EpochReviewer.tsx b/src/renderer/components/CleanComponent/EpochReviewer.tsx index ea5bc2b8..7ef327e5 100644 --- a/src/renderer/components/CleanComponent/EpochReviewer.tsx +++ b/src/renderer/components/CleanComponent/EpochReviewer.tsx @@ -1,17 +1,16 @@ -import React, { useEffect, useRef, useState } from 'react'; +import React, { useEffect, useMemo, useRef, useState } from 'react'; import type { EpochArraysMeta } from '../../actions'; import { Button } from '../ui/button'; import { cssColorForIndex } from '../../utils/eeg/conditionPalette'; import { - conditionIndexForCode, downsampleMinMax, epochChannelSeries, } from './epochArrays'; // Interactive epoch reviewer: epochs run across (x), channels stacked (y). // Click an epoch column to reject it; click a channel label to flag it bad -// (its lane washes red across all epochs). Prev/Next paginate, amp +/- scales -// traces. Canvas 2D for traces, a DOM overlay for labels/click targets. +// (its lane washes red across all epochs). Prev/Next paginate; each channel +// lane autoscales. Canvas 2D for traces, a DOM overlay for labels/click targets. interface Props { epochArrays: { buffer: ArrayBuffer; meta: EpochArraysMeta } | null; @@ -39,11 +38,6 @@ const LABEL_GUTTER = 64; // Gutter reserved at the bottom for epoch index labels (logical px). const BOTTOM_GUTTER = 20; -// Amplitude gain bounds and step (buttons multiply/divide by GAIN_STEP). -const GAIN_STEP = 1.5; -const GAIN_MIN = 0.1; -const GAIN_MAX = 20; - const REJECTED_TRACE_COLOR = 'rgba(120, 120, 120, 0.5)'; const REJECTED_FILL_COLOR = 'rgba(120, 120, 120, 0.15)'; const BAD_CHANNEL_FILL_COLOR = 'rgba(200, 60, 60, 0.10)'; @@ -60,7 +54,6 @@ export default function EpochReviewer({ // First epoch of the current page (absolute index). const [startEpoch, setStartEpoch] = useState(0); // Amplitude magnification applied around each lane's mean. - const [gain, setGain] = useState(1); const meta = epochArrays?.meta ?? null; @@ -73,6 +66,11 @@ export default function EpochReviewer({ const visibleCount = meta ? Math.min(perPage, meta.n_epochs - clampedStart) : 0; + // Deterministic per-condition coloring: position in the sorted unique codes. + const uniqueSortedCodes = useMemo( + () => [...new Set(meta?.event_codes ?? [])].sort((a, b) => a - b), + [meta] + ); useEffect(() => { const canvas = canvasRef.current; @@ -100,13 +98,9 @@ export default function EpochReviewer({ const plotHeight = CANVAS_HEIGHT - BOTTOM_GUTTER; const colWidth = plotWidth / perPage; const laneHeight = plotHeight / n_channels; - const count = Math.min(perPage, n_epochs - clampedStart); - - // Deterministic per-condition coloring: position in the sorted unique codes. - const uniqueSortedCodes = [...new Set(event_codes)].sort((a, b) => a - b); // Translucent grey wash over rejected columns (drawn first, under traces). - for (let c = 0; c < count; c += 1) { + for (let c = 0; c < visibleCount; c += 1) { const absolute = clampedStart + c; if (rejected.has(absolute)) { ctx.fillStyle = REJECTED_FILL_COLOR; @@ -151,14 +145,14 @@ export default function EpochReviewer({ const cols = Math.max(1, Math.floor(colWidth)); - for (let c = 0; c < count; c += 1) { + for (let c = 0; c < visibleCount; c += 1) { const absolute = clampedStart + c; const colLeft = LABEL_GUTTER + c * colWidth; const isRejected = rejected.has(absolute); const code = event_codes[absolute]; const traceColor = isRejected ? REJECTED_TRACE_COLOR - : cssColorForIndex(conditionIndexForCode(code, uniqueSortedCodes)); + : cssColorForIndex(Math.max(0, uniqueSortedCodes.indexOf(code))); ctx.strokeStyle = traceColor; ctx.lineWidth = 1; @@ -167,8 +161,8 @@ export default function EpochReviewer({ const laneCenter = laneTop + laneHeight / 2; const series = epochChannelSeries(buffer, meta, absolute, ch); - // Per-lane scaling centered on the trace's mean: at gain=1 the full - // [min, max] range fills the lane; gain>1 magnifies the deviation from + // Per-lane autoscaling centered on the trace's mean: the full + // [min, max] range fills the lane, i.e. deviation from // the mean. y is clamped to the lane so magnified traces don't bleed // into neighboring channels. let min = Infinity; @@ -188,7 +182,7 @@ export default function EpochReviewer({ const pad = laneHeight * 0.1; const usableHeight = laneHeight - 2 * pad; const range = max - min || 1; - const scale = (usableHeight / range) * gain; + const scale = usableHeight / range; const laneLo = laneTop + pad; const laneHi = laneTop + laneHeight - pad; const toY = (v: number): number => { @@ -225,25 +219,8 @@ export default function EpochReviewer({ ctx.stroke(); } } - - // Bold ✕ over rejected columns so the rejection reads at a glance. - if (isRejected) { - ctx.strokeStyle = 'rgba(90, 90, 90, 0.8)'; - ctx.lineWidth = 2; - const inset = Math.min(colWidth, plotHeight) * 0.18; - const x0 = colLeft + inset; - const x1 = colLeft + colWidth - inset; - const y0 = inset; - const y1 = plotHeight - inset; - ctx.beginPath(); - ctx.moveTo(x0, y0); - ctx.lineTo(x1, y1); - ctx.moveTo(x1, y0); - ctx.lineTo(x0, y1); - ctx.stroke(); - } } - }, [epochArrays, meta, rejected, clampedStart, perPage, gain, badChannels]); + }, [epochArrays, meta, rejected, clampedStart, perPage, badChannels, visibleCount, uniqueSortedCodes]); // Empty state — friendly, brand-styled, student-facing. if (!epochArrays || !meta || meta.n_epochs === 0) { @@ -260,10 +237,6 @@ export default function EpochReviewer({ const firstShown = clampedStart + 1; const lastShown = clampedStart + visibleCount; - const uniqueSortedCodes = [...new Set(meta.event_codes)].sort( - (a, b) => a - b - ); - return ( <div className="text-left"> <div className="mb-2 flex items-center justify-between"> @@ -287,25 +260,6 @@ export default function EpochReviewer({ > Next ▶ </Button> - <span className="mx-1 h-4 w-px bg-gray-300" /> - <Button - variant="outline" - size="sm" - aria-label="Decrease amplitude" - disabled={gain <= GAIN_MIN} - onClick={() => setGain((g) => Math.max(GAIN_MIN, g / GAIN_STEP))} - > - amp - - </Button> - <Button - variant="outline" - size="sm" - aria-label="Increase amplitude" - disabled={gain >= GAIN_MAX} - onClick={() => setGain((g) => Math.min(GAIN_MAX, g * GAIN_STEP))} - > - amp + - </Button> </div> </div> @@ -324,10 +278,9 @@ export default function EpochReviewer({ {meta.ch_names.map((name, ch) => { const isBad = badChannels.has(name); return ( - <div + <button key={name} - role="button" - tabIndex={0} + type="button" aria-pressed={isBad} aria-label={`${isBad ? 'Unflag' : 'Flag'} channel ${name} as bad`} title={`${isBad ? 'Unflag' : 'Flag'} channel ${name} as bad`} @@ -342,15 +295,9 @@ export default function EpochReviewer({ lineHeight: `${laneHeight}px`, }} onClick={() => onToggleChannel(name)} - onKeyDown={(e) => { - if (e.key === 'Enter' || e.key === ' ') { - e.preventDefault(); - onToggleChannel(name); - } - }} > {name} - </div> + </button> ); })} @@ -360,10 +307,9 @@ export default function EpochReviewer({ const absolute = clampedStart + c; const isRejected = rejected.has(absolute); return ( - <div + <button key={absolute} - role="button" - tabIndex={0} + type="button" aria-pressed={isRejected} aria-label={`${isRejected ? 'Restore' : 'Reject'} epoch ${absolute}`} title={`${isRejected ? 'Restore' : 'Reject'} epoch ${absolute}`} @@ -375,12 +321,6 @@ export default function EpochReviewer({ height: plotHeight, }} onClick={() => onToggleEpoch(absolute)} - onKeyDown={(e) => { - if (e.key === 'Enter' || e.key === ' ') { - e.preventDefault(); - onToggleEpoch(absolute); - } - }} /> ); })} @@ -418,7 +358,7 @@ export default function EpochReviewer({ className="inline-block h-3 w-3 rounded-sm" style={{ backgroundColor: cssColorForIndex( - conditionIndexForCode(code, uniqueSortedCodes) + Math.max(0, uniqueSortedCodes.indexOf(code)) ), }} /> diff --git a/src/renderer/components/CleanComponent/LiveErpPane.tsx b/src/renderer/components/CleanComponent/LiveErpPane.tsx index b336d5a2..8845300f 100644 --- a/src/renderer/components/CleanComponent/LiveErpPane.tsx +++ b/src/renderer/components/CleanComponent/LiveErpPane.tsx @@ -1,7 +1,7 @@ import React, { useEffect, useMemo, useRef, useState } from 'react'; import type { EpochArraysMeta } from '../../actions'; import { cssColorForIndex } from '../../utils/eeg/conditionPalette'; -import { conditionIndexForCode, meanTrace } from './epochArrays'; +import { meanTrace } from './epochArrays'; // --------------------------------------------------------------------------- // Live ERP pane — the "watch it clean up" payoff. Averages the INCLUDED epochs @@ -47,21 +47,18 @@ export default function LiveErpPane({ // Included epoch indices grouped by condition code. Recomputed whenever the // dataset or the rejected set changes. - const { groups, uniqueSortedCodes, includedCount } = useMemo(() => { + const { groups, uniqueSortedCodes } = useMemo(() => { if (!meta || meta.n_epochs === 0) { return { groups: new Map<number, number[]>(), uniqueSortedCodes: [] as number[], - includedCount: 0, }; } const byCode = new Map<number, number[]>(); - let included = 0; for (let i = 0; i < meta.n_epochs; i += 1) { if (rejected.has(i)) { continue; } - included += 1; const code = meta.event_codes[i]; const list = byCode.get(code); if (list) { @@ -73,10 +70,11 @@ export default function LiveErpPane({ return { groups: byCode, uniqueSortedCodes: [...new Set(meta.event_codes)].sort((a, b) => a - b), - includedCount: included, }; }, [meta, rejected]); + const includedCount = [...groups.values()].reduce((n, g) => n + g.length, 0); + useEffect(() => { const canvas = canvasRef.current; if (!canvas || !epochArrays || !meta || meta.n_epochs === 0) { @@ -154,13 +152,7 @@ export default function LiveErpPane({ // Vertical stimulus-onset line at t = 0 (where times crosses zero). if (times.length === n_times && n_times > 1) { - let zeroIdx = -1; - for (let i = 0; i < times.length; i += 1) { - if (times[i] >= 0) { - zeroIdx = i; - break; - } - } + const zeroIdx = times.findIndex((t) => t >= 0); if (zeroIdx >= 0) { const x = Math.round(toX(zeroIdx)) + 0.5; ctx.strokeStyle = 'rgba(0, 0, 0, 0.25)'; @@ -177,7 +169,7 @@ export default function LiveErpPane({ ctx.lineWidth = 2; for (const { code, series } of traces) { ctx.strokeStyle = cssColorForIndex( - conditionIndexForCode(code, uniqueSortedCodes) + Math.max(0, uniqueSortedCodes.indexOf(code)) ); ctx.beginPath(); for (let i = 0; i < series.length; i += 1) { @@ -248,7 +240,7 @@ export default function LiveErpPane({ className="inline-block h-3 w-3 rounded-sm" style={{ backgroundColor: cssColorForIndex( - conditionIndexForCode(code, uniqueSortedCodes) + Math.max(0, uniqueSortedCodes.indexOf(code)) ), }} /> diff --git a/src/renderer/components/CleanComponent/__tests__/EpochReviewer.test.tsx b/src/renderer/components/CleanComponent/__tests__/EpochReviewer.test.tsx index 301f9445..e4abb5ab 100644 --- a/src/renderer/components/CleanComponent/__tests__/EpochReviewer.test.tsx +++ b/src/renderer/components/CleanComponent/__tests__/EpochReviewer.test.tsx @@ -27,10 +27,8 @@ function makeEpochArrays(): { buffer: ArrayBuffer; meta: EpochArraysMeta } { n_channels: nChannels, n_times: nTimes, ch_names: ['Fp1', 'Fp2'], - sfreq: 256, times: [-0.1, 0, 0.1, 0.2], event_codes: [1, 2, 1], - drop_log: [[], [], []], }; return { buffer: data.buffer, meta }; } diff --git a/src/renderer/components/CleanComponent/__tests__/epochArrays.test.ts b/src/renderer/components/CleanComponent/__tests__/epochArrays.test.ts index 38e2ef3b..f611ba99 100644 --- a/src/renderer/components/CleanComponent/__tests__/epochArrays.test.ts +++ b/src/renderer/components/CleanComponent/__tests__/epochArrays.test.ts @@ -7,7 +7,6 @@ import { describe, it, expect } from 'vitest'; import type { EpochArraysMeta } from '../../../actions'; import { - conditionIndexForCode, downsampleMinMax, epochChannelSeries, meanTrace, @@ -18,10 +17,8 @@ const makeMeta = (over: Partial<EpochArraysMeta> = {}): EpochArraysMeta => ({ n_channels: 2, n_times: 3, ch_names: ['A', 'B'], - sfreq: 100, times: [0, 0.01, 0.02], event_codes: [1, 2], - drop_log: [[], []], ...over, }); @@ -78,21 +75,6 @@ describe('downsampleMinMax', () => { expect(out.length).toBeLessThanOrEqual(2); }); - it('emits one [v, v] per sample when width >= length', () => { - const series = [3, 7, 5]; - expect(downsampleMinMax(series, 10)).toEqual([ - [3, 3], - [7, 7], - [5, 5], - ]); - // Exactly-equal width also yields per-sample pairs. - expect(downsampleMinMax(series, 3)).toEqual([ - [3, 3], - [7, 7], - [5, 5], - ]); - }); - it('returns [] for an empty series', () => { expect(downsampleMinMax([], 5)).toEqual([]); }); @@ -111,19 +93,6 @@ describe('downsampleMinMax', () => { }); }); -describe('conditionIndexForCode', () => { - it('maps codes to their position in the sorted-unique list', () => { - const codes = [1, 2, 5]; - expect(conditionIndexForCode(1, codes)).toBe(0); - expect(conditionIndexForCode(2, codes)).toBe(1); - expect(conditionIndexForCode(5, codes)).toBe(2); - }); - - it('returns 0 for an unknown code', () => { - expect(conditionIndexForCode(99, [1, 2, 5])).toBe(0); - }); -}); - describe('meanTrace', () => { // n_epochs=3, n_channels=2, n_times=2 in C-order [epoch][channel][time]. // channel 0 across epochs = [2,4], [4,8], [6,12] -> mean over all 3 = [4, 8]. @@ -135,7 +104,6 @@ describe('meanTrace', () => { n_times: 2, times: [0, 0.01], event_codes: [1, 2, 1], - drop_log: [[], [], []], }); const data = Float32Array.from([ 2, diff --git a/src/renderer/components/CleanComponent/epochArrays.ts b/src/renderer/components/CleanComponent/epochArrays.ts index 4e163c5b..6606c703 100644 --- a/src/renderer/components/CleanComponent/epochArrays.ts +++ b/src/renderer/components/CleanComponent/epochArrays.ts @@ -14,9 +14,9 @@ export function epochChannelSeries( return floats.subarray(start, start + meta.n_times); } -// Downsample to at most `width` columns, preserving spikes by keeping the min -// AND max within each column (so a sharp transient is never averaged away). -// Returns [min, max] per column. width >= length => one [v, v] per sample. +// Downsample to `width` columns, preserving spikes by keeping the min AND max +// within each column (so a sharp transient is never averaged away). Returns +// [min, max] per column. Callers only downsample, i.e. width < series length. export function downsampleMinMax( series: ArrayLike<number>, width: number @@ -26,17 +26,6 @@ export function downsampleMinMax( return []; } - // When we have at least as many columns as samples, there is nothing to - // compress: emit one [v, v] per sample. - if (width >= length) { - const out: Array<[number, number]> = new Array(length); - for (let i = 0; i < length; i += 1) { - const v = series[i]; - out[i] = [v, v]; - } - return out; - } - // Split the series into `width` contiguous buckets. Bucket boundaries are // computed with floating math so samples distribute as evenly as possible, // and every bucket is non-empty (guaranteed because width < length). @@ -60,22 +49,11 @@ export function downsampleMinMax( return out; } -// Index into CONDITION_PALETTE by mapping an event code to its position in the -// sorted unique code list (stable, deterministic coloring per condition). -export function conditionIndexForCode( - code: number, - uniqueSortedCodes: number[] -): number { - const i = uniqueSortedCodes.indexOf(code); - return i < 0 ? 0 : i; -} - // Mean waveform for one channel, averaged over the given epoch indices — the // client-side ERP primitive for the live preview (recomputed on every reject // toggle; equivalent to np.mean over those epochs for that channel). The epochs // are already baseline-corrected + filtered at epoching time, so a plain mean -// matches MNE's evoked average to float32 display precision. Empty selection -// returns a zero-filled trace (length n_times) so the pane renders a flat line. +// matches MNE's evoked average to float32 display precision. export function meanTrace( buffer: ArrayBuffer, meta: EpochArraysMeta, @@ -83,9 +61,6 @@ export function meanTrace( channel: number ): Float32Array { const out = new Float32Array(meta.n_times); - if (epochIndices.length === 0) { - return out; - } for (const e of epochIndices) { const series = epochChannelSeries(buffer, meta, e, channel); for (let t = 0; t < meta.n_times; t += 1) { diff --git a/src/renderer/components/CleanComponent/index.tsx b/src/renderer/components/CleanComponent/index.tsx index c7dad115..f9e9be66 100644 --- a/src/renderer/components/CleanComponent/index.tsx +++ b/src/renderer/components/CleanComponent/index.tsx @@ -3,17 +3,10 @@ import path from 'pathe'; import { Link } from 'react-router-dom'; import { isNil, isString, memoize } from 'lodash'; import { Button } from '../ui/button'; -import { - Dialog, - DialogContent, - DialogHeader, - DialogTitle, - DialogDescription, -} from '../ui/dialog'; import { EXPERIMENTS, DEVICES, - getPtpThresholdPreset, + PTP_THRESHOLD, } from '../../constants/constants'; import { ExperimentParameters } from '../../constants/interfaces'; import { buildMarkerRegistry } from '../../utils/eeg/markerRegistry'; @@ -73,7 +66,6 @@ interface State { // Whether the auto-flag threshold settings panel is open. showAutoFlagSettings: boolean; // Whether the "too many bad channels" warning dialog is open. - showChannelWarning: boolean; } export default class Clean extends Component<Props, State> { @@ -90,9 +82,8 @@ export default class Clean extends Component<Props, State> { isSidebarVisible: false, rejectedEpochs: new Set(), badChannels: new Set(), - autoFlagThreshold: getPtpThresholdPreset(props.deviceType).default, + autoFlagThreshold: PTP_THRESHOLD.default, showAutoFlagSettings: false, - showChannelWarning: false, }; this.handleRecordingChange = this.handleRecordingChange.bind(this); this.handleLoadData = this.handleLoadData.bind(this); @@ -183,23 +174,26 @@ export default class Clean extends Component<Props, State> { } handleToggleChannel(name: string) { - this.setState((prev) => { - const next = new Set(prev.badChannels); - const adding = !next.has(name); - if (adding) { - next.add(name); - } else { - next.delete(name); - } - const nCh = this.props.epochArrays?.meta.n_channels ?? 0; - // Dropping >1 of a 4-channel (Muse) recording loses a lot of signal — - // warn (informational; they can still proceed). - const warn = adding && next.size > 1 && nCh === 4; - return { - badChannels: next, - showChannelWarning: warn || prev.showChannelWarning, - }; - }); + const next = new Set(this.state.badChannels); + const adding = !next.has(name); + if (adding) { + next.add(name); + } else { + next.delete(name); + } + this.setState({ badChannels: next }); + + // Dropping >1 of a 4-channel (Muse) recording loses a lot of signal — + // informational only; they can still proceed. + if (adding && next.size > 1 && this.props.epochArrays?.meta.n_channels === 4) { + window.electronAPI.showMessageBox({ + buttons: ['Got it'], + message: + "You've marked more than one bad channel on a 4-channel recording. " + + 'That removes a big chunk of your data — if the signal is really this ' + + 'noisy, consider collecting another dataset.', + }); + } } handleAutoFlag() { @@ -325,7 +319,6 @@ export default class Clean extends Component<Props, State> { renderReview( codeToLabel: Record<number, string>, - showAutoFlag: boolean, suggestedRejections: SuggestedRejection[] ) { const hasEpochs = !isNil(this.props.epochArrays); @@ -354,69 +347,60 @@ export default class Clean extends Component<Props, State> { > Clean Data </Button> - {showAutoFlag && ( - <> - <Button - variant="secondary" - disabled={isNil(this.props.epochsInfo)} - onClick={this.handleAutoFlag} - > - Auto-flag artifacts - </Button> - <Button - variant="ghost" - size="icon" - aria-label="Auto-flag settings" - onClick={() => - this.setState((prev) => ({ - showAutoFlagSettings: !prev.showAutoFlagSettings, - })) - } - > - ⚙︎ - </Button> - </> - )} + <Button + variant="secondary" + disabled={isNil(this.props.epochsInfo)} + onClick={this.handleAutoFlag} + > + Auto-flag artifacts + </Button> + <Button + variant="ghost" + size="icon" + aria-label="Auto-flag settings" + onClick={() => + this.setState((prev) => ({ + showAutoFlagSettings: !prev.showAutoFlagSettings, + })) + } + > + ⚙︎ + </Button> <div className="ml-auto">{this.renderAnalyzeButton()}</div> </div> - {showAutoFlag && - this.state.showAutoFlagSettings && - (() => { - const preset = getPtpThresholdPreset(this.props.deviceType); - return ( - <div className="mb-3 text-left"> - <label - className="text-sm font-medium block" - htmlFor="autoflag-sensitivity" - > - Auto-flag threshold - </label> - <div className="flex items-center gap-2 mt-1"> - <span className="text-xs text-gray-500">More flags</span> - <input - id="autoflag-sensitivity" - type="range" - min={preset.min} - max={preset.max} - step={preset.step} - value={this.state.autoFlagThreshold} - aria-valuetext={`${this.state.autoFlagThreshold} µV peak-to-peak`} - onChange={this.handleThresholdChange} - className="flex-1" - /> - <span className="text-xs text-gray-500">Fewer flags</span> - </div> - <p className="text-xs text-gray-500 mt-1"> - Flag epochs whose peak-to-peak amplitude exceeds{' '} - <span className="font-medium"> - {this.state.autoFlagThreshold} µV - </span> - . - </p> - </div> - ); - })()} + {this.state.showAutoFlagSettings && ( + <div className="mb-3 text-left"> + <label + className="text-sm font-medium block" + htmlFor="autoflag-sensitivity" + > + Auto-flag threshold + </label> + <div className="flex items-center gap-2 mt-1"> + <span className="text-xs text-gray-500">More flags</span> + <input + id="autoflag-sensitivity" + type="range" + min={PTP_THRESHOLD.min} + max={PTP_THRESHOLD.max} + step={PTP_THRESHOLD.step} + value={this.state.autoFlagThreshold} + aria-valuetext={`${this.state.autoFlagThreshold} µV peak-to-peak`} + onChange={this.handleThresholdChange} + className="flex-1" + /> + <span className="text-xs text-gray-500">Fewer flags</span> + </div> + <p className="text-xs text-gray-500 mt-1"> + Flag epochs whose peak-to-peak amplitude exceeds{' '} + <span className="font-medium"> + {this.state.autoFlagThreshold} µV + </span> + . + </p> + </div> + )} {suggestedRejections.length > 0 && ( <div className="mb-3 text-left text-sm text-brand"> <p className="font-medium"> @@ -468,7 +452,6 @@ export default class Clean extends Component<Props, State> { }); const codeToLabel = codeToLabelFor(this.props.params?.stimuli); - const showAutoFlag = !this.props.params?.hideAutoFlagEpochs; const { suggestedRejections } = this.props; return ( @@ -481,31 +464,8 @@ export default class Clean extends Component<Props, State> { <div className="flex-1 p-[3%] overflow-y-auto"> {this.state.view === 'select' ? this.renderSelect(filteredFilePaths) - : this.renderReview(codeToLabel, showAutoFlag, suggestedRejections)} + : this.renderReview(codeToLabel, suggestedRejections)} </div> - <Dialog - open={this.state.showChannelWarning} - onOpenChange={(o) => this.setState({ showChannelWarning: o })} - > - <DialogContent> - <DialogHeader> - <DialogTitle>That's a lot of channels to drop</DialogTitle> - <DialogDescription> - You've marked more than one bad channel on a 4-channel - recording. That removes a big chunk of your data — if the signal - is really this noisy, consider collecting another dataset. - </DialogDescription> - </DialogHeader> - <div className="mt-4 flex justify-end"> - <Button - variant="default" - onClick={() => this.setState({ showChannelWarning: false })} - > - Got it - </Button> - </div> - </DialogContent> - </Dialog> </div> ); } diff --git a/src/renderer/components/EEGExplorationComponent.tsx b/src/renderer/components/EEGExplorationComponent.tsx index 89f0a15a..10bebb47 100644 --- a/src/renderer/components/EEGExplorationComponent.tsx +++ b/src/renderer/components/EEGExplorationComponent.tsx @@ -30,54 +30,29 @@ interface Props { interface State { isConnectModalOpen: boolean; isHelpVisible: boolean; - showSettlingBanner: boolean; } -// How long after connecting we reassure the user that a red/jumpy signal is -// just the sensors settling into contact (no change to the quality math). -const SETTLING_WINDOW_MS = 45_000; - export default class Home extends Component<Props, State> { - private settleTimer: ReturnType<typeof setTimeout> | null = null; - constructor(props: Props) { super(props); this.state = { isConnectModalOpen: false, isHelpVisible: false, - showSettlingBanner: false, }; this.handleConnectModalClose = this.handleConnectModalClose.bind(this); this.handleStartConnect = this.handleStartConnect.bind(this); this.handleStopConnect = this.handleStopConnect.bind(this); } - componentDidUpdate = (prevProps: Props, prevState: State) => { + componentDidUpdate = (_prevProps: Props, prevState: State) => { if ( this.props.connectionStatus === CONNECTION_STATUS.CONNECTED && prevState.isConnectModalOpen ) { this.setState({ isConnectModalOpen: false }); } - // On the transition into CONNECTED, show the settling reassurance and - // auto-dismiss it after the window. - if ( - prevProps.connectionStatus !== CONNECTION_STATUS.CONNECTED && - this.props.connectionStatus === CONNECTION_STATUS.CONNECTED - ) { - this.setState({ showSettlingBanner: true }); - if (this.settleTimer) clearTimeout(this.settleTimer); - this.settleTimer = setTimeout( - () => this.setState({ showSettlingBanner: false }), - SETTLING_WINDOW_MS - ); - } }; - componentWillUnmount() { - if (this.settleTimer) clearTimeout(this.settleTimer); - } - handleStartConnect() { this.setState({ isConnectModalOpen: true }); this.props.DeviceActions.SetDeviceAvailability( @@ -100,29 +75,8 @@ export default class Home extends Component<Props, State> { <div className="flex items-center h-[90%]"> {this.props.connectionStatus === CONNECTION_STATUS.CONNECTED && this.props.signalQualityObservable && ( - <div className="w-full"> - {this.state.showSettlingBanner && ( - <div - role="status" - aria-live="polite" - className="mb-3 flex items-center justify-between gap-3 rounded border border-amber-300 bg-amber-50 px-4 py-2 text-sm text-amber-900" - > - <span> - Sensors settling — a red, jumpy signal is normal for the - first minute while they make contact. Sit still and watch it - turn green. - </span> - <button - aria-label="Dismiss" - className="shrink-0 font-bold" - onClick={() => this.setState({ showSettlingBanner: false })} - > - ✕ - </button> - </div> - )} - <div className="flex w-full"> - <div className="w-2/5"> + <div className="flex w-full"> + <div className="w-2/5"> <SignalQualityIndicatorComponent signalQualityObservable={this.props.signalQualityObservable} plottingInterval={PLOTTING_INTERVAL} @@ -140,7 +94,6 @@ export default class Home extends Component<Props, State> { plottingInterval={PLOTTING_INTERVAL} /> </div> - </div> </div> )} {this.props.connectionStatus !== CONNECTION_STATUS.CONNECTED && ( diff --git a/src/renderer/constants/constants.ts b/src/renderer/constants/constants.ts index fe0ba1c0..a697f4a8 100644 --- a/src/renderer/constants/constants.ts +++ b/src/renderer/constants/constants.ts @@ -9,30 +9,18 @@ export enum EXPERIMENTS { // SSVEP = 'Steady-state Visual Evoked Potential', } -export const DEFAULT_PTP_THRESHOLD_UV = 100; - /** * Slider bounds for the Clean auto-flag peak-to-peak threshold, in microvolts. * Students set sensitivity on a slider instead of typing a raw µV number. The * range is deliberately generous around real Muse ptp (tens of µV; artifacts - * well over 100). Per-device tuning plugs in via getPtpThresholdPreset — today - * every device shares one validated range (no fabricated per-hardware ceilings). + * well over 100), and is shared by every device. */ -export interface PtpThresholdPreset { - min: number; - max: number; - step: number; - default: number; -} - -export const getPtpThresholdPreset = ( - _device: DEVICES -): PtpThresholdPreset => ({ +export const PTP_THRESHOLD = { min: 20, max: 400, step: 10, - default: DEFAULT_PTP_THRESHOLD_UV, -}); + default: 100, +}; export const SCREENS = { HOME: { route: '/', title: 'HOME', order: 0 }, diff --git a/src/renderer/constants/interfaces.ts b/src/renderer/constants/interfaces.ts index 7ce9b7da..a64bfa9c 100644 --- a/src/renderer/constants/interfaces.ts +++ b/src/renderer/constants/interfaces.ts @@ -31,7 +31,6 @@ export type ExperimentParameters = { sampleType: string; selfPaced?: boolean; showProgressBar: boolean; - hideAutoFlagEpochs?: boolean; stimuli?: Stimulus[]; taskHelp?: string; trialDuration: number; diff --git a/src/renderer/epics/pyodideEpics.ts b/src/renderer/epics/pyodideEpics.ts index 5e8699ef..afbdfd40 100644 --- a/src/renderer/epics/pyodideEpics.ts +++ b/src/renderer/epics/pyodideEpics.ts @@ -235,9 +235,9 @@ const cleanEpochsEpic: Epic<PyodideActionType, PyodideActionType, RootState> = ( action$.pipe( filter(isActionOf(PyodideActions.CleanEpochs)), pluck('payload'), - mergeMap(async ({ dropIndices, badChannels }) => { + // Worker runs these FIFO: drop/flag -> save cleaned .fif -> re-fetch arrays. + tap(({ dropIndices, badChannels }) => { const worker = state$.value.pyodide.worker!; - // Worker runs these FIFO: drop/flag -> save cleaned .fif -> re-fetch arrays. applyRejection( worker, PYODIDE_VARIABLE_NAMES.RAW_EPOCHS, @@ -246,9 +246,10 @@ const cleanEpochsEpic: Epic<PyodideActionType, PyodideActionType, RootState> = ( ); saveEpochs(worker, state$.value.experiment.subject); requestEpochArrays(worker, PYODIDE_VARIABLE_NAMES.RAW_EPOCHS); - return PYODIDE_VARIABLE_NAMES.RAW_EPOCHS; }), - map((varName) => PyodideActions.GetEpochsInfo(varName)) + map(() => + PyodideActions.GetEpochsInfo(PYODIDE_VARIABLE_NAMES.RAW_EPOCHS) + ) ); const getEpochsInfoEpic: Epic< diff --git a/src/renderer/reducers/__tests__/experimentReducer.test.ts b/src/renderer/reducers/__tests__/experimentReducer.test.ts deleted file mode 100644 index 40efe63c..00000000 --- a/src/renderer/reducers/__tests__/experimentReducer.test.ts +++ /dev/null @@ -1,9 +0,0 @@ -import { describe, it, expect } from 'vitest'; -import experimentReducer from '../experimentReducer'; - -describe('experimentReducer', () => { - it('defaults isEEGEnabled to true — EEG-on is the app default, opt out for behavior-only', () => { - const state = experimentReducer(undefined, { type: '@@INIT' }); - expect(state.isEEGEnabled).toBe(true); - }); -}); diff --git a/src/renderer/utils/eeg/conditionPalette.ts b/src/renderer/utils/eeg/conditionPalette.ts index 16e5a832..64e9800b 100644 --- a/src/renderer/utils/eeg/conditionPalette.ts +++ b/src/renderer/utils/eeg/conditionPalette.ts @@ -17,9 +17,6 @@ export const CONDITION_PALETTE_RGB: ReadonlyArray< // canvas renderer to color epoch traces by their condition. export function cssColorForIndex(index: number): string { const [r, g, b] = - CONDITION_PALETTE_RGB[ - ((index % CONDITION_PALETTE_RGB.length) + CONDITION_PALETTE_RGB.length) % - CONDITION_PALETTE_RGB.length - ]; + CONDITION_PALETTE_RGB[index % CONDITION_PALETTE_RGB.length]; return `rgb(${Math.round(r * 255)}, ${Math.round(g * 255)}, ${Math.round(b * 255)})`; } diff --git a/src/renderer/utils/webworker/utils.py b/src/renderer/utils/webworker/utils.py index ab69c3af..b6ce0c30 100644 --- a/src/renderer/utils/webworker/utils.py +++ b/src/renderer/utils/webworker/utils.py @@ -14,16 +14,6 @@ # sns.set_context('talk') # sns.set_style('white') -# Condition color fallback used only when the caller does NOT inject a palette -# (in-app, the worker always injects it from the canonical source -# src/renderer/utils/eeg/conditionPalette.ts — keep these values in sync with -# that file). RGB components are 0..1 floats (matplotlib convention). -_DEFAULT_CONDITION_PALETTE = [ - (0.86, 0.37, 0.34), - (0.34, 0.86, 0.37), - (0.37, 0.34, 0.86), - (0.86, 0.72, 0.34), -] def load_data(sfreq=128., replace_ch_names=None, csv_strings=None): @@ -148,19 +138,15 @@ def load_clean_epochs(file_paths): """Load cleaned .fif epoch files from MEMFS paths into one Epochs object. Analyze stages host .fif bytes at /tmp/... in the worker MEMFS before calling - this. A single file is returned as-is; multiple recordings are concatenated. + this. """ - epochs_list = [ - read_epochs(path, preload=True, verbose=False) for path in file_paths - ] - if len(epochs_list) == 1: - return epochs_list[0] - return concatenate_epochs(epochs_list, verbose=False) + return concatenate_epochs( + [read_epochs(path, preload=True, verbose=False) for path in file_paths], + verbose=False, + ) -def plot_topo(epochs, conditions=OrderedDict(), palette=None): - if palette is None: - palette = _DEFAULT_CONDITION_PALETTE +def plot_topo(epochs, conditions, palette): evokeds = [epochs[name].average() for name in (conditions)] evoked_topo = viz.plot_evoked_topo( @@ -181,8 +167,8 @@ def plot_topo(epochs, conditions=OrderedDict(), palette=None): return evoked_topo -def plot_conditions(epochs, ch_ind=0, conditions=OrderedDict(), ci=97.5, - n_boot=1000, title='', palette=None, diff_waveform=(4, 3)): +def plot_conditions(epochs, palette, ch_ind=0, conditions=OrderedDict(), + ci=97.5, n_boot=1000, title='', diff_waveform=(4, 3)): """Plot Averaged Epochs with ERP conditions. Parameters @@ -227,8 +213,6 @@ def plot_conditions(epochs, ch_ind=0, conditions=OrderedDict(), ci=97.5, if isinstance(conditions, dict): conditions = OrderedDict(conditions) - if palette is None: - palette = _DEFAULT_CONDITION_PALETTE # get_data() is volts (load_data scales eeg uV -> V); the y-axis is labeled # uV, so convert back to microvolts for display. @@ -323,10 +307,8 @@ def get_epochs_arrays(epochs, out_path): "n_channels": int(n_channels), "n_times": int(n_times), "ch_names": ch_names, - "sfreq": float(epochs.info["sfreq"]), "times": epochs.times.tolist(), "event_codes": epochs.events[:, -1].tolist(), - "drop_log": [list(x) for x in epochs.drop_log], } @@ -366,11 +348,10 @@ def suggest_rejections(epochs, threshold_uv): pre-marks these but the user can override; the real drop goes through apply_rejection so the saved data stays MNE-exact. - Returns list[dict] with keys: index (int), channel (str), peak_uv (float, - rounded 1dp), reason (str). MNE data is in volts; threshold_uv is microvolts. + Returns list[dict] with keys: index (int, 0-based into the CURRENT epochs, + same order get_epochs_arrays produced) and reason (str). MNE data is in + volts; threshold_uv is microvolts. """ - # NOTE: peak_uv is in microvolts; index is 0-based into the CURRENT epochs - # (same order as get_epochs_arrays produced). picks = pick_types(epochs.info, eeg=True) data = epochs.get_data(picks=picks) # (n_epochs, n_channels, n_times), volts ch_names = [epochs.ch_names[i] for i in picks] @@ -381,11 +362,8 @@ def suggest_rejections(epochs, threshold_uv): worst = int(ptp[e].argmax()) peak_v = float(ptp[e, worst]) if peak_v > thresh_v: - peak_uv = round(peak_v * 1e6, 1) suggestions.append({ "index": int(e), - "channel": ch_names[worst], - "peak_uv": peak_uv, "reason": "peak-to-peak {}µV on {}".format( round(peak_v * 1e6), ch_names[worst] ), diff --git a/src/renderer/utils/webworker/webworker.js b/src/renderer/utils/webworker/webworker.js index b30349d9..0e5ad98a 100644 --- a/src/renderer/utils/webworker/webworker.js +++ b/src/renderer/utils/webworker/webworker.js @@ -134,12 +134,9 @@ async function processMessage(event) { // can be written to host disk. Pyodide's MEMFS can't reach the host FS itself. if (readFileAfter) { const fileBytes = pyodide.FS.readFile(readFileAfter); // Uint8Array - const payload = { buffer: fileBytes.buffer, plotKey, dataKey }; - // epochArrays needs metadata alongside the buffer; savedEpochs only needs bytes. - if (dataKey === 'epochArrays') { - payload.results = results; - } - self.postMessage(payload, [fileBytes.buffer]); + self.postMessage({ buffer: fileBytes.buffer, results, plotKey, dataKey }, [ + fileBytes.buffer, + ]); return; } // Fire-and-forget commands (no plotKey/dataKey/readFileAfter) must not post diff --git a/src/shared/pyodideAssets.ts b/src/shared/pyodideAssets.ts new file mode 100644 index 00000000..8043f59c --- /dev/null +++ b/src/shared/pyodideAssets.ts @@ -0,0 +1,13 @@ +/** + * Where the Pyodide payload lives, in dev and in a packaged build. + * + * These must agree with `build.extraResources` in package.json (JSON can't + * import, so that pairing is asserted in tests/build.check.ts). A mismatch is + * invisible in dev and breaks analysis completely in prod — it shipped once. + */ + +/** Source tree location; also `extraResources.from`. */ +export const PYODIDE_SOURCE_DIR = 'src/renderer/utils/webworker/src'; + +/** Folder under `process.resourcesPath` in a packaged app; also `extraResources.to`. */ +export const PYODIDE_RESOURCE_DIR = 'pyodide'; diff --git a/tests/analysis/test_suggest_rejections.py b/tests/analysis/test_suggest_rejections.py index 63e5b7ae..f0333e81 100644 --- a/tests/analysis/test_suggest_rejections.py +++ b/tests/analysis/test_suggest_rejections.py @@ -5,6 +5,8 @@ never drops anything (the real drop goes through `apply_rejection`) — so these tests only check which epochs it *suggests*, and the shape of each suggestion. """ +from mne import pick_types + import utils # src/renderer/utils/webworker/utils.py (see conftest) from synthetic import ( generate_recording, @@ -34,13 +36,10 @@ def test_threshold_gating(): suggestions = utils.suggest_rejections(epochs, 0) assert len(suggestions) == len(epochs) for s in suggestions: - assert set(s.keys()) == {"index", "channel", "peak_uv", "reason"} + assert set(s.keys()) == {"index", "reason"} assert isinstance(s["index"], int) assert 0 <= s["index"] < len(epochs) - assert s["channel"] in epochs.ch_names - assert isinstance(s["peak_uv"], float) - assert s["peak_uv"] > 0 - assert isinstance(s["reason"], str) + assert isinstance(s["reason"], str) and s["reason"] def test_detects_injected_artifact(): @@ -60,8 +59,6 @@ def test_detects_injected_artifact(): indices = [s["index"] for s in suggestions] assert k in indices - flagged = next(s for s in suggestions if s["index"] == k) - assert flagged["peak_uv"] > 1000 def test_ptp_in_physiological_range(): @@ -73,8 +70,9 @@ def test_ptp_in_physiological_range(): physiological band so that regression can't return silently. """ epochs = _build_epochs() - # threshold 0 → one suggestion per epoch, each carrying its worst ptp. - peaks = [s["peak_uv"] for s in utils.suggest_rejections(epochs, 0)] + # Worst-channel peak-to-peak per epoch, in µV (MNE data is volts). + data = epochs.get_data(picks=pick_types(epochs.info, eeg=True)) + peaks = ((data.max(axis=2) - data.min(axis=2)).max(axis=1) * 1e6).tolist() assert peaks, "expected at least one epoch" # Synthetic noise is a few µV; real Muse is tens. A correct scale keeps # every epoch well under 1000 µV — the units bug put these at ~1e7. @@ -82,6 +80,8 @@ def test_ptp_in_physiological_range(): def test_marker_channel_excluded(): + # The Marker/stim channel carries event codes, not EEG; if it were picked + # its ptp would dominate and it would be named in every reason string. epochs = _build_epochs() suggestions = utils.suggest_rejections(epochs, 0) - assert all(s["channel"] != "Marker" for s in suggestions) + assert all("Marker" not in s["reason"] for s in suggestions) diff --git a/tests/build.test.ts b/tests/build.check.ts similarity index 59% rename from tests/build.test.ts rename to tests/build.check.ts index 030bd7df..8f57184d 100644 --- a/tests/build.test.ts +++ b/tests/build.check.ts @@ -1,34 +1,29 @@ import fs from 'fs'; import path from 'path'; import { describe, it, expect } from 'vitest'; +import { + PYODIDE_SOURCE_DIR, + PYODIDE_RESOURCE_DIR, +} from '../src/shared/pyodideAssets'; /** - * Post-build artifact checks. Runs against `out/` and the postinstall-downloaded - * Pyodide payload, so it requires `npm run build` (and a full `npm install`) - * first — see the `test:build` script and the CI step ordering. + * Post-build checks: the bundles and the Pyodide payload are actually on disk + * where the packaged app will look for them. Requires `npm run build` and a + * full `npm install` — run via `npm run test:build`. * - * Purpose: catch the prod-only failure class documented in .llms/learnings.md, - * where the app builds and the unit suite passes but the packaged renderer - * cannot load Pyodide because an asset is missing from where the protocol - * handler looks. Path *spelling* is covered statically by pyodideAssets.test.ts; - * this file covers actual presence on disk. + * Named .check.ts, not .test.ts, so the default vitest glob skips it: running + * these without a build should be a loud ordering error, not a silent skip. */ // process.cwd(), not __dirname — test files are ESM, where __dirname is undefined. const repoRoot = process.cwd(); const out = (...p: string[]) => path.join(repoRoot, 'out', ...p); +const webworkerSrc = path.join(repoRoot, PYODIDE_SOURCE_DIR); const packageJson = JSON.parse( fs.readFileSync(path.join(repoRoot, 'package.json'), 'utf8') ); -const webworkerSrc = path.join( - repoRoot, - ( - packageJson.build.extraResources as Array<{ from: string; to: string }> - ).find((entry) => entry.from.includes('webworker'))!.from -); - describe('electron-vite build output', () => { it.each([ ['main', out('main/index.js')], @@ -37,18 +32,27 @@ describe('electron-vite build output', () => { ])('emits the %s bundle', (_label, filePath) => { expect( fs.existsSync(filePath), - `${filePath} missing — run \`npm run build\` before \`npm run test:build\`` + `${filePath} missing — run \`npm run build\` first` ).toBe(true); }); +}); - it('main entry point matches package.json "main"', () => { - expect(fs.existsSync(path.join(repoRoot, packageJson.main))).toBe(true); +describe('Pyodide payload', () => { + // package.json is JSON and cannot import the shared constants, so this is the + // one place that pairing gets checked. + it('extraResources matches the shared path constants', () => { + const entry = ( + packageJson.build.extraResources as Array<{ from: string; to: string }> + ).find((e) => e.from.includes('webworker')); + const normalize = (p: string) => p.replace(/^\.\//, '').replace(/\/$/, ''); + + expect(entry).toBeDefined(); + expect(normalize(entry!.from)).toBe(PYODIDE_SOURCE_DIR); + expect(normalize(entry!.to)).toBe(PYODIDE_RESOURCE_DIR); }); -}); -describe('Pyodide payload (postinstall)', () => { - // Required at runtime in prod: `indexURL` resolves the interpreter and stdlib - // through the pyodide:// handler, so a missing one breaks analysis silently. + // Required at runtime: `indexURL` resolves the interpreter and stdlib through + // the pyodide:// handler, so a missing one breaks analysis silently. it.each([ 'pyodide.mjs', 'pyodide.asm.js', @@ -78,6 +82,7 @@ describe('Pyodide payload (postinstall)', () => { // micropip.install() is driven entirely off this manifest; a listed-but- // absent wheel fails at MNE import time, deep inside the worker. expect(entries.length).toBeGreaterThan(0); + expect(Object.keys(manifest)).toContain('mne'); const missing = entries .filter( @@ -87,11 +92,4 @@ describe('Pyodide payload (postinstall)', () => { expect(missing, 'manifest lists wheels that are not on disk').toEqual([]); }); - - it('includes mne, the package the whole analysis path depends on', () => { - const manifest = JSON.parse( - fs.readFileSync(path.join(webworkerSrc, 'packages/manifest.json'), 'utf8') - ); - expect(Object.keys(manifest)).toContain('mne'); - }); }); diff --git a/tests/pyodideAssets.test.ts b/tests/pyodideAssets.test.ts deleted file mode 100644 index 5162115c..00000000 --- a/tests/pyodideAssets.test.ts +++ /dev/null @@ -1,98 +0,0 @@ -import fs from 'fs'; -import path from 'path'; -import { describe, it, expect } from 'vitest'; - -/** - * Guards the contract between where electron-builder *puts* the Pyodide payload - * and where the main process *looks* for it. - * - * These two live in different files with no shared constant, and a mismatch is - * invisible in dev (which reads from the source tree) while breaking prod - * completely — the renderer's web worker gets 404s for every wheel and analysis - * never starts. That exact mismatch shipped once; see the "Pyodide Asset Serving" - * entry in .llms/learnings.md. - * - * Static source checks, so they need no build and run in the default suite. - */ - -// process.cwd(), not __dirname — test files are ESM, where __dirname is undefined. -const repoRoot = process.cwd(); - -const packageJson = JSON.parse( - fs.readFileSync(path.join(repoRoot, 'package.json'), 'utf8') -); -const mainSource = fs.readFileSync( - path.join(repoRoot, 'src/main/index.ts'), - 'utf8' -); - -/** The `const pyodideRoot = is.dev ? ... : ...;` statement in src/main/index.ts. */ -const pyodideRootDecl = mainSource.match(/const pyodideRoot =[\s\S]*?;/)?.[0]; - -/** Strip leading `./` and trailing `/` so path spellings compare equal. */ -const normalize = (p: string) => p.replace(/^\.\//, '').replace(/\/$/, ''); - -describe('Pyodide asset path contract (package.json ↔ main process)', () => { - const extraResource = ( - packageJson.build.extraResources as Array<{ from: string; to: string }> - ).find((entry) => entry.from.includes('webworker')); - - it('package.json still ships the webworker payload via extraResources', () => { - expect(extraResource).toBeDefined(); - }); - - it('main process declares pyodideRoot with a dev and a prod branch', () => { - expect(pyodideRootDecl).toBeDefined(); - expect(pyodideRootDecl).toContain('is.dev'); - }); - - it('prod root matches the extraResources destination folder', () => { - // package.json copies webworker/src -> resources/<to>; the handler resolves - // path.join(process.resourcesPath, '<name>'). These must be the same name. - const prodName = pyodideRootDecl?.match( - /path\.join\(process\.resourcesPath,\s*'([^']+)'\)/ - )?.[1]; - - expect( - prodName, - 'could not find path.join(process.resourcesPath, ...) in the pyodideRoot declaration' - ).toBeDefined(); - expect(normalize(prodName as string)).toBe(normalize(extraResource!.to)); - }); - - it('dev root matches the extraResources source folder', () => { - const devPath = pyodideRootDecl?.match( - /path\.join\(app\.getAppPath\(\),\s*'([^']+)'\)/ - )?.[1]; - - expect( - devPath, - 'could not find path.join(app.getAppPath(), ...) in the pyodideRoot declaration' - ).toBeDefined(); - expect(normalize(devPath as string)).toBe(normalize(extraResource!.from)); - }); - - it('the dev root is the directory the install scripts write into', () => { - // InstallPyodide.mjs / InstallMNE.mjs are the only producers of this payload. - const installMne = fs.readFileSync( - path.join(repoRoot, 'internals/scripts/InstallMNE.mjs'), - 'utf8' - ); - const devPath = normalize( - pyodideRootDecl?.match( - /path\.join\(app\.getAppPath\(\),\s*'([^']+)'\)/ - )?.[1] as string - ); - - expect(installMne).toContain(`${devPath}/pyodide`); - expect(installMne).toContain(`${devPath}/packages`); - }); -}); - -describe('pyodide:// scheme registration', () => { - it('is registered as a privileged scheme before app ready', () => { - // Without this the worker cannot fetch over pyodide:// at all. - expect(mainSource).toContain("scheme: 'pyodide'"); - expect(mainSource).toContain("protocol.handle('pyodide'"); - }); -}); diff --git a/vitest.build.config.ts b/vitest.build.config.ts deleted file mode 100644 index 0c5ed6a1..00000000 --- a/vitest.build.config.ts +++ /dev/null @@ -1,13 +0,0 @@ -import { defineConfig } from 'vitest/config'; - -// Build-artifact checks (tests/build.test.ts) assert against `out/` and the -// postinstall-downloaded Pyodide payload, so they only mean anything after -// `npm run build`. They live in their own config — and are excluded from the -// default `npm test` run — so that running them without a build is a loud -// failure rather than a silently skipped test. -export default defineConfig({ - test: { - environment: 'node', - include: ['tests/build.test.ts'], - }, -}); diff --git a/vitest.config.ts b/vitest.config.ts index 058776b2..7dd13c15 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -1,4 +1,4 @@ -import { defineConfig, configDefaults } from 'vitest/config'; +import { defineConfig } from 'vitest/config'; import react from '@vitejs/plugin-react'; import path from 'path'; @@ -18,8 +18,6 @@ export default defineConfig({ globals: true, setupFiles: ['./src/test-setup.ts'], css: true, - // Needs a completed build; run via `npm run test:build` (vitest.build.config.ts). - exclude: [...configDefaults.exclude, 'tests/build.test.ts'], }, resolve: { alias: { From 8e597f3af22ad4faec6178409db5644d58aaf2ec Mon Sep 17 00:00:00 2001 From: Dano Morrison <danomorrison@meta.com> Date: Thu, 6 Aug 2026 18:46:12 -0700 Subject: [PATCH 27/29] Drop the meanTrace empty-selection test along with its guard CI caught this: the guard was removed but its test remained, so the empty case divided by zero and returned NaN. The sole caller (LiveErpPane) already `continue`s on empty condition groups, so both the guard and the test covered an unreachable path. Deleted the test and documented the non-empty precondition on meanTrace instead. --- .../CleanComponent/__tests__/epochArrays.test.ts | 7 ------- src/renderer/components/CleanComponent/epochArrays.ts | 4 +++- 2 files changed, 3 insertions(+), 8 deletions(-) diff --git a/src/renderer/components/CleanComponent/__tests__/epochArrays.test.ts b/src/renderer/components/CleanComponent/__tests__/epochArrays.test.ts index f611ba99..941b57cf 100644 --- a/src/renderer/components/CleanComponent/__tests__/epochArrays.test.ts +++ b/src/renderer/components/CleanComponent/__tests__/epochArrays.test.ts @@ -135,13 +135,6 @@ describe('meanTrace', () => { expect(Array.from(meanTrace(buffer, meta, [0, 1], 0))).toEqual([3, 6]); }); - it('returns a length-n_times all-zero trace for an empty selection', () => { - const { meta, buffer } = makeErpFixture(); - const out = meanTrace(buffer, meta, [], 0); - expect(out.length).toBe(meta.n_times); - expect(Array.from(out)).toEqual([0, 0]); - }); - it('returns exactly that epoch series for a single-epoch selection', () => { const { meta, buffer } = makeErpFixture(); const single = Array.from(meanTrace(buffer, meta, [1], 0)); diff --git a/src/renderer/components/CleanComponent/epochArrays.ts b/src/renderer/components/CleanComponent/epochArrays.ts index 6606c703..2dcbb44e 100644 --- a/src/renderer/components/CleanComponent/epochArrays.ts +++ b/src/renderer/components/CleanComponent/epochArrays.ts @@ -53,7 +53,9 @@ export function downsampleMinMax( // client-side ERP primitive for the live preview (recomputed on every reject // toggle; equivalent to np.mean over those epochs for that channel). The epochs // are already baseline-corrected + filtered at epoching time, so a plain mean -// matches MNE's evoked average to float32 display precision. +// matches MNE's evoked average to float32 display precision. `epochIndices` +// must be non-empty — the caller skips empty condition groups, and an empty +// selection would divide by zero. export function meanTrace( buffer: ArrayBuffer, meta: EpochArraysMeta, From c79f3f0f7086052cb6fbcd249923500f81b5de74 Mon Sep 17 00:00:00 2001 From: Dano Morrison <danomorrison@meta.com> Date: Thu, 6 Aug 2026 18:52:29 -0700 Subject: [PATCH 28/29] Fix two typecheck errors and orphaned comments from the review pass - EpochReviewer: the divider loop still referenced `count`, which the visibleCount dedupe removed. - CleanComponent: the suggestion <li> key still read s.channel, dropped from SuggestedRejection. Key is index + position now. - Remove two comments left behind by the deleted gain state and dialog state. --- src/renderer/components/CleanComponent/EpochReviewer.tsx | 3 +-- src/renderer/components/CleanComponent/index.tsx | 3 +-- 2 files changed, 2 insertions(+), 4 deletions(-) diff --git a/src/renderer/components/CleanComponent/EpochReviewer.tsx b/src/renderer/components/CleanComponent/EpochReviewer.tsx index 7ef327e5..4420919e 100644 --- a/src/renderer/components/CleanComponent/EpochReviewer.tsx +++ b/src/renderer/components/CleanComponent/EpochReviewer.tsx @@ -53,7 +53,6 @@ export default function EpochReviewer({ const canvasRef = useRef<HTMLCanvasElement | null>(null); // First epoch of the current page (absolute index). const [startEpoch, setStartEpoch] = useState(0); - // Amplitude magnification applied around each lane's mean. const meta = epochArrays?.meta ?? null; @@ -135,7 +134,7 @@ export default function EpochReviewer({ // Vertical dividers between epochs. ctx.strokeStyle = 'rgba(0, 0, 0, 0.15)'; - for (let e = 0; e <= count; e += 1) { + for (let e = 0; e <= visibleCount; e += 1) { const x = Math.round(LABEL_GUTTER + e * colWidth) + 0.5; ctx.beginPath(); ctx.moveTo(x, 0); diff --git a/src/renderer/components/CleanComponent/index.tsx b/src/renderer/components/CleanComponent/index.tsx index f9e9be66..a38c1b92 100644 --- a/src/renderer/components/CleanComponent/index.tsx +++ b/src/renderer/components/CleanComponent/index.tsx @@ -65,7 +65,6 @@ interface State { autoFlagThreshold: number; // Whether the auto-flag threshold settings panel is open. showAutoFlagSettings: boolean; - // Whether the "too many bad channels" warning dialog is open. } export default class Clean extends Component<Props, State> { @@ -409,7 +408,7 @@ export default class Clean extends Component<Props, State> { </p> <ul className="text-xs text-gray-600 list-disc list-inside"> {suggestedRejections.slice(0, 3).map((s, i) => ( - <li key={`${s.index}-${s.channel}-${i}`}>{s.reason}</li> + <li key={`${s.index}-${i}`}>{s.reason}</li> ))} </ul> </div> From 1c8995bb5bb960fbd8f10650da74093c9b8baf0e Mon Sep 17 00:00:00 2001 From: Dano Morrison <danomorrison@meta.com> Date: Thu, 6 Aug 2026 18:56:42 -0700 Subject: [PATCH 29/29] Restore the build-check config: a vitest positional is a filter, not an include MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CI: `vitest run tests/build.check.ts` found no test files. Positional args filter against `include`, and build.check.ts deliberately doesn't match the default *.test.ts glob, so nothing matched. Kept the review's substance — the filename alone keeps it out of `npm test`, so there is no `exclude` in vitest.config.ts and no two-file coordination. The build config now only supplies the include. --- package.json | 2 +- vitest.build.config.ts | 13 +++++++++++++ 2 files changed, 14 insertions(+), 1 deletion(-) create mode 100644 vitest.build.config.ts diff --git a/package.json b/package.json index d00049ac..55b6aa9b 100644 --- a/package.json +++ b/package.json @@ -22,7 +22,7 @@ "lint-fix": "npm run lint -- --fix", "postlint-fix": "prettier --single-quote --write '**/*.{ts,tsx,js,jsx,json,html,css,less,scss,yml}'", "test": "vitest run", - "test:build": "vitest run tests/build.check.ts", + "test:build": "vitest run --config vitest.build.config.ts", "device-integration": "vitest run src/main/lsl src/renderer/utils/eeg", "test:watch": "vitest", "test-all": "npm run lint && npm run typecheck && npm run build && npm test && npm run test:build", diff --git a/vitest.build.config.ts b/vitest.build.config.ts new file mode 100644 index 00000000..03a51362 --- /dev/null +++ b/vitest.build.config.ts @@ -0,0 +1,13 @@ +import { defineConfig } from 'vitest/config'; + +// tests/build.check.ts is deliberately not named *.test.ts, so the default +// `npm test` glob never picks it up (running it without a build should be a +// loud error, not a silent skip). A CLI positional is only a filter against +// `include`, so pointing at the file directly finds nothing — this config +// supplies the include. No exclude anywhere; the filename does that job. +export default defineConfig({ + test: { + environment: 'node', + include: ['tests/build.check.ts'], + }, +});