Command for testing against a reference implementation
6 changed files+833−28
README.mdmodified+17−0View file
@@ -388,6 +388,23 @@ Other commands:
388388 - `node scripts/check-live.mjs [url]` — smoke-check a deployed URL.
389389 - `test.html?soak=<steps>&lmax=<n>` — solver-only soak with no rendering.
390390
391+### Testing against a reference implementation
392+
393+Reference solutions live in the sibling
394+[turing-surface-test-data](https://github.com/concept-collection/turing-surface-test-data)
395+repo, so an independently-written solver never has to depend on this one.
396+`cases/schnakenberg-ellipsoid.md` there specifies the one case this repo
397+currently ships a reference for;
398+[`docs/ellipsoid-reference-spec.md`](docs/ellipsoid-reference-spec.md)
399+restates it in this repo's own terms.
400+
401+`npm run ref -- --in <file>` (`scripts/ref.ts`) loads a reference file, runs
402+the solver from its exact initial spectral state to the same physical end
403+time, and reports the relative-L2 error against its final state (plus a
404+geometry sanity check). `--niter` overrides the surface-correction iteration
405+count independent of the file, and `--tolerance` turns the check into a
406+pass/fail for CI.
407+
391408 ## Development
392409
393410 ```
docs/ellipsoid-reference-spec.mdadded+112−0View file
@@ -0,0 +1,112 @@
1+# Reference test case: Schnakenberg on a triaxial ellipsoid
2+
3+For validating this repo's Laplace-Beltrami surface correction against an
4+independently-implemented reference solver, comparing final state in
5+spherical-harmonic (SH) coefficient space.
6+
7+## Geometry
8+
9+A triaxial ellipsoid, `gx = ax·sinθ·cosφ`, `gy = ay·sinθ·sinφ`,
10+`gz = az·cosθ` (`geometries/ellipsoid.m`; defaults `ax=1.5, ay=1.0, az=0.6`).
11+
12+**Important**: the solver does not run on this analytic surface — it
13+band-limits it first, analysing `(gx, gy, gz)` into SH coefficients truncated
14+at degree `lmax` and re-synthesizing before use (`src/geom/geometry.ts`). To
15+remove geometry-representation error as a confound, a reference file
16+supplies these coefficients directly as `/geometry/Gx`, `/geometry/Gy`,
17+`/geometry/Gz`. **The reference solver must reconstruct its surface (and
18+induced metric) by synthesizing these coefficients, not by evaluating the
19+analytic formula above.**
20+
21+## Equations
22+
23+Schnakenberg reaction-diffusion with the true surface Laplace-Beltrami
24+operator `Δ_g` (`models/schnakenberg.m`):
25+
26+```
27+du/dt = D1·Δ_g(u) + a - u + u²v
28+dv/dt = D2·Δ_g(v) + b - u²v
29+```
30+
31+This repo's internal discretization (`niter` Richardson-iteration count for
32+its own `Δ_g` approximation, `dt` for its IMEX-Euler timestep) is not part of
33+the equations being tested — the reference solver may use any consistent
34+method for `Δ_g` and any timestep. It only needs to reach the same physical
35+end time `T = steps · dt`.
36+
37+## Initial condition
38+
39+Loaded from the reference file's `/initial/U` / `/initial/V` (t=0,
40+immediately after seeding, before any step), not regenerated — avoids
41+needing to reimplement this repo's PRNG (`src/mgpu/noise.ts`) to get a
42+matching initial condition.
43+
44+## Output convention (must match exactly, from `src/sht/layout.ts`)
45+
46+- Orthonormal spherical harmonics **including Condon-Shortley phase**.
47+- Real field ⇒ complex coefficients stored for `m ≥ 0` only:
48+ `Q_{l,-m} = (-1)^m · conj(Q_lm)`; `m=0` coefficients have zero imaginary part.
49+- **m-major ordering**: for `m = 0..lmax`, for `l = m..lmax`.
50+ `index(l,m) = m·(lmax+1) − m·(m−1)/2 + (l−m)`.
51+- Flat array, length `2·nlm` with `nlm = (lmax+1)(lmax+2)/2`, `[re,im]`
52+ interleaved per coefficient (`qlm[2·index(l,m)]`, `qlm[2·index(l,m)+1]`).
53+
54+The reference solver must project its final `u`, `v` onto this same
55+convention/truncation and report flat `2·nlm` arrays, to diff directly
56+against the reference file's `/final/U` / `/final/V`.
57+
58+## HDF5 file layout
59+
60+Each reference file is one `.h5` file per run (written with
61+[h5wasm](https://github.com/usnistgov/h5wasm); readable from Python with
62+`h5py.File(path, "r")`). Coefficient datasets are `float32`, each of length
63+`2·nlm` in the convention above. Metadata is stored as attributes, grouped by
64+what it describes rather than as a single flat namespace:
65+
66+```
67+/ (attrs: command, model, species)
68+├─ backend/ (attrs: adapter, runtime, precision)
69+├─ spec/ (attrs: preset, geometry, lmax, seed, steps, warmup, niter)
70+│ ├─ params/ (attrs: the model's own params, e.g. a, b, D1, D2, dt)
71+│ └─ geometry_params/ (attrs: the geometry's own params, e.g. ax, ay, az)
72+├─ grid/ (attrs: lmax, mmax, nlat, nphi, nlm)
73+├─ geometry/
74+│ ├─ Gx dataset, float32[2·nlm]
75+│ ├─ Gy dataset, float32[2·nlm]
76+│ └─ Gz dataset, float32[2·nlm]
77+├─ initial/ one dataset per species (e.g. U, V), float32[2·nlm] each
78+└─ final/ one dataset per species (e.g. U, V), float32[2·nlm] each
79+```
80+
81+`species` (root attribute) names which datasets live under `initial/` and
82+`final/` — `["U", "V"]` for Schnakenberg. `command` is the equivalent
83+`npm run bench --` invocation, for reproducing the run exactly.
84+
85+## Parameters
86+
87+| name | meaning | default |
88+|---|---|---|
89+| `a`, `b` | Schnakenberg kinetics | 0.1, 0.9 |
90+| `D1`, `D2` | diffusion coefficients | 4e-4, 8e-3 |
91+| `ax`, `ay`, `az` | ellipsoid semi-axes | 1.5, 1.0, 0.6 |
92+| `lmax` | SH truncation degree | 63 |
93+| `T = steps·dt` | physical end time | e.g. 2000·0.05 = 100 |
94+| `seed` | provenance only — IC supplied as coefficients | 1 |
95+
96+## Checking a run against a reference file
97+
98+`npm run ref -- --in <file>` loads a reference file, runs this
99+repo's own solver from its exact initial condition to the same physical end
100+time, and reports the relative-L2 error of the resulting state against the
101+file's final state (and, as a sanity check, of the regenerated geometry
102+against the file's own geometry coefficients — this should be ~0 unless
103+geometry construction itself has changed). `--niter <n>` overrides the
104+solve's own iteration count for the surface correction, independent of what
105+the reference file was generated with — useful for seeing how much that
106+correction term actually matters for a given run. `--tolerance <n>` turns
107+the check into a pass/fail (nonzero exit code on failure), for use in CI.
108+
109+## Caveat
110+
111+This repo runs fp32 on GPU; expect ~1e-4–1e-6 relative floating-point noise
112+on top of any genuine numerical-method disagreement between solvers.
package-lock.jsonmodified+453−27View file
@@ -9,6 +9,7 @@
99 "version": "0.1.0",
1010 "license": "CECILL-2.1",
1111 "dependencies": {
12+ "h5wasm": "^0.10.3",
1213 "mp4-muxer": "^5.2.2",
1314 "numbl": "file:../../numbl",
1415 "three": "^0.183.0"
@@ -112,31 +113,6 @@
112113 "dev": true,
113114 "license": "Apache-2.0"
114115 },
115- "node_modules/@emnapi/core": {
116- "version": "2.0.0-alpha.3",
117- "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-2.0.0-alpha.3.tgz",
118- "integrity": "sha512-AZypUeJ/yByuxyS7BlSNRDOMLMlROYtjYdIAuBmJssVz1UJDSeYxLrdizhXCFYhedC5bqd/ASy8EuNXbVVXp9g==",
119- "dev": true,
120- "license": "MIT",
121- "optional": true,
122- "peer": true,
123- "dependencies": {
124- "@emnapi/wasi-threads": "2.0.1",
125- "tslib": "^2.4.0"
126- }
127- },
128- "node_modules/@emnapi/runtime": {
129- "version": "2.0.0-alpha.3",
130- "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-2.0.0-alpha.3.tgz",
131- "integrity": "sha512-hFPAhMUjJD9BSyCANEISPOogeXC9Zo9ZQl7L6vKnaVsMkCtzznaW/naYypeyl0Gv5rYfWYsZbpixTMpjDJzQeA==",
132- "dev": true,
133- "license": "MIT",
134- "optional": true,
135- "peer": true,
136- "dependencies": {
137- "tslib": "^2.4.0"
138- }
139- },
140116 "node_modules/@emnapi/wasi-threads": {
141117 "version": "2.0.1",
142118 "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-2.0.1.tgz",
@@ -144,7 +120,6 @@
144120 "dev": true,
145121 "license": "MIT",
146122 "optional": true,
147- "peer": true,
148123 "dependencies": {
149124 "tslib": "^2.4.0"
150125 }
@@ -438,6 +413,23 @@
438413 "node": ">=12"
439414 }
440415 },
416+ "node_modules/@esbuild/netbsd-arm64": {
417+ "version": "0.28.1",
418+ "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.1.tgz",
419+ "integrity": "sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw==",
420+ "cpu": [
421+ "arm64"
422+ ],
423+ "dev": true,
424+ "license": "MIT",
425+ "optional": true,
426+ "os": [
427+ "netbsd"
428+ ],
429+ "engines": {
430+ "node": ">=18"
431+ }
432+ },
441433 "node_modules/@esbuild/netbsd-x64": {
442434 "version": "0.21.5",
443435 "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz",
@@ -455,6 +447,23 @@
455447 "node": ">=12"
456448 }
457449 },
450+ "node_modules/@esbuild/openbsd-arm64": {
451+ "version": "0.28.1",
452+ "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.1.tgz",
453+ "integrity": "sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q==",
454+ "cpu": [
455+ "arm64"
456+ ],
457+ "dev": true,
458+ "license": "MIT",
459+ "optional": true,
460+ "os": [
461+ "openbsd"
462+ ],
463+ "engines": {
464+ "node": ">=18"
465+ }
466+ },
458467 "node_modules/@esbuild/openbsd-x64": {
459468 "version": "0.21.5",
460469 "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz",
@@ -472,6 +481,23 @@
472481 "node": ">=12"
473482 }
474483 },
484+ "node_modules/@esbuild/openharmony-arm64": {
485+ "version": "0.28.1",
486+ "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.1.tgz",
487+ "integrity": "sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg==",
488+ "cpu": [
489+ "arm64"
490+ ],
491+ "dev": true,
492+ "license": "MIT",
493+ "optional": true,
494+ "os": [
495+ "openharmony"
496+ ],
497+ "engines": {
498+ "node": ">=18"
499+ }
500+ },
475501 "node_modules/@esbuild/sunos-x64": {
476502 "version": "0.21.5",
477503 "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz",
@@ -1676,7 +1702,8 @@
16761702 "resolved": "https://registry.npmjs.org/devtools-protocol/-/devtools-protocol-0.0.1367902.tgz",
16771703 "integrity": "sha512-XxtPuC3PGakY6PD7dG66/o8KwJ/LkH2/EKe19Dcw58w53dv4/vSQEkn/SzuyhHE2q4zPgCkxQBxus3VV4ql+Pg==",
16781704 "dev": true,
1679- "license": "BSD-3-Clause"
1705+ "license": "BSD-3-Clause",
1706+ "peer": true
16801707 },
16811708 "node_modules/emoji-regex": {
16821709 "version": "8.0.0",
@@ -1936,6 +1963,12 @@
19361963 "node": ">= 14"
19371964 }
19381965 },
1966+ "node_modules/h5wasm": {
1967+ "version": "0.10.3",
1968+ "resolved": "https://registry.npmjs.org/h5wasm/-/h5wasm-0.10.3.tgz",
1969+ "integrity": "sha512-W4Jy5ExtX/VNbyD8GdOBckDuj6AL16TemppVNxZsV3rJZEWCv2sxlCzOttZLer3zkMttbDYsWHl0qt1z3Bln+Q==",
1970+ "license": "SEE LICENSE IN LICENSE.txt"
1971+ },
19391972 "node_modules/http-proxy-agent": {
19401973 "version": "7.0.2",
19411974 "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz",
@@ -2011,6 +2044,7 @@
20112044 "integrity": "sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA==",
20122045 "dev": true,
20132046 "license": "MPL-2.0",
2047+ "peer": true,
20142048 "dependencies": {
20152049 "detect-libc": "^2.0.3"
20162050 },
@@ -2426,6 +2460,7 @@
24262460 "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==",
24272461 "dev": true,
24282462 "license": "MIT",
2463+ "peer": true,
24292464 "engines": {
24302465 "node": ">=12"
24312466 },
@@ -2939,6 +2974,397 @@
29392974 "url": "https://opencollective.com/antfu"
29402975 }
29412976 },
2977+ "node_modules/vite-node/node_modules/@esbuild/aix-ppc64": {
2978+ "version": "0.28.1",
2979+ "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.1.tgz",
2980+ "integrity": "sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==",
2981+ "cpu": [
2982+ "ppc64"
2983+ ],
2984+ "dev": true,
2985+ "license": "MIT",
2986+ "optional": true,
2987+ "os": [
2988+ "aix"
2989+ ],
2990+ "engines": {
2991+ "node": ">=18"
2992+ }
2993+ },
2994+ "node_modules/vite-node/node_modules/@esbuild/android-arm": {
2995+ "version": "0.28.1",
2996+ "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.1.tgz",
2997+ "integrity": "sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ==",
2998+ "cpu": [
2999+ "arm"
3000+ ],
3001+ "dev": true,
3002+ "license": "MIT",
3003+ "optional": true,
3004+ "os": [
3005+ "android"
3006+ ],
3007+ "engines": {
3008+ "node": ">=18"
3009+ }
3010+ },
3011+ "node_modules/vite-node/node_modules/@esbuild/android-arm64": {
3012+ "version": "0.28.1",
3013+ "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.1.tgz",
3014+ "integrity": "sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg==",
3015+ "cpu": [
3016+ "arm64"
3017+ ],
3018+ "dev": true,
3019+ "license": "MIT",
3020+ "optional": true,
3021+ "os": [
3022+ "android"
3023+ ],
3024+ "engines": {
3025+ "node": ">=18"
3026+ }
3027+ },
3028+ "node_modules/vite-node/node_modules/@esbuild/android-x64": {
3029+ "version": "0.28.1",
3030+ "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.1.tgz",
3031+ "integrity": "sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng==",
3032+ "cpu": [
3033+ "x64"
3034+ ],
3035+ "dev": true,
3036+ "license": "MIT",
3037+ "optional": true,
3038+ "os": [
3039+ "android"
3040+ ],
3041+ "engines": {
3042+ "node": ">=18"
3043+ }
3044+ },
3045+ "node_modules/vite-node/node_modules/@esbuild/darwin-arm64": {
3046+ "version": "0.28.1",
3047+ "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.1.tgz",
3048+ "integrity": "sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q==",
3049+ "cpu": [
3050+ "arm64"
3051+ ],
3052+ "dev": true,
3053+ "license": "MIT",
3054+ "optional": true,
3055+ "os": [
3056+ "darwin"
3057+ ],
3058+ "engines": {
3059+ "node": ">=18"
3060+ }
3061+ },
3062+ "node_modules/vite-node/node_modules/@esbuild/darwin-x64": {
3063+ "version": "0.28.1",
3064+ "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.1.tgz",
3065+ "integrity": "sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ==",
3066+ "cpu": [
3067+ "x64"
3068+ ],
3069+ "dev": true,
3070+ "license": "MIT",
3071+ "optional": true,
3072+ "os": [
3073+ "darwin"
3074+ ],
3075+ "engines": {
3076+ "node": ">=18"
3077+ }
3078+ },
3079+ "node_modules/vite-node/node_modules/@esbuild/freebsd-arm64": {
3080+ "version": "0.28.1",
3081+ "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.1.tgz",
3082+ "integrity": "sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw==",
3083+ "cpu": [
3084+ "arm64"
3085+ ],
3086+ "dev": true,
3087+ "license": "MIT",
3088+ "optional": true,
3089+ "os": [
3090+ "freebsd"
3091+ ],
3092+ "engines": {
3093+ "node": ">=18"
3094+ }
3095+ },
3096+ "node_modules/vite-node/node_modules/@esbuild/freebsd-x64": {
3097+ "version": "0.28.1",
3098+ "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.1.tgz",
3099+ "integrity": "sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ==",
3100+ "cpu": [
3101+ "x64"
3102+ ],
3103+ "dev": true,
3104+ "license": "MIT",
3105+ "optional": true,
3106+ "os": [
3107+ "freebsd"
3108+ ],
3109+ "engines": {
3110+ "node": ">=18"
3111+ }
3112+ },
3113+ "node_modules/vite-node/node_modules/@esbuild/linux-arm": {
3114+ "version": "0.28.1",
3115+ "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.1.tgz",
3116+ "integrity": "sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ==",
3117+ "cpu": [
3118+ "arm"
3119+ ],
3120+ "dev": true,
3121+ "license": "MIT",
3122+ "optional": true,
3123+ "os": [
3124+ "linux"
3125+ ],
3126+ "engines": {
3127+ "node": ">=18"
3128+ }
3129+ },
3130+ "node_modules/vite-node/node_modules/@esbuild/linux-arm64": {
3131+ "version": "0.28.1",
3132+ "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.1.tgz",
3133+ "integrity": "sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g==",
3134+ "cpu": [
3135+ "arm64"
3136+ ],
3137+ "dev": true,
3138+ "license": "MIT",
3139+ "optional": true,
3140+ "os": [
3141+ "linux"
3142+ ],
3143+ "engines": {
3144+ "node": ">=18"
3145+ }
3146+ },
3147+ "node_modules/vite-node/node_modules/@esbuild/linux-ia32": {
3148+ "version": "0.28.1",
3149+ "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.1.tgz",
3150+ "integrity": "sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w==",
3151+ "cpu": [
3152+ "ia32"
3153+ ],
3154+ "dev": true,
3155+ "license": "MIT",
3156+ "optional": true,
3157+ "os": [
3158+ "linux"
3159+ ],
3160+ "engines": {
3161+ "node": ">=18"
3162+ }
3163+ },
3164+ "node_modules/vite-node/node_modules/@esbuild/linux-loong64": {
3165+ "version": "0.28.1",
3166+ "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.1.tgz",
3167+ "integrity": "sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg==",
3168+ "cpu": [
3169+ "loong64"
3170+ ],
3171+ "dev": true,
3172+ "license": "MIT",
3173+ "optional": true,
3174+ "os": [
3175+ "linux"
3176+ ],
3177+ "engines": {
3178+ "node": ">=18"
3179+ }
3180+ },
3181+ "node_modules/vite-node/node_modules/@esbuild/linux-mips64el": {
3182+ "version": "0.28.1",
3183+ "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.1.tgz",
3184+ "integrity": "sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ==",
3185+ "cpu": [
3186+ "mips64el"
3187+ ],
3188+ "dev": true,
3189+ "license": "MIT",
3190+ "optional": true,
3191+ "os": [
3192+ "linux"
3193+ ],
3194+ "engines": {
3195+ "node": ">=18"
3196+ }
3197+ },
3198+ "node_modules/vite-node/node_modules/@esbuild/linux-ppc64": {
3199+ "version": "0.28.1",
3200+ "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.1.tgz",
3201+ "integrity": "sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ==",
3202+ "cpu": [
3203+ "ppc64"
3204+ ],
3205+ "dev": true,
3206+ "license": "MIT",
3207+ "optional": true,
3208+ "os": [
3209+ "linux"
3210+ ],
3211+ "engines": {
3212+ "node": ">=18"
3213+ }
3214+ },
3215+ "node_modules/vite-node/node_modules/@esbuild/linux-riscv64": {
3216+ "version": "0.28.1",
3217+ "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.1.tgz",
3218+ "integrity": "sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ==",
3219+ "cpu": [
3220+ "riscv64"
3221+ ],
3222+ "dev": true,
3223+ "license": "MIT",
3224+ "optional": true,
3225+ "os": [
3226+ "linux"
3227+ ],
3228+ "engines": {
3229+ "node": ">=18"
3230+ }
3231+ },
3232+ "node_modules/vite-node/node_modules/@esbuild/linux-s390x": {
3233+ "version": "0.28.1",
3234+ "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.1.tgz",
3235+ "integrity": "sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag==",
3236+ "cpu": [
3237+ "s390x"
3238+ ],
3239+ "dev": true,
3240+ "license": "MIT",
3241+ "optional": true,
3242+ "os": [
3243+ "linux"
3244+ ],
3245+ "engines": {
3246+ "node": ">=18"
3247+ }
3248+ },
3249+ "node_modules/vite-node/node_modules/@esbuild/linux-x64": {
3250+ "version": "0.28.1",
3251+ "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.1.tgz",
3252+ "integrity": "sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA==",
3253+ "cpu": [
3254+ "x64"
3255+ ],
3256+ "dev": true,
3257+ "license": "MIT",
3258+ "optional": true,
3259+ "os": [
3260+ "linux"
3261+ ],
3262+ "engines": {
3263+ "node": ">=18"
3264+ }
3265+ },
3266+ "node_modules/vite-node/node_modules/@esbuild/netbsd-x64": {
3267+ "version": "0.28.1",
3268+ "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.1.tgz",
3269+ "integrity": "sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg==",
3270+ "cpu": [
3271+ "x64"
3272+ ],
3273+ "dev": true,
3274+ "license": "MIT",
3275+ "optional": true,
3276+ "os": [
3277+ "netbsd"
3278+ ],
3279+ "engines": {
3280+ "node": ">=18"
3281+ }
3282+ },
3283+ "node_modules/vite-node/node_modules/@esbuild/openbsd-x64": {
3284+ "version": "0.28.1",
3285+ "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.1.tgz",
3286+ "integrity": "sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw==",
3287+ "cpu": [
3288+ "x64"
3289+ ],
3290+ "dev": true,
3291+ "license": "MIT",
3292+ "optional": true,
3293+ "os": [
3294+ "openbsd"
3295+ ],
3296+ "engines": {
3297+ "node": ">=18"
3298+ }
3299+ },
3300+ "node_modules/vite-node/node_modules/@esbuild/sunos-x64": {
3301+ "version": "0.28.1",
3302+ "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.1.tgz",
3303+ "integrity": "sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ==",
3304+ "cpu": [
3305+ "x64"
3306+ ],
3307+ "dev": true,
3308+ "license": "MIT",
3309+ "optional": true,
3310+ "os": [
3311+ "sunos"
3312+ ],
3313+ "engines": {
3314+ "node": ">=18"
3315+ }
3316+ },
3317+ "node_modules/vite-node/node_modules/@esbuild/win32-arm64": {
3318+ "version": "0.28.1",
3319+ "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.1.tgz",
3320+ "integrity": "sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA==",
3321+ "cpu": [
3322+ "arm64"
3323+ ],
3324+ "dev": true,
3325+ "license": "MIT",
3326+ "optional": true,
3327+ "os": [
3328+ "win32"
3329+ ],
3330+ "engines": {
3331+ "node": ">=18"
3332+ }
3333+ },
3334+ "node_modules/vite-node/node_modules/@esbuild/win32-ia32": {
3335+ "version": "0.28.1",
3336+ "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.1.tgz",
3337+ "integrity": "sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg==",
3338+ "cpu": [
3339+ "ia32"
3340+ ],
3341+ "dev": true,
3342+ "license": "MIT",
3343+ "optional": true,
3344+ "os": [
3345+ "win32"
3346+ ],
3347+ "engines": {
3348+ "node": ">=18"
3349+ }
3350+ },
3351+ "node_modules/vite-node/node_modules/@esbuild/win32-x64": {
3352+ "version": "0.28.1",
3353+ "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.1.tgz",
3354+ "integrity": "sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A==",
3355+ "cpu": [
3356+ "x64"
3357+ ],
3358+ "dev": true,
3359+ "license": "MIT",
3360+ "optional": true,
3361+ "os": [
3362+ "win32"
3363+ ],
3364+ "engines": {
3365+ "node": ">=18"
3366+ }
3367+ },
29423368 "node_modules/vite-node/node_modules/vite": {
29433369 "version": "8.1.5",
29443370 "resolved": "https://registry.npmjs.org/vite/-/vite-8.1.5.tgz",
package.jsonmodified+3−1View file
@@ -14,9 +14,11 @@
1414 "test:gpu": "vite build && node scripts/test-gpu.mjs",
1515 "test": "npm run test:node && npm run test:gpu",
1616 "bench": "vite-node scripts/bench.ts",
17- "bench:sht": "vite-node scripts/bench-sht.ts"
17+ "bench:sht": "vite-node scripts/bench-sht.ts",
18+ "ref": "vite-node scripts/ref.ts"
1819 },
1920 "dependencies": {
21+ "h5wasm": "^0.10.3",
2022 "mp4-muxer": "^5.2.2",
2123 "numbl": "file:../../numbl",
2224 "three": "^0.183.0"
scripts/ref.tsadded+231−0View file
@@ -0,0 +1,231 @@
1+/**
2+ * Import a reference HDF5 file — geometry, initial and final spherical-
3+ * harmonic coefficients for a run of this repo's solver, in the format
4+ * documented alongside the sibling test-data repo's case files (see
5+ * ../turing-surface-test-data/cases/) — run this repo's own solver from that
6+ * file's exact initial condition, and report the numerical error against
7+ * its final state.
8+ *
9+ * This is the regression check for the surface Laplace-Beltrami correction:
10+ * replay a saved-off run and see how far this repo's own output has drifted
11+ * (or use --niter to probe how much the correction term itself matters).
12+ *
13+ * npm run ref -- --in data/schnak-spots.h5
14+ * npm run ref -- --in data/schnak-spots.h5 --niter 0
15+ */
16+import { requestShtDevice, describeAdapter } from '../src/sht/sht.ts';
17+import { ModelSession } from '../src/mgpu/session.ts';
18+import { mModelByKey, defaultParams, type Params } from '../src/mgpu/registry.ts';
19+import { mGeometryByKey, defaultGeometryParams } from '../src/geom/registry.ts';
20+import { relL2 } from '../src/mgpu/digest.ts';
21+import { installWebGpu, errMsg, NO_ADAPTER_HINT } from './nodeWebGpu.ts';
22+import * as h5wasm from 'h5wasm/node';
23+
24+const USAGE = `usage: npm run ref -- --in <file> [options]
25+
26+ --in <file> the reference HDF5 file to check against (required)
27+ --niter <n> override the solve iteration count (default: the file's own)
28+ --tolerance <n> if given, exit 1 when any reported relL2 meets or exceeds it
29+ --json machine-readable output
30+ --help
31+
32+Runs this repo's solver from the file's exact initial spectral state, to the
33+same physical end time, and reports the relative-L2 error of the resulting
34+state against the file's final state (and, as a sanity check, of the
35+regenerated geometry against the file's own geometry coefficients).`;
36+
37+function fail(msg: string, code = 1): never {
38+ console.error(`ref: ${msg}`);
39+ process.exit(code);
40+}
41+
42+const argv = process.argv.slice(2);
43+if (argv.includes('--help') || argv.includes('-h')) {
44+ console.log(USAGE);
45+ process.exit(0);
46+}
47+let inFile: string | null = null;
48+let niterOverride: number | null = null;
49+let tolerance: number | null = null;
50+const wantJson = argv.includes('--json');
51+for (let i = 0; i < argv.length; i++) {
52+ const a = argv[i];
53+ if (a === '--json') continue;
54+ const valued = (name: string): string | null => {
55+ if (a === `--${name}`) return argv[++i];
56+ if (a.startsWith(`--${name}=`)) return a.slice(name.length + 3);
57+ return null;
58+ };
59+ const inv = valued('in');
60+ if (inv !== null) {
61+ inFile = inv;
62+ continue;
63+ }
64+ const niterv = valued('niter');
65+ if (niterv !== null) {
66+ niterOverride = Number(niterv);
67+ if (!Number.isInteger(niterOverride) || niterOverride < 0) {
68+ fail(`--niter must be an integer >= 0 (got '${niterv}')`, 2);
69+ }
70+ continue;
71+ }
72+ const tolv = valued('tolerance');
73+ if (tolv !== null) {
74+ tolerance = Number(tolv);
75+ if (!Number.isFinite(tolerance)) fail(`--tolerance must be a number (got '${tolv}')`, 2);
76+ continue;
77+ }
78+ fail(`unrecognized argument '${a}'\n\n${USAGE}`, 2);
79+}
80+if (!inFile) fail(`--in <file> is required\n\n${USAGE}`, 2);
81+
82+const attrsOf = (entity: { attrs: Record<string, { value: unknown }> }): Record<string, unknown> =>
83+ Object.fromEntries(Object.entries(entity.attrs).map(([k, v]) => [k, v.value]));
84+
85+const numberAttrs = (entity: { attrs: Record<string, { value: unknown }> }): Params =>
86+ Object.fromEntries(
87+ Object.entries(attrsOf(entity)).map(([k, v]) => [k, Number(v)]),
88+ );
89+
90+let device: GPUDevice | null = null;
91+let session: ModelSession | null = null;
92+let h5file: InstanceType<typeof h5wasm.File> | null = null;
93+
94+try {
95+ await h5wasm.ready;
96+ h5file = new h5wasm.File(inFile, 'r');
97+
98+ const rootAttrs = attrsOf(h5file);
99+ const modelKey = String(rootAttrs.model);
100+ const model = mModelByKey(modelKey);
101+ if (!model) fail(`unknown model '${modelKey}' in ${inFile}`);
102+
103+ const specGroup = h5file.get('spec') as InstanceType<typeof h5wasm.Group>;
104+ const specAttrs = attrsOf(specGroup);
105+ const geometryKey = String(specAttrs.geometry);
106+ const geometryModel = mGeometryByKey(geometryKey);
107+ if (!geometryModel) fail(`unknown geometry '${geometryKey}' in ${inFile}`);
108+
109+ const lmax = Number(specAttrs.lmax);
110+ const steps = Number(specAttrs.steps);
111+ const niter = niterOverride ?? Number(specAttrs.niter);
112+
113+ const params: Params = {
114+ ...defaultParams(model),
115+ ...numberAttrs(specGroup.get('params') as InstanceType<typeof h5wasm.Group>),
116+ };
117+ const geometryParams: Params = {
118+ ...defaultGeometryParams(geometryModel),
119+ ...numberAttrs(specGroup.get('geometry_params') as InstanceType<typeof h5wasm.Group>),
120+ };
121+
122+ const geomGroup = h5file.get('geometry') as InstanceType<typeof h5wasm.Group>;
123+ const fileGeom = {
124+ X: (geomGroup.get('Gx') as InstanceType<typeof h5wasm.Dataset>).value as Float32Array,
125+ Y: (geomGroup.get('Gy') as InstanceType<typeof h5wasm.Dataset>).value as Float32Array,
126+ Z: (geomGroup.get('Gz') as InstanceType<typeof h5wasm.Dataset>).value as Float32Array,
127+ };
128+
129+ const initialGroup = h5file.get('initial') as InstanceType<typeof h5wasm.Group>;
130+ const finalGroup = h5file.get('final') as InstanceType<typeof h5wasm.Group>;
131+ const fileInitial: Record<string, Float32Array> = {};
132+ const fileFinal: Record<string, Float32Array> = {};
133+ for (const name of model.state) {
134+ fileInitial[name] = (initialGroup.get(name) as InstanceType<typeof h5wasm.Dataset>)
135+ .value as Float32Array;
136+ fileFinal[name] = (finalGroup.get(name) as InstanceType<typeof h5wasm.Dataset>)
137+ .value as Float32Array;
138+ }
139+
140+ h5file.close();
141+ h5file = null;
142+
143+ const runtime = await installWebGpu();
144+ device = await requestShtDevice().catch((e: unknown) => {
145+ throw new Error(`${errMsg(e)}\n${NO_ADAPTER_HINT}`);
146+ });
147+ const adapter = await describeAdapter(device);
148+
149+ session = await ModelSession.create({
150+ device,
151+ model,
152+ params,
153+ lmax,
154+ geometry: geometryModel,
155+ geometryParams,
156+ niter,
157+ });
158+
159+ const geometryError = {
160+ Gx: relL2(session.geometry.X, fileGeom.X),
161+ Gy: relL2(session.geometry.Y, fileGeom.Y),
162+ Gz: relL2(session.geometry.Z, fileGeom.Z),
163+ };
164+
165+ session.loadState(fileInitial);
166+ session.step(steps);
167+
168+ const stateError: Record<string, number> = {};
169+ for (const name of model.state) {
170+ // Sequential: GpuModel.read() shares one staging buffer across calls.
171+ const ours = await session.read(name);
172+ stateError[name] = relL2(ours, fileFinal[name]);
173+ }
174+
175+ const allErrors = [...Object.values(geometryError), ...Object.values(stateError)];
176+ const worst = Math.max(...allErrors);
177+ const pass = tolerance === null ? null : worst < tolerance;
178+
179+ if (wantJson) {
180+ console.log(
181+ JSON.stringify(
182+ {
183+ in: inFile,
184+ model: model.key,
185+ geometry: geometryModel.key,
186+ grid: { lmax, nlm: session.sht.nlm },
187+ niter,
188+ steps,
189+ dt: params.dt,
190+ T: steps * (params.dt ?? 0),
191+ backend: { adapter, runtime, precision: 'fp32' },
192+ geometryError,
193+ stateError,
194+ tolerance,
195+ pass,
196+ },
197+ null,
198+ 2,
199+ ),
200+ );
201+ } else {
202+ console.log(`ref: ${inFile}`);
203+ console.log(
204+ ` model ${model.label} (${model.state.join(', ')})\n` +
205+ ` geometry ${geometryModel.label} ` +
206+ geometryModel.params.map((p) => `${p.key}=${geometryParams[p.key]}`).join(' ') +
207+ `\n grid lmax ${lmax} · nlm ${session.sht.nlm}\n` +
208+ ` niter ${niter}${niterOverride !== null ? ` (file: ${specAttrs.niter})` : ''}\n` +
209+ ` run ${steps} steps, dt=${params.dt} (T=${(steps * (params.dt ?? 0)).toFixed(2)})\n`,
210+ );
211+ console.log(` geometry check (regenerated vs file, relL2):`);
212+ for (const [k, v] of Object.entries(geometryError)) console.log(` ${k} ${v.toExponential(3)}`);
213+ console.log(`\n final state (this run vs file, relL2):`);
214+ for (const [k, v] of Object.entries(stateError)) console.log(` ${k} ${v.toExponential(3)}`);
215+ if (tolerance !== null) {
216+ console.log(
217+ `\n worst relL2 ${worst.toExponential(3)} vs tolerance ${tolerance.toExponential(3)}: ` +
218+ (pass ? 'PASS' : 'FAIL'),
219+ );
220+ }
221+ }
222+
223+ session.destroy();
224+ device.destroy();
225+ process.exit(pass === false ? 1 : 0);
226+} catch (e) {
227+ h5file?.close();
228+ session?.destroy();
229+ device?.destroy();
230+ fail(errMsg(e));
231+}
src/mgpu/session.tsmodified+17−0View file
@@ -243,6 +243,23 @@ export class ModelSession {
243243 this.steps = 0;
244244 }
245245
246+ /**
247+ * Push an exact spectral state into the running model, bypassing seeded
248+ * init, and reset model time like seed() does. `gpu.step(0)` flips which
249+ * of the init/step buffer aliases a read resolves to, without otherwise
250+ * touching the state — see GpuModel's `#lastRan`.
251+ */
252+ loadState(coeffs: Record<string, Float32Array>): void {
253+ for (const name of this.model.state) {
254+ const data = coeffs[name];
255+ if (!data) throw new Error(`loadState: missing state '${name}'`);
256+ this.gpu.upload(name, data);
257+ }
258+ this.gpu.step(0);
259+ this.t = 0;
260+ this.steps = 0;
261+ }
262+
246263 setParams(params: ModelParams): void {
247264 this.#params = params;
248265 this.gpu.setParams(params);