From 8cffa6156527f09c17fb8c99acae76b26b4bbfb6 Mon Sep 17 00:00:00 2001 From: Dano Morrison Date: Tue, 7 Jul 2026 09:52:17 -0700 Subject: [PATCH] Epoch reviewer Phase 1: interactive rejection, live ERP, apply/save MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Second half of the epoch-review UI (docs/epoch-review-ui-plan.md §0/§9) — turns the static Phase 0 viewer into a working, teach-as-you-go cleaner. Interaction (EpochReviewer, Canvas 2D + DOM overlay, OQ1): - Click an epoch column to toggle it rejected (greyed trace + wash + ✕). - Prev/Next navigation through epoch windows; column→ABSOLUTE index math is offset-aware so the right epochs are dropped after paging. - Amplitude gain control (clamped, traces can't bleed across lanes). Live ERP preview (OQ6, client-side — the teaching payoff): - LiveErpPane averages NON-rejected epochs per condition via the pure meanTrace() over the Phase-0 buffer and redraws on every toggle. ZERO worker round-trips (OQ2=2A, no runPython RPC); empty selection → flat line, no NaN. Apply at "Clean Data": - apply_rejection(epochs, drop_indices, bad_channels) in utils.py (epochs.drop + info['bads']), native-tested BIT-IDENTICAL to a manual MNE drop/bads. - CleanEpochs carries {dropIndices, badChannels}; cleanEpochsEpic posts apply_rejection (trailing ';' suppresses the Epochs PyProxy) → saves via the existing write-back bridge (PR #222) → re-fetches arrays + stats. Local selection clears after commit (fresh 0-based indices next round). Bad-channel UI is Phase 2 — apply_rejection takes the param but the UI wires badChannels: [] for now. typecheck 0 · eslint 0 errors · vitest 44/44 · native pytest 14/14 · build green --- src/renderer/actions/pyodideActions.ts | 5 +- .../CleanComponent/EpochReviewer.tsx | 252 +++++++++++++---- .../components/CleanComponent/LiveErpPane.tsx | 263 ++++++++++++++++++ .../__tests__/EpochReviewer.test.tsx | 69 +++++ .../__tests__/epochArrays.test.ts | 68 +++++ .../components/CleanComponent/epochArrays.ts | 28 ++ .../components/CleanComponent/index.tsx | 41 ++- src/renderer/epics/pyodideEpics.ts | 21 +- src/renderer/utils/webworker/index.ts | 16 ++ src/renderer/utils/webworker/utils.py | 18 ++ tests/analysis/test_apply_rejection.py | 70 +++++ 11 files changed, 790 insertions(+), 61 deletions(-) create mode 100644 src/renderer/components/CleanComponent/LiveErpPane.tsx create mode 100644 src/renderer/components/CleanComponent/__tests__/EpochReviewer.test.tsx create mode 100644 tests/analysis/test_apply_rejection.py diff --git a/src/renderer/actions/pyodideActions.ts b/src/renderer/actions/pyodideActions.ts index 11d84f9..41f2d27 100644 --- a/src/renderer/actions/pyodideActions.ts +++ b/src/renderer/actions/pyodideActions.ts @@ -29,7 +29,10 @@ export const PyodideActions = { LoadPSD: createAction('LOAD_PSD'), LoadERP: createAction('LOAD_ERP'), LoadTopo: createAction('LOAD_TOPO'), - CleanEpochs: createAction('CLEAN_EPOCHS'), + CleanEpochs: createAction< + { dropIndices: number[]; badChannels: string[] }, + 'CLEAN_EPOCHS' + >('CLEAN_EPOCHS'), GetEpochsInfo: createAction( 'GET_EPOCHS_INFO' ), diff --git a/src/renderer/components/CleanComponent/EpochReviewer.tsx b/src/renderer/components/CleanComponent/EpochReviewer.tsx index 3acc4a0..06184e6 100644 --- a/src/renderer/components/CleanComponent/EpochReviewer.tsx +++ b/src/renderer/components/CleanComponent/EpochReviewer.tsx @@ -1,5 +1,6 @@ -import React, { useEffect, useRef } from 'react'; +import React, { useEffect, useRef, useState } from 'react'; import type { EpochArraysMeta } from '../../actions'; +import { Button } from '../ui/button'; import { cssColorForIndex } from '../../utils/eeg/conditionPalette'; import { conditionIndexForCode, @@ -11,28 +12,36 @@ import { // 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 │ ~~~~~~~~ │ ~~~~~~~~ │ ~~~~~~~~ │ channel lane +// ch 0 │ ~~~~~~~~ │ ░░grey░░✕░░ │ ~~~~~~~~ │ channel lane +// ├────────────┼────────────┼────────────┤ (rejected column +// ch 1 │ ~~~~~~~~ │ ░░░░░░░░░░ │ ~~~~~~~~ │ is greyed out) // ├────────────┼────────────┼────────────┤ -// ch 1 │ ~~~~~~~~ │ ~~~~~~~~ │ ~~~~~~~~ │ -// ├────────────┼────────────┼────────────┤ -// ch 2 │ ~~~~~~~~ │ ~~~~~~~~ │ ~~~~~~~~ │ +// ch 2 │ ~~~~~~~~ │ ░░░░░░░░░░ │ ~~~~~~~~ │ // └────────────┴────────────┴────────────┘ // epoch 0 epoch 1 epoch 2 (bottom index labels) // -// Read-only (Phase 0): no pointer handlers, no reject/apply/scroll/scale. +// 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). // --------------------------------------------------------------------------- interface Props { epochArrays: { buffer: ArrayBuffer; meta: EpochArraysMeta } | null; + // ABSOLUTE epoch indices the student has marked for rejection. + rejected: Set; + // Toggle a single ABSOLUTE epoch index in/out of the rejected set. + onToggleEpoch: (index: number) => void; } // 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. +// How many epochs we draw per page. Prev/Next pages by this amount. const VISIBLE_EPOCHS = 8; // Gutter reserved on the left for channel labels (logical px). @@ -40,11 +49,37 @@ 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 { +// 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)'; + +export default function EpochReviewer({ + epochArrays, + rejected, + onToggleEpoch, +}: Props): JSX.Element { const canvasRef = useRef(null); + // 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; + // Page size is constant so column widths stay stable across pages. + const perPage = meta + ? Math.min(meta.n_epochs, VISIBLE_EPOCHS) + : VISIBLE_EPOCHS; + const maxStart = meta ? Math.max(0, meta.n_epochs - perPage) : 0; + const clampedStart = Math.min(startEpoch, maxStart); + const visibleCount = meta + ? Math.min(perPage, meta.n_epochs - clampedStart) + : 0; + useEffect(() => { const canvas = canvasRef.current; if (!canvas || !epochArrays || !meta || meta.n_epochs === 0) { @@ -67,15 +102,24 @@ export default function EpochReviewer({ epochArrays }: Props): JSX.Element { 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 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) { + const absolute = clampedStart + c; + if (rejected.has(absolute)) { + ctx.fillStyle = REJECTED_FILL_COLOR; + ctx.fillRect(LABEL_GUTTER + c * colWidth, 0, colWidth, plotHeight); + } + } + // Faint lane dividers (channels). ctx.strokeStyle = 'rgba(0, 0, 0, 0.08)'; ctx.lineWidth = 1; @@ -89,7 +133,7 @@ export default function EpochReviewer({ epochArrays }: Props): JSX.Element { // Vertical dividers between epochs. ctx.strokeStyle = 'rgba(0, 0, 0, 0.15)'; - for (let e = 0; e <= visibleEpochs; e += 1) { + for (let e = 0; e <= count; e += 1) { const x = Math.round(LABEL_GUTTER + e * colWidth) + 0.5; ctx.beginPath(); ctx.moveTo(x, 0); @@ -99,22 +143,29 @@ export default function EpochReviewer({ epochArrays }: Props): JSX.Element { 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) - ); + for (let c = 0; c < count; 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)); + ctx.strokeStyle = traceColor; ctx.lineWidth = 1; for (let ch = 0; ch < n_channels; ch += 1) { const laneTop = ch * laneHeight; - const series = epochChannelSeries(buffer, meta, e, ch); + const laneCenter = laneTop + laneHeight / 2; + const series = epochChannelSeries(buffer, meta, absolute, 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). + // 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 + // the mean. y is clamped to the lane so magnified traces don't bleed + // into neighboring channels. let min = Infinity; let max = -Infinity; + let sum = 0; for (let i = 0; i < series.length; i += 1) { const v = series[i]; if (v < min) { @@ -123,21 +174,28 @@ export default function EpochReviewer({ epochArrays }: Props): JSX.Element { if (v > max) { max = v; } + sum += v; } + const mean = series.length > 0 ? sum / series.length : 0; 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; + const scale = (usableHeight / range) * gain; + const laneLo = laneTop + pad; + const laneHi = laneTop + laneHeight - pad; + const toY = (v: number): number => { + const y = laneCenter - (v - mean) * scale; + return y < laneLo ? laneLo : y > laneHi ? laneHi : y; + }; 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]; + for (let col = 0; col < buckets.length; col += 1) { + const x = colLeft + (col * colWidth) / buckets.length; + const [lo, hi] = buckets[col]; ctx.moveTo(x, toY(hi)); ctx.lineTo(x, toY(lo)); } @@ -159,8 +217,25 @@ export default function EpochReviewer({ epochArrays }: Props): JSX.Element { 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]); + }, [epochArrays, meta, rejected, clampedStart, perPage, gain]); // Empty state — friendly, brand-styled, student-facing. if (!epochArrays || !meta || meta.n_epochs === 0) { @@ -171,13 +246,57 @@ export default function EpochReviewer({ epochArrays }: Props): JSX.Element { ); } - 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; + const colWidth = (CANVAS_WIDTH - LABEL_GUTTER) / perPage; + const plotHeight = CANVAS_HEIGHT - BOTTOM_GUTTER; + const firstShown = clampedStart + 1; + const lastShown = clampedStart + visibleCount; return (
-

Epochs

+
+

Epochs

+
+ + + + + +
+
+
))} - {/* Epoch index labels (bottom gutter). */} - {Array.from({ length: visibleEpochs }, (_, e) => ( -
- {e} -
- ))} + {/* Transparent click targets — one per visible epoch column. Clicking + toggles that ABSOLUTE epoch index in/out of the rejected set. */} + {Array.from({ length: visibleCount }, (_, c) => { + const absolute = clampedStart + c; + const isRejected = rejected.has(absolute); + return ( +
onToggleEpoch(absolute)} + onKeyDown={(e) => { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + onToggleEpoch(absolute); + } + }} + /> + ); + })} + + {/* Epoch index labels (bottom gutter) — ABSOLUTE indices. */} + {Array.from({ length: visibleCount }, (_, c) => { + const absolute = clampedStart + c; + return ( +
+ {absolute} +
+ ); + })}
- {meta.n_epochs > VISIBLE_EPOCHS && ( -

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

- )} +

