From 7b521feecd21fb0aba04b6578e4858c9892c4426 Mon Sep 17 00:00:00 2001 From: jdpigeon Date: Sun, 15 Mar 2026 19:02:34 -0400 Subject: [PATCH] Fix Pyodide analysis pipeline end-to-end MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - dataKey routing for epochs/channel info (fire-and-forget pattern) - launchEpic sequences init: patches → applyPatches → loadUtils - loadTopoEpic calls plotTopoMap (not plotTestPlot) - saveEpochs closing parenthesis fix - loadCleanedEpochs uses MEMFS via writeEpochsToMemfs + IPC readFileAsBytes - channel index 0 falsy bug: if (index !== null) - renderAnalyzeButton shows when epochsInfo.length > 0 - plotPSD uses MNE 1.x API: compute_psd().plot() - webworker.js: fsFiles MEMFS writes + dataKey passthrough - eslint.config.mjs: ignore src/renderer/utils/pyodide/** (prevents linter hang on pyodide.asm.js) - Prettier formatting fixes across modified files Co-Authored-By: Claude Sonnet 4.6 --- .llms/pyodide-fix-plan.md | 213 ++++++++++++++++++ eslint.config.mjs | 1 + src/main/index.ts | 38 +++- src/preload/index.ts | 10 +- src/renderer/actions/deviceActions.ts | 6 +- .../components/CleanComponent/index.tsx | 18 +- .../CollectComponent/ConnectModal.tsx | 4 +- .../components/CollectComponent/index.tsx | 4 +- .../DesignComponent/CustomDesignComponent.tsx | 9 +- .../HomeComponent/OverviewComponent.tsx | 3 +- .../components/HomeComponent/index.tsx | 4 +- src/renderer/components/InputCollect.tsx | 8 +- src/renderer/components/PyodidePlotWidget.tsx | 23 +- src/renderer/epics/deviceEpics.ts | 9 +- src/renderer/epics/pyodideEpics.ts | 137 +++++------ src/renderer/utils/webworker/index.ts | 45 ++-- src/renderer/utils/webworker/webworker.js | 26 ++- 17 files changed, 436 insertions(+), 122 deletions(-) create mode 100644 .llms/pyodide-fix-plan.md diff --git a/.llms/pyodide-fix-plan.md b/.llms/pyodide-fix-plan.md new file mode 100644 index 00000000..3d1a28d2 --- /dev/null +++ b/.llms/pyodide-fix-plan.md @@ -0,0 +1,213 @@ +# Pyodide Analysis Workflow — Fix Plan + +**Review date:** 2026-03-15 +**Reviewer:** Claude Code (plan-eng-review) +**Base branch:** `main` +**Coworker branch:** `teonbrooks/pyodide-upgrade` (fetch from `git@github.com:teonbrooks/BrainWaves.git`) + +--- + +## Context + +Analysis workflow (EEG data → Pyodide → plots) was completely broken due to stacked bugs. +`teonbrooks/pyodide-upgrade` has QA'd Pyodide loading in-browser and addressed infrastructure issues. +This plan documents what that branch resolves and what still needs to be applied on top. + +--- + +## What `teonbrooks/pyodide-upgrade` has already fixed + +| Bug | Status | +|-----|--------| +| Pyodide from npm (0.29.3), ESM worker, `import { loadPyodide }` | ✅ Done | +| Local HTTP asset server (port 17173) to serve .whl files past Vite SPA fallback | ✅ Done | +| Vite `serve-pyodide-assets` dev middleware | ✅ Done | +| `patches.py` Pyolite relative import — removed `patch_matplotlib` entirely; MPLBACKEND set in worker init | ✅ Done | +| `csvArray` scope — now passed in message context, not on `window` | ✅ Done | +| Inverted error toast (`if (results && !error)`) — fixed | ✅ Done | +| `plotKey` routing in worker + `pyodideMessageEpic` — plots routed directly to Redux slots | ✅ Done | +| SVG output for all plots (PSD, Topo, ERP, test) | ✅ Done | +| `plotTopoMap` restored in `index.ts` (SVG version) | ✅ Done | +| Directory renamed: `utils/pyodide/` → `utils/webworker/` | ✅ Done | +| New IPC handlers: `fs:storePyodideImageSvg`, `fs:storePyodideImagePng` | ✅ Done | + +--- + +## What still needs to be fixed (apply on top of teonbrooks branch) + +### CRITICAL — analysis data pipeline still broken + +#### 1. `requestEpochsInfo` / `requestChannelInfo` still return `void` +**File:** `src/renderer/utils/webworker/index.ts` +**Epics affected:** `getEpochsInfoEpic`, `getChannelInfoEpic` + +`worker.postMessage()` returns `void`. Both functions `await worker.postMessage(...)`, getting `undefined`. +The epic then calls `.map()` on `undefined` → throws. + +**Fix approach:** Extend the `plotKey` pattern with a `dataKey` for data-returning calls. +Worker sends `{ results, dataKey }`. `pyodideMessageEpic` routes `dataKey: 'epochsInfo'` → +`SetEpochInfo`, `dataKey: 'channelInfo'` → `SetChannelInfo`. +Update `loadEpochsEpic` and `loadCleanedEpochsEpic` to use `dataKey` in the message. + +``` +loadEpochsEpic flow (fixed): + LoadEpochs action + → loadCSV (fire-and-forget, no return needed) + → filterIIR (fire-and-forget) + → epochEvents (fire-and-forget) + → worker.postMessage({ data: 'get_epochs_info(raw_epochs)', dataKey: 'epochsInfo' }) + └─ worker responds { results: [...], dataKey: 'epochsInfo' } + └─ pyodideMessageEpic routes → SetEpochInfo + → loadEpochsEpic emits EMPTY (like plot epics) +``` + +#### 2. `launchEpic` race condition +**File:** `src/renderer/epics/pyodideEpics.ts` + +`loadPatches`, `applyPatches`, `loadUtils` all fire simultaneously in `tap()`. +Even with patches.py simplified, `applyPatches()` can still NameError if patches.py hasn't +finished running. + +**Fix:** Make `launchEpic` dispatch `SetPyodideWorker` only after `loadUtils` signals ready via +the existing `plotKey: 'ready'` mechanism. The epic should wait for `SetWorkerReady` before +completing, not fire-and-forget in tap. + +OR: simpler — sequence the init via `mergeMap(async (worker) => { ... await run each step ... })`. +The `plotKey: 'ready'` approach is also fine if the app gates analysis on `workerReady` state. + +#### 3. `loadTopoEpic` still uses `plotTestPlot`, not `plotTopoMap` +**File:** `src/renderer/epics/pyodideEpics.ts:239` +The `plotTopoMap` function exists in `index.ts` (SVG version). The epic still calls `plotTestPlot`. +One-line fix: `tap(() => plotTopoMap(state$.value.pyodide.worker!))`. + +### SIGNIFICANT — bugs that cause silent failures + +#### 4. `saveEpochs` missing closing parenthesis +**File:** `src/renderer/utils/webworker/index.ts` (saveEpochs function) + +Generated Python: `raw_epochs.save("/path/to/file"` — SyntaxError, no closing `)`. +Fix: add `)` before the closing backtick of the template literal. + +#### 5. `loadCleanedEpochs` passes OS paths to WASM filesystem +**File:** `src/renderer/utils/webworker/index.ts` (loadCleanedEpochs function) + +`read_epochs(file)` receives `/Users/dano/BrainWaves_Workspaces/.../subj-cleaned-epo.fif`. +Pyodide's WASM filesystem has no access to host OS paths → `FileNotFoundError`. + +**Fix:** Read `.fif` file bytes via IPC (`window.electronAPI.readFile(path)` returning `Uint8Array`), +write to Pyodide MEMFS via `pyodide.FS.writeFile('/tmp/subj.fif', bytes)`, pass `/tmp/subj.fif` +to `read_epochs`. Requires: +- New IPC handler: `fs:readFileAsBytes` in `src/main/index.ts` +- New helper in `index.ts`: `writeEpochsToMemfs(worker, filePaths)` +- Update `loadCleanedEpochs` to use MEMFS paths + +#### 6. Channel index 0 falsy bug +**File:** `src/renderer/epics/pyodideEpics.ts:268` + +```typescript +if (index) { return index; } // 0 is falsy → first channel always wrong +``` +Fix: `if (index !== null)`. + +#### 7. `renderAnalyzeButton` drop threshold inverted +**File:** `src/renderer/components/CleanComponent/index.tsx:131` + +```typescript +if (drop && typeof drop === 'number' && drop >= 2) { // shows button when data is bad +``` +Fix: show button when `epochsInfo !== null` (any loaded data). User sees the drop % and +can judge themselves. + +### MINOR — API compatibility + +#### 8. `raw.plot_psd()` deprecated MNE API +**File:** `src/renderer/utils/webworker/index.ts` (plotPSD function) + +`raw.plot_psd(fmin=1, fmax=30, show=False)` was deprecated in MNE 1.0 and removed in 1.2+. +Pyodide 0.29.3 ships MNE 1.x. Fix: `raw.compute_psd(fmin=1, fmax=30).plot(show=False)`. + +--- + +## `SetWorkerReady` action — verify it exists +**File:** `src/renderer/actions/pyodideActions.ts` + +`pyodideMessageEpic` references `PyodideActions.SetWorkerReady()`. Verify this action is defined. +If not, add it. Consider adding a `workerReady: boolean` field to `PyodideStateType` and gate +analysis dispatch on it. + +--- + +## Data flow diagram (target state) + +``` +User clicks "Load Dataset" (CleanComponent) + ↓ +LoadEpochs action + ↓ +loadEpochsEpic: + loadCSV({ data: 'raw = load_data()', csvArray }) → fire-and-forget + filterIIR({ data: 'raw.filter(...)' }) → fire-and-forget + epochEvents({ data: 'raw_epochs = Epochs(...)' }) → fire-and-forget + worker.postMessage({ data: 'get_epochs_info(raw_epochs)', dataKey: 'epochsInfo' }) + mergeMap(() => EMPTY) + ↓ +pyodideMessageEpic receives { results: [...], dataKey: 'epochsInfo' } + → SetEpochInfo([...]) → Redux state → CleanComponent re-renders stats + +User clicks "Analyze Dataset" (always available when epochsInfo !== null) + ↓ +LoadCleanedEpochs action + ↓ +loadCleanedEpochsEpic: + writeEpochsToMemfs(worker, filePaths) → IPC read bytes → pyodide.FS.writeFile + loadCleanedEpochs({ data: 'clean_epochs = concatenate_epochs(...)' }) + dispatch GetEpochsInfo, GetChannelInfo, LoadTopo + ↓ +Parallel: + getEpochsInfoEpic → { dataKey: 'epochsInfo' } → SetEpochInfo + getChannelInfoEpic → { dataKey: 'channelInfo' } → SetChannelInfo + loadTopoEpic → plotTopoMap({ plotKey: 'topo' }) → worker → SetTopoPlot(svg) + +User selects channel → LoadERP(channelName) + ↓ +loadERPEpic: index lookup (if (index !== null)) → plotERP({ plotKey: 'erp' }) + → worker → SetERPPlot(svg) → AnalyzeComponent renders via PyodidePlotWidget +``` + +--- + +## Files to change + +| File | Changes | +|------|---------| +| `src/renderer/utils/webworker/index.ts` | saveEpochs closing paren; plotPSD API; loadCleanedEpochs MEMFS | +| `src/renderer/epics/pyodideEpics.ts` | dataKey routing for epochs/channel info; launch sequencing; loadTopoEpic; channel index 0 | +| `src/renderer/components/CleanComponent/index.tsx` | renderAnalyzeButton condition | +| `src/renderer/actions/pyodideActions.ts` | Verify/add SetWorkerReady, ReceiveError(string) | +| `src/main/index.ts` | Add `fs:readFileAsBytes` IPC handler | +| `src/renderer/utils/webworker/webworker.js` | Add `dataKey` passthrough (similar to existing `plotKey`) | + +--- + +## Tests to write + +- `__tests__/pyodideWorker.test.ts` — dataKey routing (mock worker, verify SetEpochInfo dispatched) +- `__tests__/pyodideEpics.test.ts` — launch sequencing, loadEpochsEpic chain, channel index 0 fix +- `__tests__/pyodideIndex.test.ts` — Python code string generation: saveEpochs has correct syntax, plotPSD uses correct MNE API + +--- + +## NOT in scope + +- Worker health monitoring / auto-restart on crash +- Cancellation of in-progress analysis +- Progress callbacks +- MNE/pandas version upgrades beyond what Pyodide 0.29.3 ships + +--- + +## TODO (deferred) + +- Pyodide integration test suite: load real Pyodide + MNE in Node/jsdom, run known-good CSV + through the analysis pipeline, verify output shape. High value but non-trivial setup. + Blocked by: Jest/Vitest compatibility with WASM workers. diff --git a/eslint.config.mjs b/eslint.config.mjs index 50539aa0..4462f866 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -24,6 +24,7 @@ export default [ 'coverage/**', '.worktrees/**', 'src/renderer/utils/webworker/src/**', + 'src/renderer/utils/pyodide/**', '**/*.css.d.ts', '**/*.scss.d.ts', ], diff --git a/src/main/index.ts b/src/main/index.ts index 094518d6..e49f924d 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -41,7 +41,11 @@ function startPyodideAssetServer(rootDir: string): void { const server = http.createServer((req, res) => { res.setHeader('Access-Control-Allow-Origin', '*'); res.setHeader('Access-Control-Allow-Methods', 'GET, OPTIONS'); - if (req.method === 'OPTIONS') { res.writeHead(204); res.end(); return; } + if (req.method === 'OPTIONS') { + res.writeHead(204); + res.end(); + return; + } const urlPath = (req.url || '').split('?')[0]; const filePath = path.join(rootDir, urlPath); @@ -53,7 +57,10 @@ function startPyodideAssetServer(rootDir: string): void { res.end(`Not found: ${urlPath}`); return; } - res.setHeader('Content-Type', PYODIDE_CONTENT_TYPES[ext] || 'application/octet-stream'); + res.setHeader( + 'Content-Type', + PYODIDE_CONTENT_TYPES[ext] || 'application/octet-stream' + ); res.setHeader('Content-Length', stat.size); res.setHeader('Cache-Control', 'no-cache'); res.writeHead(200); @@ -62,7 +69,9 @@ function startPyodideAssetServer(rootDir: string): void { }); server.listen(PYODIDE_ASSET_PORT, '127.0.0.1', () => { - console.log(`[main] Pyodide asset server: http://127.0.0.1:${PYODIDE_ASSET_PORT}`); + console.log( + `[main] Pyodide asset server: http://127.0.0.1:${PYODIDE_ASSET_PORT}` + ); }); server.on('error', (err: NodeJS.ErrnoException) => { @@ -145,7 +154,9 @@ ipcMain.handle('fs:readWorkspaces', () => { try { return fs .readdirSync(workspaces) - .filter((workspace) => workspace !== '.DS_Store' && workspace !== 'Test_Plot'); + .filter( + (workspace) => workspace !== '.DS_Store' && workspace !== 'Test_Plot' + ); } catch (e: unknown) { if ((e as NodeJS.ErrnoException).code === 'ENOENT') { mkdirPathSync(workspaces); @@ -267,10 +278,15 @@ ipcMain.handle( const dir = path.join(getWorkspaceDir(title), 'Results', 'Images'); mkdirPathSync(dir); return new Promise((resolve, reject) => { - fs.writeFile(path.join(dir, `${imageTitle}.svg`), svgContent, 'utf8', (err) => { - if (err) reject(err); - else resolve(); - }); + fs.writeFile( + path.join(dir, `${imageTitle}.svg`), + svgContent, + 'utf8', + (err) => { + if (err) reject(err); + else resolve(); + } + ); }); } ); @@ -371,6 +387,12 @@ ipcMain.handle('fs:readFiles', (_event, filePathsArray: string[]) => { }); }); +ipcMain.handle('fs:readFileAsBytes', (_event, filePath: string) => { + // Returns a Uint8Array (Buffer extends Uint8Array) for binary files like .fif. + // Transferred via IPC structured clone — arrives as Uint8Array in the renderer. + return fs.readFileSync(filePath); +}); + // EEG streaming — main process holds write streams for performance ipcMain.handle( 'eeg:createWriteStream', diff --git a/src/preload/index.ts b/src/preload/index.ts index 4eaf4676..7495db76 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -93,7 +93,12 @@ contextBridge.exposeInMainWorld('electronAPI', { imageTitle: string, svgContent: string ): Promise => - ipcRenderer.invoke('fs:storePyodideImageSvg', title, imageTitle, svgContent), + ipcRenderer.invoke( + 'fs:storePyodideImageSvg', + title, + imageTitle, + svgContent + ), storePyodideImagePng: ( title: string, @@ -127,6 +132,9 @@ contextBridge.exposeInMainWorld('electronAPI', { readFiles: (filePathsArray: string[]): Promise => ipcRenderer.invoke('fs:readFiles', filePathsArray), + readFileAsBytes: (filePath: string): Promise => + ipcRenderer.invoke('fs:readFileAsBytes', filePath), + // ------------------------------------------------------------------ // EEG streaming — main process holds the write stream for performance // ------------------------------------------------------------------ diff --git a/src/renderer/actions/deviceActions.ts b/src/renderer/actions/deviceActions.ts index 52c2d07b..d9973079 100644 --- a/src/renderer/actions/deviceActions.ts +++ b/src/renderer/actions/deviceActions.ts @@ -1,6 +1,10 @@ import { createAction } from '@reduxjs/toolkit'; import { ActionType } from 'typesafe-actions'; -import { DEVICES, DEVICE_AVAILABILITY, CONNECTION_STATUS } from '../constants/constants'; +import { + DEVICES, + DEVICE_AVAILABILITY, + CONNECTION_STATUS, +} from '../constants/constants'; import { Device, DeviceInfo } from '../constants/interfaces'; // ------------------------------------------------------------------------- diff --git a/src/renderer/components/CleanComponent/index.tsx b/src/renderer/components/CleanComponent/index.tsx index 9c83d8c8..74fc5060 100644 --- a/src/renderer/components/CleanComponent/index.tsx +++ b/src/renderer/components/CleanComponent/index.tsx @@ -123,18 +123,12 @@ export default class Clean extends Component { renderAnalyzeButton() { const { epochsInfo } = this.props; - if (!isNil(epochsInfo)) { - const drop = epochsInfo.find( - (infoObj) => infoObj.name === 'Drop Percentage' - )?.value; - - if (drop && typeof drop === 'number' && drop >= 2) { - return ( - - - - ); - } + if (!isNil(epochsInfo) && epochsInfo.length > 0) { + return ( + + + + ); } return null; } diff --git a/src/renderer/components/CollectComponent/ConnectModal.tsx b/src/renderer/components/CollectComponent/ConnectModal.tsx index 5a11031e..4864ce86 100644 --- a/src/renderer/components/CollectComponent/ConnectModal.tsx +++ b/src/renderer/components/CollectComponent/ConnectModal.tsx @@ -105,7 +105,9 @@ export default class ConnectModal extends Component { tabIndex={0} className="flex items-center gap-2 py-2 cursor-pointer text-lg" onClick={() => this.setState({ selectedDevice: device })} - onKeyDown={(e) => e.key === 'Enter' && this.setState({ selectedDevice: device })} + onKeyDown={(e) => + e.key === 'Enter' && this.setState({ selectedDevice: device }) + } > {this.state.selectedDevice === device ? '✓' : '○'} {ConnectModal.getDeviceName(device)} diff --git a/src/renderer/components/CollectComponent/index.tsx b/src/renderer/components/CollectComponent/index.tsx index c02b1b27..91d15e62 100644 --- a/src/renderer/components/CollectComponent/index.tsx +++ b/src/renderer/components/CollectComponent/index.tsx @@ -102,7 +102,9 @@ export default class Collect extends Component { open={this.state.isConnectModalOpen} onClose={this.handleConnectModalClose} connectedDevice={this.props.connectedDevice} - signalQualityObservable={this.props.signalQualityObservable ?? undefined} + signalQualityObservable={ + this.props.signalQualityObservable ?? undefined + } deviceType={this.props.deviceType} deviceAvailability={this.props.deviceAvailability} connectionStatus={this.props.connectionStatus} diff --git a/src/renderer/components/DesignComponent/CustomDesignComponent.tsx b/src/renderer/components/DesignComponent/CustomDesignComponent.tsx index 332e985c..dc4dc0c0 100644 --- a/src/renderer/components/DesignComponent/CustomDesignComponent.tsx +++ b/src/renderer/components/DesignComponent/CustomDesignComponent.tsx @@ -231,7 +231,9 @@ export default class CustomDesign extends Component {
- + (en: T) { - return (val: unknown): val is T[keyof T] => Object.values(en).includes(val as T[keyof T]); + return (val: unknown): val is T[keyof T] => + Object.values(en).includes(val as T[keyof T]); } const OverviewComponent: React.FC = ({ diff --git a/src/renderer/components/HomeComponent/index.tsx b/src/renderer/components/HomeComponent/index.tsx index 8381e847..f94755dc 100644 --- a/src/renderer/components/HomeComponent/index.tsx +++ b/src/renderer/components/HomeComponent/index.tsx @@ -325,7 +325,9 @@ export default class Home extends Component { disabled={!this.props.isWorkerReady} onClick={() => this.props.PyodideActions.LoadTopo()} > - {this.props.isWorkerReady ? 'Generate Plot' : 'Loading libraries…'} + {this.props.isWorkerReady + ? 'Generate Plot' + : 'Loading libraries…'}
diff --git a/src/renderer/components/InputCollect.tsx b/src/renderer/components/InputCollect.tsx index 655886e2..990058fe 100644 --- a/src/renderer/components/InputCollect.tsx +++ b/src/renderer/components/InputCollect.tsx @@ -99,7 +99,9 @@ export default class InputCollect extends Component {
- + { />
- + { @@ -27,11 +30,17 @@ function svgToPngArrayBuffer(svg: string): Promise { ctx.drawImage(img, 0, 0); URL.revokeObjectURL(url); canvas.toBlob((pngBlob) => { - if (!pngBlob) { reject(new Error('Canvas toBlob failed')); return; } + if (!pngBlob) { + reject(new Error('Canvas toBlob failed')); + return; + } pngBlob.arrayBuffer().then(resolve).catch(reject); }, 'image/png'); }; - img.onerror = () => { URL.revokeObjectURL(url); reject(new Error('SVG load failed')); }; + img.onerror = () => { + URL.revokeObjectURL(url); + reject(new Error('SVG load failed')); + }; img.src = url; }); } @@ -56,7 +65,11 @@ export default class PyodidePlotWidget extends Component { if (!svg) return; try { const arrayBuffer = await svgToPngArrayBuffer(svg); - await storePyodideImagePng(this.props.title, this.props.imageTitle, arrayBuffer); + await storePyodideImagePng( + this.props.title, + this.props.imageTitle, + arrayBuffer + ); toast.success(`Saved ${this.props.imageTitle}.png`); } catch (err: unknown) { toast.error(`Failed to save PNG: ${(err as Error).message}`); diff --git a/src/renderer/epics/deviceEpics.ts b/src/renderer/epics/deviceEpics.ts index 902fae16..4869bac7 100644 --- a/src/renderer/epics/deviceEpics.ts +++ b/src/renderer/epics/deviceEpics.ts @@ -129,10 +129,11 @@ const connectEpic: Epic = ( action$.pipe( filter(isActionOf(DeviceActions.ConnectToDevice)), pluck('payload'), - map((device) => - (isNil(device.name) - ? connectToEmotiv(device) - : connectToMuse(device)) as Promise + map( + (device) => + (isNil(device.name) + ? connectToEmotiv(device) + : connectToMuse(device)) as Promise ), mergeMap((promise) => promise.then((deviceInfo) => deviceInfo)), // eslint-disable-next-line @typescript-eslint/no-explicit-any diff --git a/src/renderer/epics/pyodideEpics.ts b/src/renderer/epics/pyodideEpics.ts index edf633e0..0b59b72e 100644 --- a/src/renderer/epics/pyodideEpics.ts +++ b/src/renderer/epics/pyodideEpics.ts @@ -1,5 +1,5 @@ import { combineEpics, Epic } from 'redux-observable'; -import { EMPTY, fromEvent, Observable, ObservableInput, of } from 'rxjs'; +import { EMPTY, fromEvent, Observable, of } from 'rxjs'; import { map, mergeMap, tap, pluck, filter } from 'rxjs/operators'; import { toast } from 'react-toastify'; import { isActionOf } from '../utils/redux'; @@ -9,6 +9,7 @@ import { getWorkspaceDir } from '../utils/filesystem/storage'; import { loadCSV, loadCleanedEpochs, + writeEpochsToMemfs, filterIIR, epochEvents, requestEpochsInfo, @@ -17,7 +18,6 @@ import { plotPSD, plotERP, plotTopoMap, - plotTestPlot, saveEpochs, loadPyodide, loadPatches, @@ -26,11 +26,9 @@ import { } from '../utils/webworker'; import { EMOTIV_CHANNELS, - DEVICES, MUSE_CHANNELS, PYODIDE_VARIABLE_NAMES, } from '../constants/constants'; -import { parseSingleQuoteJSON } from '../utils/webworker/functions'; import { readFiles } from '../utils/filesystem/read'; @@ -43,13 +41,17 @@ const launchEpic: Epic = ( action$.pipe( filter(isActionOf(PyodideActions.Launch)), tap(() => console.log('launching')), - mergeMap(loadPyodide), - tap((worker) => { + mergeMap(async () => { + const worker = await loadPyodide(); console.log('loadPyodide completed, loading patches'); + // Fire init messages in order — worker processes them sequentially. + // patches.py defines apply_patches(); applyPatches() calls it; loadUtils() + // runs utils.py and responds with plotKey:'ready' → SetWorkerReady. loadPatches(worker); applyPatches(worker); console.log('Now loading utils'); loadUtils(worker); + return worker; }), map(PyodideActions.SetPyodideWorker) ); @@ -76,7 +78,7 @@ const pyodideErrorEpic: Epic< ); // Once pyodide webworker is created, -// Create an observable of events that corresond to what it returns +// Create an observable of events that correspond to what it returns // and then emits those events as redux actions const pyodideMessageEpic: Epic< PyodideActionType, @@ -90,20 +92,41 @@ const pyodideMessageEpic: Epic< mergeMap>((worker) => fromEvent(worker, 'message')), // eslint-disable-next-line @typescript-eslint/no-explicit-any mergeMap>((e) => { - const { results, error, plotKey } = e.data; + const { results, error, plotKey, dataKey } = e.data; if (error) { toast.error(`Pyodide: ${error}`); return of(PyodideActions.ReceiveError(error)); } + + // Route data results (returned via dataKey, not plotKey). + if (dataKey === 'epochsInfo') { + // results is a JS array of single-key objects like [{Condition1: 10}, {'Drop Percentage': 5}] + const epochInfoArray = ( + results as Array> + ).map((infoObj) => ({ + name: Object.keys(infoObj)[0], + value: infoObj[Object.keys(infoObj)[0]], + })); + return of(PyodideActions.SetEpochInfo(epochInfoArray)); + } + if (dataKey === 'channelInfo') { + // results is a JS array of channel name strings + return of(PyodideActions.SetChannelInfo(results as string[])); + } + // Route plot results to the appropriate Redux state slot. - // results is a base64-encoded PNG string returned from Python. const mimeBundle = results ? { 'image/svg+xml': results } : null; switch (plotKey) { - case 'ready': return of(PyodideActions.SetWorkerReady()); - case 'topo': return of(PyodideActions.SetTopoPlot(mimeBundle)); - case 'psd': return of(PyodideActions.SetPSDPlot(mimeBundle)); - case 'erp': return of(PyodideActions.SetERPPlot(mimeBundle)); - default: return of(PyodideActions.ReceiveMessage(e.data)); + case 'ready': + return of(PyodideActions.SetWorkerReady()); + case 'topo': + return of(PyodideActions.SetTopoPlot(mimeBundle)); + case 'psd': + return of(PyodideActions.SetPSDPlot(mimeBundle)); + case 'erp': + return of(PyodideActions.SetERPPlot(mimeBundle)); + default: + return of(PyodideActions.ReceiveMessage(e.data)); } }) ); @@ -116,30 +139,30 @@ const loadEpochsEpic: Epic = ( filter(isActionOf(PyodideActions.LoadEpochs)), pluck('payload'), filter((filePathsArray: string[]) => filePathsArray.length >= 1), - map((filePathsArray) => readFiles(filePathsArray)), - mergeMap((csvArray) => loadCSV(state$.value.pyodide.worker!, csvArray)), - mergeMap(() => filterIIR(state$.value.pyodide.worker!, 1, 30)), - map(() => { - if (!state$.value.experiment.params?.stimuli) { - return {}; + mergeMap(async (filePathsArray) => { + const worker = state$.value.pyodide.worker!; + // readFiles must be awaited before passing csvArray to worker + const csvArray = await readFiles(filePathsArray); + // Queue all processing messages in order — worker runs them sequentially + loadCSV(worker, csvArray); + filterIIR(worker, 1, 30); + if (state$.value.experiment.params?.stimuli) { + epochEvents( + worker, + Object.fromEntries( + state$.value.experiment.params.stimuli.map((stimulus, i) => [ + stimulus.title, + i, + ]) + ), + -0.1, + 0.8 + ); } - - return epochEvents( - state$.value.pyodide.worker!, - Object.fromEntries( - state$.value.experiment.params?.stimuli.map((stimulus, i) => [ - stimulus.title, - i, - ]) - ), - -0.1, - 0.8 - ); + // Request epochs info — result returns via pyodideMessageEpic → SetEpochInfo + requestEpochsInfo(worker, PYODIDE_VARIABLE_NAMES.RAW_EPOCHS); }), - tap((e) => { - console.log('epoched events: ', e); - }), - map(() => PyodideActions.GetEpochsInfo(PYODIDE_VARIABLE_NAMES.RAW_EPOCHS)) + mergeMap(() => EMPTY) ); const loadCleanedEpochsEpic: Epic< @@ -151,9 +174,12 @@ const loadCleanedEpochsEpic: Epic< filter(isActionOf(PyodideActions.LoadCleanedEpochs)), pluck('payload'), filter((filePathsArray) => filePathsArray.length >= 1), - map((epochsArray) => - loadCleanedEpochs(state$.value.pyodide.worker!, epochsArray) - ), + mergeMap(async (epochsArray) => { + // Read .fif files from the host OS and stage them in Pyodide's MEMFS. + // Pyodide's WASM filesystem cannot access host OS paths directly. + const { memfsPaths, fsFiles } = await writeEpochsToMemfs(epochsArray); + loadCleanedEpochs(state$.value.pyodide.worker!, memfsPaths, fsFiles); + }), mergeMap(() => of( PyodideActions.GetEpochsInfo(PYODIDE_VARIABLE_NAMES.CLEAN_EPOCHS), @@ -189,20 +215,9 @@ const getEpochsInfoEpic: Epic< action$.pipe( filter(isActionOf(PyodideActions.GetEpochsInfo)), pluck('payload'), - mergeMap( - (varName) => - requestEpochsInfo( - state$.value.pyodide.worker!, - varName - ) as unknown as Promise[]> - ), - map((epochInfoArray) => - epochInfoArray.map((infoObj) => ({ - name: Object.keys(infoObj)[0], - value: infoObj[Object.keys(infoObj)[0]], - })) - ), - map(PyodideActions.SetEpochInfo) + // Fire-and-forget: result returns asynchronously via pyodideMessageEpic → SetEpochInfo + tap((varName) => requestEpochsInfo(state$.value.pyodide.worker!, varName)), + mergeMap(() => EMPTY) ); const getChannelInfoEpic: Epic< @@ -212,15 +227,9 @@ const getChannelInfoEpic: Epic< > = (action$, state$) => action$.pipe( filter(isActionOf(PyodideActions.GetChannelInfo)), - mergeMap( - () => - requestChannelInfo( - state$.value.pyodide.worker! - ) as unknown as Promise - ), - map((channelInfoString) => - PyodideActions.SetChannelInfo(parseSingleQuoteJSON(channelInfoString)) - ) + // Fire-and-forget: result returns asynchronously via pyodideMessageEpic → SetChannelInfo + tap(() => requestChannelInfo(state$.value.pyodide.worker!)), + mergeMap(() => EMPTY) ); const loadPSDEpic: Epic = ( @@ -239,7 +248,7 @@ const loadTopoEpic: Epic = ( ) => action$.pipe( filter(isActionOf(PyodideActions.LoadTopo)), - tap(() => plotTestPlot(state$.value.pyodide.worker!)), + tap(() => plotTopoMap(state$.value.pyodide.worker!)), mergeMap(() => EMPTY) ); @@ -258,7 +267,7 @@ const loadERPEpic: Epic = ( if (EMOTIV_CHANNELS.includes(channelName)) { index = EMOTIV_CHANNELS.indexOf(channelName); } - if (index) { + if (index !== null) { return index; } console.warn( diff --git a/src/renderer/utils/webworker/index.ts b/src/renderer/utils/webworker/index.ts index 15456aa5..550488ae 100644 --- a/src/renderer/utils/webworker/index.ts +++ b/src/renderer/utils/webworker/index.ts @@ -43,18 +43,38 @@ export const loadCSV = async (worker: Worker, csvArray: Array) => { // --------------------------- // MNE-Related Data Processing -export const loadCleanedEpochs = async ( +export const loadCleanedEpochs = ( worker: Worker, - epochsArray: string[] + memfsPaths: string[], + fsFiles: Array<{ path: string; bytes: Uint8Array }> ) => { - await worker.postMessage({ + worker.postMessage({ + fsFiles, data: [ - `clean_epochs = concatenate_epochs([read_epochs(file) for file in ${epochsArray}])`, + `clean_epochs = concatenate_epochs([read_epochs(file) for file in ${JSON.stringify(memfsPaths)}])`, `conditions = OrderedDict({key: [value] for (key, value) in clean_epochs.event_id.items()})`, ].join('\n'), }); }; +export const writeEpochsToMemfs = async ( + filePaths: string[] +): Promise<{ + memfsPaths: string[]; + fsFiles: Array<{ path: string; bytes: Uint8Array }>; +}> => { + const memfsPaths: string[] = []; + const fsFiles: Array<{ path: string; bytes: Uint8Array }> = []; + for (const filePath of filePaths) { + const bytes: Uint8Array = + await window.electronAPI.readFileAsBytes(filePath); + const memfsPath = `/tmp/${path.basename(filePath)}`; + memfsPaths.push(memfsPath); + fsFiles.push({ path: memfsPath, bytes }); + } + return { memfsPaths, fsFiles }; +}; + // NOTE: this command includes a ';' to prevent returning data export const filterIIR = async ( worker: Worker, @@ -88,20 +108,19 @@ export const epochEvents = async ( ].join('\n'), }); -export const requestEpochsInfo = async ( - worker: Worker, - variableName: string -) => { - const pyodideReturn = await worker.postMessage({ +export const requestEpochsInfo = (worker: Worker, variableName: string) => { + worker.postMessage({ data: `get_epochs_info(${variableName})`, + dataKey: 'epochsInfo', }); - return pyodideReturn; }; -export const requestChannelInfo = async (worker: Worker) => +export const requestChannelInfo = (worker: Worker) => { worker.postMessage({ data: `[ch for ch in clean_epochs.ch_names if ch != 'Marker']`, + dataKey: 'channelInfo', }); +}; // ----------------------------- // Plot functions @@ -117,7 +136,7 @@ export const plotPSD = async (worker: Worker) => { plotKey: 'psd', data: [ 'import io', - '_fig = raw.plot_psd(fmin=1, fmax=30, show=False)', + '_fig = raw.compute_psd(fmin=1, fmax=30).plot(show=False)', '_buf = io.BytesIO()', '_fig.savefig(_buf, format="svg", bbox_inches="tight")', 'plt.close(_fig)', @@ -186,5 +205,5 @@ export const saveEpochs = ( 'EEG', `${subject}-cleaned-epo.fif` ) - )}`, + )})`, }); diff --git a/src/renderer/utils/webworker/webworker.js b/src/renderer/utils/webworker/webworker.js index 9cf665f0..b075cb7f 100644 --- a/src/renderer/utils/webworker/webworker.js +++ b/src/renderer/utils/webworker/webworker.js @@ -59,9 +59,7 @@ const pyodideReadyPromise = (async () => { // Set matplotlib backend before any imports so it takes effect on first import. // Must be 'agg' (non-interactive, buffer-based) — web workers have no DOM, // so WebAgg fails with "cannot import name 'document' from 'js'". - await pyodide.runPythonAsync( - 'import os; os.environ["MPLBACKEND"] = "agg"' - ); + await pyodide.runPythonAsync('import os; os.environ["MPLBACKEND"] = "agg"'); // Load micropip so we can install MNE and its pure-Python deps. await pyodide.loadPackage('micropip', { checkIntegrity: false }); @@ -88,7 +86,15 @@ self.onmessage = async (event) => { return; } - const { data, plotKey, ...context } = event.data; + const { data, plotKey, dataKey, fsFiles, ...context } = event.data; + + // Write any files to Pyodide's MEMFS before running Python code. + // This allows host OS file paths to be staged in the WASM virtual filesystem. + if (fsFiles && Array.isArray(fsFiles)) { + for (const { path: filePath, bytes } of fsFiles) { + pyodide.FS.writeFile(filePath, bytes); + } + } // Expose context values as globals so Python can access them via the js module. for (const [key, value] of Object.entries(context)) { @@ -96,8 +102,16 @@ self.onmessage = async (event) => { } try { - self.postMessage({ results: await pyodide.runPythonAsync(data), plotKey }); + let results = await pyodide.runPythonAsync(data); + // Convert PyProxy objects (Python lists/dicts) to plain JS before postMessage, + // which uses structuredClone — PyProxy is not serializable. + if (results && typeof results.toJs === 'function') { + const proxy = results; + results = results.toJs({ dict_converter: Object.fromEntries }); + proxy.destroy(); + } + self.postMessage({ results, plotKey, dataKey }); } catch (error) { - self.postMessage({ error: error.message, plotKey }); + self.postMessage({ error: error.message, plotKey, dataKey }); } };