// Batch-test pulseq demoSeq scripts on numbl. For each demo we auto-adapt it // (truncate after the first `seq.write(...)` — everything after is plotting/ // analysis/install), run it through the same call shape the browser runner // uses, and report whether it produced a viewable .seq, how long it took, and // any error. Also parses the output with the JS parser as an extra check. // // node scripts/test-demos.mjs # driver: test all, print table // node scripts/test-demos.mjs --one # run one demo, print JSON // // The driver spawns each demo in its own child process (isolation + timeout). import fs from 'node:fs' import path from 'node:path' import { fileURLToPath } from 'node:url' import { spawn } from 'node:child_process' const here = path.dirname(fileURLToPath(import.meta.url)) const repoRoot = path.join(here, '..') const mrRoot = path.join(repoRoot, 'src', 'engine', 'pulseq') const demoDir = process.env.PULSEQ_DEMO_DIR ?? path.resolve(repoRoot, '../../pulseq/matlab/demoSeq') const MD5_OVERRIDE = `function digest = md5(message, noBuiltIn)\n digest = hash('MD5', char(message));\nend\n` // No Python/SigPy in the browser: force the internal (non-sigpy) pulse paths. const ISSIGPY_OVERRIDE = `function [sigPyOK, pythonExe] = isSigPyAvailable()\n sigPyOK = false;\n pythonExe = '';\nend\n` const TIMEOUT_MS = 120_000 const CONCURRENCY = 6 // Statement-leading patterns that are visualization / analysis / scanner // side effects — irrelevant to generating a .seq and often unsupported // (seq.plot -> gobjects, seq.sound, seq.install, testReport, k-space plots). const STRIP_PATTERNS = [ /^\s*seq\.(plot|paperPlot|plotK|sound|install|testReport)\b/, /^\s*\[?[\w,\s~]*\]?\s*=?\s*seq\.calculateKspacePP\b/, /^\s*(figure|hold|axis|grid|subplot|title|xlabel|ylabel|zlabel|legend|colormap|colorbar|drawnow|clf|clim|caxis|view)\b/, /^\s*(plot|plot3|plot3d|imagesc|imshow|surf|mesh|quiver|scatter|stairs|stem|area|bar|pcolor|contour)\s*\(/, /^\s*set\s*\(\s*gc[af]/, /^\s*rep\s*=\s*seq\.testReport\b/, /^\s*fprintf\s*\(\s*\[rep/, ] /** * Adapt a demoSeq script for headless generation: * - strip visualization / analysis / scanner-side-effect statements * - keep the script up to and including the first `seq.write(...)` * - re-append trailing local-function definitions (which the truncation * would otherwise drop) so calls to them still resolve */ export function adaptDemo(src) { const lines = src.split('\n') const writeIdx = lines.findIndex((l) => /\bseq\.write\s*\(/.test(l)) const funcIdx = lines.findIndex((l) => /^\s*function\b/.test(l)) const strip = (arr) => arr.filter((l) => !STRIP_PATTERNS.some((re) => re.test(l))) if (writeIdx < 0) return strip(lines).join('\n') + '\n' let body = strip(lines.slice(0, writeIdx + 1)) // Trailing local functions live after the write; keep them verbatim. if (funcIdx > writeIdx) { body = body.concat('', lines.slice(funcIdx)) } return body.join('\n') + '\n' } // ── child mode: run one demo ──────────────────────────────────────────── if (process.argv[2] === '--one') { const demoPath = process.argv[3] const { executeCode, VirtualFileSystem, BrowserFileIOAdapter, BrowserSystemAdapter } = await import('numbl') const { parseSeq } = await import('../src/seq/parseSeq.ts') const { reconstruct } = await import('../src/seq/reconstruct.ts') const files = [] for (const entry of fs.readdirSync(path.join(mrRoot, '+mr'), { recursive: true })) { const rel = String(entry) const abs = path.join(mrRoot, '+mr', rel) if (!fs.statSync(abs).isFile() || !rel.endsWith('.m')) continue const vfsPath = `+mr/${rel}` let content = fs.readFileSync(abs, 'utf8') if (vfsPath === '+mr/+aux/md5.m') content = MD5_OVERRIDE else if (vfsPath === '+mr/+aux/isSigPyAvailable.m') content = ISSIGPY_OVERRIDE files.push({ path: vfsPath, content }) } const script = adaptDemo(fs.readFileSync(demoPath, 'utf8')) files.push({ path: 'main.m', content: script }) const enc = new TextEncoder() const vfs = new VirtualFileSystem() for (const f of files) vfs.writeFile('/project/' + f.path, enc.encode(f.content)) vfs.setCwd('/project') const workspaceFiles = files.map((f) => ({ name: f.path, source: f.content })) const result = { ok: false, error: null, seqFiles: [], elapsedMs: 0 } const t0 = Date.now() try { executeCode( 'main;', { onOutput: () => {}, displayResults: false, maxIterations: 1e9, optimization: '1', fileIO: new BrowserFileIOAdapter(vfs), system: new BrowserSystemAdapter(vfs), }, workspaceFiles, 'repl', [], ) result.ok = true } catch (err) { result.error = String(err?.message ?? err) if (err?.file) result.error += ` @ ${err.file}:${err.line}` } result.elapsedMs = Date.now() - t0 // Collect + parse any .seq files const collect = executeCode( `d = dir('*.seq'); for i=1:numel(d); fprintf('F:%s\\n', d(i).name); end`, { onOutput: (t) => result.seqFiles.push(...[...t.matchAll(/^F:(.+)$/gm)].map((m) => m[1])), displayResults: false, maxIterations: 1e9, optimization: '1', fileIO: new BrowserFileIOAdapter(vfs), system: new BrowserSystemAdapter(vfs), }, workspaceFiles, 'repl', [], ) void collect const seqInfo = [] for (const name of result.seqFiles) { try { const text = new TextDecoder().decode(vfs.readFile('/project/' + name)) const seq = parseSeq(text) const rec = reconstruct(seq) seqInfo.push({ name, bytes: text.length, blocks: seq.blocks.length, rf: rec.stats.rfCount, adc: rec.stats.adcCount, dur: rec.duration, sig: seq.signature?.valid ?? null, parseOk: true, }) } catch (err) { seqInfo.push({ name, parseOk: false, parseErr: String(err?.message ?? err) }) } } result.seqFiles = seqInfo process.stdout.write('@@SEQLAB_RESULT@@' + JSON.stringify(result) + '@@END@@') process.exit(0) } // ── driver mode: test all demos ───────────────────────────────────────── // Only runs when this file is executed directly; importing it (e.g. from // gen-examples.mjs for adaptDemo) must NOT kick off the batch. const isMain = process.argv[1] && fileURLToPath(import.meta.url) === path.resolve(process.argv[1]) function runOne(demo) { return new Promise((resolve) => { const child = spawn('node', [fileURLToPath(import.meta.url), '--one', path.join(demoDir, demo)], { cwd: repoRoot, }) let out = '' const timer = setTimeout(() => child.kill('SIGKILL'), TIMEOUT_MS) child.stdout.on('data', (d) => (out += d)) child.stderr.on('data', () => {}) child.on('close', () => { clearTimeout(timer) const m = out.match(/@@SEQLAB_RESULT@@([\s\S]*?)@@END@@/) if (m) { try { resolve({ demo, ...JSON.parse(m[1]) }) return } catch { /* fall through */ } } resolve({ demo, ok: false, error: 'timeout or crash', seqFiles: [], elapsedMs: TIMEOUT_MS, timedOut: true }) }) }) } async function main() { const demos = fs .readdirSync(demoDir) .filter((f) => f.endsWith('.m')) .sort() const results = [] let idx = 0 async function worker() { while (idx < demos.length) { const demo = demos[idx++] process.stderr.write(` running ${demo}…\n`) results.push(await runOne(demo)) } } await Promise.all(Array.from({ length: CONCURRENCY }, worker)) results.sort((a, b) => a.demo.localeCompare(b.demo)) let good = 0 let bad = 0 console.log('\n=== demoSeq on numbl ===\n') for (const r of results) { const seq = r.seqFiles?.[0] const wrote = r.seqFiles?.length > 0 const parseOk = wrote && r.seqFiles.every((s) => s.parseOk) const status = r.ok && wrote && parseOk ? 'OK ' : 'ERR' if (status === 'OK ') good++ else bad++ const secs = (r.elapsedMs / 1000).toFixed(1).padStart(6) let detail = '' if (r.ok && seq && seq.parseOk) { detail = `${String(seq.blocks).padStart(5)} blk ${String(seq.rf).padStart(4)} rf ${String(seq.adc).padStart(5)} adc dur ${seq.dur.toFixed(3)}s sig ${seq.sig}` } else if (r.timedOut) { detail = `TIMEOUT (>${TIMEOUT_MS / 1000}s)` } else if (!wrote && r.ok) { detail = 'ran but wrote no .seq' } else if (!parseOk && wrote) { detail = `parse failed: ${r.seqFiles.find((s) => !s.parseOk)?.parseErr}` } else { detail = (r.error ?? 'unknown').slice(0, 90) } console.log(`${status} ${secs}s ${r.demo.replace(/\.m$/, '').padEnd(30)} ${detail}`) } console.log(`\n${good} OK, ${bad} failed, of ${results.length}`) fs.writeFileSync(path.join(here, 'demo-results.json'), JSON.stringify(results, null, 2)) console.log('full results -> scripts/demo-results.json') } if (isMain) await main()