/ concept-collection / mesh-pde-solver
Sign in
concept-collection / mesh-pde-solver
mesh-pde-solver / scripts / engine-test.mjs
176 lines · 6.4 KBBlameHistoryRaw
1// Headless validation of the solver engine — runs the same MATLAB project the
2// browser worker runs, in Node, against the installed numbl. Bootstraps mip
3// exactly like the worker does and lets `mip load --install surfacefun` in
4// main.m fetch surfacefun/chebfun itself. Node has no synchronous
5// XMLHttpRequest, so websave/webread are shimmed with curl (responses cached
6// in .cache/ keyed by URL, so repeat runs are offline).
7//
8// npm run engine-test
10import {
11 executeCode,
12 VirtualFileSystem,
13 BrowserFileIOAdapter,
14 BrowserSystemAdapter,
15} from 'numbl'
16import {unzipSync} from 'fflate'
17import {execFileSync} from 'node:child_process'
18import crypto from 'node:crypto'
19import fs from 'node:fs'
20import path from 'node:path'
21import {fileURLToPath} from 'node:url'
23const root = path.dirname(path.dirname(fileURLToPath(import.meta.url)))
24const cacheDir = path.join(root, '.cache')
26const MIP_MHL_URL =
27 'https://github.com/mip-org/mip-core/releases/download/mip-numbl/mip-numbl-any.mhl'
28const MIP_SYSTEM_PREFIX = '/system/mip/packages/gh/mip-org/core/mip/'
29// In the browser this is passed by numbl/browser's session worker; executeCode
30// scans searchPaths directories since the same numbl change.
31const MIP_SEARCH_PATH = MIP_SYSTEM_PREFIX + 'mip'
33function curlCached(url) {
34 fs.mkdirSync(cacheDir, {recursive: true})
35 const key = crypto.createHash('sha1').update(url).digest('hex').slice(0, 16)
36 const cached = path.join(cacheDir, key)
37 if (!fs.existsSync(cached)) {
38 console.log(`fetching ${url}`)
39 execFileSync('curl', ['-sfL', '-o', cached, url], {stdio: 'inherit'})
40 }
41 return fs.readFileSync(cached)
44// numbl's BrowserFileIOAdapter implements websave/webread with synchronous
45// XHR (fine in a web worker, absent in Node); override with curl.
46class NodeFileIOAdapter extends BrowserFileIOAdapter {
47 constructor(vfs) {
48 super(vfs)
49 this.nodeVfs = vfs
50 }
51 websave(url, filename) {
52 this.nodeVfs.writeFile(this.nodeVfs.normalizePath(filename), new Uint8Array(curlCached(url)))
53 }
54 webread(url) {
55 return curlCached(url).toString('utf8')
56 }
59const readProjectFile = name =>
60 fs.readFileSync(path.join(root, 'matlab', name), 'utf8')
62async function main() {
63 const vfs = new VirtualFileSystem()
64 const enc = new TextEncoder()
66 // Bootstrap mip into the system VFS, as the browser worker does.
67 const mipEntries = unzipSync(new Uint8Array(curlCached(MIP_MHL_URL)))
68 let nMip = 0
69 for (const [name, content] of Object.entries(mipEntries)) {
70 if (name.endsWith('/')) continue
71 vfs.writeFile(MIP_SYSTEM_PREFIX + name, content)
72 nMip++
73 }
74 console.log(`mip core: ${nMip} files into VFS`)
76 const projectFiles = [
77 'main.m',
78 'solver_session.m',
79 'solve_pde.m',
80 'surfacemesh_from_quads.m',
81 'load_gmsh_quads.m',
82 'placeholder.html',
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.clearChangeTracking()
92 vfs.setCwd('/project')
94 const events = []
95 let compId = null
97 console.log('running main.m (mip load --install surfacefun) ...')
98 const t0 = Date.now()
99 const result = executeCode(
100 readProjectFile('main.m'),
101 {
102 onOutput: text => process.stdout.write(`[numbl] ${text}`),
103 onDrawnow: () => {},
104 displayResults: false,
105 maxIterations: 1e9,
106 optimization: '1',
107 fileIO: new NodeFileIOAdapter(vfs),
108 system: new BrowserSystemAdapter(vfs),
109 onHtmlSourceEvent: (id, name, dataJson) =>
110 events.push({name, data: JSON.parse(dataJson)}),
111 },
112 projectFiles
113 .filter(n => n.endsWith('.m'))
114 .map(n => ({name: n, source: readProjectFile(n)})),
115 vfs.normalizePath('/project/main.m'),
116 [MIP_SEARCH_PATH]
117 )
118 for (const pi of result.plotInstructions) {
119 if (pi.type === 'uihtml') compId = pi.id
120 }
121 console.log(`boot: ${(Date.now() - t0) / 1000}s, uihtml comp = ${compId}`)
122 const session = result.uihtmlSession
123 if (!session || !compId) throw new Error('no live uihtml session after run')
125 const solve = params => {
126 events.length = 0
127 const t = Date.now()
128 session.dispatchEvent(compId, 'HTMLEventReceived', {
129 name: 'solve',
130 data: params,
131 })
132 const ev = events[events.length - 1]
133 if (!ev) throw new Error('no event came back from solve')
134 console.log(`solve [${params.pde}] -> '${ev.name}' in ${(Date.now() - t) / 1000}s`)
135 return ev
136 }
138 // 1. Poisson on the closed sphere
139 let ev = solve({pde: 'poisson', f: 'x.*y.*z', c: '', p: 6, closed: true})
140 if (ev.name !== 'solution') throw new Error(`poisson failed: ${JSON.stringify(ev.data)}`)
141 let d = ev.data
142 console.log(` npatches=${d.npatches} n=${d.n} u in [${d.umin.toFixed(6)}, ${d.umax.toFixed(6)}]`)
143 if (d.npatches !== 216 || d.n !== 7) throw new Error('unexpected solution shape')
144 if (!isFinite(d.umin) || !isFinite(d.umax) || d.umin === d.umax)
145 throw new Error('degenerate solution values')
147 // Eigenfunction check: x*y*z is a degree-3 solid harmonic, so on the unit
148 // sphere lap_S (x*y*z) = -12 * (x*y*z). Solving with f = -12*x*y*z must
149 // reproduce u = x*y*z, whose max on the sphere is 1/(3*sqrt(3)).
150 ev = solve({pde: 'poisson', f: '-12*(x.*y.*z)', c: '', p: 8, closed: true})
151 d = ev.data
152 const expected = 1 / (3 * Math.sqrt(3))
153 console.log(` eigencheck: umax=${d.umax.toFixed(6)} expected~${expected.toFixed(6)}`)
154 if (Math.abs(d.umax - expected) > 0.01) throw new Error('eigenfunction check failed')
156 // 2. Helmholtz with a variable coefficient
157 ev = solve({pde: 'helmholtz', f: '1 + 0*x', c: '100*(1 - z)', p: 6, closed: true})
158 if (ev.name !== 'solution') throw new Error(`helmholtz failed: ${JSON.stringify(ev.data)}`)
159 console.log(` u in [${ev.data.umin.toFixed(6)}, ${ev.data.umax.toFixed(6)}]`)
161 // 3. Bad expression surfaces as a solveError, session stays alive
162 ev = solve({pde: 'poisson', f: 'this is not matlab', c: '', p: 4, closed: true})
163 if (ev.name !== 'solveError') throw new Error('expected solveError for bad expression')
164 console.log(` error path OK: ${JSON.stringify(ev.data).slice(0, 100)}`)
166 // 4. Session still works after an error
167 ev = solve({pde: 'poisson', f: 'x', c: '', p: 4, closed: true})
168 if (ev.name !== 'solution') throw new Error('session did not survive the error')
170 console.log('engine-test: all checks passed')
173main().catch(err => {
174 console.error(err)
175 process.exit(1)
176})
moveopenescclose