+ 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 new file mode 100644 index 0000000..0c95ddb --- /dev/null +++ b/src/renderer/components/CleanComponent/LiveErpPane.tsx @@ -0,0 +1,263 @@ +import React, { useEffect, useMemo, useRef, useState } from 'react'; +import type { EpochArraysMeta } from '../../actions'; +import { cssColorForIndex } from '../../utils/eeg/conditionPalette'; +import { conditionIndexForCode, meanTrace } from './epochArrays'; + +// --------------------------------------------------------------------------- +// Live ERP pane — the "watch it clean up" payoff. Averages the INCLUDED epochs +// (all epochs NOT in `rejected`), grouped by condition, and draws one mean line +// per condition for the selected channel. Recomputes instantly whenever the +// student toggles an epoch, so the averaged waveform visibly cleans up. +// +// Live ERP channel: [ Pz ▾ ] +// ┌──────────────────────────────────────────────┐ +// │ ╱‾‾‾╲ │ +// │ ‾‾‾╲__╱‾‾‾╱ ╲___ ← one line / condition │ +// │ ─────────┊────────────────── (zero baseline) │ +// │ 0ms │ +// └──────────────────────────────────────────────┘ +// ■ Condition 1 (42) ■ Condition 2 (39) +// Averaged over 81 epochs — reject noisy ones to clean it up +// --------------------------------------------------------------------------- + +interface Props { + epochArrays: { buffer: ArrayBuffer; meta: EpochArraysMeta } | null; + // ABSOLUTE epoch indices marked for rejection (excluded from the average). + rejected: Set; +} + +const CANVAS_WIDTH = 640; +const CANVAS_HEIGHT = 240; +const PAD_LEFT = 8; +const PAD_RIGHT = 8; +const PAD_TOP = 12; +const PAD_BOTTOM = 12; + +export default function LiveErpPane({ + epochArrays, + rejected, +}: Props): JSX.Element { + const canvasRef = useRef(null); + const [channel, setChannel] = useState(0); + + const meta = epochArrays?.meta ?? null; + + // Included epoch indices grouped by condition code. Recomputed whenever the + // dataset or the rejected set changes. + const { groups, uniqueSortedCodes, includedCount } = useMemo(() => { + if (!meta || meta.n_epochs === 0) { + return { + groups: new Map(), + uniqueSortedCodes: [] as number[], + includedCount: 0, + }; + } + const byCode = new Map(); + 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) { + list.push(i); + } else { + byCode.set(code, [i]); + } + } + return { + groups: byCode, + uniqueSortedCodes: [...new Set(meta.event_codes)].sort((a, b) => a - b), + includedCount: included, + }; + }, [meta, rejected]); + + useEffect(() => { + const canvas = canvasRef.current; + if (!canvas || !epochArrays || !meta || meta.n_epochs === 0) { + return; + } + const ctx = canvas.getContext('2d'); + if (!ctx) { + return; + } + + 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_times, times } = meta; + const plotLeft = PAD_LEFT; + const plotRight = CANVAS_WIDTH - PAD_RIGHT; + const plotTop = PAD_TOP; + const plotBottom = CANVAS_HEIGHT - PAD_BOTTOM; + const plotWidth = plotRight - plotLeft; + const plotHeight = plotBottom - plotTop; + + // Compute one mean trace per condition, tracking a shared Y range so all + // conditions are drawn on the same amplitude scale. + const traces: Array<{ code: number; series: Float32Array }> = []; + let min = Infinity; + let max = -Infinity; + for (const code of uniqueSortedCodes) { + const indices = groups.get(code); + if (!indices || indices.length === 0) { + continue; + } + const series = meanTrace(buffer, meta, indices, channel); + for (let t = 0; t < series.length; t += 1) { + if (series[t] < min) { + min = series[t]; + } + if (series[t] > max) { + max = series[t]; + } + } + traces.push({ code, series }); + } + + if (!Number.isFinite(min) || !Number.isFinite(max)) { + min = -1; + max = 1; + } + const range = max - min || 1; + const yPad = range * 0.1; + const yLo = min - yPad; + const yHi = max + yPad; + const toY = (v: number): number => + plotBottom - ((v - yLo) / (yHi - yLo)) * plotHeight; + const toX = (i: number): number => + plotLeft + (n_times <= 1 ? 0 : (i / (n_times - 1)) * plotWidth); + + // Framing. + ctx.strokeStyle = 'rgba(0, 0, 0, 0.12)'; + ctx.lineWidth = 1; + ctx.strokeRect(plotLeft + 0.5, plotTop + 0.5, plotWidth, plotHeight); + + // Horizontal zero-amplitude baseline (if 0 is inside range). + if (yLo <= 0 && yHi >= 0) { + const y = Math.round(toY(0)) + 0.5; + ctx.strokeStyle = 'rgba(0, 0, 0, 0.2)'; + ctx.beginPath(); + ctx.moveTo(plotLeft, y); + ctx.lineTo(plotRight, y); + ctx.stroke(); + } + + // 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; + } + } + if (zeroIdx >= 0) { + const x = Math.round(toX(zeroIdx)) + 0.5; + ctx.strokeStyle = 'rgba(0, 0, 0, 0.25)'; + ctx.setLineDash([4, 3]); + ctx.beginPath(); + ctx.moveTo(x, plotTop); + ctx.lineTo(x, plotBottom); + ctx.stroke(); + ctx.setLineDash([]); + } + } + + // One mean line per condition. + ctx.lineWidth = 2; + for (const { code, series } of traces) { + ctx.strokeStyle = cssColorForIndex( + conditionIndexForCode(code, uniqueSortedCodes) + ); + ctx.beginPath(); + for (let i = 0; i < series.length; i += 1) { + const x = toX(i); + const y = toY(series[i]); + if (i === 0) { + ctx.moveTo(x, y); + } else { + ctx.lineTo(x, y); + } + } + ctx.stroke(); + } + }, [epochArrays, meta, rejected, channel, groups, uniqueSortedCodes]); + + // Empty state — brand-styled, mirrors EpochReviewer's placeholder. + if (!epochArrays || !meta || meta.n_epochs === 0) { + return ( +
+

Live ERP

+
+ Load a dataset to watch the ERP build 📈 +
+
+ ); + } + + return ( +
+
+

Live ERP

+ +
+ +
+ +
+ + {/* 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 0000000..df6f674 --- /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 3df5ec0..38e2ef3 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 438d2a3..4e163c5 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 768890d..7abd463 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 8e8696d..1eb875e 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 edf1662..3c31bfd 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 20d1391..4d28916 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 0000000..6d296f0 --- /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