/ concept-collection / seqlab
Sign in
concept-collection / seqlab
seqlab / scripts / test-demos.mjs
233 lines · 8.9 KBCodeBlameHistory
78d04f1seqlab: write and view pulseq MRI sequences in the browserJeremy Magland 1// Batch-test pulseq demoSeq scripts on numbl. For each demo we auto-adapt it
2// (truncate after the first `seq.write(...)` — everything after is plotting/
3// analysis/install), run it through the same call shape the browser runner
4// uses, and report whether it produced a viewable .seq, how long it took, and
5// any error. Also parses the output with the JS parser as an extra check.
6//
7// node scripts/test-demos.mjs # driver: test all, print table
8// node scripts/test-demos.mjs --one <path> # run one demo, print JSON
9//
10// The driver spawns each demo in its own child process (isolation + timeout).
11import fs from 'node:fs'
12import path from 'node:path'
13import { fileURLToPath } from 'node:url'
14import { spawn } from 'node:child_process'
16const here = path.dirname(fileURLToPath(import.meta.url))
17const repoRoot = path.join(here, '..')
18const mrRoot = path.join(repoRoot, 'src', 'engine', 'pulseq')
19const demoDir = process.env.PULSEQ_DEMO_DIR ?? path.resolve(repoRoot, '../../pulseq/matlab/demoSeq')
21const MD5_OVERRIDE = `function digest = md5(message, noBuiltIn)\n digest = hash('MD5', char(message));\nend\n`
22// No Python/SigPy in the browser: force the internal (non-sigpy) pulse paths.
23const ISSIGPY_OVERRIDE = `function [sigPyOK, pythonExe] = isSigPyAvailable()\n sigPyOK = false;\n pythonExe = '';\nend\n`
24const TIMEOUT_MS = 120_000
25const CONCURRENCY = 6
27// Statement-leading patterns that are visualization / analysis / scanner
28// side effects — irrelevant to generating a .seq and often unsupported
29// (seq.plot -> gobjects, seq.sound, seq.install, testReport, k-space plots).
30const STRIP_PATTERNS = [
31 /^\s*seq\.(plot|paperPlot|plotK|sound|install|testReport)\b/,
32 /^\s*\[?[\w,\s~]*\]?\s*=?\s*seq\.calculateKspacePP\b/,
33 /^\s*(figure|hold|axis|grid|subplot|title|xlabel|ylabel|zlabel|legend|colormap|colorbar|drawnow|clf|clim|caxis|view)\b/,
34 /^\s*(plot|plot3|plot3d|imagesc|imshow|surf|mesh|quiver|scatter|stairs|stem|area|bar|pcolor|contour)\s*\(/,
35 /^\s*set\s*\(\s*gc[af]/,
36 /^\s*rep\s*=\s*seq\.testReport\b/,
37 /^\s*fprintf\s*\(\s*\[rep/,
40/**
41 * Adapt a demoSeq script for headless generation:
42 * - strip visualization / analysis / scanner-side-effect statements
43 * - keep the script up to and including the first `seq.write(...)`
44 * - re-append trailing local-function definitions (which the truncation
45 * would otherwise drop) so calls to them still resolve
46 */
47export function adaptDemo(src) {
48 const lines = src.split('\n')
49 const writeIdx = lines.findIndex((l) => /\bseq\.write\s*\(/.test(l))
50 const funcIdx = lines.findIndex((l) => /^\s*function\b/.test(l))
51 const strip = (arr) => arr.filter((l) => !STRIP_PATTERNS.some((re) => re.test(l)))
53 if (writeIdx < 0) return strip(lines).join('\n') + '\n'
54 let body = strip(lines.slice(0, writeIdx + 1))
55 // Trailing local functions live after the write; keep them verbatim.
56 if (funcIdx > writeIdx) {
57 body = body.concat('', lines.slice(funcIdx))
58 }
59 return body.join('\n') + '\n'
62// ── child mode: run one demo ────────────────────────────────────────────
63if (process.argv[2] === '--one') {
64 const demoPath = process.argv[3]
65 const { executeCode, VirtualFileSystem, BrowserFileIOAdapter, BrowserSystemAdapter } =
66 await import('numbl')
67 const { parseSeq } = await import('../src/seq/parseSeq.ts')
68 const { reconstruct } = await import('../src/seq/reconstruct.ts')
70 const files = []
71 for (const entry of fs.readdirSync(path.join(mrRoot, '+mr'), { recursive: true })) {
72 const rel = String(entry)
73 const abs = path.join(mrRoot, '+mr', rel)
74 if (!fs.statSync(abs).isFile() || !rel.endsWith('.m')) continue
75 const vfsPath = `+mr/${rel}`
76 let content = fs.readFileSync(abs, 'utf8')
77 if (vfsPath === '+mr/+aux/md5.m') content = MD5_OVERRIDE
78 else if (vfsPath === '+mr/+aux/isSigPyAvailable.m') content = ISSIGPY_OVERRIDE
79 files.push({ path: vfsPath, content })
80 }
81 const script = adaptDemo(fs.readFileSync(demoPath, 'utf8'))
82 files.push({ path: 'main.m', content: script })
84 const enc = new TextEncoder()
85 const vfs = new VirtualFileSystem()
86 for (const f of files) vfs.writeFile('/project/' + f.path, enc.encode(f.content))
87 vfs.setCwd('/project')
88 const workspaceFiles = files.map((f) => ({ name: f.path, source: f.content }))
90 const result = { ok: false, error: null, seqFiles: [], elapsedMs: 0 }
91 const t0 = Date.now()
92 try {
93 executeCode(
94 'main;',
95 {
96 onOutput: () => {},
97 displayResults: false,
98 maxIterations: 1e9,
99 optimization: '1',
100 fileIO: new BrowserFileIOAdapter(vfs),
101 system: new BrowserSystemAdapter(vfs),
102 },
103 workspaceFiles,
104 'repl',
105 [],
106 )
107 result.ok = true
108 } catch (err) {
109 result.error = String(err?.message ?? err)
110 if (err?.file) result.error += ` @ ${err.file}:${err.line}`
111 }
112 result.elapsedMs = Date.now() - t0
114 // Collect + parse any .seq files
115 const collect = executeCode(
116 `d = dir('*.seq'); for i=1:numel(d); fprintf('F:%s\\n', d(i).name); end`,
117 {
118 onOutput: (t) => result.seqFiles.push(...[...t.matchAll(/^F:(.+)$/gm)].map((m) => m[1])),
119 displayResults: false,
120 maxIterations: 1e9,
121 optimization: '1',
122 fileIO: new BrowserFileIOAdapter(vfs),
123 system: new BrowserSystemAdapter(vfs),
124 },
125 workspaceFiles,
126 'repl',
127 [],
128 )
129 void collect
130 const seqInfo = []
131 for (const name of result.seqFiles) {
132 try {
133 const text = new TextDecoder().decode(vfs.readFile('/project/' + name))
134 const seq = parseSeq(text)
135 const rec = reconstruct(seq)
136 seqInfo.push({
137 name,
138 bytes: text.length,
139 blocks: seq.blocks.length,
140 rf: rec.stats.rfCount,
141 adc: rec.stats.adcCount,
142 dur: rec.duration,
143 sig: seq.signature?.valid ?? null,
144 parseOk: true,
145 })
146 } catch (err) {
147 seqInfo.push({ name, parseOk: false, parseErr: String(err?.message ?? err) })
148 }
149 }
150 result.seqFiles = seqInfo
151 process.stdout.write('@@SEQLAB_RESULT@@' + JSON.stringify(result) + '@@END@@')
152 process.exit(0)
155// ── driver mode: test all demos ─────────────────────────────────────────
156// Only runs when this file is executed directly; importing it (e.g. from
157// gen-examples.mjs for adaptDemo) must NOT kick off the batch.
158const isMain = process.argv[1] && fileURLToPath(import.meta.url) === path.resolve(process.argv[1])
160function runOne(demo) {
161 return new Promise((resolve) => {
162 const child = spawn('node', [fileURLToPath(import.meta.url), '--one', path.join(demoDir, demo)], {
163 cwd: repoRoot,
164 })
165 let out = ''
166 const timer = setTimeout(() => child.kill('SIGKILL'), TIMEOUT_MS)
167 child.stdout.on('data', (d) => (out += d))
168 child.stderr.on('data', () => {})
169 child.on('close', () => {
170 clearTimeout(timer)
171 const m = out.match(/@@SEQLAB_RESULT@@([\s\S]*?)@@END@@/)
172 if (m) {
173 try {
174 resolve({ demo, ...JSON.parse(m[1]) })
175 return
176 } catch {
177 /* fall through */
178 }
179 }
180 resolve({ demo, ok: false, error: 'timeout or crash', seqFiles: [], elapsedMs: TIMEOUT_MS, timedOut: true })
181 })
182 })
185async function main() {
186 const demos = fs
187 .readdirSync(demoDir)
188 .filter((f) => f.endsWith('.m'))
189 .sort()
191 const results = []
192 let idx = 0
193 async function worker() {
194 while (idx < demos.length) {
195 const demo = demos[idx++]
196 process.stderr.write(` running ${demo}…\n`)
197 results.push(await runOne(demo))
198 }
199 }
200 await Promise.all(Array.from({ length: CONCURRENCY }, worker))
201 results.sort((a, b) => a.demo.localeCompare(b.demo))
203 let good = 0
204 let bad = 0
205 console.log('\n=== demoSeq on numbl ===\n')
206 for (const r of results) {
207 const seq = r.seqFiles?.[0]
208 const wrote = r.seqFiles?.length > 0
209 const parseOk = wrote && r.seqFiles.every((s) => s.parseOk)
210 const status = r.ok && wrote && parseOk ? 'OK ' : 'ERR'
211 if (status === 'OK ') good++
212 else bad++
213 const secs = (r.elapsedMs / 1000).toFixed(1).padStart(6)
214 let detail = ''
215 if (r.ok && seq && seq.parseOk) {
216 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}`
217 } else if (r.timedOut) {
218 detail = `TIMEOUT (>${TIMEOUT_MS / 1000}s)`
219 } else if (!wrote && r.ok) {
220 detail = 'ran but wrote no .seq'
221 } else if (!parseOk && wrote) {
222 detail = `parse failed: ${r.seqFiles.find((s) => !s.parseOk)?.parseErr}`
223 } else {
224 detail = (r.error ?? 'unknown').slice(0, 90)
225 }
226 console.log(`${status} ${secs}s ${r.demo.replace(/\.m$/, '').padEnd(30)} ${detail}`)
227 }
228 console.log(`\n${good} OK, ${bad} failed, of ${results.length}`)
229 fs.writeFileSync(path.join(here, 'demo-results.json'), JSON.stringify(results, null, 2))
230 console.log('full results -> scripts/demo-results.json')
233if (isMain) await main()
moveopenescclose