// Headless smoke test for the Bloch simulator. Reads a phantom (.phantom via // h5wasm) and a golden .seq, runs simulate(), and checks basic physics: // - FID: right after a 90° pulse all spins are in phase, so |S| at the first // ADC sample ≈ Σρ (≈ number of spins), decaying by T2 thereafter. // - GRE: the DC point of k-space (gradient-refocused echo centre) should be // the brightest sample in its readout. // Run: npm run sim-test import * as h5 from 'h5wasm/node' import fs from 'node:fs' import path from 'node:path' import { fileURLToPath } from 'node:url' import { parseSeq } from '../src/seq/parseSeq.ts' import { simulate } from '../src/sim/simulate.ts' const __dirname = path.dirname(fileURLToPath(import.meta.url)) const root = path.resolve(__dirname, '..') function loadPhantomNode(file) { const bytes = fs.readFileSync(file) // h5wasm/node writes to the real cwd; use a temp name and unlink after. const tmp = `.sim-test-${path.basename(file)}.tmp` h5.FS.writeFile(tmp, new Uint8Array(bytes)) const f = new h5.File(tmp, 'r') const g = (p) => Float32Array.from(f.get(p).value) const ph = { name: path.basename(file), x: g('position/x'), y: g('position/y'), z: g('position/z'), rho: g('contrast/ρ'), t1: g('contrast/T1'), t2: g('contrast/T2'), t2s: g('contrast/T2s'), dw: g('contrast/Δw'), } ph.ns = ph.x.length f.close() h5.FS.unlink(tmp) return ph } function mag(sig, i) { return Math.hypot(sig.re[i], sig.im[i]) } let failures = 0 function check(name, cond, detail) { if (cond) { console.log(` ✓ ${name}${detail ? ' — ' + detail : ''}`) } else { console.log(` ✗ ${name}${detail ? ' — ' + detail : ''}`) failures++ } } async function main() { await h5.ready const cube = loadPhantomNode(path.join(root, 'src/phantom/data/cube.phantom')) console.log(`Phantom: cube, ${cube.ns} spins`) // --- FID --- console.log('\nFID (test-data/fid.seq):') const fidSeq = parseSeq(fs.readFileSync(path.join(root, 'test-data/fid.seq'), 'utf8')) const t0 = Date.now() const fid = simulate(fidSeq, cube) console.log(` simulated in ${Date.now() - t0} ms; ${fid.numReadouts} readouts, ${fid.re.length} samples`) const m0 = mag(fid, 0) const sumRho = cube.rho.reduce((a, b) => a + b, 0) check('all samples finite', fid.re.every((v) => Number.isFinite(v)) && fid.im.every((v) => Number.isFinite(v))) // Right after a 90° pulse all spins are in phase; |S(0)| = Σρ, minus T2 decay // over the ~20 ms of dead time before the first sample. So it should be a // large fraction of Σρ but not exceed it. check('|S(0)| ~ Σρ (in phase)', m0 > 0.7 * sumRho && m0 <= sumRho * 1.001, `|S(0)|=${m0.toFixed(1)}, Σρ=${sumRho.toFixed(0)}`) // Direct T2 validation: within a readout, |S| must decay as exp(-t/T2). const dwell = fidSeq.adcs.get(1).dwell const T2 = cube.t2[0] const K = 400 const ratio = mag(fid, K) / m0 const expected = Math.exp(-(K * dwell) / T2) check('FID decays at rate 1/T2', Math.abs(ratio - expected) / expected < 0.02, `|S(${K})|/|S(0)|=${ratio.toFixed(4)}, exp(-t/T2)=${expected.toFixed(4)}`) // --- GRE --- console.log('\nGRE (test-data/gre.seq):') const greSeq = parseSeq(fs.readFileSync(path.join(root, 'test-data/gre.seq'), 'utf8')) const t1 = Date.now() const gre = simulate(greSeq, cube, { onProgress: (p) => { if (p.segment === 1 || p.fraction === 1 || p.segment % 20000 === 0) process.stdout.write(`\r progress ${(p.fraction * 100).toFixed(1)}% (seg ${p.segment}/${p.numSegments}, ${p.samplesDone}/${p.numSamples} samples) `) }, }) process.stdout.write('\n') console.log(` simulated in ${((Date.now() - t1) / 1000).toFixed(1)} s; ${gre.numReadouts} readouts, ${gre.maxSamplesPerReadout} samples/readout`) check('all samples finite', gre.re.every((v) => Number.isFinite(v)) && gre.im.every((v) => Number.isFinite(v))) // For the middle readout, the brightest sample should be near the readout centre (k=0 echo). const midR = Math.floor(gre.numReadouts / 2) const o = gre.offsets[midR] const n = gre.samplesPerReadout[midR] let peak = -1 let peakIdx = -1 for (let i = 0; i < n; i++) { const m = mag(gre, o + i) if (m > peak) { peak = m peakIdx = i } } check('GRE echo peaks near readout centre', Math.abs(peakIdx - n / 2) < n * 0.2, `peak at col ${peakIdx}/${n}`) console.log(`\n${failures === 0 ? 'All checks passed.' : failures + ' check(s) FAILED.'}`) process.exit(failures === 0 ? 0 : 1) } main()