From 643a1abf3146fe6e7dfe5b9d227cfbb27d46f04e Mon Sep 17 00:00:00 2001 From: Dano Morrison Date: Tue, 7 Jul 2026 08:47:43 -0700 Subject: [PATCH 1/2] Lock epoch-review plan Phase 0/1 via plan-eng-review MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Resolves the forge-blocking open questions and writes a forge-ready Phase 0/1 execution plan into docs/epoch-review-ui-plan.md §0: - OQ1 rendering → Canvas 2D + DOM/SVG overlay, thin drawEpochs boundary, viewport windowing; WebGL a later swap. - OQ2 transport → extend the dataKey pattern (epochArrays, applyRejection); defer the runPython RPC (matches TODOS). - OQ6 live ERP preview → in scope for v1 (Phase 1), computed CLIENT-SIDE over the Phase-0 buffer (pure computeErp mean, zero worker round-trips) — Flavor 1 (while-cleaning) only; real-time-during-experiment is a separate future non-Python effort. - Shared condition-color palette built in Phase 0 (used by Phase-0 render, so not dead code). - Narrow Pyodide buffer-path fidelity test added in Phase 0. - Delivery split into two sequential PRs. Also updates the standing Pyodide-fidelity TODO to note the narrow test. --- TODOS.md | 2 +- docs/epoch-review-ui-plan.md | 112 ++++++++++++++++++++++++++--------- 2 files changed, 86 insertions(+), 28 deletions(-) diff --git a/TODOS.md b/TODOS.md index 80fad40..1896c35 100644 --- a/TODOS.md +++ b/TODOS.md @@ -19,7 +19,7 @@ Deferred and in-flight work. Keep this current — when something ships, delete ## 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`). +- [ ] 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). ## Done recently diff --git a/docs/epoch-review-ui-plan.md b/docs/epoch-review-ui-plan.md index 92cf78b..43d4ee9 100644 --- a/docs/epoch-review-ui-plan.md +++ b/docs/epoch-review-ui-plan.md @@ -4,7 +4,50 @@ context first, deep technical decisions deliberately left open (flagged as Open Questions). Not yet an implementation spec. -**Owner:** Dano · **Drafted:** 2026-07-06 +**Owner:** Dano · **Drafted:** 2026-07-06 · **Locked:** 2026-07-07 (plan-eng-review) + +--- + +## 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 `` 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). + +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). --- @@ -293,19 +336,19 @@ Traced end to end against `webworker/`, `src/main/index.ts`, `src/preload/index. ## 8. Open questions (for the planning loop) -1. **Rendering tech** — Canvas 2D now, or WebGL up front for future high-channel - devices? -2. **RPC first?** — Do we build the `runPython` request/response RPC as a - prerequisite (cleaner data fetch), or bolt this onto the current fire-and-forget - `dataKey` pattern? +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** — 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? -6. **Live ERP preview** — in-scope for v1 (big teaching win, more compute) or a - fast-follow? +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 @@ -319,25 +362,40 @@ Traced end to end against `webworker/`, `src/main/index.ts`, `src/preload/index. ## 9. Rough phases (to be firmed up in planning) -- **Phase 0 — Transport & read-only render.** Extend the worker message contract - to return an `ArrayBuffer` on a new `dataKey` with a real transfer list (see - 6a); add the `get_epochs_arrays` Python helper; ship epoch arrays + metadata - across the worker boundary; render static traces in React. Proves the data path - and rendering choice. **Prerequisite input:** the rendering-tech decision (Open - Question 1, Canvas 2D vs WebGL) must be made *before* the "render static traces" - step — it determines the component's core. **Also (cheap, do it first):** the - one-line runtime confirmation of the broken Clean→Analyze round-trip (Open - Question 8) — now a sanity check, not a research task, since §6d resolves it - statically. -- **Phase 1 — Core interaction.** Click-to-reject epochs, scroll/scale/zoom, apply - → `epochs.drop` → save `-cleaned-epo.fif` **via the new MEMFS→host write-back - bridge (see §6d)**. Reaches functional parity with the *essential* MNE workflow. - - **Sequencing note:** the write-back bridge is *not* intrinsically a Phase-1 - dependency. It reuses the same worker-message-contract extension as Phase 0 - (§6a) and independently fixes a live bug (§6d), so it can land early and - standalone — make the existing `Clean Data` button actually persist alongside - the Phase-0 transport work, de-risking the apply path before the reject flow - exists. Phase 1 then just points the apply action at a bridge that works. +- **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 — `` 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` (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.** Bad-channel flagging, condition coloring/legend, auto-flag suggestions from `drop_log`/peak-to-peak. - **Phase 3 — Onboarding layer.** Explanations, guided mode, artifact tutorials, From 4bdfda863b07c39019ad5700bec45cd9f6f1d0d3 Mon Sep 17 00:00:00 2001 From: Dano Morrison Date: Tue, 7 Jul 2026 09:14:40 -0700 Subject: [PATCH 2/2] Epoch reviewer Phase 0: ship epoch arrays to a static Canvas renderer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit First half of the interactive epoch-review UI (docs/epoch-review-ui-plan.md §0/§9). Read-only this phase — proves the data path and the Canvas 2D rendering choice; click-to-reject, apply, and live ERP are Phase 1. Data path (all new, extends the proven dataKey pattern — no runPython RPC): Load Dataset → loadEpochsEpic → requestEpochArrays('raw_epochs') → worker get_epochs_arrays() writes a float32 buffer to MEMFS + returns meta → worker posts { buffer, results, dataKey:'epochArrays' } zero-copy transferable → pyodideMessageEpic → SetEpochArrays → reducer → CleanComponent → EpochReviewer - utils.py: get_epochs_arrays(epochs, out_path) → float32 C-order [epoch][channel][time] buffer + metadata (Marker/stim channel excluded via pick_types(eeg=True)); plot_topo gains a palette param; condition-color fallback DRYed to one _DEFAULT_CONDITION_PALETTE constant. - webworker.js: readFileAfter read-back unified to post { buffer, results } (savedEpochs write-back from PR #222 migrated to the same field). - conditionPalette.ts: canonical condition palette, single source for the Python matplotlib plots (JSON-injected) and the React reviewer. - EpochReviewer.tsx: Canvas 2D traces (epochs side-by-side, channels stacked) + DOM overlay (labels, dividers), viewport windowing (first 8 epochs), spike-preserving min/max downsampling, condition coloring, empty state, DPR scaling. Pure decode/downsample helpers in epochArrays.ts. Tests: native pytest (get_epochs_arrays buffer decodes to get_data(), Marker excluded, byte length, event codes) + vitest (decode/downsample/color-index). typecheck 0 · eslint 0 errors · vitest 37/37 · native pytest 10/10 · build green --- src/renderer/actions/pyodideActions.ts | 16 ++ .../CleanComponent/EpochReviewer.tsx | 232 ++++++++++++++++++ .../__tests__/epochArrays.test.ts | 124 ++++++++++ .../components/CleanComponent/epochArrays.ts | 71 ++++++ .../components/CleanComponent/index.tsx | 11 +- src/renderer/epics/pyodideEpics.ts | 15 +- src/renderer/reducers/pyodideReducer.ts | 10 +- src/renderer/utils/eeg/conditionPalette.ts | 25 ++ src/renderer/utils/webworker/index.ts | 17 +- src/renderer/utils/webworker/utils.py | 77 +++++- src/renderer/utils/webworker/webworker.js | 7 +- tests/analysis/test_epoch_arrays.py | 72 ++++++ 12 files changed, 654 insertions(+), 23 deletions(-) create mode 100644 src/renderer/components/CleanComponent/EpochReviewer.tsx create mode 100644 src/renderer/components/CleanComponent/__tests__/epochArrays.test.ts create mode 100644 src/renderer/components/CleanComponent/epochArrays.ts create mode 100644 src/renderer/utils/eeg/conditionPalette.ts create mode 100644 tests/analysis/test_epoch_arrays.py diff --git a/src/renderer/actions/pyodideActions.ts b/src/renderer/actions/pyodideActions.ts index a9d6a1c..11d84f9 100644 --- a/src/renderer/actions/pyodideActions.ts +++ b/src/renderer/actions/pyodideActions.ts @@ -2,6 +2,18 @@ import { createAction } from '@reduxjs/toolkit'; import { ActionType } from 'typesafe-actions'; import { PYODIDE_VARIABLE_NAMES } from '../constants/constants'; +// Metadata shipped alongside the raw epoch buffer (dataKey 'epochArrays'). +export interface EpochArraysMeta { + n_epochs: number; + n_channels: number; + n_times: number; + ch_names: string[]; + sfreq: number; + times: number[]; + event_codes: number[]; + drop_log: string[][]; +} + // ------------------------------------------------------------------------- // Actions @@ -26,6 +38,10 @@ export const PyodideActions = { SetEpochInfo: createAction('SET_EPOCH_INFO'), // Pyodide WASM runtime result — shape is dynamic // eslint-disable-next-line @typescript-eslint/no-explicit-any SetChannelInfo: createAction('SET_CHANNEL_INFO'), // Pyodide WASM runtime result — shape is dynamic + SetEpochArrays: createAction< + { buffer: ArrayBuffer; meta: EpochArraysMeta }, + 'SET_EPOCH_ARRAYS' + >('SET_EPOCH_ARRAYS'), // eslint-disable-next-line @typescript-eslint/no-explicit-any SetPSDPlot: createAction('SET_PSD_PLOT'), // Pyodide WASM runtime result — shape is dynamic // eslint-disable-next-line @typescript-eslint/no-explicit-any diff --git a/src/renderer/components/CleanComponent/EpochReviewer.tsx b/src/renderer/components/CleanComponent/EpochReviewer.tsx new file mode 100644 index 0000000..3acc4a0 --- /dev/null +++ b/src/renderer/components/CleanComponent/EpochReviewer.tsx @@ -0,0 +1,232 @@ +import React, { useEffect, useRef } from 'react'; +import type { EpochArraysMeta } from '../../actions'; +import { cssColorForIndex } from '../../utils/eeg/conditionPalette'; +import { + conditionIndexForCode, + downsampleMinMax, + 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. +// +// epoch 0 epoch 1 epoch 2 ... +// ┌────────────┬────────────┬────────────┐ +// ch 0 │ ~~~~~~~~ │ ~~~~~~~~ │ ~~~~~~~~ │ channel lane +// ├────────────┼────────────┼────────────┤ +// ch 1 │ ~~~~~~~~ │ ~~~~~~~~ │ ~~~~~~~~ │ +// ├────────────┼────────────┼────────────┤ +// ch 2 │ ~~~~~~~~ │ ~~~~~~~~ │ ~~~~~~~~ │ +// └────────────┴────────────┴────────────┘ +// epoch 0 epoch 1 epoch 2 (bottom index labels) +// +// Read-only (Phase 0): no pointer handlers, no reject/apply/scroll/scale. +// --------------------------------------------------------------------------- + +interface Props { + epochArrays: { buffer: ArrayBuffer; meta: EpochArraysMeta } | null; +} + +// Logical canvas size (scaled up for devicePixelRatio at draw time). +const CANVAS_WIDTH = 640; +const CANVAS_HEIGHT = 320; + +// How many epochs we draw in this static preview. Scrolling is Phase 1. +const VISIBLE_EPOCHS = 8; + +// Gutter reserved on the left for channel labels (logical px). +const LABEL_GUTTER = 64; +// Gutter reserved at the bottom for epoch index labels (logical px). +const BOTTOM_GUTTER = 20; + +export default function EpochReviewer({ epochArrays }: Props): JSX.Element { + const canvasRef = useRef(null); + + const meta = epochArrays?.meta ?? null; + + useEffect(() => { + const canvas = canvasRef.current; + if (!canvas || !epochArrays || !meta || meta.n_epochs === 0) { + return; + } + + const ctx = canvas.getContext('2d'); + if (!ctx) { + return; + } + + // Scale the backing store for crisp lines on HiDPI displays, but keep + // drawing in logical coordinates. + const dpr = window.devicePixelRatio || 1; + canvas.width = CANVAS_WIDTH * dpr; + canvas.height = CANVAS_HEIGHT * dpr; + ctx.setTransform(dpr, 0, 0, dpr, 0, 0); + ctx.clearRect(0, 0, CANVAS_WIDTH, CANVAS_HEIGHT); + + const { buffer } = epochArrays; + const { n_epochs, n_channels, n_times, event_codes } = meta; + + const visibleEpochs = Math.min(n_epochs, VISIBLE_EPOCHS); + const plotWidth = CANVAS_WIDTH - LABEL_GUTTER; + const plotHeight = CANVAS_HEIGHT - BOTTOM_GUTTER; + const colWidth = plotWidth / visibleEpochs; + const laneHeight = plotHeight / n_channels; + + // Deterministic per-condition coloring: position in the sorted unique codes. + const uniqueSortedCodes = [...new Set(event_codes)].sort((a, b) => a - b); + + // Faint lane dividers (channels). + ctx.strokeStyle = 'rgba(0, 0, 0, 0.08)'; + ctx.lineWidth = 1; + for (let ch = 1; ch < n_channels; ch += 1) { + const y = Math.round(ch * laneHeight) + 0.5; + ctx.beginPath(); + ctx.moveTo(LABEL_GUTTER, y); + ctx.lineTo(CANVAS_WIDTH, y); + ctx.stroke(); + } + + // Vertical dividers between epochs. + ctx.strokeStyle = 'rgba(0, 0, 0, 0.15)'; + for (let e = 0; e <= visibleEpochs; e += 1) { + const x = Math.round(LABEL_GUTTER + e * colWidth) + 0.5; + ctx.beginPath(); + ctx.moveTo(x, 0); + ctx.lineTo(x, plotHeight); + ctx.stroke(); + } + + const cols = Math.max(1, Math.floor(colWidth)); + + for (let e = 0; e < visibleEpochs; e += 1) { + const colLeft = LABEL_GUTTER + e * colWidth; + const code = event_codes[e]; + ctx.strokeStyle = cssColorForIndex( + conditionIndexForCode(code, uniqueSortedCodes) + ); + ctx.lineWidth = 1; + + for (let ch = 0; ch < n_channels; ch += 1) { + const laneTop = ch * laneHeight; + const series = epochChannelSeries(buffer, meta, e, ch); + + // Per-lane y-scaling: map [min, max] of this trace to the lane height + // (with a small vertical pad so traces don't touch the dividers). + let min = Infinity; + let max = -Infinity; + for (let i = 0; i < series.length; i += 1) { + const v = series[i]; + if (v < min) { + min = v; + } + if (v > max) { + max = v; + } + } + const pad = laneHeight * 0.1; + const usableHeight = laneHeight - 2 * pad; + const range = max - min || 1; + const toY = (v: number): number => + laneTop + pad + (1 - (v - min) / range) * usableHeight; + + if (n_times > cols) { + // More samples than pixels: draw a vertical min→max line per column + // so sharp transients survive downsampling. + const buckets = downsampleMinMax(series, cols); + ctx.beginPath(); + for (let c = 0; c < buckets.length; c += 1) { + const x = colLeft + (c * colWidth) / buckets.length; + const [lo, hi] = buckets[c]; + ctx.moveTo(x, toY(hi)); + ctx.lineTo(x, toY(lo)); + } + ctx.stroke(); + } else { + // Fewer samples than pixels: a normal polyline reads best. + ctx.beginPath(); + for (let i = 0; i < series.length; i += 1) { + const x = + colLeft + + (series.length <= 1 ? 0 : (i / (series.length - 1)) * colWidth); + const y = toY(series[i]); + if (i === 0) { + ctx.moveTo(x, y); + } else { + ctx.lineTo(x, y); + } + } + ctx.stroke(); + } + } + } + }, [epochArrays, meta]); + + // Empty state — friendly, brand-styled, student-facing. + if (!epochArrays || !meta || meta.n_epochs === 0) { + return ( +
+ Load a dataset to see your epochs here 🧠 +
+ ); + } + + const visibleEpochs = Math.min(meta.n_epochs, VISIBLE_EPOCHS); + const laneHeight = (CANVAS_HEIGHT - BOTTOM_GUTTER) / meta.n_channels; + const colWidth = (CANVAS_WIDTH - LABEL_GUTTER) / visibleEpochs; + + return ( +
+

Epochs

+
+ + + {/* Channel labels (left gutter). */} + {meta.ch_names.map((name, ch) => ( +
+ {name} +
+ ))} + + {/* Epoch index labels (bottom gutter). */} + {Array.from({ length: visibleEpochs }, (_, e) => ( +
+ {e} +
+ ))} +
+ + {meta.n_epochs > VISIBLE_EPOCHS && ( +

+ showing first {VISIBLE_EPOCHS} of {meta.n_epochs} epochs +

+ )} +
+ ); +} diff --git a/src/renderer/components/CleanComponent/__tests__/epochArrays.test.ts b/src/renderer/components/CleanComponent/__tests__/epochArrays.test.ts new file mode 100644 index 0000000..3df5ec0 --- /dev/null +++ b/src/renderer/components/CleanComponent/__tests__/epochArrays.test.ts @@ -0,0 +1,124 @@ +/** + * Tests for the pure epoch-array helpers backing the (read-only) EpochReviewer. + * These decode the C-order [epoch][channel][time] Float32 buffer, downsample + * traces while preserving spikes, and map event codes to a stable condition + * color index. + */ +import { describe, it, expect } from 'vitest'; +import type { EpochArraysMeta } from '../../../actions'; +import { + conditionIndexForCode, + downsampleMinMax, + epochChannelSeries, +} from '../epochArrays'; + +const makeMeta = (over: Partial = {}): EpochArraysMeta => ({ + n_epochs: 2, + 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, +}); + +describe('epochChannelSeries', () => { + it('slices the correct 3 values for each (epoch, channel)', () => { + const meta = makeMeta(); + // C-order [epoch][channel][time]: e0c0, e0c1, e1c0, e1c1 + const data = Float32Array.from([ + 1, + 2, + 3, // epoch 0, channel 0 + 4, + 5, + 6, // epoch 0, channel 1 + 7, + 8, + 9, // epoch 1, channel 0 + 10, + 11, + 12, // epoch 1, channel 1 + ]); + const { buffer } = data; + + expect(Array.from(epochChannelSeries(buffer, meta, 0, 0))).toEqual([ + 1, 2, 3, + ]); + expect(Array.from(epochChannelSeries(buffer, meta, 0, 1))).toEqual([ + 4, 5, 6, + ]); + expect(Array.from(epochChannelSeries(buffer, meta, 1, 0))).toEqual([ + 7, 8, 9, + ]); + expect(Array.from(epochChannelSeries(buffer, meta, 1, 1))).toEqual([ + 10, 11, 12, + ]); + }); + + it('returns a zero-copy view onto the underlying buffer', () => { + const meta = makeMeta(); + const data = Float32Array.from([1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12]); + const view = epochChannelSeries(data.buffer, meta, 1, 1); + expect(view.buffer).toBe(data.buffer); + }); +}); + +describe('downsampleMinMax', () => { + it('preserves a sharp spike as the max of its bucket', () => { + // Flat baseline with one large spike partway through. + const series = [0, 0, 0, 0, 99, 0, 0, 0]; + const out = downsampleMinMax(series, 2); + // The spike must appear as a max in one of the buckets. + const maxes = out.map(([, hi]) => hi); + expect(Math.max(...maxes)).toBe(99); + 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([]); + }); + + it('returns [] for width <= 0', () => { + expect(downsampleMinMax([1, 2, 3], 0)).toEqual([]); + expect(downsampleMinMax([1, 2, 3], -4)).toEqual([]); + }); + + it('never emits more columns than requested width', () => { + const series = Array.from({ length: 100 }, (_, i) => i); + const out = downsampleMinMax(series, 7); + expect(out.length).toBeLessThanOrEqual(7); + // Each entry is a [min, max] pair with min <= max. + out.forEach(([lo, hi]) => expect(lo).toBeLessThanOrEqual(hi)); + }); +}); + +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); + }); +}); diff --git a/src/renderer/components/CleanComponent/epochArrays.ts b/src/renderer/components/CleanComponent/epochArrays.ts new file mode 100644 index 0000000..438d2a3 --- /dev/null +++ b/src/renderer/components/CleanComponent/epochArrays.ts @@ -0,0 +1,71 @@ +import type { EpochArraysMeta } from '../../actions'; + +// Buffer is C-order [epoch][channel][time]. Return the time series for one +// (epoch, channel) as a zero-copy subarray view. +export function epochChannelSeries( + buffer: ArrayBuffer, + meta: EpochArraysMeta, + epoch: number, + channel: number +): Float32Array { + const floats = new Float32Array(buffer); + const stride = meta.n_channels * meta.n_times; + const start = epoch * stride + channel * meta.n_times; + 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. +export function downsampleMinMax( + series: ArrayLike, + width: number +): Array<[number, number]> { + const { length } = series; + if (width <= 0 || length === 0) { + 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). + const out: Array<[number, number]> = new Array(width); + for (let col = 0; col < width; col += 1) { + const start = Math.floor((col * length) / width); + const end = Math.floor(((col + 1) * length) / width); + let min = series[start]; + let max = series[start]; + for (let i = start + 1; i < end; i += 1) { + const v = series[i]; + if (v < min) { + min = v; + } + if (v > max) { + max = v; + } + } + out[col] = [min, max]; + } + 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; +} diff --git a/src/renderer/components/CleanComponent/index.tsx b/src/renderer/components/CleanComponent/index.tsx index 6e2e8f9..768890d 100644 --- a/src/renderer/components/CleanComponent/index.tsx +++ b/src/renderer/components/CleanComponent/index.tsx @@ -6,7 +6,12 @@ import { Button } from '../ui/button'; import { EXPERIMENTS, DEVICES } from '../../constants/constants'; import { readWorkspaceRawEEGData } from '../../utils/filesystem/storage'; import CleanSidebar from './CleanSidebar'; -import { PyodideActions, ExperimentActions } from '../../actions'; +import EpochReviewer from './EpochReviewer'; +import { + PyodideActions, + ExperimentActions, + EpochArraysMeta, +} from '../../actions'; export interface Props { type?: EXPERIMENTS; @@ -15,6 +20,7 @@ export interface Props { epochsInfo: Array<{ [key: string]: number | string; }>; + epochArrays: { buffer: ArrayBuffer; meta: EpochArraysMeta } | null; PyodideActions: typeof PyodideActions; ExperimentActions: typeof ExperimentActions; subject: string; @@ -210,6 +216,9 @@ export default class Clean extends Component { {this.renderAnalyzeButton()} +
+ +
); diff --git a/src/renderer/epics/pyodideEpics.ts b/src/renderer/epics/pyodideEpics.ts index c994358..8e8696d 100644 --- a/src/renderer/epics/pyodideEpics.ts +++ b/src/renderer/epics/pyodideEpics.ts @@ -3,7 +3,7 @@ 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 } from '../actions'; +import { PyodideActions, PyodideActionType, EpochArraysMeta } from '../actions'; import { RootState } from '../reducers'; import { buildMarkerRegistry } from '../utils/eeg/markerRegistry'; import { @@ -14,6 +14,7 @@ import { epochEvents, requestEpochsInfo, requestChannelInfo, + requestEpochArrays, cleanEpochsPlot, plotPSD, plotERP, @@ -106,8 +107,16 @@ const pyodideMessageEpic: Epic< // results is an array of channel-name strings return of(PyodideActions.SetChannelInfo(results as string[])); } + if (dataKey === 'epochArrays') { + return of( + PyodideActions.SetEpochArrays({ + buffer: e.data.buffer as ArrayBuffer, + meta: results as EpochArraysMeta, + }) + ); + } if (dataKey === 'savedEpochs') { - const savedEpochsBuffer = e.data.savedEpochsBuffer as ArrayBuffer; + const savedEpochsBuffer = e.data.buffer as ArrayBuffer; const { title, subject } = state$.value.experiment; return from( window.electronAPI.writeCleanedEpochs( @@ -170,6 +179,8 @@ const loadEpochsEpic: Epic = ( } // Result returns asynchronously via pyodideMessageEpic → SetEpochInfo. requestEpochsInfo(worker, PYODIDE_VARIABLE_NAMES.RAW_EPOCHS); + // Fetch epoch arrays for the interactive reviewer (dataKey 'epochArrays'). + requestEpochArrays(worker, PYODIDE_VARIABLE_NAMES.RAW_EPOCHS); }), mergeMap(() => EMPTY) ); diff --git a/src/renderer/reducers/pyodideReducer.ts b/src/renderer/reducers/pyodideReducer.ts index 536afb2..c3d7e3a 100644 --- a/src/renderer/reducers/pyodideReducer.ts +++ b/src/renderer/reducers/pyodideReducer.ts @@ -1,5 +1,5 @@ import { createReducer } from '@reduxjs/toolkit'; -import { PyodideActions, ExperimentActions } from '../actions'; +import { PyodideActions, ExperimentActions, EpochArraysMeta } from '../actions'; export interface PyodideStateType { readonly epochsInfo: Array<{ @@ -24,6 +24,7 @@ export interface PyodideStateType { } | null | undefined; + readonly epochArrays: { buffer: ArrayBuffer; meta: EpochArraysMeta } | null; readonly worker: Worker | null; readonly isWorkerReady: boolean; } @@ -34,6 +35,7 @@ const initialState: PyodideStateType = { psdPlot: null, topoPlot: null, erpPlot: null, + epochArrays: null, worker: null, isWorkerReady: false, }; @@ -76,14 +78,18 @@ export default createReducer(initialState, (builder) => erpPlot: action.payload, }; }) + .addCase(PyodideActions.SetEpochArrays, (state, action) => { + return { ...state, epochArrays: action.payload }; + }) .addCase(PyodideActions.SetWorkerReady, (state) => { return { ...state, isWorkerReady: true }; }) - .addCase(ExperimentActions.ExperimentCleanup, (state, action) => { + .addCase(ExperimentActions.ExperimentCleanup, (state) => { return { ...state, epochsInfo: [], channelInfo: [], + epochArrays: null, psdPlot: null, topoPlot: null, erpPlot: null, diff --git a/src/renderer/utils/eeg/conditionPalette.ts b/src/renderer/utils/eeg/conditionPalette.ts new file mode 100644 index 0000000..16e5a83 --- /dev/null +++ b/src/renderer/utils/eeg/conditionPalette.ts @@ -0,0 +1,25 @@ +// Canonical condition color palette — single source of truth for BOTH the React +// EpochReviewer (via cssColorForIndex) and the Python matplotlib plots +// (utils.py plot_topo/plot_conditions), which receive CONDITION_PALETTE_RGB +// JSON-injected by the worker. RGB components are 0..1 floats to match MNE/ +// matplotlib's convention. Keep these values in sync across the WASM boundary +// by keeping this file the ONE place they are defined. +export const CONDITION_PALETTE_RGB: ReadonlyArray< + readonly [number, number, number] +> = [ + [0.86, 0.37, 0.34], + [0.34, 0.86, 0.37], + [0.37, 0.34, 0.86], + [0.86, 0.72, 0.34], +]; + +// CSS rgb() string for the i-th condition (cycles via modulo). Used by the +// 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 + ]; + return `rgb(${Math.round(r * 255)}, ${Math.round(g * 255)}, ${Math.round(b * 255)})`; +} diff --git a/src/renderer/utils/webworker/index.ts b/src/renderer/utils/webworker/index.ts index 888953b..edf1662 100644 --- a/src/renderer/utils/webworker/index.ts +++ b/src/renderer/utils/webworker/index.ts @@ -1,6 +1,7 @@ import path from 'pathe'; import patchesPy from './patches.py?raw'; import utilsPy from './utils.py?raw'; +import { CONDITION_PALETTE_RGB } from '../eeg/conditionPalette'; // --------------------------------- // This file contains the JS functions that allow the app to access python-wasm through pyodide @@ -126,6 +127,18 @@ export const requestChannelInfo = (worker: Worker) => { }); }; +// Fetch epoch data arrays for the interactive reviewer. get_epochs_arrays writes +// a float32 buffer to a MEMFS path and returns metadata; the worker reads the +// buffer back (readFileAfter) and posts it zero-copy on dataKey 'epochArrays'. +export const requestEpochArrays = (worker: Worker, variableName: string) => { + const outPath = '/tmp/epoch_arrays.f32'; + worker.postMessage({ + data: `get_epochs_arrays(${variableName}, "${outPath}")`, + dataKey: 'epochArrays', + readFileAfter: outPath, + }); +}; + // ----------------------------- // Plot functions @@ -161,7 +174,7 @@ export const plotTopoMap = async (worker: Worker) => { plotKey: 'topo', data: [ 'import io', - '_fig = plot_topo(clean_epochs, conditions)', + `_fig = plot_topo(clean_epochs, conditions, palette=${JSON.stringify(CONDITION_PALETTE_RGB)})`, '_buf = io.BytesIO()', '_fig.savefig(_buf, format="svg", bbox_inches="tight")', 'plt.close(_fig)', @@ -193,7 +206,7 @@ export const plotERP = async (worker: Worker, channelIndex: number) => { plotKey: 'erp', data: [ 'import io', - `_fig, _ = plot_conditions(clean_epochs, ch_ind=${channelIndex}, conditions=conditions, ci=97.5, n_boot=1000, title='', diff_waveform=None)`, + `_fig, _ = plot_conditions(clean_epochs, ch_ind=${channelIndex}, conditions=conditions, ci=97.5, n_boot=1000, title='', diff_waveform=None, palette=${JSON.stringify(CONDITION_PALETTE_RGB)})`, '_buf = io.BytesIO()', '_fig.savefig(_buf, format="svg", bbox_inches="tight")', 'plt.close(_fig)', diff --git a/src/renderer/utils/webworker/utils.py b/src/renderer/utils/webworker/utils.py index 023053f..20d1391 100644 --- a/src/renderer/utils/webworker/utils.py +++ b/src/renderer/utils/webworker/utils.py @@ -4,7 +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) +from mne import (concatenate_raws, create_info, viz, find_events, Epochs, + pick_types) from mne.io import RawArray from io import StringIO @@ -13,6 +14,17 @@ # 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): """Load CSV files into a RawArray object. @@ -125,12 +137,9 @@ def get_raw_epochs(raw, event_id, tmin, tmax, baseline=None, reject=None, verbose=False, picks=picks) -def plot_topo(epochs, conditions=OrderedDict()): - # palette = sns.color_palette("hls", len(conditions) + 1) - # temp hack, just pull in the color palette from seaborn - palette = [(0.85999999999999999, 0.37119999999999997, 0.33999999999999997), - (0.33999999999999997, 0.85999999999999999, 0.37119999999999997), - (0.37119999999999997, 0.33999999999999997, 0.85999999999999999)] +def plot_topo(epochs, conditions=OrderedDict(), palette=None): + if palette is None: + palette = _DEFAULT_CONDITION_PALETTE evokeds = [epochs[name].average() for name in (conditions)] evoked_topo = viz.plot_evoked_topo( @@ -198,12 +207,7 @@ def plot_conditions(epochs, ch_ind=0, conditions=OrderedDict(), ci=97.5, conditions = OrderedDict(conditions) if palette is None: - 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), - ] + palette = _DEFAULT_CONDITION_PALETTE X = epochs.get_data() times = epochs.times @@ -254,6 +258,53 @@ def plot_conditions(epochs, ch_ind=0, conditions=OrderedDict(), ci=97.5, return fig, ax +def get_epochs_arrays(epochs, out_path): + """Serialize epoch data to a float32 buffer file plus a metadata dict. + + Writes the raw EEG epoch samples (Marker/stim channel excluded) as a flat + little-endian float32 buffer to `out_path` and returns metadata describing + the buffer's shape and per-epoch/per-channel labels. `out_path` is a Pyodide + MEMFS path in-app and a real filesystem path in the native tests. + + # buffer (float32, C-order): epoch0[ch0[t0..tN] ch1[..] ..] epoch1[..] .. + # byte length == n_epochs * n_channels * n_times * 4 + + Parameters + ---------- + epochs : mne.Epochs + The epoched data. The Marker channel (type 'stim') is excluded. + out_path : str + Destination path for the raw float32 buffer. + + Returns + ------- + meta : dict + Buffer metadata (see keys below). + """ + # 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) + data = np.ascontiguousarray(data.astype(np.float32)) + + with open(out_path, 'wb') as f: + f.write(data.tobytes()) + + n_epochs, n_channels, n_times = data.shape + ch_names = [epochs.ch_names[i] for i in picks] + + return { + "n_epochs": int(n_epochs), + "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], + } + + def get_epochs_info(epochs): print('Get Epochs Info:') # drop_log_stats() ignores IGNORED/NO_DATA entries, so the percentage is diff --git a/src/renderer/utils/webworker/webworker.js b/src/renderer/utils/webworker/webworker.js index 7a3ef84..db6f065 100644 --- a/src/renderer/utils/webworker/webworker.js +++ b/src/renderer/utils/webworker/webworker.js @@ -130,9 +130,10 @@ 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({ savedEpochsBuffer: fileBytes.buffer, dataKey }, [ - fileBytes.buffer, - ]); + self.postMessage( + { buffer: fileBytes.buffer, results, plotKey, dataKey }, + [fileBytes.buffer] + ); return; } self.postMessage({ results, plotKey, dataKey }); diff --git a/tests/analysis/test_epoch_arrays.py b/tests/analysis/test_epoch_arrays.py new file mode 100644 index 0000000..7bdc5e1 --- /dev/null +++ b/tests/analysis/test_epoch_arrays.py @@ -0,0 +1,72 @@ +"""Native-MNE tests for the epoch-array serializer (Phase 0 epoch review). + +Verifies `utils.get_epochs_arrays` writes a float32 buffer matching the frozen +contract consumed by the React canvas renderer: Marker/stim channel excluded, +C-order [epoch][channel][time] layout, and a metadata dict whose shape/labels +match the buffer exactly. +""" +import numpy as np +import mne + +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_marker_excluded_and_channel_count_matches(tmp_path): + epochs = _build_epochs() + meta = utils.get_epochs_arrays(epochs, str(tmp_path / "arr.f32")) + + assert meta["n_channels"] == len(meta["ch_names"]) + assert "Marker" not in meta["ch_names"] + + +def test_buffer_matches_epoch_data(tmp_path): + epochs = _build_epochs() + path = str(tmp_path / "arr.f32") + meta = utils.get_epochs_arrays(epochs, path) + + with open(path, "rb") as f: + raw_bytes = f.read() + arr = np.frombuffer(raw_bytes, dtype=np.float32).reshape( + meta["n_epochs"], meta["n_channels"], meta["n_times"] + ) + + expected = epochs.get_data( + picks=mne.pick_types(epochs.info, eeg=True) + ).astype(np.float32) + assert np.allclose(arr, expected, atol=1e-5) + + +def test_metadata_lengths_and_event_codes(tmp_path): + epochs = _build_epochs() + meta = utils.get_epochs_arrays(epochs, str(tmp_path / "arr.f32")) + + assert len(meta["times"]) == meta["n_times"] + assert len(meta["event_codes"]) == meta["n_epochs"] + assert meta["event_codes"] == epochs.events[:, -1].tolist() + + +def test_byte_length_matches_shape(tmp_path): + epochs = _build_epochs() + path = str(tmp_path / "arr.f32") + meta = utils.get_epochs_arrays(epochs, path) + + with open(path, "rb") as f: + n_bytes = len(f.read()) + assert n_bytes == ( + meta["n_epochs"] * meta["n_channels"] * meta["n_times"] * 4 + )