/ concept-collection / mesh-pde-solver
Sign in
concept-collection / mesh-pde-solver
mesh-pde-solver / scripts / engine-test.mjs
154 lines · 5.7 KBBlameHistoryRaw
1// Headless validation of the solver — runs the same MATLAB project the
2// browser worker runs, in Node, against the installed numbl. Each solve
3// stages params.json, runs matlab/main.m standalone (as the browser does in
4// a fresh session), and reads result.json back from the VFS. The VFS is
5// shared across solves, standing in for numbl/browser's IndexedDB-persisted
6// /system, so `mip load --install surfacefun` only downloads once. Node has
7// no synchronous XMLHttpRequest, so websave/webread are shimmed with curl
8// (responses cached in .cache/ keyed by URL, so repeat runs are offline).
9//
10// npm run engine-test
12import {
13 executeCode,
14 VirtualFileSystem,
15 BrowserFileIOAdapter,
16 BrowserSystemAdapter,
17} from 'numbl'
18import {unzipSync} from 'fflate'
19import {execFileSync} from 'node:child_process'
20import crypto from 'node:crypto'
21import fs from 'node:fs'
22import path from 'node:path'
23import {fileURLToPath} from 'node:url'
25const root = path.dirname(path.dirname(fileURLToPath(import.meta.url)))
26const cacheDir = path.join(root, '.cache')
28const MIP_MHL_URL =
29 'https://github.com/mip-org/mip-core/releases/download/mip-numbl/mip-numbl-any.mhl'
30const MIP_SYSTEM_PREFIX = '/system/mip/packages/gh/mip-org/core/mip/'
31// In the browser this is passed by numbl/browser's session worker; executeCode
32// scans searchPaths directories since the same numbl change.
33const MIP_SEARCH_PATH = MIP_SYSTEM_PREFIX + 'mip'
35function curlCached(url) {
36 fs.mkdirSync(cacheDir, {recursive: true})
37 const key = crypto.createHash('sha1').update(url).digest('hex').slice(0, 16)
38 const cached = path.join(cacheDir, key)
39 if (!fs.existsSync(cached)) {
40 console.log(`fetching ${url}`)
41 execFileSync('curl', ['-sfL', '-o', cached, url], {stdio: 'inherit'})
42 }
43 return fs.readFileSync(cached)
46// numbl's BrowserFileIOAdapter implements websave/webread with synchronous
47// XHR (fine in a web worker, absent in Node); override with curl.
48class NodeFileIOAdapter extends BrowserFileIOAdapter {
49 constructor(vfs) {
50 super(vfs)
51 this.nodeVfs = vfs
52 }
53 websave(url, filename) {
54 this.nodeVfs.writeFile(this.nodeVfs.normalizePath(filename), new Uint8Array(curlCached(url)))
55 }
56 webread(url) {
57 return curlCached(url).toString('utf8')
58 }
61const readProjectFile = name =>
62 fs.readFileSync(path.join(root, 'matlab', name), 'utf8')
64async function main() {
65 const vfs = new VirtualFileSystem()
66 const enc = new TextEncoder()
68 // Bootstrap mip into the system VFS, as the browser worker does.
69 const mipEntries = unzipSync(new Uint8Array(curlCached(MIP_MHL_URL)))
70 let nMip = 0
71 for (const [name, content] of Object.entries(mipEntries)) {
72 if (name.endsWith('/')) continue
73 vfs.writeFile(MIP_SYSTEM_PREFIX + name, content)
74 nMip++
75 }
76 console.log(`mip core: ${nMip} files into VFS`)
78 const projectFiles = ['main.m', 'solve_pde.m']
79 for (const name of projectFiles) {
80 vfs.writeFile(`/project/${name}`, enc.encode(readProjectFile(name)))
81 }
82 vfs.writeFile(
83 '/project/mesh.msh',
84 fs.readFileSync(path.join(root, 'public', 'samples', 'sphere.msh'))
85 )
86 vfs.setCwd('/project')
88 const workspaceFiles = projectFiles.map(n => ({name: n, source: readProjectFile(n)}))
89 const decoder = new TextDecoder()
91 const solve = params => {
92 vfs.writeFile('/project/params.json', enc.encode(JSON.stringify(params)))
93 vfs.writeFile('/project/result.json', enc.encode('')) // no stale reads
94 const t = Date.now()
95 executeCode(
96 readProjectFile('main.m'),
97 {
98 onOutput: text => process.stdout.write(`[numbl] ${text}`),
99 onDrawnow: () => {},
100 displayResults: false,
101 maxIterations: 1e9,
102 optimization: '1',
103 fileIO: new NodeFileIOAdapter(vfs),
104 system: new BrowserSystemAdapter(vfs),
105 },
106 workspaceFiles,
107 vfs.normalizePath('/project/main.m'),
108 [MIP_SEARCH_PATH]
109 )
110 const result = JSON.parse(decoder.decode(vfs.readFile('/project/result.json')))
111 console.log(`solve [${params.pde}] in ${(Date.now() - t) / 1000}s`)
112 return result
113 }
115 // 1. Poisson on the closed sphere
116 let d = solve({pde: 'poisson', f: 'x.*y.*z', c: '', p: 6, closed: true})
117 console.log(` npatches=${d.npatches} n=${d.n} u in [${d.umin.toFixed(6)}, ${d.umax.toFixed(6)}]`)
118 if (d.npatches !== 216 || d.n !== 7) throw new Error('unexpected solution shape')
119 if (!isFinite(d.umin) || !isFinite(d.umax) || d.umin === d.umax)
120 throw new Error('degenerate solution values')
122 // Eigenfunction check: x*y*z is a degree-3 solid harmonic, so on the unit
123 // sphere lap_S (x*y*z) = -12 * (x*y*z). Solving with f = -12*x*y*z must
124 // reproduce u = x*y*z, whose max on the sphere is 1/(3*sqrt(3)).
125 d = solve({pde: 'poisson', f: '-12*(x.*y.*z)', c: '', p: 8, closed: true})
126 const expected = 1 / (3 * Math.sqrt(3))
127 console.log(` eigencheck: umax=${d.umax.toFixed(6)} expected~${expected.toFixed(6)}`)
128 if (Math.abs(d.umax - expected) > 0.01) throw new Error('eigenfunction check failed')
130 // 2. Helmholtz with a variable coefficient
131 d = solve({pde: 'helmholtz', f: '1 + 0*x', c: '100*(1 - z)', p: 6, closed: true})
132 console.log(` u in [${d.umin.toFixed(6)}, ${d.umax.toFixed(6)}]`)
134 // 3. Bad expression errors out of the run (the host surfaces the message)
135 let err = null
136 try {
137 solve({pde: 'poisson', f: 'this is not matlab', c: '', p: 4, closed: true})
138 } catch (e) {
139 err = e
140 }
141 if (!err) throw new Error('expected an error for bad expression')
142 console.log(` error path OK: ${String(err.message).slice(0, 100)}`)
144 // 4. A later solve is unaffected (fresh run per solve)
145 d = solve({pde: 'poisson', f: 'x', c: '', p: 4, closed: true})
146 if (d.type !== 'solution') throw new Error('solve after error failed')
148 console.log('engine-test: all checks passed')
151main().catch(err => {
152 console.error(err)
153 process.exit(1)
154})
moveopenescclose