/ forks / turing-surface
Sign in
forks / turing-surface
forked from github.com/concept-collection/turing-surface

Comparing changes

… Swap
main is 32 commits ahead of compare-solver-settings. Create pull request
Merge main into compare-against-sphere
Jeremy Magland committed
9916f17
export standalone matlab
Jeremy Magland committed
9b71d5b
WIP: comparison with unit sphere
Owen Melia committed
cf4af12
Update comparison UI
Owen Melia committed
02e7a36
Add short descriptions for each mode
Owen Melia committed
2bb4f78
Fixing some bugs in the UI
Owen Melia committed
c8e230a
Open the reference comparison in one click
Jeremy Magland committed
3210e7d
Merge main into reference_soln
Jeremy Magland committed
4b7c487
Scale the randnfun3 GPU/CPU bound to the field
Dan Fortunato committed
c2fcb48
Generate numbl's stdlib bundle in CI
Dan Fortunato committed
cc9b1ca
Merge main into random-fields
Jeremy Magland committed
beac00a
Drop the work-in-progress banner
Dan Fortunato committed
f132fe4
Merge branch 'main' into reference_soln
Owen Melia committed
b460a9f
54 changed files+5769−531
.github/workflows/ci.ymlmodified+11−5View file
@@ -14,14 +14,19 @@ jobs:
1414 node-version: 24
1515 cache: npm
1616 # numbl is a `file:../../numbl` dependency: we use its compiler internals
17- # (parser, lowerer, IR, inline pass), which its published package `exports`
18- # do not expose. Clone it where that relative path expects it. Pinned so a
19- # change to those internals cannot silently break the build — the surface we
20- # rely on is written down in src/mgpu/numbl.d.ts.
17+ # (parser, lowerer, IR, inline pass) and its interpreter, which its
18+ # published package `exports` do not expose. Clone it where that relative
19+ # path expects it. Pinned so a change to those internals cannot silently
20+ # break the build — the surface we rely on is written down in
21+ # src/mgpu/numbl.d.ts.
2122 #
2223 # numbl's own dependencies are NOT needed: the slice we import is
2324 # self-contained TypeScript, verified by building against a checkout with no
24- # node_modules.
25+ # node_modules. One file in it is generated rather than committed, though —
26+ # the interpreter's stdlib bundle, which numbl gitignores — so a bare
27+ # checkout is missing it and `executeCode.ts` fails to resolve it. Its
28+ # generator only reads .m files off disk, so plain `node` (type-stripping,
29+ # unflagged since 22.18) runs it without installing anything.
2530 - name: Check out numbl (sibling dependency)
2631 env:
2732 NUMBL_REF: main
@@ -29,6 +34,7 @@ jobs:
2934 git clone --filter=blob:none --no-checkout \
3035 https://github.com/flatironinstitute/numbl.git "$GITHUB_WORKSPACE/../../numbl"
3136 git -C "$GITHUB_WORKSPACE/../../numbl" checkout --quiet "$NUMBL_REF"
37+ node "$GITHUB_WORKSPACE/../../numbl/scripts/bundle-stdlib.ts"
3238 # --ignore-scripts: npm runs a linked package's `prepare` script, and
3339 # numbl's is husky, which is not installed here.
3440 - run: npm ci --ignore-scripts
.github/workflows/deploy.ymlmodified+11−5View file
@@ -26,14 +26,19 @@ jobs:
2626 node-version: 24
2727 cache: npm
2828 # numbl is a `file:../../numbl` dependency: we use its compiler internals
29- # (parser, lowerer, IR, inline pass), which its published package `exports`
30- # do not expose. Clone it where that relative path expects it. Pinned so a
31- # change to those internals cannot silently break the build — the surface we
32- # rely on is written down in src/mgpu/numbl.d.ts.
29+ # (parser, lowerer, IR, inline pass) and its interpreter, which its
30+ # published package `exports` do not expose. Clone it where that relative
31+ # path expects it. Pinned so a change to those internals cannot silently
32+ # break the build — the surface we rely on is written down in
33+ # src/mgpu/numbl.d.ts.
3334 #
3435 # numbl's own dependencies are NOT needed: the slice we import is
3536 # self-contained TypeScript, verified by building against a checkout with no
36- # node_modules.
37+ # node_modules. One file in it is generated rather than committed, though —
38+ # the interpreter's stdlib bundle, which numbl gitignores — so a bare
39+ # checkout is missing it and `executeCode.ts` fails to resolve it. Its
40+ # generator only reads .m files off disk, so plain `node` (type-stripping,
41+ # unflagged since 22.18) runs it without installing anything.
3742 - name: Check out numbl (sibling dependency)
3843 env:
3944 NUMBL_REF: main
@@ -41,6 +46,7 @@ jobs:
4146 git clone --filter=blob:none --no-checkout \
4247 https://github.com/flatironinstitute/numbl.git "$GITHUB_WORKSPACE/../../numbl"
4348 git -C "$GITHUB_WORKSPACE/../../numbl" checkout --quiet "$NUMBL_REF"
49+ node "$GITHUB_WORKSPACE/../../numbl/scripts/bundle-stdlib.ts"
4450 # --ignore-scripts: npm runs a linked package's `prepare` script, and
4551 # numbl's is husky, which is not installed here.
4652 - run: npm ci --ignore-scripts
.github/workflows/preview.ymlmodified+6−1View file
@@ -93,7 +93,11 @@ jobs:
9393 # numbl is a `file:../../numbl` dependency: we use its compiler internals
9494 # (parser, lowerer, IR, inline pass), which its published package
9595 # `exports` do not expose. Clone it where that relative path expects it,
96- # pinned to the same ref as ci.yml and deploy.yml.
96+ # pinned to the same ref as ci.yml and deploy.yml. One file in it is
97+ # generated rather than committed — the interpreter's stdlib bundle,
98+ # which numbl gitignores — so a bare checkout is missing it and
99+ # `executeCode.ts` fails to resolve it; its generator only reads .m files
100+ # off disk, so plain `node` runs it without installing anything.
97101 - name: Check out numbl (sibling dependency)
98102 env:
99103 NUMBL_REF: main
@@ -101,6 +105,7 @@ jobs:
101105 git clone --filter=blob:none --no-checkout \
102106 https://github.com/flatironinstitute/numbl.git "$GITHUB_WORKSPACE/../../numbl"
103107 git -C "$GITHUB_WORKSPACE/../../numbl" checkout --quiet "$NUMBL_REF"
108+ node "$GITHUB_WORKSPACE/../../numbl/scripts/bundle-stdlib.ts"
104109 # --ignore-scripts: npm runs a linked package's `prepare` script, and
105110 # numbl's is husky, which is not installed here.
106111 - run: npm ci --ignore-scripts
.gitignoremodified+1−0View file
@@ -1,3 +1,4 @@
1+tmp/
12 node_modules/
23 dist/
34 *.log
README.mdmodified+198−27View file
@@ -36,10 +36,12 @@ function [gx, gy, gz] = shape(theta, phi, waist, stretch)
3636 end
3737 ```
3838
39-That is ordinary element-wise MATLAB and goes through the same compiler and the
40-same WGSL backend the models do. It is evaluated once on the solver's grid, and
41-then **analysed into coefficients**, which is the form everything downstream
42-uses. Two things follow from going through the coefficients rather than keeping
39+That is ordinary MATLAB. Unlike the models it is not compiled to WGSL: a shape
40+is evaluated exactly once at build time, so it runs through numbl's CPU
41+interpreter instead, in f64, with the full MATLAB subset available — loops,
42+arrays, `min`/`max`, `legendre`, seeded randomness via `rng`/`randn`. The
43+result is then **analysed into coefficients**, which is the form everything
44+downstream uses. Two things follow from going through the coefficients rather than keeping
4345 the pointwise values:
4446
4547 - **It is exactly band-limited at lmax.** The surface has as many derivatives as
@@ -52,18 +54,132 @@ the pointwise values:
5254 That is exact interpolation, not subdivision — the same argument that lets the
5355 species fields be oversampled, and it is checked directly in the tests.
5456
55-Four geometries ship: [sphere](geometries/sphere.m) (the reference case),
57+Five geometries ship: [sphere](geometries/sphere.m) (the reference case),
5658 [ellipsoid](geometries/ellipsoid.m), [peanut](geometries/peanut.m) — a dumbbell
57-whose waist is a saddle — and [bumpy](geometries/bumpy.m). Each is editable in
58-the page, with its own parameters. Changing a shape does not recompile the
59+whose waist is a saddle — [bumpy](geometries/bumpy.m), and one random one:
60+[blob](geometries/blob.m), surfacefun's blob — the sphere warped by a smooth
61+random function built from chebfun's `randnfunsphere` construction (random
62+spherical-harmonic coefficients up to degree ⌊2π/λ⌋, rescaled to [−1, 1]).
63+It is seeded, so the same seed always gives the same shape; `amp` sets how far
64+it departs from the sphere, `λ` how fine its lobes are, and **Re-seed shape**
65+draws another one. Each geometry is
66+editable in the page, with its own parameters. Changing a shape does not recompile the
5967 solver and does not disturb the run: the geometry is data whose shape in the
6068 bindings depends only on the grid, so a swap is sixteen buffer writes and the
6169 pattern carries straight on.
6270
71+A sixth kind of geometry arrives from outside: reharm (the sibling
72+spherical-harmonic fitting app) can hand a fitted surface — a scanned mesh, a
73+brain, a point cloud — to this page with its **Export to turing-surface**
74+button. The surface arrives as coefficients by `postMessage` (there is no .m;
75+`Geometry` is built from them directly, re-indexed onto whatever band is
76+selected), lands as an **imported** entry in the geometry dropdown at its own
77+lmax, and behaves like any other shape from there — except that the editor,
78+the benchmark command line and the MATLAB export, all of which need a .m, say
79+so instead.
80+
6381 A **morph** slider blends the drawn surface back to the unit sphere. The
6482 parametrization is the sphere's either way, so sweeping it shows which point
6583 went where.
6684
85+### Seeding, and `tools/`
86+
87+A run starts from the uniform steady state plus a small perturbation, and that
88+perturbation is a *smooth* random field rather than white noise: chebfun's
89+[`randnfun3`](tools/randnfun3.m) on the surface's bounding box, restricted to
90+the surface by evaluating it at the grid points — the way surfacefun seeds a
91+run. Each model's `init` says so itself:
92+
93+```matlab
94+function [U, V, u, v] = init(lam3, gx, gy, gz, a, b)
95+ f = randnfun3(lam3, gx, gy, gz);
96+ ...
97+```
98+
99+A band-limited seed is fully resolved by the grid, where white noise is
100+whatever the grid happened to alias: the tests measure its energy above degree
101+20 at 5e-14 of the total, and the flux-form and Algorithm-4 operators now
102+track each other to 3e-6 through a run instead of 4e-4. The **seed λ** control
103+sets the field's wavelength; smaller means finer features to grow from. It is
104+an *absolute* length in the surface's own units, as in chebfun — not a
105+fraction of the surface's size — so a larger surface draws more modes at the
106+same λ.
107+
108+**λ is useful down to about 2π/lmax, and no further.** A field of wavelength λ
109+on a unit-radius surface carries angular content up to degree ≈ 2π/λ, so at
110+the default lmax 63 the grid holds everything down to λ ≈ 0.1. Past that,
111+`init`'s own `analys` discards what the grid cannot represent, and the seed
112+gets *weaker* rather than finer while costing eight times as much per halving:
113+
114+| λ | 2π/λ | rms of the resolved seed | energy above l=55 | peak degree |
115+|---|---|---|---|---|
116+| 0.5 | 13 | 2.6e-2 | 1e-8 | 8 |
117+| 0.2 | 31 | 2.6e-2 | 1e-8 | 10 |
118+| 0.1 | 63 | 2.4e-2 | 0.10 | 44 |
119+| 0.05 | 126 | 1.5e-2 | 0.20 | 48 |
120+| 0.03 | 209 | 9.6e-3 | 0.27 | 63 |
121+
122+Raising lmax moves that floor down, and the seed really does get finer: at
123+lmax 127 the same λ=0.05 keeps its full amplitude (2.5e-2 against 1.5e-2 at
124+lmax 63) with its peak at degree 79 instead of pinned to the band edge, and
125+λ=0.1 becomes *fully* resolved (2e-8 of its energy in the top decile, against
126+1e-1 at lmax 63 — so even 0.1 is slightly under-resolved on the default grid).
127+
128+Note that lmax cuts both ways: it quadruples npts, so every λ also costs four
129+times as much to sum.
130+
131+**Nothing caps λ but memory and patience.** The mode table grows to whatever
132+is asked for and the only refusal is a table that could not be built at all,
133+reported with the mode count it wanted rather than silently truncated. On a
134+128×256 grid:
135+
136+| λ | modes | seed time |
137+|---|---|---|
138+| 0.05 | 480,431 | 0.26 s |
139+| 0.03 | 2,094,657 | 0.98 s |
140+| 0.02 | 6,882,185 | 3.1 s |
141+| 0.015 | 16,092,829 | 7.4 s |
142+| 0.01 | 53,574,764 | 25.6 s |
143+
144+Being slow is the caller's business; **freezing the browser is not**, and at
145+these times neither half of the work can be left where it was:
146+
147+- The draw is synchronous interpreter time — 13 s at λ=0.01 — which on the
148+ main thread stops the page painting and gets it offered up for killing. It
149+ runs on a worker instead
150+ ([`randnfun3.worker.ts`](src/mgpu/randnfun3.worker.ts)); it touches no GPU
151+ and no DOM, so nothing about it needed that thread. Measured during a seed:
152+ 731 animation frames, no stalled sample.
153+- The GPU sum is split across a fixed 16 dispatches (`randnfun3Chunks`)
154+ accumulating into the same output, and `submitYielding` ends the submission
155+ at each one. A browser's GPU process is shared with compositing, so a single
156+ submission running tens of seconds stops *every* tab painting, and one
157+ dispatch that long risks the watchdog killing the device outright. Slices
158+ past the end of a small table exit immediately, so a coarse λ pays nothing.
159+
160+The device is also asked for the adapter's full storage-buffer limit at
161+creation ([`src/sht/sht.ts`](src/sht/sht.ts)), so a browser's 128 MB default
162+is not what decides how fine λ can be. `seed()` is consequently async.
163+
164+`randnfun3` splits across the CPU/GPU line, and the split is forced rather
165+than chosen. Drawing the modes needs `randn` and a `sqrt(nnz)` normalization,
166+neither of which exists in the compiled WGSL dialect, so the draw is MATLAB in
167+[`tools/randnfun3.m`](tools/randnfun3.m) run by the interpreter — a few
168+thousand coefficients, ~5 ms. Evaluating is `npts × nmodes` (~6e7 terms at the
169+default λ), so that is a WGSL kernel
170+([`src/mgpu/randnfun3.ts`](src/mgpu/randnfun3.ts)) reached as an external
171+operation, the way `synth` is. The coefficient table is filled in behind the
172+call, as `synth` hides its Legendre matrices; λ is not hidden, and the plan
173+records which parameter the `.m` asked with so the host draws from that value.
174+
175+[`tools/`](tools/) is the shared MATLAB every interpreter run can call, by file
176+name, as on MATLAB's path — currently `randnfun3` and
177+[`randnfunsphere`](tools/randnfunsphere.m), which `blob.m` is written on. Both
178+keep their upstream signatures, including options nothing shipped uses yet
179+(`randnfunsphere`'s `'monochromatic'`), because the point of a tool is that a
180+geometry you write next can reach for it. Tools are not available to the
181+models' *step*, which compiles to WGSL where none of this exists.
182+
67183 ## The scheme, and where the geometry enters
68184
69185 It solves the N-species system
@@ -167,15 +283,21 @@ Two formulations ship:
167283
168284 1. **The flux form** (above, all three models): `lap_g u` as the weighted
169285 divergence of two weighted fluxes of the sin-scaled derivatives. The
170- weights `p1, p2, q2, r` are grid arrays precomputed once per surface from
171- the embedding's θ/φ tangents
286+ weights `p2, r, dp1, dq2, jinv` are grid arrays precomputed once per
287+ surface from the embedding's θ/φ tangents
172288 ([`src/geom/metric.ts`](src/geom/metric.ts)), chosen so that **every field
173289 that gets analysed is a smooth function on the sphere** — the property
174290 that makes spherical-harmonic analysis meaningful, and the entire
175- difficulty near the poles. Cost: **5 Legendre transforms** per species
176- per iteration (3 syntheses + 2 analyses; the phi flux never needs the
177- Legendre basis — `dphig` differentiates it on the grid with two FFT
178- stages, masking m past the top-degree filter — and `dthetac`/`dphic`
291+ difficulty near the poles. The divergence is split against the round
292+ sphere: the sphere's share of it is `-jinv .* lap_s(u)`, exact in
293+ spectral space, so `r ~ 1/sin²θ` multiplies only the geometry deviation.
294+ Without that split `r` amplifies the polar round-off of the whole flux
295+ into a static forcing that nucleates a spot at the pole on every seed.
296+ Cost: **6 Legendre transforms** per species per iteration (4 syntheses —
297+ two gradient, one divergence, one for the sphere's `-lam .* u`, which
298+ rides in the gradient's batch — plus 2 analyses; the phi flux never needs
299+ the Legendre basis, `dphig` differentiates it on the grid with two FFT
300+ stages, masking m past the top-degree filter, and `dthetac`/`dphic`
179301 are O(nlm) coefficient shuffles). The derivation, the smoothness
180302 argument and the fp32 error analysis are in
181303 [docs/reduced-transforms.md](docs/reduced-transforms.md).
@@ -222,13 +344,14 @@ Two consequences worth stating:
222344 each loop body assigns before the pass and refuses the ones that escape, so
223345 that case is a compile error rather than a stale read.
224346
225-Unrolling is exactly linear in the trip count: 19 GPU ops per species per
226-iteration (6 transforms, 4 coefficient shuffles, 9 kernels), asserted in the
347+Unrolling is exactly linear in the trip count: 18 GPU ops per species per
348+iteration (7 transforms, 3 coefficient shuffles, 8 kernels), asserted in the
227349 tests.
228350
229351 ## MATLAB, compiled to WebGPU
230352
231-Unchanged from turing-sphere, and it now compiles the geometry files too. numbl
353+Unchanged from turing-sphere. This is the models' path — the geometry files
354+instead run once through numbl's CPU interpreter, as above. numbl
232355 parses and lowers each function for the concrete argument types of the current
233356 grid; its inline pass folds single-use temps back into their consumer, so one
234357 line of MATLAB becomes one expression tree; and this repo emits one WGSL compute
@@ -238,8 +361,8 @@ operations whose type rules numbl learns from a `.mtoc2.js` workspace file, and
238361 which the backend maps onto the spherical-harmonic pipelines. Anything it cannot
239362 express is refused at compile time with a source position.
240363
241-The Schnakenberg step compiles to 51 GPU operations at one solve iteration:
242-16 transforms, 8 coefficient-space shuffles, 25 generated kernels, and 2
364+The Schnakenberg step compiles to 50 GPU operations at one solve iteration:
365+18 transforms, 6 coefficient-space shuffles, 24 generated kernels, and 2
243366 buffer copies feeding the new state back.
244367
245368 **Transforms batch.** The expensive part of every Legendre stage is
@@ -249,7 +372,7 @@ take multiple fields, and a grouped call runs as one batched dispatch: one
249372 walk of the recurrence, one accumulator lane per field —
250373
251374 ```matlab
252-[Ftu, Fpu, Ftv, Fpv] = synth(vtu, vpu, vtv, vpv); % one Legendre dispatch
375+[Ftu, Fpu, Ftv, Fpv, Su, Sv] = synth(vtu, vpu, vtv, vpv, lam .* Fu, lam .* Fv);
253376 ```
254377
255378 The grouping is a promise of independence, never of a lane width: the
@@ -260,7 +383,7 @@ the same source runs anywhere. Ungrouped transforms that happen to sit on
260383 consecutive independent lines are batched the same way. Per-lane arithmetic
261384 is identical to the scalar kernels', so batched and scalar plans produce
262385 bit-identical states, asserted in the tests along with compile-time refusal
263-of a group that drops one of its outputs. All 16 transforms of the step
386+of a group that drops one of its outputs. All 16 Legendre transforms of the step
264387 above land in batches, worth ~25% of the whole step (0.88 vs 1.14 ms/step at
265388 lmax 127, 2 iterations, on bumpy).
266389
@@ -309,12 +432,14 @@ there is no CPU fallback (the f64 CPU transform remains, for tests).
309432 - Spectral layout: SHTNS conventions — orthonormal + Condon–Shortley, complex
310433 coefficients for m ≥ 0, m-major ordering.
311434 - fp32 transforms introduce ~1e-6 relative error per step; for pattern formation
312- from 1e-2 seeded noise this is inconsequential. The geometry goes through one
435+ from a 1e-2 seeded perturbation this is inconsequential. The geometry goes through one
313436 analysis/synthesis round trip and picks up the same round-off: the unit sphere
314437 comes back with radius 1 to ~2e-5 under Dawn, ~4e-4 under SwiftShader.
315-- The shipped geometries are all degree ≤ 5, far below any lmax the app offers,
316- so band-limiting removes nothing from them. A shape you write yourself may not
317- be so lucky — see the note in [`geometries/bumpy.m`](geometries/bumpy.m).
438+- The shipped analytic geometries are all degree ≤ 5, and the random ones stay
439+ near degree 13 at their finest slider settings — far below any lmax the app
440+ offers, so band-limiting removes little to nothing from them. A shape you
441+ write yourself may not be so lucky — see the note in
442+ [`geometries/bumpy.m`](geometries/bumpy.m).
318443
319444 ## Desktop vs browser
320445
@@ -382,6 +507,12 @@ package alone. Its binaries need glibc 2.29+. Other flags: `--steps`,
382507 `--warmup`, `--batch`, `--json`, `--help`; `DAWN_FLAGS='backend=vulkan'`
383508 (`;`-separated) passes Dawn options through.
384509
510+### The same run in MATLAB
511+
512+A run in the page needs a browser and a GPU; further analysis usually wants neither. The app therefore exports the run on screen as one self-contained MATLAB function file: **The same run as a standalone MATLAB script**, under the benchmark command, shows the script for copying and downloads it as `turing_surface_run.m`. The current model and geometry `.m` go in verbatim, edits in the page included, with the parameter values baked in; around them the file carries double-precision ports of everything the host provides: the transforms and their derivative shuffles, the metric weights, the seeded random field, and the run loop ([`src/export/`](src/export/)). It needs base MATLAB only, R2020b or newer, no toolboxes.
513+
514+Two deliberate differences from the page are stated in the script's own header: it runs in f64 where the GPU path is f32, and random draws use MATLAB's own `rng`, so a seed value picks a different member of the same random ensemble than the same value in the app. The script plots the pattern live and writes its initial and final spectral state to HDF5 in the reference-run layout of [docs/ellipsoid-reference-spec.md](docs/ellipsoid-reference-spec.md), so a MATLAB run can be loaded back into the page (**Compare against uploaded data**) or checked with `npm run ref -- --in turing_surface_run.h5`. Exported at the defaults, a 60-step Schnakenberg run on the ellipsoid replayed that way agrees with the app to relative L2 of about 1e-7, which is fp32 accumulation; the exported flux-form and Algorithm-4 models track each other to about 3e-10 in f64.
515+
385516 ## Tests
386517
387518 There is no second implementation of the solver to diff against, so the `.m`
@@ -403,9 +534,10 @@ about the round sphere, so all three build on the sphere geometry:
403534 Looser (~4e-3) because fp32 keeps about four digits of a perturbation that
404535 small.
405536
406-[`test/geometryChecks.ts`](test/geometryChecks.ts) — the surface and the loop:
537+[`test/geometryChecks.ts`](test/geometryChecks.ts) — the surface, the loop, and
538+the seed:
407539
408-- every geometry compiles and closes; the sphere has radius 1 everywhere and is
540+- every geometry evaluates and closes; the sphere has radius 1 everywhere and is
409541 **exactly degree 1** in the harmonics, which is what makes the reference case
410542 exact rather than merely accurate;
411543 - the peanut matches its own closed-form radial profile at every grid point, and
@@ -416,7 +548,14 @@ about the round sphere, so all three build on the sphere geometry:
416548 the geometric correction is mathematically zero — the state after 20 steps
417549 stays within fp32 round-off of the 0-iteration one at 1 and 4 iterations;
418550 - a runtime loop bound is refused at compile time;
419-- swapping the surface mid-run leaves the spectral state untouched.
551+- swapping the surface mid-run leaves the spectral state untouched;
552+- the seed field's **WGSL sum matches the same modes summed in f64 on the
553+ CPU** (1.8e-6 over ~1,400 terms) — a kernel misreading the packed mode table
554+ would still produce a smooth random-looking field, which no "looks patterned"
555+ check would catch; the same seed redraws the same field and a different one
556+ does not; the field is band-limited (5e-14 of its energy above degree 20)
557+ with λ setting the scale; and a λ finer than the mode table holds is refused
558+ rather than silently truncated.
420559
421560 [`test/fluxChecks.ts`](test/fluxChecks.ts) — the six-transform flux-form
422561 Laplace-Beltrami scheme
@@ -468,6 +607,38 @@ Other commands:
468607 - `node scripts/check-live.mjs [url]` — smoke-check a deployed URL.
469608 - `test.html?soak=<steps>&lmax=<n>` — solver-only soak with no rendering.
470609
610+### Testing against a reference implementation
611+
612+Reference solutions live in the sibling
613+[turing-surface-test-data](https://github.com/concept-collection/turing-surface-test-data)
614+repo, so an independently-written solver never has to depend on this one.
615+`cases/schnakenberg-ellipsoid.md` there specifies the one case this repo
616+currently ships a reference for;
617+[`docs/ellipsoid-reference-spec.md`](docs/ellipsoid-reference-spec.md)
618+restates it in this repo's own terms.
619+
620+`npm run ref -- --in <file>` (`scripts/ref.ts`) loads a reference file, runs
621+the solver from its exact initial spectral state to the same physical end
622+time, and reports the relative-L2 and relative-L-infinity (max-norm) error
623+against its final state (plus a geometry sanity check). `--niter` overrides
624+the surface-correction iteration count independent of the file, and
625+`--tolerance`/`--tolerance-linf` each independently turn their metric into a
626+pass/fail for CI.
627+
628+The same check runs in the page: **Compare to reference…** picks a `.h5` and
629+opens the comparison in one step — the file's own settings (its recorded
630+niter, its band, its dt) as the single variant, paused at the file's exact
631+initial state, ready to Run. The file defines the whole problem — model,
632+parameters, geometry, initial state — and the run stops at the file's end
633+time, measured against one extra static row showing its final state on its
634+own surface. Watching *where* a variant leaves the reference (rather than
635+just reading one number per run) is the point. To widen the study, stop
636+comparing, pick more chips, and press Compare — the file stays loaded, with
637+the lmax choices floored at its band, since a narrower one could not hold
638+its initial state. Reading the file uses
639+[h5wasm](https://github.com/usnistgov/h5wasm)'s wasm build, loaded lazily on
640+the first file opened.
641+
471642 ## Development
472643
473644 ```
docs/ellipsoid-reference-spec.mdadded+127−0View file
@@ -0,0 +1,127 @@
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 and relative-L-infinity (max-norm) error of
101+the resulting state against the file's final state (and, as a sanity check,
102+of the regenerated geometry against the file's own geometry coefficients —
103+this should be ~0 unless geometry construction itself has changed). `--niter
104+<n>` overrides the solve's own iteration count for the surface correction,
105+independent of what the reference file was generated with — useful for seeing
106+how much that correction term actually matters for a given run. `--tolerance
107+<n>` and `--tolerance-linf <n>` each independently turn their metric into a
108+pass/fail (nonzero exit code on failure), for use in CI.
109+
110+The browser demo runs the same check visually: **Compare to reference…**
111+picks a reference file and opens the comparison in one step, with the file's
112+own settings as the single variant, paused at its exact initial state. Run
113+takes it to the file's end time and stops; its final state shows as one
114+extra static row — on the file's own surface, with each variant's
115+relative-L2 distance to it updating live — and more variants can be added
116+from the compare bar's chips. Both readers share one parser
117+(`src/compare/referenceCase.ts`), so the layout above is interpreted
118+identically on the CLI and in the page.
119+
120+Note the files record only the two endpoint states (`initial/`, `final/`) —
121+no intermediate snapshots — so the comparison is meaningful at the end time;
122+the live Δ before that reads as "distance still to the final state".
123+
124+## Caveat
125+
126+This repo runs fp32 on GPU; expect ~1e-4–1e-6 relative floating-point noise
127+on top of any genuine numerical-method disagreement between solvers.
docs/reduced-transforms.mdmodified+42−8View file
@@ -2,7 +2,8 @@
22
33 **Summary.** Algorithm 4 costs 12 transforms per matvec (8 syntheses, 4 analyses). A flux-form
44 reformulation, with weights chosen so that every analyzed field is smooth on $S^2$, evaluates the
5-same operator in **6 transforms** (4 syntheses, 2 analyses). Notation follows `algos.pdf`.
5+same operator in **6 transforms** (4 syntheses, 2 analyses), or **7** with the divergence split
6+against the round sphere that §5 turned out to require. Notation follows `algos.pdf`.
67
78 ---
89
@@ -167,14 +168,47 @@ every node alike, multiplying by $r \sim L^2$ recovers the signal and inflates t
167168 | Algorithm 4 | $\sin\theta$, $\sin\theta$ | separated by $\mathcal{A}$ | $\varepsilon L$ |
168169 | Six-transform | $\sin^2\theta$ | all at the end | $\varepsilon L^2$ |
169170
170-**This likely does not reach the returned coefficients.** Step 7's analysis suppresses the spike
171-exactly as line 5 does today: $L^{-2}\cdot L^{1/2}\cdot\varepsilon L^2 = \varepsilon L^{1/2}$,
172-comparable to the ordinary $\varepsilon\sqrt{L}$ accumulation of a transform pair — and the new
173-scheme runs half as many transforms, lowering that baseline. Inside the implicit solve, GMRES sees
174-only coefficients, so the extra power should be invisible.
171+**It does reach the returned coefficients, and it matters.** The suppression argument above is
172+right as far as it goes — step 7's analysis knocks the spike down to $\varepsilon L^{1/2}$, and this
173+document originally concluded from that the extra power would be invisible inside the solve. It is
174+not, and the reason is not about accuracy. Measured on the default ellipsoid at $L=63$: starting
175+from the exact uniform steady state, three Richardson iterations per step leave a standing
176+coefficient-space perturbation $50\times$ the no-correction floor ($1.4\times10^{-5}$ vs
177+$2.8\times10^{-7}$), against $\sim\!1\times$ for Algorithm 4. That perturbation is static, polar,
178+and re-injected every step. In a Turing problem the pattern is seeded by whatever is largest in the
179+unstable band, so a forcing four orders below the field selects the nucleation site: the run grows a
180+spot at the pole, on every seed, regardless of the initial condition.
181+
182+**The fix is to keep $r$ off the round sphere.** Write $p_1 = 1 + \delta p_1$, $q_2 = 1 + \delta q_2$
183+($p_2$ is already zero on the sphere). The sphere's share of the divergence is the cancelling part,
184+and it is known in closed form: $\sin\theta\,\partial_\theta A + \partial_\varphi B =
185+-\sin^2\theta\,\Delta_{S^2}u$, and $\Delta_{S^2}$ is diagonal. So
186+
187+$$\Delta_\Gamma u = -\frac{1}{J}\,\Delta_{S^2}u \;+\; r\,(\sin\theta\,\partial_\theta P'
188+ + \partial_\varphi \tilde{Q}'), \qquad P' = \delta p_1 A + p_2 B, \quad
189+ \tilde{Q}' = p_2 A + \delta q_2 B$$
190+
191+with $1/J = r\sin^2\theta$ bounded. Only the geometry *deviation* now meets the concentrated
192+division. Cost: one extra synthesis per species per iteration for $-\lambda u$ — 7 transforms, not
193+6 — which batches into the gradient's existing grouped call and measures at ~10% of a step, against
194+$3\times$ for reverting to Algorithm 4. $\delta p_1$ and $\delta q_2$ must be formed in float64 at
195+precompute time (`src/geom/geometry.ts`): on a near-sphere they *are* the small quantity, and
196+subtracting 1 in float32 on device would lose them.
197+
198+Measured against Algorithm 4 through a real run (relative $L^2$ of $u$ at $t=8$, $\texttt{niter}=6$):
199+
200+| | plain flux | sphere-split |
201+|---|---|---|
202+| ellipsoid, $L=63$ | $3.5\times10^{-4}$ | $8.9\times10^{-6}$ |
203+| blob, $L=63$ | $4.4\times10^{-4}$ | $8.5\times10^{-6}$ |
204+| ellipsoid, $L=127$ | $7.2\times10^{-3}$ | $6.0\times10^{-6}$ |
205+
206+and the polar noise gain is asserted in `test/fluxChecks.ts`, which fails at $50\times$ on the
207+unsplit form.
175208
176-It matters only if grid values of $\Delta_\Gamma u$ are consumed directly: a nonlinear reaction
177-term, max-norm diagnostics, or an adaptive error estimator.
209+The residual $\varepsilon L^2$ still applies to grid values of $\Delta_\Gamma u$ consumed directly
210+— a nonlinear reaction term, max-norm diagnostics, an adaptive error estimator — for the deviation
211+part alone.
178212
179213 Algorithm 1 line 7 already divides by $\sin^2\theta$, so the code is exposed to $\varepsilon L^2$
180214 today — just on the second-derivative path, which the Laplacian never touches.
geometries/blob.madded+19−0View file
@@ -0,0 +1,19 @@
1+% A random blob: the sphere, radius-modulated by a smooth random function
2+% on the sphere — surfacefun's blob, built on chebfun's randnfunsphere
3+% (tools/randnfunsphere.m).
4+%
5+% `seed` picks the draw; the same seed always gives the same blob. `scale`
6+% is the random function's wavelength, so smaller means finer lobes.
7+
8+function [gx, gy, gz] = shape(theta, phi, amp, scale, seed)
9+ rng(seed);
10+ f = randnfunsphere(scale, theta, phi);
11+ % blob.m's normalization: shift nonnegative, rescale to [-1, 1].
12+ f = f + abs(min(f));
13+ f = 2*(f/max(f)) - 1;
14+ r = 1 + amp*f;
15+ st = sin(theta);
16+ gx = r .* (st .* cos(phi));
17+ gy = r .* (st .* sin(phi));
18+ gz = r .* cos(theta);
19+end
geometries/sphere.mmodified+5−3View file
@@ -1,9 +1,11 @@
11 % The unit sphere — the reference case.
22 %
33 % A geometry file defines shape(theta, phi, ...) -> gx, gy, gz: the surface
4-% over the solver's grid (all npts x 1), compiled to WebGPU like the models.
5-% The host analyses the result into spherical-harmonic coefficients,
6-% band-limited at lmax.
4+% over the solver's grid (all npts x 1). Unlike the models it runs once, on
5+% the CPU through numbl's interpreter, so the full MATLAB subset is
6+% available — loops, arrays, min/max, legendre, seeded randomness via
7+% rng/randn. The host analyses the result into spherical-harmonic
8+% coefficients, band-limited at lmax.
79
810 function [gx, gy, gz] = shape(theta, phi)
911 st = sin(theta);
index.htmlmodified+179−83View file
@@ -18,9 +18,6 @@
1818 --tok-num: #0550ae;
1919 --tok-kw: #cf222e;
2020 --tok-ext: #8250df;
21- --warn-bg: #fff8e5;
22- --warn-line: #e3c37a;
23- --warn-edge: #bf8700;
2421 color-scheme: light dark;
2522 }
2623 @media (prefers-color-scheme: dark) {
@@ -36,9 +33,6 @@
3633 --tok-num: #79c0ff;
3734 --tok-kw: #ff7b72;
3835 --tok-ext: #d2a8ff;
39- --warn-bg: #2b2410;
40- --warn-line: #6b5518;
41- --warn-edge: #e3b341;
4236 }
4337 }
4438 body {
@@ -57,6 +51,19 @@
5751 }
5852 /* display:flex above would otherwise override the UA's [hidden] rule */
5953 .controls[hidden] { display: none; }
54+ .modes {
55+ display: flex; flex-wrap: wrap; gap: 8px;
56+ padding: 4px 0 10px; margin-bottom: 4px;
57+ border-bottom: 1px solid var(--line);
58+ }
59+ .modes .chip { font-size: 13px; padding: 4px 12px; }
60+ .mode-desc {
61+ color: var(--ink); margin: 0 0 12px; font-size: 13.5px;
62+ padding: 10px 14px; border-radius: 6px;
63+ border-left: 3px solid var(--accent);
64+ background: color-mix(in srgb, var(--accent) 10%, var(--bg));
65+ }
66+ .mode-desc:empty { display: none; }
6067 .controls label { color: var(--ink-2); font-size: 13px; white-space: nowrap; }
6168 select, input[type="number"], button {
6269 font: inherit; font-size: 13px;
@@ -110,18 +117,15 @@
110117 font: 12px/1.6 ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
111118 color: var(--ink); user-select: all;
112119 }
113- #blurb { margin-top: 4px; font-size: 13px; color: var(--ink-2); }
114- .warn {
115- margin: 0 0 14px;
116- padding: 10px 14px;
117- border: 1px solid var(--warn-line);
118- border-left: 5px solid var(--warn-edge);
119- border-radius: 6px;
120- background: var(--warn-bg);
121- color: var(--ink);
122- font-size: 13.5px; line-height: 1.5;
120+ details.cli > summary { cursor: pointer; }
121+ details.cli > summary::before { content: '▸'; font-size: 10px; color: var(--ink-2); }
122+ details.cli[open] > summary::before { content: '▾'; }
123+ #matlabscript {
124+ margin: 0; padding: 8px 10px; max-height: 30em; overflow: auto;
125+ font: 12px/1.5 ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
126+ color: var(--ink); white-space: pre; user-select: text;
123127 }
124- .warn b { color: var(--warn-edge); }
128+ #blurb { margin-top: 4px; font-size: 13px; color: var(--ink-2); }
125129 #err { color: #b35900; white-space: pre-wrap; font-size: 13px; }
126130 .editor {
127131 margin-top: 12px; border: 1px solid var(--line); border-radius: 8px;
@@ -133,10 +137,14 @@
133137 background: var(--sphere-bg); border-bottom: 1px solid var(--line);
134138 }
135139 .editor-head button { padding: 2px 10px; font-size: 12px; }
136- /* Source and compiled-op list side by side, so the editor gets the
137- height rather than sharing it with the list below. */
138- .editor-body { display: flex; align-items: stretch; }
139- .editor-code { position: relative; flex: 1 1 62%; min-width: 0; height: 34em; }
140+ /* Source and compiled-op list side by side. The fixed height lives on
141+ the row itself, not on either child: a child's own `height` would
142+ only be a *hint* stretch fills when unset, and `#compiled` sets its
143+ own smaller font, so the same em value would resolve to a shorter
144+ box than .editor-code's. Sizing the row instead makes both children
145+ stretch to one shared pixel height regardless of either's font. */
146+ .editor-body { display: flex; align-items: stretch; height: 34em; }
147+ .editor-code { position: relative; flex: 1 1 62%; min-width: 0; }
140148 /* The overlay and the textarea must agree on every metric that affects
141149 where a character lands. Keep these two rules together. */
142150 .editor-code > pre,
@@ -167,7 +175,9 @@
167175 color: var(--ink-2); white-space: pre;
168176 }
169177 @media (max-width: 860px) {
170- .editor-body { flex-direction: column; }
178+ /* Stacked, so each panel goes back to managing its own height rather
179+ than sharing the row's fixed one. */
180+ .editor-body { flex-direction: column; height: auto; }
171181 .editor-code { flex: none; height: 26em; }
172182 #compiled { border-left: 0; border-top: 1px solid var(--line); max-height: 12em; }
173183 }
@@ -175,6 +185,12 @@
175185 /* The bar's own layout: three chip rows stacked, then the reference
176186 picker and the button beside them. */
177187 .cmp-axes { display: flex; flex-direction: column; gap: 4px; }
188+ /* Without this, JS setting the `hidden` attribute (vs-sphere mode,
189+ which has no chip grid) does nothing: [hidden] and .cmp-axes tie on
190+ specificity, and the author rule above wins the tie over the
191+ browser's default — the exact opposite of what `hidden` is supposed
192+ to mean. */
193+ .cmp-axes[hidden] { display: none; }
178194 .cmp-axis { display: flex; align-items: center; gap: 8px; }
179195 .cmp-axis > span:first-child {
180196 color: var(--ink-2); font-size: 13px; width: 5.5em; text-align: right;
@@ -223,6 +239,47 @@
223239 border: 1px solid var(--line); border-radius: 2px;
224240 }
225241 .cmp-rangelab { font-variant-numeric: tabular-nums; white-space: nowrap; }
242+ /* The diff row sits right under its variant's value row: same label
243+ width so the columns still line up, but a lighter tone since it names
244+ no new variant, only the difference from one already named above. */
245+ .cmp-diffrow .cmp-rowlabel { color: var(--ink-2); font-style: italic; }
246+ .cmp-diffbox { position: relative; }
247+ .cmp-diffcap {
248+ position: absolute; bottom: 4px; right: 6px;
249+ font-size: 11px; font-variant-numeric: tabular-nums;
250+ color: #fff; background: rgba(0, 0, 0, 0.45);
251+ padding: 1px 5px; border-radius: 4px; pointer-events: none;
252+ }
253+ .cmp-chart {
254+ margin-top: 8px; padding: 8px 10px;
255+ border: 1px solid var(--line); border-radius: 8px;
256+ }
257+ .cmp-chart-legend {
258+ display: flex; flex-wrap: wrap; gap: 6px 18px;
259+ font-size: 12px; color: var(--ink-2); margin-bottom: 6px;
260+ }
261+ .cmp-chart-legend-group { display: flex; flex-wrap: wrap; gap: 12px; }
262+ /* The species group (dash styles) reads as a separate cluster from the
263+ variant group (colors) — a rule between them, not just a gap, since
264+ both groups otherwise look like more of the same kind of chip. */
265+ .cmp-chart-legend-species {
266+ padding-left: 14px; border-left: 1px solid var(--line);
267+ }
268+ .cmp-chart-legend-item { display: inline-flex; align-items: center; gap: 5px; }
269+ .cmp-chart-legend-item i {
270+ display: inline-block; width: 10px; height: 10px; border-radius: 2px;
271+ }
272+ /* Species swatches reuse the color chip's box model but draw a dashed
273+ line instead of a filled square, so the two legend groups still read
274+ as one visual language (a small icon + a name). */
275+ .cmp-chart-legend-species i.cmp-chart-dash {
276+ width: 16px; height: 0; border-radius: 0;
277+ border-top-width: 2px; border-top-color: var(--ink-2);
278+ }
279+ .cmp-chart-dash-0 { border-top-style: solid; }
280+ .cmp-chart-dash-1 { border-top-style: dashed; }
281+ .cmp-chart-dash-2 { border-top-style: dotted; }
282+ .cmp-chart-canvas { width: 100%; height: 240px; display: block; }
226283 @media (max-width: 860px) {
227284 .cmp-row { flex-direction: column; }
228285 .cmp-rowlabel { width: auto; flex-direction: row; gap: 10px; }
@@ -238,29 +295,64 @@
238295 <body>
239296 <main>
240297 <h1>turing-surface</h1>
241- <p class="warn">
242- <b>⚠ Work in progress: the geometry is drawn, not solved on.</b>
243- The solver still uses the round sphere's Laplace–Beltrami operator, so
244- on other shapes you see the sphere's pattern painted onto that surface.
245- </p>
246298 <p class="sub">
247299 Reaction-diffusion on closed surfaces, solved live with spherical
248300 harmonics on WebGPU via
249301 <a href="https://github.com/concept-collection/shtns-webgpu">shtns-webgpu</a>.
250- Both the solver and the shape are the MATLAB below, compiled in your
302+ Both the solver and the shape are the MATLAB below, run in your
251303 browser by <a href="https://numbl.org">numbl</a>. Edit either and watch
252304 it change. Drag to rotate.
253305 </p>
254- <div class="controls">
255- <label>preset
306+ <div class="modes" id="modebar">
307+ <button type="button" class="chip" id="mode-simulate" aria-pressed="true">Simulate</button>
308+ <button type="button" class="chip" id="mode-effort"
309+ title="Run several solver settings side by side on one clock">Compare computational effort</button>
310+ <button type="button" class="chip" id="mode-vs-sphere"
311+ title="Run this model once on the true geometry and once on the unit sphere, from the same starting state">
312+ Compare against sphere</button>
313+ <button type="button" class="chip" id="mode-vs-upload"
314+ title="Check this solver against a saved reference run">
315+ Compare against uploaded data</button>
316+ <input type="file" id="cmp-file" accept=".h5" hidden>
317+ </div>
318+ <p class="mode-desc" id="mode-desc"></p>
319+ <div class="controls ctrl-group" data-group="surface">
320+ <label>reaction-diffusion model
256321 <select id="model"></select>
257322 </label>
258- <label title="The surface. Rendered, but not yet in the operator.">geometry
323+ <label title="The surface the pattern is solved on. Swapping it does not recompile the solver or restart the run.">geometry
259324 <select id="geometry"></select>
260325 </label>
261- <label title="Blend between the sphere (0) and the surface (1). Display only.">morph
262- <input type="range" id="morph" min="0" max="1" step="0.01" value="1" />
326+ </div>
327+ <div class="ctrl-group" data-group="surface-params">
328+ <div class="controls" id="params"></div>
329+ <div class="controls" id="geomparams"></div>
330+ </div>
331+ <div class="controls" id="comparebar" hidden>
332+ <div class="cmp-axes" id="cmp-axes">
333+ <div class="cmp-axis">
334+ <span title="Iterations of the implicit diffusion solve">solve iters</span>
335+ <span id="cmp-niter" class="chips"></span>
336+ </div>
337+ <div class="cmp-axis">
338+ <span>lmax</span>
339+ <span id="cmp-lmax" class="chips"></span>
340+ </div>
341+ <div class="cmp-axis">
342+ <span title="Timestep, as a divisor of the model's dt. Divisors keep every variant on the same clock exactly.">dt</span>
343+ <span id="cmp-dt" class="chips"></span>
344+ </div>
345+ </div>
346+ <label title="The run everything else is measured against" id="cmp-reflabel">reference
347+ <select id="cmp-ref"></select>
263348 </label>
349+ <span id="cmp-fileinfo" class="stats" hidden></span>
350+ <button id="cmp-fileclear" hidden
351+ title="Drop the reference file and compare the variants against each other again">×</button>
352+ <button id="cmp-start" class="primary">Compile comparison</button>
353+ <span id="cmp-count" class="stats"></span>
354+ </div>
355+ <div class="controls ctrl-group" data-group="solver">
264356 <label title="Iterations of the implicit diffusion solve. Changing it recompiles.">solve iters
265357 <select id="niter">
266358 <option value="0">0</option>
@@ -282,6 +374,8 @@
282374 <option value="255">255</option>
283375 </select>
284376 </label>
377+ </div>
378+ <div class="controls ctrl-group" data-group="display">
285379 <label title="Render on a finer grid. Display only.">display oversampling
286380 <select id="oversample">
287381 <option value="auto" selected>auto</option>
@@ -294,64 +388,56 @@
294388 <label>colormap
295389 <select id="colormap"></select>
296390 </label>
391+ <label title="Blend between the sphere (0) and the surface (1). Display only.">morph
392+ <input type="range" id="morph" min="0" max="1" step="0.01" value="1" />
393+ </label>
394+ <button id="resetview">Reset view</button>
395+ </div>
396+ <div class="controls ctrl-group" data-group="playback">
297397 <button id="runpause" class="primary">Run</button>
398+ <button id="restart" title="Rewind to the initial condition this run started from, without drawing a new one">Restart</button>
399+ </div>
400+ <div class="controls ctrl-group" data-group="benchmark">
298401 <button id="benchmark">Benchmark</button>
299- <button id="reseed">Re-seed</button>
300- <button id="resetview">Reset view</button>
301- <button id="movietoggle" title="Export the run as an MP4 movie">Export movie</button>
302- <button id="comparetoggle" title="Run several solver settings side by side on one clock">Compare</button>
303402 </div>
304- <div class="controls" id="comparebar" hidden>
305- <div class="cmp-axes">
306- <div class="cmp-axis">
307- <span title="Iterations of the implicit diffusion solve">solve iters</span>
308- <span id="cmp-niter" class="chips"></span>
309- </div>
310- <div class="cmp-axis">
311- <span>lmax</span>
312- <span id="cmp-lmax" class="chips"></span>
313- </div>
314- <div class="cmp-axis">
315- <span title="Timestep, as a divisor of the model's dt. Divisors keep every variant on the same clock exactly.">dt</span>
316- <span id="cmp-dt" class="chips"></span>
317- </div>
318- </div>
319- <label title="The run everything else is measured against">reference
320- <select id="cmp-ref"></select>
403+ <div class="controls ctrl-group" data-group="seed">
404+ <label title="Wavelength of the smooth random field generating the initial condition">Initial condition wavelength λ
405+ <input id="lam3" type="number" min="0" step="0.05" value="0.5">
321406 </label>
322- <button id="cmp-start" class="primary">Compare</button>
323- <span id="cmp-count" class="stats"></span>
407+ <button id="reseed">Re-seed</button>
324408 </div>
325- <div class="controls" id="moviebar" hidden>
326- <label title="Simulation-time units per second of video">movie speed
327- <select id="moviespeed">
328- <option value="0.1">0.1×</option>
329- <option value="0.5">0.5×</option>
330- <option value="1">1×</option>
331- <option value="3">3×</option>
332- <option value="5">5×</option>
333- <option value="10" selected>10×</option>
334- <option value="20">20×</option>
335- </select>
336- </label>
337- <label title="Size of each sphere panel in the video, in pixels">resolution
338- <select id="movieres">
339- <option value="480">480</option>
340- <option value="640">640</option>
341- <option value="768" selected>768</option>
342- <option value="1080">1080</option>
343- <option value="1440">1440</option>
344- </select>
345- </label>
346- <label title="Slowly orbit the camera during the movie">
347- <input type="checkbox" id="movierotate" checked /> auto-rotate
348- </label>
349- <button id="movie" title="Replay the run from t = 0 and download an MP4">Export</button>
409+ <div class="controls ctrl-group" data-group="movie">
410+ <button id="movietoggle" title="Export the run as an MP4 movie">Export movie</button>
411+ <div class="controls" id="moviebar" hidden>
412+ <label title="Simulation-time units per second of video">movie speed
413+ <select id="moviespeed">
414+ <option value="0.1">0.1×</option>
415+ <option value="0.5">0.5×</option>
416+ <option value="1">1×</option>
417+ <option value="3">3×</option>
418+ <option value="5">5×</option>
419+ <option value="10" selected>10×</option>
420+ <option value="20">20×</option>
421+ </select>
422+ </label>
423+ <label title="Size of each sphere panel in the video, in pixels">resolution
424+ <select id="movieres">
425+ <option value="480">480</option>
426+ <option value="640">640</option>
427+ <option value="768" selected>768</option>
428+ <option value="1080">1080</option>
429+ <option value="1440">1440</option>
430+ </select>
431+ </label>
432+ <label title="Slowly orbit the camera during the movie">
433+ <input type="checkbox" id="movierotate" checked /> auto-rotate
434+ </label>
435+ <button id="movie" title="Replay the run from t = 0 and download an MP4">Export</button>
436+ </div>
350437 </div>
351- <div class="controls" id="params"></div>
352- <div class="controls" id="geomparams"></div>
353438 <p id="geomnote" class="stats"></p>
354439 <div id="panels"></div>
440+ <div id="cmp-chart" class="cmp-chart" hidden></div>
355441 <p class="stats" id="stats"></p>
356442 <p class="stats" id="benchresult"></p>
357443 <div class="editor">
@@ -386,6 +472,16 @@
386472 </div>
387473 <code id="cmd"></code>
388474 </div>
475+ <details class="cli" id="matlab">
476+ <summary class="cli-head">
477+ <span>The same run as a standalone MATLAB script</span>
478+ <span>
479+ <button id="copymatlab" type="button">Copy</button>
480+ <button id="downloadmatlab" type="button">Download .m</button>
481+ </span>
482+ </summary>
483+ <pre id="matlabscript"></pre>
484+ </details>
389485 <p id="blurb"></p>
390486 <p id="err"></p>
391487 </main>
models/allencahn.mmodified+9−8View file
@@ -2,14 +2,15 @@
22 %
33 % du/dt = eps2*lap_g(u) + u - u^3
44 %
5-% Same scheme as models/schnakenberg.m.
5+% Same scheme as models/schnakenberg.m, sphere-split flux divergence included.
66
7-function [U, u] = init(noise)
8- U = analys(noise);
7+% Seeded from a smooth random field -- see models/schnakenberg.m.
8+function [U, u] = init(lam3, gx, gy, gz)
9+ U = analys(0.01 * randnfun3(lam3, gx, gy, gz));
910 u = synth(U);
1011 end
1112
12-function [Un, u] = step(U, lam, filt, gx, gy, gz, p1, p2, q2, r, jhat, eps2, dt, niter)
13+function [Un, u] = step(U, lam, filt, gx, gy, gz, p2, r, dp1, dq2, jinv, jhat, eps2, dt, niter)
1314 u = synth(U);
1415
1516 Bu = U + dt * analys(u - u.^3);
@@ -26,15 +27,15 @@ function [Un, u] = step(U, lam, filt, gx, gy, gz, p1, p2, q2, r, jhat, eps2, dt,
2627 Fu = Un .* filt;
2728 vtu = dthetac(Fu);
2829 vpu = dphic(Fu);
29- [Ftu, Fpu] = synth(vtu, vpu);
30- Pu = p1 .* Ftu + p2 .* Fpu;
31- Qu = p2 .* Ftu + q2 .* Fpu;
30+ [Ftu, Fpu, Su] = synth(vtu, vpu, lam .* Fu);
31+ Pu = dp1 .* Ftu + p2 .* Fpu;
32+ Qu = p2 .* Ftu + dq2 .* Fpu;
3233 PAu = analys(Pu);
3334 Pcu = PAu .* filt;
3435 scu = dthetac(Pcu);
3536 Lu = synth(scu);
3637 dQu = dphig(Qu);
37- lapu = r .* (Lu + dQu);
38+ lapu = r .* (Lu + dQu) - jinv .* Su;
3839 dLu = (analys(lapu) + lamJ .* Un) .* filt;
3940
4041 Un = (Bu + (dt * eps2) * dLu) ./ (1 + (dt * eps2) * lamJ);
models/brusselator.mmodified+15−11View file
@@ -4,14 +4,18 @@
44 % dv/dt = D2*lap_g(v) + B*u - u^2*v
55 %
66 % Same scheme as models/schnakenberg.m, including the grouped transforms:
7-% [a, b] = synth(x, y) runs the group as batched Legendre dispatches.
7+% [a, b] = synth(x, y) runs the group as batched Legendre dispatches, and the
8+% sphere-split flux divergence that keeps r ~ 1/sin^2(theta) off the round
9+% sphere's share of the operator.
810
9-function [U, V, u, v] = init(noise, A, B)
10- [U, V] = analys(A + noise, (B / A) * ones(numel(noise), 1));
11+% Seeded from a smooth random field -- see models/schnakenberg.m.
12+function [U, V, u, v] = init(lam3, gx, gy, gz, A, B)
13+ f = randnfun3(lam3, gx, gy, gz);
14+ [U, V] = analys(A + 0.01*f, (B / A) * ones(numel(f), 1));
1115 [u, v] = synth(U, V);
1216 end
1317
14-function [Un, Vn, u, v] = step(U, V, lam, filt, gx, gy, gz, p1, p2, q2, r, jhat, A, B, D1, D2, dt, niter)
18+function [Un, Vn, u, v] = step(U, V, lam, filt, gx, gy, gz, p2, r, dp1, dq2, jinv, jhat, A, B, D1, D2, dt, niter)
1519 [u, v] = synth(U, V);
1620 uuv = u .* u .* v;
1721
@@ -36,11 +40,11 @@ function [Un, Vn, u, v] = step(U, V, lam, filt, gx, gy, gz, p1, p2, q2, r, jhat,
3640 vpu = dphic(Fu);
3741 vtv = dthetac(Fv);
3842 vpv = dphic(Fv);
39- [Ftu, Fpu, Ftv, Fpv] = synth(vtu, vpu, vtv, vpv);
40- Pu = p1 .* Ftu + p2 .* Fpu;
41- Qu = p2 .* Ftu + q2 .* Fpu;
42- Pv = p1 .* Ftv + p2 .* Fpv;
43- Qv = p2 .* Ftv + q2 .* Fpv;
43+ [Ftu, Fpu, Ftv, Fpv, Su, Sv] = synth(vtu, vpu, vtv, vpv, lam .* Fu, lam .* Fv);
44+ Pu = dp1 .* Ftu + p2 .* Fpu;
45+ Qu = p2 .* Ftu + dq2 .* Fpu;
46+ Pv = dp1 .* Ftv + p2 .* Fpv;
47+ Qv = p2 .* Ftv + dq2 .* Fpv;
4448 [PAu, PAv] = analys(Pu, Pv);
4549 Pcu = PAu .* filt;
4650 Pcv = PAv .* filt;
@@ -49,8 +53,8 @@ function [Un, Vn, u, v] = step(U, V, lam, filt, gx, gy, gz, p1, p2, q2, r, jhat,
4953 [Lu, Lv] = synth(scu, scv);
5054 dQu = dphig(Qu);
5155 dQv = dphig(Qv);
52- lapu = r .* (Lu + dQu);
53- lapv = r .* (Lv + dQv);
56+ lapu = r .* (Lu + dQu) - jinv .* Su;
57+ lapv = r .* (Lv + dQv) - jinv .* Sv;
5458 [LAu, LAv] = analys(lapu, lapv);
5559 dLu = (LAu + lamJ .* Un) .* filt;
5660 dLv = (LAv + lamJ .* Vn) .* filt;
models/schnakenberg.mmodified+43−16View file
@@ -9,20 +9,35 @@
99 % geometric correction dlap from that exact solve. Grid fields are npts x 1;
1010 % spectral fields are real 2 x nlm. See docs/richardson-iteration.md.
1111 %
12-% The correction evaluates lap_g in flux form -- 6 transforms per species
12+% The correction evaluates lap_g in flux form -- 7 transforms per species
1313 % per iteration where the Cartesian-gradient form (Algorithm 4 of
1414 % evolving_surface/notes/algos.tex) needs 12. See
1515 % docs/reduced-transforms.md, and models/schnakenberg_alg4.m
1616 % for the original form kept as a live reference.
17+%
18+% The flux divergence is split against the round sphere: the sphere's share
19+% of it is -jinv*lap_s(u), exact in spectral space, and only the geometry
20+% *deviation* meets r ~ 1/sin^2(theta). Without that split the concentrated
21+% division amplifies the polar roundoff of the whole flux, and since a
22+% Turing pattern is seeded by whatever is largest in its unstable band, the
23+% amplified polar noise -- static, and re-injected every step -- picks the
24+% nucleation site and grows a spot at the pole. See docs/reduced-transforms.md
25+% Sec 5.
1726
18-function [U, V, u, v] = init(noise, a, b)
27+% The uniform steady state, perturbed by a smooth random field: chebfun's
28+% randnfun3 on the surface's bounding box, restricted to the surface by
29+% evaluating it at the grid points -- the way surfacefun seeds a run. lam3
30+% is its wavelength; the draw is seeded on the host, the sum over its
31+% Fourier modes runs on the GPU (src/mgpu/randnfun3.ts).
32+function [U, V, u, v] = init(lam3, gx, gy, gz, a, b)
33+ f = randnfun3(lam3, gx, gy, gz);
1934 us = a + b;
2035 vs = b / (us * us);
21- [U, V] = analys(us + noise, vs * ones(numel(noise), 1));
36+ [U, V] = analys(us + 0.01*f, vs * ones(numel(f), 1));
2237 [u, v] = synth(U, V);
2338 end
2439
25-function [Un, Vn, u, v] = step(U, V, lam, filt, gx, gy, gz, p1, p2, q2, r, jhat, a, b, D1, D2, dt, niter)
40+function [Un, Vn, u, v] = step(U, V, lam, filt, gx, gy, gz, p2, r, dp1, dq2, jinv, jhat, a, b, D1, D2, dt, niter)
2641 % Grouped transforms -- [a, b] = synth(x, y) -- are explicit batching:
2742 % output k is the transform of input k, and the whole group runs as one
2843 % batched Legendre dispatch, or as many as the device's lane width allows
@@ -55,16 +70,28 @@ function [Un, Vn, u, v] = step(U, V, lam, filt, gx, gy, gz, p1, p2, q2, r, jhat,
5570 for k = 1:niter
5671 % dlap = lap_g - lap_s at the current iterate, in flux form
5772 % (docs/reduced-transforms.md Sec 4). The sin-weighted derivatives
58- % sin(theta)*dtheta(u) and dphi(u) -- both smooth on the sphere,
73+ % A = sin(theta)*dtheta(u) and B = dphi(u) -- both smooth on the sphere,
5974 % synthesized straight from the dthetac/dphic coefficient shuffles --
60- % are combined pointwise through the precomputed weights p1,p2,q2 into
61- % two fluxes P,Q, also smooth. The theta flux P goes back to
75+ % are combined pointwise through the precomputed weights into two
76+ % fluxes P,Q, also smooth. The theta flux P goes back to
6277 % coefficients, through the same shuffle again, and is synthesized as
6378 % sin(theta)*dtheta(P); the phi flux Q never leaves the grid -- d/dphi
6479 % is diagonal in the Fourier index, so dphig differentiates it with two
6580 % FFT stages and no Legendre work (masking m past filt's reach). Their
6681 % sum, scaled by r, is lap_g(u). The only division by sin(theta)
67- % anywhere is folded into p1,p2,q2,r at precompute time.
82+ % anywhere is folded into the weights at precompute time.
83+ %
84+ % The weights here are the *sphere-subtracted* ones: p1 = 1 + dp1 and
85+ % q2 = 1 + dq2 (p2 is zero on the sphere already), so P,Q below are the
86+ % deviation fluxes P' = P - A, Q' = Q - B. What that leaves out is the
87+ % round sphere's own divergence, sin(theta)*dtheta(A) + dphi(B) =
88+ % -sin^2(theta)*lap_s(u), which needs no flux machinery at all: lap_s is
89+ % diagonal, so it is -lam.*Fu synthesized once (S below, riding along in
90+ % the gradient's batched synthesis) and scaled by the bounded
91+ % jinv = 1/J = r*sin^2(theta). r therefore multiplies only the deviation
92+ % -- the difference between this and multiplying the whole flux is two
93+ % orders of magnitude of polar roundoff, and it is what keeps a pattern
94+ % from nucleating at the pole (src/geom/geometry.ts, dp1/dq2/jinv).
6895 % lamJ.*Un adds back the preconditioner's -lap_s(Un)/jhat, since lam
6996 % holds +l(l+1). filt zeroes the top two degrees, where the derivative
7097 % recurrences cannot exactly represent a derivative -- and the correction
@@ -74,7 +101,7 @@ function [Un, Vn, u, v] = step(U, V, lam, filt, gx, gy, gz, p1, p2, q2, r, jhat,
74101 % point is the undiffused Bu), and the two species un-diffuse at
75102 % different rates -- a spurious Turing band at the band edge.
76103 %
77- % The two species share each grouped call: the four gradient
104+ % The two species share each grouped call: the six gradient-and-sphere
78105 % syntheses, the two theta-flux analyses, the two divergence syntheses
79106 % and the two final analyses each run as one batched dispatch.
80107 Fu = Un .* filt;
@@ -83,11 +110,11 @@ function [Un, Vn, u, v] = step(U, V, lam, filt, gx, gy, gz, p1, p2, q2, r, jhat,
83110 vpu = dphic(Fu);
84111 vtv = dthetac(Fv);
85112 vpv = dphic(Fv);
86- [Ftu, Fpu, Ftv, Fpv] = synth(vtu, vpu, vtv, vpv);
87- Pu = p1 .* Ftu + p2 .* Fpu;
88- Qu = p2 .* Ftu + q2 .* Fpu;
89- Pv = p1 .* Ftv + p2 .* Fpv;
90- Qv = p2 .* Ftv + q2 .* Fpv;
113+ [Ftu, Fpu, Ftv, Fpv, Su, Sv] = synth(vtu, vpu, vtv, vpv, lam .* Fu, lam .* Fv);
114+ Pu = dp1 .* Ftu + p2 .* Fpu;
115+ Qu = p2 .* Ftu + dq2 .* Fpu;
116+ Pv = dp1 .* Ftv + p2 .* Fpv;
117+ Qv = p2 .* Ftv + dq2 .* Fpv;
91118 [PAu, PAv] = analys(Pu, Pv);
92119 Pcu = PAu .* filt;
93120 Pcv = PAv .* filt;
@@ -96,8 +123,8 @@ function [Un, Vn, u, v] = step(U, V, lam, filt, gx, gy, gz, p1, p2, q2, r, jhat,
96123 [Lu, Lv] = synth(scu, scv);
97124 dQu = dphig(Qu);
98125 dQv = dphig(Qv);
99- lapu = r .* (Lu + dQu);
100- lapv = r .* (Lv + dQv);
126+ lapu = r .* (Lu + dQu) - jinv .* Su;
127+ lapv = r .* (Lv + dQv) - jinv .* Sv;
101128 [LAu, LAv] = analys(lapu, lapv);
102129 dLu = (LAu + lamJ .* Un) .* filt;
103130 dLv = (LAv + lamJ .* Vn) .* filt;
models/schnakenberg_alg4.mmodified+5−3View file
@@ -17,11 +17,13 @@
1717 % (docs/reduced-transforms.md); this variant is kept live
1818 % for A/B comparison, in the app and in the tests.
1919
20-function [U, V, u, v] = init(noise, a, b)
20+% Seeded from a smooth random field -- see models/schnakenberg.m.
21+function [U, V, u, v] = init(lam3, gx, gy, gz, a, b)
22+ f = randnfun3(lam3, gx, gy, gz);
2123 us = a + b;
2224 vs = b / (us * us);
23- U = analys(us + noise);
24- V = analys(vs * ones(numel(noise), 1));
25+ U = analys(us + 0.01*f);
26+ V = analys(vs * ones(numel(f), 1));
2527 u = synth(U);
2628 v = synth(V);
2729 end
package-lock.jsonmodified+7−0View 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"
@@ -1936,6 +1937,12 @@
19361937 "node": ">= 14"
19371938 }
19381939 },
1940+ "node_modules/h5wasm": {
1941+ "version": "0.10.3",
1942+ "resolved": "https://registry.npmjs.org/h5wasm/-/h5wasm-0.10.3.tgz",
1943+ "integrity": "sha512-W4Jy5ExtX/VNbyD8GdOBckDuj6AL16TemppVNxZsV3rJZEWCv2sxlCzOttZLer3zkMttbDYsWHl0qt1z3Bln+Q==",
1944+ "license": "SEE LICENSE IN LICENSE.txt"
1945+ },
19391946 "node_modules/http-proxy-agent": {
19401947 "version": "7.0.2",
19411948 "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.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/bench.tsmodified+2−2View file
@@ -176,7 +176,7 @@ try {
176176 geometryParams: spec.geometryParams,
177177 niter: spec.niter,
178178 });
179- session.seed(spec.seed);
179+ await session.seed(spec.seed);
180180
181181 const plan = session.describe();
182182 const kernels = plan.step.filter((l) => l.startsWith('kernel')).length;
@@ -259,7 +259,7 @@ try {
259259 let digest = null;
260260 let state: Float32Array | null = null;
261261 if (wantDigest) {
262- session.seed(spec.seed);
262+ await session.seed(spec.seed);
263263 session.step(spec.steps);
264264 await done();
265265 state = await session.read(model.state[0]);
scripts/longrun-node.tsmodified+1−1View file
@@ -19,7 +19,7 @@ const device = await requestShtDevice().catch((e: unknown) => {
1919 throw new Error(`${errMsg(e)}\n${NO_ADAPTER_HINT}`);
2020 });
2121 const session = await ModelSession.create({ device, model, params, lmax });
22-session.seed(1);
22+await session.seed(1);
2323 console.log(`longrun — models/${model.key}.m at lmax ${lmax}, ${runtime}\n`);
2424
2525 const nsteps = Math.round(100 / params.dt);
scripts/ref.tsadded+215−0View file
@@ -0,0 +1,215 @@
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 { extractReferenceCase, type H5Node } from '../src/compare/referenceCase.ts';
19+import { relL2, relLinf } from '../src/mgpu/digest.ts';
20+import { installWebGpu, errMsg, NO_ADAPTER_HINT } from './nodeWebGpu.ts';
21+import * as h5wasm from 'h5wasm/node';
22+
23+const USAGE = `usage: npm run ref -- --in <file> [options]
24+
25+ --in <file> the reference HDF5 file to check against (required)
26+ --niter <n> override the solve iteration count (default: the file's own)
27+ --tolerance <n> if given, exit 1 when any reported relL2 meets or exceeds it
28+ --tolerance-linf <n> if given, exit 1 when any reported relLinf 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 and relative-L-infinity
34+(max-norm) error of the resulting state against the file's final state (and,
35+as a sanity check, of the regenerated geometry against the file's own
36+geometry coefficients). --tolerance and --tolerance-linf gate independently:
37+either can fail the run on its own.`;
38+
39+function fail(msg: string, code = 1): never {
40+ console.error(`ref: ${msg}`);
41+ process.exit(code);
42+}
43+
44+const argv = process.argv.slice(2);
45+if (argv.includes('--help') || argv.includes('-h')) {
46+ console.log(USAGE);
47+ process.exit(0);
48+}
49+let inFile: string | null = null;
50+let niterOverride: number | null = null;
51+let tolerance: number | null = null;
52+let toleranceLinf: number | null = null;
53+const wantJson = argv.includes('--json');
54+for (let i = 0; i < argv.length; i++) {
55+ const a = argv[i];
56+ if (a === '--json') continue;
57+ const valued = (name: string): string | null => {
58+ if (a === `--${name}`) return argv[++i];
59+ if (a.startsWith(`--${name}=`)) return a.slice(name.length + 3);
60+ return null;
61+ };
62+ const inv = valued('in');
63+ if (inv !== null) {
64+ inFile = inv;
65+ continue;
66+ }
67+ const niterv = valued('niter');
68+ if (niterv !== null) {
69+ niterOverride = Number(niterv);
70+ if (!Number.isInteger(niterOverride) || niterOverride < 0) {
71+ fail(`--niter must be an integer >= 0 (got '${niterv}')`, 2);
72+ }
73+ continue;
74+ }
75+ const tolLinfv = valued('tolerance-linf');
76+ if (tolLinfv !== null) {
77+ toleranceLinf = Number(tolLinfv);
78+ if (!Number.isFinite(toleranceLinf)) fail(`--tolerance-linf must be a number (got '${tolLinfv}')`, 2);
79+ continue;
80+ }
81+ const tolv = valued('tolerance');
82+ if (tolv !== null) {
83+ tolerance = Number(tolv);
84+ if (!Number.isFinite(tolerance)) fail(`--tolerance must be a number (got '${tolv}')`, 2);
85+ continue;
86+ }
87+ fail(`unrecognized argument '${a}'\n\n${USAGE}`, 2);
88+}
89+if (!inFile) fail(`--in <file> is required\n\n${USAGE}`, 2);
90+
91+let device: GPUDevice | null = null;
92+let session: ModelSession | null = null;
93+let h5file: InstanceType<typeof h5wasm.File> | null = null;
94+
95+try {
96+ await h5wasm.ready;
97+ h5file = new h5wasm.File(inFile, 'r');
98+ const rc = extractReferenceCase(h5file as H5Node, inFile);
99+ h5file.close();
100+ h5file = null;
101+
102+ const { model, geometry: geometryModel, params, geometryParams, lmax, steps } = rc;
103+ const niter = niterOverride ?? rc.niter;
104+ const fileGeom = rc.geometryCoeffs;
105+ const fileInitial = rc.initial;
106+ const fileFinal = rc.final;
107+
108+ const runtime = await installWebGpu();
109+ device = await requestShtDevice().catch((e: unknown) => {
110+ throw new Error(`${errMsg(e)}\n${NO_ADAPTER_HINT}`);
111+ });
112+ const adapter = await describeAdapter(device);
113+
114+ session = await ModelSession.create({
115+ device,
116+ model,
117+ params,
118+ lmax,
119+ geometry: geometryModel,
120+ geometryParams,
121+ niter,
122+ });
123+
124+ const errorOf = (a: Float32Array, b: Float32Array) => ({ relL2: relL2(a, b), relLinf: relLinf(a, b) });
125+
126+ const geometryError = {
127+ Gx: errorOf(session.geometry.X, fileGeom.X),
128+ Gy: errorOf(session.geometry.Y, fileGeom.Y),
129+ Gz: errorOf(session.geometry.Z, fileGeom.Z),
130+ };
131+
132+ session.loadState(fileInitial);
133+ session.step(steps);
134+
135+ const stateError: Record<string, { relL2: number; relLinf: number }> = {};
136+ for (const name of model.state) {
137+ // Sequential: GpuModel.read() shares one staging buffer across calls.
138+ const ours = await session.read(name);
139+ stateError[name] = errorOf(ours, fileFinal[name]);
140+ }
141+
142+ const allErrors = [...Object.values(geometryError), ...Object.values(stateError)];
143+ const worstL2 = Math.max(...allErrors.map((e) => e.relL2));
144+ const worstLinf = Math.max(...allErrors.map((e) => e.relLinf));
145+ const passL2 = tolerance === null ? null : worstL2 < tolerance;
146+ const passLinf = toleranceLinf === null ? null : worstLinf < toleranceLinf;
147+ const checks = [passL2, passLinf].filter((p): p is boolean => p !== null);
148+ const pass = checks.length === 0 ? null : checks.every(Boolean);
149+
150+ if (wantJson) {
151+ console.log(
152+ JSON.stringify(
153+ {
154+ in: inFile,
155+ model: model.key,
156+ geometry: geometryModel.key,
157+ grid: { lmax, nlm: session.sht.nlm },
158+ niter,
159+ steps,
160+ dt: params.dt,
161+ T: steps * (params.dt ?? 0),
162+ backend: { adapter, runtime, precision: 'fp32' },
163+ geometryError,
164+ stateError,
165+ worstL2,
166+ worstLinf,
167+ tolerance,
168+ toleranceLinf,
169+ passL2,
170+ passLinf,
171+ pass,
172+ },
173+ null,
174+ 2,
175+ ),
176+ );
177+ } else {
178+ console.log(`ref: ${inFile}`);
179+ console.log(
180+ ` model ${model.label} (${model.state.join(', ')})\n` +
181+ ` geometry ${geometryModel.label} ` +
182+ geometryModel.params.map((p) => `${p.key}=${geometryParams[p.key]}`).join(' ') +
183+ `\n grid lmax ${lmax} · nlm ${session.sht.nlm}\n` +
184+ ` niter ${niter}${niterOverride !== null ? ` (file: ${rc.niter})` : ''}\n` +
185+ ` run ${steps} steps, dt=${params.dt} (T=${(steps * (params.dt ?? 0)).toFixed(2)})\n`,
186+ );
187+ const fmtErr = (v: { relL2: number; relLinf: number }) =>
188+ `relL2 ${v.relL2.toExponential(3)} relLinf ${v.relLinf.toExponential(3)}`;
189+ console.log(` geometry check (regenerated vs file):`);
190+ for (const [k, v] of Object.entries(geometryError)) console.log(` ${k} ${fmtErr(v)}`);
191+ console.log(`\n final state (this run vs file):`);
192+ for (const [k, v] of Object.entries(stateError)) console.log(` ${k} ${fmtErr(v)}`);
193+ if (tolerance !== null) {
194+ console.log(
195+ `\n worst relL2 ${worstL2.toExponential(3)} vs tolerance ${tolerance.toExponential(3)}: ` +
196+ (passL2 ? 'PASS' : 'FAIL'),
197+ );
198+ }
199+ if (toleranceLinf !== null) {
200+ console.log(
201+ ` worst relLinf ${worstLinf.toExponential(3)} vs tolerance-linf ${toleranceLinf.toExponential(3)}: ` +
202+ (passLinf ? 'PASS' : 'FAIL'),
203+ );
204+ }
205+ }
206+
207+ session.destroy();
208+ device.destroy();
209+ process.exit(pass === false ? 1 : 0);
210+} catch (e) {
211+ h5file?.close();
212+ session?.destroy();
213+ device?.destroy();
214+ fail(errMsg(e));
215+}
scripts/test-node.tsmodified+12−0View file
@@ -8,6 +8,9 @@
88 *
99 * npm run test:node
1010 */
11+import { tmpdir } from 'node:os';
12+import { join } from 'node:path';
13+import * as h5wasm from 'h5wasm/node';
1114 import { requestShtDevice } from '../src/sht/sht.ts';
1215 import { installWebGpu, errMsg, NO_ADAPTER_HINT } from './nodeWebGpu.ts';
1316 import { transformChecks } from '../test/transformChecks.ts';
@@ -16,6 +19,8 @@ import { modelChecks } from '../test/modelChecks.ts';
1619 import { geometryChecks } from '../test/geometryChecks.ts';
1720 import { fluxChecks } from '../test/fluxChecks.ts';
1821 import { compareChecks } from '../test/compareChecks.ts';
22+import { referenceChecks, type H5Rt } from '../test/referenceChecks.ts';
23+import { matlabExportChecks } from '../test/matlabExportChecks.ts';
1924
2025 let failures = 0;
2126 const check = (name: string, ok: boolean, detail: string): void => {
@@ -55,6 +60,13 @@ await modelChecks(device, check, log);
5560 await geometryChecks(device, check, log);
5661 await fluxChecks(device, check, log);
5762 await compareChecks(device, check, log);
63+matlabExportChecks(check, log);
64+await referenceChecks(
65+ h5wasm as unknown as H5Rt,
66+ (name) => join(tmpdir(), `turing-surface-${process.pid}-${name}`),
67+ check,
68+ log,
69+);
5870
5971 console.log(failures === 0 ? '\nAll tests passed.' : `\n${failures} failed.`);
6072 process.exit(failures === 0 ? 0 : 1);
src/compare/compareRun.tsmodified+600−56View file
@@ -41,8 +41,10 @@ import {
4141 import { SphereScene } from '../render/SphereScene.ts';
4242 import { colormaps } from '../render/colormaps.ts';
4343 import { fmtValue, floorRange } from '../render/colorbar.ts';
44-import { sharedNoise } from './sharedStart.ts';
44+import { prolongCoeffs, sharedModes, sharedNoise } from './sharedStart.ts';
4545 import { variantLabel, VARIANT_COLORS, type Variant } from './variants.ts';
46+import type { ReferenceCase } from './referenceCase.ts';
47+import { ErrorChart, type ErrorChartRow } from '../render/errorChart.ts';
4648
4749 /**
4850 * Latitudes of the shared display grid. 256 is the same target the single-run
@@ -75,14 +77,45 @@ export interface CompareOptions {
7577 geometry: MGeometry;
7678 geometryParams: Params;
7779 geometrySource: string;
80+ /** Per-row geometry override, parallel-indexed to `variants` — the
81+ * "several rows on several geometries" case (vs-sphere mode), as opposed
82+ * to every other mode's "several rows, one geometry." Rows without an
83+ * entry (or when this is omitted entirely) fall back to the single
84+ * `geometry`/`geometryParams`/`geometrySource` above. */
85+ geometries?: { geometry: MGeometry; geometryParams: Params; geometrySource: string }[];
86+ /** Render every row — value panels and diff panels alike — on the
87+ * *reference* row's own surface (`coords`/`posBuf`) instead of each row's
88+ * own. For comparing two different geometries where only the *field*
89+ * should read as different, not the displayed shape. */
90+ renderOnReferenceGeometry?: boolean;
91+ /** Row names, overriding `variantLabel(variant, showDt)` — for a mode
92+ * where every row shares the same niter/lmax/dt, so that label alone
93+ * wouldn't tell the rows apart. */
94+ rowLabels?: string[];
7895 variants: Variant[];
79- /** Index into `variants` of the run everything else is measured against. */
96+ /** Index into `variants` of the run everything else is measured against.
97+ * Ignored when `refFile` is given — the file is the reference then. */
8098 reference: number;
99+ /**
100+ * Check against a reference file instead of against each other: its exact
101+ * initial state seeds every variant (so `seed` and `lam3` go unused), a
102+ * static extra row shows its final state, every Δ is measured against that
103+ * row, and the clock stops at the file's end time. Every variant's lmax must
104+ * be >= the file's — a narrower band could not hold the initial state.
105+ */
106+ refFile?: ReferenceCase;
107+ /** Called when a refFile run reaches the file's end time and stops. */
108+ onFinished?: () => void;
81109 seed: number;
110+ /** Wavelength of the seeded random field, shared by every variant — one
111+ * initial condition means one wavelength as much as one seed. */
112+ lam3?: number;
82113 morph: number;
83114 colormapName: () => string;
84115 /** Where the variant grid goes (the app's #panels). */
85116 container: HTMLElement;
117+ /** Where the error-vs-time chart goes (the app's #cmp-chart). */
118+ chartContainer: HTMLElement;
86119 /** Progress and, afterwards, the standing description of the study. */
87120 onStatus: (html: string) => void;
88121 }
@@ -99,6 +132,21 @@ interface Row {
99132 colorBufs: Float32Array[];
100133 /** Fields read this frame, one per species, on the shared grid. */
101134 fields: Float32Array[];
135+ /** Pointwise difference from the reference, one per species, on the shared
136+ * grid — filled by #measureDifference as it accumulates the norm below.
137+ * Unused (and never touched) for the reference row itself. */
138+ diffFields: Float32Array[];
139+ /** The diff row's own panels, one per species — empty for the reference
140+ * row, whose diff against itself is trivially zero. */
141+ diffScenes: SphereScene[];
142+ diffValueBufs: Float32Array[];
143+ diffColorBufs: Float32Array[];
144+ /** This row's own smoothed symmetric color range per species — each row
145+ * is scaled to its own diff extent, not a range shared across the
146+ * column, so one row's diff panels never affect another's coloring. */
147+ diffRanges: { lo: number; hi: number }[];
148+ /** Each diff panel's "± magnitude" caption, parallel to diffScenes. */
149+ diffCaps: HTMLElement[];
102150 /** Relative difference from the reference, one per species. */
103151 err: number[];
104152 /** False once any species has left the floating-point numbers — the shape a
@@ -108,9 +156,37 @@ interface Row {
108156 statEl: HTMLElement;
109157 }
110158
159+/**
160+ * The reference file's final state, as one more row of panels — with no
161+ * session behind it: its surface and fields are the file's coefficients
162+ * synthesized once on the shared display grid, fixed for the whole run. Only
163+ * its coloring changes, with the shared range.
164+ */
165+interface FileRow {
166+ coords: Float32Array;
167+ posBuf: Float32Array;
168+ scenes: SphereScene[];
169+ valueBufs: Float32Array[];
170+ colorBufs: Float32Array[];
171+ /** The file's final state on the shared grid, one per species. */
172+ fields: Float32Array[];
173+ /** Its extent, precomputed — a candidate for the shared color range. */
174+ bounds: (Bounds | null)[];
175+}
176+
111177 export class CompareRun {
112178 #opts: CompareOptions;
113179 #rows: Row[] = [];
180+ #fileRow: FileRow | null = null;
181+ /** What restart() reloads: the file's fixed state if opts.refFile is set,
182+ * otherwise a snapshot of the coarsest variant's state as of the last
183+ * (re-)seed — see the capture in create() and in reseed()'s plain branch. */
184+ #initial: Record<string, Float32Array>;
185+ #initialLmax: number;
186+ /** Base steps taken since the initial state — the refFile clock. */
187+ #stepsDone = 0;
188+ /** True once a refFile run has reached the file's end time. */
189+ #finished = false;
114190 #topo: SphereMeshTopology;
115191 /** Quadrature weight per grid point of the shared grid, for the L2 norm. */
116192 #weights: Float64Array;
@@ -118,6 +194,7 @@ export class CompareRun {
118194 /** Smoothed color range per species, shared by every variant so the panels
119195 * in a column are directly comparable by eye and not just by number. */
120196 #ranges: { lo: number; hi: number }[] = [];
197+ #errorChart: ErrorChart | null = null;
121198 #resizeObs: ResizeObserver | null = null;
122199
123200 #running = false;
@@ -134,14 +211,18 @@ export class CompareRun {
134211 private constructor(init: {
135212 opts: CompareOptions;
136213 rows: Row[];
214+ fileRow: FileRow | null;
137215 topo: SphereMeshTopology;
138216 weights: Float64Array;
139217 rangeBars: { fill: (lo: number, hi: number) => void }[];
140218 frameSteps: number;
141219 note: string;
220+ initial: Record<string, Float32Array>;
221+ initialLmax: number;
142222 }) {
143223 this.#opts = init.opts;
144224 this.#rows = init.rows;
225+ this.#fileRow = init.fileRow;
145226 this.#topo = init.topo;
146227 this.#weights = init.weights;
147228 this.#rangeBars = init.rangeBars;
@@ -149,6 +230,8 @@ export class CompareRun {
149230 this.#note = init.note;
150231 this.#morph = init.opts.morph;
151232 this.#ranges = init.opts.model.species.map(() => ({ lo: NaN, hi: NaN }));
233+ this.#initial = init.initial;
234+ this.#initialLmax = init.initialLmax;
152235 }
153236
154237 get variants(): Variant[] {
@@ -165,6 +248,11 @@ export class CompareRun {
165248 return this.#opts.reference;
166249 }
167250
251+ /** The reference file this study is checking against, if any. */
252+ get refFile(): ReferenceCase | null {
253+ return this.#opts.refFile ?? null;
254+ }
255+
168256 /** The base timestep a variant's dtDiv divides. */
169257 static baseDt(params: Params): number {
170258 return params.dt ?? 0;
@@ -179,10 +267,13 @@ export class CompareRun {
179267 // after the grid is up has to take them down explicitly — removing their
180268 // canvases from the DOM would leave both running.
181269 let built: Row[] = [];
270+ let builtFile: FileRow | null = null;
271+ let errorChart: ErrorChart | null = null;
182272
183273 try {
184274 for (let i = 0; i < variants.length; i++) {
185275 const v = variants[i];
276+ const g = opts.geometries?.[i];
186277 opts.onStatus(
187278 `compiling ${i + 1}/${variants.length} — ${variantLabel(v, showDt)} ` +
188279 `(a solve iteration is ~15 kernels per species, and there is no ` +
@@ -197,17 +288,18 @@ export class CompareRun {
197288 params: { ...opts.params, dt: baseDt / v.dtDiv },
198289 lmax: v.lmax,
199290 source: opts.source,
200- geometry: opts.geometry,
201- geometryParams: opts.geometryParams,
202- geometrySource: opts.geometrySource,
291+ geometry: g?.geometry ?? opts.geometry,
292+ geometryParams: g?.geometryParams ?? opts.geometryParams,
293+ geometrySource: g?.geometrySource ?? opts.geometrySource,
203294 niter: v.niter,
295+ lam3: opts.lam3,
204296 }),
205297 );
206298 }
207299
208300 // ---- the shared display grid ----------------------------------------
209301 const maxLmax = Math.max(...variants.map((v) => v.lmax));
210- const panels = variants.length * model.species.length;
302+ const panels = (variants.length + (opts.refFile ? 1 : 0)) * model.species.length;
211303 const target = panels > CROWDED_PANELS ? RENDER_NLAT_CROWDED : RENDER_NLAT;
212304 // Never below what the finest band needs to be representable at all
213305 // (ShtPlan requires nlat > lmax), whatever the panel count says.
@@ -217,9 +309,56 @@ export class CompareRun {
217309 for (const s of sessions) await s.setDisplayGrid(nlat, nphi);
218310
219311 // ---- one initial condition, on every grid ---------------------------
220- opts.onStatus('seeding all variants from one band-limited perturbation…');
221- const noise = await sharedNoise(sessions, model.seedAmp, opts.seed);
222- sessions.forEach((s, i) => s.seedWith(noise[i]));
312+ // Also what restart() reloads later — the file's fixed state, or (for
313+ // the plain case) a snapshot of the coarsest variant's own state,
314+ // taken after seeding it: the same lowest-lmax session sharedNoise
315+ // itself draws from, so prolonging it up to any other variant later is
316+ // always widening a band, never narrowing one.
317+ let initial: Record<string, Float32Array>;
318+ let initialLmax: number;
319+ if (opts.refFile) {
320+ // The file's exact spectral state, prolonged into each variant's band.
321+ // Exact, not approximate: the state is band-limited at the file's lmax
322+ // and every variant's band contains it, so each session starts from
323+ // the very field the reference run started from.
324+ opts.onStatus('loading the initial state from the reference file…');
325+ for (const s of sessions) {
326+ s.loadState(prolongState(opts.refFile.initial, model.state, opts.refFile.lmax, s.cfg.lmax));
327+ }
328+ initial = opts.refFile.initial;
329+ initialLmax = opts.refFile.lmax;
330+ } else {
331+ opts.onStatus('seeding all variants from one band-limited perturbation…');
332+ const noise = await sharedNoise(sessions, model.seedAmp, opts.seed);
333+ const modes = await sharedModes(sessions[opts.reference] ?? sessions[0], opts.seed);
334+ // One at a time: a seed submits its whole mode sum in pieces, and there
335+ // is nothing to gain from interleaving several variants' worth of it.
336+ for (let i = 0; i < sessions.length; i++) await sessions[i].seedWith(noise[i], modes);
337+ if (opts.geometries) {
338+ // Several geometries, not several lmax bands: the seeding above
339+ // drew a spatially shared field, but evaluated it on each row's
340+ // own (geometry-dependent) points — a different field per row in
341+ // coefficient space. The reference's exact resulting coefficients
342+ // replace that for every other row, so every row starts from the
343+ // identical spectral state and only the operator applied to it
344+ // differs from then on.
345+ const refSession = sessions[opts.reference];
346+ const refState = await refSession.readState();
347+ for (let i = 0; i < sessions.length; i++) {
348+ if (i === opts.reference) continue;
349+ sessions[i].loadState(
350+ prolongState(refState, model.state, refSession.cfg.lmax, sessions[i].cfg.lmax),
351+ );
352+ }
353+ initial = refState;
354+ initialLmax = refSession.cfg.lmax;
355+ } else {
356+ let coarsest = sessions[0];
357+ for (const s of sessions) if (s.cfg.lmax < coarsest.cfg.lmax) coarsest = s;
358+ initial = await coarsest.readState();
359+ initialLmax = coarsest.cfg.lmax;
360+ }
361+ }
223362
224363 // ---- the mesh, shared; the surface, per variant ---------------------
225364 const view = sessions[0].viewSht;
@@ -249,12 +388,25 @@ export class CompareRun {
249388 frameSteps = Math.max(1, frameSteps);
250389
251390 // ---- the grid of panels ---------------------------------------------
252- const { rows, rangeBars } = await buildGrid(opts, sessions, topo, showDt);
391+ const { rows, fileRow, rangeBars } = await buildGrid(opts, sessions, topo, showDt);
253392 built = rows;
393+ builtFile = fileRow;
394+
395+ // ---- the error-vs-time chart, one line per row with a diff panel ----
396+ const chartRows = rows.filter((r) => r.diffScenes.length > 0);
397+ errorChart = new ErrorChart(
398+ opts.chartContainer,
399+ model.species,
400+ chartRows.map((r): ErrorChartRow => ({ label: variantLabel(r.variant, showDt), color: r.color })),
401+ );
402+ // Nothing to chart with a single variant and no reference file — the
403+ // one row present is the reference itself.
404+ opts.chartContainer.hidden = chartRows.length === 0;
254405
255406 const solverGrid = sessions.map((s) => `${s.cfg.nlat}×${s.cfg.nphi}`);
256407 const note =
257- `${variants.length} variants · display grid ${nlat}×${nphi}` +
408+ `${variants.length} variant${variants.length === 1 ? '' : 's'} · ` +
409+ `display grid ${nlat}×${nphi}` +
258410 (sessions.some((s) => s.cfg.nlat > nlat)
259411 ? ` (below the finest solver grid ${solverGrid[solverGrid.length - 1]} — display only)`
260412 : '') +
@@ -262,15 +414,22 @@ export class CompareRun {
262414 ` · ops/step ${ops.join(', ')}`;
263415
264416 const run = new CompareRun({
265- opts, rows, topo, weights, rangeBars, frameSteps, note,
417+ opts, rows, fileRow, topo, weights, rangeBars, frameSteps, note, initial, initialLmax,
266418 });
419+ run.#errorChart = errorChart;
267420 await run.draw();
268421 run.#observeResize();
269422 run.#status();
270423 return run;
271424 } catch (e) {
272- for (const r of built) for (const s of r.scenes) s.dispose();
425+ for (const r of built) {
426+ for (const s of r.scenes) s.dispose();
427+ for (const s of r.diffScenes) s.dispose();
428+ }
429+ for (const s of builtFile?.scenes ?? []) s.dispose();
273430 for (const s of sessions) s.destroy();
431+ errorChart?.dispose();
432+ opts.chartContainer.hidden = true;
274433 opts.container.replaceChildren();
275434 opts.container.classList.remove('compare');
276435 throw e;
@@ -287,30 +446,124 @@ export class CompareRun {
287446 return this.#running;
288447 }
289448
290- /** Re-seed every variant from one new shared perturbation. */
449+ /** Re-seed every variant from one new shared perturbation — or, against a
450+ * reference file, restart from its initial state (there is nothing to
451+ * draw; the seed is ignored). */
291452 async reseed(seed: number): Promise<void> {
292453 const wasRunning = this.#running;
293454 this.#running = false;
294455 while (this.#pumping) await nextFrame();
295456 if (this.#disposed) return;
296- const noise = await sharedNoise(
297- this.#rows.map((r) => r.session),
298- this.#opts.model.seedAmp,
299- seed,
300- );
457+ const sessions = this.#rows.map((r) => r.session);
458+ const refFile = this.#opts.refFile;
459+ if (refFile) {
460+ for (const s of sessions) {
461+ s.loadState(prolongState(refFile.initial, this.#opts.model.state, refFile.lmax, s.cfg.lmax));
462+ }
463+ } else {
464+ const noise = await sharedNoise(sessions, this.#opts.model.seedAmp, seed);
465+ const modes = await sharedModes(this.referenceSession ?? sessions[0], seed);
466+ // Checked per variant, not once: a seed awaits its own submission, so a
467+ // dispose can land between two of them and destroy the sessions left.
468+ for (let i = 0; i < sessions.length; i++) {
469+ if (this.#disposed) return;
470+ await sessions[i].seedWith(noise[i], modes);
471+ }
472+ if (this.#disposed) return;
473+ // This draw becomes what restart() rewinds to from now on — see the
474+ // identical selection in create(). Recaptured here rather than left
475+ // pointing at the pre-reseed field.
476+ if (this.#opts.geometries) {
477+ // Mirrors create()'s IC block: copy the reference's exact resulting
478+ // coefficients into every other row rather than trusting their own
479+ // (geometry-dependent) seeding to have landed on the same state.
480+ const refSession = sessions[this.#opts.reference];
481+ const refState = await refSession.readState();
482+ for (let i = 0; i < sessions.length; i++) {
483+ if (i === this.#opts.reference) continue;
484+ sessions[i].loadState(
485+ prolongState(refState, this.#opts.model.state, refSession.cfg.lmax, sessions[i].cfg.lmax),
486+ );
487+ }
488+ this.#initial = refState;
489+ this.#initialLmax = refSession.cfg.lmax;
490+ } else {
491+ let coarsest = sessions[0];
492+ for (const s of sessions) if (s.cfg.lmax < coarsest.cfg.lmax) coarsest = s;
493+ this.#initial = await coarsest.readState();
494+ this.#initialLmax = coarsest.cfg.lmax;
495+ }
496+ }
497+ this.#t = 0;
498+ this.#stepsDone = 0;
499+ this.#finished = false;
500+ for (const r of this.#ranges) {
501+ r.lo = NaN;
502+ r.hi = NaN;
503+ }
504+ for (const r of this.#rows) {
505+ for (const rr of r.diffRanges) {
506+ rr.lo = NaN;
507+ rr.hi = NaN;
508+ }
509+ }
510+ this.#errorChart?.reset();
511+ await this.draw();
512+ this.#status();
513+ if (!this.#disposed && wasRunning) this.setRunning(true);
514+ }
515+
516+ /** Rewind every variant to the saved initial condition — the file's fixed
517+ * state, or (for the plain case) the last (re-)seed, not necessarily the
518+ * very first one — without drawing anything new. */
519+ async restart(): Promise<void> {
520+ const wasRunning = this.#running;
521+ this.#running = false;
522+ while (this.#pumping) await nextFrame();
301523 if (this.#disposed) return;
302- this.#rows.forEach((r, i) => r.session.seedWith(noise[i]));
524+ for (const r of this.#rows) {
525+ r.session.loadState(
526+ prolongState(this.#initial, this.#opts.model.state, this.#initialLmax, r.session.cfg.lmax),
527+ );
528+ }
303529 this.#t = 0;
530+ this.#stepsDone = 0;
531+ this.#finished = false;
304532 for (const r of this.#ranges) {
305533 r.lo = NaN;
306534 r.hi = NaN;
307535 }
536+ for (const r of this.#rows) {
537+ for (const rr of r.diffRanges) {
538+ rr.lo = NaN;
539+ rr.hi = NaN;
540+ }
541+ }
542+ this.#errorChart?.reset();
308543 await this.draw();
544+ this.#status();
309545 if (!this.#disposed && wasRunning) this.setRunning(true);
310546 }
311547
548+ /** Wavelength of the seeded random field. One number for the study: every
549+ * variant seeds from the same field, so they seed at the same wavelength. */
550+ get lam3(): number {
551+ return this.#rows[0]?.session.lam3 ?? 0;
552+ }
553+
554+ /** Change it on every variant. Like the single run's, this only takes effect
555+ * on the next reseed, which is where the field is drawn. */
556+ setLam3(lambda: number): void {
557+ this.#opts.lam3 = lambda;
558+ for (const r of this.#rows) r.session.setLam3(lambda);
559+ }
560+
312561 /** Model parameters changed. Each variant keeps its own dt. */
313562 setParams(params: Params): void {
563+ // Against a reference file the parameters *are* the file's — they define
564+ // the problem being checked — and the page's parameter panel edits the
565+ // page's own model, which need not even be this one. Nothing to apply.
566+ if (this.#opts.refFile) return;
314567 this.#opts.params = params;
315568 const baseDt = CompareRun.baseDt(params);
316569 for (const r of this.#rows) {
@@ -323,11 +576,21 @@ export class CompareRun {
323576 for (const r of this.#rows) {
324577 fillPositions(r.posBuf, r.coords, this.#topo, morph);
325578 for (const s of r.scenes) s.updatePositions(r.posBuf);
579+ // The diff row sits on the exact same mesh as the value row above it
580+ // (same coords, same posBuf) — it just never got told to re-render
581+ // when this method was first written, so it stayed fixed at whatever
582+ // shape the study was compiled with.
583+ for (const s of r.diffScenes) s.updatePositions(r.posBuf);
584+ }
585+ const f = this.#fileRow;
586+ if (f) {
587+ fillPositions(f.posBuf, f.coords, this.#topo, morph);
588+ for (const s of f.scenes) s.updatePositions(f.posBuf);
326589 }
327590 }
328591
329592 resetView(): void {
330- for (const r of this.#rows) for (const s of r.scenes) s.resetCamera();
593+ for (const s of this.#allScenes()) s.resetCamera();
331594 }
332595
333596 dispose(): void {
@@ -337,13 +600,26 @@ export class CompareRun {
337600 this.#resizeObs = null;
338601 for (const r of this.#rows) {
339602 for (const s of r.scenes) s.dispose();
603+ for (const s of r.diffScenes) s.dispose();
340604 r.session.destroy();
341605 }
606+ for (const s of this.#fileRow?.scenes ?? []) s.dispose();
342607 this.#rows = [];
608+ this.#fileRow = null;
609+ this.#errorChart?.dispose();
610+ this.#errorChart = null;
611+ this.#opts.chartContainer.hidden = true;
343612 this.#opts.container.replaceChildren();
344613 this.#opts.container.classList.remove('compare');
345614 }
346615
616+ #allScenes(): SphereScene[] {
617+ return [
618+ ...this.#rows.flatMap((r) => [...r.scenes, ...r.diffScenes]),
619+ ...(this.#fileRow?.scenes ?? []),
620+ ];
621+ }
622+
347623 // ----------------------------------------------------------------- drawing
348624 /**
349625 * One frame's readback: every variant's every species, on the shared grid.
@@ -407,7 +683,14 @@ export class CompareRun {
407683 });
408684
409685 for (let k = 0; k < species.length; k++) {
410- const anchor = leastPeak(this.#rows.map((r, i) => (r.healthy ? bounds[i][k] : null)));
686+ // The file row, when there is one, is a candidate like any healthy
687+ // variant: early on the variants' small fields set the scale (it merely
688+ // clips), and if every variant diverges it is the row that keeps the
689+ // grid readable.
690+ const anchor = leastPeak([
691+ ...this.#rows.map((r, i) => (r.healthy ? bounds[i][k] : null)),
692+ this.#fileRow?.bounds[k] ?? null,
693+ ]);
411694 const range = this.#ranges[k];
412695 if (anchor) {
413696 if (!Number.isFinite(range.lo)) {
@@ -434,10 +717,63 @@ export class CompareRun {
434717 fillColors(r.colorBufs[k], r.valueBufs[k], shown.lo, shown.hi, cmap);
435718 r.scenes[k]?.updateColors(r.colorBufs[k]);
436719 }
720+ const f = this.#fileRow;
721+ if (f) {
722+ // Its values never change; only its coloring follows the shared range.
723+ fillColors(f.colorBufs[k], f.valueBufs[k], shown.lo, shown.hi, cmap);
724+ f.scenes[k]?.updateColors(f.colorBufs[k]);
725+ }
437726 }
438727
439728 this.#measureDifference();
729+ this.#colorDiffPanels();
440730 this.#updateRowStats();
731+ this.#errorChart?.push(
732+ this.#t,
733+ this.#rows.filter((r) => r.diffScenes.length > 0).map((r) => r.err),
734+ );
735+ }
736+
737+ /**
738+ * Color every diff panel from #measureDifference's pointwise diffFields, on
739+ * a *symmetric* range that is each row's own — not shared across the
740+ * column. A diverging row's diff explodes, but with a per-row range that
741+ * only saturates its own panels; it can no longer affect how any other
742+ * row's diff panels are scaled, which is a simpler fix for exactly the
743+ * flooding problem the value panels' shared #ranges/leastPeak logic exists
744+ * to manage there. The cost: diff-panel color alone no longer tells you
745+ * which row has more error than another — that comparison now lives in the
746+ * error chart, which has actual numbers. Always drawn with a diverging
747+ * colormap regardless of the user's chosen (sequential) one — a signed
748+ * quantity centered at zero needs a diverging map to read correctly, which
749+ * coolwarm is and viridis etc. are not.
750+ */
751+ #colorDiffPanels(): void {
752+ const species = this.#opts.model.species;
753+ for (const r of this.#rows) {
754+ if (r.diffScenes.length === 0) continue;
755+ for (let k = 0; k < species.length; k++) {
756+ const m = r.healthy ? maxAbsFinite(r.diffFields[k]) : null;
757+ const range = r.diffRanges[k];
758+ if (m !== null) {
759+ if (!Number.isFinite(range.lo)) {
760+ range.lo = -m;
761+ range.hi = m;
762+ } else {
763+ const a = 0.15;
764+ range.lo += a * (-m - range.lo);
765+ range.hi += a * (m - range.hi);
766+ }
767+ }
768+ if (!Number.isFinite(range.lo) || !Number.isFinite(range.hi)) continue;
769+ const shown = floorRange(range.lo, range.hi);
770+ fillFieldValues(r.diffValueBufs[k], r.diffFields[k], this.#topo);
771+ fillColors(r.diffColorBufs[k], r.diffValueBufs[k], shown.lo, shown.hi, colormaps.coolwarm);
772+ r.diffScenes[k]?.updateColors(r.diffColorBufs[k]);
773+ const cap = r.diffCaps[k];
774+ if (cap) cap.textContent = `± ${fmtValue(shown.hi)}`;
775+ }
776+ }
441777 }
442778
443779 /**
@@ -448,8 +784,11 @@ export class CompareRun {
448784 * a physical quantity, which is all it is used for.
449785 */
450786 #measureDifference(): void {
451- const ref = this.#rows[this.#opts.reference];
452- if (!ref) return;
787+ // Against a reference file, every row is measured against its final state;
788+ // otherwise against the chosen reference variant, whose own Δ is zero.
789+ const ref = this.#fileRow ? null : this.#rows[this.#opts.reference];
790+ const refFields = this.#fileRow?.fields ?? ref?.fields;
791+ if (!refFields) return;
453792 const species = this.#opts.model.species;
454793 for (const r of this.#rows) {
455794 for (let k = 0; k < species.length; k++) {
@@ -458,16 +797,18 @@ export class CompareRun {
458797 continue;
459798 }
460799 const a = r.fields[k];
461- const b = ref.fields[k];
800+ const b = refFields[k];
462801 if (!a || !b || a.length !== b.length) {
463802 r.err[k] = NaN;
464803 continue;
465804 }
805+ const diff = r.diffFields[k];
466806 let num = 0;
467807 let den = 0;
468808 for (let i = 0; i < a.length; i++) {
469809 const w = this.#weights[i];
470810 const d = a[i] - b[i];
811+ diff[i] = d;
471812 num += w * d * d;
472813 den += w * b[i] * b[i];
473814 }
@@ -484,44 +825,49 @@ export class CompareRun {
484825 * usually the one that has converged and the fast one the one that has not.
485826 */
486827 #updateRowStats(): void {
487- const species = this.#opts.model.species;
488- const ref = this.#rows[this.#opts.reference];
828+ const ref = this.#fileRow ? null : this.#rows[this.#opts.reference];
489829 for (const r of this.#rows) {
490- const per = species
491- .map((s, k) => `${s} ${Number.isFinite(r.err[k]) ? r.err[k].toExponential(2) : '—'}`)
492- .join('<br>');
493830 // Divergence is said, not implied. Scaled to a healthy row, a blown-up
494831 // variant is a flat saturated panel, which on its own is easy to misread
495- // as a converged uniform state.
832+ // as a converged uniform state. Δ itself now lives in the error chart,
833+ // not here.
496834 const body = !r.healthy
497835 ? '<b class="cmp-diverged">diverged</b>'
498836 : r === ref
499837 ? '<b>reference</b>'
500- : `Δ ${per}`;
501- r.statEl.innerHTML = `${r.session.steps.toLocaleString()} steps<br>${body}`;
838+ : '';
839+ r.statEl.innerHTML =
840+ `${r.session.steps.toLocaleString()} steps` + (body ? `<br>${body}` : '');
502841 }
503842 }
504843
505844 #status(): void {
845+ const refFile = this.#opts.refFile;
846+ const clock = refFile
847+ ? `<b>t = ${this.#t.toFixed(2)} / ${(refFile.steps * CompareRun.baseDt(this.#opts.params)).toFixed(2)}</b>` +
848+ ` · NOTE: vertical axis measures difference from uploaded simulation's end state.`
849+ : `<b>t = ${this.#t.toFixed(2)}</b> (same for every variant)`;
506850 this.#opts.onStatus(
507- `<b>t = ${this.#t.toFixed(2)}</b> (same for every variant) · ` +
851+ `${clock} · ` +
508852 (this.#frameMs > 0 ? `${this.#frameMs.toFixed(1)} ms/frame · ` : '') +
509853 this.#note,
510854 );
511855 }
512856
513857 #observeResize(): void {
514- const scenes = this.#rows.flatMap((r) => r.scenes);
858+ const scenes = this.#allScenes();
515859 this.#resizeObs = new ResizeObserver(() => {
516860 for (const s of scenes) {
517861 const box = s.canvas.parentElement;
518862 if (box) s.resize(box.clientWidth, box.clientHeight);
519863 }
864+ this.#errorChart?.redraw();
520865 });
521866 for (const s of scenes) {
522867 const box = s.canvas.parentElement;
523868 if (box) this.#resizeObs.observe(box);
524869 }
870+ this.#resizeObs.observe(this.#opts.chartContainer);
525871 }
526872
527873 // -------------------------------------------------------------- the clock
@@ -538,13 +884,34 @@ export class CompareRun {
538884 this.#pumping = true;
539885 try {
540886 while (this.#running && !this.#disposed) {
887+ // Against a reference file the run is finite: the last frame takes
888+ // however many base steps remain, so every variant lands exactly on
889+ // the file's end time — where Δ against its final state is the
890+ // comparison — and stops there rather than drifting past it.
891+ const refFile = this.#opts.refFile;
892+ const n = refFile
893+ ? Math.min(this.#frameSteps, refFile.steps - this.#stepsDone)
894+ : this.#frameSteps;
895+ if (n <= 0) {
896+ this.#running = false;
897+ this.#opts.onFinished?.();
898+ break;
899+ }
541900 const t0 = performance.now();
542- for (const r of this.#rows) r.session.step(this.#frameSteps * r.variant.dtDiv);
543- this.#t += this.#frameSteps * CompareRun.baseDt(this.#opts.params);
901+ for (const r of this.#rows) r.session.step(n * r.variant.dtDiv);
902+ this.#stepsDone += n;
903+ this.#t += n * CompareRun.baseDt(this.#opts.params);
544904 await this.draw();
545905 if (this.#disposed) break;
546906 const dt = performance.now() - t0;
547907 this.#frameMs = this.#frameMs === 0 ? dt : this.#frameMs + 0.05 * (dt - this.#frameMs);
908+ if (refFile && this.#stepsDone >= refFile.steps) {
909+ this.#finished = true;
910+ this.#running = false;
911+ this.#status();
912+ this.#opts.onFinished?.();
913+ break;
914+ }
548915 this.#status();
549916 await nextFrame();
550917 }
@@ -560,6 +927,19 @@ export class CompareRun {
560927
561928 const nextFrame = (): Promise<number> => new Promise(requestAnimationFrame);
562929
930+/** A whole spectral state re-indexed into a (wider) band's layout — the
931+ * reference file's initial condition, in the form loadState takes. */
932+function prolongState(
933+ coeffs: Record<string, Float32Array>,
934+ names: string[],
935+ lmaxFrom: number,
936+ lmaxTo: number,
937+): Record<string, Float32Array> {
938+ const out: Record<string, Float32Array> = {};
939+ for (const name of names) out[name] = prolongCoeffs(coeffs[name], lmaxFrom, lmaxTo);
940+ return out;
941+}
942+
563943 /** Whether every entry is an ordinary number — false once a variant has left
564944 * its convergence radius and saturated to infinity or NaN. */
565945 function allFinite(f: Float32Array | undefined): boolean {
@@ -583,6 +963,22 @@ function leastPeak(all: (Bounds | null)[]): Bounds | null {
583963 return best;
584964 }
585965
966+/** Max |value| over the finite entries of a field; null if none are finite —
967+ * the diff panels' analogue of finiteRange below, since a symmetric range
968+ * only needs the one number. */
969+function maxAbsFinite(f: Float32Array): number | null {
970+ let m = -Infinity;
971+ let any = false;
972+ for (let i = 0; i < f.length; i++) {
973+ const v = f[i];
974+ if (!Number.isFinite(v)) continue;
975+ any = true;
976+ const a = Math.abs(v);
977+ if (a > m) m = a;
978+ }
979+ return any ? m : null;
980+}
981+
586982 /** Min and max over the finite entries only; null when there are none. */
587983 function finiteRange(f: Float32Array | undefined): { lo: number; hi: number } | null {
588984 if (!f) return null;
@@ -604,12 +1000,52 @@ function finiteRange(f: Float32Array | undefined): { lo: number; hi: number } |
6041000 * the same bar repeated, and would suggest each panel had its own scaling,
6051001 * which is exactly the thing that would make the comparison a lie.
6061002 */
1003+/** The file row's label color — none of the variant palette, since it is not
1004+ * a variant: it is the thing they are all measured against. */
1005+const FILE_ROW_COLOR = '#57606a';
1006+
1007+/**
1008+ * One sphere panel: a boxed SphereScene plus the value/color buffers that
1009+ * feed it. Shared by the value row, the diff row, and the file row — all
1010+ * three build a panel the same way, only differing in the class on the box
1011+ * (for styling) and in what fills the buffers afterward.
1012+ */
1013+function makeSpherePanel(
1014+ colsEl: HTMLElement,
1015+ topo: SphereMeshTopology,
1016+ posBuf: Float32Array,
1017+ background: string | undefined,
1018+ extraClass = '',
1019+): { box: HTMLElement; scene: SphereScene; valueBuf: Float32Array; colorBuf: Float32Array } {
1020+ const box = document.createElement('div');
1021+ box.className = extraClass ? `sphere-box cmp-box ${extraClass}` : 'sphere-box cmp-box';
1022+ colsEl.append(box);
1023+ const scene = new SphereScene(
1024+ box,
1025+ topo.numVertices,
1026+ topo.indices,
1027+ Float32Array.from(posBuf),
1028+ background,
1029+ );
1030+ scene.fitCamera();
1031+ return {
1032+ box,
1033+ scene,
1034+ valueBuf: new Float32Array(topo.numVertices),
1035+ colorBuf: new Float32Array(topo.numVertices * 3),
1036+ };
1037+}
1038+
6071039 async function buildGrid(
6081040 opts: CompareOptions,
6091041 sessions: ModelSession[],
6101042 topo: SphereMeshTopology,
6111043 showDt: boolean,
612-): Promise<{ rows: Row[]; rangeBars: { fill: (lo: number, hi: number) => void }[] }> {
1044+): Promise<{
1045+ rows: Row[];
1046+ fileRow: FileRow | null;
1047+ rangeBars: { fill: (lo: number, hi: number) => void }[];
1048+}> {
6131049 const { container, model } = opts;
6141050 container.replaceChildren();
6151051 container.classList.add('compare');
@@ -658,13 +1094,28 @@ async function buildGrid(
6581094 .getPropertyValue('--sphere-bg')
6591095 .trim();
6601096
1097+ // When every row should be drawn on the same shape (vs-sphere: the point
1098+ // is to isolate the field, not the surface), fetch that shape once from
1099+ // the reference row's session and hand the identical array to every row.
1100+ // fillPositions/fillFieldValues already treat "whose mesh this is" and
1101+ // "whose field this is" as fully independent buffers, so this is the only
1102+ // place that needs to know about it — every method downstream that reads
1103+ // r.coords/r.posBuf just sees one row's shape reused by every other.
1104+ const sharedCoords = opts.renderOnReferenceGeometry
1105+ ? await sessions[opts.reference].renderPositions()
1106+ : null;
1107+
6611108 const rows: Row[] = [];
6621109 for (let i = 0; i < sessions.length; i++) {
6631110 const session = sessions[i];
6641111 const variant = opts.variants[i];
6651112 const color = VARIANT_COLORS[i % VARIANT_COLORS.length];
1113+ // The reference itself never gets a diff row — its diff against itself
1114+ // is trivially zero, and a flat zero panel would just spend a WebGL
1115+ // context for nothing.
1116+ const isRef = !opts.refFile && i === opts.reference;
6661117
667- const coords = await session.renderPositions();
1118+ const coords = sharedCoords ?? (await session.renderPositions());
6681119 const posBuf = new Float32Array(topo.numVertices * 3);
6691120 fillPositions(posBuf, coords, topo, opts.morph);
6701121
@@ -675,7 +1126,7 @@ async function buildGrid(
6751126 labelEl.style.setProperty('--c', color);
6761127 const nameEl = document.createElement('div');
6771128 nameEl.className = 'cmp-rowname';
678- nameEl.textContent = variantLabel(variant, showDt);
1129+ nameEl.textContent = opts.rowLabels?.[i] ?? variantLabel(variant, showDt);
6791130 const statEl = document.createElement('div');
6801131 statEl.className = 'cmp-rowstat';
6811132 labelEl.append(nameEl, statEl);
@@ -688,32 +1139,125 @@ async function buildGrid(
6881139 const valueBufs: Float32Array[] = [];
6891140 const colorBufs: Float32Array[] = [];
6901141 for (let k = 0; k < model.species.length; k++) {
691- const box = document.createElement('div');
692- box.className = 'sphere-box cmp-box';
693- colsEl.append(box);
694- const scene = new SphereScene(
695- box,
696- topo.numVertices,
697- topo.indices,
698- Float32Array.from(posBuf),
699- sphereBg || undefined,
700- );
701- scene.fitCamera();
1142+ const { scene, valueBuf, colorBuf } = makeSpherePanel(colsEl, topo, posBuf, sphereBg || undefined);
7021143 scenes.push(scene);
703- valueBufs.push(new Float32Array(topo.numVertices));
704- colorBufs.push(new Float32Array(topo.numVertices * 3));
1144+ valueBufs.push(valueBuf);
1145+ colorBufs.push(colorBuf);
1146+ }
1147+
1148+ // ---- its diff row, right underneath ----------------------------------
1149+ const diffFields = model.species.map(() => new Float32Array(topo.nlat * topo.nphi));
1150+ const diffScenes: SphereScene[] = [];
1151+ const diffValueBufs: Float32Array[] = [];
1152+ const diffColorBufs: Float32Array[] = [];
1153+ const diffCaps: HTMLElement[] = [];
1154+ if (!isRef) {
1155+ const diffRowEl = document.createElement('div');
1156+ diffRowEl.className = 'cmp-row cmp-diffrow';
1157+ const diffLabelEl = document.createElement('div');
1158+ diffLabelEl.className = 'cmp-rowlabel';
1159+ diffLabelEl.style.setProperty('--c', color);
1160+ const diffNameEl = document.createElement('div');
1161+ diffNameEl.className = 'cmp-rowname';
1162+ diffNameEl.textContent = 'Δ vs reference';
1163+ diffLabelEl.append(diffNameEl);
1164+ const diffColsEl = document.createElement('div');
1165+ diffColsEl.className = 'cmp-cols';
1166+ diffRowEl.append(diffLabelEl, diffColsEl);
1167+ container.append(diffRowEl);
1168+
1169+ for (let k = 0; k < model.species.length; k++) {
1170+ const { box, scene, valueBuf, colorBuf } = makeSpherePanel(
1171+ diffColsEl, topo, posBuf, sphereBg || undefined, 'cmp-diffbox',
1172+ );
1173+ diffScenes.push(scene);
1174+ diffValueBufs.push(valueBuf);
1175+ diffColorBufs.push(colorBuf);
1176+ const cap = document.createElement('span');
1177+ cap.className = 'cmp-diffcap';
1178+ box.append(cap);
1179+ diffCaps.push(cap);
1180+ }
7051181 }
7061182
1183+ const diffRanges = model.species.map(() => ({ lo: NaN, hi: NaN }));
7071184 rows.push({
7081185 variant, session, color, coords, posBuf, scenes, valueBufs, colorBufs,
709- fields: [], err: model.species.map(() => 0), healthy: true, statEl,
1186+ fields: [], diffFields, diffScenes, diffValueBufs, diffColorBufs, diffCaps, diffRanges,
1187+ err: model.species.map(() => 0), healthy: true, statEl,
7101188 });
7111189 }
7121190
1191+ // ---- the reference file's final state, as one more (static) row ---------
1192+ let fileRow: FileRow | null = null;
1193+ if (opts.refFile) {
1194+ const rf = opts.refFile;
1195+ // Synthesized through the coarsest session's display plan — exact, like
1196+ // every other use of the shared grid: the file's coefficients are
1197+ // band-limited at its lmax, which every variant's band contains.
1198+ const view = sessions[0].viewSht;
1199+ const lmaxTo = sessions[0].cfg.lmax;
1200+ const on = (q: Float32Array): Promise<Float32Array> =>
1201+ view.synth(prolongCoeffs(q, rf.lmax, lmaxTo));
1202+ const [gx, gy, gz] = [
1203+ await on(rf.geometryCoeffs.X),
1204+ await on(rf.geometryCoeffs.Y),
1205+ await on(rf.geometryCoeffs.Z),
1206+ ];
1207+ // The file's own surface, not a regeneration of it — interleaved xyz, the
1208+ // same layout renderPositions() hands back.
1209+ const coords = new Float32Array(3 * gx.length);
1210+ for (let i = 0; i < gx.length; i++) {
1211+ coords[3 * i] = gx[i];
1212+ coords[3 * i + 1] = gy[i];
1213+ coords[3 * i + 2] = gz[i];
1214+ }
1215+ const posBuf = new Float32Array(topo.numVertices * 3);
1216+ fillPositions(posBuf, coords, topo, opts.morph);
1217+
1218+ const rowEl = document.createElement('div');
1219+ rowEl.className = 'cmp-row';
1220+ const labelEl = document.createElement('div');
1221+ labelEl.className = 'cmp-rowlabel';
1222+ labelEl.style.setProperty('--c', FILE_ROW_COLOR);
1223+ const nameEl = document.createElement('div');
1224+ nameEl.className = 'cmp-rowname';
1225+ nameEl.textContent = 'reference file';
1226+ nameEl.title = rf.label;
1227+ const statEl = document.createElement('div');
1228+ statEl.className = 'cmp-rowstat';
1229+ statEl.innerHTML = `${rf.steps.toLocaleString()} steps<br><b>final state</b>`;
1230+ labelEl.append(nameEl, statEl);
1231+ const colsEl = document.createElement('div');
1232+ colsEl.className = 'cmp-cols';
1233+ rowEl.append(labelEl, colsEl);
1234+ container.append(rowEl);
1235+
1236+ const scenes: SphereScene[] = [];
1237+ const valueBufs: Float32Array[] = [];
1238+ const colorBufs: Float32Array[] = [];
1239+ const fields: Float32Array[] = [];
1240+ const bounds: (Bounds | null)[] = [];
1241+ for (let k = 0; k < model.species.length; k++) {
1242+ const { scene, valueBuf, colorBuf } = makeSpherePanel(colsEl, topo, posBuf, sphereBg || undefined);
1243+ scenes.push(scene);
1244+ const field = await on(rf.final[model.state[k]]);
1245+ fields.push(field);
1246+ bounds.push(finiteRange(field));
1247+ fillFieldValues(valueBuf, field, topo);
1248+ valueBufs.push(valueBuf);
1249+ colorBufs.push(colorBuf);
1250+ }
1251+ fileRow = { coords, posBuf, scenes, valueBufs, colorBufs, fields, bounds };
1252+ }
1253+
7131254 // Every panel shares one camera: the study is about the fields, and looking
7141255 // at two of them from different angles is not comparing them.
715- const all = rows.flatMap((r) => r.scenes);
1256+ const all = [
1257+ ...rows.flatMap((r) => [...r.scenes, ...r.diffScenes]),
1258+ ...(fileRow?.scenes ?? []),
1259+ ];
7161260 for (let i = 1; i < all.length; i++) all[0].syncCamerasWith(all[i]);
7171261
718- return { rows, rangeBars };
1262+ return { rows, fileRow, rangeBars };
7191263 }
src/compare/referenceCase.tsadded+124−0View file
@@ -0,0 +1,124 @@
1+/**
2+ * Reading a reference HDF5 file into the pieces a replay needs.
3+ *
4+ * A reference file is a saved run from an independently-implemented solver —
5+ * geometry, initial and final spherical-harmonic coefficients, and the run's
6+ * parameters — in the layout documented in docs/ellipsoid-reference-spec.md.
7+ * Two things read it: the `npm run ref` CLI (through `h5wasm/node`) and the
8+ * browser's compare mode (through `h5wasm`, lazily loaded — see
9+ * referenceFile.ts). Both hand this module the same object shape, so the
10+ * format knowledge lives once.
11+ */
12+import { mModelByKey, defaultParams, type MModel, type Params } from '../mgpu/registry.ts';
13+import { mGeometryByKey, defaultGeometryParams, type MGeometry } from '../geom/registry.ts';
14+import { nlmCalc } from '../sht/layout.ts';
15+
16+/** The slice of h5wasm's File/Group/Dataset API this reader touches — enough
17+ * that the node and browser builds both satisfy it structurally. */
18+export interface H5Node {
19+ attrs: Record<string, { value: unknown }>;
20+ get(name: string): unknown;
21+}
22+
23+export interface ReferenceCase {
24+ /** Where it came from — the file name, for labels and messages. */
25+ label: string;
26+ model: MModel;
27+ geometry: MGeometry;
28+ /** The model's defaults overlaid with the file's own — `dt` included, so
29+ * `steps * params.dt` is the file's end time. */
30+ params: Params;
31+ geometryParams: Params;
32+ lmax: number;
33+ /** The solve-iteration count recorded in the file — the replay's default. */
34+ niter: number;
35+ /** Steps at `params.dt` from the initial state to the final one. */
36+ steps: number;
37+ /** The band-limited surface's own coefficients, [re, im] per (l, m). The
38+ * reference solver ran on this exact surface, not the analytic shape. */
39+ geometryCoeffs: { X: Float32Array; Y: Float32Array; Z: Float32Array };
40+ /** Spectral state per species (keyed by `model.state` name) at t = 0. */
41+ initial: Record<string, Float32Array>;
42+ /** The same, at the end time. */
43+ final: Record<string, Float32Array>;
44+}
45+
46+const attrsOf = (node: H5Node): Record<string, unknown> =>
47+ Object.fromEntries(Object.entries(node.attrs).map(([k, v]) => [k, v.value]));
48+
49+/** Attributes as numbers — h5wasm hands back number or BigInt by dtype. */
50+const numberAttrs = (node: H5Node): Params =>
51+ Object.fromEntries(Object.entries(attrsOf(node)).map(([k, v]) => [k, Number(v)]));
52+
53+function groupOf(node: H5Node, name: string): H5Node {
54+ const g = node.get(name) as H5Node | null;
55+ if (!g || typeof g.get !== 'function') {
56+ throw new Error(`no '${name}/' group — is this a reference file?`);
57+ }
58+ return g;
59+}
60+
61+function coeffsOf(group: H5Node, groupName: string, name: string, nlm: number): Float32Array {
62+ const v = (group.get(name) as { value?: unknown } | null)?.value;
63+ if (!(v instanceof Float32Array)) {
64+ throw new Error(`'${groupName}/${name}' is not a float32 dataset`);
65+ }
66+ if (v.length !== 2 * nlm) {
67+ throw new Error(`'${groupName}/${name}' has ${v.length} values, expected 2*nlm = ${2 * nlm}`);
68+ }
69+ return v;
70+}
71+
72+/** Read an open reference file. Throws with a plain message on anything the
73+ * replay could not act on — unknown model or geometry, missing or misshapen
74+ * coefficients — so both the CLI and the page can just show it. */
75+export function extractReferenceCase(file: H5Node, label: string): ReferenceCase {
76+ const modelKey = String(attrsOf(file).model);
77+ const model = mModelByKey(modelKey);
78+ if (!model) throw new Error(`unknown model '${modelKey}'`);
79+
80+ const spec = groupOf(file, 'spec');
81+ const specAttrs = attrsOf(spec);
82+ const geometryKey = String(specAttrs.geometry);
83+ const geometry = mGeometryByKey(geometryKey);
84+ if (!geometry) throw new Error(`unknown geometry '${geometryKey}'`);
85+
86+ const lmax = Number(specAttrs.lmax);
87+ const steps = Number(specAttrs.steps);
88+ const niter = Number(specAttrs.niter);
89+ if (!Number.isInteger(lmax) || lmax < 1) throw new Error(`bad lmax '${String(specAttrs.lmax)}'`);
90+ if (!Number.isInteger(steps) || steps < 1) throw new Error(`bad steps '${String(specAttrs.steps)}'`);
91+ if (!Number.isInteger(niter) || niter < 0) throw new Error(`bad niter '${String(specAttrs.niter)}'`);
92+ const nlm = nlmCalc(lmax, lmax);
93+
94+ const params: Params = {
95+ ...defaultParams(model),
96+ ...numberAttrs(groupOf(spec, 'params')),
97+ };
98+ if (!(params.dt! > 0)) throw new Error(`bad dt '${params.dt}'`);
99+ const geometryParams: Params = {
100+ ...defaultGeometryParams(geometry),
101+ ...numberAttrs(groupOf(spec, 'geometry_params')),
102+ };
103+
104+ const geom = groupOf(file, 'geometry');
105+ const geometryCoeffs = {
106+ X: coeffsOf(geom, 'geometry', 'Gx', nlm),
107+ Y: coeffsOf(geom, 'geometry', 'Gy', nlm),
108+ Z: coeffsOf(geom, 'geometry', 'Gz', nlm),
109+ };
110+
111+ const initialGroup = groupOf(file, 'initial');
112+ const finalGroup = groupOf(file, 'final');
113+ const initial: Record<string, Float32Array> = {};
114+ const final: Record<string, Float32Array> = {};
115+ for (const name of model.state) {
116+ initial[name] = coeffsOf(initialGroup, 'initial', name, nlm);
117+ final[name] = coeffsOf(finalGroup, 'final', name, nlm);
118+ }
119+
120+ return {
121+ label, model, geometry, params, geometryParams,
122+ lmax, niter, steps, geometryCoeffs, initial, final,
123+ };
124+}
src/compare/referenceFile.tsadded+29−0View file
@@ -0,0 +1,29 @@
1+/**
2+ * Reading a reference .h5 in the page.
3+ *
4+ * h5wasm's browser build carries the whole HDF5 library as embedded wasm —
5+ * about 4 MB — so it is imported here, dynamically, and nowhere else: the page
6+ * pays for it on the first file actually loaded, never on startup. The bytes
7+ * are written into the wasm module's in-memory filesystem under a fixed
8+ * scratch name (loads are sequential — there is one file input), opened,
9+ * extracted, and unlinked.
10+ */
11+import { extractReferenceCase, type H5Node, type ReferenceCase } from './referenceCase.ts';
12+
13+const SCRATCH = '/loaded-reference.h5';
14+
15+export async function loadReferenceFile(file: File): Promise<ReferenceCase> {
16+ const bytes = new Uint8Array(await file.arrayBuffer());
17+ const h5 = await import('h5wasm');
18+ const { FS } = (await h5.ready) as unknown as {
19+ FS: { writeFile(path: string, data: Uint8Array): void; unlink(path: string): void };
20+ };
21+ FS.writeFile(SCRATCH, bytes);
22+ const opened = new h5.File(SCRATCH, 'r');
23+ try {
24+ return extractReferenceCase(opened as unknown as H5Node, file.name);
25+ } finally {
26+ opened.close();
27+ FS.unlink(SCRATCH);
28+ }
29+}
src/compare/sharedStart.tsmodified+25−0View file
@@ -24,6 +24,12 @@
2424 * The coarsest variant gets the projected field too, not the raw white noise
2525 * it was analysed from — otherwise it alone would start somewhere slightly
2626 * different from the others.
27+ *
28+ * A model whose `init` calls `randnfun3` (all of the shipped ones do) draws its
29+ * perturbation from a Fourier series on the surface's bounding box instead, and
30+ * that needs no projection: it is a function of space, evaluated wherever it is
31+ * asked, so one coefficient table *is* one field on every variant's grid. It
32+ * still has to be drawn once rather than per session — see `sharedModes`.
2733 */
2834 import { lmIndex, nlmCalc } from '../sht/layout.ts';
2935 import { seededNoise } from '../mgpu/noise.ts';
@@ -74,3 +80,22 @@ export async function sharedNoise(
7480 }
7581 return out;
7682 }
83+
84+/**
85+ * The random field every variant seeds from, drawn once — from `reference`,
86+ * whose numbers the study quotes — or null for a model that does not call
87+ * `randnfun3`.
88+ *
89+ * One table for all of them is not merely an economy (the draw is interpreter
90+ * time, and at a fine wavelength seconds of it). Each session would otherwise
91+ * draw from *its own* bounding box, and a box comes from grid samples of the
92+ * surface: at different lmax those differ in the last digits, and the draw is
93+ * sensitive to the box — a different mode count consumes the RNG differently
94+ * and the fields stop being the same one. Drawing once removes the question.
95+ */
96+export function sharedModes(
97+ reference: ModelSession,
98+ seed: number,
99+): Promise<Float32Array | null> {
100+ return reference.drawSeedModes(seed);
101+}
src/export/matlabScript.tsadded+313−0View file
@@ -0,0 +1,313 @@
1+/**
2+ * The run on screen, as one standalone MATLAB script.
3+ *
4+ * The models and geometries are already MATLAB; what the app supplies around
5+ * them — the transforms, the geometry weights, the seeded field, the driver —
6+ * exists only as TypeScript and WGSL. This module assembles a single function
7+ * file carrying all of it: the current model and geometry sources verbatim as
8+ * local functions, double-precision MATLAB ports of the host-provided
9+ * operations (support.m), and a generated driver with the run's settings
10+ * baked in.
11+ *
12+ * Fidelity is method-for-method, not bit-for-bit: the ports run in f64 where
13+ * the GPU path is f32, and random draws use MATLAB's own rng, so a seed value
14+ * selects a different member of the same random ensemble than the same value
15+ * in the app. The script's results file uses the app's reference-run layout
16+ * (docs/ellipsoid-reference-spec.md), so a MATLAB run can be loaded back into
17+ * the page or checked with `npm run ref`.
18+ */
19+import supportSource from './support.m?raw';
20+import randnfun3Source from '../../tools/randnfun3.m?raw';
21+import randnfunsphereSource from '../../tools/randnfunsphere.m?raw';
22+import type { MModel, Params } from '../mgpu/registry.ts';
23+import type { MGeometry } from '../geom/registry.ts';
24+
25+/** The generated function's name — and therefore the file name to save as. */
26+export const MATLAB_SCRIPT_NAME = 'turing_surface_run';
27+
28+export interface MatlabExportSpec {
29+ model: MModel;
30+ /** Model source as running — the editor's working copy when edited. */
31+ modelSource: string;
32+ params: Params;
33+ geometry: MGeometry;
34+ geometrySource: string;
35+ geometryParams: Params;
36+ lmax: number;
37+ niter: number;
38+ /** Wavelength of the seeded random field. */
39+ lam3: number;
40+ seed: number;
41+ /** Preset key, recorded in the results file's /spec. */
42+ preset: string;
43+ /** The equivalent `npm run bench` command, recorded for provenance. */
44+ command: string;
45+ /** The generated script's own run controls; app defaults when omitted. */
46+ controls?: { nsteps?: number; plotEvery?: number; outFile?: string };
47+}
48+
49+interface Signature {
50+ outputs: string[];
51+ params: string[];
52+}
53+
54+/** First `function [outs] = name(args)` line in a .m — the same contract the
55+ * compiler applies, minus everything it checks later. */
56+function parseSignature(source: string, name: string, file: string): Signature {
57+ const re = new RegExp(
58+ String.raw`^[ \t]*function\s+(?:\[([^\]]*)\]|([A-Za-z]\w*))\s*=\s*${name}\s*\(([^)]*)\)`,
59+ 'm',
60+ );
61+ const m = re.exec(source);
62+ if (!m) {
63+ throw new Error(`cannot export: ${file} defines no function named '${name}'`);
64+ }
65+ const split = (s: string): string[] =>
66+ s.split(',').map((t) => t.trim()).filter((t) => t.length > 0);
67+ return {
68+ outputs: m[1] !== undefined ? split(m[1]) : [m[2]],
69+ params: split(m[3]),
70+ };
71+}
72+
73+/** A number as MATLAB source. JS stringification round-trips doubles exactly
74+ * and every form it produces (0.0004, 1e-21, -3) is a MATLAB literal. */
75+const num = (v: number): string => (Number.isFinite(v) ? String(v) : '0');
76+
77+/** A string as a MATLAB char literal. */
78+const str = (s: string): string => `'${s.replace(/'/g, "''")}'`;
79+
80+const banner = (title: string): string => {
81+ const line = `% ${'='.repeat(72)}`;
82+ return `${line}\n% ${title}\n${line}`;
83+};
84+
85+export function generateMatlabScript(spec: MatlabExportSpec): string {
86+ const { model, geometry } = spec;
87+ const controls = {
88+ nsteps: spec.controls?.nsteps ?? 2000,
89+ plotEvery: spec.controls?.plotEvery ?? 10,
90+ outFile: spec.controls?.outFile ?? `${MATLAB_SCRIPT_NAME}.h5`,
91+ };
92+
93+ const init = parseSignature(spec.modelSource, 'init', `models/${model.key}.m`);
94+ const step = parseSignature(spec.modelSource, 'step', `models/${model.key}.m`);
95+ const shape = parseSignature(spec.geometrySource, 'shape', `geometries/${geometry.key}.m`);
96+
97+ // The driver defines every host-provided name the .m may ask for (lam,
98+ // filt, the geometry fields, jhat, niter, ...) under its canonical name,
99+ // so a model call is its own signature read back. Only the tunable
100+ // parameters live elsewhere — in the mp/gp structs, where the person
101+ // running the script edits them — so those names are mapped.
102+ const modelParamKeys = new Set(model.params.map((p) => p.key));
103+ const modelArg = (a: string): string => (modelParamKeys.has(a) ? `mp.${a}` : a);
104+ const shapeArg = (a: string): string =>
105+ a === 'theta' || a === 'phi' ? a : `gp.${a}`;
106+
107+ const stateOuts = [...model.state, ...model.species];
108+ const outs = `[${stateOuts.join(', ')}]`;
109+ const initCall = `${outs} = init(${init.params.map(modelArg).join(', ')});`;
110+ const stepCall = `${outs} = step(${step.params.map(modelArg).join(', ')});`;
111+ const shapeCall = `[gxr, gyr, gzr] = shape(${shape.params.map(shapeArg).join(', ')});`;
112+
113+ const speciesCell = `{${model.species.join(', ')}}`;
114+ const namesCell = `{${model.species.map((s) => str(s)).join(', ')}}`;
115+
116+ // `noise` is the plain seeded grid perturbation, for a .m that takes it
117+ // instead of calling randnfun3 (none of the shipped models do).
118+ const takesNoise = init.params.includes('noise') || step.params.includes('noise');
119+
120+ const mpBlock = model.params
121+ .map((p) => `mp.${p.key} = ${num(spec.params[p.key] ?? p.value)};`)
122+ .join('\n');
123+ const gpBlock = geometry.params
124+ .map((p) => `gp.${p.key} = ${num(spec.geometryParams[p.key] ?? p.value)};`)
125+ .join('\n');
126+
127+ const driver = `function ${MATLAB_SCRIPT_NAME}()
128+% ${model.label} on ${geometry.label} -- a run captured from the
129+% turing-surface app as one standalone MATLAB script.
130+%
131+% The model and geometry .m below are the app's own, verbatim; around them
132+% this file carries double-precision MATLAB ports of everything the app
133+% provides from the host side: the spherical-harmonic transforms and their
134+% derivative shuffles, the metric weights of the surface Laplace-Beltrami
135+% operator, the seeded random field, and the run loop (src/sht and src/geom
136+% in the repository). The scheme is the app's: IMEX Euler, implicit
137+% diffusion preconditioned on the round sphere, the geometric correction
138+% iterated niter times per step.
139+%
140+% Two deliberate differences from the page. Everything here runs in double
141+% precision, where the app's GPU path is single. And random draws use
142+% MATLAB's own rng, so a seed value selects a different member of the same
143+% random ensemble than the same value in the app.
144+%
145+% Save as ${MATLAB_SCRIPT_NAME}.m and run it. The run plots live, and the
146+% final state is written to an HDF5 file in the app's reference-run layout
147+% (docs/ellipsoid-reference-spec.md in the repository), so it can be loaded
148+% back into the page ("Compare against uploaded data") or checked on the
149+% desktop with \`npm run ref -- --in ${controls.outFile}\`.
150+% Needs base MATLAB, R2020b or newer; no toolboxes.
151+
152+% ---- run controls --------------------------------------------------------
153+nsteps = ${controls.nsteps}; % timesteps to run
154+plot_every = ${controls.plotEvery}; % live-plot interval, in steps; 0 disables plotting
155+out_file = ${str(controls.outFile)}; % results file; '' disables
156+seed = ${num(spec.seed)}; % rng seed for the initial condition
157+
158+% ---- captured from the app -----------------------------------------------
159+lmax = ${spec.lmax}; % spherical-harmonic truncation degree
160+niter = ${spec.niter}; % iterations of the implicit solve's geometric correction
161+lam3 = ${num(spec.lam3)}; % wavelength of the seeded random field
162+${model.params.length ? `% ${model.label} parameters\n${mpBlock}` : `% ${model.label} has no parameters`}
163+${geometry.params.length ? `% ${geometry.label} parameters\n${gpBlock}` : `% ${geometry.label} has no parameters`}
164+
165+% ---- grid and transforms -------------------------------------------------
166+% nlat/nphi follow lmax by the app's dealiasing rule (src/sht/layout.ts) for
167+% a reaction of polynomial degree pdeg.
168+pdeg = ${model.pdeg};
169+mmax = lmax;
170+nlat = 2 * ceil(max(lmax + 1, ((pdeg + 1) * lmax + 1) / 2) / 2);
171+nphi = 2 ^ nextpow2((pdeg + 1) * lmax + 1);
172+npts = nlat * nphi;
173+sht_tables(sht_setup(lmax, mmax, nlat, nphi));
174+S = sht_tables();
175+nlm = S.nlm;
176+lam = S.lam;
177+filt = S.filt;
178+theta = S.theta;
179+phi = S.phi;
180+
181+% ---- the surface ---------------------------------------------------------
182+${shapeCall}
183+% A constant coordinate comes back scalar; spread it over the grid.
184+gxr = gxr + zeros(npts, 1);
185+gyr = gyr + zeros(npts, 1);
186+gzr = gzr + zeros(npts, 1);
187+G = surface_tables(gxr, gyr, gzr);
188+gx = G.gx; gy = G.gy; gz = G.gz;
189+Gx = G.Gx; Gy = G.Gy; Gz = G.Gz;
190+p1 = G.p1; p2 = G.p2; q2 = G.q2; r = G.r;
191+dp1 = G.dp1; dq2 = G.dq2; jinv = G.jinv;
192+Vtx = G.Vtx; Vty = G.Vty; Vtz = G.Vtz;
193+Vpx = G.Vpx; Vpy = G.Vpy; Vpz = G.Vpz;
194+jhat = G.Jhat;
195+radius = sqrt(gx.^2 + gy.^2 + gz.^2);
196+fprintf('grid %d x %d, nlm %d, radius %.3f-%.3f, Jhat %.3f\\n', ...
197+ nlat, nphi, nlm, min(radius), max(radius), jhat);
198+
199+% ---- initial condition ---------------------------------------------------
200+rng(seed);
201+${takesNoise ? `noise = ${num(model.seedAmp)} * randn(npts, 1);\n` : ''}${initCall}
202+${model.state.map((s) => `${s}0 = ${s};`).join('\n')}
203+
204+% ---- time loop -----------------------------------------------------------
205+if plot_every > 0
206+ ph = plot_setup(gx, gy, gz, ${speciesCell}, ${namesCell});
207+ plot_update(ph, ${speciesCell}, 0, 0, nsteps);
208+end
209+report_every = max(1, round(nsteps / 10));
210+t = 0;
211+tstart = tic;
212+for k = 1:nsteps
213+ ${stepCall}
214+ t = t + mp.dt;
215+ if plot_every > 0 && (mod(k, plot_every) == 0 || k == nsteps)
216+ plot_update(ph, ${speciesCell}, t, k, nsteps);
217+ end
218+ if mod(k, report_every) == 0 || k == nsteps
219+ fprintf('step %d/%d t = %.3f (%.1f s)\\n', k, nsteps, t, toc(tstart));
220+ end
221+end
222+
223+% ---- results file --------------------------------------------------------
224+% The app's reference-run layout, plus a /fields group with the final grid
225+% fields, the surface and the grid angles (each field stored nphi x nlat,
226+% ring by ring from the north pole).
227+if ~isempty(out_file)
228+ if exist(out_file, 'file') == 2
229+ delete(out_file);
230+ end
231+${['Gx', 'Gy', 'Gz']
232+ .map((c) => ` write_coeffs(out_file, '/geometry/${c}', ${c});`)
233+ .join('\n')}
234+${model.state
235+ .map((s) => ` write_coeffs(out_file, '/initial/${s}', ${s}0);`)
236+ .join('\n')}
237+${model.state
238+ .map((s) => ` write_coeffs(out_file, '/final/${s}', ${s});`)
239+ .join('\n')}
240+${[...model.species.map((s) => [s, s] as const), (['x', 'gx'] as const), (['y', 'gy'] as const), (['z', 'gz'] as const)]
241+ .map(
242+ ([name, v]) =>
243+ ` h5create(out_file, '/fields/${name}', [nphi nlat]);\n` +
244+ ` h5write(out_file, '/fields/${name}', reshape(${v}, nphi, nlat));`,
245+ )
246+ .join('\n')}
247+ h5create(out_file, '/fields/theta', nlat);
248+ h5write(out_file, '/fields/theta', acos(min(1, max(-1, S.ct))));
249+ h5create(out_file, '/fields/phi', nphi);
250+ h5write(out_file, '/fields/phi', 2*pi*(0:nphi-1)'/nphi);
251+ make_group(out_file, '/backend');
252+ make_group(out_file, '/spec');
253+ make_group(out_file, '/spec/params');
254+ make_group(out_file, '/spec/geometry_params');
255+ make_group(out_file, '/grid');
256+ h5writeatt(out_file, '/', 'model', ${str(model.key)});
257+ h5writeatt(out_file, '/', 'species', [${model.state.map((s) => `"${s}"`).join(' ')}]);
258+ h5writeatt(out_file, '/', 'command', ${str(spec.command)});
259+ h5writeatt(out_file, '/backend', 'runtime', 'matlab');
260+ h5writeatt(out_file, '/backend', 'adapter', ['MATLAB ' version]);
261+ h5writeatt(out_file, '/backend', 'precision', 'double');
262+ h5writeatt(out_file, '/spec', 'preset', ${str(spec.preset)});
263+ h5writeatt(out_file, '/spec', 'geometry', ${str(geometry.key)});
264+ h5writeatt(out_file, '/spec', 'lmax', lmax);
265+ h5writeatt(out_file, '/spec', 'seed', seed);
266+ h5writeatt(out_file, '/spec', 'steps', nsteps);
267+ h5writeatt(out_file, '/spec', 'warmup', 0);
268+ h5writeatt(out_file, '/spec', 'niter', niter);
269+ h5writeatt(out_file, '/spec', 'lam3', lam3);
270+${model.params
271+ .map((p) => ` h5writeatt(out_file, '/spec/params', ${str(p.key)}, mp.${p.key});`)
272+ .join('\n')}
273+${geometry.params
274+ .map((p) => ` h5writeatt(out_file, '/spec/geometry_params', ${str(p.key)}, gp.${p.key});`)
275+ .join('\n')}
276+ h5writeatt(out_file, '/grid', 'lmax', lmax);
277+ h5writeatt(out_file, '/grid', 'mmax', mmax);
278+ h5writeatt(out_file, '/grid', 'nlat', nlat);
279+ h5writeatt(out_file, '/grid', 'nphi', nphi);
280+ h5writeatt(out_file, '/grid', 'nlm', nlm);
281+ fprintf('wrote %s\\n', out_file);
282+end
283+end`;
284+
285+ // tools/randnfun3.m verbatim, renamed: the models call the app's builtin
286+ // `randnfun3(lam3, gx, gy, gz)`, which support.m provides as a dispatcher
287+ // over this mode draw.
288+ const modesSource = randnfun3Source.replace(
289+ /function\s*\[\s*k\s*,\s*c\s*\]\s*=\s*randnfun3\s*\(/,
290+ 'function [k, c] = randnfun3_modes(',
291+ );
292+ if (modesSource === randnfun3Source) {
293+ throw new Error('cannot export: tools/randnfun3.m no longer matches the expected signature');
294+ }
295+
296+ const usesSphere = /\brandnfunsphere\b/.test(spec.geometrySource + spec.modelSource);
297+
298+ const parts = [
299+ driver,
300+ banner(`models/${model.key}.m -- the model, verbatim`),
301+ spec.modelSource.trim(),
302+ banner(`geometries/${geometry.key}.m -- the surface, verbatim`),
303+ spec.geometrySource.trim(),
304+ banner('tools/randnfun3.m -- the random-field mode draw, verbatim'),
305+ modesSource.trim(),
306+ ...(usesSphere
307+ ? [banner('tools/randnfunsphere.m -- verbatim'), randnfunsphereSource.trim()]
308+ : []),
309+ banner('host-provided operations, ported from src/sht and src/geom'),
310+ supportSource.trim(),
311+ ];
312+ return parts.join('\n\n') + '\n';
313+}
src/export/support.madded+388−0View file
@@ -0,0 +1,388 @@
1+% ---------------------------------------------------------------- transforms
2+%
3+% Double-precision MATLAB ports of the operations the app provides to a .m
4+% around its compiled GPU pipeline. Conventions follow src/sht/layout.ts:
5+% orthonormal spherical harmonics with the Condon-Shortley phase, coefficients
6+% stored for m >= 0 only in m-major order (m = 0..mmax, l = m..lmax within
7+% each m) -- here as complex nlm x 1 column vectors where the GPU carries
8+% interleaved [re, im] pairs. Grid fields are npts x 1 columns, phi-fastest:
9+% point (itheta, iphi) sits at row (itheta-1)*nphi + iphi, north row first.
10+
11+% Holds the precomputed tables between calls: set once from the top of the
12+% run, read back by every transform below.
13+function S = sht_tables(S)
14+ persistent stored
15+ if nargin > 0
16+ stored = S;
17+ end
18+ S = stored;
19+end
20+
21+% Everything the transforms need for one grid: Gauss nodes and weights,
22+% per-m Legendre tables, the coefficient layout, the derivative shuffles,
23+% and the eigenvalue/filter vectors the models take as `lam` and `filt`.
24+function S = sht_setup(lmax, mmax, nlat, nphi)
25+ S.lmax = lmax;
26+ S.mmax = mmax;
27+ S.nlat = nlat;
28+ S.nphi = nphi;
29+ S.npts = nlat * nphi;
30+ [ct, wg] = gauss_legendre(nlat);
31+ S.ct = ct;
32+ S.st = sqrt(1 - ct.^2);
33+ S.wg = wg;
34+ S.nlm = (mmax + 1) * (lmax + 1) - mmax * (mmax + 1) / 2;
35+
36+ % The grid angles as npts x 1 fields, phi-fastest like everything else.
37+ S.theta = repelem(acos(min(1, max(-1, ct))), nphi);
38+ S.phi = repmat(2*pi*(0:nphi-1)'/nphi, nlat, 1);
39+ S.stpt = repelem(S.st, nphi);
40+
41+ % Degree and order of each coefficient, and each m block's start.
42+ off = zeros(mmax + 1, 1);
43+ lv = zeros(S.nlm, 1);
44+ mv = zeros(S.nlm, 1);
45+ pos = 1;
46+ for m = 0:mmax
47+ n = lmax - m + 1;
48+ off(m + 1) = pos;
49+ lv(pos:pos + n - 1) = (m:lmax)';
50+ mv(pos:pos + n - 1) = m;
51+ pos = pos + n;
52+ end
53+ S.off = off;
54+ S.lv = lv;
55+ S.mv = mv;
56+ % Laplace-Beltrami eigenvalues l(l+1) and the top-mode filter: 1 below
57+ % lmax-2, 0 at the top two degrees, where the derivative recurrences cannot
58+ % exactly represent a derivative (src/mgpu/model.ts).
59+ S.lam = lv .* (lv + 1);
60+ S.filt = double(lv < lmax - 2);
61+
62+ % Orthonormal Legendre tables ytilde_l^m(theta_i), one nlat x (lmax-m+1)
63+ % block per m, by the standard three-term recurrence (src/sht/coeffs.ts;
64+ % SHTNS normalization, Condon-Shortley phase carried in the seed's sign).
65+ S.Y = cell(mmax + 1, 1);
66+ t = 1 / (4*pi);
67+ amm = sqrt(t);
68+ for m = 0:mmax
69+ if m > 0
70+ t = t * (2*m + 1) / (2*m);
71+ amm = (-1)^m * sqrt(t);
72+ end
73+ n = lmax - m + 1;
74+ Y = zeros(nlat, n);
75+ y0 = amm * S.st.^m;
76+ Y(:, 1) = y0;
77+ if n > 1
78+ y1 = sqrt(2*m + 3) * ct .* y0;
79+ Y(:, 2) = y1;
80+ for l = m + 2:lmax
81+ t1 = (l + m) * (l - m);
82+ a = sqrt((2*l + 1) * (2*l - 1) / t1);
83+ b = -sqrt(((2*l + 1) / (2*l - 3)) * ((l - 1 + m) * (l - 1 - m) / t1));
84+ y2 = a * ct .* y1 + b * y0;
85+ Y(:, l - m + 1) = y2;
86+ y0 = y1;
87+ y1 = y2;
88+ end
89+ end
90+ S.Y{m + 1} = Y;
91+ end
92+
93+ % sin(theta)*dtheta in coefficient space: v_l^m = ap(lm) u_{l-1}^m +
94+ % am(lm) u_{l+1}^m (src/sht/derivCoeffs.ts). Neighbors sit at +-1 within
95+ % each m block; ap/am are zero at the block edges, so the clamped index
96+ % vectors never read across a boundary.
97+ l = lv;
98+ m = mv;
99+ ap = (l - 1) .* sqrt(max(0, (l - m) .* (l + m)) ./ ((2*l - 1) .* (2*l + 1)));
100+ ap(l <= m) = 0;
101+ am = -(l + 2) .* sqrt((l + 1 - m) .* (l + 1 + m) ./ ((2*l + 1) .* (2*l + 3)));
102+ am(l >= lmax) = 0;
103+ S.ap = ap;
104+ S.am = am;
105+ S.iprev = max((1:S.nlm)' - 1, 1);
106+ S.inext = min((1:S.nlm)' + 1, S.nlm);
107+
108+ % dphig's Fourier multiplier: i*m on fft's frequency layout, masked past
109+ % the filter's reach (mcut = lmax-3), mirroring src/sht/wgsl/deriv.ts.
110+ freq = [(0:nphi/2)'; (-nphi/2 + 1:-1)'];
111+ S.dmul = 1i * freq .* (abs(freq) <= max(0, lmax - 3));
112+end
113+
114+% Gauss-Legendre nodes cos(theta), in decreasing order (north pole first),
115+% and weights for integration over cos(theta) -- Newton iteration on P_n,
116+% as src/sht/gauss.ts.
117+function [x, w] = gauss_legendre(n)
118+ x = zeros(n, 1);
119+ w = zeros(n, 1);
120+ half = floor((n + 1) / 2);
121+ for i = 1:half
122+ z = cos(pi * (i - 0.25) / (n + 0.5));
123+ pp = 0;
124+ for it = 1:100
125+ p1 = 1;
126+ p2 = 0;
127+ for j = 1:n
128+ p3 = p2;
129+ p2 = p1;
130+ p1 = ((2*j - 1) * z * p2 - (j - 1) * p3) / j;
131+ end
132+ pp = n * (z * p1 - p2) / (z^2 - 1);
133+ dz = p1 / pp;
134+ z = z - dz;
135+ if abs(dz) < 1e-15 * abs(z) + 1e-300
136+ p1 = 1;
137+ p2 = 0;
138+ for j = 1:n
139+ p3 = p2;
140+ p2 = p1;
141+ p1 = ((2*j - 1) * z * p2 - (j - 1) * p3) / j;
142+ end
143+ pp = n * (z * p1 - p2) / (z^2 - 1);
144+ z = z - p1 / pp;
145+ break;
146+ end
147+ end
148+ x(i) = z;
149+ x(n + 1 - i) = -z;
150+ wi = 2 / ((1 - z^2) * pp^2);
151+ w(i) = wi;
152+ w(n + 1 - i) = wi;
153+ end
154+ if mod(n, 2) == 1
155+ x(half) = 0;
156+ end
157+end
158+
159+% Synthesis, spectral -> grid. Grouped calls -- [a, b] = synth(x, y) -- are
160+% the app's batching hint; here each member simply runs in turn.
161+function varargout = synth(varargin)
162+ S = sht_tables();
163+ varargout = cell(1, nargin);
164+ for k = 1:nargin
165+ varargout{k} = synth_one(S, varargin{k});
166+ end
167+end
168+
169+function f = synth_one(S, Q)
170+ % Legendre stage per m, then one inverse FFT per latitude ring with the
171+ % m < 0 modes filled in by conjugate symmetry (the field is real).
172+ G = zeros(S.nphi, S.nlat);
173+ for m = 0:S.mmax
174+ Fm = (S.Y{m + 1} * Q(S.off(m + 1):S.off(m + 1) + S.lmax - m)).';
175+ G(m + 1, :) = Fm;
176+ if m > 0
177+ G(S.nphi + 1 - m, :) = conj(Fm);
178+ end
179+ end
180+ f = S.nphi * real(ifft(G, [], 1));
181+ f = f(:);
182+end
183+
184+% Analysis, grid -> spectral: forward FFT per ring, then Gauss quadrature
185+% against the same Legendre tables.
186+function varargout = analys(varargin)
187+ S = sht_tables();
188+ varargout = cell(1, nargin);
189+ for k = 1:nargin
190+ varargout{k} = analys_one(S, varargin{k});
191+ end
192+end
193+
194+function Q = analys_one(S, f)
195+ F = fft(reshape(f, S.nphi, S.nlat), [], 1) * (2*pi/S.nphi);
196+ Q = complex(zeros(S.nlm, 1));
197+ for m = 0:S.mmax
198+ Q(S.off(m + 1):S.off(m + 1) + S.lmax - m) = S.Y{m + 1}.' * (S.wg .* F(m + 1, :).');
199+ end
200+end
201+
202+% The coefficients of sin(theta)*dtheta(u): the alpha^+/alpha^- shift by one
203+% degree within each m block.
204+function V = dthetac(Q)
205+ S = sht_tables();
206+ V = S.ap .* Q(S.iprev) + S.am .* Q(S.inext);
207+end
208+
209+% The coefficients of dphi(u): i*m, diagonal.
210+function V = dphic(Q)
211+ S = sht_tables();
212+ V = 1i * (S.mv .* Q);
213+end
214+
215+% Grid-space derivatives, coefficients in: compositions of the shuffles and
216+% the synthesis (src/sht/deriv.ts). dtheta divides by sin(theta) afterwards.
217+function f = dtheta(Q)
218+ S = sht_tables();
219+ f = synth(dthetac(Q)) ./ S.stpt;
220+end
221+
222+function f = dphi(Q)
223+ f = synth(dphic(Q));
224+end
225+
226+% Grid-space phi derivative, grid in: two FFT stages and a pointwise i*m,
227+% no Legendre work -- d/dphi is diagonal in the Fourier index.
228+function g = dphig(f)
229+ S = sht_tables();
230+ F = fft(reshape(f, S.nphi, S.nlat), [], 1);
231+ g = real(ifft(S.dmul .* F, [], 1));
232+ g = g(:);
233+end
234+
235+% ---------------------------------------------------------------- the surface
236+%
237+% What the app precomputes from a shape's raw grid values: the band-limited
238+% embedding and both metric formulations built on it (src/geom/geometry.ts,
239+% src/geom/metric.ts). The solver runs on the synthesis of the coefficients,
240+% not on the raw values -- for a shape with sharp features the two differ.
241+function G = surface_tables(gxr, gyr, gzr)
242+ S = sht_tables();
243+ [G.Gx, G.Gy, G.Gz] = analys(gxr, gyr, gzr);
244+ [G.gx, G.gy, G.gz] = synth(G.Gx, G.Gy, G.Gz);
245+
246+ % Flux-form metric weights, from the sin-weighted theta tangent
247+ % sin(theta)*X_theta and X_phi, both smooth on the sphere:
248+ % gtt~ = sin^2 g_tt, gtp~ = sin g_tp, D = J sin^2(theta).
249+ [sXtx, sXty, sXtz] = synth(dthetac(G.Gx), dthetac(G.Gy), dthetac(G.Gz));
250+ [Xpx, Xpy, Xpz] = synth(dphic(G.Gx), dphic(G.Gy), dphic(G.Gz));
251+ gtt = sXtx.^2 + sXty.^2 + sXtz.^2;
252+ gtp = sXtx.*Xpx + sXty.*Xpy + sXtz.*Xpz;
253+ gpp = Xpx.^2 + Xpy.^2 + Xpz.^2;
254+ D = sqrt(gtt .* gpp - gtp.^2);
255+ G.p1 = gpp ./ D;
256+ G.p2 = -gtp ./ D;
257+ G.q2 = gtt ./ D;
258+ G.r = 1 ./ D;
259+
260+ % The sphere-subtracted weights and the bounded 1/J = r sin^2(theta) --
261+ % what keeps the concentrated division off the round sphere's share of the
262+ % flux divergence. Formed here in f64, as the app forms them.
263+ G.jinv = G.r .* S.stpt.^2;
264+ G.dp1 = G.p1 - 1;
265+ G.dq2 = G.q2 - 1;
266+
267+ % Preconditioner scale Jhat = 2/(muMin + muMax) over the eigenvalues of
268+ % the operator's symbol S = (1/J)[[p1, p2], [p2, q2]].
269+ s11 = G.p1 .* G.jinv;
270+ s12 = G.p2 .* G.jinv;
271+ s22 = G.q2 .* G.jinv;
272+ mn = (s11 + s22) / 2;
273+ disc = sqrt(((s11 - s22) / 2).^2 + s12.^2);
274+ G.Jhat = 2 / (min(mn - disc) + max(mn + disc));
275+
276+ % Inverse metric quantities V_theta/V_phi, for the Algorithm-4 models.
277+ Xtx = sXtx ./ S.stpt;
278+ Xty = sXty ./ S.stpt;
279+ Xtz = sXtz ./ S.stpt;
280+ g11 = Xtx.^2 + Xty.^2 + Xtz.^2;
281+ g12 = Xtx.*Xpx + Xty.*Xpy + Xtz.*Xpz;
282+ g22 = gpp;
283+ det = g11 .* g22 - g12.^2;
284+ G.Vtx = (g22 .* Xtx - g12 .* Xpx) ./ det;
285+ G.Vty = (g22 .* Xty - g12 .* Xpy) ./ det;
286+ G.Vtz = (g22 .* Xtz - g12 .* Xpz) ./ det;
287+ G.Vpx = (g11 .* Xpx - g12 .* Xtx) ./ det;
288+ G.Vpy = (g11 .* Xpy - g12 .* Xty) ./ det;
289+ G.Vpz = (g11 .* Xpz - g12 .* Xtz) ./ det;
290+end
291+
292+% ---------------------------------------------------------------- random field
293+%
294+% chebfun-style smooth random field in 3D, restricted to the surface by
295+% evaluating it at the grid points -- the way surfacefun seeds a run. Two
296+% signatures, as in the app:
297+% [k, c] = randnfun3(lambda, dom) the Fourier-mode draw (tools/randnfun3.m)
298+% f = randnfun3(lambda, gx, gy, gz) that draw, summed at the surface points
299+% Seed with rng(...) before calling.
300+function varargout = randnfun3(lambda, varargin)
301+ if nargin == 2
302+ [k, c] = randnfun3_modes(lambda, varargin{1});
303+ varargout = {k, c};
304+ return;
305+ end
306+ [gx, gy, gz] = deal(varargin{1:3});
307+ dom = [min(gx) max(gx) min(gy) max(gy) min(gz) max(gz)];
308+ [k, c] = randnfun3_modes(lambda, dom);
309+ % Summed in blocks of modes: the full npts x nmodes phase matrix can reach
310+ % hundreds of MB at a fine wavelength.
311+ f = zeros(numel(gx), 1);
312+ blk = 2048;
313+ for j0 = 1:blk:size(k, 1)
314+ j1 = min(j0 + blk - 1, size(k, 1));
315+ t = gx * k(j0:j1, 1)' + gy * k(j0:j1, 2)' + gz * k(j0:j1, 3)';
316+ f = f + cos(t) * c(j0:j1, 1) - sin(t) * c(j0:j1, 2);
317+ end
318+ varargout = {f};
319+end
320+
321+% ---------------------------------------------------------------- display
322+%
323+% The pattern on the surface, one panel per species. The solver grid has no
324+% pole rows and an open phi seam; wrap_grid closes both for display, capping
325+% each pole with the mean of its nearest ring.
326+function h = plot_setup(gx, gy, gz, fields, names)
327+ fig = figure('Name', 'turing-surface', 'Color', 'w');
328+ Xs = wrap_grid(gx);
329+ Ys = wrap_grid(gy);
330+ Zs = wrap_grid(gz);
331+ n = numel(fields);
332+ h.surf = gobjects(1, n);
333+ h.ax = gobjects(1, n);
334+ for k = 1:n
335+ ax = subplot(1, n, k, 'Parent', fig);
336+ h.surf(k) = surf(ax, Xs, Ys, Zs, wrap_grid(fields{k}), 'EdgeColor', 'none');
337+ shading(ax, 'interp');
338+ axis(ax, 'equal');
339+ axis(ax, 'off');
340+ colormap(ax, 'jet');
341+ colorbar(ax);
342+ h.ax(k) = ax;
343+ end
344+ h.names = names;
345+end
346+
347+function plot_update(h, fields, t, k, nsteps)
348+ for i = 1:numel(fields)
349+ C = wrap_grid(fields{i});
350+ set(h.surf(i), 'CData', C);
351+ lo = min(C(:));
352+ hi = max(C(:));
353+ if ~(hi > lo)
354+ hi = lo + 1;
355+ end
356+ caxis(h.ax(i), [lo hi]);
357+ title(h.ax(i), sprintf('%s t = %.3f (step %d/%d)', h.names{i}, t, k, nsteps));
358+ end
359+ drawnow;
360+end
361+
362+function M = wrap_grid(f)
363+ S = sht_tables();
364+ M = reshape(f, S.nphi, S.nlat).';
365+ M = [M, M(:, 1)];
366+ M = [mean(M(1, :)) * ones(1, S.nphi + 1); M; mean(M(end, :)) * ones(1, S.nphi + 1)];
367+end
368+
369+% ---------------------------------------------------------------- results file
370+%
371+% Complex coefficients -> flat float32 [re, im] per (l, m), the layout the
372+% app's reference-file reader expects (docs/ellipsoid-reference-spec.md).
373+function write_coeffs(fname, path, Q)
374+ flat = zeros(2 * numel(Q), 1);
375+ flat(1:2:end) = real(Q);
376+ flat(2:2:end) = imag(Q);
377+ h5create(fname, path, numel(flat), 'Datatype', 'single');
378+ h5write(fname, path, single(flat));
379+end
380+
381+% h5writeatt cannot create a bare group, so the attribute-only groups of the
382+% reference layout are made through the low-level API.
383+function make_group(fname, path)
384+ fid = H5F.open(fname, 'H5F_ACC_RDWR', 'H5P_DEFAULT');
385+ gid = H5G.create(fid, path, 'H5P_DEFAULT', 'H5P_DEFAULT', 'H5P_DEFAULT');
386+ H5G.close(gid);
387+ H5F.close(fid);
388+end
src/geom/geometry.tsmodified+246−140View file
@@ -6,9 +6,13 @@
66 *
77 * function [gx, gy, gz] = shape(theta, phi, <parameters>)
88 *
9- * over the solver's (theta, phi) grid — the same element-wise MATLAB the models
10- * are written in, compiled by the same backend into the same kind of WGSL
11- * kernel. It is evaluated once, on the CPU's behalf, and then *analysed*: the
9+ * over the solver's (theta, phi) grid. Unlike the models it is *not* compiled
10+ * to WGSL: a model's step runs every frame and must lower to a fixed sequence
11+ * of GPU dispatches, but a shape is evaluated exactly once at build time and
12+ * survives only as coefficients. So it runs through numbl's CPU interpreter
13+ * instead, which buys the full MATLAB subset — loops, arrays, reductions,
14+ * `legendre`, seeded randomness via `rng`/`randn` — and f64 evaluation, where
15+ * the step dialect is element-wise f32. The result is then *analysed*: the
1216 * canonical geometry this project carries is the three sets of coefficients
1317 * `X`, `Y`, `Z`, one per Cartesian component of the embedding.
1418 *
@@ -28,20 +32,26 @@
2832 * The unit sphere is the case where `x`, `y`, `z` are pure degree-1 harmonics
2933 * and everything downstream reduces to turing-sphere.
3034 */
35+import { parseMFile, type FunctionStmt } from 'numbl-src/numbl-core/parser/index.ts';
36+import { executeCode } from 'numbl-src/numbl-core/executeCode.ts';
37+import {
38+ RuntimeTensor,
39+ isRuntimeTensor,
40+ type RuntimeValue,
41+} from 'numbl-src/numbl-core/runtime/types.ts';
3142 import { ShtPlan } from '../sht/sht.ts';
32-import type { ShtConfig } from '../sht/layout.ts';
43+import { relayoutCoeffs, type ShtConfig } from '../sht/layout.ts';
3344 import type { DerivPlan } from '../sht/deriv.ts';
45+import type { ImportedCoeffs } from './registry.ts';
3446 import { computeMetric, computeFluxMetric } from './metric.ts';
35-import { HostBuffers, ModelPlan } from '../mgpu/plan.ts';
36-import { CompiledModel, type Binding } from '../mgpu/compile.ts';
37-import { inFunction, inFunctionAsync, inModel } from '../mgpu/errors.ts';
47+import { toolFiles } from '../tools.ts';
48+import { inFunction, inModel, ModelCompileError } from '../mgpu/errors.ts';
3849 import type { ModelParams } from '../mgpu/model.ts';
3950
4051 /** The function a geometry file must define. */
4152 export const SHAPE_FN = 'shape';
4253
4354 export interface GeometryOptions {
44- device: GPUDevice;
4555 /** The solver's transform plan — the grid the shape is evaluated on. */
4656 sht: ShtPlan;
4757 cfg: ShtConfig;
@@ -52,6 +62,13 @@ export interface GeometryOptions {
5262 params: ModelParams;
5363 /** Computes the theta/phi derivatives the inverse metric quantities need. */
5464 deriv: DerivPlan;
65+ /**
66+ * The surface as coefficients instead of a .m. When set, `source` is never
67+ * evaluated: the coefficients are re-indexed onto this plan's band
68+ * (truncation is exact projection, padding is exact) and everything from
69+ * synthesis onward runs unchanged.
70+ */
71+ coeffs?: ImportedCoeffs;
5572 }
5673
5774 export class Geometry {
@@ -85,6 +102,27 @@ export class Geometry {
85102 readonly p2: Float32Array;
86103 readonly q2: Float32Array;
87104 readonly r: Float32Array;
105+ /**
106+ * The same flux weights with the round sphere subtracted off, plus the
107+ * bounded 1/J — what lets a model evaluate lap_g without ever multiplying
108+ * the *whole* flux divergence by r ~ 1/sin^2(theta). Writing p1 = 1 + dp1,
109+ * q2 = 1 + dq2 (p2 is already a pure deviation, zero on the sphere) splits
110+ * the divergence into a round-sphere part, whose cancelling bracket
111+ * sin(theta) dtheta(A) + dphi(B) = -sin^2(theta) lap_s u is known exactly in
112+ * spectral space, and a remainder:
113+ *
114+ * lap_g u = -jinv * lap_s u + r * (sin(theta) dtheta(P') + dphi(Q'))
115+ *
116+ * with P' = dp1*A + p2*B, Q' = p2*A + dq2*B. Only the remainder meets the
117+ * concentrated division, so the polar roundoff gain drops by |P'|/|P|
118+ * instead of applying to the full flux. Subtracting 1 in f64 here is the
119+ * point: on a near-sphere dp1 is the small quantity, and forming it as an
120+ * f32 difference in the .m would lose it. See docs/reduced-transforms.md
121+ * Sec 5 and models/schnakenberg.m.
122+ */
123+ readonly dp1: Float32Array;
124+ readonly dq2: Float32Array;
125+ readonly jinv: Float32Array;
88126 /**
89127 * Preconditioner scale for the implicit solve (docs/reduced-transforms.md
90128 * Sec 10). At high degree the Richardson iteration's per-mode factor is
@@ -120,6 +158,7 @@ export class Geometry {
120158 Vtx: Float32Array; Vty: Float32Array; Vtz: Float32Array;
121159 Vpx: Float32Array; Vpy: Float32Array; Vpz: Float32Array;
122160 p1: Float32Array; p2: Float32Array; q2: Float32Array; r: Float32Array;
161+ dp1: Float32Array; dq2: Float32Array; jinv: Float32Array;
123162 Jhat: number; muMin: number; muMax: number; Jmin: number; Jmax: number;
124163 }) {
125164 this.x = init.x;
@@ -138,6 +177,9 @@ export class Geometry {
138177 this.p2 = init.p2;
139178 this.q2 = init.q2;
140179 this.r = init.r;
180+ this.dp1 = init.dp1;
181+ this.dq2 = init.dq2;
182+ this.jinv = init.jinv;
141183 this.Jhat = init.Jhat;
142184 this.muMin = init.muMin;
143185 this.muMax = init.muMax;
@@ -146,125 +188,110 @@ export class Geometry {
146188 }
147189
148190 /**
149- * Compile the shape file, evaluate it once on the solver grid, and reduce it
150- * to coefficients. Everything here happens at build time — a geometry never
151- * takes part in the timestep — so it reads back through the CPU freely.
191+ * Evaluate the shape file once on the solver grid and reduce it to
192+ * coefficients. Everything here happens at build time — a geometry never
193+ * takes part in the timestep — so the .m runs on the CPU (see
194+ * `evaluateShape`) and only the analysis onward touches the GPU.
152195 */
153196 static async create(opts: GeometryOptions): Promise<Geometry> {
154- const { device, sht, cfg, source, paramNames, params, deriv } = opts;
197+ const { sht, cfg, source, paramNames, params, deriv } = opts;
155198 const npts = cfg.nlat * cfg.nphi;
156- const nlm = sht.nlm;
157-
158- const bindings: Record<string, Binding> = {
159- theta: { kind: 'tensor', shape: [npts, 1] },
160- phi: { kind: 'tensor', shape: [npts, 1] },
161- npts: { kind: 'const', value: npts },
162- };
163- for (const p of paramNames) bindings[p] = { kind: 'param' };
164-
165- const compiled = inModel(() => new CompiledModel(source, bindings, { npts, nlm }));
166- const fn = inFunction(SHAPE_FN, () => compiled.specialize(SHAPE_FN, 3));
167- compiled.finish();
168-
169- const host = new HostBuffers(device);
170- host.ensure('theta', npts);
171- host.ensure('phi', npts);
172-
173- const plan = await inFunctionAsync(SHAPE_FN, () =>
174- // Nothing feeds back: the three outputs are read once and the plan is
175- // thrown away.
176- ModelPlan.create(device, sht, { fn, feedback: [null, null, null] }, host),
177- );
178199
179- try {
200+ // Coefficients first, then back to the grid: what the solver and the
201+ // renderer both see is the band-limited surface, not the raw .m output.
202+ // An imported surface arrives already as coefficients, so its .m stage is
203+ // a re-indexing onto this plan's band rather than an evaluation.
204+ let X: Float32Array;
205+ let Y: Float32Array;
206+ let Z: Float32Array;
207+ if (opts.coeffs) {
208+ const from = { lmax: opts.coeffs.lmax, mmax: opts.coeffs.mmax };
209+ X = relayoutCoeffs(opts.coeffs.X, from, cfg);
210+ Y = relayoutCoeffs(opts.coeffs.Y, from, cfg);
211+ Z = relayoutCoeffs(opts.coeffs.Z, from, cfg);
212+ } else {
180213 const { theta, phi } = gridAngles(sht, cfg);
181- host.upload('theta', theta);
182- host.upload('phi', phi);
183- plan.setParams(params);
184-
185- const enc = device.createCommandEncoder({ label: 'geometry-shape' });
186- plan.encodeSteps(enc, 1);
187- device.queue.submit([enc.finish()]);
188-
189- const raw = await Promise.all(
190- fn.outputs.map((out) => readBuffer(device, plan, out.name, npts)),
191- );
192- // Coefficients first, then back to the grid: what the solver and the
193- // renderer both see is the band-limited surface, not the raw .m output.
194- const [X, Y, Z] = [
214+ const raw = evaluateShape(source, paramNames, params, theta, phi, npts);
215+ [X, Y, Z] = [
195216 await sht.analys(raw[0]),
196217 await sht.analys(raw[1]),
197218 await sht.analys(raw[2]),
198219 ];
199- const [x, y, z] = [
200- await sht.synth(X),
201- await sht.synth(Y),
202- await sht.synth(Z),
203- ];
220+ }
221+ const [x, y, z] = [
222+ await sht.synth(X),
223+ await sht.synth(Y),
224+ await sht.synth(Z),
225+ ];
204226
205- // Inverse metric quantities (algos.tex Algorithm 2): theta/phi
206- // derivatives of the embedding's coefficients, contracted through the
207- // inverse first fundamental form. Depends only on the geometry, so
208- // this is a one-off alongside x,y,z above, not per-step work.
209- const Xt = await deriv.dtheta(X);
210- const Xp = await deriv.dphi(X);
211- const Yt = await deriv.dtheta(Y);
212- const Yp = await deriv.dphi(Y);
213- const Zt = await deriv.dtheta(Z);
214- const Zp = await deriv.dphi(Z);
215- const { Vtx, Vty, Vtz, Vpx, Vpy, Vpz } = computeMetric(npts, Xt, Xp, Yt, Yp, Zt, Zp);
227+ // Inverse metric quantities (algos.tex Algorithm 2): theta/phi
228+ // derivatives of the embedding's coefficients, contracted through the
229+ // inverse first fundamental form. Depends only on the geometry, so
230+ // this is a one-off alongside x,y,z above, not per-step work.
231+ const Xt = await deriv.dtheta(X);
232+ const Xp = await deriv.dphi(X);
233+ const Yt = await deriv.dtheta(Y);
234+ const Yp = await deriv.dphi(Y);
235+ const Zt = await deriv.dtheta(Z);
236+ const Zp = await deriv.dphi(Z);
237+ const { Vtx, Vty, Vtz, Vpx, Vpy, Vpz } = computeMetric(npts, Xt, Xp, Yt, Yp, Zt, Zp);
216238
217- // Flux-form metric weights for the six-transform scheme, built from the
218- // *undivided* theta tangents sin(theta)*X_theta (smooth on the sphere,
219- // unlike X_theta itself) and the same X_phi as above. Also a one-off;
220- // the f64 combination happens on the CPU, rounded to f32 for upload.
221- const sXtx = await deriv.sinDtheta(X);
222- const sXty = await deriv.sinDtheta(Y);
223- const sXtz = await deriv.sinDtheta(Z);
224- const flux = computeFluxMetric(npts, sXtx, sXty, sXtz, Xp, Yp, Zp);
239+ // Flux-form metric weights for the six-transform scheme, built from the
240+ // *undivided* theta tangents sin(theta)*X_theta (smooth on the sphere,
241+ // unlike X_theta itself) and the same X_phi as above. Also a one-off;
242+ // the f64 combination happens on the CPU, rounded to f32 for upload.
243+ const sXtx = await deriv.sinDtheta(X);
244+ const sXty = await deriv.sinDtheta(Y);
245+ const sXtz = await deriv.sinDtheta(Z);
246+ const flux = computeFluxMetric(npts, sXtx, sXty, sXtz, Xp, Yp, Zp);
225247
226- // The preconditioner scale — see the Jhat field comment. The symbol
227- // matrix in the orthonormal frame is S = (1/J)[[p1,p2],[p2,q2]] with
228- // 1/J = r sin^2(theta); its entries are the bounded quantities
229- // g^tt, sin g^tp, sin^2 g^pp, so the eigenvalue extremes are clean to
230- // take over the grid. det S = 1/J^2, so the area factor comes along
231- // for free. f64 throughout.
232- let muMin = Infinity;
233- let muMax = 0;
234- let Jmin = Infinity;
235- let Jmax = 0;
236- for (let i = 0; i < cfg.nlat; i++) {
237- const ct = sht.cosTheta[i];
238- const st2 = Math.max(0, 1 - ct * ct);
239- for (let j = 0; j < cfg.nphi; j++) {
240- const k = i * cfg.nphi + j;
241- const invJ = flux.r[k] * st2;
242- const s11 = flux.p1[k] * invJ;
243- const s12 = flux.p2[k] * invJ;
244- const s22 = flux.q2[k] * invJ;
245- const mean = (s11 + s22) / 2;
246- const disc = Math.sqrt(((s11 - s22) / 2) ** 2 + s12 * s12);
247- if (mean - disc < muMin) muMin = mean - disc;
248- if (mean + disc > muMax) muMax = mean + disc;
249- const J = 1 / invJ;
250- if (J < Jmin) Jmin = J;
251- if (J > Jmax) Jmax = J;
252- }
248+ // The preconditioner scale — see the Jhat field comment. The symbol
249+ // matrix in the orthonormal frame is S = (1/J)[[p1,p2],[p2,q2]] with
250+ // 1/J = r sin^2(theta); its entries are the bounded quantities
251+ // g^tt, sin g^tp, sin^2 g^pp, so the eigenvalue extremes are clean to
252+ // take over the grid. det S = 1/J^2, so the area factor comes along
253+ // for free. f64 throughout.
254+ let muMin = Infinity;
255+ let muMax = 0;
256+ let Jmin = Infinity;
257+ let Jmax = 0;
258+ // The sphere-subtracted weights ride along on this loop: 1/J is already
259+ // being formed here, and dp1/dq2 want the same f64 arithmetic.
260+ const dp1 = new Float32Array(npts);
261+ const dq2 = new Float32Array(npts);
262+ const jinv = new Float32Array(npts);
263+ for (let i = 0; i < cfg.nlat; i++) {
264+ const ct = sht.cosTheta[i];
265+ const st2 = Math.max(0, 1 - ct * ct);
266+ for (let j = 0; j < cfg.nphi; j++) {
267+ const k = i * cfg.nphi + j;
268+ const invJ = flux.r[k] * st2;
269+ dp1[k] = flux.p1[k] - 1;
270+ dq2[k] = flux.q2[k] - 1;
271+ jinv[k] = invJ;
272+ const s11 = flux.p1[k] * invJ;
273+ const s12 = flux.p2[k] * invJ;
274+ const s22 = flux.q2[k] * invJ;
275+ const mean = (s11 + s22) / 2;
276+ const disc = Math.sqrt(((s11 - s22) / 2) ** 2 + s12 * s12);
277+ if (mean - disc < muMin) muMin = mean - disc;
278+ if (mean + disc > muMax) muMax = mean + disc;
279+ const J = 1 / invJ;
280+ if (J < Jmin) Jmin = J;
281+ if (J > Jmax) Jmax = J;
253282 }
254- const Jhat = 2 / (muMin + muMax);
255-
256- return new Geometry({
257- x, y, z, X, Y, Z, Vtx, Vty, Vtz, Vpx, Vpy, Vpz,
258- p1: new Float32Array(flux.p1),
259- p2: new Float32Array(flux.p2),
260- q2: new Float32Array(flux.q2),
261- r: new Float32Array(flux.r),
262- Jhat, muMin, muMax, Jmin, Jmax,
263- });
264- } finally {
265- plan.destroy();
266- host.destroy();
267283 }
284+ const Jhat = 2 / (muMin + muMax);
285+
286+ return new Geometry({
287+ x, y, z, X, Y, Z, Vtx, Vty, Vtz, Vpx, Vpy, Vpz,
288+ p1: new Float32Array(flux.p1),
289+ p2: new Float32Array(flux.p2),
290+ q2: new Float32Array(flux.q2),
291+ r: new Float32Array(flux.r),
292+ dp1, dq2, jinv,
293+ Jhat, muMin, muMax, Jmin, Jmax,
294+ });
268295 }
269296
270297 /**
@@ -300,14 +327,15 @@ export class Geometry {
300327 }
301328 }
302329
303-/** The (theta, phi) of every grid point, flattened phi-fastest as the fields are. */
330+/** The (theta, phi) of every grid point, flattened phi-fastest as the fields
331+ * are — in f64, the precision the shape is evaluated at. */
304332 function gridAngles(
305333 sht: ShtPlan,
306334 cfg: ShtConfig,
307-): { theta: Float32Array; phi: Float32Array } {
335+): { theta: Float64Array; phi: Float64Array } {
308336 const { nlat, nphi } = cfg;
309- const theta = new Float32Array(nlat * nphi);
310- const phi = new Float32Array(nlat * nphi);
337+ const theta = new Float64Array(nlat * nphi);
338+ const phi = new Float64Array(nlat * nphi);
311339 for (let i = 0; i < nlat; i++) {
312340 const th = Math.acos(Math.max(-1, Math.min(1, sht.cosTheta[i])));
313341 for (let j = 0; j < nphi; j++) {
@@ -318,30 +346,108 @@ function gridAngles(
318346 return { theta, phi };
319347 }
320348
321-async function readBuffer(
322- device: GPUDevice,
323- plan: ModelPlan,
349+/**
350+ * Evaluate the shape file on the grid, through numbl's CPU interpreter.
351+ *
352+ * The .m keeps the same contract it had as a compiled model: it names the
353+ * arguments it wants — `theta`, `phi`, and any of the registry's parameters —
354+ * and the host supplies them by name, so their order in the signature is the
355+ * .m's own business. A one-line driver script calls `shape` with exactly the
356+ * arguments its signature declares, with those names pre-bound in the
357+ * driver's workspace.
358+ */
359+function evaluateShape(
360+ source: string,
361+ paramNames: string[],
362+ params: ModelParams,
363+ theta: Float64Array,
364+ phi: Float64Array,
365+ npts: number,
366+): [Float32Array, Float32Array, Float32Array] {
367+ const file = `${SHAPE_FN}.m`;
368+ const ast = inModel(() => parseMFile(source, file));
369+ const fn = ast.body.find(
370+ (s): s is FunctionStmt =>
371+ s.type === 'Function' && (s as FunctionStmt).name === SHAPE_FN,
372+ );
373+ if (!fn) {
374+ throw new ModelCompileError(
375+ `the geometry defines no function named '${SHAPE_FN}'`,
376+ );
377+ }
378+ if (fn.outputs.length !== 3) {
379+ throw new ModelCompileError(
380+ `'${SHAPE_FN}' must return three outputs [gx, gy, gz], not ${fn.outputs.length}`,
381+ { fn: SHAPE_FN, start: fn.span.start, end: fn.span.end },
382+ );
383+ }
384+ const known = new Set(['theta', 'phi', ...paramNames]);
385+ for (const p of fn.params) {
386+ if (!known.has(p)) {
387+ throw new ModelCompileError(
388+ `'${SHAPE_FN}' takes an argument '${p}' that is neither the grid ` +
389+ `(theta, phi) nor one of this geometry's parameters` +
390+ (paramNames.length ? ` (${paramNames.join(', ')})` : ''),
391+ { fn: SHAPE_FN, start: fn.span.start, end: fn.span.end },
392+ );
393+ }
394+ }
395+
396+ const vars: Record<string, RuntimeValue> = {
397+ theta: new RuntimeTensor(theta, [npts, 1]),
398+ phi: new RuntimeTensor(phi, [npts, 1]),
399+ };
400+ for (const name of paramNames) {
401+ const v = params[name];
402+ // Missing parameters read as 0, as ModelPlan.setParams has it.
403+ vars[name] = Number.isFinite(v) ? v : 0;
404+ }
405+
406+ const driver = `[gx__, gy__, gz__] = ${SHAPE_FN}(${fn.params.join(', ')});`;
407+ const result = inFunction(SHAPE_FN, () =>
408+ executeCode(
409+ driver,
410+ { initialVariableValues: vars, displayResults: false, implicitCwdPath: null },
411+ [...toolFiles, { name: file, source }],
412+ 'geometry-driver.m',
413+ ),
414+ );
415+
416+ return [
417+ toGridField(result.variableValues['gx__'], fn.outputs[0], npts),
418+ toGridField(result.variableValues['gy__'], fn.outputs[1], npts),
419+ toGridField(result.variableValues['gz__'], fn.outputs[2], npts),
420+ ];
421+}
422+
423+/** One returned coordinate → npts values, rounded to the transforms' f32. */
424+function toGridField(
425+ value: RuntimeValue | undefined,
324426 name: string,
325- count: number,
326-): Promise<Float32Array> {
327- const buffer = plan.buffer(name);
328- if (!buffer) {
329- throw new Error(`the geometry never assigns '${name}'`);
427+ npts: number,
428+): Float32Array {
429+ // A constant coordinate stays scalar in MATLAB; spread it over the grid.
430+ if (typeof value === 'number') return new Float32Array(npts).fill(value);
431+ if (value !== undefined && isRuntimeTensor(value)) {
432+ if (value.imag) {
433+ throw new ModelCompileError(
434+ `the geometry's '${name}' is complex; coordinates must be real`,
435+ { fn: SHAPE_FN },
436+ );
437+ }
438+ // A vector of npts values, either orientation. A 2-D reshape is refused
439+ // rather than reordered: the tensor's column-major layout would not match
440+ // the grid's phi-fastest rows.
441+ if (value.data.length === npts && value.shape.every((d) => d === 1 || d === npts)) {
442+ return new Float32Array(value.data);
443+ }
444+ throw new ModelCompileError(
445+ `the geometry's '${name}' is ${value.shape.join(' x ')}, but the grid ` +
446+ `wants one value per point (${npts} x 1)`,
447+ { fn: SHAPE_FN },
448+ );
330449 }
331- const staging = device.createBuffer({
332- label: `geometry-read-${name}`,
333- size: 4 * count,
334- usage: GPUBufferUsage.MAP_READ | GPUBufferUsage.COPY_DST,
450+ throw new ModelCompileError(`the geometry's '${name}' is not numeric`, {
451+ fn: SHAPE_FN,
335452 });
336- try {
337- const enc = device.createCommandEncoder({ label: `geometry-read-${name}` });
338- enc.copyBufferToBuffer(buffer, 0, staging, 0, 4 * count);
339- device.queue.submit([enc.finish()]);
340- await staging.mapAsync(GPUMapMode.READ);
341- const out = new Float32Array(staging.getMappedRange().slice(0));
342- staging.unmap();
343- return out;
344- } finally {
345- staging.destroy();
346- }
347453 }
src/geom/registry.tsmodified+60−1View file
@@ -13,6 +13,7 @@ import sphereSource from '../../geometries/sphere.m?raw';
1313 import ellipsoidSource from '../../geometries/ellipsoid.m?raw';
1414 import peanutSource from '../../geometries/peanut.m?raw';
1515 import bumpySource from '../../geometries/bumpy.m?raw';
16+import blobSource from '../../geometries/blob.m?raw';
1617 import type { ParamSpec, Params } from '../mgpu/registry.ts';
1718
1819 export interface MGeometry {
@@ -22,6 +23,23 @@ export interface MGeometry {
2223 params: ParamSpec[];
2324 /** MATLAB source — the shape itself. */
2425 source: string;
26+ /**
27+ * When set, the shape IS these spherical-harmonic coefficients and `source`
28+ * is a display-only stub: Geometry.create re-indexes them onto the session's
29+ * band instead of evaluating any .m. How a surface handed over by another
30+ * app (reharm's "Export to turing-surface") enters the pipeline.
31+ */
32+ coeffs?: ImportedCoeffs;
33+}
34+
35+/** A surface as coefficients of x, y, z — the same three arrays a reference
36+ * file's /geometry group holds, in the shared SHTNS layout (src/sht/layout.ts). */
37+export interface ImportedCoeffs {
38+ lmax: number;
39+ mmax: number;
40+ X: Float32Array;
41+ Y: Float32Array;
42+ Z: Float32Array;
2543 }
2644
2745 const sphere: MGeometry = {
@@ -67,7 +85,19 @@ const bumpy: MGeometry = {
6785 source: bumpySource,
6886 };
6987
70-export const mGeometries: MGeometry[] = [sphere, ellipsoid, peanut, bumpy];
88+const blob: MGeometry = {
89+ key: 'blob',
90+ label: 'Blob',
91+ blurb: 'The sphere warped by a smooth random function — a fresh shape per seed.',
92+ params: [
93+ { key: 'amp', label: 'amp', value: 0.5, min: 0, max: 0.8, step: 0.05 },
94+ { key: 'scale', label: 'λ', value: 1, min: 0.5, max: 3, step: 0.1 },
95+ { key: 'seed', label: 'seed', value: 1, min: 0, max: 9999, step: 1, reseed: true },
96+ ],
97+ source: blobSource,
98+};
99+
100+export const mGeometries: MGeometry[] = [sphere, ellipsoid, peanut, bumpy, blob];
71101
72102 export const mGeometryByKey = (key: string): MGeometry | undefined =>
73103 mGeometries.find((g) => g.key === key);
@@ -79,6 +109,35 @@ export const defaultGeometryParams = (g: MGeometry): Params =>
79109 * none gets: the case where the solver is exact. */
80110 export const SPHERE_KEY = 'sphere';
81111
112+/** The dropdown key an imported surface lives under. Not in `mGeometries`:
113+ * the entry exists only after an import arrives, and the page owns it. */
114+export const IMPORTED_GEOMETRY_KEY = 'imported';
115+
116+/**
117+ * Wrap a handed-over surface as a geometry the rest of the app can hold like
118+ * any registry entry. No parameters — the shape is whatever the coefficients
119+ * say — and the .m slot carries a note rather than code, since the import
120+ * path in Geometry.create never evaluates it.
121+ */
122+export function makeImportedGeometry(
123+ name: string,
124+ blurb: string,
125+ coeffs: ImportedCoeffs,
126+): MGeometry {
127+ return {
128+ key: IMPORTED_GEOMETRY_KEY,
129+ label: name,
130+ blurb,
131+ params: [],
132+ source:
133+ `% ${name}\n` +
134+ `% This surface was handed over as spherical-harmonic coefficients\n` +
135+ `% (lmax ${coeffs.lmax}), so there is no .m to edit: the solver uses the\n` +
136+ `% coefficients directly, and edits here have no effect.\n`,
137+ coeffs,
138+ };
139+}
140+
82141 /** What the app and the benchmark start on. Not the sphere: this project
83142 * exists for the other shapes, and opening on the reference case would hide
84143 * the one thing it adds. */
src/main.tsmodified+720−51View file
@@ -1,5 +1,5 @@
11 import { requestShtDevice, describeAdapter } from './sht/sht.ts';
2-import { gridForLmax } from './sht/layout.ts';
2+import { gridForLmax, nlmCalc } from './sht/layout.ts';
33 import { ModelSession } from './mgpu/session.ts';
44 import { mModelByKey, presets, type MModel, type Params } from './mgpu/registry.ts';
55 import { ModelCompileError, formatFailure } from './mgpu/errors.ts';
@@ -17,8 +17,11 @@ import {
1717 mGeometries,
1818 mGeometryByKey,
1919 defaultGeometryParams,
20- SPHERE_KEY,
2120 DEFAULT_GEOMETRY_KEY,
21+ SPHERE_KEY,
22+ IMPORTED_GEOMETRY_KEY,
23+ makeImportedGeometry,
24+ type ImportedCoeffs,
2225 type MGeometry,
2326 } from './geom/registry.ts';
2427 import {
@@ -40,6 +43,9 @@ import {
4043 variantLabel,
4144 type Variant,
4245 } from './compare/variants.ts';
46+import { loadReferenceFile } from './compare/referenceFile.ts';
47+import type { ReferenceCase } from './compare/referenceCase.ts';
48+import { generateMatlabScript, MATLAB_SCRIPT_NAME } from './export/matlabScript.ts';
4349
4450 const $ = <T extends HTMLElement>(id: string): T =>
4551 document.getElementById(id) as T;
@@ -52,8 +58,10 @@ const elLmax = $<HTMLSelectElement>('lmax');
5258 const elOversample = $<HTMLSelectElement>('oversample');
5359 const elColormap = $<HTMLSelectElement>('colormap');
5460 const elRunPause = $<HTMLButtonElement>('runpause');
61+const elRestart = $<HTMLButtonElement>('restart');
5562 const elBenchmark = $<HTMLButtonElement>('benchmark');
5663 const elReseed = $<HTMLButtonElement>('reseed');
64+const elLam3 = $<HTMLInputElement>('lam3');
5765 const elResetView = $<HTMLButtonElement>('resetview');
5866 const elMovieToggle = $<HTMLButtonElement>('movietoggle');
5967 const elMovieBar = $('moviebar');
@@ -61,22 +69,36 @@ const elMovieSpeed = $<HTMLSelectElement>('moviespeed');
6169 const elMovieRes = $<HTMLSelectElement>('movieres');
6270 const elMovieRotate = $<HTMLInputElement>('movierotate');
6371 const elMovie = $<HTMLButtonElement>('movie');
64-const elCompareToggle = $<HTMLButtonElement>('comparetoggle');
72+const elModeSimulate = $<HTMLButtonElement>('mode-simulate');
73+const elModeEffort = $<HTMLButtonElement>('mode-effort');
74+const elModeVsSphere = $<HTMLButtonElement>('mode-vs-sphere');
75+const elModeVsUpload = $<HTMLButtonElement>('mode-vs-upload');
76+const elModeDesc = $('mode-desc');
6577 const elCompareBar = $('comparebar');
78+const elCmpAxes = $('cmp-axes');
6679 const elCmpNiter = $('cmp-niter');
6780 const elCmpLmax = $('cmp-lmax');
6881 const elCmpDt = $('cmp-dt');
82+const elCmpRefLabel = $('cmp-reflabel');
6983 const elCmpRef = $<HTMLSelectElement>('cmp-ref');
84+const elCmpFile = $<HTMLInputElement>('cmp-file');
85+const elCmpFileInfo = $('cmp-fileinfo');
86+const elCmpFileClear = $<HTMLButtonElement>('cmp-fileclear');
7087 const elCmpStart = $<HTMLButtonElement>('cmp-start');
7188 const elCmpCount = $('cmp-count');
7289 const elParams = $('params');
7390 const elGeomParams = $('geomparams');
7491 const elGeomNote = $('geomnote');
7592 const elPanels = $('panels');
93+const elCmpChart = $('cmp-chart');
7694 const elStats = $('stats');
7795 const elBenchResult = $('benchresult');
7896 const elCmd = $('cmd');
7997 const elCopyCmd = $<HTMLButtonElement>('copycmd');
98+const elMatlab = $<HTMLDetailsElement>('matlab');
99+const elMatlabScript = $('matlabscript');
100+const elCopyMatlab = $<HTMLButtonElement>('copymatlab');
101+const elDownloadMatlab = $<HTMLButtonElement>('downloadmatlab');
80102 const elBlurb = $('blurb');
81103 const elErr = $('err');
82104 const elSource = $<HTMLTextAreaElement>('source');
@@ -87,6 +109,21 @@ const elEditorFile = $<HTMLSelectElement>('editor-file');
87109 const elRecompile = $<HTMLButtonElement>('recompile');
88110 const elRevert = $<HTMLButtonElement>('revert');
89111
112+/** The named groups the control area is organized into (index.html's
113+ * `.ctrl-group[data-group]` wrappers). Each mode shows a declared subset of
114+ * these — see MODE_GROUPS and applyModeVisibility below. */
115+const GROUP_NAMES = [
116+ 'surface', 'surface-params', 'solver', 'display',
117+ 'playback', 'benchmark', 'seed', 'movie',
118+] as const;
119+type GroupName = (typeof GROUP_NAMES)[number];
120+const groupEls: Record<GroupName, HTMLElement> = Object.fromEntries(
121+ GROUP_NAMES.map((name) => [
122+ name,
123+ document.querySelector(`.ctrl-group[data-group="${name}"]`) as HTMLElement,
124+ ]),
125+) as Record<GroupName, HTMLElement>;
126+
90127 for (const p of presets) {
91128 const o = document.createElement('option');
92129 o.value = p.key;
@@ -254,6 +291,14 @@ let posBuf: Float32Array | null = null;
254291 * mode. While it is non-null there is no `session`: the study owns one per
255292 * variant, and the panels area is its grid. */
256293 let compareRun: CompareRun | null = null;
294+/** The surface handed over by another app (reharm), when one has arrived —
295+ * what the geometry dropdown's 'imported' entry resolves to. */
296+let importedGeometry: MGeometry | null = null;
297+/** `session`'s spectral state as of the last (re-)seed — what "Restart"
298+ * rewinds to. Captured fresh each time a new field is actually established
299+ * (rebuild/reseed), not just once, so Restart reflects the run's current
300+ * starting point rather than permanently the very first draw. */
301+let initialState: Record<string, Float32Array> | null = null;
257302
258303 const source = (): string => editedSource ?? model.source;
259304 const geomSource = (): string => editedGeomSource ?? geometry.source;
@@ -261,6 +306,10 @@ const geomSource = (): string => editedGeomSource ?? geometry.source;
261306 // ---------------------------------------------------------------- UI wiring
262307 function buildParamInputs(): void {
263308 elParams.replaceChildren();
309+ if (model.params.length === 0) return;
310+ const tag = document.createElement('label');
311+ tag.textContent = 'model parameters';
312+ elParams.append(tag);
264313 for (const spec of model.params) {
265314 const label = document.createElement('label');
266315 label.textContent = `${spec.label} `;
@@ -296,9 +345,32 @@ function buildGeomParamInputs(): void {
296345 elGeomParams.replaceChildren();
297346 if (geometry.params.length === 0) return;
298347 const tag = document.createElement('label');
299- tag.textContent = `${geometry.key}.m`;
348+ tag.textContent = 'geometry parameters';
300349 elGeomParams.append(tag);
301350 for (const spec of geometry.params) {
351+ // A random seed picks a draw and means nothing on its own, so it gets a
352+ // button to the next one rather than a box to type a number into. The
353+ // shape changes; the simulation running on it does not restart.
354+ if (spec.reseed) {
355+ const button = document.createElement('button');
356+ button.textContent = 'Re-seed shape';
357+ button.title =
358+ `Draw another ${geometry.label.toLowerCase()} — a new random surface, ` +
359+ `leaving the pattern running on it alone.`;
360+ button.addEventListener('click', () => {
361+ const span = spec.max - spec.min;
362+ let next = geomParams[spec.key];
363+ // Never hand back the shape that is already on screen.
364+ while (next === geomParams[spec.key]) {
365+ next = spec.min + Math.floor(Math.random() * (span + 1));
366+ }
367+ geomParams[spec.key] = next;
368+ updateCommand();
369+ viewChange = viewChange.then(() => applyGeometry());
370+ });
371+ elGeomParams.append(button);
372+ continue;
373+ }
302374 const label = document.createElement('label');
303375 label.textContent = `${spec.label} `;
304376 const input = document.createElement('input');
@@ -310,6 +382,7 @@ function buildGeomParamInputs(): void {
310382 input.addEventListener('change', () => {
311383 const v = Number(input.value);
312384 if (Number.isFinite(v)) geomParams[spec.key] = v;
385+ updateCommand();
313386 viewChange = viewChange.then(() => applyGeometry());
314387 });
315388 label.append(input);
@@ -334,7 +407,10 @@ function applyPreset(presetKey: string): void {
334407 }
335408
336409 function applyGeometryChoice(key: string): void {
337- const next = mGeometryByKey(key);
410+ // The imported entry is the page's own, not the registry's — it exists only
411+ // after a surface has been handed over (see the reharm import block below).
412+ const next =
413+ key === IMPORTED_GEOMETRY_KEY ? importedGeometry : mGeometryByKey(key);
338414 if (!next) {
339415 elErr.textContent = `No .m geometry for '${key}'`;
340416 return;
@@ -344,6 +420,8 @@ function applyGeometryChoice(key: string): void {
344420 editedGeomSource = null;
345421 buildGeomParamInputs();
346422 showEditorFile();
423+ // The command line and the MATLAB export both bake the surface in.
424+ updateCommand();
347425 }
348426
349427 /** Load the chosen file into the editor, keeping any unsaved edit to it. */
@@ -381,7 +459,58 @@ function currentSpec(): RunSpec {
381459 }
382460
383461 function updateCommand(): void {
384- elCmd.textContent = formatCommand(currentSpec());
462+ // A study against a reference file replays the file, so its desktop
463+ // equivalent is the ref checker, not the benchmark.
464+ if (compareRun?.refFile) {
465+ elCmd.textContent = `npm run ref -- --in ${compareRun.refFile.label}`;
466+ } else if (geometry.coeffs) {
467+ // An imported surface exists only as coefficients in this page, so no
468+ // command line can regenerate the run.
469+ elCmd.textContent = '(imported surface — no desktop equivalent)';
470+ } else {
471+ elCmd.textContent = formatCommand(currentSpec());
472+ }
473+ if (elMatlab.open) refreshMatlabScript();
474+}
475+
476+/** The run on screen as one standalone .m — in a study, its reference
477+ * variant, the same choice `currentSpec` makes. Throws on a working copy
478+ * the export cannot parse (no init/step/shape function). */
479+function matlabScriptText(): string {
480+ if (geometry.coeffs) {
481+ throw new Error(
482+ 'The imported surface has no .m, so the MATLAB export cannot bake it in. ' +
483+ 'Export the surface from reharm as .h5 and load its /geometry ' +
484+ 'coefficients with h5read instead.',
485+ );
486+ }
487+ const spec = currentSpec();
488+ return generateMatlabScript({
489+ model,
490+ modelSource: source(),
491+ params: spec.params,
492+ geometry,
493+ geometrySource: geomSource(),
494+ geometryParams: geomParams,
495+ lmax: spec.lmax,
496+ niter: spec.niter,
497+ lam3: Number(elLam3.value),
498+ seed,
499+ preset: spec.preset,
500+ command: formatCommand(spec),
501+ });
502+}
503+
504+/** Regenerate the visible script text; on failure, show why in its place. */
505+function refreshMatlabScript(): string | null {
506+ try {
507+ const text = matlabScriptText();
508+ elMatlabScript.textContent = text;
509+ return text;
510+ } catch (e) {
511+ elMatlabScript.textContent = e instanceof Error ? e.message : String(e);
512+ return null;
513+ }
385514 }
386515
387516 elModel.addEventListener('change', () => {
@@ -407,6 +536,40 @@ elGeometry.addEventListener('change', () => {
407536 applyGeometryChoice(elGeometry.value);
408537 viewChange = viewChange.then(() => applyGeometry());
409538 });
539+// The seed field's wavelength: a uniform plus a host-side redraw, so it
540+// reseeds the run in place rather than recompiling it. Too small a value asks
541+// for more Fourier modes than the table holds, which `drawModes` refuses —
542+// report that like any other failure instead of leaving the run half-seeded.
543+elLam3.addEventListener('change', () => {
544+ const v = Number(elLam3.value);
545+ if (!Number.isFinite(v) || v <= 0) return;
546+ // Not in the bench command, but the MATLAB export bakes lam3 in.
547+ updateCommand();
548+ // Changing the wavelength redraws the field, which restarts the run — so
549+ // pause first, exactly as the Re-seed button does. Without it the reseed's
550+ // readback races the pump's own, and the two collide on the staging buffer.
551+ setRunning(false);
552+ viewChange = viewChange.then(async () => {
553+ // A study seeds every variant from one field at one wavelength, so this is
554+ // the same control there — set on each variant, redrawn by the one reseed.
555+ const target = compareRun ?? session;
556+ if (!target) return;
557+ const previous = target.lam3;
558+ try {
559+ target.setLam3(v);
560+ await reseed();
561+ elErr.textContent = '';
562+ } catch (e) {
563+ // Too fine a wavelength asks for more Fourier modes than the table
564+ // holds. Put the working value back rather than leaving the run seeded
565+ // from a field that was never drawn.
566+ elErr.textContent = e instanceof Error ? e.message : String(e);
567+ target.setLam3(previous);
568+ elLam3.value = String(previous);
569+ await reseed();
570+ }
571+ });
572+});
410573 // Morph is pure rendering: no readback, no GPU work, just the vertex buffer.
411574 elMorph.addEventListener('input', () => {
412575 morph = Number(elMorph.value);
@@ -437,6 +600,10 @@ elReseed.addEventListener('click', () => {
437600 updateCommand();
438601 void reseed();
439602 });
603+elRestart.addEventListener('click', () => {
604+ setRunning(false);
605+ void restart();
606+});
440607 elResetView.addEventListener('click', () => {
441608 compareRun?.resetView();
442609 for (const s of scenes) s.resetCamera();
@@ -481,6 +648,46 @@ elCopyCmd.addEventListener('click', () => {
481648 navigator.clipboard.writeText(text).then(() => flash('Copied'), selectCommand);
482649 });
483650
651+// The MATLAB export: the same run as one self-contained .m. Regenerated from
652+// the current UI state whenever it is shown, copied or downloaded, so the
653+// text always matches the run on screen.
654+elMatlab.addEventListener('toggle', () => {
655+ if (elMatlab.open) refreshMatlabScript();
656+});
657+elCopyMatlab.addEventListener('click', (e) => {
658+ // The buttons live inside the <summary>; without this a click also toggles.
659+ e.preventDefault();
660+ e.stopPropagation();
661+ const text = refreshMatlabScript();
662+ if (text === null || !navigator.clipboard) {
663+ // Open to show the error, or the text to select by hand.
664+ elMatlab.open = true;
665+ return;
666+ }
667+ navigator.clipboard.writeText(text).then(
668+ () => {
669+ elCopyMatlab.textContent = 'Copied';
670+ setTimeout(() => (elCopyMatlab.textContent = 'Copy'), 1200);
671+ },
672+ () => (elMatlab.open = true),
673+ );
674+});
675+elDownloadMatlab.addEventListener('click', (e) => {
676+ e.preventDefault();
677+ e.stopPropagation();
678+ const text = refreshMatlabScript();
679+ if (text === null) {
680+ elMatlab.open = true;
681+ return;
682+ }
683+ const url = URL.createObjectURL(new Blob([text], { type: 'text/x-matlab' }));
684+ const a = document.createElement('a');
685+ a.href = url;
686+ a.download = `${MATLAB_SCRIPT_NAME}.m`;
687+ a.click();
688+ URL.revokeObjectURL(url);
689+});
690+
484691 // ---------------------------------------------------------------- setup
485692 function disposeView(): void {
486693 for (const s of scenes) s.dispose();
@@ -629,7 +836,7 @@ function applyMorph(): void {
629836 for (const s of scenes) s.updatePositions(posBuf);
630837 }
631838
632-/** What the surface is, and the standing caveat about where it is not. */
839+/** What the surface is, and how far it departs from the sphere. */
633840 function updateGeomNote(): void {
634841 // In compare mode each variant carries the surface band-limited at its own
635842 // lmax; the reference's is the one quoted, as everywhere else.
@@ -639,11 +846,9 @@ function updateGeomNote(): void {
639846 return;
640847 }
641848 const { lo, hi } = s.geometry.radiusRange();
642- const isSphere = s.geometryModel.key === SPHERE_KEY;
643849 elGeomNote.innerHTML =
644850 `<b>${s.geometryModel.label}</b> — ${s.geometryModel.blurb} ` +
645- `Radius ${lo.toFixed(3)}–${hi.toFixed(3)}.` +
646- (isSphere ? '' : ' <b>Rendered only</b> — not yet in the operator.');
851+ `Radius ${lo.toFixed(3)}–${hi.toFixed(3)}.`;
647852 }
648853
649854 /** Report a compile failure, and select the offending text in the editor. */
@@ -690,6 +895,7 @@ async function rebuild(): Promise<void> {
690895 geometryParams: geomParams,
691896 geometrySource: geomSource(),
692897 niter: Number(elNiter.value),
898+ lam3: Number(elLam3.value),
693899 });
694900 } catch (e) {
695901 reportCompileError(e);
@@ -697,7 +903,10 @@ async function rebuild(): Promise<void> {
697903 }
698904 if (gen !== generation) return;
699905
700- session.seed(seed);
906+ await session.seed(seed);
907+ if (gen !== generation) return;
908+ initialState = await session.readState();
909+ if (gen !== generation) return;
701910
702911 const plan = session.describe();
703912 elCompiled.textContent =
@@ -730,8 +939,26 @@ async function reseed(): Promise<void> {
730939 if (compareRun) return compareRun.reseed(seed);
731940 if (!session) return;
732941 const gen = generation;
733- session.seed(seed);
942+ await session.seed(seed);
734943 if (gen !== generation) return;
944+ initialState = await session.readState();
945+ if (gen !== generation) return;
946+ for (const r of ranges) {
947+ r.lo = NaN;
948+ r.hi = NaN;
949+ }
950+ await draw();
951+ updateStats();
952+}
953+
954+/** Rewind to the field this run is currently starting from — the last
955+ * (re-)seed, not necessarily the very first one — without drawing a new
956+ * one. Unlike reseed(), the seed value and lam3 are untouched, so nothing
957+ * the CLI command line encodes changes. */
958+async function restart(): Promise<void> {
959+ if (compareRun) return compareRun.restart();
960+ if (!session || !initialState) return;
961+ session.loadState(initialState);
735962 for (const r of ranges) {
736963 r.lo = NaN;
737964 r.hi = NaN;
@@ -933,7 +1160,7 @@ function submitSteps(n: number): void {
9331160 function setMovieUi(on: boolean): void {
9341161 const locked = [
9351162 elModel, elGeometry, elMorph, elNiter, elLmax, elOversample, elColormap,
936- elRunPause, elBenchmark, elReseed, elRecompile, elRevert, elEditorFile,
1163+ elRunPause, elRestart, elBenchmark, elReseed, elRecompile, elRevert, elEditorFile,
9371164 elMovieSpeed, elMovieRes, elMovieRotate, elMovieToggle,
9381165 ];
9391166 for (const el of locked) el.disabled = on;
@@ -1014,7 +1241,7 @@ async function recordMovie(): Promise<void> {
10141241 try {
10151242 // Reset the color-range smoothing as a re-seed does, so the shading
10161243 // evolves in the movie the way it did live.
1017- session.seed(seed);
1244+ await session.seed(seed);
10181245 seeded = true;
10191246 for (const r of ranges) {
10201247 r.lo = NaN;
@@ -1150,6 +1377,15 @@ function buildChips(host: HTMLElement, values: number[], selected: Set<number>,
11501377 const cmpVariants = (): Variant[] =>
11511378 crossProduct([...cmpSelected.niter], [...cmpSelected.lmax], [...cmpSelected.dt]);
11521379
1380+/**
1381+ * A loaded reference file, or null. While one is loaded the study checks the
1382+ * variants against it instead of against each other: the file defines the
1383+ * whole problem (model, parameters, geometry, initial state, end time), so
1384+ * the page's own model and geometry choices do not enter the study at all —
1385+ * only the solver knobs above do.
1386+ */
1387+let refCase: ReferenceCase | null = null;
1388+
11531389 /** The reference the user picked, clamped to the current variant list. */
11541390 let cmpRefKey = '';
11551391
@@ -1160,21 +1396,47 @@ function compareRefIndex(): number {
11601396 }
11611397
11621398 function refreshVariants(): void {
1399+ // vs-sphere has no chip grid feeding cmpVariants() — always exactly two
1400+ // fixed rows plus one diff row, regardless of MAX_VARIANTS/MAX_PANELS —
1401+ // so the grid-count logic below doesn't apply here at all.
1402+ if (currentMode === 'vs-sphere') {
1403+ elCmpCount.textContent = `2 rows × ${model.species.length} species + 1 diff row`;
1404+ elCmpCount.style.color = '';
1405+ elCmpStart.disabled = false;
1406+ return;
1407+ }
11631408 const variants = cmpVariants();
11641409 const showDt = cmpSelected.dt.size > 1;
1165- const panels = variants.length * model.species.length;
1410+ // With a file loaded the study's model is the file's, and its final state
1411+ // is one more row of panels.
1412+ const cmpModel = refCase?.model ?? model;
1413+ const rowCount = variants.length + (refCase ? 1 : 0);
1414+ // Every row but the reference variant (or, against a file, every variant)
1415+ // gets a second diff row underneath it — each one more WebGL context per
1416+ // species, so MAX_PANELS has to bound the real total, not just the values.
1417+ const diffRowCount = refCase ? variants.length : Math.max(0, variants.length - 1);
1418+ const panels = (rowCount + diffRowCount) * cmpModel.species.length;
11661419
11671420 const prev = cmpRefKey;
11681421 elCmpRef.replaceChildren();
1169- for (const v of variants) {
1422+ if (refCase) {
1423+ // The file is the reference; the pick among variants means nothing here.
11701424 const o = document.createElement('option');
1171- o.value = variantKey(v);
1172- o.textContent = variantLabel(v, showDt);
1425+ o.textContent = `the file's final state`;
11731426 elCmpRef.append(o);
1427+ elCmpRef.disabled = true;
1428+ } else {
1429+ elCmpRef.disabled = false;
1430+ for (const v of variants) {
1431+ const o = document.createElement('option');
1432+ o.value = variantKey(v);
1433+ o.textContent = variantLabel(v, showDt);
1434+ elCmpRef.append(o);
1435+ }
1436+ const keys = variants.map(variantKey);
1437+ cmpRefKey = keys.includes(prev) ? prev : keys[mostResolved(variants)];
1438+ elCmpRef.value = cmpRefKey;
11741439 }
1175- const keys = variants.map(variantKey);
1176- cmpRefKey = keys.includes(prev) ? prev : keys[mostResolved(variants)];
1177- elCmpRef.value = cmpRefKey;
11781440
11791441 const tooMany =
11801442 variants.length > MAX_VARIANTS
@@ -1184,23 +1446,74 @@ function refreshVariants(): void {
11841446 : '';
11851447 elCmpCount.textContent = tooMany
11861448 ? `too many: ${tooMany}`
1187- : `${variants.length} variants × ${model.species.length} species = ${panels} panels`;
1449+ : `${variants.length} variant${variants.length === 1 ? '' : 's'}` +
1450+ `${refCase ? ' + the file' : ''} × ` +
1451+ `${cmpModel.species.length} species` +
1452+ (diffRowCount > 0 ? ` + ${diffRowCount} diff row${diffRowCount === 1 ? '' : 's'}` : '') +
1453+ ` = ${panels} panels`;
11881454 elCmpCount.style.color = tooMany ? '#b35900' : '';
11891455 elCmpStart.disabled = tooMany !== '' && compareRun === null;
11901456 }
11911457
1192-buildChips(
1193- elCmpNiter,
1194- [...elNiter.options].map((o) => Number(o.value)),
1195- cmpSelected.niter,
1196- String,
1197-);
1198-buildChips(
1199- elCmpLmax,
1200- [...elLmax.options].map((o) => Number(o.value)),
1201- cmpSelected.lmax,
1202- String,
1203-);
1458+/**
1459+ * The niter chips on offer. A loaded reference file adds its own recorded
1460+ * iteration count if the standard list lacks it, so the file's settings are
1461+ * always selectable; clearing the file drops any selection outside the
1462+ * standard list again.
1463+ */
1464+function rebuildNiterChips(): void {
1465+ const all = [...elNiter.options].map((o) => Number(o.value));
1466+ let values = all;
1467+ if (refCase && !all.includes(refCase.niter)) {
1468+ values = [...all, refCase.niter].sort((a, b) => a - b);
1469+ }
1470+ if (!refCase) {
1471+ for (const v of [...cmpSelected.niter]) if (!values.includes(v)) cmpSelected.niter.delete(v);
1472+ if (cmpSelected.niter.size === 0) cmpSelected.niter.add(DEFAULT_NITER);
1473+ }
1474+ buildChips(elCmpNiter, values, cmpSelected.niter, String);
1475+}
1476+
1477+/**
1478+ * The lmax chips on offer. A loaded reference file floors them at its own
1479+ * band: a variant below it could not even hold the file's initial state
1480+ * (prolongation only widens), so those values are not offered rather than
1481+ * offered and refused.
1482+ */
1483+function rebuildLmaxChips(): void {
1484+ const all = [...elLmax.options].map((o) => Number(o.value));
1485+ let values = all;
1486+ if (refCase) {
1487+ const floor = refCase.lmax;
1488+ values = all.filter((v) => v >= floor);
1489+ if (!values.includes(floor)) values = [floor, ...values];
1490+ for (const v of [...cmpSelected.lmax]) if (!values.includes(v)) cmpSelected.lmax.delete(v);
1491+ if (cmpSelected.lmax.size === 0) cmpSelected.lmax.add(floor);
1492+ }
1493+ buildChips(elCmpLmax, values, cmpSelected.lmax, String);
1494+}
1495+
1496+/**
1497+ * The four top-level modes and which control groups each shows (see
1498+ * GROUP_NAMES/groupEls above; `.ctrl-group` wrappers in index.html).
1499+ * `currentMode` tracks which configuration is on screen — the compare bar
1500+ * being open, and in which flavor — not whether a study has actually been
1501+ * started inside it. That match matters: without it, opening the bar
1502+ * (which already shows the right groups) leaves its top-row button
1503+ * unhighlighted until a study happens to start, which is inconsistent with
1504+ * `vs-upload`'s one-click flow and reads as broken.
1505+ *
1506+ * Declared here, ahead of the top-level `refreshVariants()` call just below
1507+ * — that call reads `currentMode` (to know whether to skip its chip-grid
1508+ * counting for vs-sphere), so the declaration has to be in scope by the time
1509+ * this file's top-level code actually runs, not merely by the time
1510+ * `refreshVariants` is later invoked from an event handler.
1511+ */
1512+type Mode = 'simulate' | 'compute-effort' | 'vs-sphere' | 'vs-upload';
1513+let currentMode: Mode = 'simulate';
1514+
1515+rebuildNiterChips();
1516+rebuildLmaxChips();
12041517 buildChips(elCmpDt, DT_DIVISORS, cmpSelected.dt, (v) => (v === 1 ? 'dt' : `dt/${v}`));
12051518 refreshVariants();
12061519
@@ -1209,8 +1522,174 @@ elCmpRef.addEventListener('change', () => {
12091522 if (compareRun) void rebuildCompare();
12101523 });
12111524
1212-elCompareToggle.addEventListener('click', () => {
1213- elCompareBar.hidden = !elCompareBar.hidden;
1525+/** Reflect the loaded (or cleared) reference file in the compare bar. */
1526+function applyRefUi(): void {
1527+ elCmpFileInfo.hidden = elCmpFileClear.hidden = refCase === null;
1528+ if (refCase) {
1529+ const rc = refCase;
1530+ const geomParamText = rc.geometry.params
1531+ .map((p) => `${p.key}=${rc.geometryParams[p.key]}`)
1532+ .join(' ');
1533+ const name = document.createElement('b');
1534+ name.textContent = rc.label;
1535+ const info = document.createElement('span');
1536+ info.textContent =
1537+ ` — ${rc.model.label} on ${rc.geometry.label.toLowerCase()}` +
1538+ (geomParamText ? ` (${geomParamText})` : '') +
1539+ `, lmax ${rc.lmax}, T = ${(rc.steps * (rc.params.dt ?? 0)).toFixed(2)}` +
1540+ ` (${rc.steps} × dt ${rc.params.dt})`;
1541+ elCmpFileInfo.replaceChildren(name, info);
1542+ }
1543+ rebuildNiterChips();
1544+ rebuildLmaxChips();
1545+ refreshVariants();
1546+}
1547+
1548+const MODE_GROUPS: Record<Mode, readonly GroupName[]> = {
1549+ simulate: ['surface', 'surface-params', 'solver', 'display', 'playback', 'benchmark', 'seed', 'movie'],
1550+ 'compute-effort': ['surface', 'surface-params', 'display', 'playback', 'seed'],
1551+ // Unlike compute-effort, there is no separate chip grid for this mode —
1552+ // both rows always mirror whatever niter/lmax the Simulate controls have,
1553+ // so `solver` has to stay visible; it's the only place those get set.
1554+ 'vs-sphere': ['surface', 'surface-params', 'solver', 'display', 'playback', 'seed'],
1555+ // No `seed` here: nothing in that group does anything useful against a
1556+ // loaded file (lam3 is silently absorbed, and Restart already covers what
1557+ // Re-seed would otherwise be doing — reloading the file's fixed initial
1558+ // state) — see CompareRun.restart().
1559+ 'vs-upload': ['display', 'playback'],
1560+};
1561+
1562+const MODE_DESCRIPTIONS: Record<Mode, string> = {
1563+ simulate:
1564+ 'This mode runs one standalone reaction-diffusion solver.',
1565+ 'compute-effort':
1566+ 'When we change the computational effort of the solver by varying solve iterations, lmax, or timestep, ' +
1567+ 'how does the solution change? Find out by running several ' +
1568+ 'so you can see how each setting trades accuracy for speed.',
1569+ 'vs-sphere':
1570+ 'Run this model once on the selected geometry and once on the plain unit sphere, from the exact same ' +
1571+ 'starting state and the same solve iterations, lmax and timestep, so geometry is the only thing that ' +
1572+ 'differs. See how much resolving the true shape actually changes the pattern, versus approximating it ' +
1573+ 'as a sphere.',
1574+ 'vs-upload':
1575+ 'Load a saved reference run (an .h5 file) and run this solver to the ' +
1576+ 'same physical end time from the same initial condition, to check how ' +
1577+ 'closely it reproduces the reference. You can adjust the solver settings ' +
1578+ 'to see how they affect the outcome.',
1579+};
1580+
1581+function setModeButtons(mode: Mode): void {
1582+ elModeSimulate.setAttribute('aria-pressed', String(mode === 'simulate'));
1583+ elModeEffort.setAttribute('aria-pressed', String(mode === 'compute-effort'));
1584+ elModeVsSphere.setAttribute('aria-pressed', String(mode === 'vs-sphere'));
1585+ elModeVsUpload.setAttribute('aria-pressed', String(mode === 'vs-upload'));
1586+ elModeDesc.textContent = MODE_DESCRIPTIONS[mode];
1587+}
1588+
1589+/** Show exactly the groups `mode` declares; hide the rest. */
1590+function applyModeVisibility(mode: Mode): void {
1591+ currentMode = mode;
1592+ const shown = new Set<GroupName>(MODE_GROUPS[mode]);
1593+ for (const name of GROUP_NAMES) groupEls[name].hidden = !shown.has(name);
1594+ setModeButtons(mode);
1595+}
1596+
1597+/**
1598+ * Enter `mode`: groups, top-row buttons, and the compare bar's own
1599+ * visibility (open for the two compare flavors, closed for Simulate).
1600+ * Doesn't touch `compareRun`/`refCase` or start/stop a study — callers
1601+ * decide that; this only decides what's on screen, and it decides it
1602+ * immediately, so the button you clicked lights up right away rather than
1603+ * waiting on a study that may not exist yet (or may never start, if the
1604+ * bar's own Compare is never pressed).
1605+ */
1606+function enterMode(mode: Mode): void {
1607+ applyModeVisibility(mode);
1608+ elCompareBar.hidden = mode === 'simulate';
1609+ // vs-sphere has no chip grid to pick from — both rows mirror the Simulate
1610+ // panel's own niter/lmax — and no reference to pick among variants either,
1611+ // since the reference is always "the selected geometry." Just the
1612+ // description and the Compile button apply.
1613+ elCmpAxes.hidden = mode === 'vs-sphere';
1614+ elCmpRefLabel.hidden = mode === 'vs-sphere';
1615+}
1616+
1617+/** Entering a mode from the top row. */
1618+function setMode(mode: Mode): void {
1619+ if (mode === 'simulate') {
1620+ if (compareRun) void stopCompare();
1621+ enterMode('simulate');
1622+ return;
1623+ }
1624+ if (mode === 'compute-effort' || mode === 'vs-sphere') {
1625+ // Tear down whatever study is running first (mirrors Simulate above) —
1626+ // stopCompare's synchronous prefix disposes it and nulls `compareRun`
1627+ // before its first `await`, so `refCase` is safe to drop right after.
1628+ // vs-sphere never uses a reference file either — its "reference" is
1629+ // always the selected geometry — so the same drop applies there too.
1630+ if (compareRun) void stopCompare();
1631+ if (refCase) {
1632+ refCase = null;
1633+ applyRefUi();
1634+ }
1635+ enterMode(mode);
1636+ return;
1637+ }
1638+ // vs-upload: opens the file picker; entering the mode itself happens once
1639+ // a file is actually chosen (elCmpFile's change handler below) — not here,
1640+ // since cancelling the dialog must leave the current mode untouched.
1641+ elCmpFile.click();
1642+}
1643+
1644+elModeSimulate.addEventListener('click', () => setMode('simulate'));
1645+elModeEffort.addEventListener('click', () => setMode('compute-effort'));
1646+elModeVsSphere.addEventListener('click', () => setMode('vs-sphere'));
1647+elModeVsUpload.addEventListener('click', () => setMode('vs-upload'));
1648+
1649+elCmpFile.addEventListener('change', () => {
1650+ const file = elCmpFile.files?.[0];
1651+ // Cleared so picking the same file again still fires a change event.
1652+ elCmpFile.value = '';
1653+ if (!file) return;
1654+ void (async () => {
1655+ try {
1656+ refCase = await loadReferenceFile(file);
1657+ elErr.textContent = '';
1658+ } catch (e) {
1659+ refCase = null;
1660+ elErr.textContent = `reference file ${file.name}: ${e instanceof Error ? e.message : e}`;
1661+ applyRefUi();
1662+ return;
1663+ }
1664+ // One click, one study: the file's own settings become the single
1665+ // variant — its recorded niter, its band, its dt undivided — and the
1666+ // comparison opens on them, paused at the initial state so what runs is
1667+ // the user's choice. (Widening it is: teardown the comparison, pick more
1668+ // chips, compile it again — the file stays loaded.)
1669+ cmpSelected.niter.clear();
1670+ cmpSelected.niter.add(refCase.niter);
1671+ cmpSelected.lmax.clear();
1672+ cmpSelected.lmax.add(refCase.lmax);
1673+ cmpSelected.dt.clear();
1674+ cmpSelected.dt.add(1);
1675+ applyRefUi();
1676+ enterMode('vs-upload');
1677+ if (compareRun) {
1678+ // A study is already up (this one loaded over it): same teardown as
1679+ // rebuildCompare, then the new file's study takes its place.
1680+ compareRun.dispose();
1681+ compareRun = null;
1682+ setCompareUi(false);
1683+ }
1684+ await startCompare();
1685+ })();
1686+});
1687+elCmpFileClear.addEventListener('click', () => {
1688+ refCase = null;
1689+ applyRefUi();
1690+ // The bar stays open — this only drops back to the plain chip comparison.
1691+ // Only reachable while idle (elCmpFileClear is disabled during a study).
1692+ enterMode('compute-effort');
12141693 });
12151694
12161695 elCmpStart.addEventListener('click', () => {
@@ -1218,23 +1697,69 @@ elCmpStart.addEventListener('click', () => {
12181697 else void startCompare();
12191698 });
12201699
1221-/** Controls the study supersedes or cannot honour while it is running. */
1700+/**
1701+ * Controls the study supersedes or cannot honour while it is running.
1702+ * Mode/group/button state is not this function's job — that's set the
1703+ * moment a mode is entered (enterMode, above), independent of whether a
1704+ * study inside it has actually started or stopped.
1705+ */
12221706 function setCompareUi(on: boolean): void {
1223- for (const el of [elNiter, elLmax, elOversample, elBenchmark, elMovieToggle]) {
1224- el.disabled = on;
1225- }
1707+ // A study picks its own display grid, so oversample stays individually
1708+ // disabled inside the still-visible display group; and clearing a loaded
1709+ // file out from under a running study would leave it checking against one
1710+ // that no longer exists.
1711+ elOversample.disabled = on;
1712+ elCmpFileClear.disabled = on;
12261713 elCmpNiter.querySelectorAll('button').forEach((b) => (b.disabled = on));
12271714 elCmpLmax.querySelectorAll('button').forEach((b) => (b.disabled = on));
12281715 elCmpDt.querySelectorAll('button').forEach((b) => (b.disabled = on));
1229- elCmpStart.textContent = on ? 'Stop comparing' : 'Compare';
1230- elCompareToggle.textContent = on ? 'Comparing' : 'Compare';
1716+ elCmpStart.textContent = on ? 'Teardown comparison' : 'Compile comparison';
1717+ // The movie bar's own hidden flag is independent of the movie *group's* —
1718+ // force it closed so it doesn't reappear open once the group is shown
1719+ // again on returning to Simulate.
12311720 if (on) elMovieBar.hidden = true;
12321721 }
12331722
12341723 async function startCompare(): Promise<void> {
12351724 if (compareRun || !device) return;
1236- const variants = cmpVariants();
1237- if (variants.length > MAX_VARIANTS || variants.length * model.species.length > MAX_PANELS) {
1725+ // Snapshotted for the whole study: `refCase` only changes with no study up
1726+ // (clearing is disabled during one, and loading tears it down first).
1727+ // vs-sphere never has one — its "reference" is always the selected
1728+ // geometry, not a file.
1729+ const rc = currentMode === 'vs-sphere' ? null : refCase;
1730+ const cmpModel = rc?.model ?? model;
1731+
1732+ // vs-sphere: exactly two rows — the selected geometry (the reference) and
1733+ // the plain unit sphere — mirroring whatever niter/lmax the Simulate
1734+ // controls have, so geometry is the only thing that differs between them.
1735+ // Every other mode still drives its rows from the chip grid.
1736+ const isVsSphere = currentMode === 'vs-sphere';
1737+ let variants: Variant[];
1738+ let reference: number;
1739+ let geometries: { geometry: MGeometry; geometryParams: Params; geometrySource: string }[] | undefined;
1740+ let renderOnReferenceGeometry: boolean | undefined;
1741+ let rowLabels: string[] | undefined;
1742+ if (isVsSphere) {
1743+ const niter = Number(elNiter.value);
1744+ const lmax = Number(elLmax.value);
1745+ variants = [{ niter, lmax, dtDiv: 1 }, { niter, lmax, dtDiv: 1 }];
1746+ reference = 0;
1747+ const sphereGeom = mGeometryByKey(SPHERE_KEY)!;
1748+ geometries = [
1749+ { geometry, geometryParams: geomParams, geometrySource: geomSource() },
1750+ { geometry: sphereGeom, geometryParams: defaultGeometryParams(sphereGeom), geometrySource: sphereGeom.source },
1751+ ];
1752+ renderOnReferenceGeometry = true;
1753+ rowLabels = [geometry.label, 'unit sphere'];
1754+ } else {
1755+ variants = cmpVariants();
1756+ reference = rc ? 0 : compareRefIndex();
1757+ }
1758+
1759+ const rowCount = variants.length + (rc ? 1 : 0);
1760+ const diffRowCount = rc ? variants.length : Math.max(0, variants.length - 1);
1761+ const panels = (rowCount + diffRowCount) * cmpModel.species.length;
1762+ if (!isVsSphere && (variants.length > MAX_VARIANTS || panels > MAX_PANELS)) {
12381763 return;
12391764 }
12401765 // Take down the single run first: its pump, its scenes, its session. The
@@ -1250,20 +1775,30 @@ async function startCompare(): Promise<void> {
12501775 setCompareUi(true);
12511776
12521777 try {
1778+ // Against a reference file, the problem is the file's — its model,
1779+ // parameters and geometry, from the registry sources (the editor's
1780+ // working copies describe the page's run, not the file's).
12531781 compareRun = await CompareRun.create({
12541782 device,
1255- model,
1256- params,
1257- source: source(),
1258- geometry,
1259- geometryParams: geomParams,
1260- geometrySource: geomSource(),
1783+ model: cmpModel,
1784+ params: rc ? rc.params : params,
1785+ source: rc ? rc.model.source : source(),
1786+ geometry: rc ? rc.geometry : geometry,
1787+ geometryParams: rc ? rc.geometryParams : geomParams,
1788+ geometrySource: rc ? rc.geometry.source : geomSource(),
12611789 variants,
1262- reference: compareRefIndex(),
1790+ reference,
1791+ geometries,
1792+ renderOnReferenceGeometry,
1793+ rowLabels,
1794+ refFile: rc ?? undefined,
1795+ onFinished: () => setRunning(false),
12631796 seed,
1797+ lam3: rc ? undefined : Number(elLam3.value),
12641798 morph,
12651799 colormapName: () => elColormap.value,
12661800 container: elPanels,
1801+ chartContainer: elCmpChart,
12671802 onStatus: (html) => (elStats.innerHTML = html),
12681803 });
12691804 } catch (e) {
@@ -1300,8 +1835,121 @@ async function rebuildCompare(): Promise<void> {
13001835 await startCompare();
13011836 }
13021837
1838+// ------------------------------------------------------ import from reharm
1839+// reharm's "Export to turing-surface" button opens this page with ?import and
1840+// hands the fitted surface over by postMessage — the two apps live on
1841+// different origins, so a message between opener and opened tab is the only
1842+// channel, and structured clone carries the Float32Arrays without encoding.
1843+// Protocol: the opener pings {type:'reharm-import-ping'} until this page
1844+// answers {type:'reharm-import-ready'} (only once boot has a live session to
1845+// swap the surface into), then posts {type:'reharm-geometry', version, lmax,
1846+// mmax, Gx, Gy, Gz, name, provenance}; this page validates, installs it as
1847+// the dropdown's 'imported' entry, and acks. The payload is data, not code:
1848+// three coefficient arrays and label strings, the strings stripped of markup
1849+// before they reach any innerHTML (updateGeomNote).
1850+
1851+/** null until boot resolves; then '' when imports can proceed, or the reason
1852+ * they cannot (no WebGPU). */
1853+let importGate: string | null = null;
1854+
1855+const cleanLabel = (v: unknown, max: number): string =>
1856+ String(v ?? '')
1857+ .replace(/[<>&"'`]/g, ' ')
1858+ .replace(/\s+/g, ' ')
1859+ .trim()
1860+ .slice(0, max);
1861+
1862+/** Validate a handed-over payload into coefficients, or say what is wrong. */
1863+function importedCoeffsOf(d: Record<string, unknown>): ImportedCoeffs | string {
1864+ const version = Number(d.version);
1865+ if (version !== 1) return `unknown import payload version '${String(d.version)}'`;
1866+ const lmax = Number(d.lmax);
1867+ const mmax = Number(d.mmax);
1868+ if (!Number.isInteger(lmax) || lmax < 1 || lmax > 4096) return `bad lmax '${String(d.lmax)}'`;
1869+ if (!Number.isInteger(mmax) || mmax < 0 || mmax > lmax) return `bad mmax '${String(d.mmax)}'`;
1870+ const nlm = nlmCalc(lmax, mmax);
1871+ const arrays: Float32Array[] = [];
1872+ for (const name of ['Gx', 'Gy', 'Gz']) {
1873+ const q = d[name];
1874+ if (!(q instanceof Float32Array)) return `'${name}' is not a Float32Array`;
1875+ if (q.length !== 2 * nlm) return `'${name}' has ${q.length} values, expected 2*nlm = ${2 * nlm}`;
1876+ if (!q.every(Number.isFinite)) return `'${name}' has non-finite values`;
1877+ arrays.push(q);
1878+ }
1879+ return { lmax, mmax, X: arrays[0], Y: arrays[1], Z: arrays[2] };
1880+}
1881+
1882+/** Adopt the surface: it becomes (or replaces) the dropdown's imported entry
1883+ * and is selected. The run also adopts the surface's own band — truncating a
1884+ * fit to a coarser default would silently smooth the shape that was just
1885+ * exported — so the lmax dropdown gains the exact value when it is not a
1886+ * stock one, and a band change goes through the full rebuild (new grid)
1887+ * rather than the in-place swap. */
1888+function installImportedGeometry(g: MGeometry): void {
1889+ importedGeometry = g;
1890+ let opt = elGeometry.querySelector<HTMLOptionElement>(
1891+ `option[value="${IMPORTED_GEOMETRY_KEY}"]`,
1892+ );
1893+ if (!opt) {
1894+ opt = document.createElement('option');
1895+ opt.value = IMPORTED_GEOMETRY_KEY;
1896+ elGeometry.append(opt);
1897+ }
1898+ opt.textContent = g.label;
1899+ elGeometry.value = IMPORTED_GEOMETRY_KEY;
1900+ applyGeometryChoice(IMPORTED_GEOMETRY_KEY);
1901+ const lmax = g.coeffs!.lmax;
1902+ if (Number(elLmax.value) !== lmax) {
1903+ const options = [...elLmax.options];
1904+ if (!options.some((o) => Number(o.value) === lmax)) {
1905+ const o = document.createElement('option');
1906+ o.value = String(lmax);
1907+ o.textContent = String(lmax);
1908+ elLmax.add(o, options.find((x) => Number(x.value) > lmax) ?? null);
1909+ }
1910+ elLmax.value = String(lmax);
1911+ void rebuild();
1912+ } else {
1913+ viewChange = viewChange.then(() => applyGeometry());
1914+ }
1915+}
1916+
1917+window.addEventListener('message', (ev: MessageEvent) => {
1918+ const d = ev.data as Record<string, unknown> | null;
1919+ if (!d || typeof d !== 'object' || typeof d.type !== 'string') return;
1920+ const reply = (msg: Record<string, unknown>): void => {
1921+ (ev.source as Window | null)?.postMessage(msg, { targetOrigin: ev.origin });
1922+ };
1923+ if (d.type === 'reharm-import-ping') {
1924+ // Silence before boot resolves is fine — the opener keeps pinging.
1925+ if (importGate === '') reply({ type: 'reharm-import-ready' });
1926+ else if (importGate) reply({ type: 'reharm-import-error', message: importGate });
1927+ return;
1928+ }
1929+ if (d.type !== 'reharm-geometry') return;
1930+ if (importGate !== '') {
1931+ reply({ type: 'reharm-import-error', message: importGate ?? 'the page is still starting up' });
1932+ return;
1933+ }
1934+ const coeffs = importedCoeffsOf(d);
1935+ if (typeof coeffs === 'string') {
1936+ reply({ type: 'reharm-import-error', message: coeffs });
1937+ return;
1938+ }
1939+ const p = (d.provenance ?? {}) as Record<string, unknown>;
1940+ const name = `imported: ${cleanLabel(d.name, 40) || 'surface'}`;
1941+ const blurb =
1942+ `Handed over by reharm (lmax ${coeffs.lmax}` +
1943+ (p.map ? `, map ${cleanLabel(p.map, 60)}` : '') +
1944+ (p.filter ? `, filter ${cleanLabel(p.filter, 60)}` : '') +
1945+ `).`;
1946+ installImportedGeometry(makeImportedGeometry(name, blurb, coeffs));
1947+ reply({ type: 'reharm-import-ack' });
1948+});
1949+
13031950 // ---------------------------------------------------------------- boot
13041951 async function boot(): Promise<void> {
1952+ enterMode('simulate');
13051953 elModel.value = presets[0].key;
13061954 // The iteration count is one default shared with the benchmark, like the
13071955 // rest of the RunSpec's — take it from there rather than from the markup, so
@@ -1319,6 +1967,8 @@ async function boot(): Promise<void> {
13191967 elErr.textContent =
13201968 `WebGPU is not available (${e instanceof Error ? e.message : e}). ` +
13211969 `Use a WebGPU-capable browser such as Chrome or Edge.`;
1970+ importGate = 'WebGPU is not available in the opened tab';
1971+ announceImportGate();
13221972 return;
13231973 }
13241974 device.lost.then((info) => {
@@ -1327,6 +1977,25 @@ async function boot(): Promise<void> {
13271977 }
13281978 });
13291979 await rebuild();
1980+ // Imports wait for a live run to swap the surface into; a tab opened by
1981+ // reharm's export button (?import) is told without waiting to be pinged.
1982+ importGate = '';
1983+ announceImportGate();
1984+}
1985+
1986+/** Tell the opener that navigated here with ?import how imports stand. Only
1987+ * a signal — no data — so the opener's origin need not be known. */
1988+function announceImportGate(): void {
1989+ if (!window.opener || !new URLSearchParams(location.search).has('import')) return;
1990+ const msg =
1991+ importGate === ''
1992+ ? { type: 'reharm-import-ready' }
1993+ : { type: 'reharm-import-error', message: importGate };
1994+ try {
1995+ (window.opener as Window).postMessage(msg, { targetOrigin: '*' });
1996+ } catch {
1997+ // The opener may already be gone; the ping path covers the rest.
1998+ }
13301999 }
13312000
13322001 void boot();
src/mgpu/digest.tsmodified+13−0View file
@@ -69,6 +69,19 @@ export function relL2(a: ArrayLike<number>, b: ArrayLike<number>): number {
6969 return Math.sqrt(num / Math.max(den, 1e-300));
7070 }
7171
72+/** Relative L-infinity (max-norm) difference of two states of equal length. */
73+export function relLinf(a: ArrayLike<number>, b: ArrayLike<number>): number {
74+ let num = 0;
75+ let den = 0;
76+ for (let i = 0; i < a.length; i++) {
77+ const d = Math.abs(a[i] - b[i]);
78+ if (d > num) num = d;
79+ const bd = Math.abs(b[i]);
80+ if (bd > den) den = bd;
81+ }
82+ return num / Math.max(den, 1e-300);
83+}
84+
7285 export function formatDigest(d: StateDigest): string {
7386 const g = (v: number): string => v.toPrecision(9);
7487 return (
src/mgpu/externals.tsmodified+61−1View file
@@ -153,10 +153,70 @@ export function externalOpFiles(g: GridSizes): { name: string; source: string }[
153153 name: 'dphig.mtoc2.js',
154154 source: transformSource('dphig', g.npts, 1, g.npts, 1),
155155 },
156+ {
157+ // The seeded random field a model's `init` starts from
158+ // (src/mgpu/randnfun3.ts): a wavelength and the three surface
159+ // coordinates in, one value per grid point out.
160+ name: 'randnfun3.mtoc2.js',
161+ source: randnfun3Source(g),
162+ },
156163 ];
157164 }
158165
166+/** Source for `randnfun3`'s `.mtoc2.js`: `f = randnfun3(lambda, x, y, z)`. */
167+function randnfun3Source(g: GridSizes): string {
168+ return `
169+exports.name = "randnfun3";
170+
171+exports.transfer = function (argTypes, nargout) {
172+ if (argTypes.length !== 4) {
173+ throw new Error(
174+ "randnfun3 takes a wavelength and the three surface coordinates -- " +
175+ "randnfun3(lambda, gx, gy, gz) -- got " + argTypes.length + " argument(s)"
176+ );
177+ }
178+ if (nargout > 1) {
179+ throw new Error("randnfun3 returns one value, but " + nargout + " were requested");
180+ }
181+ var lam = argTypes[0];
182+ if (!lam || lam.kind !== "Numeric" || lam.isComplex) {
183+ throw new Error("randnfun3's wavelength must be a real number");
184+ }
185+ var ls = lam.shape;
186+ if (!ls || ls.length !== 2 || ls[0] !== 1 || ls[1] !== 1) {
187+ throw new Error(
188+ "randnfun3's wavelength must be a single number, not a " +
189+ (ls ? ls.join("x") : "unknown shape") + " array"
190+ );
191+ }
192+ var names = ["gx", "gy", "gz"];
193+ for (var i = 1; i < 4; i++) {
194+ var a = argTypes[i];
195+ if (!a || a.kind !== "Numeric" || a.isComplex) {
196+ throw new Error("randnfun3 requires real numeric arrays (" + names[i - 1] + ")");
197+ }
198+ var s = a.shape;
199+ if (!s || s.length !== 2 || s[0] !== ${g.npts} || s[1] !== 1) {
200+ throw new Error(
201+ "randnfun3 evaluates on the grid, so " + names[i - 1] +
202+ " must be ${g.npts}x1, not " + (s ? s.join("x") : "unknown shape")
203+ );
204+ }
205+ }
206+ return [${numericType(g.npts, 1)}];
207+};
208+
209+// Never called: this project executes the IR on WebGPU and emits no C.
210+exports.emit = function () {
211+ throw new Error("randnfun3: no C backend (this runs on WebGPU)");
212+};
213+exports.cBody = function () {
214+ return "";
215+};
216+`;
217+}
218+
159219 /** Names the WGSL backend must implement as GPU encodes rather than kernels. */
160220 export const EXTERNAL_OPS = new Set([
161- 'synth', 'analys', 'dtheta', 'dphi', 'dthetac', 'dphic', 'dphig',
221+ 'synth', 'analys', 'dtheta', 'dphi', 'dthetac', 'dphic', 'dphig', 'randnfun3',
162222 ]);
src/mgpu/model.tsmodified+39−8View file
@@ -20,7 +20,8 @@
2020 import { ShtPlan } from '../sht/sht.ts';
2121 import type { DerivPlan } from '../sht/deriv.ts';
2222 import { lmIndex, type ShtConfig } from '../sht/layout.ts';
23-import { HostBuffers, ModelPlan } from './plan.ts';
23+import { HostBuffers, ModelPlan, type Randnfun3Lambda } from './plan.ts';
24+import { MODE_BUFFER } from './randnfun3.ts';
2425 import { inFunction, inFunctionAsync, inModel } from './errors.ts';
2526 import { CompiledModel, type Binding } from './compile.ts';
2627
@@ -83,6 +84,12 @@ export interface GeometryBuffers {
8384 p2: Float32Array;
8485 q2: Float32Array;
8586 r: Float32Array;
87+ /** The same weights with the round sphere subtracted (Geometry.dp1/dq2/jinv)
88+ * — the sphere-split form of the flux divergence, which keeps r off the
89+ * round-sphere part of the operator. */
90+ dp1: Float32Array;
91+ dq2: Float32Array;
92+ jinv: Float32Array;
8693 /** Mean-J preconditioner scale (Geometry.Jhat): folded into every
8794 * setParams upload as the 'jhat' uniform, so a .m that takes jhat is
8895 * never left with the zero a missing parameter would default to. An
@@ -98,7 +105,7 @@ export const GEOMETRY_SPECTRAL_NAMES = ['Gx', 'Gy', 'Gz'] as const;
98105 export const METRIC_GRID_NAMES = ['Vtx', 'Vty', 'Vtz', 'Vpx', 'Vpy', 'Vpz'] as const;
99106 /** Names the .m may take for the flux-form metric weights (six-transform
100107 * scheme). A model asks for whichever set its loop uses; both are uploaded. */
101-export const FLUX_METRIC_GRID_NAMES = ['p1', 'p2', 'q2', 'r'] as const;
108+export const FLUX_METRIC_GRID_NAMES = ['p1', 'p2', 'q2', 'r', 'dp1', 'dq2', 'jinv'] as const;
102109
103110 /** Laplace-Beltrami eigenvalues l(l+1), duplicated across re/im so the array
104111 * matches the 2 x nlm spectral layout element for element. */
@@ -208,6 +215,10 @@ export class GpuModel {
208215 // so swapping the surface updates it with no recompile. The session
209216 // folds the current geometry's value into every setParams call.
210217 bindings['jhat'] = { kind: 'param' };
218+ // The wavelength of the seeded random field (src/mgpu/randnfun3.ts).
219+ // A uniform like jhat, not a const: changing it redraws the field
220+ // without recompiling the step.
221+ bindings['lam3'] = { kind: 'param' };
211222 }
212223 for (const s of state) bindings[s] = { kind: 'tensor', shape: [2, nlm] };
213224 for (const p of paramNames) bindings[p] = { kind: 'param' };
@@ -265,6 +276,9 @@ export class GpuModel {
265276 host.upload('p2', geometry.p2);
266277 host.upload('q2', geometry.q2);
267278 host.upload('r', geometry.r);
279+ host.upload('dp1', geometry.dp1);
280+ host.upload('dq2', geometry.dq2);
281+ host.upload('jinv', geometry.jinv);
268282 }
269283
270284 const readback = device.createBuffer({
@@ -315,6 +329,7 @@ export class GpuModel {
315329 ['Vpx', geometry.Vpx], ['Vpy', geometry.Vpy], ['Vpz', geometry.Vpz],
316330 ['p1', geometry.p1], ['p2', geometry.p2],
317331 ['q2', geometry.q2], ['r', geometry.r],
332+ ['dp1', geometry.dp1], ['dq2', geometry.dq2], ['jinv', geometry.jinv],
318333 ];
319334 for (const [name, data] of fields) {
320335 if (this.#host.get(name)) this.#host.upload(name, data);
@@ -324,12 +339,28 @@ export class GpuModel {
324339 this.#jhat = geometry.Jhat;
325340 }
326341
327- /** Upload the seeded perturbation and run `init`. */
328- init(noise: Float32Array): void {
329- this.#host.upload('noise', noise);
330- const enc = this.#device.createCommandEncoder({ label: 'mgpu-init' });
331- this.#initPlan.encodeSteps(enc, 1);
332- this.#device.queue.submit([enc.finish()]);
342+ /** The wavelength this model's `init` asked `randnfun3` for, or null if it
343+ * seeds some other way. The session resolves it and draws the modes. */
344+ get randnfun3Lambda(): Randnfun3Lambda | null {
345+ return this.#initPlan.randnfun3Lambda;
346+ }
347+
348+ /**
349+ * Upload the seeded initial data and run `init`.
350+ *
351+ * Both inputs are optional in the sense that a .m uses one or the other:
352+ * `modes` is the random field's coefficient table for a model that calls
353+ * `randnfun3`, `noise` the plain grid field for one that takes `noise`
354+ * directly (the analytic test models inject exact initial conditions that
355+ * way). Only what the plan actually bound is uploaded.
356+ */
357+ async init(noise: Float32Array, modes: Float32Array | null): Promise<void> {
358+ if (this.#host.get('noise')) this.#host.upload('noise', noise);
359+ // Sized to the wavelength, so this may reallocate and rebind.
360+ if (modes) this.#initPlan.uploadRandnfun3Table(this.#host, modes);
361+ // Submitted in pieces: a fine seed wavelength makes the mode sum long
362+ // enough that one submission would stall the browser's compositor.
363+ await this.#initPlan.submitYielding('mgpu-init');
333364 this.#lastRan = 'init';
334365 }
335366
src/mgpu/numbl.d.tsmodified+90−5View file
@@ -1,10 +1,12 @@
11 /**
22 * The numbl compiler surface this project depends on.
33 *
4- * We reach past numbl's published entry points into its JIT internals (parser,
5- * lowerer, IR, inline pass), which its package `exports` map does not expose.
6- * Those imports resolve through the `numbl-src` alias in vite.config.ts; these
7- * declarations are what TypeScript checks against.
4+ * We reach past numbl's published entry points into its internals — the JIT
5+ * side (parser, lowerer, IR, inline pass) that compiles the models, and the
6+ * interpreter side (executeCode, runtime values) that evaluates the
7+ * geometries — which its package `exports` map does not expose. Those imports
8+ * resolve through the `numbl-src` alias in vite.config.ts; these declarations
9+ * are what TypeScript checks against.
810 *
911 * Declaring the surface here rather than type-checking numbl's sources
1012 * directly keeps this project's compiler settings independent of numbl's, and
@@ -192,13 +194,96 @@ declare module 'numbl-src/numbl-core/jit/lowering/ir.ts' {
192194 }
193195
194196 declare module 'numbl-src/numbl-core/parser/index.ts' {
197+ export interface ParseSpan {
198+ start: number;
199+ end: number;
200+ }
201+
202+ /** The one parse-tree node this project inspects (src/geom/geometry.ts,
203+ * finding `shape` and its argument names). */
204+ export interface FunctionStmt {
205+ type: 'Function';
206+ name: string;
207+ params: string[];
208+ outputs: string[];
209+ span: ParseSpan;
210+ }
211+
212+ /** Any other statement in a file's body — opaque to this project. Its
213+ * `type` is some other literal; narrowing to FunctionStmt goes through an
214+ * explicit type guard rather than the discriminant. */
215+ export interface OtherParseStmt {
216+ type: string;
217+ span: ParseSpan;
218+ }
219+
220+ export type Stmt = FunctionStmt | OtherParseStmt;
221+
195222 export interface AbstractSyntaxTree {
196- body: unknown[];
223+ body: Stmt[];
197224 }
198225 export function parseMFile(input: string, fileName?: string): AbstractSyntaxTree;
199226 export class SyntaxError extends Error {}
200227 }
201228
229+declare module 'numbl-src/numbl-core/runtime/types.ts' {
230+ /** A numeric array: f64 data in column-major order, with its shape. */
231+ export class RuntimeTensor {
232+ readonly kind: 'tensor';
233+ data: Float64Array;
234+ /** Present iff the value is complex. */
235+ imag: Float64Array | undefined;
236+ shape: number[];
237+ constructor(data: Float64Array, shape: number[], imag?: Float64Array);
238+ }
239+
240+ /** Every other value kind the interpreter can hold, collapsed. */
241+ export interface OtherRuntimeValue {
242+ readonly kind: string;
243+ }
244+
245+ export type RuntimeValue =
246+ | number
247+ | boolean
248+ | string
249+ | RuntimeTensor
250+ | OtherRuntimeValue;
251+
252+ export function isRuntimeTensor(value: RuntimeValue): value is RuntimeTensor;
253+}
254+
255+declare module 'numbl-src/numbl-core/executeCode.ts' {
256+ import type { RuntimeValue } from 'numbl-src/numbl-core/runtime/types.ts';
257+
258+ export interface ExecOptions {
259+ /** Variables pre-bound in the script's workspace before it runs. */
260+ initialVariableValues?: Record<string, RuntimeValue>;
261+ displayResults?: boolean;
262+ onOutput?: (text: string) => void;
263+ /** null opts out of scanning a working directory for .m files. */
264+ implicitCwdPath?: string | null;
265+ }
266+
267+ export interface ExecWorkspaceFile {
268+ name: string;
269+ source: string;
270+ }
271+
272+ export interface ExecResult {
273+ output: string[];
274+ /** The script's workspace after it ran. */
275+ variableValues: Record<string, RuntimeValue>;
276+ }
277+
278+ /** Run a script through numbl's interpreter (with its JS-JIT), CPU-side. */
279+ export function executeCode(
280+ source: string,
281+ options?: ExecOptions,
282+ workspaceFiles?: ExecWorkspaceFile[],
283+ mainFileName?: string,
284+ ): ExecResult;
285+}
286+
202287 declare module 'numbl-src/numbl-core/jit/index.ts' {
203288 import type { AbstractSyntaxTree } from 'numbl-src/numbl-core/parser/index.ts';
204289 import type { IRProgram, IRFunc, Span } from 'numbl-src/numbl-core/jit/lowering/ir.ts';
src/mgpu/plan.tsmodified+291−12View file
@@ -21,6 +21,13 @@ import { ShtPlan, type ShtBinding, type ShtBatchBinding, type ShtDphigBinding }
2121 import { DerivPlan, type DerivBinding } from '../sht/deriv.ts';
2222 import type { CompiledFunction } from './compile.ts';
2323 import { EXTERNAL_OPS } from './externals.ts';
24+import {
25+ MODE_BUFFER,
26+ INITIAL_MODES,
27+ modeTableLength,
28+ randnfun3Chunks,
29+ randnfun3WGSL,
30+} from './randnfun3.ts';
2431 import {
2532 buildKernel,
2633 UnsupportedOnGpu,
@@ -96,6 +103,35 @@ export class HostBuffers {
96103 return this.#slots.get(name);
97104 }
98105
106+ /**
107+ * Replace a slot's buffer with a larger one. Only for buffers whose size is
108+ * not fixed by the grid — the randnfun3 mode table, which grows with the
109+ * wavelength asked for. The caller must rebuild any bind group holding the
110+ * old buffer; it is destroyed here.
111+ */
112+ resize(name: string, count: number): Slot {
113+ const existing = this.#slots.get(name);
114+ if (!existing) throw new Error(`resize: no buffer named '${name}'`);
115+ if (count <= existing.count) return existing;
116+ existing.buffer.destroy();
117+ const slot = { buffer: makeBuffer(this.#device, `mgpu-${name}`, count), count };
118+ this.#slots.set(name, slot);
119+ return slot;
120+ }
121+
122+ /** Upload into the front of a slot, leaving any tail as it was. For a
123+ * variable-length payload in a buffer sized to its high-water mark. */
124+ uploadInto(name: string, data: Float32Array): void {
125+ const slot = this.#slots.get(name);
126+ if (!slot) throw new Error(`uploadInto: no buffer named '${name}'`);
127+ if (data.length > slot.count) {
128+ throw new Error(
129+ `uploadInto '${name}': ${data.length} elements into a ${slot.count}-element buffer`,
130+ );
131+ }
132+ this.#device.queue.writeBuffer(slot.buffer, 0, data as Float32Array<ArrayBuffer>);
133+ }
134+
99135 /** Upload initial data for a host binding. */
100136 upload(name: string, data: Float32Array): void {
101137 const slot = this.#slots.get(name);
@@ -124,6 +160,9 @@ type Op =
124160 /** Set when the kernel had to write to scratch because its output
125161 * aliases one of its inputs; copied back after the dispatch. */
126162 copyBack?: { from: GPUBuffer; to: GPUBuffer; bytes: number };
163+ /** End the submission here when run through `submitYielding`, so the
164+ * GPU is handed back between chunks of a long seed. */
165+ yieldAfter?: boolean;
127166 }
128167 | { kind: 'synth' | 'analys'; binding: ShtBinding; label: string }
129168 | { kind: 'synth-batch' | 'analys-batch'; binding: ShtBatchBinding; labels: string[] }
@@ -295,6 +334,9 @@ async function makePipeline(
295334 export class ModelPlan {
296335 /** Scalar parameter names, in the order the params buffer expects them. */
297336 readonly paramNames: string[];
337+ /** The wavelength this plan's `randnfun3` call asked for, or null if it
338+ * makes none. The host draws the coefficient table from it. */
339+ readonly randnfun3Lambda: Randnfun3Lambda | null;
298340
299341 #device: GPUDevice;
300342 #sht: ShtPlan;
@@ -303,6 +345,7 @@ export class ModelPlan {
303345 #owned: GPUBuffer[];
304346 #paramBuf: GPUBuffer;
305347 #paramData: Float32Array;
348+ #rebindRandnfun3: ((table: GPUBuffer) => void) | null;
306349 /** Public name -> buffer, for uploading initial state and reading results. */
307350 #byName: Map<string, Slot>;
308351
@@ -316,6 +359,8 @@ export class ModelPlan {
316359 paramBuf: GPUBuffer;
317360 paramData: Float32Array;
318361 paramNames: string[];
362+ randnfun3Lambda: Randnfun3Lambda | null;
363+ rebindRandnfun3: ((table: GPUBuffer) => void) | null;
319364 }) {
320365 this.#device = init.device;
321366 this.#sht = init.sht;
@@ -326,6 +371,30 @@ export class ModelPlan {
326371 this.#paramBuf = init.paramBuf;
327372 this.#paramData = init.paramData;
328373 this.paramNames = init.paramNames;
374+ this.randnfun3Lambda = init.randnfun3Lambda;
375+ this.#rebindRandnfun3 = init.rebindRandnfun3;
376+ }
377+
378+ /**
379+ * Point the randnfun3 dispatch at a mode table big enough for `data`,
380+ * growing the buffer if this wavelength needs more modes than the last one,
381+ * and upload it.
382+ */
383+ uploadRandnfun3Table(host: HostBuffers, data: Float32Array): void {
384+ const slot = host.get(MODE_BUFFER);
385+ if (!slot || !this.#rebindRandnfun3) return;
386+ if (data.length > slot.count) {
387+ const max = this.#device.limits.maxStorageBufferBindingSize;
388+ if (4 * data.length > max) {
389+ throw new Error(
390+ `randnfun3: this wavelength needs a ${(4 * data.length / 1e6).toFixed(0)} MB ` +
391+ `mode table, past this device's ${(max / 1e6).toFixed(0)} MB limit ` +
392+ `on a single buffer. Use a larger lambda.`,
393+ );
394+ }
395+ this.#rebindRandnfun3(host.resize(MODE_BUFFER, data.length).buffer);
396+ }
397+ host.uploadInto(MODE_BUFFER, data);
329398 }
330399
331400 static async create(
@@ -375,6 +444,14 @@ export class ModelPlan {
375444 usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST,
376445 });
377446
447+ /** Set when the .m calls `randnfun3`: which wavelength it asked for, so
448+ * the host draws the coefficient table the kernel reads from exactly
449+ * that value (src/mgpu/randnfun3.ts). */
450+ let randnfun3Lambda: Randnfun3Lambda | null = null;
451+ /** Rebuilds the randnfun3 dispatch's bind group after the mode table is
452+ * reallocated for a finer wavelength. */
453+ let rebindRandnfun3: ((table: GPUBuffer) => void) | null = null;
454+
378455 const planned: Planned[] = [];
379456 for (const stmt of fn.body) {
380457 await planStatement(stmt);
@@ -413,6 +490,7 @@ export class ModelPlan {
413490
414491 return new ModelPlan({
415492 device, sht, deriv, ops, byName, owned, paramBuf, paramData, paramNames,
493+ randnfun3Lambda, rebindRandnfun3,
416494 });
417495
418496 async function planStatement(stmt: IRStmt): Promise<void> {
@@ -457,14 +535,19 @@ export class ModelPlan {
457535
458536 const ext = externalCall(stmt);
459537 if (ext) {
460- const argSlot = slots.get(ext.argCName);
538+ if (ext.name === 'randnfun3') {
539+ await planRandnfun3(stmt, ext.args, dest);
540+ return;
541+ }
542+ const arg = ext.args[0] as IRExpr & { kind: 'Var' };
543+ const argSlot = slots.get(arg.cName);
461544 if (!argSlot) {
462545 throw new UnsupportedOnGpu(
463- `'${ext.name}' reads '${ext.argName}', which has no buffer`,
546+ `'${ext.name}' reads '${arg.name}', which has no buffer`,
464547 stmt.span,
465548 );
466549 }
467- const label = `${stmt.name} = ${ext.name}(${ext.argName})`;
550+ const label = `${stmt.name} = ${ext.name}(${arg.name})`;
468551 if (ext.name === 'dphig') {
469552 // Grid -> grid, staged through the plan's fm scratch; safe even
470553 // in place, so no aliasing guard is needed.
@@ -504,7 +587,7 @@ export class ModelPlan {
504587 // silently reroute.
505588 if (argSlot.buffer === dest.buffer) {
506589 throw new UnsupportedOnGpu(
507- `'${stmt.name} = ${ext.name}(${ext.argName})' reads and ` +
590+ `'${stmt.name} = ${ext.name}(${arg.name})' reads and ` +
508591 `writes the same buffer; assign to a new name instead`,
509592 stmt.span,
510593 );
@@ -658,6 +741,115 @@ export class ModelPlan {
658741 }
659742 }
660743
744+ /**
745+ * `f = randnfun3(lambda, gx, gy, gz)`: the seeded random field, summed
746+ * over its Fourier modes at every surface point.
747+ *
748+ * One dispatch, one thread per point. The coefficient table is not an
749+ * argument — it is a host buffer this plan binds and the host refills per
750+ * seed, the way `synth` reads Legendre matrices the .m never names. What
751+ * the .m *does* choose is the wavelength, which is recorded here so the
752+ * host draws the table for exactly that value.
753+ */
754+ async function planRandnfun3(
755+ stmt: Assign,
756+ args: IRExpr[],
757+ dest: Slot,
758+ ): Promise<void> {
759+ const lam = args[0];
760+ const lambda: Randnfun3Lambda | null =
761+ lam.kind === 'NumLit'
762+ ? { kind: 'const', value: lam.value }
763+ : lam.kind === 'Var' && paramSlots.has(lam.cName)
764+ ? { kind: 'param', name: lam.name }
765+ : null;
766+ if (!lambda) {
767+ throw new UnsupportedOnGpu(
768+ `randnfun3's wavelength is drawn on the host before the step runs, ` +
769+ `so it must be a number or a model parameter — not a value ` +
770+ `computed on the GPU`,
771+ stmt.span,
772+ );
773+ }
774+ if (randnfun3Lambda && !sameLambda(randnfun3Lambda, lambda)) {
775+ throw new UnsupportedOnGpu(
776+ `this function calls randnfun3 with two different wavelengths; ` +
777+ `one coefficient table is drawn per plan, so only one is supported`,
778+ stmt.span,
779+ );
780+ }
781+ randnfun3Lambda = lambda;
782+
783+ const points = args.slice(1).map((a) => {
784+ const v = a as IRExpr & { kind: 'Var' };
785+ const slot = slots.get(v.cName);
786+ if (!slot) {
787+ throw new UnsupportedOnGpu(
788+ `randnfun3 reads '${v.name}', which has no buffer`,
789+ stmt.span,
790+ );
791+ }
792+ return { slot, name: v.name };
793+ });
794+
795+ const modes = host.ensure(MODE_BUFFER, modeTableLength(INITIAL_MODES));
796+ const label =
797+ `${stmt.name} = randnfun3(${
798+ lambda.kind === 'const' ? lambda.value : lambda.name
799+ }, ${points.map((p) => p.name).join(', ')})`;
800+
801+ const bindGroupLayout = device.createBindGroupLayout({
802+ label: 'mgpu-randnfun3',
803+ entries: [0, 1, 2, 3, 4].map((binding) => ({
804+ binding,
805+ visibility: GPUShaderStage.COMPUTE,
806+ buffer: { type: binding === 0 ? ('storage' as const) : ('read-only-storage' as const) },
807+ })),
808+ });
809+ // The table is sized to whatever wavelength is actually asked for, so a
810+ // finer one reallocates it — and with it these bind groups, which are
811+ // the only things holding the old buffer.
812+ const bind = (table: GPUBuffer): GPUBindGroup =>
813+ device.createBindGroup({
814+ layout: bindGroupLayout,
815+ entries: [
816+ { binding: 0, resource: { buffer: dest.buffer } },
817+ ...points.map((p, i) => ({
818+ binding: i + 1,
819+ resource: { buffer: p.slot.buffer },
820+ })),
821+ { binding: 4, resource: { buffer: table } },
822+ ],
823+ });
824+
825+ // One dispatch per slice of the mode table — see randnfun3Chunks. Each
826+ // reads the same table and accumulates into the same output, so they
827+ // share a bind group and differ only in their compiled slice index.
828+ const ops: (Op & { kind: 'kernel' })[] = [];
829+ for (let chunk = 0; chunk < randnfun3Chunks; chunk++) {
830+ const chunkLabel = `${label} [${chunk + 1}/${randnfun3Chunks}]`;
831+ const op = {
832+ kind: 'kernel' as const,
833+ pipeline: await makePipeline(
834+ device,
835+ randnfun3WGSL(dest.count, chunk),
836+ chunkLabel,
837+ bindGroupLayout,
838+ ),
839+ bindGroup: bind(modes.buffer),
840+ count: dest.count,
841+ label: chunkLabel,
842+ yieldAfter: true,
843+ };
844+ ops.push(op);
845+ planned.push(op);
846+ }
847+ rebindRandnfun3 = (table: GPUBuffer): void => {
848+ const group = bind(table);
849+ for (const op of ops) op.bindGroup = group;
850+ };
851+ }
852+
661853 /**
662854 * Unroll a counted loop into the op sequence.
663855 *
@@ -739,13 +931,64 @@ export class ModelPlan {
739931 return this.#byName.get(name)?.count;
740932 }
741933
934+ /**
935+ * Run one pass of this plan, submitting in pieces so the GPU is not held for
936+ * the whole of it.
937+ *
938+ * For `init` only, and only because the seed field's mode sum can be huge:
939+ * at a fine wavelength the dispatches add up to tens of seconds, and a
940+ * browser's GPU process is shared with compositing, so one submission that
941+ * long stops the whole browser painting — the user's tabs included. Ops
942+ * marked `yieldAfter` (the randnfun3 chunks) end their submission and give
943+ * the queue back before the next one is recorded, which turns a freeze into
944+ * a wait. Everything else is recorded exactly as `encodeSteps` would.
945+ */
946+ async submitYielding(label: string): Promise<void> {
947+ let encoder = this.#device.createCommandEncoder({ label });
948+ let any = false;
949+ for (const group of this.#yieldGroups()) {
950+ if (any) {
951+ // Let the queue drain, then hand the event loop back, so compositing
952+ // and input get a turn between chunks.
953+ await this.#device.queue.onSubmittedWorkDone();
954+ await new Promise((r) => setTimeout(r, 0));
955+ encoder = this.#device.createCommandEncoder({ label });
956+ }
957+ this.#encodeOps(encoder, group);
958+ this.#device.queue.submit([encoder.finish()]);
959+ any = true;
960+ }
961+ if (!any) {
962+ this.#encodeOps(encoder, []);
963+ this.#device.queue.submit([encoder.finish()]);
964+ }
965+ }
966+
967+ /** The op list split at every `yieldAfter` boundary. */
968+ *#yieldGroups(): Generator<Op[]> {
969+ let group: Op[] = [];
970+ for (const op of this.#ops) {
971+ group.push(op);
972+ if (op.kind === 'kernel' && op.yieldAfter) {
973+ yield group;
974+ group = [];
975+ }
976+ }
977+ if (group.length) yield group;
978+ }
979+
742980 /**
743981 * Record `steps` timesteps. Synchronous: no awaits, no readback. All of the
744982 * ops share one compute pass, which WebGPU executes in submission order
745983 * with a barrier between dispatches.
746984 */
747985 encodeSteps(encoder: GPUCommandEncoder, steps: number): void {
748- for (let s = 0; s < steps; s++) {
986+ for (let s = 0; s < steps; s++) this.#encodeOps(encoder, this.#ops);
987+ }
988+
989+ /** Record one pass over `ops` into `encoder`. */
990+ #encodeOps(encoder: GPUCommandEncoder, ops: Op[]): void {
991+ {
749992 let pass: GPUComputePassEncoder | null = null;
750993 const inPass = (): GPUComputePassEncoder => {
751994 if (!pass) pass = encoder.beginComputePass({ label: 'mgpu-step' });
@@ -757,7 +1000,7 @@ export class ModelPlan {
7571000 pass = null;
7581001 }
7591002 };
760- for (const op of this.#ops) {
1003+ for (const op of ops) {
7611004 switch (op.kind) {
7621005 case 'kernel': {
7631006 const p = inPass();
@@ -847,22 +1090,58 @@ export class ModelPlan {
8471090 }
8481091 }
8491092
850-/** `x = synth(y)` / `x = analys(y)` -> the call's name and argument. */
1093+/**
1094+ * `x = synth(y)` / `x = randnfun3(lam, gx, gy, gz)` -> the call's name and
1095+ * arguments.
1096+ *
1097+ * Every external op but `randnfun3` takes exactly one array; `randnfun3`
1098+ * takes a wavelength and the three surface coordinates. Its wavelength may
1099+ * be a literal, so arguments are returned as expressions and the caller
1100+ * decides which it needs as a buffer.
1101+ */
8511102 function externalCall(
8521103 stmt: Assign,
853-): { name: string; argCName: string; argName: string } | null {
1104+): { name: string; args: IRExpr[] } | null {
8541105 const e = stmt.expr;
8551106 if (e.kind !== 'Call' || !EXTERNAL_OPS.has(e.name)) return null;
856- if (e.args.length !== 1 || e.args[0].kind !== 'Var') {
1107+ const arity = e.name === 'randnfun3' ? 4 : 1;
1108+ if (e.args.length !== arity) {
8571109 throw new UnsupportedOnGpu(
858- `'${e.name}' must be applied to a single variable`,
1110+ arity === 1
1111+ ? `'${e.name}' must be applied to a single variable`
1112+ : `'${e.name}' takes ${arity} arguments, got ${e.args.length}`,
8591113 stmt.span,
8601114 );
8611115 }
862- const arg = e.args[0];
863- return { name: e.name, argCName: arg.cName, argName: arg.name };
1116+ // Only the wavelength may be something other than a plain variable.
1117+ for (let i = e.name === 'randnfun3' ? 1 : 0; i < e.args.length; i++) {
1118+ if (e.args[i].kind !== 'Var') {
1119+ throw new UnsupportedOnGpu(
1120+ `'${e.name}' must be applied to variables, not expressions`,
1121+ stmt.span,
1122+ );
1123+ }
1124+ }
1125+ return { name: e.name, args: e.args };
8641126 }
8651127
1128+/** A `randnfun3` wavelength argument: a literal, or the parameter to read it
1129+ * from when the host fills the coefficient table. */
1130+export type Randnfun3Lambda =
1131+ | { kind: 'const'; value: number }
1132+ | { kind: 'param'; name: string };
1133+
1134+const sameLambda = (a: Randnfun3Lambda, b: Randnfun3Lambda): boolean =>
1135+ a.kind === 'const' && b.kind === 'const'
1136+ ? a.value === b.value
1137+ : a.kind === 'param' && b.kind === 'param' && a.name === b.name;
1138+
1139+/** The wavelength value a plan's `randnfun3` call resolves to. */
1140+export const resolveLambda = (
1141+ lambda: Randnfun3Lambda,
1142+ params: Record<string, number>,
1143+): number => (lambda.kind === 'const' ? lambda.value : params[lambda.name]);
1144+
8661145 function collectTensorVars(e: IRExpr, visit: (cName: string) => void): void {
8671146 const walk = (x: IRExpr): void => {
8681147 switch (x.kind) {
src/mgpu/randnfun3.tsadded+294−0View file
@@ -0,0 +1,294 @@
1+/**
2+ * `randnfun3` — a smooth random function in 3D, evaluated at the surface.
3+ *
4+ * chebfun's randnfun3 is a random trig series on a box: a few thousand
5+ * Fourier modes with independent normal coefficients, confined to a ball for
6+ * isotropy and normalized to unit variance. Restricting it to a surface is
7+ * just evaluating it at the surface's points, which is what a model's `init`
8+ * wants for a seeded initial condition (surfacefun seeds exactly this way).
9+ *
10+ * The work splits in two, and the split is forced rather than chosen:
11+ *
12+ * - **Drawing the modes needs `randn`**, which the compiled WGSL dialect has
13+ * no counterpart for, and `sqrt(nnz)` normalization, which is a reduction.
14+ * Both are a few lines of MATLAB, so the draw lives in
15+ * `tools/randnfun3.m` and runs in numbl's interpreter — a few thousand
16+ * numbers, ~5 ms.
17+ * - **Evaluating is npts x nmodes**, ~6e7 terms at the default lambda. That
18+ * is the whole cost, and it is what this file's kernel does on the GPU.
19+ *
20+ * So the .m calls `f = randnfun3(lambda, gx, gy, gz)` — chebfun's signature,
21+ * lambda in and values out — and the coefficient table is filled in behind it
22+ * by the host, the way `synth` hides its Legendre matrices. lambda is not
23+ * decorative: the plan records which parameter the .m passed, and the host
24+ * draws the table from *that* parameter's value (src/mgpu/plan.ts,
25+ * `randnfun3Lambda`), so changing it in the .m changes the field.
26+ */
27+import { executeCode } from 'numbl-src/numbl-core/executeCode.ts';
28+import { isRuntimeTensor } from 'numbl-src/numbl-core/runtime/types.ts';
29+import { toolFiles } from '../tools.ts';
30+
31+/**
32+ * Dispatches the mode sum is split across.
33+ *
34+ * lambda is an absolute length and the mode count goes as its inverse cube,
35+ * so halving lambda costs eight times the work — there is no natural ceiling
36+ * to put on that, and nothing in the method breaks as it grows. It just gets
37+ * slower, which is the caller's business. What is *not* the caller's business
38+ * is a browser's GPU-process watchdog, which kills the device outright when a
39+ * single dispatch runs too long; a fine wavelength would otherwise turn "this
40+ * takes a while" into "device lost".
41+ *
42+ * So the sum is split into a fixed number of dispatches, each covering its own
43+ * slice of the table and accumulating into the same output. The count is fixed
44+ * at plan time (the op sequence has no runtime branching) and the slice bounds
45+ * come from the table's header, so one plan serves any wavelength. Slices that
46+ * fall past the end of a small table exit immediately, which is why a coarse
47+ * wavelength pays nothing for the split.
48+ */
49+const CHUNKS = 16;
50+
51+/** Floats the table needs for `nmodes` modes. */
52+export const modeTableLength = (nmodes: number): number =>
53+ HEADER + STRIDE * nmodes;
54+
55+/** Modes a table holds, from its header. */
56+export const modeCount = (table: Float32Array): number => table[0];
57+
58+/**
59+ * Largest table this will try to build, in f32. Not a policy about how fine a
60+ * wavelength is sensible — that is the caller's call, and a fine one is
61+ * merely slow — but the point past which the draw would fail anyway: the
62+ * host-side Float32Array alone would be 8 GB. The device's own
63+ * storage-buffer limit is checked separately, when the buffer is allocated.
64+ */
65+const MAX_TABLE_FLOATS = 2 ** 31;
66+
67+/** What the table starts at, before any seed has been drawn. Big enough for
68+ * the default wavelength on the shipped surfaces, so the common case never
69+ * reallocates. */
70+export const INITIAL_MODES = 4096;
71+
72+/** Wavelength of the seeded field when the app names none. Fine enough to
73+ * give a Turing pattern plenty to grow from, coarse enough that the draw is
74+ * ~1,400 modes rather than the ~11,500 of the slider's finest setting. */
75+export const DEFAULT_LAMBDA = 0.5;
76+
77+/** Floats before the first mode: `[nmodes, 0, 0, 0]`. The count travels in
78+ * the buffer rather than a second binding, so the kernel needs one storage
79+ * buffer and the host one write. */
80+const HEADER = 4;
81+/** Floats per mode: kx, ky, kz, real, imag. */
82+const STRIDE = 5;
83+
84+/** The name the coefficient buffer takes in the plan's HostBuffers. */
85+export const MODE_BUFFER = 'randnfun3_modes';
86+
87+/** How many dispatches `randnfun3WGSL` must be planned as. */
88+export const randnfun3Chunks = CHUNKS;
89+
90+/**
91+ * One thread per surface point, summing this chunk's slice of the modes.
92+ *
93+ * The inner loop is a dot product, a cos, a sin and two multiply-adds, over a
94+ * table small enough (~1,400 modes at the default lambda) to sit in cache for
95+ * every thread. Chunk 0 initializes the output and the rest accumulate onto
96+ * it; dispatches within one compute pass are ordered, so the reads see the
97+ * previous chunk's writes. Nothing here is per-step work: `init` runs once a
98+ * seed.
99+ */
100+export function randnfun3WGSL(npts: number, chunk: number): string {
101+ return `
102+@group(0) @binding(0) var<storage, read_write> outf: array<f32>;
103+@group(0) @binding(1) var<storage, read> px: array<f32>;
104+@group(0) @binding(2) var<storage, read> py: array<f32>;
105+@group(0) @binding(3) var<storage, read> pz: array<f32>;
106+// [nmodes, _, _, _], then kx, ky, kz, re, im per mode.
107+@group(0) @binding(4) var<storage, read> modes: array<f32>;
108+
109+@compute @workgroup_size(64)
110+fn main(@builtin(global_invocation_id) gid: vec3<u32>) {
111+ let i = gid.x;
112+ if (i >= ${npts}u) { return; }
113+ let n = u32(modes[0]);
114+ // This chunk's slice. Ceiling division, so the last slices are the short
115+ // ones and an empty slice costs a single comparison.
116+ let per = (n + ${CHUNKS}u - 1u) / ${CHUNKS}u;
117+ let lo = min(${chunk}u * per, n);
118+ let hi = min(lo + per, n);
119+ var acc = 0.0;
120+ if (lo < hi) {
121+ let x = px[i];
122+ let y = py[i];
123+ let z = pz[i];
124+ for (var m = lo; m < hi; m = m + 1u) {
125+ let b = ${HEADER}u + m * ${STRIDE}u;
126+ let t = modes[b] * x + modes[b + 1u] * y + modes[b + 2u] * z;
127+ acc = acc + modes[b + 3u] * cos(t) - modes[b + 4u] * sin(t);
128+ }
129+ }
130+${chunk === 0 ? ' outf[i] = acc;' : ' outf[i] = outf[i] + acc;'}
131+}
132+`;
133+}
134+
135+/**
136+ * Modes a wavelength will draw on a box, without drawing them: chebfun's
137+ * cube size, times the fraction its isotropy ball keeps (pi/6 of a cube,
138+ * approached from below at small m). Used to price a wavelength up front.
139+ */
140+function plannedModes(lambda: number, box: BoundingBox): number {
141+ const side = (w: number): number => 2 * Math.round((1.2 * w) / lambda + 2) + 1;
142+ const cube =
143+ side(box.x1 - box.x0) * side(box.y1 - box.y0) * side(box.z1 - box.z0);
144+ return Math.ceil((Math.PI / 6) * cube);
145+}
146+
147+/** The box a random field is drawn over: the surface's own bounding box. */
148+export interface BoundingBox {
149+ x0: number; x1: number;
150+ y0: number; y1: number;
151+ z0: number; z1: number;
152+}
153+
154+/** The bounding box of a surface, as `Geometry` holds its coordinates. */
155+export function boundingBox(
156+ x: Float32Array,
157+ y: Float32Array,
158+ z: Float32Array,
159+): BoundingBox {
160+ const box = {
161+ x0: Infinity, x1: -Infinity,
162+ y0: Infinity, y1: -Infinity,
163+ z0: Infinity, z1: -Infinity,
164+ };
165+ for (let i = 0; i < x.length; i++) {
166+ if (x[i] < box.x0) box.x0 = x[i];
167+ if (x[i] > box.x1) box.x1 = x[i];
168+ if (y[i] < box.y0) box.y0 = y[i];
169+ if (y[i] > box.y1) box.y1 = y[i];
170+ if (z[i] < box.z0) box.z0 = z[i];
171+ if (z[i] > box.z1) box.z1 = z[i];
172+ }
173+ return box;
174+}
175+
176+/**
177+ * Draw a field's modes and pack them for the GPU: `tools/randnfun3.m` run
178+ * through the interpreter, seeded, then interleaved into the buffer layout
179+ * above. Column-major out of MATLAB, interleaved on the way in.
180+ */
181+export function drawModes(
182+ lambda: number,
183+ box: BoundingBox,
184+ seed: number,
185+ /** Points the field will be summed at, for the cost budget. */
186+ npts: number,
187+): Float32Array {
188+ if (!(lambda > 0) || !Number.isFinite(lambda)) {
189+ throw new Error(`randnfun3: lambda must be a positive number, got ${lambda}`);
190+ }
191+ // The mode count follows from lambda and the box alone, so a table that
192+ // cannot be built is refused before anything is drawn. The only ceiling is
193+ // what fits: how slow a fine wavelength is, is the caller's to decide.
194+ const planned = plannedModes(lambda, box);
195+ if (modeTableLength(planned) > MAX_TABLE_FLOATS) {
196+ throw new Error(
197+ `randnfun3: lambda ${lambda} needs about ` +
198+ `${planned.toLocaleString()} Fourier modes on this surface, a ` +
199+ `${((4 * modeTableLength(planned)) / 1e9).toFixed(1)} GB table. ` +
200+ `lambda is an absolute length, so a larger surface needs more modes ` +
201+ `for the same value, and halving it costs eight times as many.`,
202+ );
203+ }
204+ const result = executeCode(
205+ 'rng(seed); [k, c] = randnfun3(lambda, [x0 x1 y0 y1 z0 z1]);',
206+ {
207+ initialVariableValues: { lambda, seed, ...box },
208+ displayResults: false,
209+ implicitCwdPath: null,
210+ },
211+ toolFiles,
212+ 'randnfun3-driver.m',
213+ );
214+ const k = result.variableValues['k'];
215+ const c = result.variableValues['c'];
216+ if (!k || !c || !isRuntimeTensor(k) || !isRuntimeTensor(c)) {
217+ throw new Error("randnfun3: tools/randnfun3.m did not return [k, c] arrays");
218+ }
219+ const nmodes = k.shape[0];
220+ const out = new Float32Array(modeTableLength(nmodes));
221+ out[0] = nmodes;
222+ for (let i = 0; i < nmodes; i++) {
223+ const b = HEADER + STRIDE * i;
224+ out[b] = k.data[i]; // kx
225+ out[b + 1] = k.data[nmodes + i]; // ky
226+ out[b + 2] = k.data[2 * nmodes + i]; // kz
227+ out[b + 3] = c.data[i]; // real
228+ out[b + 4] = c.data[nmodes + i]; // imag
229+ }
230+ return out;
231+}
232+
233+/**
234+ * `drawModes` on a worker thread, so a fine wavelength does not freeze the
235+ * page (src/mgpu/randnfun3.worker.ts).
236+ *
237+ * Falls back to drawing in place where there is no `Worker` — the node test
238+ * runner and the desktop benchmark, neither of which has an event loop it
239+ * would matter to. Failures surface as a rejection either way, so a caller
240+ * never has to know which path ran.
241+ */
242+export function drawModesAsync(
243+ lambda: number,
244+ box: BoundingBox,
245+ seed: number,
246+ npts: number,
247+): Promise<Float32Array> {
248+ if (typeof Worker === 'undefined') {
249+ try {
250+ return Promise.resolve(drawModes(lambda, box, seed, npts));
251+ } catch (e) {
252+ return Promise.reject(e instanceof Error ? e : new Error(String(e)));
253+ }
254+ }
255+ const w = drawWorker();
256+ const id = nextDrawId++;
257+ return new Promise((resolve, reject) => {
258+ pendingDraws.set(id, { resolve, reject });
259+ w.postMessage({ id, lambda, box, seed, npts });
260+ });
261+}
262+
263+let worker: Worker | null = null;
264+let nextDrawId = 1;
265+const pendingDraws = new Map<
266+ number,
267+ { resolve: (t: Float32Array) => void; reject: (e: Error) => void }
268+>();
269+
270+/** The draw worker, started on first use and kept for the session — starting
271+ * one re-parses numbl, which costs more than a coarse draw does. */
272+function drawWorker(): Worker {
273+ if (worker) return worker;
274+ worker = new Worker(new URL('./randnfun3.worker.ts', import.meta.url), {
275+ type: 'module',
276+ });
277+ worker.onmessage = (e: MessageEvent<{ id: number; table?: Float32Array; error?: string }>): void => {
278+ const waiting = pendingDraws.get(e.data.id);
279+ if (!waiting) return;
280+ pendingDraws.delete(e.data.id);
281+ if (e.data.error !== undefined) waiting.reject(new Error(e.data.error));
282+ else waiting.resolve(e.data.table!);
283+ };
284+ worker.onerror = (e: ErrorEvent): void => {
285+ // A worker that died takes every outstanding draw with it.
286+ for (const [, waiting] of pendingDraws) {
287+ waiting.reject(new Error(`randnfun3 draw worker failed: ${e.message}`));
288+ }
289+ pendingDraws.clear();
290+ worker?.terminate();
291+ worker = null;
292+ };
293+ return worker;
294+}
src/mgpu/randnfun3.worker.tsadded+40−0View file
@@ -0,0 +1,40 @@
1+/**
2+ * Drawing a seed field's Fourier modes, off the main thread.
3+ *
4+ * The draw is `tools/randnfun3.m` in numbl's interpreter, and its cost goes as
5+ * the inverse cube of the wavelength: milliseconds at the default lambda, but
6+ * ~13 s at lambda 0.01. Synchronous JS that long does not merely feel slow —
7+ * it blocks the event loop outright, so the page stops painting and the
8+ * browser offers to kill it. Nothing about the draw needs the main thread
9+ * (it touches no GPU and no DOM), so it runs here and the result is
10+ * transferred back.
11+ */
12+import { drawModes, type BoundingBox } from './randnfun3.ts';
13+
14+export interface DrawRequest {
15+ id: number;
16+ lambda: number;
17+ box: BoundingBox;
18+ seed: number;
19+ npts: number;
20+}
21+
22+export type DrawReply =
23+ | { id: number; table: Float32Array; error?: undefined }
24+ | { id: number; table?: undefined; error: string };
25+
26+self.onmessage = (e: MessageEvent<DrawRequest>): void => {
27+ const { id, lambda, box, seed, npts } = e.data;
28+ let reply: DrawReply;
29+ let transfer: Transferable[] = [];
30+ try {
31+ const table = drawModes(lambda, box, seed, npts);
32+ reply = { id, table };
33+ transfer = [table.buffer];
34+ } catch (err) {
35+ reply = { id, error: err instanceof Error ? err.message : String(err) };
36+ }
37+ (self as unknown as {
38+ postMessage: (m: DrawReply, t: Transferable[]) => void;
39+ }).postMessage(reply, transfer);
40+};
src/mgpu/registry.tsmodified+7−0View file
@@ -26,6 +26,13 @@ export interface ParamSpec {
2626 min: number;
2727 max: number;
2828 step: number;
29+ /**
30+ * This parameter is a random seed: its value picks a draw and means nothing
31+ * on its own, so the UI offers a button that jumps to another one rather
32+ * than a box to type a number into. `min`/`max` still bound what the button
33+ * picks.
34+ */
35+ reseed?: boolean;
2936 }
3037
3138 export interface MModel {
src/mgpu/session.tsmodified+113−22View file
@@ -11,6 +11,8 @@ import { DerivPlan } from '../sht/deriv.ts';
1111 import { gridForLmax, type ShtConfig } from '../sht/layout.ts';
1212 import { GpuModel, type ModelParams } from './model.ts';
1313 import { seededNoise } from './noise.ts';
14+import { boundingBox, drawModesAsync, DEFAULT_LAMBDA } from './randnfun3.ts';
15+import { resolveLambda } from './plan.ts';
1416 import type { MModel } from './registry.ts';
1517 import { Geometry } from '../geom/geometry.ts';
1618 import { mGeometryByKey, defaultGeometryParams, SPHERE_KEY, type MGeometry } from '../geom/registry.ts';
@@ -36,6 +38,9 @@ export interface ModelSessionOptions {
3638 * is unrolled into the op sequence, so a change recompiles.
3739 */
3840 niter?: number;
41+ /** Wavelength of the seeded random field a model's `init` draws
42+ * (src/mgpu/randnfun3.ts). Redrawn on the next seed, never recompiled. */
43+ lam3?: number;
3944 }
4045
4146 export class ModelSession {
@@ -62,6 +67,10 @@ export class ModelSession {
6267 /** Display-only transforms on the oversampled grid; null at 1x. */
6368 #displaySht: ShtPlan | null;
6469 #oversample: number;
70+ /** Wavelength of the seeded random field, and the seed it was drawn from —
71+ * kept so changing one can redraw with the other unchanged. */
72+ #lam3: number;
73+ #seed = 1;
6574
6675 private constructor(init: {
6776 device: GPUDevice;
@@ -76,6 +85,7 @@ export class ModelSession {
7685 geometryModel: MGeometry;
7786 deriv: DerivPlan;
7887 niter: number;
88+ lam3: number;
7989 }) {
8090 this.device = init.device;
8191 this.model = init.model;
@@ -90,6 +100,7 @@ export class ModelSession {
90100 this.#geometryModel = init.geometryModel;
91101 this.#deriv = init.deriv;
92102 this.niter = init.niter;
103+ this.#lam3 = init.lam3;
93104 }
94105
95106 get geometry(): Geometry {
@@ -138,13 +149,13 @@ export class ModelSession {
138149 // buffers of numbers (the embedding, and both metric formulations built
139150 // on it: the inverse metric quantities and the flux-form weights).
140151 const geometry = await Geometry.create({
141- device,
142152 sht,
143153 cfg,
144154 source: opts.geometrySource ?? geometryModel.source,
145155 paramNames: geometryModel.params.map((p) => p.key),
146156 params: geometryParams,
147157 deriv,
158+ coeffs: geometryModel.coeffs,
148159 });
149160 const gpu = await GpuModel.create({
150161 device,
@@ -158,10 +169,11 @@ export class ModelSession {
158169 deriv,
159170 niter,
160171 });
161- gpu.setParams(params);
172+ const lam3 = opts.lam3 ?? DEFAULT_LAMBDA;
173+ gpu.setParams({ lam3, ...params });
162174 return new ModelSession({
163175 device, model, cfg, sht, displaySht, gpu, params, oversample,
164- geometry, geometryModel, deriv, niter,
176+ geometry, geometryModel, deriv, niter, lam3,
165177 });
166178 } catch (e) {
167179 // The transform plans own GPU buffers; do not leak them on a compile error.
@@ -194,13 +206,13 @@ export class ModelSession {
194206 source?: string,
195207 ): Promise<void> {
196208 const next = await Geometry.create({
197- device: this.device,
198209 sht: this.sht,
199210 cfg: this.cfg,
200211 source: source ?? geometryModel.source,
201212 paramNames: geometryModel.params.map((p) => p.key),
202213 params,
203214 deriv: this.#deriv,
215+ coeffs: geometryModel.coeffs,
204216 });
205217 this.#geometry = next;
206218 this.#geometryModel = geometryModel;
@@ -255,31 +267,96 @@ export class ModelSession {
255267 old?.destroy();
256268 }
257269
270+ /**
271+ * The coefficient table a model that calls `randnfun3` seeds from — drawn
272+ * over the current surface's bounding box, at the wavelength its own .m asked
273+ * for — or null for a model that seeds some other way. The draw is host-side
274+ * MATLAB (a few ms); the evaluation at every grid point is the GPU kernel
275+ * inside `init`.
276+ *
277+ * Separate from `seed` because the table is a function of space, not of a
278+ * grid: drawn once, it is the *same field* wherever it is evaluated, which is
279+ * how every variant of a comparison across lmax seeds from one random field
280+ * (see src/compare/sharedStart.ts).
281+ */
282+ drawSeedModes(seed: number): Promise<Float32Array | null> {
283+ const lambda = this.gpu.randnfun3Lambda;
284+ if (!lambda) return Promise.resolve(null);
285+ // Drawn on a worker: at a fine wavelength this is seconds of interpreter
286+ // time, and it must not be seconds of frozen page.
287+ return drawModesAsync(
288+ resolveLambda(lambda, this.#mergedParams()),
289+ boundingBox(this.#geometry.x, this.#geometry.y, this.#geometry.z),
290+ seed,
291+ this.npts,
292+ );
293+ }
294+
258295 /** Run `init` from a seeded perturbation, resetting model time. */
259- seed(seed: number): void {
260- this.seedWith(seededNoise(this.npts, this.model.seedAmp, seed));
296+ async seed(seed: number): Promise<void> {
297+ const modes = await this.drawSeedModes(seed);
298+ await this.seedWith(seededNoise(this.npts, this.model.seedAmp, seed), modes);
299+ this.#seed = seed;
261300 }
262301
263302 /**
264- * Run `init` from a caller-supplied perturbation field, resetting model time.
265- * `seed()` is this with the field the host's RNG produces on this session's
266- * grid; supplying the field instead is how several sessions on *different*
267- * grids can be started from the same band-limited initial condition, which is
268- * the only way a comparison across lmax compares one problem rather than two
269- * (see src/compare/sharedStart.ts).
303+ * Run `init` from a caller-supplied perturbation, resetting model time.
304+ * `seed()` is this with what this session would draw for itself: the host
305+ * RNG's field on its own grid, and its own random-field table. Supplying them
306+ * instead is how several sessions on *different* grids can be started from
307+ * the same initial condition, which is the only way a comparison across lmax
308+ * compares one problem rather than two (see src/compare/sharedStart.ts).
270309 */
271- seedWith(noise: Float32Array): void {
310+ async seedWith(noise: Float32Array, modes: Float32Array | null = null): Promise<void> {
272311 if (noise.length !== this.npts) {
273312 throw new Error(`seedWith: noise must have length ${this.npts} (got ${noise.length})`);
274313 }
275- this.gpu.init(noise);
314+ await this.gpu.init(noise, modes);
315+ this.t = 0;
316+ this.steps = 0;
317+ }
318+
319+ /**
320+ * Push an exact spectral state into the running model, bypassing seeded
321+ * init, and reset model time like seed() does. `gpu.step(0)` flips which
322+ * of the init/step buffer aliases a read resolves to, without otherwise
323+ * touching the state — see GpuModel's `#lastRan`.
324+ */
325+ loadState(coeffs: Record<string, Float32Array>): void {
326+ for (const name of this.model.state) {
327+ const data = coeffs[name];
328+ if (!data) throw new Error(`loadState: missing state '${name}'`);
329+ this.gpu.upload(name, data);
330+ }
331+ this.gpu.step(0);
276332 this.t = 0;
277333 this.steps = 0;
278334 }
279335
336+ /** The model's parameters plus the ones the host owns. */
337+ #mergedParams(): ModelParams {
338+ return { lam3: this.#lam3, ...this.#params };
339+ }
340+
280341 setParams(params: ModelParams): void {
281342 this.#params = params;
282- this.gpu.setParams(params);
343+ this.gpu.setParams(this.#mergedParams());
344+ }
345+
346+ /** Wavelength of the seeded random field. Redraws on the next seed. */
347+ get lam3(): number {
348+ return this.#lam3;
349+ }
350+
351+ /**
352+ * Change the random field's wavelength. Nothing recompiles — lam3 is a
353+ * uniform and the coefficient table is host-drawn — but the field itself
354+ * only changes on the next `seed`, which is where it is drawn.
355+ */
356+ setLam3(lambda: number): void {
357+ if (lambda === this.#lam3) return;
358+ this.#lam3 = lambda;
359+ this.gpu.setParams(this.#mergedParams());
283360 }
284361
285362 /** Advance `n` steps. Synchronous: records and submits, nothing read back. */
@@ -323,18 +400,32 @@ export class ModelSession {
323400 }
324401
325402 /**
326- * Read species `k` at render resolution (`viewSht`'s grid). Without
327- * oversampling this is the grid field the .m returned. With oversampling the
328- * spectral state is synthesized on the finer grid instead — the same field,
329- * since the models define each species as synth of its state, evaluated
330- * exactly on more points.
403+ * Read every state species back to the CPU, in the shape `loadState`
404+ * consumes — the capture side of that method's reload, so a caller can
405+ * hold onto the current spectral state and restore it exactly later
406+ * (e.g. a "restart to this run's initial condition" control). One at a
407+ * time, not `Promise.all`: every `read()` copies into the same shared
408+ * readback buffer (`GpuModel#readback`, model.ts:448-452), so two in
409+ * flight at once race `mapAsync` against each other's `unmap`.
410+ */
411+ async readState(): Promise<Record<string, Float32Array>> {
412+ const out: Record<string, Float32Array> = {};
413+ for (const name of this.model.state) out[name] = await this.read(name);
414+ return out;
415+ }
416+
417+ /**
418+ * Read species `k` at render resolution (`viewSht`'s grid): the spectral
419+ * state synthesized there. The models define each species as synth of its
420+ * state, so this is the field the .m returned — evaluated exactly, whatever
421+ * the grid — and it is current however the state last changed, including a
422+ * `loadState`, which runs no kernel that would write the grid-space fields.
331423 */
332424 readSpecies(k: number): Promise<Float32Array> {
333- if (!this.#displaySht) return this.read(this.model.species[k]);
334425 const state = this.model.state[k];
335426 const buf = this.gpu.valueBuffer(state);
336427 if (!buf) throw new Error(`readSpecies: no buffer for state '${state}'`);
337- return this.#displaySht.synthFrom(buf);
428+ return this.viewSht.synthFrom(buf);
338429 }
339430
340431 describe(): { init: string[]; step: string[] } {
src/raw.d.tsmodified+10−0View file
@@ -3,3 +3,13 @@ declare module '*?raw' {
33 const source: string;
44 export default source;
55 }
6+
7+/** Vite's `import.meta.glob`, used to load every .m in tools/ at once
8+ * (src/tools.ts). Only the eager + `?raw` form this project uses is
9+ * declared — it returns each match's text, keyed by path. */
10+interface ImportMeta {
11+ glob(
12+ pattern: string,
13+ options: { query: '?raw'; eager: true; import: 'default' },
14+ ): Record<string, string>;
15+}
src/render/errorChart.tsadded+328−0View file
@@ -0,0 +1,328 @@
1+/**
2+ * Live Δ(t) chart for a comparison study: one canvas, one line per
3+ * (variant, species) pair, sharing a log-scaled y-axis and an x-axis in
4+ * model time `t`. A variant's lines are colored with that variant's own
5+ * accent color (the same color shown on the left edge of its row of
6+ * panels); species are told apart within one color by line style (solid,
7+ * dashed, dotted, …), matched by the legend's dash swatches.
8+ *
9+ * Deliberately dumb — no library, just a canvas redrawn from scratch on every
10+ * push(). History is unbounded (a study is a "does it converge" question, and
11+ * capping the window would hide exactly the slow drift that question is
12+ * about); only the running log-min/max is maintained incrementally so a
13+ * redraw stays O(points on screen), not O(history) beyond what it already
14+ * draws.
15+ */
16+
17+import { fmtValue } from './colorbar.ts';
18+
19+export interface ErrorChartRow {
20+ label: string;
21+ color: string;
22+}
23+
24+/** Floor for the log scale — well below any error this app ever measures,
25+ * just far enough that log10 never sees zero or a negative. */
26+const MIN_LOG_VALUE = 1e-12;
27+
28+/** Canvas line-dash patterns by species index — solid, dashed, dotted,
29+ * repeating for a fourth-plus species (no shipped model has one). Mirrored
30+ * in index.html's `.cmp-chart-dash-{k}` border-style classes for the
31+ * legend swatches. */
32+const DASH_PATTERNS: number[][] = [[], [7, 4], [2, 3]];
33+
34+const MARGIN_LEFT = 44;
35+const MARGIN_BOTTOM = 38;
36+const MARGIN_TOP = 8;
37+const MARGIN_RIGHT = 10;
38+
39+export class ErrorChart {
40+ #container: HTMLElement;
41+ #species: string[];
42+ #rows: ErrorChartRow[];
43+ #canvas: HTMLCanvasElement | null = null;
44+
45+ #ts: number[] = [];
46+ /** #errs[k][i] is one species' one row's history, same length as #ts. */
47+ #errs: number[][][] = [];
48+ /** Running log10 extent across every (species, row) pushed — one shared
49+ * y-axis now, not one per species. */
50+ #minLog = Infinity;
51+ #maxLog = -Infinity;
52+
53+ constructor(container: HTMLElement, species: string[], rows: ErrorChartRow[]) {
54+ this.#container = container;
55+ this.#species = species;
56+ this.#rows = rows;
57+ this.#resetSeries();
58+ this.#build();
59+ }
60+
61+ #resetSeries(): void {
62+ this.#ts = [];
63+ this.#errs = this.#species.map(() => this.#rows.map(() => []));
64+ this.#minLog = Infinity;
65+ this.#maxLog = -Infinity;
66+ }
67+
68+ #build(): void {
69+ this.#container.replaceChildren();
70+ this.#canvas = null;
71+ if (this.#rows.length === 0 || this.#species.length === 0) return;
72+
73+ const legend = document.createElement('div');
74+ legend.className = 'cmp-chart-legend';
75+
76+ const variantGroup = document.createElement('div');
77+ variantGroup.className = 'cmp-chart-legend-group';
78+ for (const r of this.#rows) {
79+ const item = document.createElement('span');
80+ item.className = 'cmp-chart-legend-item';
81+ const swatch = document.createElement('i');
82+ swatch.style.background = r.color;
83+ item.append(swatch, document.createTextNode(r.label));
84+ variantGroup.append(item);
85+ }
86+
87+ const speciesGroup = document.createElement('div');
88+ speciesGroup.className = 'cmp-chart-legend-group cmp-chart-legend-species';
89+ this.#species.forEach((name, k) => {
90+ const item = document.createElement('span');
91+ item.className = 'cmp-chart-legend-item';
92+ const swatch = document.createElement('i');
93+ swatch.className = `cmp-chart-dash cmp-chart-dash-${k % DASH_PATTERNS.length}`;
94+ item.append(swatch, document.createTextNode(name));
95+ speciesGroup.append(item);
96+ });
97+
98+ legend.append(variantGroup, speciesGroup);
99+ this.#container.append(legend);
100+
101+ const canvas = document.createElement('canvas');
102+ canvas.className = 'cmp-chart-canvas';
103+ this.#container.append(canvas);
104+ this.#canvas = canvas;
105+ }
106+
107+ /** One frame's sample: `t` shared by every row, `perRowErr[i][k]` the
108+ * relative-L2 error of row i's species k against the reference. Rows here
109+ * are exactly the ones passed to the constructor, in the same order — the
110+ * reference row is never included (its Δ against itself is always 0). */
111+ push(t: number, perRowErr: number[][]): void {
112+ this.#ts.push(t);
113+ for (let k = 0; k < this.#species.length; k++) {
114+ for (let i = 0; i < this.#rows.length; i++) {
115+ const v = perRowErr[i]?.[k];
116+ this.#errs[k][i].push(v === undefined ? NaN : v);
117+ if (Number.isFinite(v) && v! > 0) {
118+ const lv = Math.log10(v!);
119+ if (lv < this.#minLog) this.#minLog = lv;
120+ if (lv > this.#maxLog) this.#maxLog = lv;
121+ }
122+ }
123+ }
124+ this.#draw();
125+ }
126+
127+ /** Back to no history — called wherever the study's clock itself resets
128+ * (restart, reseed), so the chart never shows a curve spanning a rewind. */
129+ reset(): void {
130+ this.#resetSeries();
131+ this.#draw();
132+ }
133+
134+ /** Redraw with the data as it stands — for a container resize, where
135+ * nothing new has been measured but the canvas backing buffer has to
136+ * change to match. */
137+ redraw(): void {
138+ this.#draw();
139+ }
140+
141+ dispose(): void {
142+ this.#container.replaceChildren();
143+ this.#canvas = null;
144+ }
145+
146+ #draw(): void {
147+ const canvas = this.#canvas;
148+ if (!canvas) return;
149+ const rect = canvas.getBoundingClientRect();
150+ const dpr = window.devicePixelRatio || 1;
151+ const cssW = Math.max(1, rect.width);
152+ const cssH = Math.max(1, rect.height || 240);
153+ const pxW = Math.max(1, Math.round(cssW * dpr));
154+ const pxH = Math.max(1, Math.round(cssH * dpr));
155+ if (canvas.width !== pxW) canvas.width = pxW;
156+ if (canvas.height !== pxH) canvas.height = pxH;
157+ const ctx = canvas.getContext('2d');
158+ if (!ctx) return;
159+ // Draw in CSS-pixel coordinates throughout; the transform alone accounts
160+ // for devicePixelRatio, rather than scaling every margin/font by hand.
161+ ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
162+ ctx.clearRect(0, 0, cssW, cssH);
163+
164+ const n = this.#ts.length;
165+ if (n < 2) return;
166+
167+ const rawLo = this.#minLog;
168+ const rawHi = this.#maxLog;
169+ if (!Number.isFinite(rawLo) || !Number.isFinite(rawHi)) return;
170+ let lo = rawLo;
171+ let hi = rawHi;
172+ if (hi - lo < 1e-6) {
173+ lo -= 0.5;
174+ hi += 0.5;
175+ }
176+ const pad = (hi - lo) * 0.08;
177+ lo -= pad;
178+ hi += pad;
179+
180+ const style = getComputedStyle(document.documentElement);
181+ const inkColor = style.getPropertyValue('--ink-2').trim() || '#666';
182+ const gridColor = style.getPropertyValue('--line').trim() || '#ccc';
183+
184+ const plotX = MARGIN_LEFT;
185+ const plotY = MARGIN_TOP;
186+ const plotW = Math.max(1, cssW - MARGIN_LEFT - MARGIN_RIGHT);
187+ const plotH = Math.max(1, cssH - MARGIN_TOP - MARGIN_BOTTOM);
188+
189+ const t0 = this.#ts[0];
190+ const t1 = this.#ts[n - 1];
191+ const tSpan = t1 - t0 || 1;
192+ const xAt = (t: number): number => plotX + ((t - t0) / tSpan) * plotW;
193+ const yAt = (v: number): number => {
194+ const lv = Math.max(lo, Math.min(hi, Math.log10(Math.max(v, MIN_LOG_VALUE))));
195+ return plotY + (1 - (lv - lo) / (hi - lo)) * plotH;
196+ };
197+
198+ ctx.font = '11px sans-serif';
199+
200+ // ---- y-axis: decade gridlines + ticks + labels -----------------------
201+ const yTicks = decadeTicks(lo, hi, rawLo, rawHi);
202+ ctx.textAlign = 'right';
203+ ctx.textBaseline = 'middle';
204+ for (const tick of yTicks) {
205+ const y = yAt(tick.value);
206+ ctx.strokeStyle = gridColor;
207+ ctx.globalAlpha = 0.3;
208+ ctx.lineWidth = 1;
209+ ctx.beginPath();
210+ ctx.moveTo(plotX, y);
211+ ctx.lineTo(plotX + plotW, y);
212+ ctx.stroke();
213+ ctx.globalAlpha = 1;
214+ ctx.beginPath();
215+ ctx.moveTo(plotX - 4, y);
216+ ctx.lineTo(plotX, y);
217+ ctx.stroke();
218+ ctx.fillStyle = inkColor;
219+ ctx.fillText(tick.label, plotX - 6, y);
220+ }
221+
222+ // ---- x-axis: ticks + labels --------------------------------------------
223+ const xTicks = evenTicks(t0, t1, 5);
224+ ctx.textAlign = 'center';
225+ ctx.textBaseline = 'top';
226+ ctx.strokeStyle = gridColor;
227+ for (const t of xTicks) {
228+ const x = xAt(t);
229+ ctx.beginPath();
230+ ctx.moveTo(x, plotY + plotH);
231+ ctx.lineTo(x, plotY + plotH + 4);
232+ ctx.stroke();
233+ ctx.fillStyle = inkColor;
234+ ctx.fillText(fmtValue(t), x, plotY + plotH + 6);
235+ }
236+
237+ // ---- axis frame ---------------------------------------------------------
238+ ctx.strokeStyle = gridColor;
239+ ctx.beginPath();
240+ ctx.moveTo(plotX, plotY);
241+ ctx.lineTo(plotX, plotY + plotH);
242+ ctx.lineTo(plotX + plotW, plotY + plotH);
243+ ctx.stroke();
244+
245+ // ---- axis titles ---------------------------------------------------------
246+ ctx.fillStyle = inkColor;
247+ ctx.textAlign = 'center';
248+ ctx.textBaseline = 'bottom';
249+ ctx.fillText('t', plotX + plotW / 2, cssH - 2);
250+
251+ ctx.save();
252+ ctx.translate(12, plotY + plotH / 2);
253+ ctx.rotate(-Math.PI / 2);
254+ ctx.textAlign = 'center';
255+ ctx.textBaseline = 'alphabetic';
256+ ctx.fillText('relative L2 error', 0, 0);
257+ ctx.restore();
258+
259+ // ---- the data: one line per (species, row) -----------------------------
260+ for (let k = 0; k < this.#species.length; k++) {
261+ const dash = DASH_PATTERNS[k % DASH_PATTERNS.length];
262+ for (let i = 0; i < this.#rows.length; i++) {
263+ const series = this.#errs[k][i];
264+ ctx.setLineDash(dash);
265+ ctx.strokeStyle = this.#rows[i].color;
266+ ctx.lineWidth = 1.5;
267+ ctx.beginPath();
268+ let started = false;
269+ for (let j = 0; j < n; j++) {
270+ const v = series[j];
271+ // A non-finite or non-positive sample (a diverged row, or a norm
272+ // with zero denominator) breaks the line rather than drawing a
273+ // spurious segment through a point that has no place on a log axis.
274+ if (!Number.isFinite(v) || v <= 0) {
275+ started = false;
276+ continue;
277+ }
278+ const x = xAt(this.#ts[j]);
279+ const y = yAt(v);
280+ if (started) ctx.lineTo(x, y);
281+ else ctx.moveTo(x, y);
282+ started = true;
283+ }
284+ ctx.stroke();
285+ }
286+ }
287+ ctx.setLineDash([]);
288+ }
289+}
290+
291+/**
292+ * Y-axis ticks: one per decade spanned by [lo, hi] (the padded plotting
293+ * range), labeled `1e{n}`. If the visible range covers less than a full
294+ * decade — a study that hasn't had time to spread out yet — decade ticks
295+ * would give zero or one of them, so fall back to two ticks at the actual
296+ * (unpadded) data extent instead, labeled with their real value.
297+ */
298+function decadeTicks(
299+ lo: number,
300+ hi: number,
301+ rawLo: number,
302+ rawHi: number,
303+): { value: number; label: string }[] {
304+ const start = Math.ceil(lo);
305+ const end = Math.floor(hi);
306+ const decades: number[] = [];
307+ for (let d = start; d <= end; d++) decades.push(d);
308+ if (decades.length >= 2) {
309+ return decades.map((d) => ({
310+ value: 10 ** d,
311+ label: (10 ** d).toExponential(0).replace('e+', 'e'),
312+ }));
313+ }
314+ const loVal = 10 ** rawLo;
315+ const hiVal = 10 ** rawHi;
316+ if (hiVal <= loVal) return [{ value: loVal, label: fmtValue(loVal) }];
317+ return [
318+ { value: loVal, label: fmtValue(loVal) },
319+ { value: hiVal, label: fmtValue(hiVal) },
320+ ];
321+}
322+
323+/** `count` evenly spaced ticks between t0 and t1 inclusive; just t0 if the
324+ * span is degenerate (a single point pushed so far). */
325+function evenTicks(t0: number, t1: number, count: number): number[] {
326+ if (t1 <= t0) return [t0];
327+ return Array.from({ length: count }, (_, i) => t0 + (i * (t1 - t0)) / (count - 1));
328+}
src/sht/layout.tsmodified+27−0View file
@@ -46,6 +46,33 @@ export function isPowerOfTwo(n: number): boolean {
4646 return n > 0 && (n & (n - 1)) === 0;
4747 }
4848
49+/**
50+ * Re-index coefficients from one (lmax, mmax) layout into another, dropping
51+ * the modes the target cannot hold and zero-filling the ones the source does
52+ * not have. Both truncation and padding are exact spectral operations —
53+ * truncation is orthogonal projection onto the smaller band, padding changes
54+ * nothing — so a surface carried as coefficients can be moved onto any plan.
55+ */
56+export function relayoutCoeffs(
57+ q: Float32Array,
58+ from: { lmax: number; mmax: number },
59+ to: { lmax: number; mmax: number },
60+): Float32Array {
61+ if (from.lmax === to.lmax && from.mmax === to.mmax) return q;
62+ const out = new Float32Array(2 * nlmCalc(to.lmax, to.mmax));
63+ const mTop = Math.min(from.mmax, to.mmax);
64+ const lTop = Math.min(from.lmax, to.lmax);
65+ for (let m = 0; m <= mTop; m++) {
66+ for (let l = m; l <= lTop; l++) {
67+ const src = 2 * lmIndex(from.lmax, l, m);
68+ const dst = 2 * lmIndex(to.lmax, l, m);
69+ out[dst] = q[src];
70+ out[dst + 1] = q[src + 1];
71+ }
72+ }
73+ return out;
74+}
75+
4976 /** Grid sizes for a given lmax, dealiased for a reaction of polynomial degree
5077 * `pdeg` (the rule from websph's reference implementation):
5178 * nlat >= ((pdeg+1)*lmax+1)/2, nphi >= (pdeg+1)*lmax+1. nphi is rounded up to
src/sht/sht.tsmodified+13−1View file
@@ -776,8 +776,20 @@ export async function requestShtDevice(): Promise<GPUDevice> {
776776 // timestamp-query is only used by the profiling scripts, but it has to be
777777 // requested at device creation, and asking costs nothing when unused.
778778 if (adapter.features.has('timestamp-query')) features.push('timestamp-query');
779+ // The seed field's mode table is the one buffer whose size is not fixed by
780+ // the grid — it grows with how fine a wavelength is asked for
781+ // (src/mgpu/randnfun3.ts), and a browser's default 128 MB storage-buffer
782+ // limit is well below what the adapter will actually give. Ask for the
783+ // adapter's own maximum so the wavelength is limited by the hardware rather
784+ // than by a default.
785+ const maxStorage = adapter.limits.maxStorageBufferBindingSize;
786+ const maxBuffer = adapter.limits.maxBufferSize;
779787 return adapter.requestDevice({
780788 requiredFeatures: features,
781- requiredLimits: { maxComputeWorkgroupStorageSize: wgStorage },
789+ requiredLimits: {
790+ maxComputeWorkgroupStorageSize: wgStorage,
791+ maxStorageBufferBindingSize: maxStorage,
792+ maxBufferSize: maxBuffer,
793+ },
782794 });
783795 }
src/tools.tsadded+27−0View file
@@ -0,0 +1,27 @@
1+/**
2+ * The shared MATLAB utilities in `tools/`, as interpreter workspace files.
3+ *
4+ * A geometry or a seeding draw is evaluated by numbl's interpreter (see
5+ * src/geom/geometry.ts), which resolves a call like `randnfunsphere(...)`
6+ * against the workspace files it is handed. Everything in `tools/` is handed
7+ * to every such run, so any .m can call any tool by name — MATLAB's own path
8+ * semantics, where the file name is the function name.
9+ *
10+ * These are *not* available to the models: a model's step compiles to WGSL,
11+ * where none of this exists.
12+ */
13+const sources = import.meta.glob('../tools/*.m', {
14+ query: '?raw',
15+ eager: true,
16+ import: 'default',
17+}) as Record<string, string>;
18+
19+export interface ToolFile {
20+ name: string;
21+ source: string;
22+}
23+
24+/** Every tool, named as MATLAB wants it (`randnfunsphere.m`). */
25+export const toolFiles: ToolFile[] = Object.entries(sources)
26+ .map(([path, source]) => ({ name: path.slice(path.lastIndexOf('/') + 1), source }))
27+ .sort((a, b) => a.name.localeCompare(b.name));
test/analyticChecks.tsmodified+2−3View file
@@ -65,7 +65,6 @@ async function makeModel(
6565 const sht = await ShtPlan.create(device, cfg);
6666 const deriv = await DerivPlan.create(device, sht);
6767 const geometry = await Geometry.create({
68- device,
6968 sht,
7069 cfg,
7170 source: mGeometryByKey(SPHERE_KEY)!.source,
@@ -163,7 +162,7 @@ export async function analyticChecks(
163162
164163 // Uniform initial field: stays uniform, and diffusion cannot touch it.
165164 const field = new Float32Array(npts).fill(u0);
166- gpu.init(field);
165+ await gpu.init(field, null);
167166 const Ustart = await gpu.read('U');
168167 gpu.step(nsteps);
169168 const Uend = await gpu.read('U');
@@ -220,7 +219,7 @@ export async function analyticChecks(
220219
221220 // Seed the exact homogeneous fixed point by handing init a zero
222221 // perturbation, then add a small single-mode bump to u only.
223- gpu.init(new Float32Array(npts));
222+ await gpu.init(new Float32Array(npts), null);
224223 const l = 24;
225224 const m = 7;
226225 const idx = lmIndex(lmax, l, m);
test/compareChecks.tsmodified+184−19View file
@@ -7,21 +7,25 @@
77 * that were never at the same time. Neither failure looks like a failure, which
88 * is exactly why they are pinned here.
99 *
10- * 1. One initial condition. Sessions at different lmax seeded through
11- * sharedNoise hold the *same* spectral state, zero-padded — and the
12- * control shows what that is owed to: seeded the ordinary per-grid way,
13- * the same integer seed gives two unrelated fields.
10+ * 1. One initial condition, in both of the ways a model can seed. A model
11+ * that calls `randnfun3` — every shipped one does — gets a field in space,
12+ * so one table drawn once is one field on every grid; the control is the
13+ * per-session draw the study must not do. A model that takes `noise` gets
14+ * one deviate per grid point, which sharedNoise has to project; the
15+ * control there is starker, since the same integer seed on two grids is
16+ * simply two unrelated fields.
1417 *
1518 * 2. One clock. dt varies by a power-of-two divisor, so `steps * dt` is
1619 * bit-identical across variants and no comparison is ever made across a
1720 * fraction of a timestep.
1821 *
19- * Deliberately small — two sessions at niter 1, lmax 31 and 63 — because a
22+ * Deliberately small — pairs of sessions at niter 1, lmax 31 and 63 — because a
2023 * session compiles its whole unrolled step and this suite has to stay short.
2124 */
2225 import { ModelSession } from '../src/mgpu/session.ts';
23-import { mModelByKey, defaultParams } from '../src/mgpu/registry.ts';
24-import { prolongCoeffs, sharedNoise } from '../src/compare/sharedStart.ts';
26+import { mModelByKey, defaultParams, type MModel, type ParamSpec } from '../src/mgpu/registry.ts';
27+import { prolongCoeffs, sharedNoise, sharedModes } from '../src/compare/sharedStart.ts';
28+import linearSource from './models/linear.m?raw';
2529 import { lmIndex, nlmCalc } from '../src/sht/layout.ts';
2630 import { crossProduct, mostResolved } from '../src/compare/variants.ts';
2731 import { floorRange } from '../src/render/colorbar.ts';
@@ -71,7 +75,74 @@ export async function compareChecks(
7175 );
7276 }
7377
74- // ---- one initial condition across lmax, and the control ------------------
78+ // ---- a file's exact state loads onto every grid --------------------------
79+ // What a reference-file study does instead of seeding: the file's spectral
80+ // state pushed into each variant by loadState, prolonged into its band. The
81+ // load is a plain upload, so the state must come back bit-exact; and read on
82+ // one shared grid the variants must then show one field, because synthesis
83+ // of the same band-limited coefficients is evaluation, not resampling.
84+ {
85+ const model = mModelByKey('allencahn')!;
86+ const params = defaultParams(model);
87+ const sessions: ModelSession[] = [];
88+ try {
89+ for (const lmax of [COARSE, FINE]) {
90+ sessions.push(await ModelSession.create({ device, model, params, lmax, niter: 0 }));
91+ }
92+ const [coarse, fine] = sessions;
93+ // A deterministic band-limited state, decaying like a real spectrum;
94+ // m = 0 imaginary parts stay zero (the state is a real field).
95+ const q = new Float32Array(2 * nlmCalc(COARSE, COARSE));
96+ for (let m = 0; m <= COARSE; m++) {
97+ for (let l = m; l <= COARSE; l++) {
98+ const i = 2 * lmIndex(COARSE, l, m);
99+ const amp = Math.exp(-l / 6);
100+ q[i] = amp * Math.sin(1 + 3 * l + 7 * m);
101+ q[i + 1] = m === 0 ? 0 : amp * Math.cos(2 + 5 * l + 11 * m);
102+ }
103+ }
104+ coarse.loadState({ U: q });
105+ fine.loadState({ U: prolongCoeffs(q, COARSE, FINE) });
106+
107+ const back = await coarse.read('U');
108+ let exact = back.length === q.length;
109+ if (exact) {
110+ for (let i = 0; i < q.length; i++) {
111+ if (back[i] !== q[i]) {
112+ exact = false;
113+ break;
114+ }
115+ }
116+ }
117+ check(
118+ 'compare: loadState puts the exact coefficients in the state',
119+ exact,
120+ `${q.length} float32 values round-tripped bit-exact at lmax ${COARSE}`,
121+ );
122+
123+ // The coarse session's own solver grid, so its display plan is the
124+ // solver's — the branch a crowded study lands on.
125+ for (const s of sessions) await s.setDisplayGrid(64, 128);
126+ const cu = await coarse.readSpecies(0);
127+ const fu = await fine.readSpecies(0);
128+ let maxd = 0;
129+ let scale = 0;
130+ for (let i = 0; i < cu.length; i++) {
131+ maxd = Math.max(maxd, Math.abs(cu[i] - fu[i]));
132+ scale = Math.max(scale, Math.abs(cu[i]));
133+ }
134+ check(
135+ 'compare: one loaded state reads back as one field on a shared grid',
136+ maxd < 1e-4 * scale,
137+ `max |du| = ${maxd.toExponential(2)} vs max |u| = ${scale.toExponential(2)} ` +
138+ `across lmax ${COARSE} vs ${FINE}`,
139+ );
140+ } finally {
141+ for (const s of sessions) s.destroy();
142+ }
143+ }
144+
145+ // ---- one random field across lmax: the shipped models' seeding -----------
75146 {
76147 const model = mModelByKey('schnakenberg')!;
77148 const params = defaultParams(model);
@@ -82,9 +153,72 @@ export async function compareChecks(
82153 }
83154 const [coarse, fine] = sessions;
84155
85- // What the study does: one band-limited field, evaluated on each grid.
156+ // What the study does: one coefficient table, drawn once, summed on each
157+ // variant's own grid points. The residual is the coarse grid's analysis of
158+ // a field with a little content above its band, not a difference in the
159+ // field -- so it is bounded by the perturbation, not by |U|, which is why
160+ // compareStates measures against the non-constant part.
86161 const noise = await sharedNoise(sessions, model.seedAmp, 1);
87- sessions.forEach((s, i) => s.seedWith(noise[i]));
162+ const modes = await sharedModes(fine, 1);
163+ check(
164+ 'compare: a randnfun3 model seeds every variant from one drawn table',
165+ modes !== null,
166+ modes ? `${modes[0]} Fourier modes, one table for both grids` : 'no table drawn',
167+ );
168+ for (let i = 0; i < sessions.length; i++) await sessions[i].seedWith(noise[i], modes);
169+ const shared = compareStates(
170+ prolongCoeffs(await coarse.read('U'), COARSE, FINE),
171+ await fine.read('U'),
172+ );
173+ check(
174+ 'compare: one shared random field gives both grids the same state',
175+ shared.rel < 1e-3,
176+ `max |dU| = ${shared.abs.toExponential(2)} ` +
177+ `(${(100 * shared.rel).toFixed(3)}% of the perturbation, max ` +
178+ `${shared.scale.toExponential(2)}) across lmax ${COARSE} vs ${FINE}`,
179+ );
180+
181+ // The control, and the reason `sharedModes` exists: left to seed itself
182+ // each session draws over *its own* bounding box, and a box is grid
183+ // samples of the surface, so the two draws are near neighbours rather than
184+ // one field. A tolerance is not what separates them — the shared table is
185+ // simply closer, and would be however either number moved.
186+ await coarse.seed(1);
187+ await fine.seed(1);
188+ const own = compareStates(
189+ prolongCoeffs(await coarse.read('U'), COARSE, FINE),
190+ await fine.read('U'),
191+ );
192+ check(
193+ 'compare: control — a per-session draw is not the same field',
194+ own.abs > shared.abs,
195+ `per-session draws differ by ${own.abs.toExponential(2)}, ` +
196+ `${(own.abs / Math.max(shared.abs, 1e-30)).toFixed(1)}x the shared table's ` +
197+ `${shared.abs.toExponential(2)}`,
198+ );
199+ } finally {
200+ for (const s of sessions) s.destroy();
201+ }
202+ }
203+
204+ // ---- one grid-point perturbation across lmax, and the control ------------
205+ // The other way a model can seed: `init(noise)` takes the host's field
206+ // directly, one deviate per grid point (the test models here, and any .m
207+ // edited to do it). Nothing about it is a function of space, so this is the
208+ // case sharedNoise's projection is for — and the case where the same integer
209+ // seed on two grids gives two entirely unrelated initial conditions.
210+ {
211+ const params = { c: 0, D: 1e-3, dt: 0.05 };
212+ const model = noiseModel();
213+ const sessions: ModelSession[] = [];
214+ try {
215+ for (const lmax of [COARSE, FINE]) {
216+ sessions.push(await ModelSession.create({ device, model, params, lmax, niter: 1 }));
217+ }
218+ const [coarse, fine] = sessions;
219+
220+ const noise = await sharedNoise(sessions, model.seedAmp, 1);
221+ for (let i = 0; i < sessions.length; i++) await sessions[i].seedWith(noise[i]);
88222 const shared = compareStates(
89223 prolongCoeffs(await coarse.read('U'), COARSE, FINE),
90224 await fine.read('U'),
@@ -93,15 +227,12 @@ export async function compareChecks(
93227 'compare: one shared perturbation gives both grids the same state',
94228 shared.rel < 5e-5,
95229 `max |dU| = ${shared.abs.toExponential(2)} ` +
96- `(${(100 * shared.rel).toFixed(4)}% of max |U| = ${shared.scale.toFixed(3)}) ` +
230+ `(${(100 * shared.rel).toFixed(4)}% of max |U| = ${shared.scale.toExponential(2)}) ` +
97231 `across lmax ${COARSE} vs ${FINE}`,
98232 );
99233
100- // The control: the ordinary per-grid seeding these two would otherwise
101- // get. One deviate per grid point, and the grids differ, so the same
102- // integer seed is two different initial conditions -- comparing runs
103- // started this way would report a difference that is entirely the seed.
104- sessions.forEach((s) => s.seed(1));
234+ await coarse.seed(1);
235+ await fine.seed(1);
105236 const plain = compareStates(
106237 prolongCoeffs(await coarse.read('U'), COARSE, FINE),
107238 await fine.read('U'),
@@ -189,8 +320,42 @@ export async function compareChecks(
189320 }
190321 }
191322
192-/** Max absolute difference of two equal-length spectral states, and that
193- * difference relative to the scale of the reference. */
323+/**
324+ * The one-species linear test model, seeded from `noise` rather than from a
325+ * random field — `init(noise)`, so the host's grid-point field is what reaches
326+ * the state (test/models/linear.m). Never stepped here; the parameters exist
327+ * because the .m names them.
328+ */
329+function noiseModel(): MModel {
330+ const param = (key: string): ParamSpec => ({
331+ key, label: key, value: 0, min: -1e9, max: 1e9, step: 1,
332+ });
333+ return {
334+ key: 'linear',
335+ label: 'linear',
336+ blurb: '',
337+ species: ['u'],
338+ state: ['U'],
339+ params: ['c', 'D', 'dt'].map(param),
340+ pdeg: 1,
341+ seedAmp: 1e-2,
342+ source: linearSource,
343+ };
344+}
345+
346+/**
347+ * Max absolute difference of two equal-length spectral states, and that
348+ * difference relative to the scale of the reference's *non-constant* part —
349+ * every coefficient but (l, m) = (0, 0), which is index 0 in either layout.
350+ *
351+ * Normalizing against the whole state would hide the question. A model seeded as
352+ * a perturbation of a uniform steady state puts that state in (0, 0) alone, two
353+ * orders above everything else, so |dU| / max |U| would report a comfortable
354+ * fraction of the *background* however unrelated the two perturbations were —
355+ * including when no perturbation arrived at all, which is what a table that
356+ * never reaches a session looks like. Against the perturbation, that failure
357+ * reads as a ratio of 1.
358+ */
194359 function compareStates(
195360 a: Float32Array,
196361 b: Float32Array,
@@ -200,7 +365,7 @@ function compareStates(
200365 const n = Math.min(a.length, b.length);
201366 for (let i = 0; i < n; i++) {
202367 abs = Math.max(abs, Math.abs(a[i] - b[i]));
203- scale = Math.max(scale, Math.abs(b[i]));
368+ if (i >= 2) scale = Math.max(scale, Math.abs(b[i]));
204369 }
205370 return { abs, rel: scale > 0 ? abs / scale : Infinity, scale };
206371 }
test/fluxChecks.tsmodified+79−6View file
@@ -30,6 +30,17 @@
3030 * non-axisymmetric surface, where the off-diagonal weight p2 actually does
3131 * something. The headline transform count (6 vs 12 per species per
3232 * iteration) is asserted from the compiled op sequences, not the doc.
33+ *
34+ * 3. The polar conditioning of the divergence (doc Sec 5). r ~ 1/sin^2(theta)
35+ * multiplies a bracket that must cancel to O(sin^2(theta)) at the poles,
36+ * so it amplifies the polar round-off of whatever it is handed. Splitting
37+ * the round sphere out of the divergence (models/schnakenberg.m, and
38+ * dp1/dq2/jinv in src/geom/geometry.ts) keeps r off all but the geometry
39+ * deviation; without the split, the amplified round-off is a static polar
40+ * forcing that a Turing instability grows into a spot at the pole,
41+ * regardless of the seed. That is the failure this checks for: it is
42+ * invisible to 1 and 2, which compare operators rather than watch what a
43+ * run nucleates from.
3344 */
3445 import { ShtPlan } from '../src/sht/sht.ts';
3546 import { DerivPlan } from '../src/sht/deriv.ts';
@@ -239,7 +250,7 @@ export async function fluxChecks(
239250 const sht = await ShtPlan.create(device, cfg);
240251 const deriv = await DerivPlan.create(device, sht);
241252 const geometry = await Geometry.create({
242- device, sht, cfg,
253+ sht, cfg,
243254 source: g.source,
244255 paramNames: g.params.map((p) => p.key),
245256 params: defaultGeometryParams(g),
@@ -359,7 +370,7 @@ export async function fluxChecks(
359370 ).length,
360371 );
361372 if (niter === 1) {
362- session.seed(1);
373+ await session.seed(1);
363374 session.step(STEPS);
364375 states.push(await session.read('U'));
365376 }
@@ -368,13 +379,15 @@ export async function fluxChecks(
368379 xformsPerIter.push(counts[1] - counts[0]);
369380 }
370381
371- // The headline number, from the compiled op sequences: 5 Legendre
382+ // The headline number, from the compiled op sequences: 6 Legendre
372383 // transforms per species per iteration against Algorithm 4's 12
373- // (2 species here). The phi flux's derivative runs as dphig -- two
384+ // (2 species here). Five of the six are the flux matvec; the sixth is
385+ // the round-sphere synthesis the divergence split buys its polar
386+ // conditioning with. The phi flux's derivative runs as dphig -- two
374387 // Fourier stages, no Legendre work -- and is deliberately not counted.
375388 check(
376- 'flux: 5 Legendre transforms per species per iteration, versus 12',
377- xformsPerIter[0] === 10 && xformsPerIter[1] === 24,
389+ 'flux: 6 Legendre transforms per species per iteration, versus 12',
390+ xformsPerIter[0] === 12 && xformsPerIter[1] === 24,
378391 `flux form adds ${xformsPerIter[0]} transforms/iteration, ` +
379392 `Algorithm 4 adds ${xformsPerIter[1]}`,
380393 );
@@ -399,5 +412,65 @@ export async function fluxChecks(
399412 `max |U_flux - U_alg4| = ${worst.toExponential(2)} after ${STEPS} steps ` +
400413 `on bumpy at lmax ${LMAX_AB}`,
401414 );
415+
416+ // ---- 3. the correction must not manufacture its own perturbation -----
417+ //
418+ // From the exact uniform steady state, with the Turing band switched off
419+ // (D1 = D2) so nothing can grow on its own, the only thing driving the
420+ // state away from uniform is round-off. niter = 0 never touches the flux
421+ // machinery and sets the floor; niter = 3 runs it three times per step.
422+ // The ratio is the correction's noise gain. Sphere-split it is O(1); with
423+ // r multiplying the whole divergence it was ~50 at lmax 63, and that
424+ // margin is what decides where a pattern nucleates. The ellipsoid is the
425+ // case to run it on: axisymmetric grid, strongly non-spherical geometry.
426+ const model = mModelByKey('schnakenberg')!;
427+ const quiet = model.source.replace(
428+ /function \[U, V, u, v\] = init\([\s\S]*?\nend/,
429+ `function [U, V, u, v] = init(lam3, gx, gy, gz, a, b)
430+ us = a + b;
431+ vs = b / (us * us);
432+ [U, V] = analys(us * ones(numel(gx), 1), vs * ones(numel(gx), 1));
433+ [u, v] = synth(U, V);
434+end`,
435+ );
436+ if (quiet === model.source) throw new Error('quiet-start fixture no longer matches schnakenberg.m');
437+ const ell = mGeometryByKey('ellipsoid')!;
438+ const noise: number[] = [];
439+ for (const niter of [0, 3]) {
440+ const session = await ModelSession.create({
441+ device,
442+ model,
443+ params: { ...defaultParams(model), D2: defaultParams(model).D1 },
444+ lmax: LMAX_AB,
445+ source: quiet,
446+ niter,
447+ geometry: ell,
448+ geometryParams: defaultGeometryParams(ell),
449+ });
450+ await session.seed(1);
451+ session.step(400);
452+ const U = await session.read('U');
453+ // Everything above the mean: l = 0, m = 0 is the uniform state itself.
454+ let sum = 0;
455+ for (let m = 0; m <= LMAX_AB; m++) {
456+ for (let l = Math.max(m, 1); l <= LMAX_AB; l++) {
457+ const i = lmIndex(LMAX_AB, l, m);
458+ sum += (U[2 * i] ** 2 + U[2 * i + 1] ** 2) * (m === 0 ? 1 : 2);
459+ }
460+ }
461+ noise.push(Math.sqrt(sum));
462+ session.destroy();
463+ }
464+ const gain = noise[1] / noise[0];
465+ log(
466+ ` flux polar noise gain on ellipsoid: ||U'|| ${noise[0].toExponential(2)} ` +
467+ `at niter 0, ${noise[1].toExponential(2)} at niter 3`,
468+ );
469+ check(
470+ 'flux: the geometric correction does not amplify polar round-off',
471+ Number.isFinite(gain) && gain < 5,
472+ `niter-3 round-off is ${gain.toFixed(1)}x the niter-0 floor ` +
473+ `(sphere-split: ~1; r on the whole divergence: ~50)`,
474+ );
402475 }
403476 }
test/geometryChecks.tsmodified+307−12View file
@@ -37,6 +37,7 @@ import {
3737 SPHERE_KEY,
3838 } from '../src/geom/registry.ts';
3939 import { ModelCompileError } from '../src/mgpu/errors.ts';
40+import { boundingBox, drawModes, DEFAULT_LAMBDA } from '../src/mgpu/randnfun3.ts';
4041 import type { Check, Log } from './analyticChecks.ts';
4142
4243 const LMAX = 31;
@@ -56,7 +57,6 @@ async function buildGeometry(device: GPUDevice, key: string) {
5657 const sht = await ShtPlan.create(device, cfg);
5758 const deriv = await DerivPlan.create(device, sht);
5859 const geometry = await Geometry.create({
59- device,
6060 sht,
6161 cfg,
6262 source: g.source,
@@ -282,6 +282,127 @@ export async function geometryChecks(
282282 sht.destroy();
283283 }
284284
285+ // ---- a surface handed over as coefficients is the same surface -----------
286+ // The import path (reharm's "Export to turing-surface"): Geometry.create
287+ // with `coeffs` set evaluates no .m — the coefficients are re-indexed onto
288+ // the session's band and everything from synthesis onward runs unchanged.
289+ // Checked in all three band relations: same (must reproduce the original
290+ // geometry, arrays and all), wider (zero-padding; the surface must still
291+ // match its closed form on the finer plan's own grid), and narrower
292+ // (truncation; exact when the content fits the smaller band, as the
293+ // degree-1 sphere does).
294+ {
295+ const importGeometry = async (
296+ coeffs: { lmax: number; mmax: number; X: Float32Array; Y: Float32Array; Z: Float32Array },
297+ lmax: number,
298+ ) => {
299+ const { nlat, nphi } = gridForLmax(lmax, 3);
300+ const cfg = { lmax, mmax: lmax, nlat, nphi };
301+ const sht = await ShtPlan.create(device, cfg);
302+ const deriv = await DerivPlan.create(device, sht);
303+ const geometry = await Geometry.create({
304+ sht, cfg, source: '', paramNames: [], params: {}, deriv, coeffs,
305+ });
306+ return { sht, deriv, cfg, geometry };
307+ };
308+
309+ const peanut = await buildGeometry(device, 'peanut');
310+ const coeffs = {
311+ lmax: peanut.cfg.lmax,
312+ mmax: peanut.cfg.mmax,
313+ X: peanut.geometry.X,
314+ Y: peanut.geometry.Y,
315+ Z: peanut.geometry.Z,
316+ };
317+
318+ // Same band: the identical pipeline on identical inputs, so the surface
319+ // and both metric formulations must come back numerically unchanged.
320+ const same = await importGeometry(coeffs, peanut.cfg.lmax);
321+ let worst = 0;
322+ const pairs: [Float32Array, Float32Array][] = [
323+ [same.geometry.x, peanut.geometry.x],
324+ [same.geometry.y, peanut.geometry.y],
325+ [same.geometry.z, peanut.geometry.z],
326+ [same.geometry.p1, peanut.geometry.p1],
327+ [same.geometry.p2, peanut.geometry.p2],
328+ [same.geometry.q2, peanut.geometry.q2],
329+ [same.geometry.r, peanut.geometry.r],
330+ [same.geometry.Vtx, peanut.geometry.Vtx],
331+ [same.geometry.Vpx, peanut.geometry.Vpx],
332+ ];
333+ for (const [a, b] of pairs) {
334+ for (let i = 0; i < a.length; i++) {
335+ worst = Math.max(worst, Math.abs(a[i] - b[i]));
336+ }
337+ }
338+ check(
339+ 'import: coefficients rebuild the identical geometry on the same band',
340+ worst <= 1e-7,
341+ `max |difference| = ${worst.toExponential(2)} over surface and metric`,
342+ );
343+ same.deriv.destroy();
344+ same.sht.destroy();
345+
346+ // Wider band: zero-padded onto a finer plan, then checked against the
347+ // .m's own closed form on that plan's grid — points the original build
348+ // never evaluated (Gauss latitudes do not nest).
349+ const p = defaultGeometryParams(peanut.g);
350+ const peanutRadius = (ct: number): number => {
351+ const st2 = Math.max(0, 1 - ct * ct);
352+ const r = 1 - p.waist * st2;
353+ return r * Math.hypot(Math.sqrt(st2), (1 + p.stretch) * ct);
354+ };
355+ const wide = await importGeometry(coeffs, 2 * peanut.cfg.lmax + 1);
356+ let dr = 0;
357+ for (let i = 0; i < wide.cfg.nlat; i++) {
358+ const want = peanutRadius(wide.sht.cosTheta[i]);
359+ for (let j = 0; j < wide.cfg.nphi; j++) {
360+ const k = i * wide.cfg.nphi + j;
361+ const got = Math.hypot(wide.geometry.x[k], wide.geometry.y[k], wide.geometry.z[k]);
362+ dr = Math.max(dr, Math.abs(got - want));
363+ }
364+ }
365+ check(
366+ 'import: zero-padding onto a wider band keeps the surface',
367+ dr < 1e-3,
368+ `max |dr| = ${dr.toExponential(2)} at lmax ${wide.cfg.lmax}`,
369+ );
370+ wide.deriv.destroy();
371+ wide.sht.destroy();
372+ peanut.deriv.destroy();
373+ peanut.sht.destroy();
374+
375+ // Narrower band: truncation is orthogonal projection, so a surface whose
376+ // content fits the smaller band comes through exactly — the sphere is
377+ // degree 1 and must still have radius 1 after import at any lmax.
378+ const sphere = await buildGeometry(device, SPHERE_KEY);
379+ const narrow = await importGeometry(
380+ {
381+ lmax: sphere.cfg.lmax,
382+ mmax: sphere.cfg.mmax,
383+ X: sphere.geometry.X,
384+ Y: sphere.geometry.Y,
385+ Z: sphere.geometry.Z,
386+ },
387+ Math.floor(sphere.cfg.lmax / 2),
388+ );
389+ let radiusErr = 0;
390+ for (let k = 0; k < narrow.geometry.x.length; k++) {
391+ const r = Math.hypot(narrow.geometry.x[k], narrow.geometry.y[k], narrow.geometry.z[k]);
392+ radiusErr = Math.max(radiusErr, Math.abs(r - 1));
393+ }
394+ check(
395+ 'import: truncation onto a narrower band is exact when the content fits',
396+ radiusErr < 2e-3,
397+ `sphere from lmax ${sphere.cfg.lmax} coefficients at lmax ${narrow.cfg.lmax}: ` +
398+ `max |r - 1| = ${radiusErr.toExponential(2)}`,
399+ );
400+ narrow.deriv.destroy();
401+ narrow.sht.destroy();
402+ sphere.deriv.destroy();
403+ sphere.sht.destroy();
404+ }
405+
285406 // ---- the unrolled loop: more ops, identical answer ----------------------
286407 {
287408 const model = mModelByKey('schnakenberg')!;
@@ -295,7 +416,7 @@ export async function geometryChecks(
295416 device, model, params, lmax: LMAX, niter,
296417 });
297418 ops.push(session.describe().step.length);
298- session.seed(1);
419+ await session.seed(1);
299420 session.step(STEPS);
300421 states.push(await session.read('U'));
301422 session.destroy();
@@ -310,14 +431,15 @@ export async function geometryChecks(
310431 `${ops.join(' < ')} ops for ${counts.join(', ')} iterations`,
311432 );
312433 // Unrolling has to be exactly linear in the trip count: the body planned
313- // once per iteration, no more and no less. Per species per iteration: 3
434+ // once per iteration, no more and no less. Per species per iteration: 4
314435 // synths + 2 analyses (the flux-form matvec's five Legendre transforms,
315- // docs/reduced-transforms.md Sec 4 with the dphig variation) + the
316- // grid-space phi-derivative + 3 coefficient-space shuffles plus 7
317- // generated kernels -- see test/modelChecks.ts's KERNELS_PER_ITERATION,
318- // which counts the kernels alone; this counts every op.
436+ // docs/reduced-transforms.md Sec 4 with the dphig variation, plus the
437+ // round-sphere synthesis of the divergence split) + the grid-space
438+ // phi-derivative + 3 coefficient-space shuffles plus 8 generated kernels
439+ // -- see test/modelChecks.ts's KERNELS_PER_ITERATION, which counts the
440+ // kernels alone; this counts every op.
319441 const perIteration = ops[1] - ops[0];
320- const want = 32;
442+ const want = 36;
321443 check(
322444 'loop: unrolling is exactly linear in the trip count',
323445 perIteration === want && ops[2] - ops[0] === 4 * perIteration,
@@ -361,7 +483,7 @@ export async function geometryChecks(
361483 device, model, params, lmax: SWEEP_LMAX,
362484 geometry: peanut, geometryParams: peanutParams, niter,
363485 });
364- session.seed(1);
486+ await session.seed(1);
365487 session.step(STEPS);
366488 states.push(await session.read('U'));
367489 session.destroy();
@@ -416,7 +538,7 @@ export async function geometryChecks(
416538 geometry: geomSpec, geometryParams: defaultGeometryParams(geomSpec),
417539 niter,
418540 });
419- session.seed(1);
541+ await session.seed(1);
420542 session.step(STEPS);
421543 const values = await session.read('u');
422544 const finite = values.every((v) => Number.isFinite(v));
@@ -469,7 +591,7 @@ export async function geometryChecks(
469591 `rate ${((g.muMax - g.muMin) / (g.muMax + g.muMin)).toFixed(3)} ` +
470592 `vs plain ${(g.muMax - 1).toFixed(2)}`;
471593 }
472- session.seed(1);
594+ await session.seed(1);
473595 session.step(STEPS);
474596 const values = await session.read('u');
475597 outcomes.push(values.every((v) => Number.isFinite(v)));
@@ -512,7 +634,7 @@ export async function geometryChecks(
512634 const session = await ModelSession.create({
513635 device, model, params: defaultParams(model), lmax: LMAX,
514636 });
515- session.seed(1);
637+ await session.seed(1);
516638 session.step(STEPS);
517639 const before = await session.read('U');
518640
@@ -534,6 +656,179 @@ export async function geometryChecks(
534656 );
535657 session.destroy();
536658 }
659+
660+ await randnfun3Checks(device, check, log);
661+}
662+
663+/**
664+ * The seeded initial condition: chebfun's randnfun3, drawn on the host and
665+ * summed on the GPU (src/mgpu/randnfun3.ts).
666+ *
667+ * The split is the thing worth testing. The draw is MATLAB whose distribution
668+ * is checked directly, and the sum is a WGSL kernel checked against the same
669+ * modes evaluated in f64 on the CPU — if the kernel's indexing into the packed
670+ * mode table were wrong it would still produce a smooth random-looking field,
671+ * which is exactly the kind of wrong no "looks patterned" check would catch.
672+ */
673+async function randnfun3Checks(
674+ device: GPUDevice,
675+ check: Check,
676+ log: Log,
677+): Promise<void> {
678+ const model = mModelByKey('schnakenberg')!;
679+ const params = defaultParams(model);
680+ const make = (lam3: number): Promise<ModelSession> =>
681+ ModelSession.create({ device, model, params, lmax: LMAX, lam3 });
682+
683+ // ---- the GPU sum matches the same modes evaluated on the CPU -----------
684+ {
685+ const session = await make(DEFAULT_LAMBDA);
686+ await session.seed(3);
687+ // `u` after init is the steady state plus 0.01*f, so the field is
688+ // recovered by removing the model's own uniform offset.
689+ const u = await session.read('u');
690+ const g = session.geometry;
691+ const modes = drawModes(
692+ DEFAULT_LAMBDA,
693+ boundingBox(g.x, g.y, g.z),
694+ 3,
695+ g.x.length,
696+ );
697+ const nmodes = modes[0];
698+
699+ // The same sum in f64, straight from the packed table the GPU read.
700+ let maxErr = 0;
701+ let amp = 0;
702+ const us = params.a + params.b;
703+ for (let i = 0; i < g.x.length; i++) {
704+ let f = 0;
705+ for (let j = 0; j < nmodes; j++) {
706+ const b = 4 + 5 * j;
707+ const t = modes[b] * g.x[i] + modes[b + 1] * g.y[i] + modes[b + 2] * g.z[i];
708+ f += modes[b + 3] * Math.cos(t) - modes[b + 4] * Math.sin(t);
709+ }
710+ const want = us + 0.01 * f;
711+ maxErr = Math.max(maxErr, Math.abs(u[i] - want));
712+ amp = Math.max(amp, Math.abs(0.01 * f));
713+ }
714+ log(` randnfun3: ${nmodes} modes at lambda ${DEFAULT_LAMBDA}, |perturbation| up to ${amp.toExponential(2)}`);
715+ check(
716+ 'randnfun3: the GPU sum matches the same modes summed on the CPU',
717+ // fp32 over ~1400 terms against f64. What is being bounded is the
718+ // summation floor, and its size is the backend's accumulation order:
719+ // Metal lands at 2.0e-6, SwiftShader at 3.8e-6, so an absolute constant
720+ // tuned on one is a coin flip on the other. Scale it to the field
721+ // instead. The bug this exists to catch -- a mis-indexed read into the
722+ // packed table, which would still look like a smooth random field -- is
723+ // wrong by O(amp), a thousand times over the bound.
724+ maxErr < 1e-3 * amp && amp > 1e-3,
725+ `max |GPU - CPU| = ${maxErr.toExponential(2)}, perturbation amplitude ${amp.toExponential(2)}`,
726+ );
727+ session.destroy();
728+ }
729+
730+ // ---- a seed reproduces, a different seed does not ----------------------
731+ {
732+ const a = await make(DEFAULT_LAMBDA);
733+ await a.seed(11);
734+ const first = await a.read('u');
735+ await a.seed(11);
736+ const again = await a.read('u');
737+ await a.seed(12);
738+ const other = await a.read('u');
739+ let same = true;
740+ let differs = false;
741+ for (let i = 0; i < first.length; i++) {
742+ if (first[i] !== again[i]) same = false;
743+ if (first[i] !== other[i]) differs = true;
744+ }
745+ check(
746+ 'randnfun3: the same seed redraws the same field, a different one does not',
747+ same && differs,
748+ same ? (differs ? 'reproducible and seed-dependent' : 'seed 12 gave seed 11 back') : 'not reproducible',
749+ );
750+ a.destroy();
751+ }
752+
753+ // ---- the field is smooth, and lambda sets how smooth -------------------
754+ //
755+ // This is what randnfun3 buys over the white noise it replaced: the seed is
756+ // band-limited, so it is fully resolved by the grid instead of being
757+ // whatever the grid happened to alias. Measured as the share of spectral
758+ // energy above degree 20 — near zero for a smooth field, and larger for a
759+ // shorter wavelength, which is the direction lambda is supposed to move it.
760+ {
761+ const tail = async (lam3: number): Promise<number> => {
762+ const session = await make(lam3);
763+ await session.seed(5);
764+ const U = await session.read('U');
765+ let lo = 0;
766+ let hi = 0;
767+ for (let m = 0; m <= LMAX; m++) {
768+ for (let l = m; l <= LMAX; l++) {
769+ const i = lmIndex(LMAX, l, m);
770+ const e = U[2 * i] ** 2 + U[2 * i + 1] ** 2;
771+ if (l > 20) hi += e;
772+ else lo += e;
773+ }
774+ }
775+ session.destroy();
776+ return hi / (lo + hi);
777+ };
778+ const coarse = await tail(1);
779+ const fine = await tail(0.4);
780+ log(` randnfun3: energy above l=20 is ${coarse.toExponential(2)} at lambda 1, ${fine.toExponential(2)} at lambda 0.4`);
781+ check(
782+ 'randnfun3: the seed is band-limited, and lambda sets its scale',
783+ coarse < 1e-3 && fine > coarse,
784+ `tail ${coarse.toExponential(2)} (lambda 1) < ${fine.toExponential(2)} (lambda 0.4)`,
785+ );
786+ }
787+
788+ // ---- a finer wavelength grows the table rather than being capped -------
789+ //
790+ // The mode table is sized to the wavelength asked for, so going finer
791+ // reallocates it and rebinds the dispatch. Getting that wrong would leave
792+ // the kernel reading a destroyed buffer or a stale one, so check that a
793+ // fine field is actually there and actually different.
794+ {
795+ const session = await make(DEFAULT_LAMBDA);
796+ await session.seed(21);
797+ const coarse = await session.read('u');
798+ session.setLam3(0.12);
799+ await session.seed(21);
800+ const fine = await session.read('u');
801+ let differs = false;
802+ let finite = true;
803+ for (let i = 0; i < fine.length; i++) {
804+ if (!Number.isFinite(fine[i])) finite = false;
805+ if (fine[i] !== coarse[i]) differs = true;
806+ }
807+ check(
808+ 'randnfun3: a finer wavelength grows the mode table and rebinds',
809+ finite && differs,
810+ finite ? 'redrew finer, buffer rebound' : 'field went non-finite after resize',
811+ );
812+ session.destroy();
813+ }
814+
815+ // ---- a wavelength past the cost budget is refused, not truncated -------
816+ {
817+ const session = await make(DEFAULT_LAMBDA);
818+ let message = '';
819+ try {
820+ session.setLam3(1e-4);
821+ await session.seed(1);
822+ } catch (e) {
823+ message = e instanceof Error ? e.message : String(e);
824+ }
825+ check(
826+ 'randnfun3: a wavelength whose table could not be built is refused',
827+ message.includes('Fourier modes on this surface'),
828+ message ? `refused: ${message.slice(0, 62)}…` : 'drew it anyway',
829+ );
830+ session.destroy();
831+ }
537832 }
538833
539834 /** Index of the entry minimizing `score`, over the first `n` entries. */
test/matlabExportChecks.tsadded+134−0View file
@@ -0,0 +1,134 @@
1+/**
2+ * The MATLAB export (src/export/matlabScript.ts) is string assembly, so these
3+ * checks are cheap and need no GPU: every preset x geometry combination must
4+ * generate, the assembled file must keep its local-function namespace free of
5+ * collisions, and the driver's calls must match the signatures the .m files
6+ * declare. Whether the generated MATLAB actually reproduces a run is checked
7+ * against MATLAB itself, not here: a run exported at defaults and executed in
8+ * MATLAB R2026b lands within fp32 accumulation error of the app's own replay
9+ * (relL2 ~1e-7 over 60 steps via `npm run ref`), and the flux and Algorithm-4
10+ * exports track each other to ~3e-10 in f64.
11+ */
12+import { generateMatlabScript, MATLAB_SCRIPT_NAME } from '../src/export/matlabScript.ts';
13+import { presets, mModelByKey } from '../src/mgpu/registry.ts';
14+import { mGeometries } from '../src/geom/registry.ts';
15+import { formatCommand, resolvePreset, DEFAULT_WARMUP } from '../src/bench/runSpec.ts';
16+
17+type Check = (name: string, ok: boolean, detail: string) => void;
18+type Log = (s: string) => void;
19+
20+export function matlabExportChecks(check: Check, log: Log): void {
21+ log('--- MATLAB export ---');
22+ for (const preset of presets) {
23+ const { model, params } = resolvePreset(preset.key);
24+ for (const geometry of mGeometries) {
25+ const geometryParams = Object.fromEntries(
26+ geometry.params.map((p) => [p.key, p.value]),
27+ );
28+ const spec = {
29+ preset: preset.key,
30+ lmax: 63,
31+ seed: 1,
32+ steps: 2000,
33+ warmup: DEFAULT_WARMUP,
34+ params,
35+ geometry: geometry.key,
36+ geometryParams,
37+ niter: 8,
38+ };
39+ const name = `matlab-export ${preset.key} on ${geometry.key}`;
40+ let text: string;
41+ try {
42+ text = generateMatlabScript({
43+ model,
44+ modelSource: model.source,
45+ params,
46+ geometry,
47+ geometrySource: geometry.source,
48+ geometryParams,
49+ lmax: 63,
50+ niter: 8,
51+ lam3: 0.5,
52+ seed: 1,
53+ preset: preset.key,
54+ command: formatCommand(spec),
55+ });
56+ } catch (e) {
57+ check(name, false, e instanceof Error ? e.message : String(e));
58+ continue;
59+ }
60+
61+ // One file, one namespace: every local function name must be unique,
62+ // or MATLAB silently shadows one definition with another.
63+ const fnNames = [...text.matchAll(/^[ \t]*function\s+(?:\[[^\]]*\]|\w+)\s*=\s*(\w+)\s*\(/gm)]
64+ .map((m) => m[1]);
65+ const dupes = fnNames.filter((n, i) => fnNames.indexOf(n) !== i);
66+
67+ // The driver must define what it calls: the state it steps, the model
68+ // call mapped through the mp struct, and the transform setup.
69+ const wants = [
70+ `function ${MATLAB_SCRIPT_NAME}()`,
71+ 'sht_tables(sht_setup(lmax, mmax, nlat, nphi));',
72+ `= init(`,
73+ `= step(${model.state.join(', ')}, `,
74+ 'surface_tables(gxr, gyr, gzr)',
75+ `'/final/${model.state[0]}'`,
76+ ];
77+ const missing = wants.filter((w) => !text.includes(w));
78+
79+ // randnfunsphere rides along exactly when the geometry draws on it.
80+ const wantsSphereTool = /\brandnfunsphere\b/.test(geometry.source);
81+ const carriesSphereTool = /function f = randnfunsphere\(/.test(text);
82+
83+ const problems = [
84+ ...(dupes.length ? [`duplicate local functions: ${[...new Set(dupes)].join(', ')}`] : []),
85+ ...(missing.length ? [`missing: ${missing.join(' | ')}`] : []),
86+ ...(wantsSphereTool !== carriesSphereTool
87+ ? [`randnfunsphere ${wantsSphereTool ? 'missing' : 'included needlessly'}`]
88+ : []),
89+ ];
90+ check(name, problems.length === 0, problems.join('; ') || `${fnNames.length} local functions`);
91+ }
92+ }
93+
94+ // An edited working copy that dropped a required function is refused with a
95+ // message naming the file, not exported broken.
96+ const { model, params } = resolvePreset(presets[0].key);
97+ const geometry = mGeometries[0];
98+ try {
99+ generateMatlabScript({
100+ model,
101+ modelSource: '% nothing here',
102+ params,
103+ geometry,
104+ geometrySource: geometry.source,
105+ geometryParams: {},
106+ lmax: 63,
107+ niter: 8,
108+ lam3: 0.5,
109+ seed: 1,
110+ preset: presets[0].key,
111+ command: '',
112+ });
113+ check('matlab-export refuses a source without init', false, 'no error thrown');
114+ } catch (e) {
115+ const msg = e instanceof Error ? e.message : String(e);
116+ check(
117+ 'matlab-export refuses a source without init',
118+ msg.includes("'init'") && msg.includes(model.key),
119+ msg,
120+ );
121+ }
122+ // Guard the assumption the model registry makes for stateFor: state names
123+ // are used as `<name>0` initial-capture variables, which must not collide
124+ // with the species names.
125+ for (const p of presets) {
126+ const m = mModelByKey(p.modelKey)!;
127+ const all = new Set([...m.state, ...m.species]);
128+ check(
129+ `matlab-export names disjoint for ${m.key}`,
130+ all.size === m.state.length + m.species.length,
131+ [...all].join(', '),
132+ );
133+ }
134+}
test/modelChecks.tsmodified+23−17View file
@@ -41,15 +41,19 @@ const EXPECTED_KERNELS: Record<string, number> = {
4141 * docs/reduced-transforms.md Sec 4: the two sin-weighted
4242 * derivative synths, the pointwise flux combination through p1/p2/q2, the
4343 * two flux analyses, the re-shifted divergence and its r-scaled synthesis,
44- * plus the round-sphere eigenvalue added back — see models/schnakenberg.m
44+ * the round-sphere share of the divergence subtracted off through jinv, plus
45+ * the round-sphere eigenvalue added back — see models/schnakenberg.m
4546 * and docs/richardson-iteration.md. `schnakenberg-alg4` keeps the original
4647 * Cartesian-gradient form (Algorithm 3/4 of evolving_surface/notes/algos.tex)
4748 * as a live reference, with its original counts.
4849 */
4950 const KERNELS_PER_ITERATION: Record<string, number> = {
50- schnakenberg: 14,
51- brusselator: 14,
52- allencahn: 7,
51+ // 14 / 14 / 7 before the divergence was split against the round sphere:
52+ // forming lam .* F for the sphere term, and subtracting jinv .* S from the
53+ // deviation's r-scaled divergence, is one extra kernel per species.
54+ schnakenberg: 16,
55+ brusselator: 16,
56+ allencahn: 8,
5357 // 30 before the correction gained its band projection (.* filt on dLu):
5458 // that line fused into the state update in this model's expression shape,
5559 // and no longer does — one extra 2 x nlm kernel per species per iteration.
@@ -119,7 +123,7 @@ export async function modelChecks(
119123 `${kernels} kernels (expected ${expected})`,
120124 );
121125
122- session.seed(1);
126+ await session.seed(1);
123127 session.step(STEPS);
124128
125129 // Every rendered field must be finite and have developed some contrast.
@@ -168,7 +172,7 @@ export async function modelChecks(
168172 .describe()
169173 .step.filter((l) => l.includes('[batch lane')).length;
170174 }
171- session.seed(1);
175+ await session.seed(1);
172176 session.step(STEPS);
173177 states.push(await session.read('U'));
174178 session.destroy();
@@ -178,15 +182,16 @@ export async function modelChecks(
178182 }
179183 // Every batchable run at one solve iteration: the u/v syntheses and the
180184 // reaction analyses outside the loop (2 + 2), the four gradient
181- // syntheses, two theta-flux analyses, two divergence syntheses and two
182- // final analyses inside it (4 + 2 + 2 + 2; the phi flux goes through
183- // dphig, which has no Legendre stage to batch). Lane counts are
184- // batch-width invariant: a x4 run is one batch at K = 4 and two at
185- // K = 2, but the lanes annotated are the same 14 either way.
185+ // syntheses and the two round-sphere syntheses riding in the same group,
186+ // two theta-flux analyses, two divergence syntheses and two final
187+ // analyses inside it (6 + 2 + 2 + 2; the phi flux goes through dphig,
188+ // which has no Legendre stage to batch). Lane counts are batch-width
189+ // invariant: a x4 run is one batch at K = 4 and two at K = 2, but the
190+ // lanes annotated are the same 16 either way.
186191 check(
187192 'batch: the compiled step batches every adjacent transform pair',
188- batchedLanes === 14,
189- `${batchedLanes} batched transform lanes (expected 14)`,
193+ batchedLanes === 16,
194+ `${batchedLanes} batched transform lanes (expected 16)`,
190195 );
191196 let worst = 0;
192197 for (let i = 0; i < states[0].length; i++) {
@@ -208,7 +213,7 @@ export async function modelChecks(
208213 const cases: [string, string, string][] = [
209214 [
210215 'a single output bound to a grouped call',
211- 'Ftu = synth(vtu, vpu);',
216+ 'Ftu = synth(vtu, vpu, lam .* Fu);',
212217 'bind each one',
213218 ],
214219 [
@@ -216,12 +221,13 @@ export async function modelChecks(
216221 // Fpu is reassigned so the only error left is the dropped slot
217222 // itself, which the planner refuses (numbl would otherwise catch
218223 // the undefined 'Fpu' first, masking the check under test).
219- '[Ftu, ~] = synth(vtu, vpu);\n Fpu = Ftu;',
224+ '[Ftu, ~, Su] = synth(vtu, vpu, lam .* Fu);\n Fpu = Ftu;',
220225 'must be bound',
221226 ],
222227 ];
223228 for (const [what, bad, expect] of cases) {
224- const source = model.source.replace('[Ftu, Fpu] = synth(vtu, vpu);', bad);
229+ const source = model.source.replace('[Ftu, Fpu, Su] = synth(vtu, vpu, lam .* Fu);', bad);
230+ if (source === model.source) throw new Error('grouped-call fixture no longer matches allencahn.m');
225231 let message = '';
226232 try {
227233 const session = await ModelSession.create({
@@ -252,7 +258,7 @@ export async function modelChecks(
252258 lmax: LMAX,
253259 oversample: 2,
254260 });
255- session.seed(1);
261+ await session.seed(1);
256262 session.step(STEPS);
257263
258264 const fine = await session.readSpecies(0);
test/referenceChecks.tsadded+132−0View file
@@ -0,0 +1,132 @@
1+/**
2+ * The reference-file reader, against a file this test writes itself.
3+ *
4+ * No GPU: this is about the format — that what h5wasm writes in the
5+ * documented layout (docs/ellipsoid-reference-spec.md) comes back through
6+ * `extractReferenceCase` with nothing renamed, rescaled or truncated, and
7+ * that a file the replay could not act on is refused with a message rather
8+ * than half-read. The h5wasm module is injected: the node harness passes
9+ * `h5wasm/node` (real files), the browser harness `h5wasm` (in-memory wasm
10+ * filesystem) — so the browser run also proves the wasm build actually ships.
11+ */
12+import { extractReferenceCase, type H5Node } from '../src/compare/referenceCase.ts';
13+import { nlmCalc } from '../src/sht/layout.ts';
14+
15+type Check = (name: string, ok: boolean, detail: string) => void;
16+type Log = (line: string) => void;
17+
18+/** The slice of h5wasm's writing API these checks touch — the node and
19+ * browser builds both satisfy it structurally. */
20+interface H5Out {
21+ create_group(name: string): H5Out;
22+ create_attribute(name: string, data: unknown): void;
23+ create_dataset(args: { name: string; data: unknown; dtype?: string }): unknown;
24+}
25+export interface H5Rt {
26+ ready: Promise<unknown>;
27+ File: new (path: string, mode?: string) => H5Out & H5Node & { close(): unknown };
28+}
29+
30+const LMAX = 3;
31+const STEPS = 8;
32+
33+export async function referenceChecks(
34+ h5: H5Rt,
35+ /** Where a named scratch file may live: a temp dir on node, '/' in the
36+ * browser's in-memory filesystem. */
37+ pathFor: (name: string) => string,
38+ check: Check,
39+ log: Log,
40+): Promise<void> {
41+ log('\nreference files (HDF5 layout):');
42+ const mod = (await h5.ready) as { FS?: { unlink(path: string): void } };
43+ const nlm = nlmCalc(LMAX, LMAX);
44+ const series = (offset: number): Float32Array =>
45+ Float32Array.from({ length: 2 * nlm }, (_, i) => offset + i / 16);
46+ const arrays = {
47+ Gx: series(100), Gy: series(200), Gz: series(300),
48+ initialU: series(1), finalU: series(2),
49+ };
50+
51+ // ---- write the documented layout, read it back ---------------------------
52+ const goodPath = pathFor('ref-roundtrip.h5');
53+ {
54+ const f = new h5.File(goodPath, 'w');
55+ f.create_attribute('model', 'allencahn');
56+ f.create_attribute('species', ['U']);
57+ const spec = f.create_group('spec');
58+ spec.create_attribute('geometry', 'ellipsoid');
59+ spec.create_attribute('lmax', LMAX);
60+ spec.create_attribute('steps', STEPS);
61+ spec.create_attribute('niter', 2);
62+ spec.create_attribute('seed', 1);
63+ spec.create_attribute('warmup', 0);
64+ const params = spec.create_group('params');
65+ params.create_attribute('dt', 0.0625);
66+ params.create_attribute('eps2', 0.5);
67+ const geomParams = spec.create_group('geometry_params');
68+ geomParams.create_attribute('ax', 2.5);
69+ geomParams.create_attribute('ay', 1.25);
70+ geomParams.create_attribute('az', 0.75);
71+ const geom = f.create_group('geometry');
72+ geom.create_dataset({ name: 'Gx', data: arrays.Gx, dtype: '<f4' });
73+ geom.create_dataset({ name: 'Gy', data: arrays.Gy, dtype: '<f4' });
74+ geom.create_dataset({ name: 'Gz', data: arrays.Gz, dtype: '<f4' });
75+ f.create_group('initial').create_dataset({ name: 'U', data: arrays.initialU, dtype: '<f4' });
76+ f.create_group('final').create_dataset({ name: 'U', data: arrays.finalU, dtype: '<f4' });
77+ f.close();
78+ }
79+ {
80+ const f = new h5.File(goodPath, 'r');
81+ const rc = extractReferenceCase(f, 'ref-roundtrip.h5');
82+ f.close();
83+ mod.FS?.unlink(goodPath);
84+
85+ check(
86+ 'reference: the run identity survives the round trip',
87+ rc.model.key === 'allencahn' && rc.geometry.key === 'ellipsoid' &&
88+ rc.lmax === LMAX && rc.steps === STEPS && rc.niter === 2,
89+ `${rc.model.key} on ${rc.geometry.key}, lmax ${rc.lmax}, ` +
90+ `${rc.steps} steps, niter ${rc.niter}`,
91+ );
92+ check(
93+ 'reference: the file’s parameters override the defaults',
94+ rc.params.dt === 0.0625 && rc.params.eps2 === 0.5 &&
95+ rc.geometryParams.ax === 2.5 && rc.geometryParams.ay === 1.25 &&
96+ rc.geometryParams.az === 0.75,
97+ `dt ${rc.params.dt}, eps2 ${rc.params.eps2}, ` +
98+ `ax/ay/az ${rc.geometryParams.ax}/${rc.geometryParams.ay}/${rc.geometryParams.az}`,
99+ );
100+ const same = (a: Float32Array, b: Float32Array): boolean =>
101+ a.length === b.length && a.every((v, i) => v === b[i]);
102+ check(
103+ 'reference: every coefficient array comes back bit-exact',
104+ same(rc.geometryCoeffs.X, arrays.Gx) && same(rc.geometryCoeffs.Y, arrays.Gy) &&
105+ same(rc.geometryCoeffs.Z, arrays.Gz) && same(rc.initial.U, arrays.initialU) &&
106+ same(rc.final.U, arrays.finalU),
107+ `5 arrays x ${2 * nlm} float32 values`,
108+ );
109+ }
110+
111+ // ---- a file the replay cannot act on is refused, not half-read -----------
112+ {
113+ const badPath = pathFor('ref-unknown-model.h5');
114+ const f = new h5.File(badPath, 'w');
115+ f.create_attribute('model', 'nosuchmodel');
116+ f.close();
117+ const r = new h5.File(badPath, 'r');
118+ let message = '';
119+ try {
120+ extractReferenceCase(r, 'ref-unknown-model.h5');
121+ } catch (e) {
122+ message = e instanceof Error ? e.message : String(e);
123+ }
124+ r.close();
125+ mod.FS?.unlink(badPath);
126+ check(
127+ 'reference: an unknown model is refused with its name',
128+ message.includes('nosuchmodel'),
129+ message || 'no error thrown',
130+ );
131+ }
132+}
test/test-page.tsmodified+8−2View file
@@ -23,12 +23,15 @@ import {
2323 defaultGeometryParams,
2424 DEFAULT_GEOMETRY_KEY,
2525 } from '../src/geom/registry.ts';
26+import * as h5wasm from 'h5wasm';
2627 import { transformChecks } from './transformChecks.ts';
2728 import { analyticChecks } from './analyticChecks.ts';
2829 import { modelChecks } from './modelChecks.ts';
2930 import { geometryChecks } from './geometryChecks.ts';
3031 import { fluxChecks } from './fluxChecks.ts';
3132 import { compareChecks } from './compareChecks.ts';
33+import { referenceChecks, type H5Rt } from './referenceChecks.ts';
34+import { matlabExportChecks } from './matlabExportChecks.ts';
3235
3336 declare global {
3437 interface Window {
@@ -88,7 +91,7 @@ async function soak(steps: number, lmax: number): Promise<void> {
8891 geometryParams: defaultGeometryParams(geometry),
8992 niter: DEFAULT_NITER,
9093 });
91- session.seed(5);
94+ await session.seed(5);
9295 log(
9396 `soak: ${steps} steps at lmax ${lmax} ` +
9497 `(grid ${session.cfg.nlat}x${session.cfg.nphi}, ${geometry.key}, ` +
@@ -192,7 +195,7 @@ async function dumpState(q: URLSearchParams): Promise<void> {
192195 geometryParams: spec.geometryParams,
193196 niter: spec.niter,
194197 });
195- session.seed(spec.seed);
198+ await session.seed(spec.seed);
196199 session.step(spec.steps);
197200 await session.sync();
198201 const state = await session.read(model.state[0]);
@@ -222,6 +225,9 @@ async function main(): Promise<void> {
222225 await geometryChecks(device, check, log, { sweep: q.has('sweep') });
223226 await fluxChecks(device, check, log, { ab: q.has('sweep') });
224227 await compareChecks(device, check, log);
228+ // '/' is the wasm module's in-memory filesystem — nothing touches disk.
229+ await referenceChecks(h5wasm as unknown as H5Rt, (name) => `/${name}`, check, log);
230+ matlabExportChecks(check, log);
225231
226232 window.__RESULTS__ = { ok: failures === 0, lines };
227233 log(failures === 0 ? 'ALL PASS' : `${failures} FAILURE(S)`);
tools/randnfun3.madded+66−0View file
@@ -0,0 +1,66 @@
1+% Smooth random function in 3D — chebfun's randnfun3, as the Fourier modes
2+% it is built from rather than as a chebfun3.
3+%
4+% [K, C] = randnfun3(LAMBDA, DOM) draws a random trig series on the box
5+% DOM = [x0 x1 y0 y1 z0 z1] with maximum frequency about 2*pi/LAMBDA in
6+% each direction and standard normal distribution N(0,1) at each point.
7+% K is nmodes x 3 (angular wavenumbers) and C is nmodes x 2 (real and
8+% imaginary parts), defining
9+%
10+% f(x,y,z) = sum_j C(j,1)*cos(K(j,:)*[x;y;z]) - C(j,2)*sin(K(j,:)*[x;y;z])
11+%
12+% Seed the draw with rng(...) before calling.
13+%
14+% chebfun returns a chebfun3 and evaluates it later; this project has no
15+% such object, and the sum above is what the GPU evaluates at the surface
16+% points (src/mgpu/randnfun3.ts). Splitting it here is also what keeps the
17+% draw in MATLAB: randn has no counterpart in the compiled WGSL dialect.
18+
19+function [k, c] = randnfun3(lambda, dom)
20+ % chebfun's nonperiodic path builds a periodic function on a domain about
21+ % 20% larger and restricts it. Restriction is free when evaluating at
22+ % points, so we keep the enlarged period and never form the smaller one.
23+ m = round(1.2*(dom(2)-dom(1))/lambda + 2);
24+ n = round(1.2*(dom(4)-dom(3))/lambda + 2);
25+ p = round(1.2*(dom(6)-dom(5))/lambda + 2);
26+ m2 = 2*m+1;
27+ n2 = 2*n+1;
28+ p2 = 2*p+1;
29+ N = m2*n2*p2;
30+
31+ % chebfun draws the whole cube (column-major) before masking; drawing in
32+ % that same order keeps a seed meaning the same thing here as there.
33+ cr = randn(N, 1);
34+ ci = randn(N, 1);
35+
36+ % The cube's integer wavenumbers, -m:m x -n:n x -p:p in column-major order.
37+ i = (0:N-1).';
38+ jx = mod(i, m2) - m;
39+ jy = mod(floor(i/m2), n2) - n;
40+ jz = floor(i/(m2*n2)) - p;
41+
42+ % Confine to a ball for isotropy.
43+ keep = ((jx/m).^2 + (jy/n).^2 + (jz/p).^2) <= 1;
44+ jx = jx(keep);
45+ jy = jy(keep);
46+ jz = jz(keep);
47+ cr = cr(keep);
48+ ci = ci(keep);
49+
50+ % Normalize so the variance is 1 at each point.
51+ s = 1/sqrt(numel(cr));
52+ cr = s*cr;
53+ ci = s*ci;
54+
55+ % Angular wavenumbers on the enlarged period, which is a whole number of
56+ % wavelengths on each side.
57+ kx = 2*pi*jx/(m*lambda);
58+ ky = 2*pi*jy/(n*lambda);
59+ kz = 2*pi*jz/(p*lambda);
60+
61+ % Fold the box's origin into the phase, so evaluating is a plain sum over
62+ % cos(k.x) and sin(k.x) with no offset left to carry.
63+ ph = -(kx*dom(1) + ky*dom(3) + kz*dom(5));
64+ k = [kx, ky, kz];
65+ c = [cr.*cos(ph) - ci.*sin(ph), cr.*sin(ph) + ci.*cos(ph)];
66+end
tools/randnfunsphere.madded+59−0View file
@@ -0,0 +1,59 @@
1+% Smooth random function on the unit sphere — chebfun's randnfunsphere,
2+% evaluated at the given (theta, phi) instead of returned as a spherefun.
3+%
4+% F = randnfunsphere(LAMBDA, THETA, PHI) is a combination of all spherical
5+% harmonics up to degree floor(2*pi/LAMBDA) with independent N(0,1)
6+% coefficients, normalized so the variance is 1 at each point.
7+%
8+% randnfunsphere(LAMBDA, THETA, PHI, 'monochromatic') uses only the
9+% harmonics of that one degree, so every component has the same wave
10+% number — chebfun's 'monochrome' option.
11+%
12+% Seed the draw with rng(...) before calling. This project has no chebfun
13+% objects: what would be a spherefun there is returned here as values on the
14+% grid the caller passes in.
15+
16+function f = randnfunsphere(lambda, theta, phi, type)
17+ if ( nargin < 4 )
18+ type = 'white';
19+ end
20+ % The unit sphere has circumference 2*pi, matching randnfun's deg = L/lambda.
21+ deg = floor(2*pi/lambda);
22+ if ( strncmpi(type, 'm', 1) )
23+ c = randn(2*deg+1, 1);
24+ c = sqrt(4*pi/numel(c)) * c; % normalize so the variance is 1
25+ f = sphHarmSumFixedDeg(theta, phi, deg, c);
26+ else
27+ c = randn((deg+1)^2, 1);
28+ c = sqrt(4*pi/numel(c)) * c; % normalize so the variance is 1
29+ f = sphHarmSum(theta, phi, deg, c);
30+ end
31+end
32+
33+% All spherical harmonics up to degree deg, with coefficients ordered by
34+% degree and order (0, -1,0,1, -2,-1,0,1,2, ...). Order +m carries
35+% cos(m*phi), order -m carries sin(m*phi).
36+function f = sphHarmSum(theta, phi, deg, c)
37+ f = 1/sqrt(4*pi) * c(1) * ones(size(theta));
38+ k = 1; % coefficients consumed so far
39+ for l = 1:deg
40+ cl = c(k+1 : k+2*l+1); % this degree's orders, -l..l
41+ k = k + 2*l + 1;
42+ f = f + sphHarmSumFixedDeg(theta, phi, l, cl);
43+ end
44+end
45+
46+% All spherical harmonics of the single degree l.
47+function f = sphHarmSumFixedDeg(theta, phi, l, c)
48+ m = (0:l).';
49+ a = (-1).^m ./ sqrt((1 + double(m==0)) * pi);
50+ costh = cos(theta(:)).'; % legendre wants cos(theta), in a row
51+ G = legendre(l, costh, 'norm'); % (l+1) x npts
52+ f = 0 * theta;
53+ for mm = 0:l
54+ f = f + a(mm+1) * c(l+1+mm) * (G(mm+1,:).' .* cos(mm*phi));
55+ if mm > 0
56+ f = f + a(mm+1) * c(l+1-mm) * (G(mm+1,:).' .* sin(mm*phi));
57+ end
58+ end
59+end
vite.config.tsmodified+6−1View file
@@ -1,4 +1,5 @@
11 import { defineConfig } from 'vite';
2+import { realpathSync } from 'node:fs';
23 import { resolve } from 'node:path';
34
45 // numbl is a local `file:` dependency, so node_modules/numbl is a symlink to
@@ -8,7 +9,11 @@ import { resolve } from 'node:path';
89 // express this — Node rejects node_modules targets — and plain Node could not
910 // resolve numbl's internal `.js`->`.ts` imports anyway, which is why the GPU
1011 // tests run in the browser harness rather than under `node`.)
11-const numblSrc = resolve(import.meta.dirname, 'node_modules/numbl/src');
12+// Realpath'd through the symlink: dev serves modules under their real ids, so
13+// aliasing the node_modules path would give the same file two identities (one
14+// per spelling) and run its side effects twice — the interpreter's builtin
15+// registry throws on the second.
16+const numblSrc = realpathSync(resolve(import.meta.dirname, 'node_modules/numbl/src'));
1217
1318 export default defineConfig({
1419 base: './',
↑↓ move↵openescclose