diff --git a/.llms/learnings.md b/.llms/learnings.md index 53e4fa37..11fbe124 100644 --- a/.llms/learnings.md +++ b/.llms/learnings.md @@ -304,8 +304,8 @@ Redux, so it never lands in the persisted `appState.json`. ## Global `li { list-style: none }` hides `list-decimal`/`list-disc` `app.global.css` resets `li` unlayered, so Tailwind list utilities on `
    `/`
  1. ` -lose and numbered lists render bare. Write the numbers as text (see -`CleanExplainer` in `CleanComponent/index.tsx`) or add a scoped class. +lose and numbered lists render bare. Write the numbers as text (see the +loop list in `Clean/CleanDatasetSelect.tsx`) or add a scoped class. ## Headset setup: discovery is time-limited and gesture-bound @@ -447,6 +447,10 @@ auto-answered by re-registering `dialog:showMessage` from that inspector `dialog.showSaveDialog` over the main-process inspector to drive Export. - Browser-tool clicks can leave `:hover` styles stuck on clicked tabs in screenshots; `element.click()` via `evaluate` avoids it. +- The first visit to Collect can trigger Vite's "optimized dependencies + changed. reloading": Redux resets but the hash route stays on `/collect`, so a + run started then has `title: ''` and writes to `~/BrainWaves_Workspaces/Data/`. + Reopen the workspace from Home after any dev reload before recording. ## Prepare: `PrepareSteps` owns the built-in step chrome diff --git a/docs/user-flow.md b/docs/user-flow.md index e2d80706..debb4069 100644 --- a/docs/user-flow.md +++ b/docs/user-flow.md @@ -105,11 +105,13 @@ Custom experiments add Conditions / Trials / Parameters / Instructions. Pick 1 Shown when EEG is enabled. -1. Select a subject and one or more recordings. -2. **Load Dataset** — Pyodide epochs + interactive `EpochReviewer` / `LiveErpPane`. -3. **Clean Data** — reject artifacts, write `.fif`. **Analyze Dataset** is available once `epochsInfo` exists; it is not gated on a drop-percentage threshold. +Pick **one** complete raw recording (ended-early recordings stay hidden until revealed, and can only be deleted to the Trash) → review its trials in `EpochReviewer` / `LiveErpPane` → leave out trials, flag sensors, and **Accept** or **Restore** each auto-flag suggestion (nothing is applied automatically) → **Apply exclusions** saves in place, and **Save cleaned dataset & analyze** always saves before opening Analyze. ### 5. Analyze (`/analyze`) -- **EEG mode** — topoplot (Pyodide/matplotlib SVG), ERP waveforms, behavioral plots (Plotly). -- **Behavior-only mode** — RT / accuracy (bar, box, scatter), outlier removal, export. +Tabs follow the EEG toggle: +- **Overview** (EEG): cleaned-recording checklist, an "Included" summary, and PSD and topography side by side. +- **ERP** (EEG): head-diagram sensor picker, the MNE ERP figure, and the "Walk me through it" walkthrough, drawn from the cleaned epochs. +- **Behavior** (always): response time or accuracy as bars, dots or box plots, outlier removal, and summary CSV export. + +EEG tabs show "Clean first" until a cleaned recording exists. Behavior never gates. diff --git a/src/main/__tests__/recordings.test.ts b/src/main/__tests__/recordings.test.ts index 91476bc5..401b339e 100644 --- a/src/main/__tests__/recordings.test.ts +++ b/src/main/__tests__/recordings.test.ts @@ -3,7 +3,9 @@ import os from 'os'; import path from 'path'; import { afterEach, beforeEach, describe, expect, it } from 'vitest'; import { + incompleteRecordingFiles, isBehaviorFile, + isIncompleteRawEEGFile, isRawEEGFile, markRecordingIncomplete, recordingExists, @@ -11,7 +13,9 @@ import { let dir: string; beforeEach(() => { - dir = fs.mkdtempSync(path.join(os.tmpdir(), 'bw-recordings-')); + dir = fs.realpathSync( + fs.mkdtempSync(path.join(os.tmpdir(), 'bw-recordings-')) + ); }); afterEach(() => fs.rmSync(dir, { recursive: true, force: true })); @@ -20,10 +24,11 @@ const write = (rel: string) => { fs.mkdirSync(path.dirname(file), { recursive: true }); fs.writeFileSync(file, 'x'); }; +/** Workspace CSVs relative to `dir`, with `/` separators (tests alias `path` to `pathe`). */ const csvFiles = () => - (fs.readdirSync(dir, { recursive: true }) as string[]).filter((f) => - f.endsWith('.csv') - ); + (fs.readdirSync(dir, { recursive: true }) as string[]) + .map(path.normalize) + .filter((f) => f.endsWith('.csv')); describe('recordings', () => { it('complete recordings are discovered', () => { @@ -55,4 +60,68 @@ describe('recordings', () => { expect(csvFiles().filter(isBehaviorFile)).toEqual([]); expect(recordingExists(dir, 'P1', 'A', 1)).toBe(true); }); + + it('an ended-early run is listed as incomplete and deletes with its behavior and events siblings', () => { + write('Data/P1/Behavior/P1-A-1-behavior.csv'); + write('Data/P1/EEG/P1-A-1-raw.csv'); + write('Data/P1/EEG/P1-A-1-events.json'); + + markRecordingIncomplete(dir, 'P1', 'A', 1); + + const eeg = path.join(dir, 'Data/P1/EEG/P1-A-1-raw.incomplete.csv'); + expect(csvFiles().filter(isIncompleteRawEEGFile)).toEqual([ + path.join('Data/P1/EEG/P1-A-1-raw.incomplete.csv'), + ]); + expect(incompleteRecordingFiles(dir, eeg).map(path.normalize)).toEqual([ + eeg, + path.join(dir, 'Data/P1/Behavior/P1-A-1-behavior.incomplete.csv'), + path.join(dir, 'Data/P1/EEG/P1-A-1-events.json'), + ]); + }); + + it('refuses to delete anything but an ended-early EEG file inside Data', () => { + write('Data/P1/EEG/P1-A-1-raw.csv'); + write('Other/P1-A-1-raw.incomplete.csv'); + + expect(() => + incompleteRecordingFiles( + dir, + path.join(dir, 'Data/P1/EEG/P1-A-1-raw.csv') + ) + ).toThrow(); + expect(() => + incompleteRecordingFiles( + dir, + path.join(dir, 'Other/P1-A-1-raw.incomplete.csv') + ) + ).toThrow(); + expect(() => + incompleteRecordingFiles( + dir, + `${dir}/Data/../Other/P1-A-1-raw.incomplete.csv` + ) + ).toThrow(); + }); + + it('refuses symlinks out of Data, the wrong depth, and directories', () => { + write('Other/P1-A-1-raw.incomplete.csv'); + write('Data/P1-A-1-raw.incomplete.csv'); + fs.mkdirSync(path.join(dir, 'Data/P1/EEG/P1-A-2-raw.incomplete.csv'), { + recursive: true, + }); + fs.symlinkSync( + path.join(dir, 'Other/P1-A-1-raw.incomplete.csv'), + path.join(dir, 'Data/P1/EEG/P1-A-1-raw.incomplete.csv') + ); + + for (const rel of [ + 'Data/P1/EEG/P1-A-1-raw.incomplete.csv', + 'Data/P1-A-1-raw.incomplete.csv', + 'Data/P1/EEG/P1-A-2-raw.incomplete.csv', + ]) { + expect(() => + incompleteRecordingFiles(dir, path.join(dir, rel)) + ).toThrow(); + } + }); }); diff --git a/src/main/index.ts b/src/main/index.ts index 86dba357..925ae842 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -45,7 +45,9 @@ import type { } from '../shared/lslTypes'; import { importExperimentFile } from './importExperimentFile'; import { + incompleteRecordingFiles, isBehaviorFile, + isIncompleteRawEEGFile, isRawEEGFile, markRecordingIncomplete, recordingExists, @@ -119,7 +121,13 @@ const getStimulusFileAccess = () => { return stimulusFileAccess; }; -const getWorkspaceDir = (title: string) => path.join(workspaces, title); +/** A workspace's folder; `title` comes from the renderer, so it must be one path segment. */ +const getWorkspaceDir = (title: string) => { + if (!title || title === '.' || title === '..' || title !== path.basename(title)) { + throw new Error(`Invalid workspace title: ${title}`); + } + return path.join(workspaces, title); +}; const mkdirPathSync = (dirPath: string) => fs.mkdirSync(dirPath, { recursive: true }); @@ -248,6 +256,24 @@ ipcMain.handle('fs:readWorkspaceRawEEGData', (_event, title) => { } }); +/** Ended-early raw EEG runs, which `fs:readWorkspaceRawEEGData` leaves out. */ +ipcMain.handle('fs:readWorkspaceIncompleteEEGData', (_event, title) => { + try { + const files = fs.readdirSync(getWorkspaceDir(title), { + recursive: true, + }) as string[]; + return files + .filter(isIncompleteRawEEGFile) + .map((filepath) => { + const fullPath = path.join(getWorkspaceDir(title), filepath); + return { name: path.basename(filepath), path: fullPath }; + }); + } catch (e: unknown) { + if ((e as NodeJS.ErrnoException).code === 'ENOENT') console.log(e); + return []; + } +}); + ipcMain.handle('fs:readWorkspaceCleanedEEGData', (_event, title) => { try { const files = fs.readdirSync(getWorkspaceDir(title), { @@ -352,7 +378,20 @@ ipcMain.handle( ); ipcMain.handle('fs:deleteWorkspaceDir', (_event, title) => - shell.trashItem(path.join(workspaces, title)) + shell.trashItem(getWorkspaceDir(title)) +); + +/** Moves one ended-early run (EEG file and behavior sibling) to the Trash. */ +ipcMain.handle( + 'fs:deleteIncompleteRecording', + async (_event, title: string, eegPath: string) => { + for (const file of incompleteRecordingFiles( + getWorkspaceDir(title), + eegPath + )) { + await shell.trashItem(file); + } + } ); ipcMain.handle( diff --git a/src/main/recordings.ts b/src/main/recordings.ts index 341d564f..011f5304 100644 --- a/src/main/recordings.ts +++ b/src/main/recordings.ts @@ -26,6 +26,60 @@ export const isRawEEGFile = (file: string) => file.endsWith('raw.csv'); /** Behavior files Analyze and the workflow badges may offer; never incomplete ones. */ export const isBehaviorFile = (file: string) => file.endsWith('behavior.csv'); +/** Ended-early raw EEG recordings, which Clean lists only so they can be deleted. */ +export const isIncompleteRawEEGFile = (file: string) => + file.endsWith(`-raw${INCOMPLETE}`); + +/** + * Files `fs:deleteIncompleteRecording` trashes for one ended-early run: the EEG + * file plus, when present, its `Behavior/-behavior.incomplete.csv` and + * `EEG/-events.json` siblings. `eegPath` comes from the renderer, so after + * resolving symlinks it must be a regular file at exactly + * `/Data//EEG/-raw.incomplete.csv` (and each sibling + * a regular file in its expected folder); otherwise throws. + */ +export const incompleteRecordingFiles = ( + workspaceDir: string, + eegPath: string +) => { + const reject = (): never => { + throw new Error( + `Not an ended-early recording in this workspace: ${eegPath}` + ); + }; + const dataDir = fs.realpathSync(path.join(workspaceDir, 'Data')); + const eeg = fs.realpathSync(eegPath); + const [subject, folder, file, ...rest] = path + .relative(dataDir, eeg) + .split(path.sep); + if ( + rest.length > 0 || + !file || + subject === '..' || + folder !== 'EEG' || + !isIncompleteRawEEGFile(file) || + !fs.statSync(eeg).isFile() + ) { + reject(); + } + const stem = file.slice(0, -`-raw${INCOMPLETE}`.length); + const siblings = [ + path.join(dataDir, subject, 'Behavior', `${stem}-behavior${INCOMPLETE}`), + path.join(dataDir, subject, 'EEG', `${stem}-events.json`), + ].filter((sibling) => fs.existsSync(sibling)); + const realSiblings = siblings.map((sibling) => { + const real = fs.realpathSync(sibling); + if ( + path.dirname(real) !== path.dirname(sibling) || + !fs.statSync(real).isFile() + ) { + reject(); + } + return real; + }); + return [eeg, ...realSiblings]; +}; + /** True when any artifact of this session exists, complete or ended early. */ export const recordingExists = ( workspaceDir: string, diff --git a/src/preload/index.ts b/src/preload/index.ts index 7e8c250c..fe24343d 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -67,6 +67,9 @@ contextBridge.exposeInMainWorld('electronAPI', { readWorkspaceRawEEGData: (title: string) => ipcRenderer.invoke('fs:readWorkspaceRawEEGData', title), + readWorkspaceIncompleteEEGData: (title: string) => + ipcRenderer.invoke('fs:readWorkspaceIncompleteEEGData', title), + readWorkspaceCleanedEEGData: (title: string) => ipcRenderer.invoke('fs:readWorkspaceCleanedEEGData', title), @@ -118,6 +121,9 @@ contextBridge.exposeInMainWorld('electronAPI', { deleteWorkspaceDir: (title: string): Promise => ipcRenderer.invoke('fs:deleteWorkspaceDir', title), + deleteIncompleteRecording: (title: string, eegPath: string): Promise => + ipcRenderer.invoke('fs:deleteIncompleteRecording', title, eegPath), + importExperimentFile: ( title: string, sourcePath: string diff --git a/src/renderer/actions/pyodideActions.ts b/src/renderer/actions/pyodideActions.ts index 8c3a8b53..6bcd50eb 100644 --- a/src/renderer/actions/pyodideActions.ts +++ b/src/renderer/actions/pyodideActions.ts @@ -37,7 +37,8 @@ export const PyodideActions = { SetPyodideWorker: createAction( 'SET_PYODIDE_WORKER' ), - LoadEpochs: createAction('LOAD_EPOCHS'), + /** One raw recording path: a flagged sensor belongs to one recording. */ + LoadEpochs: createAction('LOAD_EPOCHS'), LoadCleanedEpochs: createAction( 'LOAD_CLEANED_EPOCHS' ), diff --git a/src/renderer/components/AppShell/WorkspaceAreaGate.tsx b/src/renderer/components/AppShell/WorkspaceAreaGate.tsx index c082692b..d7cf0f83 100644 --- a/src/renderer/components/AppShell/WorkspaceAreaGate.tsx +++ b/src/renderer/components/AppShell/WorkspaceAreaGate.tsx @@ -35,7 +35,12 @@ export default function WorkspaceAreaGate({ area, children }: Props) { const navigate = useNavigate(); const modality = experiment.isEEGEnabled ? 'eeg' : 'behavior'; - const { counts } = useWorkspaceProgress(experiment.title, modality, pathname); + const { counts } = useWorkspaceProgress( + experiment.title, + modality, + pathname, + 0 + ); if (counts === null) return null; diff --git a/src/renderer/components/AppShell/useWorkspaceProgress.ts b/src/renderer/components/AppShell/useWorkspaceProgress.ts index 3cb8acc9..2d3a7bfa 100644 --- a/src/renderer/components/AppShell/useWorkspaceProgress.ts +++ b/src/renderer/components/AppShell/useWorkspaceProgress.ts @@ -47,12 +47,15 @@ export function summarize( /** * Reads the workspace folder and derives AppShell's `badges` / `nextArea`. - * Refreshes on navigation; an unreadable or missing folder counts as zero. + * Refreshes on navigation and whenever `refresh` changes (e.g. a cleaned save + * lands without leaving the screen); an unreadable or missing folder counts as + * zero. */ export function useWorkspaceProgress( title: string | undefined, modality: Modality, - pathname: string + pathname: string, + refresh: number ): WorkspaceProgress { const [progress, setProgress] = useState(EMPTY); @@ -83,7 +86,7 @@ export function useWorkspaceProgress( return () => { cancelled = true; }; - }, [title, modality, pathname]); + }, [title, modality, pathname, refresh]); return progress; } diff --git a/src/renderer/components/Clean/CleanDatasetSelect.tsx b/src/renderer/components/Clean/CleanDatasetSelect.tsx index dac050ce..c57ec162 100644 --- a/src/renderer/components/Clean/CleanDatasetSelect.tsx +++ b/src/renderer/components/Clean/CleanDatasetSelect.tsx @@ -127,9 +127,6 @@ export default function CleanDatasetSelect({ /> {recording.subject} {recording.name} - - {recording.duration} -
  2. ); @@ -184,7 +181,7 @@ export default function CleanDatasetSelect({ = { 1: { title: 'Leave out noisy trials', - body: 'Every column in the Epochs panel is one trial. Click a noisy one to leave it out — click it again to bring it back.', + body: 'Every column in the Trials panel is one trial. Click a noisy one to leave it out — click it again to bring it back.', pointer: 'Click a noisy trial column to leave it out', }, 2: { diff --git a/src/renderer/components/Clean/CleanReview.tsx b/src/renderer/components/Clean/CleanReview.tsx index bf0492ec..2eb0ad82 100644 --- a/src/renderer/components/Clean/CleanReview.tsx +++ b/src/renderer/components/Clean/CleanReview.tsx @@ -7,11 +7,7 @@ import { Button } from '../ui/button'; import { Spinner } from '../ui/spinner'; import { RailSection, ResultStatus, railLabel } from '../Analyze/AnalyzeParts'; import { CleanLayout, ConfirmDialog, FitPane } from './CleanParts'; -import { - CleanPrimerPanel, - PrimerPointer, - PrimerStep, -} from './CleanPrimer'; +import { CleanPrimerPanel, PrimerPointer, PrimerStep } from './CleanPrimer'; import type { EpochArrays } from './fixtures'; /** Which `CleanComponent` confirmation is open, restyled as an in-app dialog. */ @@ -35,7 +31,7 @@ export interface CleanReviewProps { dataset: { subject: string; recording: string }; /** Epochs as `pyodide.epochArrays` holds them; null while loading. */ epochArrays: EpochArrays | null; - /** `loading` and `no-epochs` replace the review area; the rail stays usable. */ + /** `loading` and `no-epochs` replace the review area and disable saving; the rail stays usable. */ status: 'ready' | 'loading' | 'no-epochs'; codeToLabel: Record; /** ABSOLUTE epoch indices left out, including accepted suggestions. */ @@ -79,6 +75,7 @@ export default function CleanReview(props: CleanReviewProps) { const acceptedCount = props.suggestions.filter((s) => s.accepted).length; const kept = total - props.rejected.size; const { dataset } = props; + const busy = props.saveState === 'saving'; const rail = ( <> @@ -91,7 +88,10 @@ export default function CleanReview(props: CleanReviewProps) { ← Pick different data - +
    More flags props.onThresholdChange(Number(e.target.value))} + disabled={busy} className="flex-1 accent-brand" /> Fewer @@ -115,7 +116,10 @@ export default function CleanReview(props: CleanReviewProps) { Suggest noisy trials - + {total === 0 ? (
    Counts show up once the trials are loaded. @@ -168,7 +172,12 @@ export default function CleanReview(props: CleanReviewProps) { -
    @@ -183,17 +192,30 @@ export default function CleanReview(props: CleanReviewProps) { Your original recording is unchanged. The cleaned copy is ready to use in Analyze.
    - )} {props.saveState === 'idle' && (
    - -
    @@ -226,7 +248,11 @@ export default function CleanReview(props: CleanReviewProps) { - @@ -281,7 +307,7 @@ export default function CleanReview(props: CleanReviewProps) { {props.suggestions.length > 0 && (
    Suggested by auto-flag @@ -289,7 +315,7 @@ export default function CleanReview(props: CleanReviewProps) { Suggestions, not decisions — you decide.
    -
      +
        {props.suggestions.map((suggestion) => (
      • Trial {suggestion.index} - + {suggestion.reason} {suggestion.accepted ? ( <> - ✓ Left out (from a suggestion) + ✓ Left out - ); } - function renderSelect(filteredFilePaths: DropdownOption[]) { - return ( -
        -

        Clean your data

        -

        - Remove the noisy bits of a recording and save a cleaned copy. Analyze - uses that cleaned copy to make your results. -

        - -

        Select Subject

        - -

        Select Recordings

        - - -
        - ); + function handleConfirmAccept() { + setConfirm(null); + if (confirm !== 'dropChannels') save([...rejected]); } - function renderReview( - codeToLabel: Record, - suggestedRejections: SuggestedRejection[] - ) { - const hasEpochs = !isNil(props.epochArrays); - const nRecordings = selectedFilePaths.length; + if (view === 'select') { return ( - <> -
        - -

        Clean your data

        - - {selectedSubject} · {nRecordings} recording - {nRecordings === 1 ? '' : 's'} - -
        - - - -
        - - - -
        {renderAnalyzeButton()}
        -
        - - {showAutoFlagSettings && ( -
        - -
        - More flags - - Fewer flags -
        -

        - Flag epochs whose peak-to-peak amplitude exceeds{' '} - {autoFlagThreshold} µV. -

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

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

        -
          - {suggestedRejections.slice(0, 3).map((s, i) => ( -
        • {s.reason}
        • - ))} -
        -
        - )} - -
        {renderStats()}
        - - {hasEpochs ? ( -
        - - -
        - ) : ( -
        - Loading your epochs… 🧠 -
        - )} - + void handleDeleteConfirm()} + onDeleteCancel={() => setDeletingRecording(null)} + onStart={handleStart} + /> ); } - const filteredFilePaths = eegFilePaths.filter((filepath) => { - const strVal = filepath.value; - const subjectFromFilepath = strVal.split(path.sep)[ - strVal.split(path.sep).length - 3 - ]; - return selectedSubject === subjectFromFilepath; - }); - - const codeToLabel = codeToLabelFor(props.params); - const { suggestedRejections } = props; - return ( -
        - {view === 'select' - ? renderSelect(filteredFilePaths) - : renderReview(codeToLabel, suggestedRejections)} -
        + { + if (saving) return; + edited(); + setRejected((prev) => { + const next = new Set(prev); + if (!next.delete(index)) next.add(index); + return next; + }); + }} + onToggleChannel={handleToggleChannel} + autoFlagThreshold={autoFlagThreshold} + onThresholdChange={setAutoFlagThreshold} + suggestions={props.suggestedRejections.map((s) => ({ + ...s, + accepted: rejected.has(s.index), + }))} + onAcceptSuggestion={(index) => { + edited(); + setRejected((prev) => new Set(prev).add(index)); + }} + onRestoreSuggestion={(index) => { + edited(); + setRejected((prev) => { + const next = new Set(prev); + next.delete(index); + return next; + }); + }} + onSuggest={() => + props.PyodideActions.GetSuggestedRejections(autoFlagThreshold) + } + saveState={saveState} + onApply={handleApply} + onSave={handleSave} + onRetrySave={() => save([])} + onGoToAnalyze={() => navigate(SCREENS.ANALYZE.route)} + onGoToCollect={() => navigate(AREA_ROUTES.collect)} + onBackToSelection={() => setView('select')} + confirm={confirm} + onConfirmAccept={handleConfirmAccept} + onConfirmCancel={() => setConfirm(null)} + primerOpen={primerOpen} + primerStep={primerStep} + onPrimerOpenChange={setPrimerOpen} + onPrimerStepChange={setPrimerStep} + /> ); } diff --git a/src/renderer/containers/AppShellContainer.tsx b/src/renderer/containers/AppShellContainer.tsx index 76d0616a..9cb4578b 100644 --- a/src/renderer/containers/AppShellContainer.tsx +++ b/src/renderer/containers/AppShellContainer.tsx @@ -66,6 +66,9 @@ export default function AppShellContainer({ const experiment = useSelector((state: RootState) => state.experiment); const device = useSelector((state: RootState) => state.device); const recordsEEG = useSelector(selectRecordsEEG); + const cleanedSaveRevision = useSelector( + (state: RootState) => state.pyodide.cleanedEpochsSave.revision + ); const modality: Modality = experiment.isEEGEnabled ? 'eeg' : 'behavior'; const workspace = experiment.title @@ -79,7 +82,8 @@ export default function AppShellContainer({ const { badges, next } = useWorkspaceProgress( workspace?.name, modality, - pathname + pathname, + cleanedSaveRevision ); const { isRunning } = experiment; diff --git a/src/renderer/containers/CleanContainer.ts b/src/renderer/containers/CleanContainer.ts index 80de3766..762a2bdc 100644 --- a/src/renderer/containers/CleanContainer.ts +++ b/src/renderer/containers/CleanContainer.ts @@ -8,13 +8,9 @@ import { RootState } from '../store'; function mapStateToProps(state: RootState) { return { - type: state.experiment.type, title: state.experiment.title, - 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 2c957efa..20286afa 100644 --- a/src/renderer/epics/pyodideEpics.ts +++ b/src/renderer/epics/pyodideEpics.ts @@ -196,12 +196,11 @@ const loadEpochsEpic: Epic = ( action$.pipe( filter(isActionOf(PyodideActions.LoadEpochs)), pluck('payload'), - filter((filePathsArray: string[]) => filePathsArray.length >= 1), - mergeMap(async (filePathsArray) => { + mergeMap(async (filePath) => { const worker = state$.value.pyodide.worker!; // readFiles is async — await before posting csvArray to the worker. // (An unresolved Promise into postMessage throws DataCloneError.) - const csvArray = await readFiles(filePathsArray); + const csvArray = await readFiles([filePath]); // Queue processing messages in order; the worker runs them sequentially. loadCSV(worker, csvArray); filterIIR(worker, 1, 30); diff --git a/src/renderer/reducers/pyodideReducer.ts b/src/renderer/reducers/pyodideReducer.ts index 6547259e..cd5a7c31 100644 --- a/src/renderer/reducers/pyodideReducer.ts +++ b/src/renderer/reducers/pyodideReducer.ts @@ -106,6 +106,11 @@ export default createReducer(initialState, (builder) => failedPlots: state.failedPlots.filter((key) => key !== 'erp'), }; }) + .addCase(PyodideActions.LoadEpochs, (state) => ({ + ...state, + epochArrays: null, + suggestedRejections: [], + })) .addCase(PyodideActions.SetEpochArrays, (state, action) => { // New epoch arrays → any prior auto-flag suggestions are stale. return { ...state, epochArrays: action.payload, suggestedRejections: [] }; diff --git a/src/renderer/types/electron.d.ts b/src/renderer/types/electron.d.ts index c30b9c70..817ae30f 100644 --- a/src/renderer/types/electron.d.ts +++ b/src/renderer/types/electron.d.ts @@ -32,6 +32,9 @@ declare global { readWorkspaceRawEEGData: ( title: string ) => Promise>; + readWorkspaceIncompleteEEGData: ( + title: string + ) => Promise>; readWorkspaceCleanedEEGData: ( title: string ) => Promise>; @@ -61,6 +64,10 @@ declare global { rawData: ArrayBuffer ) => Promise; deleteWorkspaceDir: (title: string) => Promise; + deleteIncompleteRecording: ( + title: string, + eegPath: string + ) => Promise; importExperimentFile: ( title: string, sourcePath: string diff --git a/src/renderer/utils/filesystem/storage.ts b/src/renderer/utils/filesystem/storage.ts index ef7de027..00bf11d2 100644 --- a/src/renderer/utils/filesystem/storage.ts +++ b/src/renderer/utils/filesystem/storage.ts @@ -83,6 +83,12 @@ export const readWorkspaces = (): Promise => api().readWorkspaces(); export const readWorkspaceRawEEGData = (title: string) => api().readWorkspaceRawEEGData(title); +/** Ended-early runs (`*-raw.incomplete.csv`), which the raw listing leaves out. */ +export const readWorkspaceIncompleteEEGData = ( + title: string +): Promise> => + api().readWorkspaceIncompleteEEGData(title); + export const readWorkspaceCleanedEEGData = (title: string) => api().readWorkspaceCleanedEEGData(title); @@ -122,6 +128,12 @@ export const getSubjectNamesFromFiles = (filePaths: string[]): string[] => export const deleteWorkspaceDir = (title: string): Promise => api().deleteWorkspaceDir(title); +/** Moves one ended-early run and its behavior sibling to the Trash. */ +export const deleteIncompleteRecording = ( + title: string, + eegPath: string +): Promise => api().deleteIncompleteRecording(title, eegPath); + /** First session number, starting at `session`, with no behavior or EEG file on disk. */ export const nextFreeSession = async ( title: string, diff --git a/src/renderer/utils/webworker/utils.py b/src/renderer/utils/webworker/utils.py index 9f1bc5bd..028b8926 100644 --- a/src/renderer/utils/webworker/utils.py +++ b/src/renderer/utils/webworker/utils.py @@ -276,10 +276,12 @@ def plot_conditions(epochs, palette, ch_ind=0, conditions=OrderedDict(), 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. + Writes the raw EEG epoch samples (Marker/stim channel excluded) in + microvolts as a flat little-endian float32 buffer to `out_path` and returns + metadata describing the buffer's shape and per-epoch/per-channel labels. + Channels in `info['bads']` stay in the buffer so the viewer can show them + flagged (and the student can un-flag them). `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 @@ -296,11 +298,7 @@ def get_epochs_arrays(epochs, out_path): 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) - # 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. + picks = pick_types(epochs.info, eeg=True, exclude=[]) data = epochs.get_data(picks=picks) * 1e6 # (n_epochs, n_channels, n_times) data = np.ascontiguousarray(data.astype(np.float32)) @@ -337,14 +335,14 @@ def apply_rejection(epochs, drop_indices, bad_channels): 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']. + bad_channels : list[str] -- the full set of bad channel names; replaces + info['bads'], so an empty list clears sensors un-flagged since the last save. 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) + epochs.info['bads'] = list(bad_channels) if drop_indices: epochs.drop(list(drop_indices)) return epochs @@ -356,7 +354,7 @@ def suggest_rejections(epochs, threshold_uv): 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 + requires the user to accept each one; the real drop goes through apply_rejection so the saved data stays MNE-exact. Returns list[dict] with keys: index (int, 0-based into the CURRENT epochs, diff --git a/tests/analysis/test_apply_rejection.py b/tests/analysis/test_apply_rejection.py index 6d296f06..f36719b3 100644 --- a/tests/analysis/test_apply_rejection.py +++ b/tests/analysis/test_apply_rejection.py @@ -65,6 +65,16 @@ def test_empty_args_are_no_ops(): assert result.info["bads"] == [] +def test_bad_channels_replace_existing_bads(): + epochs = _build_epochs() + epochs.info["bads"] = ["TP9"] + + assert utils.apply_rejection(epochs, [], []).info["bads"] == [] + + epochs.info["bads"] = ["TP9"] + assert utils.apply_rejection(epochs, [], ["AF7"]).info["bads"] == ["AF7"] + + def test_returns_the_same_object(): e = _build_epochs().copy() assert utils.apply_rejection(e, [], []) is e diff --git a/tests/analysis/test_epoch_arrays.py b/tests/analysis/test_epoch_arrays.py index 7c18df3a..62c6dd4d 100644 --- a/tests/analysis/test_epoch_arrays.py +++ b/tests/analysis/test_epoch_arrays.py @@ -71,3 +71,16 @@ def test_byte_length_matches_shape(tmp_path): assert n_bytes == ( meta["n_epochs"] * meta["n_channels"] * meta["n_times"] * 4 ) + + +def test_bad_channels_stay_in_the_buffer(tmp_path): + epochs = _build_epochs() + n_channels = utils.get_epochs_arrays(epochs, str(tmp_path / "a.f32"))[ + "n_channels" + ] + epochs.info["bads"] = ["AF7"] + + meta = utils.get_epochs_arrays(epochs, str(tmp_path / "b.f32")) + + assert "AF7" in meta["ch_names"] + assert meta["n_channels"] == n_channels