/ concept-collection / mesh-pde-solver
Sign in
concept-collection / mesh-pde-solver
mesh-pde-solver / scripts / engine-test.mjs
159 lines · 5.8 KBCodeBlameHistory
f4de30dSimplify the solver to run-per-solve; drop the uihtml event bridgeJeremy Magland 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).
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 = [
79 'main.m',
80 'solve_pde.m',
81 'surfacemesh_from_quads.m',
82 'load_gmsh_quads.m',
83 ]
84 for (const name of projectFiles) {
85 vfs.writeFile(`/project/${name}`, enc.encode(readProjectFile(name)))
86 }
87 vfs.writeFile(
88 '/project/mesh.msh',
89 fs.readFileSync(path.join(root, 'public', 'samples', 'sphere.msh'))
90 )
91 vfs.setCwd('/project')
f4de30dSimplify the solver to run-per-solve; drop the uihtml event bridgeJeremy Magland 93 const workspaceFiles = projectFiles.map(n => ({name: n, source: readProjectFile(n)}))
94 const decoder = new TextDecoder()
96 const solve = params => {
f4de30dSimplify the solver to run-per-solve; drop the uihtml event bridgeJeremy Magland 97 vfs.writeFile('/project/params.json', enc.encode(JSON.stringify(params)))
98 vfs.writeFile('/project/result.json', enc.encode('')) // no stale reads
101 readProjectFile('main.m'),
102 {
103 onOutput: text => process.stdout.write(`[numbl] ${text}`),
104 onDrawnow: () => {},
105 displayResults: false,
106 maxIterations: 1e9,
107 optimization: '1',
108 fileIO: new NodeFileIOAdapter(vfs),
109 system: new BrowserSystemAdapter(vfs),
110 },
111 workspaceFiles,
112 vfs.normalizePath('/project/main.m'),
113 [MIP_SEARCH_PATH]
114 )
115 const result = JSON.parse(decoder.decode(vfs.readFile('/project/result.json')))
116 console.log(`solve [${params.pde}] in ${(Date.now() - t) / 1000}s`)
117 return result
120 // 1. Poisson on the closed sphere
f4de30dSimplify the solver to run-per-solve; drop the uihtml event bridgeJeremy Magland 121 let d = solve({pde: 'poisson', f: 'x.*y.*z', c: '', p: 6, closed: true})
28f8ec1mesh-pde-solver: upload a quad mesh, solve PDEs on the surface in-browserJeremy Magland 122 console.log(` npatches=${d.npatches} n=${d.n} u in [${d.umin.toFixed(6)}, ${d.umax.toFixed(6)}]`)
123 if (d.npatches !== 216 || d.n !== 7) throw new Error('unexpected solution shape')
124 if (!isFinite(d.umin) || !isFinite(d.umax) || d.umin === d.umax)
125 throw new Error('degenerate solution values')
127 // Eigenfunction check: x*y*z is a degree-3 solid harmonic, so on the unit
128 // sphere lap_S (x*y*z) = -12 * (x*y*z). Solving with f = -12*x*y*z must
129 // reproduce u = x*y*z, whose max on the sphere is 1/(3*sqrt(3)).
f4de30dSimplify the solver to run-per-solve; drop the uihtml event bridgeJeremy Magland 130 d = solve({pde: 'poisson', f: '-12*(x.*y.*z)', c: '', p: 8, closed: true})
28f8ec1mesh-pde-solver: upload a quad mesh, solve PDEs on the surface in-browserJeremy Magland 131 const expected = 1 / (3 * Math.sqrt(3))
132 console.log(` eigencheck: umax=${d.umax.toFixed(6)} expected~${expected.toFixed(6)}`)
133 if (Math.abs(d.umax - expected) > 0.01) throw new Error('eigenfunction check failed')
135 // 2. Helmholtz with a variable coefficient
f4de30dSimplify the solver to run-per-solve; drop the uihtml event bridgeJeremy Magland 136 d = solve({pde: 'helmholtz', f: '1 + 0*x', c: '100*(1 - z)', p: 6, closed: true})
137 console.log(` u in [${d.umin.toFixed(6)}, ${d.umax.toFixed(6)}]`)
139 // 3. Bad expression errors out of the run (the host surfaces the message)
140 let err = null
141 try {
142 solve({pde: 'poisson', f: 'this is not matlab', c: '', p: 4, closed: true})
143 } catch (e) {
144 err = e
145 }
146 if (!err) throw new Error('expected an error for bad expression')
147 console.log(` error path OK: ${String(err.message).slice(0, 100)}`)
149 // 4. A later solve is unaffected (fresh run per solve)
150 d = solve({pde: 'poisson', f: 'x', c: '', p: 4, closed: true})
151 if (d.type !== 'solution') throw new Error('solve after error failed')
153 console.log('engine-test: all checks passed')
156main().catch(err => {
157 console.error(err)
158 process.exit(1)
159})
moveopenescclose