Comparing changes
main is 43 commits ahead of laplace_beltrami.
Create pull request
export standalone matlab
Jeremy Magland committed
9b71d5bMerge pull request #10 from concept-collection/compare-interface
Jeremy Magland committed
71b6492Add short descriptions for each mode
Owen Melia committed
2bb4f78Adding a way to reset simulation from same random IC.
Owen Melia committed
ca37955Fixing some bugs in the UI
Owen Melia committed
c8e230aWIP: First draft at new interface with multiple comparison modes
Owen Melia committed
9389d73Update ids in index.html for easier refactor
Owen Melia committed
a158c73Merge pull request #9 from concept-collection/reference-compare-mode
Jeremy Magland committed
c88cad7Generate numbl's stdlib bundle in the preview workflow too
Jeremy Magland committed
1a01a2fGenerate numbl's stdlib bundle in the preview workflow too
Jeremy Magland committed
90d9678Open the reference comparison in one click
Jeremy Magland committed
3210e7dCheck reference files in the browser's compare mode
Jeremy Magland committed
c90d0e2Merge pull request #6 from concept-collection/reference_soln
Jeremy Magland committed
327b4ecMerge main into reference_soln
Jeremy Magland committed
4b7c487Scale the randnfun3 GPU/CPU bound to the field
Dan Fortunato committed
c2fcb48Generate numbl's stdlib bundle in CI
Dan Fortunato committed
cc9b1caMerge pull request #7 from concept-collection/random-fields
Jeremy Magland committed
87ad61eMerge pull request #8 from concept-collection/flux-polar-conditioning
Jeremy Magland committed
7d4ff58Split the flux-form divergence against the round sphere
Dan Fortunato committed
3d078cfMerge main into random-fields
Jeremy Magland committed
beac00aDrop the work-in-progress banner
Dan Fortunato committed
f132fe4Seed runs from smooth random fields, and add the blob geometry
Dan Fortunato committed
0ae15cfMerge pull request #5 from concept-collection/compare-solver-settings
Jeremy Magland committed
f03ab93Changed local node to version 24; updated package-lock.json accordingly
Owen Melia committed
f4de749Do not stretch the colormap across a constant field's roundoff
Jeremy Magland committed
ef3ae33Updated command now tracks L_infty error too.
Owen Melia committed
66ae13eCompare several solver settings side by side, on one clock
Jeremy Magland committed
f467ffaMerge branch 'main' into reference_soln
Owen Melia committed
b460a9fCommand for testing against a reference implementation
Owen Melia committed
90108a6Merge pull request #3 from concept-collection/reduced-transforms
Jeremy Magland committed
ae74084Use numbl main branch instead of a pinned commit in workflows
Jeremy Magland committed
0c74713Use numbl main branch instead of a pinned commit in workflows
Jeremy Magland committed
79878d9Merge main into reduced-transforms
Jeremy Magland committed
65f421dMerge pull request #4 from concept-collection/add-preview-workflow
Jeremy Magland committed
27223d1Add a /preview PR command that deploys the build to R2
Jeremy Magland committed
5fc1080Differentiate the phi flux in grid space
Dan Fortunato committed
0d99c91Pin numbl at the multi-output external lowering
Dan Fortunato committed
bb3e4bePrecondition with the operator's symbol; project the correction onto the band
Dan Fortunato committed
e4d6a3bBatch independent transforms through one Legendre dispatch
Dan Fortunato committed
a4fee9cReduce the Laplace-Beltrami matvec to 6 transforms per species per iteration
Dan Fortunato committed
591a4f5Fix the GPU suite in CI: keep it short, and split the metric tolerance
Jeremy Magland committed
3b2f40cupdate package lock
Jeremy Magland committed
cc86692Merge pull request #1 from concept-collection/laplace_beltrami
Jeremy Magland committed
bd8a88c66 changed files+9241−1087
.github/workflows/ci.ymlmodified+12−6View file
@@ -14,21 +14,27 @@ 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:
27- NUMBL_REF: 38ce14046d64d03ecf05cb57def53057a6bc64ab
32+ NUMBL_REF: main
2833 run: |
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+12−6View file
@@ -26,21 +26,27 @@ 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:
39- NUMBL_REF: 38ce14046d64d03ecf05cb57def53057a6bc64ab
44+ NUMBL_REF: main
4045 run: |
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.ymladded+153−0View file
@@ -0,0 +1,153 @@
1+## Comment `/preview` on a pull request to build it and publish the result to
2+## https://tempory.net/previews/<repo>/<branch>/index.html
3+##
4+## This file is meant to be copied into other repos as-is. Only the "build"
5+## block below is repo-specific; everything else is driven by `env` at the top.
6+##
7+## Two things to know before copying it:
8+##
9+## 1. `issue_comment` workflows only ever run the copy of this file on the
10+## DEFAULT branch, so it has to be merged to main before `/preview` works
11+## on any PR — including the PR that adds it.
12+## 2. It checks out and builds the PR's head commit while the R2 credentials
13+## are in scope. The `author_association` guard means only the repo owner,
14+## an org member, or a collaborator can trigger it, but one of those people
15+## asking for a preview of an untrusted fork PR would run that fork's code.
16+## Read the diff before commenting `/preview` on a fork.
17+
18+name: preview
19+
20+on:
21+ issue_comment:
22+ types: [created]
23+
24+env:
25+ R2_BUCKET: tempory
26+ R2_PREFIX: previews
27+ PREVIEW_BASE_URL: https://tempory.net/previews
28+ DIST_DIR: dist
29+ NODE_VERSION: "24"
30+
31+permissions:
32+ contents: read
33+ issues: write
34+ pull-requests: write
35+
36+concurrency:
37+ group: preview-${{ github.event.issue.number }}
38+ cancel-in-progress: true
39+
40+jobs:
41+ preview:
42+ if: >-
43+ github.event.issue.pull_request &&
44+ startsWith(github.event.comment.body, '/preview') &&
45+ contains(fromJSON('["OWNER", "MEMBER", "COLLABORATOR"]'), github.event.comment.author_association)
46+ runs-on: ubuntu-latest
47+ env:
48+ GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
49+ PR_NUMBER: ${{ github.event.issue.number }}
50+ APP_NAME: ${{ github.event.repository.name }}
51+ steps:
52+ - name: Acknowledge the command
53+ env:
54+ COMMENT_ID: ${{ github.event.comment.id }}
55+ run: |
56+ gh api --silent -X POST \
57+ "repos/$GITHUB_REPOSITORY/issues/comments/$COMMENT_ID/reactions" \
58+ -f content=eyes
59+
60+ # The comment event carries no commit info, so ask the API what the PR
61+ # currently points at. `head.repo` differs from this repo for fork PRs.
62+ - name: Resolve the PR head
63+ id: head
64+ run: |
65+ gh api "repos/$GITHUB_REPOSITORY/pulls/$PR_NUMBER" \
66+ --jq '"repo=\(.head.repo.full_name)", "sha=\(.head.sha)", "branch=\(.head.ref)"' \
67+ >> "$GITHUB_OUTPUT"
68+
69+ # Branch names may contain characters that are awkward in a URL path
70+ # (most commonly `/`, as in `feat/thing`), so flatten to one path segment.
71+ # BRANCH goes through the environment rather than `${{ }}` because on a
72+ # fork PR its value is chosen by someone outside the org.
73+ - name: Compute the deploy path
74+ id: path
75+ env:
76+ BRANCH: ${{ steps.head.outputs.branch }}
77+ run: |
78+ slug=$(printf '%s' "$BRANCH" | tr -c 'A-Za-z0-9._-' '-')
79+ echo "key=$R2_PREFIX/$APP_NAME/$slug" >> "$GITHUB_OUTPUT"
80+ echo "url=$PREVIEW_BASE_URL/$APP_NAME/$slug/index.html" >> "$GITHUB_OUTPUT"
81+
82+ - uses: actions/checkout@v4
83+ with:
84+ repository: ${{ steps.head.outputs.repo }}
85+ ref: ${{ steps.head.outputs.sha }}
86+
87+ - uses: actions/setup-node@v4
88+ with:
89+ node-version: ${{ env.NODE_VERSION }}
90+ cache: npm
91+
92+ ## ---- repo-specific build (replace this block when copying) ----------
93+ # numbl is a `file:../../numbl` dependency: we use its compiler internals
94+ # (parser, lowerer, IR, inline pass), which its published package
95+ # `exports` do not expose. Clone it where that relative path expects it,
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.
101+ - name: Check out numbl (sibling dependency)
102+ env:
103+ NUMBL_REF: main
104+ run: |
105+ git clone --filter=blob:none --no-checkout \
106+ https://github.com/flatironinstitute/numbl.git "$GITHUB_WORKSPACE/../../numbl"
107+ git -C "$GITHUB_WORKSPACE/../../numbl" checkout --quiet "$NUMBL_REF"
108+ node "$GITHUB_WORKSPACE/../../numbl/scripts/bundle-stdlib.ts"
109+ # --ignore-scripts: npm runs a linked package's `prepare` script, and
110+ # numbl's is husky, which is not installed here.
111+ - run: npm ci --ignore-scripts
112+ - run: npm run build
113+ ## ---- end repo-specific build ----------------------------------------
114+
115+ - name: Publish to R2
116+ env:
117+ AWS_ACCESS_KEY_ID: ${{ secrets.R2_ACCESS_KEY_ID }}
118+ AWS_SECRET_ACCESS_KEY: ${{ secrets.R2_SECRET_ACCESS_KEY }}
119+ AWS_DEFAULT_REGION: auto
120+ R2_ENDPOINT: ${{ secrets.R2_ENDPOINT }}
121+ # R2 rejects the extra integrity checksums AWS CLI v2 adds by default.
122+ AWS_REQUEST_CHECKSUM_CALCULATION: when_required
123+ AWS_RESPONSE_CHECKSUM_VALIDATION: when_required
124+ KEY: ${{ steps.path.outputs.key }}
125+ run: |
126+ # Two passes so the entry HTML is always revalidated while the
127+ # content-hashed assets around it can be cached hard. The filters
128+ # apply to both sides of the sync, so `--delete` in each pass only
129+ # ever removes files of that same kind.
130+ aws s3 sync "$DIST_DIR" "s3://$R2_BUCKET/$KEY" \
131+ --endpoint-url "$R2_ENDPOINT" --delete --no-progress \
132+ --exclude '*.html' \
133+ --cache-control 'public, max-age=31536000, immutable'
134+ aws s3 sync "$DIST_DIR" "s3://$R2_BUCKET/$KEY" \
135+ --endpoint-url "$R2_ENDPOINT" --delete --no-progress \
136+ --exclude '*' --include '*.html' \
137+ --cache-control 'no-cache'
138+
139+ - name: Comment with the preview link
140+ env:
141+ URL: ${{ steps.path.outputs.url }}
142+ SHA: ${{ steps.head.outputs.sha }}
143+ run: |
144+ gh pr comment "$PR_NUMBER" --repo "$GITHUB_REPOSITORY" \
145+ --body "Preview of \`${SHA:0:7}\` is live: $URL"
146+
147+ - name: Report failure
148+ if: failure()
149+ env:
150+ RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
151+ run: |
152+ gh pr comment "$PR_NUMBER" --repo "$GITHUB_REPOSITORY" \
153+ --body "Preview build failed — [run log]($RUN_URL)."
.gitignoremodified+1−0View file
@@ -1,3 +1,4 @@
1+tmp/
12 node_modules/
23 dist/
34 *.log
README.mdmodified+321−80View file
@@ -9,15 +9,13 @@ This is the sibling of
99 solves the same systems on the round sphere. Everything there is here; what is
1010 added is a *surface*.
1111
12-> [!WARNING]
13-> **The geometry is rendered, not yet solved on.** The Laplace–Beltrami
14-> operator in the models is still the round sphere's — the term that carries
15-> the shape is a placeholder that is identically zero. On anything but the
16-> sphere you are looking at the sphere's pattern painted onto that surface, not
17-> the pattern that surface would grow. Everything the correction needs in order
18-> to be dropped in — the embedding, the split of the operator, the iterative
19-> solve, the unrolled loop — is built and tested. See
20-> [The geometry is not in the operator yet](#the-geometry-is-not-in-the-operator-yet).
12+The geometry is in the operator: the models evaluate the surface
13+Laplace–Beltrami operator `lap_g` inside the implicit solve, in a **flux form
14+that costs 5 spherical-harmonic transforms per species per iteration** (plus
15+one Legendre-free FFT derivative) where the textbook Cartesian-gradient form
16+needs 12. See
17+[The geometry in the operator](#the-geometry-in-the-operator) and
18+[docs/reduced-transforms.md](docs/reduced-transforms.md).
2119
2220 ## What a surface is here
2321
@@ -38,10 +36,12 @@ function [gx, gy, gz] = shape(theta, phi, waist, stretch)
3836 end
3937 ```
4038
41-That is ordinary element-wise MATLAB and goes through the same compiler and the
42-same WGSL backend the models do. It is evaluated once on the solver's grid, and
43-then **analysed into coefficients**, which is the form everything downstream
44-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
4545 the pointwise values:
4646
4747 - **It is exactly band-limited at lmax.** The surface has as many derivatives as
@@ -54,18 +54,122 @@ the pointwise values:
5454 That is exact interpolation, not subdivision — the same argument that lets the
5555 species fields be oversampled, and it is checked directly in the tests.
5656
57-Four geometries ship: [sphere](geometries/sphere.m) (the reference case),
57+Five geometries ship: [sphere](geometries/sphere.m) (the reference case),
5858 [ellipsoid](geometries/ellipsoid.m), [peanut](geometries/peanut.m) — a dumbbell
59-whose waist is a saddle — and [bumpy](geometries/bumpy.m). Each is editable in
60-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
6167 solver and does not disturb the run: the geometry is data whose shape in the
62-bindings depends only on the grid, so a swap is six buffer writes and the
68+bindings depends only on the grid, so a swap is sixteen buffer writes and the
6369 pattern carries straight on.
6470
6571 A **morph** slider blends the drawn surface back to the unit sphere. The
6672 parametrization is the sphere's either way, so sweeping it shows which point
6773 went where.
6874
75+### Seeding, and `tools/`
76+
77+A run starts from the uniform steady state plus a small perturbation, and that
78+perturbation is a *smooth* random field rather than white noise: chebfun's
79+[`randnfun3`](tools/randnfun3.m) on the surface's bounding box, restricted to
80+the surface by evaluating it at the grid points — the way surfacefun seeds a
81+run. Each model's `init` says so itself:
82+
83+```matlab
84+function [U, V, u, v] = init(lam3, gx, gy, gz, a, b)
85+ f = randnfun3(lam3, gx, gy, gz);
86+ ...
87+```
88+
89+A band-limited seed is fully resolved by the grid, where white noise is
90+whatever the grid happened to alias: the tests measure its energy above degree
91+20 at 5e-14 of the total, and the flux-form and Algorithm-4 operators now
92+track each other to 3e-6 through a run instead of 4e-4. The **seed λ** control
93+sets the field's wavelength; smaller means finer features to grow from. It is
94+an *absolute* length in the surface's own units, as in chebfun — not a
95+fraction of the surface's size — so a larger surface draws more modes at the
96+same λ.
97+
98+**λ is useful down to about 2π/lmax, and no further.** A field of wavelength λ
99+on a unit-radius surface carries angular content up to degree ≈ 2π/λ, so at
100+the default lmax 63 the grid holds everything down to λ ≈ 0.1. Past that,
101+`init`'s own `analys` discards what the grid cannot represent, and the seed
102+gets *weaker* rather than finer while costing eight times as much per halving:
103+
104+| λ | 2π/λ | rms of the resolved seed | energy above l=55 | peak degree |
105+|---|---|---|---|---|
106+| 0.5 | 13 | 2.6e-2 | 1e-8 | 8 |
107+| 0.2 | 31 | 2.6e-2 | 1e-8 | 10 |
108+| 0.1 | 63 | 2.4e-2 | 0.10 | 44 |
109+| 0.05 | 126 | 1.5e-2 | 0.20 | 48 |
110+| 0.03 | 209 | 9.6e-3 | 0.27 | 63 |
111+
112+Raising lmax moves that floor down, and the seed really does get finer: at
113+lmax 127 the same λ=0.05 keeps its full amplitude (2.5e-2 against 1.5e-2 at
114+lmax 63) with its peak at degree 79 instead of pinned to the band edge, and
115+λ=0.1 becomes *fully* resolved (2e-8 of its energy in the top decile, against
116+1e-1 at lmax 63 — so even 0.1 is slightly under-resolved on the default grid).
117+
118+Note that lmax cuts both ways: it quadruples npts, so every λ also costs four
119+times as much to sum.
120+
121+**Nothing caps λ but memory and patience.** The mode table grows to whatever
122+is asked for and the only refusal is a table that could not be built at all,
123+reported with the mode count it wanted rather than silently truncated. On a
124+128×256 grid:
125+
126+| λ | modes | seed time |
127+|---|---|---|
128+| 0.05 | 480,431 | 0.26 s |
129+| 0.03 | 2,094,657 | 0.98 s |
130+| 0.02 | 6,882,185 | 3.1 s |
131+| 0.015 | 16,092,829 | 7.4 s |
132+| 0.01 | 53,574,764 | 25.6 s |
133+
134+Being slow is the caller's business; **freezing the browser is not**, and at
135+these times neither half of the work can be left where it was:
136+
137+- The draw is synchronous interpreter time — 13 s at λ=0.01 — which on the
138+ main thread stops the page painting and gets it offered up for killing. It
139+ runs on a worker instead
140+ ([`randnfun3.worker.ts`](src/mgpu/randnfun3.worker.ts)); it touches no GPU
141+ and no DOM, so nothing about it needed that thread. Measured during a seed:
142+ 731 animation frames, no stalled sample.
143+- The GPU sum is split across a fixed 16 dispatches (`randnfun3Chunks`)
144+ accumulating into the same output, and `submitYielding` ends the submission
145+ at each one. A browser's GPU process is shared with compositing, so a single
146+ submission running tens of seconds stops *every* tab painting, and one
147+ dispatch that long risks the watchdog killing the device outright. Slices
148+ past the end of a small table exit immediately, so a coarse λ pays nothing.
149+
150+The device is also asked for the adapter's full storage-buffer limit at
151+creation ([`src/sht/sht.ts`](src/sht/sht.ts)), so a browser's 128 MB default
152+is not what decides how fine λ can be. `seed()` is consequently async.
153+
154+`randnfun3` splits across the CPU/GPU line, and the split is forced rather
155+than chosen. Drawing the modes needs `randn` and a `sqrt(nnz)` normalization,
156+neither of which exists in the compiled WGSL dialect, so the draw is MATLAB in
157+[`tools/randnfun3.m`](tools/randnfun3.m) run by the interpreter — a few
158+thousand coefficients, ~5 ms. Evaluating is `npts × nmodes` (~6e7 terms at the
159+default λ), so that is a WGSL kernel
160+([`src/mgpu/randnfun3.ts`](src/mgpu/randnfun3.ts)) reached as an external
161+operation, the way `synth` is. The coefficient table is filled in behind the
162+call, as `synth` hides its Legendre matrices; λ is not hidden, and the plan
163+records which parameter the `.m` asked with so the host draws from that value.
164+
165+[`tools/`](tools/) is the shared MATLAB every interpreter run can call, by file
166+name, as on MATLAB's path — currently `randnfun3` and
167+[`randnfunsphere`](tools/randnfunsphere.m), which `blob.m` is written on. Both
168+keep their upstream signatures, including options nothing shipped uses yet
169+(`randnfunsphere`'s `'monochromatic'`), because the point of a tool is that a
170+geometry you write next can reach for it. Tools are not available to the
171+models' *step*, which compiles to WGSL where none of this exists.
172+
69173 ## The scheme, and where the geometry enters
70174
71175 It solves the N-species system
@@ -103,57 +207,104 @@ Unew = (B + dt*D*dlap(Unew)) ./ (1 + dt*D*lam)
103207 and the loop iterates it from the round-sphere answer. That is preconditioned
104208 Richardson, with the operator we can invert exactly as the preconditioner; it
105209 converges while `dt*D*dlap` stays small against `(I - dt*D*lap_s)`, which is
106-what would keep the cost to a few transforms per step rather than a full
107-elliptic solve. Written out, the whole of
108-[`models/schnakenberg.m`](models/schnakenberg.m)'s step is:
210+what keeps the cost to a few transforms per step rather than a full elliptic
211+solve (see [docs/richardson-iteration.md](docs/richardson-iteration.md)). One
212+species of [`models/schnakenberg.m`](models/schnakenberg.m)'s solve loop:
109213
110214 ```matlab
111-function [Un, Vn, u, v] = step(U, V, lam, gx, gy, gz, a, b, D1, D2, dt, niter)
112- u = synth(U);
113- v = synth(V);
114- uuv = u .* u .* v;
115-
116- Bu = U + dt * analys(a - u + uuv);
117- Bv = V + dt * analys(b - uuv);
118-
119- Un = Bu ./ (1 + (dt * D1) * lam);
120- Vn = Bv ./ (1 + (dt * D2) * lam);
121-
122- for k = 1:niter
123- dLu = 0 * Un; % <- the placeholder
124- dLv = 0 * Vn;
125- Un = (Bu + (dt * D1) * dLu) ./ (1 + (dt * D1) * lam);
126- Vn = (Bv + (dt * D2) * dLv) ./ (1 + (dt * D2) * lam);
127- end
215+lamJ = lam ./ jhat; % mean-J preconditioner eigenvalues (below)
216+...
217+for k = 1:niter
218+ Fu = Un .* filt; % zero the top 2 degrees before differentiating
219+ vtu = dthetac(Fu);
220+ vpu = dphic(Fu);
221+ [Ftu, Fpu] = synth(vtu, vpu); % sin(theta)*dtheta(u), dphi(u) -- smooth on
222+ % the sphere, one batched dispatch
223+ Pu = p1 .* Ftu + p2 .* Fpu; % the two fluxes, also smooth: the precomputed
224+ Qu = p2 .* Ftu + q2 .* Fpu; % weights carry every 1/sin(theta) there is
225+ PAu = analys(Pu);
226+ Pcu = PAu .* filt;
227+ scu = dthetac(Pcu); % theta part of the divergence, coefficients
228+ Lu = synth(scu); % sin(theta) * dtheta(P) on the grid
229+ dQu = dphig(Qu); % d/dphi is diagonal in the Fourier index:
230+ % two FFT stages, no Legendre work at all
231+ lapu = r .* (Lu + dQu); % = lap_g(u) on the grid
232+ dLu = (analys(lapu) + lamJ .* Un) .* filt; % dlap, projected onto the band
233+ Un = (Bu + (dt * D1) * dLu) ./ (1 + (dt * D1) * lamJ);
128234 end
129235 ```
130236
131-Written this way rather than as a residual correction on purpose: with `dlap`
132-zero, every iterate is *bit for bit* the first line, with no cancellation to
133-round differently. So the sphere case is not "close to" turing-sphere, it is
134-the same arithmetic, and the tests assert exactly that — the state after 20
135-steps is identical at 0, 1 and 4 iterations.
136-
137-### The geometry is not in the operator yet
138-
139-What belongs where `dLu` is now is `dlap = lap_g - lap_s` applied to the current
140-iterate. Getting it needs two things this repo does not have:
141-
142-1. **The induced metric**, `g_ij = ∂_i X · ∂_j X` for `X = (gx, gy, gz)`. The
143- geometry is static and low-degree, so this is a one-off precomputation, not
144- per-step work — but it needs θ- and φ-derivatives of the embedding.
145-2. **Surface derivatives of the field**, per iteration. In the round frame this
146- is the spheroidal transform pair — SHTNS's `SHsph_to_spat` and
147- `spat_to_SHsph`, i.e. `grad_s` and `div_s` — which lets the operator be
148- written as `div_s(A grad_s f)` with `A` built from the metric, with no
149- explicit `1/sin θ` to go singular at the poles.
150-
151-Both need Legendre *derivative* tables, which the vendored WGSL transforms under
152-[`src/sht/`](src/sht/) do not implement — they are scalar synthesis and analysis
153-only. That is the missing piece, and it is a substantial addition to the
154-transforms rather than a change to the models. Until it lands, the models take
155-`gx, gy, gz` (the surface on the grid) and `Gx, Gy, Gz` (the same surface as
156-coefficients) as arguments and do not use them, and the app says so.
237+On the sphere `dlap` is mathematically zero — `p1 = q2 = 1`, `p2 = 0`,
238+`r = 1/sin²θ`, `jhat = 1`, and the composition collapses to `lap_s` — so the
239+sphere case reproduces turing-sphere to fp32 round-off, and the tests assert
240+the state stays put across 0, 1 and 4 iterations.
241+
242+**The preconditioner folds in the symbol of the operator.** `jhat` is the
243+host's minimax scale `2/(μmin + μmax)` over the eigenvalues `μ(x)` of the
244+operator's principal symbol — the inverse squared principal stretches of
245+the embedding, direction included, read straight off the flux-metric
246+arrays (`S = (1/J)·[[p1,p2],[p2,q2]]`). Preconditioning with `lam/jhat`
247+then contracts every mode *and every direction* at rate
248+`(μmax − μmin)/(μmax + μmin) < 1` on any surface, where the plain `lam`
249+diverges wherever `μ > 2` — peanut reaches `μ = 6.2`. A det-based mean of
250+the area factor (μ's geometric mean, exact only for conformal surfaces) is
251+not enough: it under-corrects anisotropic stretching and leaves directional
252+high-degree bands with amplification > 1, which surfaced as patterns going
253+high-frequency and diverging as `niter` or `lmax` grew. The answer never
254+depends on `jhat` — the `lamJ` term added inside `dLu` is the term divided
255+back out — only the convergence rate does.
256+
257+**The correction is projected onto the band** (`.* filt` on `dLu`,
258+matching algos.tex Algorithm 5's zeroing of the top coefficients). Without
259+it the top two degrees iterate toward the *undiffused* `Bu` — each solve
260+iteration strips a bit more of their implicit diffusion, at species-
261+dependent rates, which manufactures a spurious Turing band at the band
262+edge: visible on the round sphere as top-degree energy growing ~3%/step at
263+`lmax 127, niter 8`. With both fixes the whole niter × geometry sweep
264+converges, the spectral centroid of the pattern is resolution-independent
265+(l ≈ 26 at lmax 63 and 127 alike), and `jhat: 1` is kept as the divergent
266+control in the tests.
267+
268+### The geometry in the operator
269+
270+`dlap = lap_g - lap_s` is applied to the current iterate at every solve
271+iteration, so its transform count is what the whole step's cost scales with.
272+Two formulations ship:
273+
274+1. **The flux form** (above, all three models): `lap_g u` as the weighted
275+ divergence of two weighted fluxes of the sin-scaled derivatives. The
276+ weights `p2, r, dp1, dq2, jinv` are grid arrays precomputed once per
277+ surface from the embedding's θ/φ tangents
278+ ([`src/geom/metric.ts`](src/geom/metric.ts)), chosen so that **every field
279+ that gets analysed is a smooth function on the sphere** — the property
280+ that makes spherical-harmonic analysis meaningful, and the entire
281+ difficulty near the poles. The divergence is split against the round
282+ sphere: the sphere's share of it is `-jinv .* lap_s(u)`, exact in
283+ spectral space, so `r ~ 1/sin²θ` multiplies only the geometry deviation.
284+ Without that split `r` amplifies the polar round-off of the whole flux
285+ into a static forcing that nucleates a spot at the pole on every seed.
286+ Cost: **6 Legendre transforms** per species per iteration (4 syntheses —
287+ two gradient, one divergence, one for the sphere's `-lam .* u`, which
288+ rides in the gradient's batch — plus 2 analyses; the phi flux never needs
289+ the Legendre basis, `dphig` differentiates it on the grid with two FFT
290+ stages, masking m past the top-degree filter, and `dthetac`/`dphic`
291+ are O(nlm) coefficient shuffles). The derivation, the smoothness
292+ argument and the fp32 error analysis are in
293+ [docs/reduced-transforms.md](docs/reduced-transforms.md).
294+2. **The Cartesian-gradient form** (Algorithm 4 of `docs/algos.pdf`), kept as
295+ a live reference in
296+ [`models/schnakenberg_alg4.m`](models/schnakenberg_alg4.m) and selectable
297+ in the app: the surface gradient carried as three ambient components
298+ through the inverse metric quantities `Vt*/Vp*`. Cost: **12 transforms**
299+ per species per iteration. The tests hold both forms to the same answer on
300+ a curved surface, and both metric formulations are precomputed and
301+ uploaded for every geometry, so either kind of model runs.
302+
303+The θ-derivative machinery both forms need — the α± recurrence
304+(`sin θ ∂θ Y_l^m = α⁺Y_{l+1}^m + α⁻Y_{l-1}^m`) as a coefficient-space shuffle
305+feeding the existing scalar synthesis — lives in
306+[`src/sht/deriv.ts`](src/sht/deriv.ts); no Legendre-derivative tables are
307+required.
157308
158309 ### `for` loops, unrolled
159310
@@ -183,12 +334,14 @@ Two consequences worth stating:
183334 each loop body assigns before the pass and refuses the ones that escape, so
184335 that case is a compile error rather than a stale read.
185336
186-Unrolling is exactly linear in the trip count: 2 GPU ops per species per
187-iteration, asserted in the tests.
337+Unrolling is exactly linear in the trip count: 18 GPU ops per species per
338+iteration (7 transforms, 3 coefficient shuffles, 8 kernels), asserted in the
339+tests.
188340
189341 ## MATLAB, compiled to WebGPU
190342
191-Unchanged from turing-sphere, and it now compiles the geometry files too. numbl
343+Unchanged from turing-sphere. This is the models' path — the geometry files
344+instead run once through numbl's CPU interpreter, as above. numbl
192345 parses and lowers each function for the concrete argument types of the current
193346 grid; its inline pass folds single-use temps back into their consumer, so one
194347 line of MATLAB becomes one expression tree; and this repo emits one WGSL compute
@@ -198,9 +351,31 @@ operations whose type rules numbl learns from a `.mtoc2.js` workspace file, and
198351 which the backend maps onto the spherical-harmonic pipelines. Anything it cannot
199352 express is refused at compile time with a source position.
200353
201-The Schnakenberg step above compiles to 17 GPU operations at one solve
202-iteration: 4 transforms, 11 generated kernels, and 2 buffer copies feeding the
203-new state back.
354+The Schnakenberg step compiles to 50 GPU operations at one solve iteration:
355+18 transforms, 6 coefficient-space shuffles, 24 generated kernels, and 2
356+buffer copies feeding the new state back.
357+
358+**Transforms batch.** The expensive part of every Legendre stage is
359+generating the associated Legendre values on the fly by recurrence — work
360+that depends only on the grid, not on the field. `synth`/`analys` therefore
361+take multiple fields, and a grouped call runs as one batched dispatch: one
362+walk of the recurrence, one accumulator lane per field —
363+
364+```matlab
365+[Ftu, Fpu, Ftv, Fpv, Su, Sv] = synth(vtu, vpu, vtv, vpv, lam .* Fu, lam .* Fv);
366+```
367+
368+The grouping is a promise of independence, never of a lane width: the
369+planner ([`src/mgpu/plan.ts`](src/mgpu/plan.ts), `materializeTransforms`)
370+chunks each group into whatever the device supports — one ×4 batch under the
371+default WebGPU limits, or scalar dispatches with `SHT_BATCH=0` for A/B — so
372+the same source runs anywhere. Ungrouped transforms that happen to sit on
373+consecutive independent lines are batched the same way. Per-lane arithmetic
374+is identical to the scalar kernels', so batched and scalar plans produce
375+bit-identical states, asserted in the tests along with compile-time refusal
376+of a group that drops one of its outputs. All 16 Legendre transforms of the step
377+above land in batches, worth ~25% of the whole step (0.88 vs 1.14 ms/step at
378+lmax 127, 2 iterations, on bumpy).
204379
205380 Two consequences carried over:
206381
@@ -247,12 +422,14 @@ there is no CPU fallback (the f64 CPU transform remains, for tests).
247422 - Spectral layout: SHTNS conventions — orthonormal + Condon–Shortley, complex
248423 coefficients for m ≥ 0, m-major ordering.
249424 - fp32 transforms introduce ~1e-6 relative error per step; for pattern formation
250- from 1e-2 seeded noise this is inconsequential. The geometry goes through one
425+ from a 1e-2 seeded perturbation this is inconsequential. The geometry goes through one
251426 analysis/synthesis round trip and picks up the same round-off: the unit sphere
252427 comes back with radius 1 to ~2e-5 under Dawn, ~4e-4 under SwiftShader.
253-- The shipped geometries are all degree ≤ 5, far below any lmax the app offers,
254- so band-limiting removes nothing from them. A shape you write yourself may not
255- be so lucky — see the note in [`geometries/bumpy.m`](geometries/bumpy.m).
428+- The shipped analytic geometries are all degree ≤ 5, and the random ones stay
429+ near degree 13 at their finest slider settings — far below any lmax the app
430+ offers, so band-limiting removes little to nothing from them. A shape you
431+ write yourself may not be so lucky — see the note in
432+ [`geometries/bumpy.m`](geometries/bumpy.m).
256433
257434 ## Desktop vs browser
258435
@@ -320,11 +497,17 @@ package alone. Its binaries need glibc 2.29+. Other flags: `--steps`,
320497 `--warmup`, `--batch`, `--json`, `--help`; `DAWN_FLAGS='backend=vulkan'`
321498 (`;`-separated) passes Dawn options through.
322499
500+### The same run in MATLAB
501+
502+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.
503+
504+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.
505+
323506 ## Tests
324507
325508 There is no second implementation of the solver to diff against, so the `.m`
326509 path is checked against **closed-form answers** and against **exact structural
327-properties**. Four modules, run in both environments:
510+properties**. Five modules, run in both environments:
328511
329512 [`test/analyticChecks.ts`](test/analyticChecks.ts) — cases whose evolution is
330513 known exactly, run through the whole real pipeline. All three are statements
@@ -341,19 +524,43 @@ about the round sphere, so all three build on the sphere geometry:
341524 Looser (~4e-3) because fp32 keeps about four digits of a perturbation that
342525 small.
343526
344-[`test/geometryChecks.ts`](test/geometryChecks.ts) — the surface and the loop:
527+[`test/geometryChecks.ts`](test/geometryChecks.ts) — the surface, the loop, and
528+the seed:
345529
346-- every geometry compiles and closes; the sphere has radius 1 everywhere and is
530+- every geometry evaluates and closes; the sphere has radius 1 everywhere and is
347531 **exactly degree 1** in the harmonics, which is what makes the reference case
348532 exact rather than merely accurate;
349533 - the peanut matches its own closed-form radial profile at every grid point, and
350534 **the same coefficients give the same surface on a 2× grid** — the 2× Gauss
351535 latitudes share no point with the 1× ones, so agreeing there is agreeing
352536 everywhere, which is what "rendered exactly, not subdivided" means;
353-- unrolling is **exactly linear** in the trip count, and the state after 20 steps
354- is **bit-identical** at 0, 1 and 4 iterations;
537+- unrolling is **exactly linear** in the trip count, and on the sphere — where
538+ the geometric correction is mathematically zero — the state after 20 steps
539+ stays within fp32 round-off of the 0-iteration one at 1 and 4 iterations;
355540 - a runtime loop bound is refused at compile time;
356-- swapping the surface mid-run leaves the spectral state untouched.
541+- swapping the surface mid-run leaves the spectral state untouched;
542+- the seed field's **WGSL sum matches the same modes summed in f64 on the
543+ CPU** (1.8e-6 over ~1,400 terms) — a kernel misreading the packed mode table
544+ would still produce a smooth random-looking field, which no "looks patterned"
545+ check would catch; the same seed redraws the same field and a different one
546+ does not; the field is band-limited (5e-14 of its energy above degree 20)
547+ with λ setting the scale; and a λ finer than the mode table holds is refused
548+ rather than silently truncated.
549+
550+[`test/fluxChecks.ts`](test/fluxChecks.ts) — the six-transform flux-form
551+Laplace-Beltrami scheme
552+([docs/reduced-transforms.md](docs/reduced-transforms.md)):
553+
554+- on the sphere, the precomputed weights match their closed form and the
555+ analysed fluxes are **exactly band-limited** (beyond-band tails at f64
556+ round-off, ~1e-13), while the deliberately non-smooth control
557+ `Q̃/sin θ` keeps a fat tail (~1e-2) — the discrimination the whole scheme
558+ rests on;
559+- on a non-axisymmetric surface, the flux tails match the Cartesian gradient
560+ component's, the doc's §7.1 criterion;
561+- the compiled op sequences add **5 Legendre transforms per species per
562+ iteration against Algorithm 4's 12**, and a real simulation driven by
563+ each stays within fp32 accumulation of the other.
357564
358565 [`test/modelChecks.ts`](test/modelChecks.ts) compiles every model the app offers
359566 and asserts **how many kernels it compiles to**, split into the base step and
@@ -362,7 +569,9 @@ stops folding, the results stay correct while every operator becomes its own
362569 dispatch, which is invisible in the numbers.
363570
364571 [`test/transformChecks.ts`](test/transformChecks.ts) compares the WGSL transforms
365-against shtns-webgpu's f64 CPU twin.
572+against shtns-webgpu's f64 CPU twin, and holds every compiled batch width to
573+the scalar transforms lane by lane; a model run with `SHT_BATCH=0` must
574+reproduce the batched run's state exactly.
366575
367576 - `npm run test:node` — under Dawn on the desktop, via `vite-node`. Needs a GPU;
368577 `--skip-without-gpu` lets a machine without one say so and move on (which is
@@ -388,6 +597,38 @@ Other commands:
388597 - `node scripts/check-live.mjs [url]` — smoke-check a deployed URL.
389598 - `test.html?soak=<steps>&lmax=<n>` — solver-only soak with no rendering.
390599
600+### Testing against a reference implementation
601+
602+Reference solutions live in the sibling
603+[turing-surface-test-data](https://github.com/concept-collection/turing-surface-test-data)
604+repo, so an independently-written solver never has to depend on this one.
605+`cases/schnakenberg-ellipsoid.md` there specifies the one case this repo
606+currently ships a reference for;
607+[`docs/ellipsoid-reference-spec.md`](docs/ellipsoid-reference-spec.md)
608+restates it in this repo's own terms.
609+
610+`npm run ref -- --in <file>` (`scripts/ref.ts`) loads a reference file, runs
611+the solver from its exact initial spectral state to the same physical end
612+time, and reports the relative-L2 and relative-L-infinity (max-norm) error
613+against its final state (plus a geometry sanity check). `--niter` overrides
614+the surface-correction iteration count independent of the file, and
615+`--tolerance`/`--tolerance-linf` each independently turn their metric into a
616+pass/fail for CI.
617+
618+The same check runs in the page: **Compare to reference…** picks a `.h5` and
619+opens the comparison in one step — the file's own settings (its recorded
620+niter, its band, its dt) as the single variant, paused at the file's exact
621+initial state, ready to Run. The file defines the whole problem — model,
622+parameters, geometry, initial state — and the run stops at the file's end
623+time, measured against one extra static row showing its final state on its
624+own surface. Watching *where* a variant leaves the reference (rather than
625+just reading one number per run) is the point. To widen the study, stop
626+comparing, pick more chips, and press Compare — the file stays loaded, with
627+the lmax choices floored at its band, since a narrower one could not hold
628+its initial state. Reading the file uses
629+[h5wasm](https://github.com/usnistgov/h5wasm)'s wasm build, loaded lazily on
630+the first file opened.
631+
391632 ## Development
392633
393634 ```
docs/algos.pdfadded+0−0View file
Binary file not shown.
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.mdadded+464−0View file
@@ -0,0 +1,464 @@
1+# Reducing spherical-harmonic transforms in $\Delta_\Gamma$
2+
3+**Summary.** Algorithm 4 costs 12 transforms per matvec (8 syntheses, 4 analyses). A flux-form
4+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), or **7** with the divergence split
6+against the round sphere that §5 turned out to require. Notation follows `algos.pdf`.
7+
8+---
9+
10+## 1. Where the current cost comes from
11+
12+| Algorithm 4 line | Work | Transforms |
13+|---|---|---|
14+| 1 | $\partial_\theta u,\ \partial_\varphi u$ | 2 $\mathcal{S}$ |
15+| 5 | analysis of 3 Cartesian components of $\nabla_\Gamma u$ | 3 $\mathcal{A}$ |
16+| 6 | $\partial_\theta$ and $\partial_\varphi$ of each of those 3 components | 6 $\mathcal{S}$ |
17+| 8 | final analysis | 1 $\mathcal{A}$ |
18+| | | **12** |
19+
20+Two sources of waste:
21+
22+1. The gradient is carried in **ambient $\mathbb{R}^3$ components** — 3 fields for an intrinsically
23+ 2-dimensional object.
24+2. Line 6 takes **both** derivatives of **each** component, where the divergence needs only one
25+ derivative of each of two fluxes.
26+
27+There is also a possible free win independent of everything below: Algorithm 1 as specified returns
28+all five derivatives. The Laplacian path needs only $\partial_\theta u$ and $\partial_\varphi u$;
29+the second-derivative and mixed-derivative machinery is used exclusively by Algorithm 3 (curvature).
30+If `surface_screened_laplacian` calls Algorithm 1 wholesale, it is doing 5 syntheses where 2 suffice
31+at lines 1 and 6.
32+
33+---
34+
35+## 2. The constraint that shapes the solution
36+
37+The Cartesian design in Algorithm 4 exists to avoid pole singularities, and it is correct to do so.
38+The relevant property is smoothness **as a scalar function on $S^2$**, since that is what controls
39+SH coefficient decay and hence whether $\mathcal{A}$ is meaningful.
40+
41+| Quantity | Smooth on $S^2$? |
42+|---|---|
43+| $\partial_\varphi u$ | yes — exactly band-limited, eq. (2.2) |
44+| $\sin\theta\,\partial_\theta u$ | yes — exactly band-limited, eq. (2.4) |
45+| $\partial_\theta u$ | **no** — bounded, but $\varphi$-dependent limit at the poles |
46+| $g_{\theta\theta},\ g^{\theta\theta},\ V_\theta,\ V_\varphi$ | **no** in general |
47+| $(\nabla_\Gamma u)_x,\ (\nabla_\Gamma u)_y,\ (\nabla_\Gamma u)_z$ | yes |
48+
49+Concretely, for the ellipsoid $X = (a\sin\theta\cos\varphi,\ b\sin\theta\sin\varphi,\ c\cos\theta)$,
50+$|X_\theta|^2 \to a^2\cos^2\varphi + b^2\sin^2\varphi$ as $\theta\to 0$: no limit exists.
51+
52+Algorithm 4 never analyzes anything in the "no" rows — the non-smooth quantities appear only as
53+pointwise grid factors. **Any replacement must preserve this property.** The naive flux form
54+$P = \sqrt{g}\,(g^{\theta\theta}u_\theta + g^{\theta\varphi}u_\varphi)$,
55+$Q = \sqrt{g}\,(g^{\varphi\theta}u_\theta + g^{\varphi\varphi}u_\varphi)$ does not:
56+on the round sphere with $u = x$, $Q = -\sin\varphi$, which is not a function on $S^2$.
57+
58+### The correct weighting
59+
60+$P$ and $Q$ are $\sqrt{g}$ times the contravariant components of $G := \nabla_\Gamma u$. Using
61+$\det[X_\theta, X_\varphi, n] = -\sqrt{g}$:
62+
63+$$P = -\,G\cdot(X_\varphi \times n), \qquad \sin\theta\,Q = -\,G\cdot\big(n \times \sin\theta\,X_\theta\big).$$
64+
65+Every factor on the right is smooth on $S^2$: $G$ smooth, $n$ smooth, $X_\varphi$ smooth, and
66+$\sin\theta\,X_\theta$ smooth because it is exactly band-limited by the recurrence already
67+implemented. Hence $P$ and $\tilde{Q} := \sin\theta\,Q$ are analyzable, on the same footing and for
68+the same structural reason as the Cartesian gradient components.
69+
70+The cross products are the smoothness certificate only — they are not needed in the code.
71+
72+---
73+
74+## 3. Precompute (once per surface, grid space)
75+
76+Replaces `_precompute_metric_quantities()`. From the embedding coefficients $\hat{X}^m_\ell$, obtain
77+$X_\varphi$ and $\sin\theta\,X_\theta$ componentwise via Algorithm 1, where the latter is the
78+**undivided** output of Algorithm 1 line 4, i.e. $\mathcal{S}(v^m_\ell)$ with no $/\sin\theta$.
79+Then, pointwise:
80+
81+$$\tilde{g}_{\theta\theta} := |\sin\theta\,X_\theta|^2, \qquad
82+ \tilde{g}_{\theta\varphi} := (\sin\theta\,X_\theta)\cdot X_\varphi, \qquad
83+ g_{\varphi\varphi} := |X_\varphi|^2$$
84+
85+$$J := \frac{\sqrt{\tilde{g}_{\theta\theta}\,g_{\varphi\varphi} - \tilde{g}_{\theta\varphi}^{\,2}}}{\sin^2\theta}
86+ \qquad\text{so that } \sqrt{\det g} = J\sin\theta$$
87+
88+(The radicand is $\sin^2\theta\det g = J^2\sin^4\theta$, so the square root is $J\sin^2\theta$ — hence
89+$\sin^2\theta$, not $\sin\theta$, in the denominator.)
90+
91+Store four scalar grid arrays:
92+
93+$$p_1 = \frac{g_{\varphi\varphi}}{J\sin^2\theta}, \qquad
94+ p_2 = -\frac{\tilde{g}_{\theta\varphi}}{J\sin^2\theta}, \qquad
95+ q_2 = \frac{\tilde{g}_{\theta\theta}}{J\sin^2\theta}, \qquad
96+ r = \frac{1}{J\sin^2\theta}$$
97+
98+All four are bounded: the $\sin^2\theta$ denominators cancel against vanishing numerators
99+($\tilde{g}_{\theta\varphi} = O(\sin^2\theta)$, $g_{\varphi\varphi} = O(\sin^2\theta)$), the same
100+finite limits the current $V_\theta, V_\varphi$ have. Note $p_1, p_2, q_2$ are bounded where
101+$V_\varphi = O(1/\sin\theta)$ is not.
102+
103+**Three scalar arrays replace the six components of $V_\theta, V_\varphi$.** The surface
104+representation is unchanged: $X_\theta, X_\varphi$ still come componentwise from $\hat{X}^m_\ell$
105+via Algorithm 1.
106+
107+---
108+
109+## 4. The per-matvec algorithm
110+
111+Input $\{u^m_\ell\}$; output $\{(\Delta_\Gamma u)^m_\ell\}$.
112+
113+| # | Step | Transforms |
114+|---|---|---|
115+| 1 | $v^m_\ell \leftarrow \alpha^+(\ell-1,m)u^m_{\ell-1} + \alpha^-(\ell+1,m)u^m_{\ell+1}$; $A \leftarrow \mathcal{S}(v^m_\ell)$ | $\mathcal{S}$ |
116+| 2 | $B \leftarrow \mathcal{S}(im\,u^m_\ell)$ | $\mathcal{S}$ |
117+| 3 | $P \leftarrow p_1 A + p_2 B$, $\tilde{Q} \leftarrow p_2 A + q_2 B$ — pointwise | — |
118+| 4 | $\hat{P} \leftarrow \mathcal{A}(P)$, $\hat{\tilde{Q}} \leftarrow \mathcal{A}(\tilde{Q})$ | 2 $\mathcal{A}$ |
119+| 5 | $s^m_\ell \leftarrow \alpha^+(\ell-1,m)\hat{P}^m_{\ell-1} + \alpha^-(\ell+1,m)\hat{P}^m_{\ell+1} + im\,\hat{\tilde{Q}}^m_\ell$ | — |
120+| 6 | $\Delta_\Gamma u \leftarrow r \cdot \mathcal{S}(s^m_\ell)$ | $\mathcal{S}$ |
121+| 7 | $\{(\Delta_\Gamma u)^m_\ell\} \leftarrow \mathcal{A}(\Delta_\Gamma u)$; zero $\ell \ge L-2$ | $\mathcal{A}$ |
122+
123+**4 syntheses + 2 analyses = 6**, versus 12.
124+
125+- $A$ and $B$ are exactly $\sin\theta\,\partial_\theta u$ and $\partial_\varphi u$.
126+- Steps 1 and 5 use the **same** precomputed $\alpha^\pm$ table; step 5 is the adjoint-style reuse
127+ of the shift already implemented for step 1. Adding the two flux contributions in coefficient
128+ space before synthesizing is what saves the final pair of transforms.
129+- The **only** division by $\sin\theta$ anywhere is folded into $p_1, p_2, q_2, r$ at precompute
130+ time. The per-matvec path contains none.
131+
132+### Implemented variation (2026-08-05): the $\varphi$-flux never needs the Legendre basis
133+
134+Step 4's analysis of $\tilde{Q}$ exists only so step 5 can apply $\partial_\varphi$ — but
135+$\partial_\varphi$ is diagonal in the Fourier index, so the implementation differentiates
136+$\tilde{Q}$ on the grid instead: FFT each latitude row, multiply mode $m$ by $im$ (zeroing
137+$m \ge L-2$ to mirror the top-degree filter; the Fourier analysis stage truncates $m > m_{\max}$
138+for free), inverse FFT. **5 Legendre transforms + one Legendre-free FFT derivative**, versus 6.
139+The caveat is that the grid route skips $\tilde Q$'s band projection in $\ell$; measured, this
140+does not bite — the band-edge spectra are identical to the 6-transform route's (the $m$ mask and
141+the final analysis's projection contain it), the Algorithm-4 A/B agreement is unchanged
142+($3.6\times10^{-4}$ after 20 steps at $L=63$), and the step gets ~8% faster at $L=255$
143+(~2% at $L=127$, where transform batching had already amortized most of what this removes).
144+
145+---
146+
147+## 5. Numerical trade-off
148+
149+Both schemes contain two powers of $\sin\theta$ division in total. What differs is **placement**.
150+
151+- **Algorithm 4** spends them in separate stages, one before line 5 and one after. The intervening
152+ analysis suppresses the polar spike: Gauss–Legendre weights give $w_1 = O(L^{-2})$ at the polar
153+ ring, so a grid error of $\varepsilon L$ there contributes
154+ $\sim L^{-2}\cdot L^{1/2}\cdot \varepsilon L = \varepsilon L^{-1/2}$ to any coefficient. Stage 2
155+ then starts from clean coefficients and incurs a *fresh* $\varepsilon L$. The two amplifications
156+ never multiply. Net grid-space relative error: $\varepsilon L$.
157+- **The new scheme** has no division at all through step 5, then pays for both powers at once in
158+ $r = O(L^2)$ at step 6 — one event, with no intervening analysis to break it in half. Net
159+ grid-space relative error: $\varepsilon L^2$.
160+
161+The mechanism: with $N \approx L+1$ Gauss–Legendre nodes, $1 - x_1 = O(N^{-2})$ so
162+$\sin\theta_1 = O(N^{-1})$. Since $s = J\sin^2\theta\,\Delta_\Gamma u$ is $O(L^{-2})$ at the polar
163+ring but $O(1)$ over the bulk, and synthesis commits roundoff scaled by the field's *global* size at
164+every node alike, multiplying by $r \sim L^2$ recovers the signal and inflates the noise.
165+
166+| | divisions | placement | grid-space relative error |
167+|---|---|---|---|
168+| Algorithm 4 | $\sin\theta$, $\sin\theta$ | separated by $\mathcal{A}$ | $\varepsilon L$ |
169+| Six-transform | $\sin^2\theta$ | all at the end | $\varepsilon L^2$ |
170+
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.
208+
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.
212+
213+Algorithm 1 line 7 already divides by $\sin^2\theta$, so the code is exposed to $\varepsilon L^2$
214+today — just on the second-derivative path, which the Laplacian never touches.
215+
216+---
217+
218+## 5a. float32 / WebGPU
219+
220+Target is WebGPU, which is float32-only: $\varepsilon = 2^{-24} \approx 6\times10^{-8}$ (spacing
221+$2^{-23} \approx 1.2\times10^{-7}$). No float64 fallback exists on device. All estimates in §5 are
222+linear in $\varepsilon$, so they scale directly:
223+
224+| | $\varepsilon\sqrt{L}$ (coeffs) | $\varepsilon L$ (Alg. 4 grid) | $\varepsilon L^2$ (new, grid) |
225+|---|---|---|---|
226+| $L=64$ | $5\times10^{-7}$ | $4\times10^{-6}$ | $2\times10^{-4}$ |
227+| $L=128$ | $7\times10^{-7}$ | $8\times10^{-6}$ | $1\times10^{-3}$ |
228+| $L=256$ | $1\times10^{-6}$ | $1.5\times10^{-5}$ | $4\times10^{-3}$ |
229+
230+**Coefficient space is fine** (~$10^{-6}$), which is the floor a float32 iterative solve sits at
231+anyway. **Grid space is not**: 0.1–0.4% relative on the polar rings at $L\ge128$. For a
232+reaction–diffusion solver this matters only if grid-space $\Delta_\Gamma u$ is consumed outside the
233+matvec. If the IMEX splitting evaluates $f(u)$ from $u$ on the grid (typical), it never is.
234+
235+**Two float32-specific arguments in favour of the new scheme:**
236+
237+- Baseline SHT roundoff accumulates per transform ($\sim\varepsilon\sqrt{L}$ to $\varepsilon L$
238+ each). Running 6 transforms instead of 12 halves that accumulation. On the coefficient-space error
239+ GMRES actually sees, this plausibly outweighs the polar term — the new scheme may be *more*
240+ accurate end-to-end in float32. Not asserted without measurement.
241+- 3 weight arrays instead of 6 halves per-matvec texture/buffer traffic. On GPU that is often the
242+ real bottleneck, independent of arithmetic.
243+
244+**Mitigations available without float64:**
245+
246+- **Pairwise or blocked summation in the Legendre sum over $\ell$.** The single highest-value
247+ float32 change, and it benefits the existing code too. See §5b.
248+- **Double-float (`f32x2`) arithmetic** for the pointwise steps 3 and 6 if needed — cheap, no
249+ transforms involved. Does not help with transform roundoff, which is the dominant term, so try
250+ summation order first.
251+- **CPU precompute in float64.** JS `Number` is float64, so §3 can run on the CPU regardless of
252+ WebGPU's limits, with float32 weights uploaded. Cost is CPU-side SHTs plus upload, paid once per
253+ surface update; viable if the surface evolves slowly or is prescribed analytically, likely too
254+ slow if the metric is rebuilt every timestep. Per the correction below, this is probably
255+ unnecessary.
256+- **Cap $L$.** All the error terms grow with $L$; float32 sets a practical ceiling that float64
257+ would not.
258+
259+---
260+
261+## 5b. Summation order in the Legendre transform
262+
263+This is orthogonal to the 12→6 change, applies equally to the current code, and in float32 is
264+probably worth more than the transform-count reduction. Do it first and independently, so its effect
265+can be measured on its own.
266+
267+**Why.** Every $\varepsilon L$ and $\varepsilon L^2$ in §5 rides on the per-transform roundoff
268+floor, and in float32 that floor is set by *how the sums are accumulated*, not by the mathematics.
269+For each $(m, \theta_i)$ the synthesis evaluates
270+
271+$$u^m(\theta_i) = \sum_{\ell=|m|}^{L} u^m_\ell\,\bar P^m_\ell(\cos\theta_i),$$
272+
273+an $O(L)$-term sum. Error growth by accumulation strategy, for an $N$-term sum:
274+
275+| Strategy | Worst case | Typical (random signs) |
276+|---|---|---|
277+| Sequential | $\varepsilon N$ | $\varepsilon\sqrt{N}$ |
278+| Pairwise / tree | $\varepsilon\log_2 N$ | $\varepsilon\sqrt{\log_2 N}$ |
279+| Kahan compensated | $\varepsilon$ (+ $O(\varepsilon^2 N)$) | $\varepsilon$ |
280+
281+At $L=256$ in float32 that is the difference between $\sim1.5\times10^{-5}$ and $\sim5\times10^{-7}$
282+per transform — more than an order of magnitude, for no change in operation count.
283+
284+**On GPU this may already be partly free.** A workgroup tree reduction over $\ell$ *is* pairwise
285+summation. The failure mode is a serial `for` loop over $\ell$ inside a single thread, which is the
286+natural way to write the shader if each thread owns one $(m,\theta_i)$ pair and is exactly the
287+$\varepsilon N$ row above. Check which shape the kernel has before assuming anything.
288+
289+**Where it applies.**
290+
291+- Synthesis $\mathcal{S}$: the sum over $\ell$, as above. The $\varphi$-direction FFT is already
292+ tree-structured and needs no attention.
293+- Analysis $\mathcal{A}$: the quadrature sum over latitude nodes $\theta_i$ carries the identical
294+ problem and the identical fix. It also matters more here, because this is the step relied on in
295+ §5 to suppress the polar spike — a noisy quadrature sum weakens exactly the mechanism the
296+ six-transform scheme depends on.
297+
298+**Practical notes.**
299+
300+- Blocked summation (accumulate in blocks of 8–32, then combine) captures most of the pairwise
301+ benefit with a simpler kernel and better register behaviour than a full tree.
302+- Kahan costs ~4 flops per term and is usually bandwidth-hidden on GPU; worth benchmarking rather
303+ than assuming it is too expensive.
304+- For $m>0$ near the poles, $\bar P^m_\ell(\cos\theta)$ spans many orders of magnitude across $\ell$.
305+ Summing smallest-magnitude-first helps, and is nearly free here because the terms are already
306+ roughly ordered by $\ell$.
307+- Standard stable recurrences for $\bar P^m_\ell$ (and guarding their under/overflow in float32's
308+ narrower exponent range) are a separate prerequisite — no summation strategy rescues inaccurate
309+ Legendre values.
310+
311+**Measurement.** Transform a band-limited field forward then back and compare to the input, in
312+float32, sweeping $L\in\{64,128,256\}$. Sequential accumulation shows error growing roughly linearly
313+in $L$; pairwise shows near-flat growth. This isolates the transform floor from everything else in
314+§7 and should be run before the validation gate there, since it sets the baseline that gate is
315+measured against.
316+
317+### Measured (2026-08-04, Dawn/Metal, `scripts/sht-accuracy.ts`)
318+
319+The sweep was run and the summation-order changes tried. Outcome: **withdrawn — the floor here is
320+not summation-limited.**
321+
322+| $L$ | grid | rel-$L_2$ roundtrip | worst degree |
323+|---|---|---|---|
324+| 63 | 64×128 | $3.4\times10^{-6}$ | $\ell=62$: $4.5\times10^{-6}$ |
325+| 127 | 128×256 | $4.7\times10^{-6}$ | $\ell=110$: $6.4\times10^{-6}$ |
326+| 255 | 256×512 | $1.1\times10^{-5}$ | $\ell=246$: $1.4\times10^{-5}$ |
327+
328+- **The analysis side already sums pairwise.** The quadrature over latitudes is a workgroup
329+ tree/subgroup reduction (`leg_analys`); only the synthesis has the serial per-thread $\ell$-loop.
330+- **Kahan is unavailable on WebGPU in practice.** Dawn/Metal compiles WGSL with fast-math: a probe
331+ kernel evaluates $((10^8 + 1) - 10^8) - 1$ to $0$, so the compensation folds away and Kahan
332+ compiles to plain summation (bit-identical results, verified).
333+- **Blocked summation (B=16) in the synthesis $\ell$-loop moved nothing**: $1.128\times10^{-5}
334+ \to 1.128\times10^{-5}$ at $L=255$ (low digits shift, confirming the reordering was live), while
335+ costing ~5% per round trip at $L=255$. Reverted.
336+- **Diagnosis:** the worst error concentrates at the top degrees — the signature of the Legendre
337+ *recurrence* error (chains of length $\sim\ell$), not of $\ell$-uniform accumulation noise. This
338+ is the "standard stable recurrences are a separate prerequisite" caveat above: the floor is set
339+ by the accuracy of the $\bar P^m_\ell$ values themselves, and no summation strategy touches it.
340+- The measured floor ($\sim\varepsilon L^{0.85}$, $1.1\times10^{-5}$ at $L=255$) is what the §7
341+ validation gate should be read against.
342+
343+---
344+
345+## 6. Code changes
346+
347+| Location | Change |
348+|---|---|
349+| `src/surface_gradient/partial_derivatives` | Expose $\mathcal{S}(v^m_\ell)$ **pre-division** (flag or separate entry point). Needed by both the precompute and step 1. |
350+| `SurfaceDiffOperator._precompute_metric_quantities()` | Return `p1, p2, q2, r` instead of `V_theta, V_phi`, per §3. |
351+| `src/surface_screened_laplacian::surface_screened_laplacian()` | Replace body with §4. Both `for i in {x,y,z}` loops disappear. |
352+| `SurfaceDiffOperator._precompute_curvature()`, Algorithm 3 | **Unchanged.** Still needs $X_{\theta\theta}, X_{\theta\varphi}, X_{\varphi\varphi}$ and the full Algorithm 1. |
353+| `src/timestepping::make_implicit_op()`, Algorithm 5 | **Unchanged.** Only what line 8 calls changes. |
354+| `src/real_embedding.py` | **Unchanged.** |
355+
356+The deprecated `SurfaceDiffOperator` methods for $\Delta_\Gamma$ and $(I + c\Delta_\Gamma)$ are the
357+natural place to keep the old path as a reference implementation for the validation below.
358+
359+---
360+
361+### Correction: precompute conditioning
362+
363+An earlier draft claimed the polar relative error in $\tilde g_{\theta\theta}$ is $\varepsilon L^2$,
364+making float64 precompute essential. That was wrong by a factor of $L$, in the safe direction.
365+$\tilde g_{\theta\theta}$ is not synthesized directly; it is the square of $\sin\theta\,X_\theta$,
366+which *is* synthesized, is $O(\sin\theta)$ at the poles, and carries absolute error $\varepsilon$ —
367+so relative error $\varepsilon L$, preserved (up to a factor 2) by squaring. Same for
368+$g_{\varphi\varphi}$ and $\tilde g_{\theta\varphi}$. The determinant combination is $O(\sin^4\theta)$
369+and so are both of its terms, so there is no extra cancellation generically; $J$ inherits
370+$\sim\varepsilon L$, i.e. $\sim10^{-5}$ at $L=128$ in float32. Acceptable.
371+
372+Caveat: this assumes the difference is not small compared to its terms, which fails if $X_\theta$
373+becomes nearly parallel to $X_\varphi$ (near-degenerate parametrization). Worth a runtime check on
374+$\det g$ if the surface can deform that far.
375+
376+Precompute error is also a *fixed* perturbation, identical every matvec, so it perturbs which
377+operator is being solved but injects no noise into the Krylov space — GMRES converges normally.
378+
379+---
380+
381+## 7. Validation, in order
382+
383+At $\varepsilon=6\times10^{-8}$ there is no margin for the $\varepsilon\sqrt{L}$ suppression estimate
384+in §5 to be off by an order of magnitude. Step 2 is a **gate**, not a confirmation.
385+
386+1. **Smoothness check (do this first).** On a deformed, non-axisymmetric surface, form $P$ and
387+ $\tilde{Q}$ on the grid and compare their SH coefficient decay against $(\nabla_\Gamma u)_x$ from
388+ the current code. Matching tails confirm both are genuinely smooth on $S^2$. If this fails,
389+ nothing else is worth doing. Run this in float64 on CPU — it is a mathematical check, not a
390+ precision one.
391+2. **Coefficient-space diff in float32 at production $L$**, against a float64 CPU reference
392+ implementation of Algorithm 4. Landing near $10^{-6}$ means the suppression argument holds.
393+ Landing near $10^{-4}$ means the polar spike is surviving the analysis and the polar rings need
394+ separate handling.
395+3. **Sweep $L \in \{64,128,256\}$** and fit the growth exponent of (2). Flat-ish confirms
396+ suppression; growth like $L^2$ means it is not working.
397+4. **Grid-space max-norm diff near the poles.** Expect the extra power of $L$ here. If only this
398+ grows and (2) stays flat, the scheme is fine for use inside the implicit solve.
399+5. **GMRES iteration count and final achieved residual**, float32, versus the current code. The
400+ operator is the same, so iterations should be unchanged; a stall above tolerance that does not
401+ occur in float64 indicates the matvec noise floor is binding.
402+
403+---
404+
405+## 8. Suggestions considered and withdrawn
406+
407+- **Splitting $r$ across steps 3 and 6** to keep $\sin^1$ scaling. Does not work: $P/\sin\theta$ is
408+ not smooth on $S^2$ (round sphere, $u = x$: $P = \sin\theta\cos\theta\cos\varphi$, so
409+ $P/\sin\theta \to \cos\varphi$ at the pole). That $\sin\theta$ must stay in $r$. Algorithm 4 *can*
410+ split because its intermediate — the Cartesian gradient — is smooth; that smoothness is precisely
411+ what the six extra transforms buy.
412+- **Weighting by $J$ to get an SPD operator and use PCG.** Avoiding the $1/\sqrt{g}$ makes
413+ $\mathcal{L}$ self-adjoint, but the mass term becomes multiplication by $J$, costing its own
414+ synthesis/analysis pair. A wash on transform count; worth it only if the CG properties themselves
415+ are wanted. Discrete symmetry would also hold only to quadrature accuracy unless products are
416+ dealiased (3/2 rule).
417+- **Mixed precision (float64 for step 6's synthesis and the $r$ multiply only).** Unavailable:
418+ WebGPU is float32-only. Superseded by the mitigations in §5a.
419+- If the $\varepsilon L^2$ ever does bind, the remaining remedies are a shifted or uniform-in-$\theta$
420+ latitude grid (removing the $O(L^{-2})$ node clustering) or a separate local formula for the polar
421+ rings — both more work than the transform savings justify without a specific reason. In float32
422+ the ceiling on $L$ may bind first and be the cheaper accommodation.
423+
424+## 9. Related: external vector transforms
425+
426+Steps 1–2 and 4–5 together are a vector/spin-weighted spherical harmonic transform. If SHTns
427+(`spat_to_SHsphtor`, `SHsphtor_to_spat`) or SPHEREPACK (`gradgs`, `divgs`) can be linked, the
428+gradient and divergence each become a single library call, the pole divisions are handled internally,
429+and the hand-rolled $\alpha^\pm$ recurrences on this path are no longer needed.
430+
431+## 10. Beyond transform count
432+
433+The other lever is iteration count rather than cost per iteration. Since $M^{-1}A$ approaches
434+multiplication by $1/J$ at high $\ell$, folding a mean or smoothed $J$ into the preconditioner could
435+reduce GMRES iterations by more than any of the above reduces transforms.
436+
437+### Measured (2026-08-05, Richardson iteration, fp32)
438+
439+Implemented, with two corrections the measurements forced.
440+
441+**The right constant is the minimax over the symbol, not over $J$.** The high-$\ell$ per-mode
442+factor is governed by the full principal symbol: in the orthonormal frame the symbol matrix is
443+$S = (1/J)\begin{pmatrix} p_1 & p_2 \\ p_2 & q_2\end{pmatrix}$, whose eigenvalues $\mu(x)$ are the
444+inverse squared principal stretches — direction matters. Preconditioning with $\lambda/\hat J$
445+contracts every mode and direction iff $\hat J\mu \in (0,2)$, so
446+$$\hat J = 2/(\mu_{\min} + \mu_{\max}), \qquad \text{rate} = (\mu_{\max}-\mu_{\min})/(\mu_{\max}+\mu_{\min}) < 1.$$
447+The det-based mean of $J$ (this section's original suggestion; $\mu$'s geometric mean, exact only
448+for conformal surfaces) is insufficient: on the shipped ellipsoid it leaves directional
449+high-degree bands with amplification $> 1$ — patterns went qualitatively high-frequency at
450+moderate settings and diverged as niter or $L$ grew. With the symbol-based constant every
451+niter/geometry combination in the test sweep converges (peanut: $\mu \in [0.44, 6.2]$, plain rate
452+5.2, preconditioned rate 0.87).
453+
454+**The correction must be band-projected.** Algorithm 5's "zero $\ell \ge L-2$" is load-bearing:
455+without applying the same mask to the correction $d\Delta u$, the top two degrees iterate toward
456+the *undiffused* right-hand side — each Richardson iteration strips more of their implicit
457+diffusion, at species-dependent rates, manufacturing a spurious Turing band at the band edge
458+(observed on the round sphere: top-degree energy growing $\sim 3\%$/step at $L=127$, 8 iterations).
459+
460+**Payoff shape:** on mildly deformed surfaces one iteration already reaches the $\sim10^{-4}$ fp32
461+accumulation floor, so iteration counts do not drop — the speculation above does not hold at fp32.
462+The gain is reach and correctness: stiff geometries and high niter/$L$ combinations that
463+previously diverged (or silently shifted the pattern's wavelength) now converge with a
464+resolution-independent spectrum.
docs/richardson-iteration.mdmodified+17−0View file
@@ -86,6 +86,23 @@ diverges over many steps — rather than being caught the way algos.tex's
8686 `solve_step` catches it (its `info != 0` return, logged when GMRES fails to
8787 reach `tol` within `maxiter`).
8888
89+**Update (symbol-based preconditioning).** The models now precondition
90+with `M = I + dt*D*lam/jhat` where `jhat = 2/(muMin + muMax)` is the host's
91+minimax scale over the eigenvalues of the operator's principal symbol — the
92+inverse squared principal stretches of the embedding, direction included
93+(see docs/reduced-transforms.md Sec 10). At high degree the iteration then
94+contracts at rate `(muMax - muMin)/(muMax + muMin) < 1` on any surface,
95+where the plain `M` diverges wherever `mu > 2` — which is what used to put
96+peanut outside the convergence radius at niter >= 2, and what made patterns
97+drift high-frequency on the ellipsoid as niter or lmax grew. The correction
98+is also projected onto the band (`.* filt` on `dLu`, algos.tex Algorithm
99+5's zeroing), without which the top two degrees iterate toward an
100+undiffused fixed point. The silent-failure caveat above still stands for
101+what a constant scale cannot capture (strong *spatial* variation of the
102+symbol at low degree, or `dt*D` beyond the correction's reach), but the
103+sweep's previously divergent cases all converge now, and `jhat: 1`
104+reproduces the old behavior for A/B.
105+
89106 That tradeoff is deliberate, not an oversight, and it comes from where the
90107 two projects run. algos.tex's GMRES needs, every iteration: a dot product
91108 across the whole spectral state (Arnoldi orthogonalization) and a residual
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+189−64View 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,10 +175,69 @@
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 }
184+ /* ---- compare mode ------------------------------------------------ */
185+ /* The bar's own layout: three chip rows stacked, then the reference
186+ picker and the button beside them. */
187+ .cmp-axes { display: flex; flex-direction: column; gap: 4px; }
188+ .cmp-axis { display: flex; align-items: center; gap: 8px; }
189+ .cmp-axis > span:first-child {
190+ color: var(--ink-2); font-size: 13px; width: 5.5em; text-align: right;
191+ }
192+ .chips { display: flex; flex-wrap: wrap; gap: 4px; }
193+ .chip {
194+ font: inherit; font-size: 12px; padding: 2px 8px;
195+ border: 1px solid var(--line); border-radius: 999px;
196+ background: var(--bg); color: var(--ink-2); cursor: pointer;
197+ }
198+ .chip:hover { border-color: var(--accent); }
199+ .chip[aria-pressed="true"] {
200+ border-color: var(--accent); color: var(--accent);
201+ background: color-mix(in srgb, var(--accent) 12%, transparent);
202+ font-weight: 600;
203+ }
204+ /* The panel area becomes a stack of labelled rows. Overrides the flex
205+ wrap the single-run view uses. */
206+ #panels.compare { flex-direction: column; gap: 8px; }
207+ .cmp-row { display: flex; align-items: stretch; gap: 8px; }
208+ .cmp-rowlabel {
209+ flex: none; width: 13em; padding: 6px 8px;
210+ border-left: 4px solid var(--c, var(--line));
211+ font-size: 12px; color: var(--ink-2);
212+ display: flex; flex-direction: column; justify-content: center; gap: 3px;
213+ }
214+ .cmp-rowname { color: var(--ink); font-weight: 600; }
215+ .cmp-rowstat { font-variant-numeric: tabular-nums; line-height: 1.35; }
216+ .cmp-diverged { color: var(--warn-edge); }
217+ /* The header row's label cell is a spacer, not a variant — no swatch. */
218+ .cmp-head .cmp-rowlabel { border-left-color: transparent; padding: 0 8px; }
219+ .cmp-cols { flex: 1; display: flex; gap: 8px; min-width: 0; }
220+ .cmp-box {
221+ flex: 1 1 0; min-width: 0;
222+ border: 1px solid var(--line); border-radius: 8px; overflow: hidden;
223+ max-height: 42vh;
224+ }
225+ .cmp-head { align-items: flex-end; }
226+ .cmp-colhead {
227+ flex: 1 1 0; min-width: 0;
228+ display: flex; align-items: center; gap: 8px;
229+ font-size: 12px; color: var(--ink-2);
230+ }
231+ .cmp-rangebar {
232+ flex: 1 1 auto; min-width: 0; height: 8px;
233+ border: 1px solid var(--line); border-radius: 2px;
234+ }
235+ .cmp-rangelab { font-variant-numeric: tabular-nums; white-space: nowrap; }
236+ @media (max-width: 860px) {
237+ .cmp-row { flex-direction: column; }
238+ .cmp-rowlabel { width: auto; flex-direction: row; gap: 10px; }
239+ .cmp-head { display: none; }
240+ }
174241 .tok-com { color: var(--tok-com); }
175242 .tok-str { color: var(--tok-str); }
176243 .tok-num { color: var(--tok-num); }
@@ -181,36 +248,69 @@
181248 <body>
182249 <main>
183250 <h1>turing-surface</h1>
184- <p class="warn">
185- <b>⚠ Work in progress: the geometry is drawn, not solved on.</b>
186- The solver still uses the round sphere's Laplace–Beltrami operator, so
187- on other shapes you see the sphere's pattern painted onto that surface.
188- </p>
189251 <p class="sub">
190252 Reaction-diffusion on closed surfaces, solved live with spherical
191253 harmonics on WebGPU via
192254 <a href="https://github.com/concept-collection/shtns-webgpu">shtns-webgpu</a>.
193- Both the solver and the shape are the MATLAB below, compiled in your
255+ Both the solver and the shape are the MATLAB below, run in your
194256 browser by <a href="https://numbl.org">numbl</a>. Edit either and watch
195257 it change. Drag to rotate.
196258 </p>
197- <div class="controls">
198- <label>preset
259+ <div class="modes" id="modebar">
260+ <button type="button" class="chip" id="mode-simulate" aria-pressed="true">Simulate</button>
261+ <button type="button" class="chip" id="mode-effort"
262+ title="Run several solver settings side by side on one clock">Compare computational effort</button>
263+ <button type="button" class="chip" id="mode-vs-sphere" disabled title="Coming soon">Compare against sphere (Coming soon)</button>
264+ <button type="button" class="chip" id="mode-vs-upload"
265+ title="Check this solver against a saved reference run">
266+ Compare against uploaded data</button>
267+ <input type="file" id="cmp-file" accept=".h5" hidden>
268+ </div>
269+ <p class="mode-desc" id="mode-desc"></p>
270+ <div class="controls ctrl-group" data-group="surface">
271+ <label>reaction-diffusion model
199272 <select id="model"></select>
200273 </label>
201- <label title="The surface. Rendered, but not yet in the operator.">geometry
274+ <label title="The surface the pattern is solved on. Swapping it does not recompile the solver or restart the run.">geometry
202275 <select id="geometry"></select>
203276 </label>
204- <label title="Blend between the sphere (0) and the surface (1). Display only.">morph
205- <input type="range" id="morph" min="0" max="1" step="0.01" value="1" />
277+ </div>
278+ <div class="ctrl-group" data-group="surface-params">
279+ <div class="controls" id="params"></div>
280+ <div class="controls" id="geomparams"></div>
281+ </div>
282+ <div class="controls" id="comparebar" hidden>
283+ <div class="cmp-axes">
284+ <div class="cmp-axis">
285+ <span title="Iterations of the implicit diffusion solve">solve iters</span>
286+ <span id="cmp-niter" class="chips"></span>
287+ </div>
288+ <div class="cmp-axis">
289+ <span>lmax</span>
290+ <span id="cmp-lmax" class="chips"></span>
291+ </div>
292+ <div class="cmp-axis">
293+ <span title="Timestep, as a divisor of the model's dt. Divisors keep every variant on the same clock exactly.">dt</span>
294+ <span id="cmp-dt" class="chips"></span>
295+ </div>
296+ </div>
297+ <label title="The run everything else is measured against">reference
298+ <select id="cmp-ref"></select>
206299 </label>
300+ <span id="cmp-fileinfo" class="stats" hidden></span>
301+ <button id="cmp-fileclear" hidden
302+ title="Drop the reference file and compare the variants against each other again">×</button>
303+ <button id="cmp-start" class="primary">Compile comparison</button>
304+ <span id="cmp-count" class="stats"></span>
305+ </div>
306+ <div class="controls ctrl-group" data-group="solver">
207307 <label title="Iterations of the implicit diffusion solve. Changing it recompiles.">solve iters
208308 <select id="niter">
209309 <option value="0">0</option>
210- <option value="1" selected>1</option>
310+ <option value="1">1</option>
211311 <option value="2">2</option>
212312 <option value="4">4</option>
213- <option value="8">8</option>
313+ <option value="8" selected>8</option>
214314 <option value="16">16</option>
215315 <option value="32">32</option>
216316 <option value="64">64</option>
@@ -225,6 +325,8 @@
225325 <option value="255">255</option>
226326 </select>
227327 </label>
328+ </div>
329+ <div class="controls ctrl-group" data-group="display">
228330 <label title="Render on a finer grid. Display only.">display oversampling
229331 <select id="oversample">
230332 <option value="auto" selected>auto</option>
@@ -237,40 +339,53 @@
237339 <label>colormap
238340 <select id="colormap"></select>
239341 </label>
342+ <label title="Blend between the sphere (0) and the surface (1). Display only.">morph
343+ <input type="range" id="morph" min="0" max="1" step="0.01" value="1" />
344+ </label>
345+ <button id="resetview">Reset view</button>
346+ </div>
347+ <div class="controls ctrl-group" data-group="playback">
240348 <button id="runpause" class="primary">Run</button>
349+ <button id="restart" title="Rewind to the initial condition this run started from, without drawing a new one">Restart</button>
350+ </div>
351+ <div class="controls ctrl-group" data-group="benchmark">
241352 <button id="benchmark">Benchmark</button>
242- <button id="reseed">Re-seed</button>
243- <button id="resetview">Reset view</button>
244- <button id="movietoggle" title="Export the run as an MP4 movie">Export movie</button>
245353 </div>
246- <div class="controls" id="moviebar" hidden>
247- <label title="Simulation-time units per second of video">movie speed
248- <select id="moviespeed">
249- <option value="0.1">0.1×</option>
250- <option value="0.5">0.5×</option>
251- <option value="1">1×</option>
252- <option value="3">3×</option>
253- <option value="5">5×</option>
254- <option value="10" selected>10×</option>
255- <option value="20">20×</option>
256- </select>
257- </label>
258- <label title="Size of each sphere panel in the video, in pixels">resolution
259- <select id="movieres">
260- <option value="480">480</option>
261- <option value="640">640</option>
262- <option value="768" selected>768</option>
263- <option value="1080">1080</option>
264- <option value="1440">1440</option>
265- </select>
266- </label>
267- <label title="Slowly orbit the camera during the movie">
268- <input type="checkbox" id="movierotate" checked /> auto-rotate
354+ <div class="controls ctrl-group" data-group="seed">
355+ <label title="Wavelength of the smooth random field generating the initial condition">Initial condition wavelength λ
356+ <input id="lam3" type="number" min="0" step="0.05" value="0.5">
269357 </label>
270- <button id="movie" title="Replay the run from t = 0 and download an MP4">Export</button>
358+ <button id="reseed">Re-seed</button>
359+ </div>
360+ <div class="controls ctrl-group" data-group="movie">
361+ <button id="movietoggle" title="Export the run as an MP4 movie">Export movie</button>
362+ <div class="controls" id="moviebar" hidden>
363+ <label title="Simulation-time units per second of video">movie speed
364+ <select id="moviespeed">
365+ <option value="0.1">0.1×</option>
366+ <option value="0.5">0.5×</option>
367+ <option value="1">1×</option>
368+ <option value="3">3×</option>
369+ <option value="5">5×</option>
370+ <option value="10" selected>10×</option>
371+ <option value="20">20×</option>
372+ </select>
373+ </label>
374+ <label title="Size of each sphere panel in the video, in pixels">resolution
375+ <select id="movieres">
376+ <option value="480">480</option>
377+ <option value="640">640</option>
378+ <option value="768" selected>768</option>
379+ <option value="1080">1080</option>
380+ <option value="1440">1440</option>
381+ </select>
382+ </label>
383+ <label title="Slowly orbit the camera during the movie">
384+ <input type="checkbox" id="movierotate" checked /> auto-rotate
385+ </label>
386+ <button id="movie" title="Replay the run from t = 0 and download an MP4">Export</button>
387+ </div>
271388 </div>
272- <div class="controls" id="params"></div>
273- <div class="controls" id="geomparams"></div>
274389 <p id="geomnote" class="stats"></p>
275390 <div id="panels"></div>
276391 <p class="stats" id="stats"></p>
@@ -307,6 +422,16 @@
307422 </div>
308423 <code id="cmd"></code>
309424 </div>
425+ <details class="cli" id="matlab">
426+ <summary class="cli-head">
427+ <span>The same run as a standalone MATLAB script</span>
428+ <span>
429+ <button id="copymatlab" type="button">Copy</button>
430+ <button id="downloadmatlab" type="button">Download .m</button>
431+ </span>
432+ </summary>
433+ <pre id="matlabscript"></pre>
434+ </details>
310435 <p id="blurb"></p>
311436 <p id="err"></p>
312437 </main>
models/allencahn.mmodified+26−29View file
@@ -2,45 +2,42 @@
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, Vtx, Vty, Vtz, Vpx, Vpy, Vpz, 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);
16- Un = Bu ./ (1 + (dt * eps2) * lam);
17+
18+ % Mean-J preconditioning -- see models/schnakenberg.m.
19+ lamJ = lam ./ jhat;
20+ Un = Bu ./ (1 + (dt * eps2) * lamJ);
1721
1822 for k = 1:niter
19- % dlap = lap_g - lap_s, evaluated at the current iterate (see
20- % models/schnakenberg.m and docs/richardson-iteration.md for the
21- % derivation).
23+ % dlap = lap_g - lap_s, evaluated at the current iterate in flux form
24+ % (see models/schnakenberg.m, docs/richardson-iteration.md and
25+ % docs/reduced-transforms.md for the derivation; the grouped calls run
26+ % the gradient syntheses and the flux analyses as batched dispatches).
2227 Fu = Un .* filt;
23- Ftu = dtheta(Fu);
24- Fpu = dphi(Fu);
25- dux = Ftu .* Vtx + Fpu .* Vpx;
26- duy = Ftu .* Vty + Fpu .* Vpy;
27- duz = Ftu .* Vtz + Fpu .* Vpz;
28- cux = analys(dux) .* filt;
29- cuy = analys(duy) .* filt;
30- cuz = analys(duz) .* filt;
31- Ftcux = dtheta(cux);
32- Fpcux = dphi(cux);
33- Ftcuy = dtheta(cuy);
34- Fpcuy = dphi(cuy);
35- Ftcuz = dtheta(cuz);
36- Fpcuz = dphi(cuz);
37- lapu = Ftcux .* Vtx + Fpcux .* Vpx;
38- lapu = lapu + Ftcuy .* Vty;
39- lapu = lapu + Fpcuy .* Vpy;
40- lapu = lapu + Ftcuz .* Vtz;
41- lapu = lapu + Fpcuz .* Vpz;
42- dLu = analys(lapu) + lam .* Un;
28+ vtu = dthetac(Fu);
29+ vpu = dphic(Fu);
30+ [Ftu, Fpu, Su] = synth(vtu, vpu, lam .* Fu);
31+ Pu = dp1 .* Ftu + p2 .* Fpu;
32+ Qu = p2 .* Ftu + dq2 .* Fpu;
33+ PAu = analys(Pu);
34+ Pcu = PAu .* filt;
35+ scu = dthetac(Pcu);
36+ Lu = synth(scu);
37+ dQu = dphig(Qu);
38+ lapu = r .* (Lu + dQu) - jinv .* Su;
39+ dLu = (analys(lapu) + lamJ .* Un) .* filt;
4340
44- Un = (Bu + (dt * eps2) * dLu) ./ (1 + (dt * eps2) * lam);
41+ Un = (Bu + (dt * eps2) * dLu) ./ (1 + (dt * eps2) * lamJ);
4542 end
4643 end
models/brusselator.mmodified+47−59View file
@@ -3,75 +3,63 @@
33 % du/dt = D1*lap_g(u) + A - (B+1)*u + u^2*v
44 % dv/dt = D2*lap_g(v) + B*u - u^2*v
55 %
6-% Same scheme as models/schnakenberg.m.
6+% Same scheme as models/schnakenberg.m, including the grouped transforms:
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.
710
8-function [U, V, u, v] = init(noise, A, B)
9- U = analys(A + noise);
10- V = analys((B / A) * ones(numel(noise), 1));
11- u = synth(U);
12- v = synth(V);
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));
15+ [u, v] = synth(U, V);
1316 end
1417
15-function [Un, Vn, u, v] = step(U, V, lam, filt, gx, gy, gz, Vtx, Vty, Vtz, Vpx, Vpy, Vpz, A, B, D1, D2, dt, niter)
16- u = synth(U);
17- v = synth(V);
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)
19+ [u, v] = synth(U, V);
1820 uuv = u .* u .* v;
1921
20- Bu = U + dt * analys(A - (B + 1) * u + uuv);
21- Bv = V + dt * analys(B * u - uuv);
22+ ru = A - (B + 1) * u + uuv;
23+ rv = B * u - uuv;
24+ [Ru, Rv] = analys(ru, rv);
25+ Bu = U + dt * Ru;
26+ Bv = V + dt * Rv;
2227
23- Un = Bu ./ (1 + (dt * D1) * lam);
24- Vn = Bv ./ (1 + (dt * D2) * lam);
28+ % Mean-J preconditioning -- see models/schnakenberg.m.
29+ lamJ = lam ./ jhat;
30+ Un = Bu ./ (1 + (dt * D1) * lamJ);
31+ Vn = Bv ./ (1 + (dt * D2) * lamJ);
2532
2633 for k = 1:niter
27- % dlap = lap_g - lap_s, evaluated at the current iterate (see
28- % models/schnakenberg.m and docs/richardson-iteration.md for the
29- % derivation).
34+ % dlap = lap_g - lap_s, evaluated at the current iterate in flux form
35+ % (see models/schnakenberg.m, docs/richardson-iteration.md and
36+ % docs/reduced-transforms.md for the derivation and the ordering).
3037 Fu = Un .* filt;
31- Ftu = dtheta(Fu);
32- Fpu = dphi(Fu);
33- dux = Ftu .* Vtx + Fpu .* Vpx;
34- duy = Ftu .* Vty + Fpu .* Vpy;
35- duz = Ftu .* Vtz + Fpu .* Vpz;
36- cux = analys(dux) .* filt;
37- cuy = analys(duy) .* filt;
38- cuz = analys(duz) .* filt;
39- Ftcux = dtheta(cux);
40- Fpcux = dphi(cux);
41- Ftcuy = dtheta(cuy);
42- Fpcuy = dphi(cuy);
43- Ftcuz = dtheta(cuz);
44- Fpcuz = dphi(cuz);
45- lapu = Ftcux .* Vtx + Fpcux .* Vpx;
46- lapu = lapu + Ftcuy .* Vty;
47- lapu = lapu + Fpcuy .* Vpy;
48- lapu = lapu + Ftcuz .* Vtz;
49- lapu = lapu + Fpcuz .* Vpz;
50- dLu = analys(lapu) + lam .* Un;
51-
5238 Fv = Vn .* filt;
53- Ftv = dtheta(Fv);
54- Fpv = dphi(Fv);
55- dvx = Ftv .* Vtx + Fpv .* Vpx;
56- dvy = Ftv .* Vty + Fpv .* Vpy;
57- dvz = Ftv .* Vtz + Fpv .* Vpz;
58- cvx = analys(dvx) .* filt;
59- cvy = analys(dvy) .* filt;
60- cvz = analys(dvz) .* filt;
61- Ftcvx = dtheta(cvx);
62- Fpcvx = dphi(cvx);
63- Ftcvy = dtheta(cvy);
64- Fpcvy = dphi(cvy);
65- Ftcvz = dtheta(cvz);
66- Fpcvz = dphi(cvz);
67- lapv = Ftcvx .* Vtx + Fpcvx .* Vpx;
68- lapv = lapv + Ftcvy .* Vty;
69- lapv = lapv + Fpcvy .* Vpy;
70- lapv = lapv + Ftcvz .* Vtz;
71- lapv = lapv + Fpcvz .* Vpz;
72- dLv = analys(lapv) + lam .* Vn;
39+ vtu = dthetac(Fu);
40+ vpu = dphic(Fu);
41+ vtv = dthetac(Fv);
42+ vpv = dphic(Fv);
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;
48+ [PAu, PAv] = analys(Pu, Pv);
49+ Pcu = PAu .* filt;
50+ Pcv = PAv .* filt;
51+ scu = dthetac(Pcu);
52+ scv = dthetac(Pcv);
53+ [Lu, Lv] = synth(scu, scv);
54+ dQu = dphig(Qu);
55+ dQv = dphig(Qv);
56+ lapu = r .* (Lu + dQu) - jinv .* Su;
57+ lapv = r .* (Lv + dQv) - jinv .* Sv;
58+ [LAu, LAv] = analys(lapu, lapv);
59+ dLu = (LAu + lamJ .* Un) .* filt;
60+ dLv = (LAv + lamJ .* Vn) .* filt;
7361
74- Un = (Bu + (dt * D1) * dLu) ./ (1 + (dt * D1) * lam);
75- Vn = (Bv + (dt * D2) * dLv) ./ (1 + (dt * D2) * lam);
62+ Un = (Bu + (dt * D1) * dLu) ./ (1 + (dt * D1) * lamJ);
63+ Vn = (Bv + (dt * D2) * dLv) ./ (1 + (dt * D2) * lamJ);
7664 end
7765 end
models/schnakenberg.mmodified+109−64View file
@@ -8,83 +8,128 @@
88 % spherical-harmonic space (eigenvalues -lam), and the loop iterates the
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.
11+%
12+% The correction evaluates lap_g in flux form -- 7 transforms per species
13+% per iteration where the Cartesian-gradient form (Algorithm 4 of
14+% evolving_surface/notes/algos.tex) needs 12. See
15+% docs/reduced-transforms.md, and models/schnakenberg_alg4.m
16+% 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.
1126
12-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);
1334 us = a + b;
1435 vs = b / (us * us);
15- U = analys(us + noise);
16- V = analys(vs * ones(numel(noise), 1));
17- u = synth(U);
18- v = synth(V);
36+ [U, V] = analys(us + 0.01*f, vs * ones(numel(f), 1));
37+ [u, v] = synth(U, V);
1938 end
2039
21-function [Un, Vn, u, v] = step(U, V, lam, filt, gx, gy, gz, Vtx, Vty, Vtz, Vpx, Vpy, Vpz, a, b, D1, D2, dt, niter)
22- u = synth(U);
23- v = synth(V);
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)
41+ % Grouped transforms -- [a, b] = synth(x, y) -- are explicit batching:
42+ % output k is the transform of input k, and the whole group runs as one
43+ % batched Legendre dispatch, or as many as the device's lane width allows
44+ % (src/mgpu/plan.ts, materializeTransforms). The grouping is a promise of
45+ % independence, never of a lane width, so the same source runs anywhere.
46+ [u, v] = synth(U, V);
2447 uuv = u .* u .* v;
2548
2649 % Right-hand side of the implicit solve (I - dt*D*lap_g) Unew = B.
27- Bu = U + dt * analys(a - u + uuv);
28- Bv = V + dt * analys(b - uuv);
50+ ru = a - u + uuv;
51+ rv = b - uuv;
52+ [Ru, Rv] = analys(ru, rv);
53+ Bu = U + dt * Ru;
54+ Bv = V + dt * Rv;
2955
30- % Round-sphere solve, then iterate the geometric correction.
31- Un = Bu ./ (1 + (dt * D1) * lam);
32- Vn = Bv ./ (1 + (dt * D2) * lam);
56+ % Preconditioned solve, then iterate the geometric correction. jhat is
57+ % the host's minimax scale over the operator's symbol eigenvalues mu(x)
58+ % -- the inverse squared principal stretches of the embedding, direction
59+ % included (src/geom/geometry.ts, Jhat): preconditioning with lam/jhat
60+ % contracts every mode and direction at rate
61+ % (muMax - muMin)/(muMax + muMin) < 1 on any surface, where the plain
62+ % lam (jhat = 1) diverges wherever mu > 2 -- docs/reduced-transforms.md
63+ % Sec 10. The answer never depends on jhat (the lamJ term added inside
64+ % dLu is the term divided back out); only the convergence rate does. On
65+ % the sphere mu = 1 and lamJ = lam.
66+ lamJ = lam ./ jhat;
67+ Un = Bu ./ (1 + (dt * D1) * lamJ);
68+ Vn = Bv ./ (1 + (dt * D2) * lamJ);
3369
3470 for k = 1:niter
35- % dlap = lap_g - lap_s, evaluated at the current iterate (Algorithm 3 of
36- % evolving_surface/notes/algos.tex): surface gradient of the field,
37- % contracted through the inverse metric quantities Vt*/Vp*; each
38- % Cartesian component re-analysed and differentiated again; recombined
39- % into the surface divergence. lam.*Un adds back -lap_s(Un), since lam
40- % holds +l(l+1). filt zeroes the top two degrees, where the theta/phi
41- % derivative recurrences cannot exactly represent a derivative. See
42- % docs/richardson-iteration.md.
71+ % dlap = lap_g - lap_s at the current iterate, in flux form
72+ % (docs/reduced-transforms.md Sec 4). The sin-weighted derivatives
73+ % A = sin(theta)*dtheta(u) and B = dphi(u) -- both smooth on the sphere,
74+ % synthesized straight from the dthetac/dphic coefficient shuffles --
75+ % are combined pointwise through the precomputed weights into two
76+ % fluxes P,Q, also smooth. The theta flux P goes back to
77+ % coefficients, through the same shuffle again, and is synthesized as
78+ % sin(theta)*dtheta(P); the phi flux Q never leaves the grid -- d/dphi
79+ % is diagonal in the Fourier index, so dphig differentiates it with two
80+ % FFT stages and no Legendre work (masking m past filt's reach). Their
81+ % sum, scaled by r, is lap_g(u). The only division by sin(theta)
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).
95+ % lamJ.*Un adds back the preconditioner's -lap_s(Un)/jhat, since lam
96+ % holds +l(l+1). filt zeroes the top two degrees, where the derivative
97+ % recurrences cannot exactly represent a derivative -- and the correction
98+ % itself is projected onto the same band (algos.tex Algorithm 5 zeroes
99+ % the same coefficients): without that, each iteration replaces a bit
100+ % more of the top degrees' implicit diffusion with nothing (their fixed
101+ % point is the undiffused Bu), and the two species un-diffuse at
102+ % different rates -- a spurious Turing band at the band edge.
103+ %
104+ % The two species share each grouped call: the six gradient-and-sphere
105+ % syntheses, the two theta-flux analyses, the two divergence syntheses
106+ % and the two final analyses each run as one batched dispatch.
43107 Fu = Un .* filt;
44- Ftu = dtheta(Fu);
45- Fpu = dphi(Fu);
46- dux = Ftu .* Vtx + Fpu .* Vpx;
47- duy = Ftu .* Vty + Fpu .* Vpy;
48- duz = Ftu .* Vtz + Fpu .* Vpz;
49- cux = analys(dux) .* filt;
50- cuy = analys(duy) .* filt;
51- cuz = analys(duz) .* filt;
52- Ftcux = dtheta(cux);
53- Fpcux = dphi(cux);
54- Ftcuy = dtheta(cuy);
55- Fpcuy = dphi(cuy);
56- Ftcuz = dtheta(cuz);
57- Fpcuz = dphi(cuz);
58- lapu = Ftcux .* Vtx + Fpcux .* Vpx;
59- lapu = lapu + Ftcuy .* Vty;
60- lapu = lapu + Fpcuy .* Vpy;
61- lapu = lapu + Ftcuz .* Vtz;
62- lapu = lapu + Fpcuz .* Vpz;
63- dLu = analys(lapu) + lam .* Un;
64-
65108 Fv = Vn .* filt;
66- Ftv = dtheta(Fv);
67- Fpv = dphi(Fv);
68- dvx = Ftv .* Vtx + Fpv .* Vpx;
69- dvy = Ftv .* Vty + Fpv .* Vpy;
70- dvz = Ftv .* Vtz + Fpv .* Vpz;
71- cvx = analys(dvx) .* filt;
72- cvy = analys(dvy) .* filt;
73- cvz = analys(dvz) .* filt;
74- Ftcvx = dtheta(cvx);
75- Fpcvx = dphi(cvx);
76- Ftcvy = dtheta(cvy);
77- Fpcvy = dphi(cvy);
78- Ftcvz = dtheta(cvz);
79- Fpcvz = dphi(cvz);
80- lapv = Ftcvx .* Vtx + Fpcvx .* Vpx;
81- lapv = lapv + Ftcvy .* Vty;
82- lapv = lapv + Fpcvy .* Vpy;
83- lapv = lapv + Ftcvz .* Vtz;
84- lapv = lapv + Fpcvz .* Vpz;
85- dLv = analys(lapv) + lam .* Vn;
109+ vtu = dthetac(Fu);
110+ vpu = dphic(Fu);
111+ vtv = dthetac(Fv);
112+ vpv = dphic(Fv);
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;
118+ [PAu, PAv] = analys(Pu, Pv);
119+ Pcu = PAu .* filt;
120+ Pcv = PAv .* filt;
121+ scu = dthetac(Pcu);
122+ scv = dthetac(Pcv);
123+ [Lu, Lv] = synth(scu, scv);
124+ dQu = dphig(Qu);
125+ dQv = dphig(Qv);
126+ lapu = r .* (Lu + dQu) - jinv .* Su;
127+ lapv = r .* (Lv + dQv) - jinv .* Sv;
128+ [LAu, LAv] = analys(lapu, lapv);
129+ dLu = (LAu + lamJ .* Un) .* filt;
130+ dLv = (LAv + lamJ .* Vn) .* filt;
86131
87- Un = (Bu + (dt * D1) * dLu) ./ (1 + (dt * D1) * lam);
88- Vn = (Bv + (dt * D2) * dLv) ./ (1 + (dt * D2) * lam);
132+ Un = (Bu + (dt * D1) * dLu) ./ (1 + (dt * D1) * lamJ);
133+ Vn = (Bv + (dt * D2) * dLv) ./ (1 + (dt * D2) * lamJ);
89134 end
90135 end
models/schnakenberg_alg4.madded+102−0View file
@@ -0,0 +1,102 @@
1+% Schnakenberg reaction-diffusion on a closed surface.
2+%
3+% du/dt = D1*lap_g(u) + a - u + u^2*v
4+% dv/dt = D2*lap_g(v) + b - u^2*v
5+%
6+% Explicit reaction, implicit diffusion (IMEX Euler). The implicit solve
7+% splits lap_g = lap_s + dlap: the round-sphere part lap_s is diagonal in
8+% spherical-harmonic space (eigenvalues -lam), and the loop iterates the
9+% geometric correction dlap from that exact solve. Grid fields are npts x 1;
10+% spectral fields are real 2 x nlm. See docs/richardson-iteration.md.
11+%
12+% This is the 12-transform reference: the correction evaluates lap_g in
13+% Cartesian-gradient form (Algorithm 4 of evolving_surface/notes/algos.tex),
14+% carrying grad_g(u) as three ambient components through the inverse metric
15+% quantities Vt*/Vp*. models/schnakenberg.m computes the same operator in
16+% flux form with 6 transforms per species per iteration
17+% (docs/reduced-transforms.md); this variant is kept live
18+% for A/B comparison, in the app and in the tests.
19+
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);
23+ us = a + b;
24+ vs = b / (us * us);
25+ U = analys(us + 0.01*f);
26+ V = analys(vs * ones(numel(f), 1));
27+ u = synth(U);
28+ v = synth(V);
29+end
30+
31+function [Un, Vn, u, v] = step(U, V, lam, filt, gx, gy, gz, Vtx, Vty, Vtz, Vpx, Vpy, Vpz, jhat, a, b, D1, D2, dt, niter)
32+ u = synth(U);
33+ v = synth(V);
34+ uuv = u .* u .* v;
35+
36+ % Right-hand side of the implicit solve (I - dt*D*lap_g) Unew = B.
37+ Bu = U + dt * analys(a - u + uuv);
38+ Bv = V + dt * analys(b - uuv);
39+
40+ % Mean-J preconditioned solve (see models/schnakenberg.m), then iterate
41+ % the geometric correction.
42+ lamJ = lam ./ jhat;
43+ Un = Bu ./ (1 + (dt * D1) * lamJ);
44+ Vn = Bv ./ (1 + (dt * D2) * lamJ);
45+
46+ for k = 1:niter
47+ % dlap = lap_g - lap_s, evaluated at the current iterate (Algorithm 3 of
48+ % evolving_surface/notes/algos.tex): surface gradient of the field,
49+ % contracted through the inverse metric quantities Vt*/Vp*; each
50+ % Cartesian component re-analysed and differentiated again; recombined
51+ % into the surface divergence. lam.*Un adds back -lap_s(Un), since lam
52+ % holds +l(l+1). filt zeroes the top two degrees, where the theta/phi
53+ % derivative recurrences cannot exactly represent a derivative. See
54+ % docs/richardson-iteration.md.
55+ Fu = Un .* filt;
56+ Ftu = dtheta(Fu);
57+ Fpu = dphi(Fu);
58+ dux = Ftu .* Vtx + Fpu .* Vpx;
59+ duy = Ftu .* Vty + Fpu .* Vpy;
60+ duz = Ftu .* Vtz + Fpu .* Vpz;
61+ cux = analys(dux) .* filt;
62+ cuy = analys(duy) .* filt;
63+ cuz = analys(duz) .* filt;
64+ Ftcux = dtheta(cux);
65+ Fpcux = dphi(cux);
66+ Ftcuy = dtheta(cuy);
67+ Fpcuy = dphi(cuy);
68+ Ftcuz = dtheta(cuz);
69+ Fpcuz = dphi(cuz);
70+ lapu = Ftcux .* Vtx + Fpcux .* Vpx;
71+ lapu = lapu + Ftcuy .* Vty;
72+ lapu = lapu + Fpcuy .* Vpy;
73+ lapu = lapu + Ftcuz .* Vtz;
74+ lapu = lapu + Fpcuz .* Vpz;
75+ dLu = (analys(lapu) + lamJ .* Un) .* filt;
76+
77+ Fv = Vn .* filt;
78+ Ftv = dtheta(Fv);
79+ Fpv = dphi(Fv);
80+ dvx = Ftv .* Vtx + Fpv .* Vpx;
81+ dvy = Ftv .* Vty + Fpv .* Vpy;
82+ dvz = Ftv .* Vtz + Fpv .* Vpz;
83+ cvx = analys(dvx) .* filt;
84+ cvy = analys(dvy) .* filt;
85+ cvz = analys(dvz) .* filt;
86+ Ftcvx = dtheta(cvx);
87+ Fpcvx = dphi(cvx);
88+ Ftcvy = dtheta(cvy);
89+ Fpcvy = dphi(cvy);
90+ Ftcvz = dtheta(cvz);
91+ Fpcvz = dphi(cvz);
92+ lapv = Ftcvx .* Vtx + Fpcvx .* Vpx;
93+ lapv = lapv + Ftcvy .* Vty;
94+ lapv = lapv + Fpcvy .* Vpy;
95+ lapv = lapv + Ftcvz .* Vtz;
96+ lapv = lapv + Fpcvz .* Vpz;
97+ dLv = (analys(lapv) + lamJ .* Vn) .* filt;
98+
99+ Un = (Bu + (dt * D1) * dLu) ./ (1 + (dt * D1) * lamJ);
100+ Vn = (Bv + (dt * D2) * dLv) ./ (1 + (dt * D2) * lamJ);
101+ end
102+end
package-lock.jsonmodified+34−446View file
@@ -9,6 +9,7 @@
99 "version": "0.1.0",
1010 "license": "CECILL-2.1",
1111 "dependencies": {
12+ "h5wasm": "^0.10.3",
1213 "mp4-muxer": "^5.2.2",
1314 "numbl": "file:../../numbl",
1415 "three": "^0.183.0"
@@ -112,6 +113,31 @@
112113 "dev": true,
113114 "license": "Apache-2.0"
114115 },
116+ "node_modules/@emnapi/core": {
117+ "version": "2.0.0-alpha.3",
118+ "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-2.0.0-alpha.3.tgz",
119+ "integrity": "sha512-AZypUeJ/yByuxyS7BlSNRDOMLMlROYtjYdIAuBmJssVz1UJDSeYxLrdizhXCFYhedC5bqd/ASy8EuNXbVVXp9g==",
120+ "dev": true,
121+ "license": "MIT",
122+ "optional": true,
123+ "peer": true,
124+ "dependencies": {
125+ "@emnapi/wasi-threads": "2.0.1",
126+ "tslib": "^2.4.0"
127+ }
128+ },
129+ "node_modules/@emnapi/runtime": {
130+ "version": "2.0.0-alpha.3",
131+ "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-2.0.0-alpha.3.tgz",
132+ "integrity": "sha512-hFPAhMUjJD9BSyCANEISPOogeXC9Zo9ZQl7L6vKnaVsMkCtzznaW/naYypeyl0Gv5rYfWYsZbpixTMpjDJzQeA==",
133+ "dev": true,
134+ "license": "MIT",
135+ "optional": true,
136+ "peer": true,
137+ "dependencies": {
138+ "tslib": "^2.4.0"
139+ }
140+ },
115141 "node_modules/@emnapi/wasi-threads": {
116142 "version": "2.0.1",
117143 "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-2.0.1.tgz",
@@ -119,6 +145,7 @@
119145 "dev": true,
120146 "license": "MIT",
121147 "optional": true,
148+ "peer": true,
122149 "dependencies": {
123150 "tslib": "^2.4.0"
124151 }
@@ -412,23 +439,6 @@
412439 "node": ">=12"
413440 }
414441 },
415- "node_modules/@esbuild/netbsd-arm64": {
416- "version": "0.28.1",
417- "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.1.tgz",
418- "integrity": "sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw==",
419- "cpu": [
420- "arm64"
421- ],
422- "dev": true,
423- "license": "MIT",
424- "optional": true,
425- "os": [
426- "netbsd"
427- ],
428- "engines": {
429- "node": ">=18"
430- }
431- },
432442 "node_modules/@esbuild/netbsd-x64": {
433443 "version": "0.21.5",
434444 "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz",
@@ -446,23 +456,6 @@
446456 "node": ">=12"
447457 }
448458 },
449- "node_modules/@esbuild/openbsd-arm64": {
450- "version": "0.28.1",
451- "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.1.tgz",
452- "integrity": "sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q==",
453- "cpu": [
454- "arm64"
455- ],
456- "dev": true,
457- "license": "MIT",
458- "optional": true,
459- "os": [
460- "openbsd"
461- ],
462- "engines": {
463- "node": ">=18"
464- }
465- },
466459 "node_modules/@esbuild/openbsd-x64": {
467460 "version": "0.21.5",
468461 "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz",
@@ -480,23 +473,6 @@
480473 "node": ">=12"
481474 }
482475 },
483- "node_modules/@esbuild/openharmony-arm64": {
484- "version": "0.28.1",
485- "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.1.tgz",
486- "integrity": "sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg==",
487- "cpu": [
488- "arm64"
489- ],
490- "dev": true,
491- "license": "MIT",
492- "optional": true,
493- "os": [
494- "openharmony"
495- ],
496- "engines": {
497- "node": ">=18"
498- }
499- },
500476 "node_modules/@esbuild/sunos-x64": {
501477 "version": "0.21.5",
502478 "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz",
@@ -1701,8 +1677,7 @@
17011677 "resolved": "https://registry.npmjs.org/devtools-protocol/-/devtools-protocol-0.0.1367902.tgz",
17021678 "integrity": "sha512-XxtPuC3PGakY6PD7dG66/o8KwJ/LkH2/EKe19Dcw58w53dv4/vSQEkn/SzuyhHE2q4zPgCkxQBxus3VV4ql+Pg==",
17031679 "dev": true,
1704- "license": "BSD-3-Clause",
1705- "peer": true
1680+ "license": "BSD-3-Clause"
17061681 },
17071682 "node_modules/emoji-regex": {
17081683 "version": "8.0.0",
@@ -1962,6 +1937,12 @@
19621937 "node": ">= 14"
19631938 }
19641939 },
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+ },
19651946 "node_modules/http-proxy-agent": {
19661947 "version": "7.0.2",
19671948 "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz",
@@ -2037,7 +2018,6 @@
20372018 "integrity": "sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA==",
20382019 "dev": true,
20392020 "license": "MPL-2.0",
2040- "peer": true,
20412021 "dependencies": {
20422022 "detect-libc": "^2.0.3"
20432023 },
@@ -2453,7 +2433,6 @@
24532433 "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==",
24542434 "dev": true,
24552435 "license": "MIT",
2456- "peer": true,
24572436 "engines": {
24582437 "node": ">=12"
24592438 },
@@ -2967,397 +2946,6 @@
29672946 "url": "https://opencollective.com/antfu"
29682947 }
29692948 },
2970- "node_modules/vite-node/node_modules/@esbuild/aix-ppc64": {
2971- "version": "0.28.1",
2972- "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.1.tgz",
2973- "integrity": "sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==",
2974- "cpu": [
2975- "ppc64"
2976- ],
2977- "dev": true,
2978- "license": "MIT",
2979- "optional": true,
2980- "os": [
2981- "aix"
2982- ],
2983- "engines": {
2984- "node": ">=18"
2985- }
2986- },
2987- "node_modules/vite-node/node_modules/@esbuild/android-arm": {
2988- "version": "0.28.1",
2989- "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.1.tgz",
2990- "integrity": "sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ==",
2991- "cpu": [
2992- "arm"
2993- ],
2994- "dev": true,
2995- "license": "MIT",
2996- "optional": true,
2997- "os": [
2998- "android"
2999- ],
3000- "engines": {
3001- "node": ">=18"
3002- }
3003- },
3004- "node_modules/vite-node/node_modules/@esbuild/android-arm64": {
3005- "version": "0.28.1",
3006- "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.1.tgz",
3007- "integrity": "sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg==",
3008- "cpu": [
3009- "arm64"
3010- ],
3011- "dev": true,
3012- "license": "MIT",
3013- "optional": true,
3014- "os": [
3015- "android"
3016- ],
3017- "engines": {
3018- "node": ">=18"
3019- }
3020- },
3021- "node_modules/vite-node/node_modules/@esbuild/android-x64": {
3022- "version": "0.28.1",
3023- "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.1.tgz",
3024- "integrity": "sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng==",
3025- "cpu": [
3026- "x64"
3027- ],
3028- "dev": true,
3029- "license": "MIT",
3030- "optional": true,
3031- "os": [
3032- "android"
3033- ],
3034- "engines": {
3035- "node": ">=18"
3036- }
3037- },
3038- "node_modules/vite-node/node_modules/@esbuild/darwin-arm64": {
3039- "version": "0.28.1",
3040- "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.1.tgz",
3041- "integrity": "sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q==",
3042- "cpu": [
3043- "arm64"
3044- ],
3045- "dev": true,
3046- "license": "MIT",
3047- "optional": true,
3048- "os": [
3049- "darwin"
3050- ],
3051- "engines": {
3052- "node": ">=18"
3053- }
3054- },
3055- "node_modules/vite-node/node_modules/@esbuild/darwin-x64": {
3056- "version": "0.28.1",
3057- "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.1.tgz",
3058- "integrity": "sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ==",
3059- "cpu": [
3060- "x64"
3061- ],
3062- "dev": true,
3063- "license": "MIT",
3064- "optional": true,
3065- "os": [
3066- "darwin"
3067- ],
3068- "engines": {
3069- "node": ">=18"
3070- }
3071- },
3072- "node_modules/vite-node/node_modules/@esbuild/freebsd-arm64": {
3073- "version": "0.28.1",
3074- "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.1.tgz",
3075- "integrity": "sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw==",
3076- "cpu": [
3077- "arm64"
3078- ],
3079- "dev": true,
3080- "license": "MIT",
3081- "optional": true,
3082- "os": [
3083- "freebsd"
3084- ],
3085- "engines": {
3086- "node": ">=18"
3087- }
3088- },
3089- "node_modules/vite-node/node_modules/@esbuild/freebsd-x64": {
3090- "version": "0.28.1",
3091- "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.1.tgz",
3092- "integrity": "sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ==",
3093- "cpu": [
3094- "x64"
3095- ],
3096- "dev": true,
3097- "license": "MIT",
3098- "optional": true,
3099- "os": [
3100- "freebsd"
3101- ],
3102- "engines": {
3103- "node": ">=18"
3104- }
3105- },
3106- "node_modules/vite-node/node_modules/@esbuild/linux-arm": {
3107- "version": "0.28.1",
3108- "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.1.tgz",
3109- "integrity": "sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ==",
3110- "cpu": [
3111- "arm"
3112- ],
3113- "dev": true,
3114- "license": "MIT",
3115- "optional": true,
3116- "os": [
3117- "linux"
3118- ],
3119- "engines": {
3120- "node": ">=18"
3121- }
3122- },
3123- "node_modules/vite-node/node_modules/@esbuild/linux-arm64": {
3124- "version": "0.28.1",
3125- "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.1.tgz",
3126- "integrity": "sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g==",
3127- "cpu": [
3128- "arm64"
3129- ],
3130- "dev": true,
3131- "license": "MIT",
3132- "optional": true,
3133- "os": [
3134- "linux"
3135- ],
3136- "engines": {
3137- "node": ">=18"
3138- }
3139- },
3140- "node_modules/vite-node/node_modules/@esbuild/linux-ia32": {
3141- "version": "0.28.1",
3142- "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.1.tgz",
3143- "integrity": "sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w==",
3144- "cpu": [
3145- "ia32"
3146- ],
3147- "dev": true,
3148- "license": "MIT",
3149- "optional": true,
3150- "os": [
3151- "linux"
3152- ],
3153- "engines": {
3154- "node": ">=18"
3155- }
3156- },
3157- "node_modules/vite-node/node_modules/@esbuild/linux-loong64": {
3158- "version": "0.28.1",
3159- "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.1.tgz",
3160- "integrity": "sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg==",
3161- "cpu": [
3162- "loong64"
3163- ],
3164- "dev": true,
3165- "license": "MIT",
3166- "optional": true,
3167- "os": [
3168- "linux"
3169- ],
3170- "engines": {
3171- "node": ">=18"
3172- }
3173- },
3174- "node_modules/vite-node/node_modules/@esbuild/linux-mips64el": {
3175- "version": "0.28.1",
3176- "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.1.tgz",
3177- "integrity": "sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ==",
3178- "cpu": [
3179- "mips64el"
3180- ],
3181- "dev": true,
3182- "license": "MIT",
3183- "optional": true,
3184- "os": [
3185- "linux"
3186- ],
3187- "engines": {
3188- "node": ">=18"
3189- }
3190- },
3191- "node_modules/vite-node/node_modules/@esbuild/linux-ppc64": {
3192- "version": "0.28.1",
3193- "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.1.tgz",
3194- "integrity": "sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ==",
3195- "cpu": [
3196- "ppc64"
3197- ],
3198- "dev": true,
3199- "license": "MIT",
3200- "optional": true,
3201- "os": [
3202- "linux"
3203- ],
3204- "engines": {
3205- "node": ">=18"
3206- }
3207- },
3208- "node_modules/vite-node/node_modules/@esbuild/linux-riscv64": {
3209- "version": "0.28.1",
3210- "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.1.tgz",
3211- "integrity": "sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ==",
3212- "cpu": [
3213- "riscv64"
3214- ],
3215- "dev": true,
3216- "license": "MIT",
3217- "optional": true,
3218- "os": [
3219- "linux"
3220- ],
3221- "engines": {
3222- "node": ">=18"
3223- }
3224- },
3225- "node_modules/vite-node/node_modules/@esbuild/linux-s390x": {
3226- "version": "0.28.1",
3227- "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.1.tgz",
3228- "integrity": "sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag==",
3229- "cpu": [
3230- "s390x"
3231- ],
3232- "dev": true,
3233- "license": "MIT",
3234- "optional": true,
3235- "os": [
3236- "linux"
3237- ],
3238- "engines": {
3239- "node": ">=18"
3240- }
3241- },
3242- "node_modules/vite-node/node_modules/@esbuild/linux-x64": {
3243- "version": "0.28.1",
3244- "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.1.tgz",
3245- "integrity": "sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA==",
3246- "cpu": [
3247- "x64"
3248- ],
3249- "dev": true,
3250- "license": "MIT",
3251- "optional": true,
3252- "os": [
3253- "linux"
3254- ],
3255- "engines": {
3256- "node": ">=18"
3257- }
3258- },
3259- "node_modules/vite-node/node_modules/@esbuild/netbsd-x64": {
3260- "version": "0.28.1",
3261- "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.1.tgz",
3262- "integrity": "sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg==",
3263- "cpu": [
3264- "x64"
3265- ],
3266- "dev": true,
3267- "license": "MIT",
3268- "optional": true,
3269- "os": [
3270- "netbsd"
3271- ],
3272- "engines": {
3273- "node": ">=18"
3274- }
3275- },
3276- "node_modules/vite-node/node_modules/@esbuild/openbsd-x64": {
3277- "version": "0.28.1",
3278- "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.1.tgz",
3279- "integrity": "sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw==",
3280- "cpu": [
3281- "x64"
3282- ],
3283- "dev": true,
3284- "license": "MIT",
3285- "optional": true,
3286- "os": [
3287- "openbsd"
3288- ],
3289- "engines": {
3290- "node": ">=18"
3291- }
3292- },
3293- "node_modules/vite-node/node_modules/@esbuild/sunos-x64": {
3294- "version": "0.28.1",
3295- "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.1.tgz",
3296- "integrity": "sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ==",
3297- "cpu": [
3298- "x64"
3299- ],
3300- "dev": true,
3301- "license": "MIT",
3302- "optional": true,
3303- "os": [
3304- "sunos"
3305- ],
3306- "engines": {
3307- "node": ">=18"
3308- }
3309- },
3310- "node_modules/vite-node/node_modules/@esbuild/win32-arm64": {
3311- "version": "0.28.1",
3312- "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.1.tgz",
3313- "integrity": "sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA==",
3314- "cpu": [
3315- "arm64"
3316- ],
3317- "dev": true,
3318- "license": "MIT",
3319- "optional": true,
3320- "os": [
3321- "win32"
3322- ],
3323- "engines": {
3324- "node": ">=18"
3325- }
3326- },
3327- "node_modules/vite-node/node_modules/@esbuild/win32-ia32": {
3328- "version": "0.28.1",
3329- "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.1.tgz",
3330- "integrity": "sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg==",
3331- "cpu": [
3332- "ia32"
3333- ],
3334- "dev": true,
3335- "license": "MIT",
3336- "optional": true,
3337- "os": [
3338- "win32"
3339- ],
3340- "engines": {
3341- "node": ">=18"
3342- }
3343- },
3344- "node_modules/vite-node/node_modules/@esbuild/win32-x64": {
3345- "version": "0.28.1",
3346- "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.1.tgz",
3347- "integrity": "sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A==",
3348- "cpu": [
3349- "x64"
3350- ],
3351- "dev": true,
3352- "license": "MIT",
3353- "optional": true,
3354- "os": [
3355- "win32"
3356- ],
3357- "engines": {
3358- "node": ">=18"
3359- }
3360- },
33612949 "node_modules/vite-node/node_modules/vite": {
33622950 "version": "8.1.5",
33632951 "resolved": "https://registry.npmjs.org/vite/-/vite-8.1.5.tgz",
package.jsonmodified+3−1View file
@@ -14,9 +14,11 @@
1414 "test:gpu": "vite build && node scripts/test-gpu.mjs",
1515 "test": "npm run test:node && npm run test:gpu",
1616 "bench": "vite-node scripts/bench.ts",
17- "bench:sht": "vite-node scripts/bench-sht.ts"
17+ "bench:sht": "vite-node scripts/bench-sht.ts",
18+ "ref": "vite-node scripts/ref.ts"
1819 },
1920 "dependencies": {
21+ "h5wasm": "^0.10.3",
2022 "mp4-muxer": "^5.2.2",
2123 "numbl": "file:../../numbl",
2224 "three": "^0.183.0"
scripts/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/sht-accuracy.tsadded+83−0View file
@@ -0,0 +1,83 @@
1+/**
2+ * The fp32 transform round-trip floor, swept in lmax — the measurement
3+ * docs/reduced-transforms.md Sec 5b prescribes before and
4+ * after any change to summation order in the Legendre kernels.
5+ *
6+ * npx vite-node scripts/sht-accuracy.ts [--lmax 63,127,255] [--seed 42]
7+ *
8+ * For a band-limited spectrum q, analys(synth(q)) = q exactly (Gauss
9+ * quadrature is exact for the band), so the relative round-trip error is the
10+ * transforms' own fp32 round-off with no reference implementation in the
11+ * loop. Sequential accumulation over l in the synthesis shows this floor
12+ * growing roughly linearly in lmax; pairwise/compensated accumulation shows
13+ * it near-flat. The per-degree profile says *where* the error lives (the
14+ * high-l coefficients are the ones the alpha shifts and the l(l+1)
15+ * eigenvalues amplify).
16+ */
17+import { ShtPlan, requestShtDevice, describeAdapter } from '../src/sht/sht.ts';
18+import { gridForLmax, lmIndex, nlmCalc } from '../src/sht/layout.ts';
19+import { randomSpectrum } from '../src/sht/reference.ts';
20+import { installWebGpu, errMsg, NO_ADAPTER_HINT } from './nodeWebGpu.ts';
21+
22+const arg = (name: string): string | undefined => {
23+ const i = process.argv.indexOf(`--${name}`);
24+ return i >= 0 ? process.argv[i + 1] : undefined;
25+};
26+const LMAXES = (arg('lmax') ?? '63,127,255').split(',').map(Number);
27+const SEED = Number(arg('seed') ?? 42);
28+
29+let runtime: string;
30+try {
31+ runtime = await installWebGpu();
32+} catch (e) {
33+ console.error(`sht-accuracy: ${errMsg(e)}\n${NO_ADAPTER_HINT}`);
34+ process.exit(1);
35+}
36+const device = await requestShtDevice();
37+console.log(`sht-accuracy — ${runtime}, ${await describeAdapter(device)}\n`);
38+console.log(' lmax grid rel L2 roundtrip worst degree (rel)');
39+
40+for (const lmax of LMAXES) {
41+ const { nlat, nphi } = gridForLmax(lmax, 1);
42+ const cfg = { lmax, mmax: lmax, nlat, nphi };
43+ const plan = await ShtPlan.create(device, cfg);
44+ const q = randomSpectrum(cfg, SEED);
45+
46+ const grid = await plan.synth(q);
47+ const back = await plan.analys(grid);
48+
49+ // Overall relative L2, and the same per degree — errors concentrate in l.
50+ let num = 0;
51+ let den = 0;
52+ const nlm = nlmCalc(lmax, lmax);
53+ const errL = new Float64Array(lmax + 1);
54+ const magL = new Float64Array(lmax + 1);
55+ for (let m = 0; m <= lmax; m++) {
56+ for (let l = m; l <= lmax; l++) {
57+ const i = lmIndex(lmax, l, m);
58+ const dr = back[2 * i] - q[2 * i];
59+ const di = back[2 * i + 1] - q[2 * i + 1];
60+ const d2 = dr * dr + di * di;
61+ const m2 = q[2 * i] ** 2 + q[2 * i + 1] ** 2;
62+ num += d2;
63+ den += m2;
64+ errL[l] += d2;
65+ magL[l] += m2;
66+ }
67+ }
68+ let worstL = 0;
69+ let worstRel = 0;
70+ for (let l = 0; l <= lmax; l++) {
71+ const rel = Math.sqrt(errL[l] / Math.max(magL[l], 1e-300));
72+ if (rel > worstRel) {
73+ worstRel = rel;
74+ worstL = l;
75+ }
76+ }
77+ console.log(
78+ ` ${String(lmax).padEnd(6)} ${`${nlat}x${nphi}`.padEnd(11)} ` +
79+ `${Math.sqrt(num / den).toExponential(3).padEnd(18)} ` +
80+ `l=${worstL}: ${worstRel.toExponential(3)} (${nlm} coefficients)`,
81+ );
82+ plan.destroy();
83+}
scripts/test-gpu.mjsmodified+22−3View file
@@ -3,7 +3,13 @@
33 * Chrome (falling back to the SwiftShader software WebGPU adapter when no
44 * hardware GPU is available), and reports the suite results.
55 *
6- * Run after `vite build`: node scripts/test-gpu.mjs
6+ * Run after `vite build`: node scripts/test-gpu.mjs [--sweep]
7+ *
8+ * --sweep adds the niter x geometry sweep, which the page leaves out by
9+ * default because it is far too slow here to belong in CI: every session it
10+ * builds recompiles its whole unrolled step (445 kernels at niter 8), and
11+ * software WebGPU compiles those at about a second each. See
12+ * test/geometryChecks.ts.
713 */
814 import { createServer } from 'node:http';
915 import { readFile } from 'node:fs/promises';
@@ -40,14 +46,27 @@ const flagSets = [
4046 ['--headless=new', '--no-sandbox', '--enable-unsafe-webgpu', '--use-webgpu-adapter=swiftshader', '--enable-unsafe-swiftshader'],
4147 ];
4248
49+const query = process.argv.includes('--sweep') ? '?sweep=1' : '';
50+
4351 let final = null;
4452 for (const flags of flagSets) {
45- const browser = await puppeteer.launch({ executablePath: CHROME, args: flags });
53+ const browser = await puppeteer.launch({
54+ executablePath: CHROME,
55+ // A copy: puppeteer splices --enable-features out of the array it is given
56+ // and re-adds it merged with its own, which would drop it from the
57+ // diagnostic below and make a failure look like it ran with fewer flags.
58+ args: [...flags],
59+ // Puppeteer's default is 180 s, and it bounds the CDP call that
60+ // waitForFunction polls inside — so without this the wait below silently
61+ // caps at 3 minutes no matter what timeout it is given, and a suite that
62+ // runs longer fails as 'Runtime.callFunctionOn timed out' with no results.
63+ protocolTimeout: 900_000,
64+ });
4665 try {
4766 const page = await browser.newPage();
4867 page.on('console', (msg) => console.log(` [page] ${msg.text()}`));
4968 page.on('pageerror', (err) => console.log(` [pageerror] ${err.message}`));
50- await page.goto(`http://127.0.0.1:${port}/test.html`, { waitUntil: 'load' });
69+ await page.goto(`http://127.0.0.1:${port}/test.html${query}`, { waitUntil: 'load' });
5170 const results = await page.waitForFunction(() => window.__RESULTS__, { timeout: 600_000 });
5271 final = await results.jsonValue();
5372 } catch (e) {
scripts/test-node.tsmodified+16−0View file
@@ -8,12 +8,19 @@
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';
1417 import { analyticChecks } from '../test/analyticChecks.ts';
1518 import { modelChecks } from '../test/modelChecks.ts';
1619 import { geometryChecks } from '../test/geometryChecks.ts';
20+import { fluxChecks } from '../test/fluxChecks.ts';
21+import { compareChecks } from '../test/compareChecks.ts';
22+import { referenceChecks, type H5Rt } from '../test/referenceChecks.ts';
23+import { matlabExportChecks } from '../test/matlabExportChecks.ts';
1724
1825 let failures = 0;
1926 const check = (name: string, ok: boolean, detail: string): void => {
@@ -51,6 +58,15 @@ await transformChecks(device, check, log);
5158 await analyticChecks(device, check, log);
5259 await modelChecks(device, check, log);
5360 await geometryChecks(device, check, log);
61+await fluxChecks(device, check, log);
62+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+);
5470
5571 console.log(failures === 0 ? '\nAll tests passed.' : `\n${failures} failed.`);
5672 process.exit(failures === 0 ? 0 : 1);
src/bench/runSpec.tsmodified+3−1View file
@@ -46,7 +46,9 @@ export interface RunSpec {
4646 niter: number;
4747 }
4848
49-export const DEFAULT_NITER = 1;
49+/** Iterations of the implicit solve, everywhere that does not say otherwise:
50+ * the app's `solve iters` control, `npm run bench`, and the soak. */
51+export const DEFAULT_NITER = 8;
5052
5153 /** Geometry + starting parameters of a geometry key. */
5254 export function resolveGeometry(key: string): { geometry: MGeometry; params: Params } {
src/compare/compareRun.tsadded+1019−0View file
@@ -0,0 +1,1019 @@
1+/**
2+ * Several solver settings, one problem, one clock.
3+ *
4+ * A convergence study of the knobs that decide how well the implicit solve is
5+ * resolved — `niter`, `lmax`, `dt` — run side by side so the answer to "does it
6+ * matter?" is visible rather than argued. Every variant is its own
7+ * `ModelSession` (both `niter` and `lmax` are structural: they change the
8+ * compiled step and the grid), and what makes the set a comparison rather than
9+ * a collection is three things they are forced to share:
10+ *
11+ * - **One initial condition.** Band-limited at the coarsest variant's lmax and
12+ * evaluated on each variant's own grid, so every session starts from the same
13+ * *function* rather than from the same random seed — see sharedStart.ts for
14+ * why the seed alone is not enough.
15+ *
16+ * - **One clock.** Variants differ in dt only by an integer power-of-two
17+ * divisor, and a frame advances each of them by `frameSteps * dtDiv` steps.
18+ * Every variant therefore lands on exactly the same model time at the end of
19+ * every frame, having taken a different number of steps to get there. Nothing
20+ * is ever compared across a time offset.
21+ *
22+ * - **One grid to look at.** Each session's *display* plan is pointed at a
23+ * common grid (ModelSession.setDisplayGrid), which is exact evaluation rather
24+ * than resampling because the state is band-limited. So the fields come back
25+ * directly comparable point by point, one mesh topology serves every panel,
26+ * and the difference norm is an ordinary weighted sum.
27+ *
28+ * What is *not* shared is the surface: each variant carries the geometry
29+ * band-limited at its own lmax, and renders the surface it actually solves on.
30+ */
31+import { ModelSession } from '../mgpu/session.ts';
32+import type { MModel, Params } from '../mgpu/registry.ts';
33+import type { MGeometry } from '../geom/registry.ts';
34+import {
35+ buildTopology,
36+ fillFieldValues,
37+ fillPositions,
38+ fillColors,
39+ type SphereMeshTopology,
40+} from '../render/sphereMesh.ts';
41+import { SphereScene } from '../render/SphereScene.ts';
42+import { colormaps } from '../render/colormaps.ts';
43+import { fmtValue, floorRange } from '../render/colorbar.ts';
44+import { prolongCoeffs, sharedModes, sharedNoise } from './sharedStart.ts';
45+import { variantLabel, VARIANT_COLORS, type Variant } from './variants.ts';
46+import type { ReferenceCase } from './referenceCase.ts';
47+
48+/**
49+ * Latitudes of the shared display grid. 256 is the same target the single-run
50+ * view uses for 'auto' oversampling, and for the same reason — beyond it a
51+ * finer mesh costs vertices without showing anything.
52+ *
53+ * Here it is a ceiling as well as a target, in two directions. At lmax 255 the
54+ * solver grid is finer than this, so the panels sample the (exact) state more
55+ * coarsely than the solver carries it; and past a handful of panels the mesh is
56+ * paid for once per panel, in vertices, normals and a WebGL context each, so it
57+ * halves. Both are display choices, both are reported in the status line, and
58+ * neither touches the difference norm's meaning: that is computed on this same
59+ * grid for every variant, so it stays a consistent comparison whatever the grid.
60+ */
61+const RENDER_NLAT = 256;
62+const RENDER_NLAT_CROWDED = 128;
63+const CROWDED_PANELS = 6;
64+
65+/** See main.ts's DISPATCH_BUDGET — the same watchdog argument, per variant. */
66+const DISPATCH_BUDGET = 1000;
67+const STEPS_PER_FRAME_BASE = 4;
68+
69+export interface CompareOptions {
70+ device: GPUDevice;
71+ model: MModel;
72+ /** The model's parameters, with `dt` read as the *base* timestep that each
73+ * variant's dtDiv divides. */
74+ params: Params;
75+ source: string;
76+ geometry: MGeometry;
77+ geometryParams: Params;
78+ geometrySource: string;
79+ variants: Variant[];
80+ /** Index into `variants` of the run everything else is measured against.
81+ * Ignored when `refFile` is given — the file is the reference then. */
82+ reference: number;
83+ /**
84+ * Check against a reference file instead of against each other: its exact
85+ * initial state seeds every variant (so `seed` and `lam3` go unused), a
86+ * static extra row shows its final state, every Δ is measured against that
87+ * row, and the clock stops at the file's end time. Every variant's lmax must
88+ * be >= the file's — a narrower band could not hold the initial state.
89+ */
90+ refFile?: ReferenceCase;
91+ /** Called when a refFile run reaches the file's end time and stops. */
92+ onFinished?: () => void;
93+ seed: number;
94+ /** Wavelength of the seeded random field, shared by every variant — one
95+ * initial condition means one wavelength as much as one seed. */
96+ lam3?: number;
97+ morph: number;
98+ colormapName: () => string;
99+ /** Where the variant grid goes (the app's #panels). */
100+ container: HTMLElement;
101+ /** Progress and, afterwards, the standing description of the study. */
102+ onStatus: (html: string) => void;
103+}
104+
105+interface Row {
106+ variant: Variant;
107+ session: ModelSession;
108+ color: string;
109+ /** Surface coordinates on the shared render grid — this variant's own. */
110+ coords: Float32Array;
111+ posBuf: Float32Array;
112+ scenes: SphereScene[];
113+ valueBufs: Float32Array[];
114+ colorBufs: Float32Array[];
115+ /** Fields read this frame, one per species, on the shared grid. */
116+ fields: Float32Array[];
117+ /** Relative difference from the reference, one per species. */
118+ err: number[];
119+ /** False once any species has left the floating-point numbers — the shape a
120+ * variant outside the convergence radius eventually fails in. Such a row is
121+ * never used to scale a column, and its label says so. */
122+ healthy: boolean;
123+ statEl: HTMLElement;
124+}
125+
126+/**
127+ * The reference file's final state, as one more row of panels — with no
128+ * session behind it: its surface and fields are the file's coefficients
129+ * synthesized once on the shared display grid, fixed for the whole run. Only
130+ * its coloring changes, with the shared range.
131+ */
132+interface FileRow {
133+ coords: Float32Array;
134+ posBuf: Float32Array;
135+ scenes: SphereScene[];
136+ valueBufs: Float32Array[];
137+ colorBufs: Float32Array[];
138+ /** The file's final state on the shared grid, one per species. */
139+ fields: Float32Array[];
140+ /** Its extent, precomputed — a candidate for the shared color range. */
141+ bounds: (Bounds | null)[];
142+}
143+
144+export class CompareRun {
145+ #opts: CompareOptions;
146+ #rows: Row[] = [];
147+ #fileRow: FileRow | null = null;
148+ /** What restart() reloads: the file's fixed state if opts.refFile is set,
149+ * otherwise a snapshot of the coarsest variant's state as of the last
150+ * (re-)seed — see the capture in create() and in reseed()'s plain branch. */
151+ #initial: Record<string, Float32Array>;
152+ #initialLmax: number;
153+ /** Base steps taken since the initial state — the refFile clock. */
154+ #stepsDone = 0;
155+ /** True once a refFile run has reached the file's end time. */
156+ #finished = false;
157+ #topo: SphereMeshTopology;
158+ /** Quadrature weight per grid point of the shared grid, for the L2 norm. */
159+ #weights: Float64Array;
160+ #rangeBars: { fill: (lo: number, hi: number) => void }[] = [];
161+ /** Smoothed color range per species, shared by every variant so the panels
162+ * in a column are directly comparable by eye and not just by number. */
163+ #ranges: { lo: number; hi: number }[] = [];
164+ #resizeObs: ResizeObserver | null = null;
165+
166+ #running = false;
167+ #pumping = false;
168+ #disposed = false;
169+ #morph: number;
170+ /** Base steps per frame; variant i takes this times its dtDiv. */
171+ #frameSteps = STEPS_PER_FRAME_BASE;
172+ /** Model time all variants are at — one number, by construction. */
173+ #t = 0;
174+ #frameMs = 0;
175+ #note: string;
176+
177+ private constructor(init: {
178+ opts: CompareOptions;
179+ rows: Row[];
180+ fileRow: FileRow | null;
181+ topo: SphereMeshTopology;
182+ weights: Float64Array;
183+ rangeBars: { fill: (lo: number, hi: number) => void }[];
184+ frameSteps: number;
185+ note: string;
186+ initial: Record<string, Float32Array>;
187+ initialLmax: number;
188+ }) {
189+ this.#opts = init.opts;
190+ this.#rows = init.rows;
191+ this.#fileRow = init.fileRow;
192+ this.#topo = init.topo;
193+ this.#weights = init.weights;
194+ this.#rangeBars = init.rangeBars;
195+ this.#frameSteps = init.frameSteps;
196+ this.#note = init.note;
197+ this.#morph = init.opts.morph;
198+ this.#ranges = init.opts.model.species.map(() => ({ lo: NaN, hi: NaN }));
199+ this.#initial = init.initial;
200+ this.#initialLmax = init.initialLmax;
201+ }
202+
203+ get variants(): Variant[] {
204+ return this.#rows.map((r) => r.variant);
205+ }
206+
207+ /** The variant everything else is measured against — the one whose numbers
208+ * stand on their own, so the one the app quotes when it has to quote one. */
209+ get referenceSession(): ModelSession | null {
210+ return this.#rows[this.#opts.reference]?.session ?? null;
211+ }
212+
213+ get referenceIndex(): number {
214+ return this.#opts.reference;
215+ }
216+
217+ /** The reference file this study is checking against, if any. */
218+ get refFile(): ReferenceCase | null {
219+ return this.#opts.refFile ?? null;
220+ }
221+
222+ /** The base timestep a variant's dtDiv divides. */
223+ static baseDt(params: Params): number {
224+ return params.dt ?? 0;
225+ }
226+
227+ static async create(opts: CompareOptions): Promise<CompareRun> {
228+ const { device, model, variants } = opts;
229+ const baseDt = CompareRun.baseDt(opts.params);
230+ const showDt = variants.some((v) => v.dtDiv !== variants[0].dtDiv);
231+ const sessions: ModelSession[] = [];
232+ // Scenes own a WebGL context and an animation frame each, so a failure
233+ // after the grid is up has to take them down explicitly — removing their
234+ // canvases from the DOM would leave both running.
235+ let built: Row[] = [];
236+ let builtFile: FileRow | null = null;
237+
238+ try {
239+ for (let i = 0; i < variants.length; i++) {
240+ const v = variants[i];
241+ opts.onStatus(
242+ `compiling ${i + 1}/${variants.length} — ${variantLabel(v, showDt)} ` +
243+ `(a solve iteration is ~15 kernels per species, and there is no ` +
244+ `pipeline cache across sessions)`,
245+ );
246+ // Yield, so the status actually paints before the compile blocks.
247+ await new Promise<number>(requestAnimationFrame);
248+ sessions.push(
249+ await ModelSession.create({
250+ device,
251+ model,
252+ params: { ...opts.params, dt: baseDt / v.dtDiv },
253+ lmax: v.lmax,
254+ source: opts.source,
255+ geometry: opts.geometry,
256+ geometryParams: opts.geometryParams,
257+ geometrySource: opts.geometrySource,
258+ niter: v.niter,
259+ lam3: opts.lam3,
260+ }),
261+ );
262+ }
263+
264+ // ---- the shared display grid ----------------------------------------
265+ const maxLmax = Math.max(...variants.map((v) => v.lmax));
266+ const panels = (variants.length + (opts.refFile ? 1 : 0)) * model.species.length;
267+ const target = panels > CROWDED_PANELS ? RENDER_NLAT_CROWDED : RENDER_NLAT;
268+ // Never below what the finest band needs to be representable at all
269+ // (ShtPlan requires nlat > lmax), whatever the panel count says.
270+ const nlat = Math.max(target, 2 * Math.ceil((maxLmax + 2) / 2));
271+ let nphi = 1;
272+ while (nphi < Math.max(2 * nlat, 2 * maxLmax + 1)) nphi *= 2;
273+ for (const s of sessions) await s.setDisplayGrid(nlat, nphi);
274+
275+ // ---- one initial condition, on every grid ---------------------------
276+ // Also what restart() reloads later — the file's fixed state, or (for
277+ // the plain case) a snapshot of the coarsest variant's own state,
278+ // taken after seeding it: the same lowest-lmax session sharedNoise
279+ // itself draws from, so prolonging it up to any other variant later is
280+ // always widening a band, never narrowing one.
281+ let initial: Record<string, Float32Array>;
282+ let initialLmax: number;
283+ if (opts.refFile) {
284+ // The file's exact spectral state, prolonged into each variant's band.
285+ // Exact, not approximate: the state is band-limited at the file's lmax
286+ // and every variant's band contains it, so each session starts from
287+ // the very field the reference run started from.
288+ opts.onStatus('loading the initial state from the reference file…');
289+ for (const s of sessions) {
290+ s.loadState(prolongState(opts.refFile.initial, model.state, opts.refFile.lmax, s.cfg.lmax));
291+ }
292+ initial = opts.refFile.initial;
293+ initialLmax = opts.refFile.lmax;
294+ } else {
295+ opts.onStatus('seeding all variants from one band-limited perturbation…');
296+ const noise = await sharedNoise(sessions, model.seedAmp, opts.seed);
297+ const modes = await sharedModes(sessions[opts.reference] ?? sessions[0], opts.seed);
298+ // One at a time: a seed submits its whole mode sum in pieces, and there
299+ // is nothing to gain from interleaving several variants' worth of it.
300+ for (let i = 0; i < sessions.length; i++) await sessions[i].seedWith(noise[i], modes);
301+ let coarsest = sessions[0];
302+ for (const s of sessions) if (s.cfg.lmax < coarsest.cfg.lmax) coarsest = s;
303+ initial = await coarsest.readState();
304+ initialLmax = coarsest.cfg.lmax;
305+ }
306+
307+ // ---- the mesh, shared; the surface, per variant ---------------------
308+ const view = sessions[0].viewSht;
309+ const phi = new Float64Array(nphi);
310+ for (let j = 0; j < nphi; j++) phi[j] = (2 * Math.PI * j) / nphi;
311+ const topo = buildTopology(view.cosTheta, phi);
312+ // Gauss weights carry the sin(theta) of the area element; the constant
313+ // 2*pi/nphi is common to every point and cancels in the relative norm.
314+ const weights = new Float64Array(nlat * nphi);
315+ for (let i = 0; i < nlat; i++) {
316+ for (let j = 0; j < nphi; j++) weights[i * nphi + j] = view.gaussWeights[i];
317+ }
318+
319+ // ---- how many steps a frame may submit ------------------------------
320+ // Per variant: its own unrolled step size times its dtDiv, since a ÷K
321+ // variant takes K times as many steps to reach the same time.
322+ let frameSteps = STEPS_PER_FRAME_BASE;
323+ const ops: number[] = [];
324+ for (let i = 0; i < sessions.length; i++) {
325+ const n = Math.max(1, sessions[i].describe().step.length);
326+ ops.push(n);
327+ frameSteps = Math.min(
328+ frameSteps,
329+ Math.max(1, Math.floor(DISPATCH_BUDGET / (n * variants[i].dtDiv))),
330+ );
331+ }
332+ frameSteps = Math.max(1, frameSteps);
333+
334+ // ---- the grid of panels ---------------------------------------------
335+ const { rows, fileRow, rangeBars } = await buildGrid(opts, sessions, topo, showDt);
336+ built = rows;
337+ builtFile = fileRow;
338+
339+ const solverGrid = sessions.map((s) => `${s.cfg.nlat}×${s.cfg.nphi}`);
340+ const note =
341+ `${variants.length} variant${variants.length === 1 ? '' : 's'} · ` +
342+ `display grid ${nlat}×${nphi}` +
343+ (sessions.some((s) => s.cfg.nlat > nlat)
344+ ? ` (below the finest solver grid ${solverGrid[solverGrid.length - 1]} — display only)`
345+ : '') +
346+ ` · ${frameSteps} base step${frameSteps === 1 ? '' : 's'}/frame` +
347+ ` · ops/step ${ops.join(', ')}`;
348+
349+ const run = new CompareRun({
350+ opts, rows, fileRow, topo, weights, rangeBars, frameSteps, note, initial, initialLmax,
351+ });
352+ await run.draw();
353+ run.#observeResize();
354+ run.#status();
355+ return run;
356+ } catch (e) {
357+ for (const r of built) for (const s of r.scenes) s.dispose();
358+ for (const s of builtFile?.scenes ?? []) s.dispose();
359+ for (const s of sessions) s.destroy();
360+ opts.container.replaceChildren();
361+ opts.container.classList.remove('compare');
362+ throw e;
363+ }
364+ }
365+
366+ // ------------------------------------------------------------------ state
367+ setRunning(next: boolean): void {
368+ this.#running = next;
369+ if (next) void this.#pump();
370+ }
371+
372+ get running(): boolean {
373+ return this.#running;
374+ }
375+
376+ /** Re-seed every variant from one new shared perturbation — or, against a
377+ * reference file, restart from its initial state (there is nothing to
378+ * draw; the seed is ignored). */
379+ async reseed(seed: number): Promise<void> {
380+ const wasRunning = this.#running;
381+ this.#running = false;
382+ while (this.#pumping) await nextFrame();
383+ if (this.#disposed) return;
384+ const sessions = this.#rows.map((r) => r.session);
385+ const refFile = this.#opts.refFile;
386+ if (refFile) {
387+ for (const s of sessions) {
388+ s.loadState(prolongState(refFile.initial, this.#opts.model.state, refFile.lmax, s.cfg.lmax));
389+ }
390+ } else {
391+ const noise = await sharedNoise(sessions, this.#opts.model.seedAmp, seed);
392+ const modes = await sharedModes(this.referenceSession ?? sessions[0], seed);
393+ // Checked per variant, not once: a seed awaits its own submission, so a
394+ // dispose can land between two of them and destroy the sessions left.
395+ for (let i = 0; i < sessions.length; i++) {
396+ if (this.#disposed) return;
397+ await sessions[i].seedWith(noise[i], modes);
398+ }
399+ if (this.#disposed) return;
400+ // This draw becomes what restart() rewinds to from now on — see the
401+ // identical selection in create(). Recaptured here rather than left
402+ // pointing at the pre-reseed field.
403+ let coarsest = sessions[0];
404+ for (const s of sessions) if (s.cfg.lmax < coarsest.cfg.lmax) coarsest = s;
405+ this.#initial = await coarsest.readState();
406+ this.#initialLmax = coarsest.cfg.lmax;
407+ }
408+ this.#t = 0;
409+ this.#stepsDone = 0;
410+ this.#finished = false;
411+ for (const r of this.#ranges) {
412+ r.lo = NaN;
413+ r.hi = NaN;
414+ }
415+ await this.draw();
416+ this.#status();
417+ if (!this.#disposed && wasRunning) this.setRunning(true);
418+ }
419+
420+ /** Rewind every variant to the saved initial condition — the file's fixed
421+ * state, or (for the plain case) the last (re-)seed, not necessarily the
422+ * very first one — without drawing anything new. */
423+ async restart(): Promise<void> {
424+ const wasRunning = this.#running;
425+ this.#running = false;
426+ while (this.#pumping) await nextFrame();
427+ if (this.#disposed) return;
428+ for (const r of this.#rows) {
429+ r.session.loadState(
430+ prolongState(this.#initial, this.#opts.model.state, this.#initialLmax, r.session.cfg.lmax),
431+ );
432+ }
433+ this.#t = 0;
434+ this.#stepsDone = 0;
435+ this.#finished = false;
436+ for (const r of this.#ranges) {
437+ r.lo = NaN;
438+ r.hi = NaN;
439+ }
440+ await this.draw();
441+ this.#status();
442+ if (!this.#disposed && wasRunning) this.setRunning(true);
443+ }
444+
445+ /** Wavelength of the seeded random field. One number for the study: every
446+ * variant seeds from the same field, so they seed at the same wavelength. */
447+ get lam3(): number {
448+ return this.#rows[0]?.session.lam3 ?? 0;
449+ }
450+
451+ /** Change it on every variant. Like the single run's, this only takes effect
452+ * on the next reseed, which is where the field is drawn. */
453+ setLam3(lambda: number): void {
454+ this.#opts.lam3 = lambda;
455+ for (const r of this.#rows) r.session.setLam3(lambda);
456+ }
457+
458+ /** Model parameters changed. Each variant keeps its own dt. */
459+ setParams(params: Params): void {
460+ // Against a reference file the parameters *are* the file's — they define
461+ // the problem being checked — and the page's parameter panel edits the
462+ // page's own model, which need not even be this one. Nothing to apply.
463+ if (this.#opts.refFile) return;
464+ this.#opts.params = params;
465+ const baseDt = CompareRun.baseDt(params);
466+ for (const r of this.#rows) {
467+ r.session.setParams({ ...params, dt: baseDt / r.variant.dtDiv });
468+ }
469+ }
470+
471+ setMorph(morph: number): void {
472+ this.#morph = morph;
473+ for (const r of this.#rows) {
474+ fillPositions(r.posBuf, r.coords, this.#topo, morph);
475+ for (const s of r.scenes) s.updatePositions(r.posBuf);
476+ }
477+ const f = this.#fileRow;
478+ if (f) {
479+ fillPositions(f.posBuf, f.coords, this.#topo, morph);
480+ for (const s of f.scenes) s.updatePositions(f.posBuf);
481+ }
482+ }
483+
484+ resetView(): void {
485+ for (const s of this.#allScenes()) s.resetCamera();
486+ }
487+
488+ dispose(): void {
489+ this.#disposed = true;
490+ this.#running = false;
491+ this.#resizeObs?.disconnect();
492+ this.#resizeObs = null;
493+ for (const r of this.#rows) {
494+ for (const s of r.scenes) s.dispose();
495+ r.session.destroy();
496+ }
497+ for (const s of this.#fileRow?.scenes ?? []) s.dispose();
498+ this.#rows = [];
499+ this.#fileRow = null;
500+ this.#opts.container.replaceChildren();
501+ this.#opts.container.classList.remove('compare');
502+ }
503+
504+ #allScenes(): SphereScene[] {
505+ return [...this.#rows.flatMap((r) => r.scenes), ...(this.#fileRow?.scenes ?? [])];
506+ }
507+
508+ // ----------------------------------------------------------------- drawing
509+ /**
510+ * One frame's readback: every variant's every species, on the shared grid.
511+ * Read first, then color — the range is shared down a column, so no panel can
512+ * be filled until the column's range is known.
513+ */
514+ async draw(): Promise<void> {
515+ if (this.#disposed) return;
516+ const species = this.#opts.model.species;
517+ // Sessions are independent, so their readbacks can be in flight together;
518+ // within one session they must not be (they share its staging buffers).
519+ await Promise.all(
520+ this.#rows.map(async (r) => {
521+ for (let k = 0; k < species.length; k++) {
522+ r.fields[k] = await r.session.readSpecies(k);
523+ }
524+ }),
525+ );
526+ if (this.#disposed) return;
527+
528+ const cmap = colormaps[this.#opts.colormapName()] ?? colormaps.viridis;
529+
530+ /**
531+ * What scales a column is the whole question, and it has three wrong
532+ * answers.
533+ *
534+ * Per panel is wrong: a range each rescales every variant to itself and
535+ * hides exactly the difference the grid exists to show. The union over
536+ * variants is wrong for the opposite reason: a variant outside the
537+ * iteration's convergence radius runs away to 1e20 and then to NaN, and a
538+ * union range rescales the *whole column* to it, flattening every panel to
539+ * one colour — which reads as "they all blew up" when only one did.
540+ *
541+ * The reference alone is wrong too, less obviously, and it is the case that
542+ * actually bites: outside the convergence radius *more* Richardson
543+ * iterations diverge *faster*, so the row that goes first is usually the
544+ * highest-niter one — which is the reference.
545+ *
546+ * So the column is scaled by whichever variant **reaches least far from
547+ * zero** — the least-blown-up one. That is a comparison between the rows,
548+ * not a threshold on any of them, and the distinction is the whole point:
549+ * any "is this value too big?" test has a window in which a diverging field
550+ * is still under the limit, and for as long as that window lasts it drags
551+ * the scale and flattens the grid, until it finally trips and everything
552+ * springs back. A comparison has no such window — a run-away only has to be
553+ * *larger* than a healthy row to stop setting the scale, which it is from
554+ * its first bad step, and it stays larger no matter how many other rows go
555+ * with it. One healthy variant is enough to keep the grid readable.
556+ *
557+ * The cost is a slight bias: among healthy variants the scale comes from
558+ * the one with the smallest peak, so the others clip by however much they
559+ * exceed it. They are approximations of the same solution, so that is a
560+ * fraction of a percent, and the alternative is a display that a single
561+ * divergence can take away.
562+ */
563+ const bounds = this.#rows.map((r) => species.map((_, k) => finiteRange(r.fields[k])));
564+ this.#rows.forEach((r, i) => {
565+ // A row with any non-finite value is out of the running entirely: its
566+ // finite entries are whatever survived, and no rank over them means much.
567+ r.healthy = species.every((_, k) => allFinite(r.fields[k]) && bounds[i][k] !== null);
568+ });
569+
570+ for (let k = 0; k < species.length; k++) {
571+ // The file row, when there is one, is a candidate like any healthy
572+ // variant: early on the variants' small fields set the scale (it merely
573+ // clips), and if every variant diverges it is the row that keeps the
574+ // grid readable.
575+ const anchor = leastPeak([
576+ ...this.#rows.map((r, i) => (r.healthy ? bounds[i][k] : null)),
577+ this.#fileRow?.bounds[k] ?? null,
578+ ]);
579+ const range = this.#ranges[k];
580+ if (anchor) {
581+ if (!Number.isFinite(range.lo)) {
582+ range.lo = anchor.lo;
583+ range.hi = anchor.hi;
584+ } else {
585+ // Smooth in both directions so the shading evolves gently as the
586+ // pattern grows, as the single-run view does.
587+ const a = 0.15;
588+ range.lo += a * (anchor.lo - range.lo);
589+ range.hi += a * (anchor.hi - range.hi);
590+ }
591+ }
592+ // With every row gone, the last good range is kept rather than replaced
593+ // by nothing: the panels freeze at a readable scale and the row labels
594+ // say what happened, instead of the grid going blank.
595+ if (!Number.isFinite(range.lo) || !Number.isFinite(range.hi)) continue;
596+ // The floor is applied to what is drawn, not to what is tracked, so it
597+ // never feeds back into the smoothing above.
598+ const shown = floorRange(range.lo, range.hi);
599+ this.#rangeBars[k]?.fill(shown.lo, shown.hi);
600+ for (const r of this.#rows) {
601+ fillFieldValues(r.valueBufs[k], r.fields[k], this.#topo);
602+ fillColors(r.colorBufs[k], r.valueBufs[k], shown.lo, shown.hi, cmap);
603+ r.scenes[k]?.updateColors(r.colorBufs[k]);
604+ }
605+ const f = this.#fileRow;
606+ if (f) {
607+ // Its values never change; only its coloring follows the shared range.
608+ fillColors(f.colorBufs[k], f.valueBufs[k], shown.lo, shown.hi, cmap);
609+ f.scenes[k]?.updateColors(f.colorBufs[k]);
610+ }
611+ }
612+
613+ this.#measureDifference();
614+ this.#updateRowStats();
615+ }
616+
617+ /**
618+ * Relative L2 difference from the reference, per species, on the shared
619+ * grid. Weighted by the Gauss weights, so it is the norm on the parameter
620+ * sphere — not on the embedded surface, which would weight by the area
621+ * element. That makes it a consistent diagnostic across variants rather than
622+ * a physical quantity, which is all it is used for.
623+ */
624+ #measureDifference(): void {
625+ // Against a reference file, every row is measured against its final state;
626+ // otherwise against the chosen reference variant, whose own Δ is zero.
627+ const ref = this.#fileRow ? null : this.#rows[this.#opts.reference];
628+ const refFields = this.#fileRow?.fields ?? ref?.fields;
629+ if (!refFields) return;
630+ const species = this.#opts.model.species;
631+ for (const r of this.#rows) {
632+ for (let k = 0; k < species.length; k++) {
633+ if (r === ref) {
634+ r.err[k] = 0;
635+ continue;
636+ }
637+ const a = r.fields[k];
638+ const b = refFields[k];
639+ if (!a || !b || a.length !== b.length) {
640+ r.err[k] = NaN;
641+ continue;
642+ }
643+ let num = 0;
644+ let den = 0;
645+ for (let i = 0; i < a.length; i++) {
646+ const w = this.#weights[i];
647+ const d = a[i] - b[i];
648+ num += w * d * d;
649+ den += w * b[i] * b[i];
650+ }
651+ r.err[k] = den > 0 ? Math.sqrt(num / den) : NaN;
652+ }
653+ }
654+ }
655+
656+ /**
657+ * Each row's standing line: how many of its own steps it took to reach the
658+ * common time, and how far it is from the reference right now, per species.
659+ * Per species rather than a single worst-case number because the two are
660+ * genuinely different questions on a two-species model — the slow species is
661+ * usually the one that has converged and the fast one the one that has not.
662+ */
663+ #updateRowStats(): void {
664+ const species = this.#opts.model.species;
665+ const ref = this.#fileRow ? null : this.#rows[this.#opts.reference];
666+ for (const r of this.#rows) {
667+ const per = species
668+ .map((s, k) => `${s} ${Number.isFinite(r.err[k]) ? r.err[k].toExponential(2) : '—'}`)
669+ .join('<br>');
670+ // Divergence is said, not implied. Scaled to a healthy row, a blown-up
671+ // variant is a flat saturated panel, which on its own is easy to misread
672+ // as a converged uniform state.
673+ const body = !r.healthy
674+ ? '<b class="cmp-diverged">diverged</b>'
675+ : r === ref
676+ ? '<b>reference</b>'
677+ : `Δ ${per}`;
678+ r.statEl.innerHTML = `${r.session.steps.toLocaleString()} steps<br>${body}`;
679+ }
680+ }
681+
682+ #status(): void {
683+ const refFile = this.#opts.refFile;
684+ const clock = refFile
685+ ? `<b>t = ${this.#t.toFixed(2)} / ${(refFile.steps * CompareRun.baseDt(this.#opts.params)).toFixed(2)}</b>` +
686+ (this.#finished
687+ ? ` — <b>at the file's end time</b>: Δ is the final comparison against its final state`
688+ : ` · Δ is the distance still to the file's <i>final</i> state — read it at the end time`)
689+ : `<b>t = ${this.#t.toFixed(2)}</b> (same for every variant)`;
690+ this.#opts.onStatus(
691+ `${clock} · ` +
692+ (this.#frameMs > 0 ? `${this.#frameMs.toFixed(1)} ms/frame · ` : '') +
693+ this.#note,
694+ );
695+ }
696+
697+ #observeResize(): void {
698+ const scenes = this.#allScenes();
699+ this.#resizeObs = new ResizeObserver(() => {
700+ for (const s of scenes) {
701+ const box = s.canvas.parentElement;
702+ if (box) s.resize(box.clientWidth, box.clientHeight);
703+ }
704+ });
705+ for (const s of scenes) {
706+ const box = s.canvas.parentElement;
707+ if (box) this.#resizeObs.observe(box);
708+ }
709+ }
710+
711+ // -------------------------------------------------------------- the clock
712+ /**
713+ * One frame advances every variant by the *same model time*: `frameSteps`
714+ * base steps, which a ÷K variant covers in K times as many of its own. That
715+ * is the whole reason dt varies by an integer divisor — the alternative is
716+ * rounding each variant to the nearest step and comparing fields that are a
717+ * fraction of a timestep apart, which would show up as a difference and be
718+ * indistinguishable from a real one.
719+ */
720+ async #pump(): Promise<void> {
721+ if (this.#pumping) return;
722+ this.#pumping = true;
723+ try {
724+ while (this.#running && !this.#disposed) {
725+ // Against a reference file the run is finite: the last frame takes
726+ // however many base steps remain, so every variant lands exactly on
727+ // the file's end time — where Δ against its final state is the
728+ // comparison — and stops there rather than drifting past it.
729+ const refFile = this.#opts.refFile;
730+ const n = refFile
731+ ? Math.min(this.#frameSteps, refFile.steps - this.#stepsDone)
732+ : this.#frameSteps;
733+ if (n <= 0) {
734+ this.#running = false;
735+ this.#opts.onFinished?.();
736+ break;
737+ }
738+ const t0 = performance.now();
739+ for (const r of this.#rows) r.session.step(n * r.variant.dtDiv);
740+ this.#stepsDone += n;
741+ this.#t += n * CompareRun.baseDt(this.#opts.params);
742+ await this.draw();
743+ if (this.#disposed) break;
744+ const dt = performance.now() - t0;
745+ this.#frameMs = this.#frameMs === 0 ? dt : this.#frameMs + 0.05 * (dt - this.#frameMs);
746+ if (refFile && this.#stepsDone >= refFile.steps) {
747+ this.#finished = true;
748+ this.#running = false;
749+ this.#status();
750+ this.#opts.onFinished?.();
751+ break;
752+ }
753+ this.#status();
754+ await nextFrame();
755+ }
756+ if (!this.#disposed) {
757+ await this.draw();
758+ this.#status();
759+ }
760+ } finally {
761+ this.#pumping = false;
762+ }
763+ }
764+}
765+
766+const nextFrame = (): Promise<number> => new Promise(requestAnimationFrame);
767+
768+/** A whole spectral state re-indexed into a (wider) band's layout — the
769+ * reference file's initial condition, in the form loadState takes. */
770+function prolongState(
771+ coeffs: Record<string, Float32Array>,
772+ names: string[],
773+ lmaxFrom: number,
774+ lmaxTo: number,
775+): Record<string, Float32Array> {
776+ const out: Record<string, Float32Array> = {};
777+ for (const name of names) out[name] = prolongCoeffs(coeffs[name], lmaxFrom, lmaxTo);
778+ return out;
779+}
780+
781+/** Whether every entry is an ordinary number — false once a variant has left
782+ * its convergence radius and saturated to infinity or NaN. */
783+function allFinite(f: Float32Array | undefined): boolean {
784+ if (!f) return false;
785+ for (let i = 0; i < f.length; i++) if (!Number.isFinite(f[i])) return false;
786+ return true;
787+}
788+
789+type Bounds = { lo: number; hi: number };
790+
791+/** How far a field reaches from zero — the one number the rows are ranked by
792+ * when deciding which of them sets a column's scale. */
793+const peak = (b: Bounds): number => Math.max(Math.abs(b.lo), Math.abs(b.hi));
794+
795+/** Whichever of the given bounds reaches least far from zero; null if none. */
796+function leastPeak(all: (Bounds | null)[]): Bounds | null {
797+ let best: Bounds | null = null;
798+ for (const b of all) {
799+ if (b !== null && (best === null || peak(b) < peak(best))) best = b;
800+ }
801+ return best;
802+}
803+
804+/** Min and max over the finite entries only; null when there are none. */
805+function finiteRange(f: Float32Array | undefined): { lo: number; hi: number } | null {
806+ if (!f) return null;
807+ let lo = Infinity;
808+ let hi = -Infinity;
809+ for (let i = 0; i < f.length; i++) {
810+ const v = f[i];
811+ if (!Number.isFinite(v)) continue;
812+ if (v < lo) lo = v;
813+ if (v > hi) hi = v;
814+ }
815+ return lo <= hi ? { lo, hi } : null;
816+}
817+
818+/**
819+ * The DOM: a header row naming each species and carrying that column's shared
820+ * color range, then one row per variant. The colorbar is per *column* rather
821+ * than per panel because the range is shared — a bar on every panel would be
822+ * the same bar repeated, and would suggest each panel had its own scaling,
823+ * which is exactly the thing that would make the comparison a lie.
824+ */
825+/** The file row's label color — none of the variant palette, since it is not
826+ * a variant: it is the thing they are all measured against. */
827+const FILE_ROW_COLOR = '#57606a';
828+
829+async function buildGrid(
830+ opts: CompareOptions,
831+ sessions: ModelSession[],
832+ topo: SphereMeshTopology,
833+ showDt: boolean,
834+): Promise<{
835+ rows: Row[];
836+ fileRow: FileRow | null;
837+ rangeBars: { fill: (lo: number, hi: number) => void }[];
838+}> {
839+ const { container, model } = opts;
840+ container.replaceChildren();
841+ container.classList.add('compare');
842+
843+ const head = document.createElement('div');
844+ head.className = 'cmp-row cmp-head';
845+ const headSpacer = document.createElement('div');
846+ headSpacer.className = 'cmp-rowlabel';
847+ const headCols = document.createElement('div');
848+ headCols.className = 'cmp-cols';
849+ head.append(headSpacer, headCols);
850+ container.append(head);
851+
852+ const rangeBars = model.species.map((name) => {
853+ const col = document.createElement('div');
854+ col.className = 'cmp-colhead';
855+ const tag = document.createElement('b');
856+ tag.textContent = name;
857+ const canvas = document.createElement('canvas');
858+ canvas.width = 160;
859+ canvas.height = 8;
860+ canvas.className = 'cmp-rangebar';
861+ const lab = document.createElement('span');
862+ lab.className = 'cmp-rangelab';
863+ col.append(tag, canvas, lab);
864+ headCols.append(col);
865+ let painted = false;
866+ return {
867+ fill: (lo: number, hi: number): void => {
868+ const ctx = canvas.getContext('2d');
869+ if (ctx && !painted) {
870+ painted = true;
871+ const cmap = colormaps[opts.colormapName()] ?? colormaps.viridis;
872+ for (let x = 0; x < canvas.width; x++) {
873+ const [r, g, b] = cmap(x / (canvas.width - 1));
874+ ctx.fillStyle = `rgb(${r},${g},${b})`;
875+ ctx.fillRect(x, 0, 1, canvas.height);
876+ }
877+ }
878+ lab.textContent = `${fmtValue(lo)} … ${fmtValue(hi)}`;
879+ },
880+ };
881+ });
882+
883+ const sphereBg = getComputedStyle(document.documentElement)
884+ .getPropertyValue('--sphere-bg')
885+ .trim();
886+
887+ const rows: Row[] = [];
888+ for (let i = 0; i < sessions.length; i++) {
889+ const session = sessions[i];
890+ const variant = opts.variants[i];
891+ const color = VARIANT_COLORS[i % VARIANT_COLORS.length];
892+
893+ const coords = await session.renderPositions();
894+ const posBuf = new Float32Array(topo.numVertices * 3);
895+ fillPositions(posBuf, coords, topo, opts.morph);
896+
897+ const rowEl = document.createElement('div');
898+ rowEl.className = 'cmp-row';
899+ const labelEl = document.createElement('div');
900+ labelEl.className = 'cmp-rowlabel';
901+ labelEl.style.setProperty('--c', color);
902+ const nameEl = document.createElement('div');
903+ nameEl.className = 'cmp-rowname';
904+ nameEl.textContent = variantLabel(variant, showDt);
905+ const statEl = document.createElement('div');
906+ statEl.className = 'cmp-rowstat';
907+ labelEl.append(nameEl, statEl);
908+ const colsEl = document.createElement('div');
909+ colsEl.className = 'cmp-cols';
910+ rowEl.append(labelEl, colsEl);
911+ container.append(rowEl);
912+
913+ const scenes: SphereScene[] = [];
914+ const valueBufs: Float32Array[] = [];
915+ const colorBufs: Float32Array[] = [];
916+ for (let k = 0; k < model.species.length; k++) {
917+ const box = document.createElement('div');
918+ box.className = 'sphere-box cmp-box';
919+ colsEl.append(box);
920+ const scene = new SphereScene(
921+ box,
922+ topo.numVertices,
923+ topo.indices,
924+ Float32Array.from(posBuf),
925+ sphereBg || undefined,
926+ );
927+ scene.fitCamera();
928+ scenes.push(scene);
929+ valueBufs.push(new Float32Array(topo.numVertices));
930+ colorBufs.push(new Float32Array(topo.numVertices * 3));
931+ }
932+
933+ rows.push({
934+ variant, session, color, coords, posBuf, scenes, valueBufs, colorBufs,
935+ fields: [], err: model.species.map(() => 0), healthy: true, statEl,
936+ });
937+ }
938+
939+ // ---- the reference file's final state, as one more (static) row ---------
940+ let fileRow: FileRow | null = null;
941+ if (opts.refFile) {
942+ const rf = opts.refFile;
943+ // Synthesized through the coarsest session's display plan — exact, like
944+ // every other use of the shared grid: the file's coefficients are
945+ // band-limited at its lmax, which every variant's band contains.
946+ const view = sessions[0].viewSht;
947+ const lmaxTo = sessions[0].cfg.lmax;
948+ const on = (q: Float32Array): Promise<Float32Array> =>
949+ view.synth(prolongCoeffs(q, rf.lmax, lmaxTo));
950+ const [gx, gy, gz] = [
951+ await on(rf.geometryCoeffs.X),
952+ await on(rf.geometryCoeffs.Y),
953+ await on(rf.geometryCoeffs.Z),
954+ ];
955+ // The file's own surface, not a regeneration of it — interleaved xyz, the
956+ // same layout renderPositions() hands back.
957+ const coords = new Float32Array(3 * gx.length);
958+ for (let i = 0; i < gx.length; i++) {
959+ coords[3 * i] = gx[i];
960+ coords[3 * i + 1] = gy[i];
961+ coords[3 * i + 2] = gz[i];
962+ }
963+ const posBuf = new Float32Array(topo.numVertices * 3);
964+ fillPositions(posBuf, coords, topo, opts.morph);
965+
966+ const rowEl = document.createElement('div');
967+ rowEl.className = 'cmp-row';
968+ const labelEl = document.createElement('div');
969+ labelEl.className = 'cmp-rowlabel';
970+ labelEl.style.setProperty('--c', FILE_ROW_COLOR);
971+ const nameEl = document.createElement('div');
972+ nameEl.className = 'cmp-rowname';
973+ nameEl.textContent = 'reference file';
974+ nameEl.title = rf.label;
975+ const statEl = document.createElement('div');
976+ statEl.className = 'cmp-rowstat';
977+ statEl.innerHTML = `${rf.steps.toLocaleString()} steps<br><b>final state</b>`;
978+ labelEl.append(nameEl, statEl);
979+ const colsEl = document.createElement('div');
980+ colsEl.className = 'cmp-cols';
981+ rowEl.append(labelEl, colsEl);
982+ container.append(rowEl);
983+
984+ const scenes: SphereScene[] = [];
985+ const valueBufs: Float32Array[] = [];
986+ const colorBufs: Float32Array[] = [];
987+ const fields: Float32Array[] = [];
988+ const bounds: (Bounds | null)[] = [];
989+ for (let k = 0; k < model.species.length; k++) {
990+ const box = document.createElement('div');
991+ box.className = 'sphere-box cmp-box';
992+ colsEl.append(box);
993+ const scene = new SphereScene(
994+ box,
995+ topo.numVertices,
996+ topo.indices,
997+ Float32Array.from(posBuf),
998+ sphereBg || undefined,
999+ );
1000+ scene.fitCamera();
1001+ scenes.push(scene);
1002+ const field = await on(rf.final[model.state[k]]);
1003+ fields.push(field);
1004+ bounds.push(finiteRange(field));
1005+ const valueBuf = new Float32Array(topo.numVertices);
1006+ fillFieldValues(valueBuf, field, topo);
1007+ valueBufs.push(valueBuf);
1008+ colorBufs.push(new Float32Array(topo.numVertices * 3));
1009+ }
1010+ fileRow = { coords, posBuf, scenes, valueBufs, colorBufs, fields, bounds };
1011+ }
1012+
1013+ // Every panel shares one camera: the study is about the fields, and looking
1014+ // at two of them from different angles is not comparing them.
1015+ const all = [...rows.flatMap((r) => r.scenes), ...(fileRow?.scenes ?? [])];
1016+ for (let i = 1; i < all.length; i++) all[0].syncCamerasWith(all[i]);
1017+
1018+ return { rows, fileRow, rangeBars };
1019+}
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.tsadded+101−0View file
@@ -0,0 +1,101 @@
1+/**
2+ * One initial condition, on every variant's grid.
3+ *
4+ * The host's seeded perturbation is one normal deviate per *grid point*
5+ * (src/mgpu/noise.ts), so two sessions at different lmax seeded from the same
6+ * integer do not start from the same field — they start from unrelated fields
7+ * that merely share a random seed. Comparing them would compare two different
8+ * problems, and every number the comparison produced would be meaningless.
9+ *
10+ * So the field is built once, band-limited at the *coarsest* variant's lmax,
11+ * and evaluated on each variant's own grid:
12+ *
13+ * 1. white noise on the coarsest grid
14+ * 2. analysed there -> coefficients up to lmax_min
15+ * 3. zero-padded into each variant's coefficient layout
16+ * 4. synthesized on that variant's grid
17+ *
18+ * Steps 3 and 4 are exact: the field is band-limited at lmax_min, and every
19+ * variant's band contains that, so each one receives the *same function*
20+ * sampled where it needs it. Running each model's own `init` on it then leaves
21+ * every session holding the identical spectral state (zero-padded), which is
22+ * what makes a pointwise comparison at later times mean something.
23+ *
24+ * The coarsest variant gets the projected field too, not the raw white noise
25+ * it was analysed from — otherwise it alone would start somewhere slightly
26+ * 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`.
33+ */
34+import { lmIndex, nlmCalc } from '../sht/layout.ts';
35+import { seededNoise } from '../mgpu/noise.ts';
36+import type { ModelSession } from '../mgpu/session.ts';
37+
38+/**
39+ * Re-index coefficients from a band limit into a wider one's layout, zero-
40+ * filling the degrees the source does not have. Both layouts are SHTNS
41+ * m-major with mmax = lmax, so nothing but the index mapping changes.
42+ */
43+export function prolongCoeffs(
44+ q: Float32Array,
45+ lmaxFrom: number,
46+ lmaxTo: number,
47+): Float32Array {
48+ if (lmaxTo === lmaxFrom) return q;
49+ if (lmaxTo < lmaxFrom) {
50+ throw new Error(`prolongCoeffs: cannot widen ${lmaxFrom} into a smaller ${lmaxTo}`);
51+ }
52+ const out = new Float32Array(2 * nlmCalc(lmaxTo, lmaxTo));
53+ for (let m = 0; m <= lmaxFrom; m++) {
54+ for (let l = m; l <= lmaxFrom; l++) {
55+ const from = 2 * lmIndex(lmaxFrom, l, m);
56+ const to = 2 * lmIndex(lmaxTo, l, m);
57+ out[to] = q[from];
58+ out[to + 1] = q[from + 1];
59+ }
60+ }
61+ return out;
62+}
63+
64+/**
65+ * The same band-limited perturbation, sampled on each session's grid. Order
66+ * follows `sessions`. Nothing may be in flight on any session's transform
67+ * plan — the one-off analys/synth here use the plan's own scratch buffers.
68+ */
69+export async function sharedNoise(
70+ sessions: ModelSession[],
71+ amp: number,
72+ seed: number,
73+): Promise<Float32Array[]> {
74+ let base = sessions[0];
75+ for (const s of sessions) if (s.cfg.lmax < base.cfg.lmax) base = s;
76+ const coeffs = await base.sht.analys(seededNoise(base.npts, amp, seed));
77+ const out: Float32Array[] = [];
78+ for (const s of sessions) {
79+ out.push(await s.sht.synth(prolongCoeffs(coeffs, base.cfg.lmax, s.cfg.lmax)));
80+ }
81+ return out;
82+}
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/compare/variants.tsadded+81−0View file
@@ -0,0 +1,81 @@
1+/**
2+ * One point of a convergence study: a choice of the three knobs that decide
3+ * *how well* the same problem is being solved, rather than what the problem is.
4+ *
5+ * niter iterations of the implicit solve (structural — it unrolls into the
6+ * compiled step, so each value is its own compiled session)
7+ * lmax the spectral band, and with it the grid (also structural)
8+ * dtDiv the timestep, as an integer divisor of the model's own dt
9+ *
10+ * dt is a *divisor* rather than a free value on purpose, and it is the whole
11+ * reason the comparison can be trusted: variants have to be compared at the
12+ * same model time, and with dt = dtBase/K every variant lands exactly on the
13+ * same t after K times as many steps — no rounding, no drift, no interpolation
14+ * in time. A free dt would put each variant on its own timeline and every
15+ * difference reported would be part real and part "these are 0.003 apart".
16+ */
17+
18+export interface Variant {
19+ /** Iterations of the implicit solve. */
20+ niter: number;
21+ /** Spectral band limit. */
22+ lmax: number;
23+ /** Timestep divisor: this variant runs at dtBase / dtDiv. */
24+ dtDiv: number;
25+}
26+
27+/** Stable identity of a variant, for keying maps and the reference <select>. */
28+export const variantKey = (v: Variant): string => `${v.niter}/${v.lmax}/${v.dtDiv}`;
29+
30+/** Human label. The dt term is dropped when nothing varies it, so the common
31+ * case (niter x lmax) reads as just those two. */
32+export const variantLabel = (v: Variant, showDt: boolean): string =>
33+ `niter ${v.niter} · lmax ${v.lmax}` + (showDt ? ` · dt/${v.dtDiv}` : '');
34+
35+/**
36+ * Every combination of the selected values, in a stable order: coarsest first,
37+ * so the grid reads top-to-bottom from least to most resolved and the
38+ * reference (the last row) is the one everything is measured against.
39+ */
40+export function crossProduct(
41+ niters: number[],
42+ lmaxes: number[],
43+ dtDivs: number[],
44+): Variant[] {
45+ const out: Variant[] = [];
46+ for (const lmax of [...lmaxes].sort((a, b) => a - b)) {
47+ for (const dtDiv of [...dtDivs].sort((a, b) => a - b)) {
48+ for (const niter of [...niters].sort((a, b) => a - b)) {
49+ out.push({ niter, lmax, dtDiv });
50+ }
51+ }
52+ }
53+ return out;
54+}
55+
56+/**
57+ * Index of the most-resolved variant: the natural reference, since it is the
58+ * one every other choice is an approximation of. Finer band first (it bounds
59+ * what can be represented at all), then more solve iterations, then smaller
60+ * timestep.
61+ */
62+export function mostResolved(variants: Variant[]): number {
63+ let best = 0;
64+ for (let i = 1; i < variants.length; i++) {
65+ const a = variants[i];
66+ const b = variants[best];
67+ if (
68+ a.lmax > b.lmax ||
69+ (a.lmax === b.lmax && a.niter > b.niter) ||
70+ (a.lmax === b.lmax && a.niter === b.niter && a.dtDiv > b.dtDiv)
71+ ) {
72+ best = i;
73+ }
74+ }
75+ return best;
76+}
77+
78+/** Distinguishable line/label colors, one per variant row. */
79+export const VARIANT_COLORS = [
80+ '#0969da', '#bf8700', '#1a7f37', '#cf222e', '#8250df', '#0f7c8a',
81+];
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+285−102View 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,25 @@
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';
3243 import type { ShtConfig } from '../sht/layout.ts';
3344 import type { DerivPlan } from '../sht/deriv.ts';
34-import { computeMetric } 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';
45+import { computeMetric, computeFluxMetric } from './metric.ts';
46+import { toolFiles } from '../tools.ts';
47+import { inFunction, inModel, ModelCompileError } from '../mgpu/errors.ts';
3848 import type { ModelParams } from '../mgpu/model.ts';
3949
4050 /** The function a geometry file must define. */
4151 export const SHAPE_FN = 'shape';
4252
4353 export interface GeometryOptions {
44- device: GPUDevice;
4554 /** The solver's transform plan — the grid the shape is evaluated on. */
4655 sht: ShtPlan;
4756 cfg: ShtConfig;
@@ -66,7 +75,8 @@ export class Geometry {
6675 /**
6776 * Inverse metric quantities (src/geom/metric.ts), grid space, npts each.
6877 * Depend only on the geometry, so — like x,y,z,X,Y,Z above — these are a
69- * one-off computed here, not per-solve-step work.
78+ * one-off computed here, not per-solve-step work. Used by the Algorithm-4
79+ * (12-transform) Laplace-Beltrami path.
7080 */
7181 readonly Vtx: Float32Array;
7282 readonly Vty: Float32Array;
@@ -74,12 +84,74 @@ export class Geometry {
7484 readonly Vpx: Float32Array;
7585 readonly Vpy: Float32Array;
7686 readonly Vpz: Float32Array;
87+ /**
88+ * Flux-form metric weights (src/geom/metric.ts computeFluxMetric), grid
89+ * space, npts each — the six-transform Laplace-Beltrami scheme's
90+ * replacement for the six V arrays (docs/reduced-transforms.md
91+ * Sec 3). Both sets are carried so either operator formulation can run.
92+ */
93+ readonly p1: Float32Array;
94+ readonly p2: Float32Array;
95+ readonly q2: Float32Array;
96+ readonly r: Float32Array;
97+ /**
98+ * The same flux weights with the round sphere subtracted off, plus the
99+ * bounded 1/J — what lets a model evaluate lap_g without ever multiplying
100+ * the *whole* flux divergence by r ~ 1/sin^2(theta). Writing p1 = 1 + dp1,
101+ * q2 = 1 + dq2 (p2 is already a pure deviation, zero on the sphere) splits
102+ * the divergence into a round-sphere part, whose cancelling bracket
103+ * sin(theta) dtheta(A) + dphi(B) = -sin^2(theta) lap_s u is known exactly in
104+ * spectral space, and a remainder:
105+ *
106+ * lap_g u = -jinv * lap_s u + r * (sin(theta) dtheta(P') + dphi(Q'))
107+ *
108+ * with P' = dp1*A + p2*B, Q' = p2*A + dq2*B. Only the remainder meets the
109+ * concentrated division, so the polar roundoff gain drops by |P'|/|P|
110+ * instead of applying to the full flux. Subtracting 1 in f64 here is the
111+ * point: on a near-sphere dp1 is the small quantity, and forming it as an
112+ * f32 difference in the .m would lose it. See docs/reduced-transforms.md
113+ * Sec 5 and models/schnakenberg.m.
114+ */
115+ readonly dp1: Float32Array;
116+ readonly dq2: Float32Array;
117+ readonly jinv: Float32Array;
118+ /**
119+ * Preconditioner scale for the implicit solve (docs/reduced-transforms.md
120+ * Sec 10). At high degree the Richardson iteration's per-mode factor is
121+ * governed by the operator's principal symbol: in the orthonormal frame
122+ * the surface symbol matrix is S = (1/J)[[p1, p2], [p2, q2]], whose
123+ * eigenvalues mu(x) are the inverse squared principal stretches of the
124+ * embedding — the round sphere has mu = 1. Preconditioning with lam/Jhat
125+ * contracts every mode and every direction iff Jhat*mu stays in (0, 2),
126+ * so the minimax constant is the harmonic mean of the symbol extremes,
127+ *
128+ * Jhat = 2/(muMin + muMax), rate = (muMax - muMin)/(muMax + muMin) < 1.
129+ *
130+ * The direction dependence is the point: a det-based mean of the area
131+ * factor J (mu's geometric mean, exact only for conformal surfaces)
132+ * under-corrects anisotropic stretching — on the shipped ellipsoid it
133+ * leaves a band of directional high-degree modes with amplification > 1,
134+ * which inflates the pattern's spectrum at moderate niter/lmax and
135+ * diverges at larger ones. The plain scheme (Jhat = 1) diverges wherever
136+ * muMax > 2. The solve's fixed point never depends on Jhat; only the
137+ * convergence rate does.
138+ */
139+ readonly Jhat: number;
140+ /** Symbol-eigenvalue range over the grid (see Jhat), for diagnostics. */
141+ readonly muMin: number;
142+ readonly muMax: number;
143+ /** Area-factor range over the grid, for diagnostics. */
144+ readonly Jmin: number;
145+ readonly Jmax: number;
77146
78147 private constructor(init: {
79148 x: Float32Array; y: Float32Array; z: Float32Array;
80149 X: Float32Array; Y: Float32Array; Z: Float32Array;
81150 Vtx: Float32Array; Vty: Float32Array; Vtz: Float32Array;
82151 Vpx: Float32Array; Vpy: Float32Array; Vpz: Float32Array;
152+ p1: Float32Array; p2: Float32Array; q2: Float32Array; r: Float32Array;
153+ dp1: Float32Array; dq2: Float32Array; jinv: Float32Array;
154+ Jhat: number; muMin: number; muMax: number; Jmin: number; Jmax: number;
83155 }) {
84156 this.x = init.x;
85157 this.y = init.y;
@@ -93,82 +165,114 @@ export class Geometry {
93165 this.Vpx = init.Vpx;
94166 this.Vpy = init.Vpy;
95167 this.Vpz = init.Vpz;
168+ this.p1 = init.p1;
169+ this.p2 = init.p2;
170+ this.q2 = init.q2;
171+ this.r = init.r;
172+ this.dp1 = init.dp1;
173+ this.dq2 = init.dq2;
174+ this.jinv = init.jinv;
175+ this.Jhat = init.Jhat;
176+ this.muMin = init.muMin;
177+ this.muMax = init.muMax;
178+ this.Jmin = init.Jmin;
179+ this.Jmax = init.Jmax;
96180 }
97181
98182 /**
99- * Compile the shape file, evaluate it once on the solver grid, and reduce it
100- * to coefficients. Everything here happens at build time — a geometry never
101- * takes part in the timestep — so it reads back through the CPU freely.
183+ * Evaluate the shape file once on the solver grid and reduce it to
184+ * coefficients. Everything here happens at build time — a geometry never
185+ * takes part in the timestep — so the .m runs on the CPU (see
186+ * `evaluateShape`) and only the analysis onward touches the GPU.
102187 */
103188 static async create(opts: GeometryOptions): Promise<Geometry> {
104- const { device, sht, cfg, source, paramNames, params, deriv } = opts;
189+ const { sht, cfg, source, paramNames, params, deriv } = opts;
105190 const npts = cfg.nlat * cfg.nphi;
106- const nlm = sht.nlm;
107-
108- const bindings: Record<string, Binding> = {
109- theta: { kind: 'tensor', shape: [npts, 1] },
110- phi: { kind: 'tensor', shape: [npts, 1] },
111- npts: { kind: 'const', value: npts },
112- };
113- for (const p of paramNames) bindings[p] = { kind: 'param' };
114-
115- const compiled = inModel(() => new CompiledModel(source, bindings, { npts, nlm }));
116- const fn = inFunction(SHAPE_FN, () => compiled.specialize(SHAPE_FN, 3));
117- compiled.finish();
118-
119- const host = new HostBuffers(device);
120- host.ensure('theta', npts);
121- host.ensure('phi', npts);
122-
123- const plan = await inFunctionAsync(SHAPE_FN, () =>
124- // Nothing feeds back: the three outputs are read once and the plan is
125- // thrown away.
126- ModelPlan.create(device, sht, { fn, feedback: [null, null, null] }, host),
127- );
128191
129- try {
130- const { theta, phi } = gridAngles(sht, cfg);
131- host.upload('theta', theta);
132- host.upload('phi', phi);
133- plan.setParams(params);
192+ const { theta, phi } = gridAngles(sht, cfg);
193+ const raw = evaluateShape(source, paramNames, params, theta, phi, npts);
194+
195+ // Coefficients first, then back to the grid: what the solver and the
196+ // renderer both see is the band-limited surface, not the raw .m output.
197+ const [X, Y, Z] = [
198+ await sht.analys(raw[0]),
199+ await sht.analys(raw[1]),
200+ await sht.analys(raw[2]),
201+ ];
202+ const [x, y, z] = [
203+ await sht.synth(X),
204+ await sht.synth(Y),
205+ await sht.synth(Z),
206+ ];
207+
208+ // Inverse metric quantities (algos.tex Algorithm 2): theta/phi
209+ // derivatives of the embedding's coefficients, contracted through the
210+ // inverse first fundamental form. Depends only on the geometry, so
211+ // this is a one-off alongside x,y,z above, not per-step work.
212+ const Xt = await deriv.dtheta(X);
213+ const Xp = await deriv.dphi(X);
214+ const Yt = await deriv.dtheta(Y);
215+ const Yp = await deriv.dphi(Y);
216+ const Zt = await deriv.dtheta(Z);
217+ const Zp = await deriv.dphi(Z);
218+ const { Vtx, Vty, Vtz, Vpx, Vpy, Vpz } = computeMetric(npts, Xt, Xp, Yt, Yp, Zt, Zp);
134219
135- const enc = device.createCommandEncoder({ label: 'geometry-shape' });
136- plan.encodeSteps(enc, 1);
137- device.queue.submit([enc.finish()]);
220+ // Flux-form metric weights for the six-transform scheme, built from the
221+ // *undivided* theta tangents sin(theta)*X_theta (smooth on the sphere,
222+ // unlike X_theta itself) and the same X_phi as above. Also a one-off;
223+ // the f64 combination happens on the CPU, rounded to f32 for upload.
224+ const sXtx = await deriv.sinDtheta(X);
225+ const sXty = await deriv.sinDtheta(Y);
226+ const sXtz = await deriv.sinDtheta(Z);
227+ const flux = computeFluxMetric(npts, sXtx, sXty, sXtz, Xp, Yp, Zp);
138228
139- const raw = await Promise.all(
140- fn.outputs.map((out) => readBuffer(device, plan, out.name, npts)),
141- );
142- // Coefficients first, then back to the grid: what the solver and the
143- // renderer both see is the band-limited surface, not the raw .m output.
144- const [X, Y, Z] = [
145- await sht.analys(raw[0]),
146- await sht.analys(raw[1]),
147- await sht.analys(raw[2]),
148- ];
149- const [x, y, z] = [
150- await sht.synth(X),
151- await sht.synth(Y),
152- await sht.synth(Z),
153- ];
154-
155- // Inverse metric quantities (algos.tex Algorithm 2): theta/phi
156- // derivatives of the embedding's coefficients, contracted through the
157- // inverse first fundamental form. Depends only on the geometry, so
158- // this is a one-off alongside x,y,z above, not per-step work.
159- const Xt = await deriv.dtheta(X);
160- const Xp = await deriv.dphi(X);
161- const Yt = await deriv.dtheta(Y);
162- const Yp = await deriv.dphi(Y);
163- const Zt = await deriv.dtheta(Z);
164- const Zp = await deriv.dphi(Z);
165- const { Vtx, Vty, Vtz, Vpx, Vpy, Vpz } = computeMetric(npts, Xt, Xp, Yt, Yp, Zt, Zp);
166-
167- return new Geometry({ x, y, z, X, Y, Z, Vtx, Vty, Vtz, Vpx, Vpy, Vpz });
168- } finally {
169- plan.destroy();
170- host.destroy();
229+ // The preconditioner scale — see the Jhat field comment. The symbol
230+ // matrix in the orthonormal frame is S = (1/J)[[p1,p2],[p2,q2]] with
231+ // 1/J = r sin^2(theta); its entries are the bounded quantities
232+ // g^tt, sin g^tp, sin^2 g^pp, so the eigenvalue extremes are clean to
233+ // take over the grid. det S = 1/J^2, so the area factor comes along
234+ // for free. f64 throughout.
235+ let muMin = Infinity;
236+ let muMax = 0;
237+ let Jmin = Infinity;
238+ let Jmax = 0;
239+ // The sphere-subtracted weights ride along on this loop: 1/J is already
240+ // being formed here, and dp1/dq2 want the same f64 arithmetic.
241+ const dp1 = new Float32Array(npts);
242+ const dq2 = new Float32Array(npts);
243+ const jinv = new Float32Array(npts);
244+ for (let i = 0; i < cfg.nlat; i++) {
245+ const ct = sht.cosTheta[i];
246+ const st2 = Math.max(0, 1 - ct * ct);
247+ for (let j = 0; j < cfg.nphi; j++) {
248+ const k = i * cfg.nphi + j;
249+ const invJ = flux.r[k] * st2;
250+ dp1[k] = flux.p1[k] - 1;
251+ dq2[k] = flux.q2[k] - 1;
252+ jinv[k] = invJ;
253+ const s11 = flux.p1[k] * invJ;
254+ const s12 = flux.p2[k] * invJ;
255+ const s22 = flux.q2[k] * invJ;
256+ const mean = (s11 + s22) / 2;
257+ const disc = Math.sqrt(((s11 - s22) / 2) ** 2 + s12 * s12);
258+ if (mean - disc < muMin) muMin = mean - disc;
259+ if (mean + disc > muMax) muMax = mean + disc;
260+ const J = 1 / invJ;
261+ if (J < Jmin) Jmin = J;
262+ if (J > Jmax) Jmax = J;
263+ }
171264 }
265+ const Jhat = 2 / (muMin + muMax);
266+
267+ return new Geometry({
268+ x, y, z, X, Y, Z, Vtx, Vty, Vtz, Vpx, Vpy, Vpz,
269+ p1: new Float32Array(flux.p1),
270+ p2: new Float32Array(flux.p2),
271+ q2: new Float32Array(flux.q2),
272+ r: new Float32Array(flux.r),
273+ dp1, dq2, jinv,
274+ Jhat, muMin, muMax, Jmin, Jmax,
275+ });
172276 }
173277
174278 /**
@@ -204,14 +308,15 @@ export class Geometry {
204308 }
205309 }
206310
207-/** The (theta, phi) of every grid point, flattened phi-fastest as the fields are. */
311+/** The (theta, phi) of every grid point, flattened phi-fastest as the fields
312+ * are — in f64, the precision the shape is evaluated at. */
208313 function gridAngles(
209314 sht: ShtPlan,
210315 cfg: ShtConfig,
211-): { theta: Float32Array; phi: Float32Array } {
316+): { theta: Float64Array; phi: Float64Array } {
212317 const { nlat, nphi } = cfg;
213- const theta = new Float32Array(nlat * nphi);
214- const phi = new Float32Array(nlat * nphi);
318+ const theta = new Float64Array(nlat * nphi);
319+ const phi = new Float64Array(nlat * nphi);
215320 for (let i = 0; i < nlat; i++) {
216321 const th = Math.acos(Math.max(-1, Math.min(1, sht.cosTheta[i])));
217322 for (let j = 0; j < nphi; j++) {
@@ -222,30 +327,108 @@ function gridAngles(
222327 return { theta, phi };
223328 }
224329
225-async function readBuffer(
226- device: GPUDevice,
227- plan: ModelPlan,
330+/**
331+ * Evaluate the shape file on the grid, through numbl's CPU interpreter.
332+ *
333+ * The .m keeps the same contract it had as a compiled model: it names the
334+ * arguments it wants — `theta`, `phi`, and any of the registry's parameters —
335+ * and the host supplies them by name, so their order in the signature is the
336+ * .m's own business. A one-line driver script calls `shape` with exactly the
337+ * arguments its signature declares, with those names pre-bound in the
338+ * driver's workspace.
339+ */
340+function evaluateShape(
341+ source: string,
342+ paramNames: string[],
343+ params: ModelParams,
344+ theta: Float64Array,
345+ phi: Float64Array,
346+ npts: number,
347+): [Float32Array, Float32Array, Float32Array] {
348+ const file = `${SHAPE_FN}.m`;
349+ const ast = inModel(() => parseMFile(source, file));
350+ const fn = ast.body.find(
351+ (s): s is FunctionStmt =>
352+ s.type === 'Function' && (s as FunctionStmt).name === SHAPE_FN,
353+ );
354+ if (!fn) {
355+ throw new ModelCompileError(
356+ `the geometry defines no function named '${SHAPE_FN}'`,
357+ );
358+ }
359+ if (fn.outputs.length !== 3) {
360+ throw new ModelCompileError(
361+ `'${SHAPE_FN}' must return three outputs [gx, gy, gz], not ${fn.outputs.length}`,
362+ { fn: SHAPE_FN, start: fn.span.start, end: fn.span.end },
363+ );
364+ }
365+ const known = new Set(['theta', 'phi', ...paramNames]);
366+ for (const p of fn.params) {
367+ if (!known.has(p)) {
368+ throw new ModelCompileError(
369+ `'${SHAPE_FN}' takes an argument '${p}' that is neither the grid ` +
370+ `(theta, phi) nor one of this geometry's parameters` +
371+ (paramNames.length ? ` (${paramNames.join(', ')})` : ''),
372+ { fn: SHAPE_FN, start: fn.span.start, end: fn.span.end },
373+ );
374+ }
375+ }
376+
377+ const vars: Record<string, RuntimeValue> = {
378+ theta: new RuntimeTensor(theta, [npts, 1]),
379+ phi: new RuntimeTensor(phi, [npts, 1]),
380+ };
381+ for (const name of paramNames) {
382+ const v = params[name];
383+ // Missing parameters read as 0, as ModelPlan.setParams has it.
384+ vars[name] = Number.isFinite(v) ? v : 0;
385+ }
386+
387+ const driver = `[gx__, gy__, gz__] = ${SHAPE_FN}(${fn.params.join(', ')});`;
388+ const result = inFunction(SHAPE_FN, () =>
389+ executeCode(
390+ driver,
391+ { initialVariableValues: vars, displayResults: false, implicitCwdPath: null },
392+ [...toolFiles, { name: file, source }],
393+ 'geometry-driver.m',
394+ ),
395+ );
396+
397+ return [
398+ toGridField(result.variableValues['gx__'], fn.outputs[0], npts),
399+ toGridField(result.variableValues['gy__'], fn.outputs[1], npts),
400+ toGridField(result.variableValues['gz__'], fn.outputs[2], npts),
401+ ];
402+}
403+
404+/** One returned coordinate → npts values, rounded to the transforms' f32. */
405+function toGridField(
406+ value: RuntimeValue | undefined,
228407 name: string,
229- count: number,
230-): Promise<Float32Array> {
231- const buffer = plan.buffer(name);
232- if (!buffer) {
233- throw new Error(`the geometry never assigns '${name}'`);
408+ npts: number,
409+): Float32Array {
410+ // A constant coordinate stays scalar in MATLAB; spread it over the grid.
411+ if (typeof value === 'number') return new Float32Array(npts).fill(value);
412+ if (value !== undefined && isRuntimeTensor(value)) {
413+ if (value.imag) {
414+ throw new ModelCompileError(
415+ `the geometry's '${name}' is complex; coordinates must be real`,
416+ { fn: SHAPE_FN },
417+ );
418+ }
419+ // A vector of npts values, either orientation. A 2-D reshape is refused
420+ // rather than reordered: the tensor's column-major layout would not match
421+ // the grid's phi-fastest rows.
422+ if (value.data.length === npts && value.shape.every((d) => d === 1 || d === npts)) {
423+ return new Float32Array(value.data);
424+ }
425+ throw new ModelCompileError(
426+ `the geometry's '${name}' is ${value.shape.join(' x ')}, but the grid ` +
427+ `wants one value per point (${npts} x 1)`,
428+ { fn: SHAPE_FN },
429+ );
234430 }
235- const staging = device.createBuffer({
236- label: `geometry-read-${name}`,
237- size: 4 * count,
238- usage: GPUBufferUsage.MAP_READ | GPUBufferUsage.COPY_DST,
431+ throw new ModelCompileError(`the geometry's '${name}' is not numeric`, {
432+ fn: SHAPE_FN,
239433 });
240- try {
241- const enc = device.createCommandEncoder({ label: `geometry-read-${name}` });
242- enc.copyBufferToBuffer(buffer, 0, staging, 0, 4 * count);
243- device.queue.submit([enc.finish()]);
244- await staging.mapAsync(GPUMapMode.READ);
245- const out = new Float32Array(staging.getMappedRange().slice(0));
246- staging.unmap();
247- return out;
248- } finally {
249- staging.destroy();
250- }
251434 }
src/geom/metric.tsmodified+77−0View file
@@ -67,3 +67,80 @@ export function computeMetric(
6767
6868 return { Vtx, Vty, Vtz, Vpx, Vpy, Vpz };
6969 }
70+
71+/**
72+ * Flux-form metric weights p1, p2, q2, r of the six-transform Laplace-Beltrami
73+ * scheme (docs/reduced-transforms.md Sec 3). Built from the
74+ * *sin-weighted* theta tangent sin(theta)*X_theta — the undivided synthesis of
75+ * the alpha shift, DerivPlan.sinDtheta — and X_phi, both smooth on the sphere:
76+ *
77+ * gtt~ = |sin(theta) X_theta|^2 (= sin^2(theta) g_tt)
78+ * gtp~ = (sin(theta) X_theta).X_phi (= sin(theta) g_tp)
79+ * gpp = |X_phi|^2
80+ * D = sqrt(gtt~ gpp - gtp~^2) (= sin^2(theta) sqrt(det g) / sin(theta)
81+ * = J sin^2(theta), with J = sqrt(det g)/sin(theta))
82+ *
83+ * p1 = gpp / D, p2 = -gtp~ / D, q2 = gtt~ / D, r = 1 / D.
84+ *
85+ * With these, for A = sin(theta) dtheta(u) and B = dphi(u), the two fluxes
86+ *
87+ * P = p1*A + p2*B, Qtilde = p2*A + q2*B
88+ *
89+ * equal sqrt(det g) g^{theta j} u_j and sin(theta) sqrt(det g) g^{phi j} u_j —
90+ * both smooth on the sphere — and Delta_Gamma u = r * (sin(theta) dtheta(P) +
91+ * dphi(Qtilde)). p1, p2, q2 are bounded (the sin^2 in D cancels against the
92+ * vanishing numerators); r ~ 1/sin^2(theta) is finite at the Gauss nodes and
93+ * is the scheme's one concentrated division (Sec 5 of the doc).
94+ *
95+ * All arithmetic is f64 (JS numbers) regardless of the input arrays' storage
96+ * type; results are rounded to f32 only on upload. That is the doc's "CPU
97+ * precompute in float64" mitigation, inherited for free.
98+ */
99+export interface FluxMetricFields {
100+ p1: Float64Array;
101+ p2: Float64Array;
102+ q2: Float64Array;
103+ r: Float64Array;
104+}
105+
106+/**
107+ * sXt* are the Cartesian components of sin(theta)*X_theta, Xp* those of
108+ * X_phi, all grid space, npts each. No sin(theta) input is needed: every
109+ * division the scheme performs is by D, which the sin-weighted inputs build
110+ * directly.
111+ */
112+export function computeFluxMetric(
113+ npts: number,
114+ sXtx: ArrayLike<number>,
115+ sXty: ArrayLike<number>,
116+ sXtz: ArrayLike<number>,
117+ Xpx: ArrayLike<number>,
118+ Xpy: ArrayLike<number>,
119+ Xpz: ArrayLike<number>,
120+): FluxMetricFields {
121+ const p1 = new Float64Array(npts);
122+ const p2 = new Float64Array(npts);
123+ const q2 = new Float64Array(npts);
124+ const r = new Float64Array(npts);
125+
126+ for (let i = 0; i < npts; i++) {
127+ const xt = sXtx[i];
128+ const yt = sXty[i];
129+ const zt = sXtz[i];
130+ const xp = Xpx[i];
131+ const yp = Xpy[i];
132+ const zp = Xpz[i];
133+
134+ const gtt = xt * xt + yt * yt + zt * zt; // sin^2 g_tt
135+ const gtp = xt * xp + yt * yp + zt * zp; // sin g_tp
136+ const gpp = xp * xp + yp * yp + zp * zp; // g_pp
137+ const D = Math.sqrt(gtt * gpp - gtp * gtp); // J sin^2(theta)
138+
139+ p1[i] = gpp / D;
140+ p2[i] = -gtp / D;
141+ q2[i] = gtt / D;
142+ r[i] = 1 / D;
143+ }
144+
145+ return { p1, p2, q2, r };
146+}
src/geom/registry.tsmodified+14−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 {
@@ -67,7 +68,19 @@ const bumpy: MGeometry = {
6768 source: bumpySource,
6869 };
6970
70-export const mGeometries: MGeometry[] = [sphere, ellipsoid, peanut, bumpy];
71+const blob: MGeometry = {
72+ key: 'blob',
73+ label: 'Blob',
74+ blurb: 'The sphere warped by a smooth random function — a fresh shape per seed.',
75+ params: [
76+ { key: 'amp', label: 'amp', value: 0.5, min: 0, max: 0.8, step: 0.05 },
77+ { key: 'scale', label: 'λ', value: 1, min: 0.5, max: 3, step: 0.1 },
78+ { key: 'seed', label: 'seed', value: 1, min: 0, max: 9999, step: 1, reseed: true },
79+ ],
80+ source: blobSource,
81+};
82+
83+export const mGeometries: MGeometry[] = [sphere, ellipsoid, peanut, bumpy, blob];
7184
7285 export const mGeometryByKey = (key: string): MGeometry | undefined =>
7386 mGeometries.find((g) => g.key === key);
src/main.tsmodified+743−29View file
@@ -8,6 +8,7 @@ import { CodeEditor } from './editor/codeEditor.ts';
88 import {
99 formatCommand,
1010 resolvePreset,
11+ DEFAULT_NITER,
1112 DEFAULT_STEPS,
1213 DEFAULT_WARMUP,
1314 type RunSpec,
@@ -16,7 +17,6 @@ import {
1617 mGeometries,
1718 mGeometryByKey,
1819 defaultGeometryParams,
19- SPHERE_KEY,
2020 DEFAULT_GEOMETRY_KEY,
2121 type MGeometry,
2222 } from './geom/registry.ts';
@@ -28,9 +28,20 @@ import {
2828 type SphereMeshTopology,
2929 } from './render/sphereMesh.ts';
3030 import { SphereScene } from './render/SphereScene.ts';
31-import { Colorbar, fmtValue } from './render/colorbar.ts';
31+import { Colorbar, fmtValue, floorRange } from './render/colorbar.ts';
3232 import { colormaps, colormapNames } from './render/colormaps.ts';
3333 import { MovieRecorder } from './render/movie.ts';
34+import { CompareRun } from './compare/compareRun.ts';
35+import {
36+ crossProduct,
37+ mostResolved,
38+ variantKey,
39+ variantLabel,
40+ type Variant,
41+} from './compare/variants.ts';
42+import { loadReferenceFile } from './compare/referenceFile.ts';
43+import type { ReferenceCase } from './compare/referenceCase.ts';
44+import { generateMatlabScript, MATLAB_SCRIPT_NAME } from './export/matlabScript.ts';
3445
3546 const $ = <T extends HTMLElement>(id: string): T =>
3647 document.getElementById(id) as T;
@@ -43,8 +54,10 @@ const elLmax = $<HTMLSelectElement>('lmax');
4354 const elOversample = $<HTMLSelectElement>('oversample');
4455 const elColormap = $<HTMLSelectElement>('colormap');
4556 const elRunPause = $<HTMLButtonElement>('runpause');
57+const elRestart = $<HTMLButtonElement>('restart');
4658 const elBenchmark = $<HTMLButtonElement>('benchmark');
4759 const elReseed = $<HTMLButtonElement>('reseed');
60+const elLam3 = $<HTMLInputElement>('lam3');
4861 const elResetView = $<HTMLButtonElement>('resetview');
4962 const elMovieToggle = $<HTMLButtonElement>('movietoggle');
5063 const elMovieBar = $('moviebar');
@@ -52,6 +65,20 @@ const elMovieSpeed = $<HTMLSelectElement>('moviespeed');
5265 const elMovieRes = $<HTMLSelectElement>('movieres');
5366 const elMovieRotate = $<HTMLInputElement>('movierotate');
5467 const elMovie = $<HTMLButtonElement>('movie');
68+const elModeSimulate = $<HTMLButtonElement>('mode-simulate');
69+const elModeEffort = $<HTMLButtonElement>('mode-effort');
70+const elModeVsUpload = $<HTMLButtonElement>('mode-vs-upload');
71+const elModeDesc = $('mode-desc');
72+const elCompareBar = $('comparebar');
73+const elCmpNiter = $('cmp-niter');
74+const elCmpLmax = $('cmp-lmax');
75+const elCmpDt = $('cmp-dt');
76+const elCmpRef = $<HTMLSelectElement>('cmp-ref');
77+const elCmpFile = $<HTMLInputElement>('cmp-file');
78+const elCmpFileInfo = $('cmp-fileinfo');
79+const elCmpFileClear = $<HTMLButtonElement>('cmp-fileclear');
80+const elCmpStart = $<HTMLButtonElement>('cmp-start');
81+const elCmpCount = $('cmp-count');
5582 const elParams = $('params');
5683 const elGeomParams = $('geomparams');
5784 const elGeomNote = $('geomnote');
@@ -60,6 +87,10 @@ const elStats = $('stats');
6087 const elBenchResult = $('benchresult');
6188 const elCmd = $('cmd');
6289 const elCopyCmd = $<HTMLButtonElement>('copycmd');
90+const elMatlab = $<HTMLDetailsElement>('matlab');
91+const elMatlabScript = $('matlabscript');
92+const elCopyMatlab = $<HTMLButtonElement>('copymatlab');
93+const elDownloadMatlab = $<HTMLButtonElement>('downloadmatlab');
6394 const elBlurb = $('blurb');
6495 const elErr = $('err');
6596 const elSource = $<HTMLTextAreaElement>('source');
@@ -70,6 +101,21 @@ const elEditorFile = $<HTMLSelectElement>('editor-file');
70101 const elRecompile = $<HTMLButtonElement>('recompile');
71102 const elRevert = $<HTMLButtonElement>('revert');
72103
104+/** The named groups the control area is organized into (index.html's
105+ * `.ctrl-group[data-group]` wrappers). Each mode shows a declared subset of
106+ * these — see MODE_GROUPS and applyModeVisibility below. */
107+const GROUP_NAMES = [
108+ 'surface', 'surface-params', 'solver', 'display',
109+ 'playback', 'benchmark', 'seed', 'movie',
110+] as const;
111+type GroupName = (typeof GROUP_NAMES)[number];
112+const groupEls: Record<GroupName, HTMLElement> = Object.fromEntries(
113+ GROUP_NAMES.map((name) => [
114+ name,
115+ document.querySelector(`.ctrl-group[data-group="${name}"]`) as HTMLElement,
116+ ]),
117+) as Record<GroupName, HTMLElement>;
118+
73119 for (const p of presets) {
74120 const o = document.createElement('option');
75121 o.value = p.key;
@@ -233,6 +279,15 @@ let generation = 0; // bumped on every rebuild to cancel stale pumps
233279 * re-synthesizing. */
234280 let coords: Float32Array | null = null;
235281 let posBuf: Float32Array | null = null;
282+/** The convergence study, when one is running; null in ordinary single-run
283+ * mode. While it is non-null there is no `session`: the study owns one per
284+ * variant, and the panels area is its grid. */
285+let compareRun: CompareRun | null = null;
286+/** `session`'s spectral state as of the last (re-)seed — what "Restart"
287+ * rewinds to. Captured fresh each time a new field is actually established
288+ * (rebuild/reseed), not just once, so Restart reflects the run's current
289+ * starting point rather than permanently the very first draw. */
290+let initialState: Record<string, Float32Array> | null = null;
236291
237292 const source = (): string => editedSource ?? model.source;
238293 const geomSource = (): string => editedGeomSource ?? geometry.source;
@@ -240,6 +295,10 @@ const geomSource = (): string => editedGeomSource ?? geometry.source;
240295 // ---------------------------------------------------------------- UI wiring
241296 function buildParamInputs(): void {
242297 elParams.replaceChildren();
298+ if (model.params.length === 0) return;
299+ const tag = document.createElement('label');
300+ tag.textContent = 'model parameters';
301+ elParams.append(tag);
243302 for (const spec of model.params) {
244303 const label = document.createElement('label');
245304 label.textContent = `${spec.label} `;
@@ -253,8 +312,11 @@ function buildParamInputs(): void {
253312 const v = Number(input.value);
254313 if (Number.isFinite(v)) params[spec.key] = v;
255314 // Parameters are uniforms, not constants baked into the kernels, so a
256- // change costs an upload rather than a recompile.
315+ // change costs an upload rather than a recompile. In compare mode `dt`
316+ // is the *base* timestep each variant's divisor divides, so the study
317+ // re-derives every variant's dt from it.
257318 session?.setParams(params);
319+ compareRun?.setParams(params);
258320 updateCommand();
259321 });
260322 label.append(input);
@@ -272,9 +334,32 @@ function buildGeomParamInputs(): void {
272334 elGeomParams.replaceChildren();
273335 if (geometry.params.length === 0) return;
274336 const tag = document.createElement('label');
275- tag.textContent = `${geometry.key}.m`;
337+ tag.textContent = 'geometry parameters';
276338 elGeomParams.append(tag);
277339 for (const spec of geometry.params) {
340+ // A random seed picks a draw and means nothing on its own, so it gets a
341+ // button to the next one rather than a box to type a number into. The
342+ // shape changes; the simulation running on it does not restart.
343+ if (spec.reseed) {
344+ const button = document.createElement('button');
345+ button.textContent = 'Re-seed shape';
346+ button.title =
347+ `Draw another ${geometry.label.toLowerCase()} — a new random surface, ` +
348+ `leaving the pattern running on it alone.`;
349+ button.addEventListener('click', () => {
350+ const span = spec.max - spec.min;
351+ let next = geomParams[spec.key];
352+ // Never hand back the shape that is already on screen.
353+ while (next === geomParams[spec.key]) {
354+ next = spec.min + Math.floor(Math.random() * (span + 1));
355+ }
356+ geomParams[spec.key] = next;
357+ updateCommand();
358+ viewChange = viewChange.then(() => applyGeometry());
359+ });
360+ elGeomParams.append(button);
361+ continue;
362+ }
278363 const label = document.createElement('label');
279364 label.textContent = `${spec.label} `;
280365 const input = document.createElement('input');
@@ -286,6 +371,7 @@ function buildGeomParamInputs(): void {
286371 input.addEventListener('change', () => {
287372 const v = Number(input.value);
288373 if (Number.isFinite(v)) geomParams[spec.key] = v;
374+ updateCommand();
289375 viewChange = viewChange.then(() => applyGeometry());
290376 });
291377 label.append(input);
@@ -320,6 +406,8 @@ function applyGeometryChoice(key: string): void {
320406 editedGeomSource = null;
321407 buildGeomParamInputs();
322408 showEditorFile();
409+ // The command line and the MATLAB export both bake the surface in.
410+ updateCommand();
323411 }
324412
325413 /** Load the chosen file into the editor, keeping any unsaved edit to it. */
@@ -334,23 +422,70 @@ function showEditorFile(): void {
334422 }
335423 }
336424
337-/** The run currently on screen, as the benchmark's RunSpec. */
425+/**
426+ * The run currently on screen, as the benchmark's RunSpec. While a study is
427+ * running there is no single run, so this describes its *reference* variant —
428+ * the one the other rows are measured against, and the only one of them whose
429+ * numbers mean anything on their own.
430+ */
338431 function currentSpec(): RunSpec {
432+ const ref = compareRun?.variants[compareRefIndex()];
433+ const dt = ref ? { dt: (params.dt ?? 0) / ref.dtDiv } : null;
339434 return {
340435 preset: elModel.value,
341- lmax: Number(elLmax.value),
436+ lmax: ref ? ref.lmax : Number(elLmax.value),
342437 seed,
343438 steps: DEFAULT_STEPS,
344439 warmup: DEFAULT_WARMUP,
345- params,
440+ params: dt ? { ...params, ...dt } : params,
346441 geometry: geometry.key,
347442 geometryParams: geomParams,
348- niter: Number(elNiter.value),
443+ niter: ref ? ref.niter : Number(elNiter.value),
349444 };
350445 }
351446
352447 function updateCommand(): void {
353- elCmd.textContent = formatCommand(currentSpec());
448+ // A study against a reference file replays the file, so its desktop
449+ // equivalent is the ref checker, not the benchmark.
450+ if (compareRun?.refFile) {
451+ elCmd.textContent = `npm run ref -- --in ${compareRun.refFile.label}`;
452+ } else {
453+ elCmd.textContent = formatCommand(currentSpec());
454+ }
455+ if (elMatlab.open) refreshMatlabScript();
456+}
457+
458+/** The run on screen as one standalone .m — in a study, its reference
459+ * variant, the same choice `currentSpec` makes. Throws on a working copy
460+ * the export cannot parse (no init/step/shape function). */
461+function matlabScriptText(): string {
462+ const spec = currentSpec();
463+ return generateMatlabScript({
464+ model,
465+ modelSource: source(),
466+ params: spec.params,
467+ geometry,
468+ geometrySource: geomSource(),
469+ geometryParams: geomParams,
470+ lmax: spec.lmax,
471+ niter: spec.niter,
472+ lam3: Number(elLam3.value),
473+ seed,
474+ preset: spec.preset,
475+ command: formatCommand(spec),
476+ });
477+}
478+
479+/** Regenerate the visible script text; on failure, show why in its place. */
480+function refreshMatlabScript(): string | null {
481+ try {
482+ const text = matlabScriptText();
483+ elMatlabScript.textContent = text;
484+ return text;
485+ } catch (e) {
486+ elMatlabScript.textContent = e instanceof Error ? e.message : String(e);
487+ return null;
488+ }
354489 }
355490
356491 elModel.addEventListener('change', () => {
@@ -366,23 +501,69 @@ elNiter.addEventListener('change', () => void rebuild());
366501 // one chain: a rapid second change waits its turn.
367502 let viewChange = Promise.resolve();
368503 elOversample.addEventListener('change', () => {
504+ // The study picks its own display grid — one grid common to every variant is
505+ // what makes their fields comparable — so this control is inert (and
506+ // disabled) while one is running.
507+ if (compareRun) return;
369508 viewChange = viewChange.then(() => applyOversample());
370509 });
371510 elGeometry.addEventListener('change', () => {
372511 applyGeometryChoice(elGeometry.value);
373512 viewChange = viewChange.then(() => applyGeometry());
374513 });
514+// The seed field's wavelength: a uniform plus a host-side redraw, so it
515+// reseeds the run in place rather than recompiling it. Too small a value asks
516+// for more Fourier modes than the table holds, which `drawModes` refuses —
517+// report that like any other failure instead of leaving the run half-seeded.
518+elLam3.addEventListener('change', () => {
519+ const v = Number(elLam3.value);
520+ if (!Number.isFinite(v) || v <= 0) return;
521+ // Not in the bench command, but the MATLAB export bakes lam3 in.
522+ updateCommand();
523+ // Changing the wavelength redraws the field, which restarts the run — so
524+ // pause first, exactly as the Re-seed button does. Without it the reseed's
525+ // readback races the pump's own, and the two collide on the staging buffer.
526+ setRunning(false);
527+ viewChange = viewChange.then(async () => {
528+ // A study seeds every variant from one field at one wavelength, so this is
529+ // the same control there — set on each variant, redrawn by the one reseed.
530+ const target = compareRun ?? session;
531+ if (!target) return;
532+ const previous = target.lam3;
533+ try {
534+ target.setLam3(v);
535+ await reseed();
536+ elErr.textContent = '';
537+ } catch (e) {
538+ // Too fine a wavelength asks for more Fourier modes than the table
539+ // holds. Put the working value back rather than leaving the run seeded
540+ // from a field that was never drawn.
541+ elErr.textContent = e instanceof Error ? e.message : String(e);
542+ target.setLam3(previous);
543+ elLam3.value = String(previous);
544+ await reseed();
545+ }
546+ });
547+});
375548 // Morph is pure rendering: no readback, no GPU work, just the vertex buffer.
376549 elMorph.addEventListener('input', () => {
377550 morph = Number(elMorph.value);
378- applyMorph();
551+ if (compareRun) compareRun.setMorph(morph);
552+ else applyMorph();
553+});
554+elColormap.addEventListener('change', () => {
555+ if (compareRun) void compareRun.draw();
556+ else void draw();
379557 });
380-elColormap.addEventListener('change', () => void draw());
381558 elEditorFile.addEventListener('change', () => showEditorFile());
382559
383560 function setRunning(next: boolean): void {
384561 running = next;
385562 elRunPause.textContent = running ? 'Pause' : 'Run';
563+ if (compareRun) {
564+ compareRun.setRunning(next);
565+ return;
566+ }
386567 if (running) void pump();
387568 }
388569
@@ -394,7 +575,12 @@ elReseed.addEventListener('click', () => {
394575 updateCommand();
395576 void reseed();
396577 });
578+elRestart.addEventListener('click', () => {
579+ setRunning(false);
580+ void restart();
581+});
397582 elResetView.addEventListener('click', () => {
583+ compareRun?.resetView();
398584 for (const s of scenes) s.resetCamera();
399585 });
400586 elMovieToggle.addEventListener('click', () => {
@@ -437,6 +623,46 @@ elCopyCmd.addEventListener('click', () => {
437623 navigator.clipboard.writeText(text).then(() => flash('Copied'), selectCommand);
438624 });
439625
626+// The MATLAB export: the same run as one self-contained .m. Regenerated from
627+// the current UI state whenever it is shown, copied or downloaded, so the
628+// text always matches the run on screen.
629+elMatlab.addEventListener('toggle', () => {
630+ if (elMatlab.open) refreshMatlabScript();
631+});
632+elCopyMatlab.addEventListener('click', (e) => {
633+ // The buttons live inside the <summary>; without this a click also toggles.
634+ e.preventDefault();
635+ e.stopPropagation();
636+ const text = refreshMatlabScript();
637+ if (text === null || !navigator.clipboard) {
638+ // Open to show the error, or the text to select by hand.
639+ elMatlab.open = true;
640+ return;
641+ }
642+ navigator.clipboard.writeText(text).then(
643+ () => {
644+ elCopyMatlab.textContent = 'Copied';
645+ setTimeout(() => (elCopyMatlab.textContent = 'Copy'), 1200);
646+ },
647+ () => (elMatlab.open = true),
648+ );
649+});
650+elDownloadMatlab.addEventListener('click', (e) => {
651+ e.preventDefault();
652+ e.stopPropagation();
653+ const text = refreshMatlabScript();
654+ if (text === null) {
655+ elMatlab.open = true;
656+ return;
657+ }
658+ const url = URL.createObjectURL(new Blob([text], { type: 'text/x-matlab' }));
659+ const a = document.createElement('a');
660+ a.href = url;
661+ a.download = `${MATLAB_SCRIPT_NAME}.m`;
662+ a.click();
663+ URL.revokeObjectURL(url);
664+});
665+
440666 // ---------------------------------------------------------------- setup
441667 function disposeView(): void {
442668 for (const s of scenes) s.dispose();
@@ -547,6 +773,10 @@ async function applyOversample(): Promise<void> {
547773 * can be changed mid-run. Only the mesh is rebuilt.
548774 */
549775 async function applyGeometry(): Promise<void> {
776+ // The in-place swap below is a single session's trick. Each variant carries
777+ // the surface band-limited at its own lmax, and the study's meshes are built
778+ // from those, so a shape change goes through the full rebuild instead.
779+ if (compareRun) return rebuildCompare();
550780 if (!session) return;
551781 const gen = generation;
552782 const wasRunning = running;
@@ -581,18 +811,19 @@ function applyMorph(): void {
581811 for (const s of scenes) s.updatePositions(posBuf);
582812 }
583813
584-/** What the surface is, and the standing caveat about where it is not. */
814+/** What the surface is, and how far it departs from the sphere. */
585815 function updateGeomNote(): void {
586- if (!session) {
816+ // In compare mode each variant carries the surface band-limited at its own
817+ // lmax; the reference's is the one quoted, as everywhere else.
818+ const s = session ?? compareRun?.referenceSession ?? null;
819+ if (!s) {
587820 elGeomNote.textContent = '';
588821 return;
589822 }
590- const { lo, hi } = session.geometry.radiusRange();
591- const isSphere = session.geometryModel.key === SPHERE_KEY;
823+ const { lo, hi } = s.geometry.radiusRange();
592824 elGeomNote.innerHTML =
593- `<b>${session.geometryModel.label}</b> — ${session.geometryModel.blurb} ` +
594- `Radius ${lo.toFixed(3)}–${hi.toFixed(3)}.` +
595- (isSphere ? '' : ' <b>Rendered only</b> — not yet in the operator.');
825+ `<b>${s.geometryModel.label}</b> — ${s.geometryModel.blurb} ` +
826+ `Radius ${lo.toFixed(3)}–${hi.toFixed(3)}.`;
596827 }
597828
598829 /** Report a compile failure, and select the offending text in the editor. */
@@ -605,6 +836,10 @@ function reportCompileError(e: unknown): void {
605836 }
606837
607838 async function rebuild(): Promise<void> {
839+ // A study is several runs, so "rebuild the run" means rebuild all of them.
840+ // Everything that recompiles — a model or preset change, an edit to either
841+ // .m, a revert — arrives here, and none of it needs to know which mode is up.
842+ if (compareRun) return rebuildCompare();
608843 generation++;
609844 const gen = generation;
610845 setRunning(false);
@@ -635,6 +870,7 @@ async function rebuild(): Promise<void> {
635870 geometryParams: geomParams,
636871 geometrySource: geomSource(),
637872 niter: Number(elNiter.value),
873+ lam3: Number(elLam3.value),
638874 });
639875 } catch (e) {
640876 reportCompileError(e);
@@ -642,7 +878,10 @@ async function rebuild(): Promise<void> {
642878 }
643879 if (gen !== generation) return;
644880
645- session.seed(seed);
881+ await session.seed(seed);
882+ if (gen !== generation) return;
883+ initialState = await session.readState();
884+ if (gen !== generation) return;
646885
647886 const plan = session.describe();
648887 elCompiled.textContent =
@@ -670,9 +909,14 @@ async function rebuild(): Promise<void> {
670909 }
671910
672911 async function reseed(): Promise<void> {
912+ // One new perturbation for the whole study, band-limited at its coarsest
913+ // variant and evaluated on each grid — see src/compare/sharedStart.ts.
914+ if (compareRun) return compareRun.reseed(seed);
673915 if (!session) return;
674916 const gen = generation;
675- session.seed(seed);
917+ await session.seed(seed);
918+ if (gen !== generation) return;
919+ initialState = await session.readState();
676920 if (gen !== generation) return;
677921 for (const r of ranges) {
678922 r.lo = NaN;
@@ -682,6 +926,22 @@ async function reseed(): Promise<void> {
682926 updateStats();
683927 }
684928
929+/** Rewind to the field this run is currently starting from — the last
930+ * (re-)seed, not necessarily the very first one — without drawing a new
931+ * one. Unlike reseed(), the seed value and lam3 are untouched, so nothing
932+ * the CLI command line encodes changes. */
933+async function restart(): Promise<void> {
934+ if (compareRun) return compareRun.restart();
935+ if (!session || !initialState) return;
936+ session.loadState(initialState);
937+ for (const r of ranges) {
938+ r.lo = NaN;
939+ r.hi = NaN;
940+ }
941+ await draw();
942+ updateStats();
943+}
944+
685945 // ---------------------------------------------------------------- drawing
686946 async function draw(): Promise<void> {
687947 if (!session || !topo) return;
@@ -717,14 +977,13 @@ async function draw(): Promise<void> {
717977 r.lo += a * (lo - r.lo);
718978 r.hi += a * (hi - r.hi);
719979 }
720- if (r.hi - r.lo < 1e-9) {
721- const mid = (r.hi + r.lo) / 2;
722- r.lo = mid - 5e-10;
723- r.hi = mid + 5e-10;
724- }
725- fillColors(colorBufs[k], valueBufs[k], r.lo, r.hi, cmap);
980+ // A field that is uniform to fp32 precision — Schnakenberg's v at t = 0 is
981+ // exactly constant — would otherwise have the colormap stretched across its
982+ // roundoff and be drawn as vivid noise. See floorRange.
983+ const shown = floorRange(r.lo, r.hi);
984+ fillColors(colorBufs[k], valueBufs[k], shown.lo, shown.hi, cmap);
726985 scenes[k]?.updateColors(colorBufs[k]);
727- colorbars[k]?.update(cmap, r.lo, r.hi);
986+ colorbars[k]?.update(cmap, shown.lo, shown.hi);
728987 }
729988 }
730989
@@ -876,7 +1135,7 @@ function submitSteps(n: number): void {
8761135 function setMovieUi(on: boolean): void {
8771136 const locked = [
8781137 elModel, elGeometry, elMorph, elNiter, elLmax, elOversample, elColormap,
879- elRunPause, elBenchmark, elReseed, elRecompile, elRevert, elEditorFile,
1138+ elRunPause, elRestart, elBenchmark, elReseed, elRecompile, elRevert, elEditorFile,
8801139 elMovieSpeed, elMovieRes, elMovieRotate, elMovieToggle,
8811140 ];
8821141 for (const el of locked) el.disabled = on;
@@ -957,7 +1216,7 @@ async function recordMovie(): Promise<void> {
9571216 try {
9581217 // Reset the color-range smoothing as a re-seed does, so the shading
9591218 // evolves in the movie the way it did live.
960- session.seed(seed);
1219+ await session.seed(seed);
9611220 seeded = true;
9621221 for (const r of ranges) {
9631222 r.lo = NaN;
@@ -1029,9 +1288,464 @@ async function recordMovie(): Promise<void> {
10291288 }
10301289 }
10311290
1291+// ---------------------------------------------------------------- compare
1292+/**
1293+ * Comparing several solver settings at once.
1294+ *
1295+ * Deliberately a mode rather than a widening of the ordinary controls: the
1296+ * single-run path above is untouched, and with the bar closed nothing about
1297+ * using this page has changed. Opening it and pressing Compare tears down the
1298+ * one session and hands the panels area to a CompareRun, which owns a session
1299+ * per variant; pressing it again puts the single run back.
1300+ *
1301+ * The ceilings below are not arbitrary. Each variant compiles its whole
1302+ * unrolled step with no pipeline cache between sessions (a solve iteration is
1303+ * ~15 kernels per species), so the variant count is what you wait for; and
1304+ * each panel is a WebGL context and a full mesh, so the panel count is what
1305+ * the browser has to keep alive at once.
1306+ */
1307+const MAX_VARIANTS = 6;
1308+const MAX_PANELS = 12;
1309+/** dt divisors. Powers of two so that dtBase/K is exact in binary and every
1310+ * variant lands on the same model time with no accumulated drift. */
1311+const DT_DIVISORS = [1, 2, 4, 8];
1312+
1313+/**
1314+ * What the bar opens on: the default iteration count against the next step up,
1315+ * at the default band. Two variants, so the first study is quick to compile,
1316+ * and it asks the question the control exists for — is the default already
1317+ * converged? A flat, low curve says yes; one that climbs says the answer is
1318+ * still moving at niter 8 and the default is not enough for this shape.
1319+ */
1320+const cmpSelected = {
1321+ niter: new Set<number>([DEFAULT_NITER, 2 * DEFAULT_NITER]),
1322+ lmax: new Set<number>([63]),
1323+ dt: new Set<number>([1]),
1324+};
1325+
1326+/** A row of toggle chips backed by a Set. At least one stays selected — an
1327+ * empty axis has no meaning here, and silently falling back to a default
1328+ * would hide which values are actually being run. */
1329+function buildChips(host: HTMLElement, values: number[], selected: Set<number>, label: (v: number) => string): void {
1330+ host.replaceChildren();
1331+ for (const value of values) {
1332+ const chip = document.createElement('button');
1333+ chip.type = 'button';
1334+ chip.className = 'chip';
1335+ chip.textContent = label(value);
1336+ const paint = (): void => chip.setAttribute('aria-pressed', String(selected.has(value)));
1337+ paint();
1338+ chip.addEventListener('click', () => {
1339+ if (selected.has(value)) {
1340+ if (selected.size === 1) return;
1341+ selected.delete(value);
1342+ } else {
1343+ selected.add(value);
1344+ }
1345+ paint();
1346+ refreshVariants();
1347+ });
1348+ host.append(chip);
1349+ }
1350+}
1351+
1352+const cmpVariants = (): Variant[] =>
1353+ crossProduct([...cmpSelected.niter], [...cmpSelected.lmax], [...cmpSelected.dt]);
1354+
1355+/**
1356+ * A loaded reference file, or null. While one is loaded the study checks the
1357+ * variants against it instead of against each other: the file defines the
1358+ * whole problem (model, parameters, geometry, initial state, end time), so
1359+ * the page's own model and geometry choices do not enter the study at all —
1360+ * only the solver knobs above do.
1361+ */
1362+let refCase: ReferenceCase | null = null;
1363+
1364+/** The reference the user picked, clamped to the current variant list. */
1365+let cmpRefKey = '';
1366+
1367+/** Index of the reference in the current variant list, never negative. */
1368+function compareRefIndex(): number {
1369+ const i = cmpVariants().map(variantKey).indexOf(cmpRefKey);
1370+ return i < 0 ? 0 : i;
1371+}
1372+
1373+function refreshVariants(): void {
1374+ const variants = cmpVariants();
1375+ const showDt = cmpSelected.dt.size > 1;
1376+ // With a file loaded the study's model is the file's, and its final state
1377+ // is one more row of panels.
1378+ const cmpModel = refCase?.model ?? model;
1379+ const rowCount = variants.length + (refCase ? 1 : 0);
1380+ const panels = rowCount * cmpModel.species.length;
1381+
1382+ const prev = cmpRefKey;
1383+ elCmpRef.replaceChildren();
1384+ if (refCase) {
1385+ // The file is the reference; the pick among variants means nothing here.
1386+ const o = document.createElement('option');
1387+ o.textContent = `the file's final state`;
1388+ elCmpRef.append(o);
1389+ elCmpRef.disabled = true;
1390+ } else {
1391+ elCmpRef.disabled = false;
1392+ for (const v of variants) {
1393+ const o = document.createElement('option');
1394+ o.value = variantKey(v);
1395+ o.textContent = variantLabel(v, showDt);
1396+ elCmpRef.append(o);
1397+ }
1398+ const keys = variants.map(variantKey);
1399+ cmpRefKey = keys.includes(prev) ? prev : keys[mostResolved(variants)];
1400+ elCmpRef.value = cmpRefKey;
1401+ }
1402+
1403+ const tooMany =
1404+ variants.length > MAX_VARIANTS
1405+ ? `${variants.length} variants — at most ${MAX_VARIANTS}`
1406+ : panels > MAX_PANELS
1407+ ? `${panels} panels — at most ${MAX_PANELS}`
1408+ : '';
1409+ elCmpCount.textContent = tooMany
1410+ ? `too many: ${tooMany}`
1411+ : `${variants.length} variant${variants.length === 1 ? '' : 's'}` +
1412+ `${refCase ? ' + the file' : ''} × ` +
1413+ `${cmpModel.species.length} species = ${panels} panels`;
1414+ elCmpCount.style.color = tooMany ? '#b35900' : '';
1415+ elCmpStart.disabled = tooMany !== '' && compareRun === null;
1416+}
1417+
1418+/**
1419+ * The niter chips on offer. A loaded reference file adds its own recorded
1420+ * iteration count if the standard list lacks it, so the file's settings are
1421+ * always selectable; clearing the file drops any selection outside the
1422+ * standard list again.
1423+ */
1424+function rebuildNiterChips(): void {
1425+ const all = [...elNiter.options].map((o) => Number(o.value));
1426+ let values = all;
1427+ if (refCase && !all.includes(refCase.niter)) {
1428+ values = [...all, refCase.niter].sort((a, b) => a - b);
1429+ }
1430+ if (!refCase) {
1431+ for (const v of [...cmpSelected.niter]) if (!values.includes(v)) cmpSelected.niter.delete(v);
1432+ if (cmpSelected.niter.size === 0) cmpSelected.niter.add(DEFAULT_NITER);
1433+ }
1434+ buildChips(elCmpNiter, values, cmpSelected.niter, String);
1435+}
1436+
1437+/**
1438+ * The lmax chips on offer. A loaded reference file floors them at its own
1439+ * band: a variant below it could not even hold the file's initial state
1440+ * (prolongation only widens), so those values are not offered rather than
1441+ * offered and refused.
1442+ */
1443+function rebuildLmaxChips(): void {
1444+ const all = [...elLmax.options].map((o) => Number(o.value));
1445+ let values = all;
1446+ if (refCase) {
1447+ const floor = refCase.lmax;
1448+ values = all.filter((v) => v >= floor);
1449+ if (!values.includes(floor)) values = [floor, ...values];
1450+ for (const v of [...cmpSelected.lmax]) if (!values.includes(v)) cmpSelected.lmax.delete(v);
1451+ if (cmpSelected.lmax.size === 0) cmpSelected.lmax.add(floor);
1452+ }
1453+ buildChips(elCmpLmax, values, cmpSelected.lmax, String);
1454+}
1455+
1456+rebuildNiterChips();
1457+rebuildLmaxChips();
1458+buildChips(elCmpDt, DT_DIVISORS, cmpSelected.dt, (v) => (v === 1 ? 'dt' : `dt/${v}`));
1459+refreshVariants();
1460+
1461+elCmpRef.addEventListener('change', () => {
1462+ cmpRefKey = elCmpRef.value;
1463+ if (compareRun) void rebuildCompare();
1464+});
1465+
1466+/** Reflect the loaded (or cleared) reference file in the compare bar. */
1467+function applyRefUi(): void {
1468+ elCmpFileInfo.hidden = elCmpFileClear.hidden = refCase === null;
1469+ if (refCase) {
1470+ const rc = refCase;
1471+ const geomParamText = rc.geometry.params
1472+ .map((p) => `${p.key}=${rc.geometryParams[p.key]}`)
1473+ .join(' ');
1474+ const name = document.createElement('b');
1475+ name.textContent = rc.label;
1476+ const info = document.createElement('span');
1477+ info.textContent =
1478+ ` — ${rc.model.label} on ${rc.geometry.label.toLowerCase()}` +
1479+ (geomParamText ? ` (${geomParamText})` : '') +
1480+ `, lmax ${rc.lmax}, T = ${(rc.steps * (rc.params.dt ?? 0)).toFixed(2)}` +
1481+ ` (${rc.steps} × dt ${rc.params.dt})`;
1482+ elCmpFileInfo.replaceChildren(name, info);
1483+ }
1484+ rebuildNiterChips();
1485+ rebuildLmaxChips();
1486+ refreshVariants();
1487+}
1488+
1489+/**
1490+ * The four top-level modes and which control groups each shows (see
1491+ * GROUP_NAMES/groupEls above; `.ctrl-group` wrappers in index.html).
1492+ * `currentMode` tracks which configuration is on screen — the compare bar
1493+ * being open, and in which flavor — not whether a study has actually been
1494+ * started inside it. That match matters: without it, opening the bar
1495+ * (which already shows the right groups) leaves its top-row button
1496+ * unhighlighted until a study happens to start, which is inconsistent with
1497+ * `vs-upload`'s one-click flow and reads as broken.
1498+ */
1499+type Mode = 'simulate' | 'compute-effort' | 'vs-sphere' | 'vs-upload';
1500+let currentMode: Mode = 'simulate';
1501+
1502+const MODE_GROUPS: Record<Mode, readonly GroupName[]> = {
1503+ simulate: ['surface', 'surface-params', 'solver', 'display', 'playback', 'benchmark', 'seed', 'movie'],
1504+ 'compute-effort': ['surface', 'surface-params', 'display', 'playback', 'seed'],
1505+ 'vs-sphere': [], // unreachable — the button is disabled, no listener ever calls setMode with this
1506+ // No `seed` here: nothing in that group does anything useful against a
1507+ // loaded file (lam3 is silently absorbed, and Restart already covers what
1508+ // Re-seed would otherwise be doing — reloading the file's fixed initial
1509+ // state) — see CompareRun.restart().
1510+ 'vs-upload': ['display', 'playback'],
1511+};
1512+
1513+const MODE_DESCRIPTIONS: Record<Mode, string> = {
1514+ simulate:
1515+ 'This mode runs one standalone reaction-diffusion solver.',
1516+ 'compute-effort':
1517+ 'When we change the computational effort of the solver by varying solve iterations, lmax, or timestep, ' +
1518+ 'how does the solution change? Find out by running several ' +
1519+ 'so you can see how each setting trades accuracy for speed.',
1520+ 'vs-sphere': '',
1521+ 'vs-upload':
1522+ 'Load a saved reference run (an .h5 file) and run this solver to the ' +
1523+ 'same physical end time from the same initial condition, to check how ' +
1524+ 'closely it reproduces the reference. You can adjust the solver settings ' +
1525+ 'to see how they affect the outcome.',
1526+};
1527+
1528+function setModeButtons(mode: Mode): void {
1529+ elModeSimulate.setAttribute('aria-pressed', String(mode === 'simulate'));
1530+ elModeEffort.setAttribute('aria-pressed', String(mode === 'compute-effort'));
1531+ elModeVsUpload.setAttribute('aria-pressed', String(mode === 'vs-upload'));
1532+ elModeDesc.textContent = MODE_DESCRIPTIONS[mode];
1533+}
1534+
1535+/** Show exactly the groups `mode` declares; hide the rest. */
1536+function applyModeVisibility(mode: Mode): void {
1537+ currentMode = mode;
1538+ const shown = new Set<GroupName>(MODE_GROUPS[mode]);
1539+ for (const name of GROUP_NAMES) groupEls[name].hidden = !shown.has(name);
1540+ setModeButtons(mode);
1541+}
1542+
1543+/**
1544+ * Enter `mode`: groups, top-row buttons, and the compare bar's own
1545+ * visibility (open for the two compare flavors, closed for Simulate).
1546+ * Doesn't touch `compareRun`/`refCase` or start/stop a study — callers
1547+ * decide that; this only decides what's on screen, and it decides it
1548+ * immediately, so the button you clicked lights up right away rather than
1549+ * waiting on a study that may not exist yet (or may never start, if the
1550+ * bar's own Compare is never pressed).
1551+ */
1552+function enterMode(mode: Mode): void {
1553+ applyModeVisibility(mode);
1554+ elCompareBar.hidden = mode === 'simulate';
1555+}
1556+
1557+/** Entering a mode from the top row. */
1558+function setMode(mode: Mode): void {
1559+ if (mode === 'vs-sphere') return; // unreachable — button is disabled
1560+ if (mode === 'simulate') {
1561+ if (compareRun) void stopCompare();
1562+ enterMode('simulate');
1563+ return;
1564+ }
1565+ if (mode === 'compute-effort') {
1566+ // Tear down whatever study is running first (mirrors Simulate above) —
1567+ // stopCompare's synchronous prefix disposes it and nulls `compareRun`
1568+ // before its first `await`, so `refCase` is safe to drop right after.
1569+ if (compareRun) void stopCompare();
1570+ if (refCase) {
1571+ refCase = null;
1572+ applyRefUi();
1573+ }
1574+ enterMode('compute-effort');
1575+ return;
1576+ }
1577+ // vs-upload: opens the file picker; entering the mode itself happens once
1578+ // a file is actually chosen (elCmpFile's change handler below) — not here,
1579+ // since cancelling the dialog must leave the current mode untouched.
1580+ elCmpFile.click();
1581+}
1582+
1583+elModeSimulate.addEventListener('click', () => setMode('simulate'));
1584+elModeEffort.addEventListener('click', () => setMode('compute-effort'));
1585+elModeVsUpload.addEventListener('click', () => setMode('vs-upload'));
1586+
1587+elCmpFile.addEventListener('change', () => {
1588+ const file = elCmpFile.files?.[0];
1589+ // Cleared so picking the same file again still fires a change event.
1590+ elCmpFile.value = '';
1591+ if (!file) return;
1592+ void (async () => {
1593+ try {
1594+ refCase = await loadReferenceFile(file);
1595+ elErr.textContent = '';
1596+ } catch (e) {
1597+ refCase = null;
1598+ elErr.textContent = `reference file ${file.name}: ${e instanceof Error ? e.message : e}`;
1599+ applyRefUi();
1600+ return;
1601+ }
1602+ // One click, one study: the file's own settings become the single
1603+ // variant — its recorded niter, its band, its dt undivided — and the
1604+ // comparison opens on them, paused at the initial state so what runs is
1605+ // the user's choice. (Widening it is: teardown the comparison, pick more
1606+ // chips, compile it again — the file stays loaded.)
1607+ cmpSelected.niter.clear();
1608+ cmpSelected.niter.add(refCase.niter);
1609+ cmpSelected.lmax.clear();
1610+ cmpSelected.lmax.add(refCase.lmax);
1611+ cmpSelected.dt.clear();
1612+ cmpSelected.dt.add(1);
1613+ applyRefUi();
1614+ enterMode('vs-upload');
1615+ if (compareRun) {
1616+ // A study is already up (this one loaded over it): same teardown as
1617+ // rebuildCompare, then the new file's study takes its place.
1618+ compareRun.dispose();
1619+ compareRun = null;
1620+ setCompareUi(false);
1621+ }
1622+ await startCompare();
1623+ })();
1624+});
1625+elCmpFileClear.addEventListener('click', () => {
1626+ refCase = null;
1627+ applyRefUi();
1628+ // The bar stays open — this only drops back to the plain chip comparison.
1629+ // Only reachable while idle (elCmpFileClear is disabled during a study).
1630+ enterMode('compute-effort');
1631+});
1632+
1633+elCmpStart.addEventListener('click', () => {
1634+ if (compareRun) void stopCompare();
1635+ else void startCompare();
1636+});
1637+
1638+/**
1639+ * Controls the study supersedes or cannot honour while it is running.
1640+ * Mode/group/button state is not this function's job — that's set the
1641+ * moment a mode is entered (enterMode, above), independent of whether a
1642+ * study inside it has actually started or stopped.
1643+ */
1644+function setCompareUi(on: boolean): void {
1645+ // A study picks its own display grid, so oversample stays individually
1646+ // disabled inside the still-visible display group; and clearing a loaded
1647+ // file out from under a running study would leave it checking against one
1648+ // that no longer exists.
1649+ elOversample.disabled = on;
1650+ elCmpFileClear.disabled = on;
1651+ elCmpNiter.querySelectorAll('button').forEach((b) => (b.disabled = on));
1652+ elCmpLmax.querySelectorAll('button').forEach((b) => (b.disabled = on));
1653+ elCmpDt.querySelectorAll('button').forEach((b) => (b.disabled = on));
1654+ elCmpStart.textContent = on ? 'Teardown comparison' : 'Compile comparison';
1655+ // The movie bar's own hidden flag is independent of the movie *group's* —
1656+ // force it closed so it doesn't reappear open once the group is shown
1657+ // again on returning to Simulate.
1658+ if (on) elMovieBar.hidden = true;
1659+}
1660+
1661+async function startCompare(): Promise<void> {
1662+ if (compareRun || !device) return;
1663+ // Snapshotted for the whole study: `refCase` only changes with no study up
1664+ // (clearing is disabled during one, and loading tears it down first).
1665+ const rc = refCase;
1666+ const cmpModel = rc?.model ?? model;
1667+ const variants = cmpVariants();
1668+ const rowCount = variants.length + (rc ? 1 : 0);
1669+ if (variants.length > MAX_VARIANTS || rowCount * cmpModel.species.length > MAX_PANELS) {
1670+ return;
1671+ }
1672+ // Take down the single run first: its pump, its scenes, its session. The
1673+ // generation bump makes any readback already in flight drop its result.
1674+ generation++;
1675+ setRunning(false);
1676+ while (pumping) await nextFrame();
1677+ disposeView();
1678+ session?.destroy();
1679+ session = null;
1680+ elBenchResult.textContent = '';
1681+ elErr.textContent = '';
1682+ setCompareUi(true);
1683+
1684+ try {
1685+ // Against a reference file, the problem is the file's — its model,
1686+ // parameters and geometry, from the registry sources (the editor's
1687+ // working copies describe the page's run, not the file's).
1688+ compareRun = await CompareRun.create({
1689+ device,
1690+ model: cmpModel,
1691+ params: rc ? rc.params : params,
1692+ source: rc ? rc.model.source : source(),
1693+ geometry: rc ? rc.geometry : geometry,
1694+ geometryParams: rc ? rc.geometryParams : geomParams,
1695+ geometrySource: rc ? rc.geometry.source : geomSource(),
1696+ variants,
1697+ reference: rc ? 0 : compareRefIndex(),
1698+ refFile: rc ?? undefined,
1699+ onFinished: () => setRunning(false),
1700+ seed,
1701+ lam3: rc ? undefined : Number(elLam3.value),
1702+ morph,
1703+ colormapName: () => elColormap.value,
1704+ container: elPanels,
1705+ onStatus: (html) => (elStats.innerHTML = html),
1706+ });
1707+ } catch (e) {
1708+ compareRun = null;
1709+ setCompareUi(false);
1710+ refreshVariants();
1711+ reportCompileError(e);
1712+ await rebuild();
1713+ return;
1714+ }
1715+ updateGeomNote();
1716+ // The command describes the reference variant, which only exists now.
1717+ updateCommand();
1718+ elRunPause.textContent = 'Run';
1719+}
1720+
1721+async function stopCompare(): Promise<void> {
1722+ if (!compareRun) return;
1723+ compareRun.dispose();
1724+ compareRun = null;
1725+ setCompareUi(false);
1726+ refreshVariants();
1727+ elStats.textContent = '';
1728+ await rebuild();
1729+}
1730+
1731+/** Rebuild the study in place — after a model, geometry, source or reference
1732+ * change. Same teardown as stopping, without leaving the mode. */
1733+async function rebuildCompare(): Promise<void> {
1734+ if (!compareRun) return;
1735+ compareRun.dispose();
1736+ compareRun = null;
1737+ setCompareUi(false);
1738+ await startCompare();
1739+}
1740+
10321741 // ---------------------------------------------------------------- boot
10331742 async function boot(): Promise<void> {
1743+ enterMode('simulate');
10341744 elModel.value = presets[0].key;
1745+ // The iteration count is one default shared with the benchmark, like the
1746+ // rest of the RunSpec's — take it from there rather than from the markup, so
1747+ // the page and `npm run bench` cannot start out disagreeing about it.
1748+ elNiter.value = String(DEFAULT_NITER);
10351749 elGeometry.value = DEFAULT_GEOMETRY_KEY;
10361750 elMorph.value = String(morph);
10371751 applyGeometryChoice(DEFAULT_GEOMETRY_KEY);
src/mgpu/compile.tsmodified+8−3View file
@@ -215,13 +215,16 @@ function forLoops(stmts: IRStmt[]): For[] {
215215 return out;
216216 }
217217
218-/** cNames assigned anywhere in a statement list, including inside loops. */
218+/** cNames assigned anywhere in a statement list, including inside loops.
219+ * A MultiAssignCall assigns every bound output slot. */
219220 function assignedCNames(stmts: IRStmt[]): Set<string> {
220221 const out = new Set<string>();
221222 const walk = (list: IRStmt[]): void => {
222223 for (const s of list) {
223224 if (s.kind === 'Assign') out.add(s.cName);
224- else if (s.kind === 'For') walk(s.body);
225+ else if (s.kind === 'MultiAssignCall') {
226+ for (const o of s.outputs) if (o.binding) out.add(o.binding.cName);
227+ } else if (s.kind === 'For') walk(s.body);
225228 }
226229 };
227230 walk(stmts);
@@ -280,7 +283,9 @@ function checkLoopEscapes(fn: IRFunc, loop: For, assignedBefore: Set<string>): v
280283 for (const s of list) {
281284 if (s === (loop as IRStmt)) continue; // the loop's own body is not "outside"
282285 if (s.kind === 'Assign') forEachVarRead(s.expr, (c) => readOutside.add(c));
283- else if (s.kind === 'For') walk(s.body);
286+ else if (s.kind === 'MultiAssignCall') {
287+ for (const a of s.args) forEachVarRead(a, (c) => readOutside.add(c));
288+ } else if (s.kind === 'For') walk(s.body);
284289 }
285290 };
286291 walk(fn.body);
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+133−20View file
@@ -35,36 +35,66 @@ const numericType = (rows: number, cols: number): string =>
3535 const dim = (n: number): string =>
3636 n === 1 ? `{ kind: "exact", value: 1 }` : `{ kind: "exact", value: ${n} }`;
3737
38-/** Source for one transform's `.mtoc2.js`. */
38+/** Source for one transform's `.mtoc2.js`. With `multi`, the op maps each of
39+ * N inputs to its own output — `[a, b] = synth(x, y)` — so the backend can
40+ * run the group as one batched Legendre dispatch (or split it to whatever
41+ * lane width the device supports; the syntax promises grouping intent, not
42+ * a width). */
3943 function transformSource(
4044 name: string,
4145 inRows: number,
4246 inCols: number,
4347 outRows: number,
4448 outCols: number,
49+ multi = false,
4550 ): string {
4651 return `
4752 exports.name = ${JSON.stringify(name)};
4853
4954 exports.transfer = function (argTypes, nargout) {
50- if (argTypes.length !== 1) {
55+ ${
56+ multi
57+ ? `if (argTypes.length < 1) {
58+ throw new Error("${name} takes at least one argument");
59+ }
60+ if (nargout > 1 && nargout !== argTypes.length) {
61+ throw new Error(
62+ "${name}: each input produces one output, so " + argTypes.length +
63+ " input(s) return " + argTypes.length + " output(s), but " +
64+ nargout + " were requested -- write [a, b] = ${name}(x, y)"
65+ );
66+ }
67+ if (nargout <= 1 && argTypes.length !== 1) {
68+ throw new Error(
69+ "${name}: " + argTypes.length + " inputs produce " + argTypes.length +
70+ " outputs -- bind each one: [a, b] = ${name}(x, y)"
71+ );
72+ }`
73+ : `if (argTypes.length !== 1) {
5174 throw new Error("${name} takes exactly one argument, got " + argTypes.length);
5275 }
5376 if (nargout > 1) {
5477 throw new Error("${name} returns one value, but " + nargout + " were requested");
78+ }`
5579 }
56- var a = argTypes[0];
57- if (!a || a.kind !== "Numeric" || a.isComplex) {
58- throw new Error("${name} requires a real numeric array");
80+ for (var i = 0; i < argTypes.length; i++) {
81+ var a = argTypes[i];
82+ if (!a || a.kind !== "Numeric" || a.isComplex) {
83+ throw new Error("${name} requires real numeric arrays (argument " + (i + 1) + ")");
84+ }
85+ var s = a.shape;
86+ if (!s || s.length !== 2 || s[0] !== ${inRows} || s[1] !== ${inCols}) {
87+ throw new Error(
88+ "${name} requires ${inRows}x${inCols} arrays, argument " + (i + 1) +
89+ " is " + (s ? s.join("x") : "unknown shape")
90+ );
91+ }
5992 }
60- var s = a.shape;
61- if (!s || s.length !== 2 || s[0] !== ${inRows} || s[1] !== ${inCols}) {
62- throw new Error(
63- "${name} requires a ${inRows}x${inCols} array, got " +
64- (s ? s.join("x") : "unknown shape")
65- );
93+ var out = [];
94+ for (var k = 0; k < Math.max(1, nargout); k++) {
95+ out.push(${numericType(outRows, outCols)});
6696 }
67- return [${numericType(outRows, outCols)}];
97+ return out;
6898 };
6999
70100 // Never called: this project executes the IR on WebGPU and emits no C.
@@ -78,21 +108,26 @@ exports.cBody = function () {
78108 }
79109
80110 /**
81- * Workspace files that make `synth` / `analys` / `dtheta` / `dphi` resolvable
82- * during lowering. `dtheta` and `dphi` (the surface's first partial
83- * derivatives, coefficients -> grid — see src/sht/deriv.ts) have exactly
84- * `synth`'s shape rule: both take spectral coefficients and produce a grid
85- * field.
111+ * Workspace files that make `synth` / `analys` / `dtheta` / `dphi` /
112+ * `dthetac` / `dphic` resolvable during lowering. `dtheta` and `dphi` (the
113+ * surface's first partial derivatives, coefficients -> grid — see
114+ * src/sht/deriv.ts) have exactly `synth`'s shape rule: both take spectral
115+ * coefficients and produce a grid field. `dthetac` and `dphic` are their
116+ * coefficient-space halves alone — the alpha^+/alpha^- shift and the i*m
117+ * multiply, spectral -> spectral — which the six-transform Laplace-Beltrami
118+ * scheme (docs/reduced-transforms.md) applies twice per
119+ * matvec: to the field (gradient side) and to the analysed fluxes
120+ * (divergence side, the same shift, not its transpose).
86121 */
87122 export function externalOpFiles(g: GridSizes): { name: string; source: string }[] {
88123 return [
89124 {
90125 name: 'synth.mtoc2.js',
91- source: transformSource('synth', 2, g.nlm, g.npts, 1),
126+ source: transformSource('synth', 2, g.nlm, g.npts, 1, true),
92127 },
93128 {
94129 name: 'analys.mtoc2.js',
95- source: transformSource('analys', g.npts, 1, 2, g.nlm),
130+ source: transformSource('analys', g.npts, 1, 2, g.nlm, true),
96131 },
97132 {
98133 name: 'dtheta.mtoc2.js',
@@ -102,8 +137,86 @@ export function externalOpFiles(g: GridSizes): { name: string; source: string }[
102137 name: 'dphi.mtoc2.js',
103138 source: transformSource('dphi', 2, g.nlm, g.npts, 1),
104139 },
140+ {
141+ name: 'dthetac.mtoc2.js',
142+ source: transformSource('dthetac', 2, g.nlm, 2, g.nlm),
143+ },
144+ {
145+ name: 'dphic.mtoc2.js',
146+ source: transformSource('dphic', 2, g.nlm, 2, g.nlm),
147+ },
148+ {
149+ // Grid-space phi-derivative: two Fourier stages and an i*m multiply,
150+ // no Legendre work (d/dphi is diagonal in the Fourier index). What
151+ // lets the flux-form divergence skip the Q-flux's spherical-harmonic
152+ // analysis.
153+ name: 'dphig.mtoc2.js',
154+ source: transformSource('dphig', g.npts, 1, g.npts, 1),
155+ },
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+ },
105163 ];
106164 }
107165
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+
108219 /** Names the WGSL backend must implement as GPU encodes rather than kernels. */
109-export const EXTERNAL_OPS = new Set(['synth', 'analys', 'dtheta', 'dphi']);
220+export const EXTERNAL_OPS = new Set([
221+ 'synth', 'analys', 'dtheta', 'dphi', 'dthetac', 'dphic', 'dphig', 'randnfun3',
222+]);
src/mgpu/model.tsmodified+80−12View 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
@@ -68,20 +69,43 @@ export interface GeometryBuffers {
6869 X: Float32Array;
6970 Y: Float32Array;
7071 Z: Float32Array;
71- /** Inverse metric quantities (src/geom/metric.ts), grid space, npts each. */
72+ /** Inverse metric quantities (src/geom/metric.ts), grid space, npts each —
73+ * the Algorithm-4 (12-transform) Laplace-Beltrami path. */
7274 Vtx: Float32Array;
7375 Vty: Float32Array;
7476 Vtz: Float32Array;
7577 Vpx: Float32Array;
7678 Vpy: Float32Array;
7779 Vpz: Float32Array;
80+ /** Flux-form metric weights (src/geom/metric.ts computeFluxMetric), grid
81+ * space, npts each — the six-transform Laplace-Beltrami scheme of
82+ * docs/reduced-transforms.md. */
83+ p1: Float32Array;
84+ p2: Float32Array;
85+ q2: Float32Array;
86+ 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;
93+ /** Mean-J preconditioner scale (Geometry.Jhat): folded into every
94+ * setParams upload as the 'jhat' uniform, so a .m that takes jhat is
95+ * never left with the zero a missing parameter would default to. An
96+ * explicit jhat in the params wins (jhat: 1 pins the plain round-sphere
97+ * preconditioner, for A/B). */
98+ Jhat: number;
7899 }
79100
80101 /** Names the .m may take for the grid coordinates and for their coefficients. */
81102 export const GEOMETRY_GRID_NAMES = ['gx', 'gy', 'gz'] as const;
82103 export const GEOMETRY_SPECTRAL_NAMES = ['Gx', 'Gy', 'Gz'] as const;
83-/** Names the .m may take for the inverse metric quantities. */
104+/** Names the .m may take for the inverse metric quantities (Algorithm 4). */
84105 export const METRIC_GRID_NAMES = ['Vtx', 'Vty', 'Vtz', 'Vpx', 'Vpy', 'Vpz'] as const;
106+/** Names the .m may take for the flux-form metric weights (six-transform
107+ * scheme). A model asks for whichever set its loop uses; both are uploaded. */
108+export const FLUX_METRIC_GRID_NAMES = ['p1', 'p2', 'q2', 'r', 'dp1', 'dq2', 'jinv'] as const;
85109
86110 /** Laplace-Beltrami eigenvalues l(l+1), duplicated across re/im so the array
87111 * matches the 2 x nlm spectral layout element for element. */
@@ -129,6 +153,8 @@ export class GpuModel {
129153 #host: HostBuffers;
130154 #initPlan: ModelPlan;
131155 #stepPlan: ModelPlan;
156+ /** Current geometry's mean-J scale; 1 with no geometry (the sphere). */
157+ #jhat = 1;
132158 #readback: GPUBuffer;
133159 /** Scratch holding a copy of the whole spectral state; see snapshotState. */
134160 #stash: GPUBuffer;
@@ -184,6 +210,15 @@ export class GpuModel {
184210 for (const g of GEOMETRY_GRID_NAMES) bindings[g] = { kind: 'tensor', shape: [npts, 1] };
185211 for (const g of GEOMETRY_SPECTRAL_NAMES) bindings[g] = { kind: 'tensor', shape: [2, nlm] };
186212 for (const g of METRIC_GRID_NAMES) bindings[g] = { kind: 'tensor', shape: [npts, 1] };
213+ for (const g of FLUX_METRIC_GRID_NAMES) bindings[g] = { kind: 'tensor', shape: [npts, 1] };
214+ // Mean-J preconditioner scale (Geometry.Jhat): a uniform, not a const,
215+ // so swapping the surface updates it with no recompile. The session
216+ // folds the current geometry's value into every setParams call.
217+ 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' };
187222 }
188223 for (const s of state) bindings[s] = { kind: 'tensor', shape: [2, nlm] };
189224 for (const p of paramNames) bindings[p] = { kind: 'param' };
@@ -212,6 +247,7 @@ export class GpuModel {
212247 for (const g of GEOMETRY_GRID_NAMES) host.ensure(g, npts);
213248 for (const g of GEOMETRY_SPECTRAL_NAMES) host.ensure(g, 2 * nlm);
214249 for (const g of METRIC_GRID_NAMES) host.ensure(g, npts);
250+ for (const g of FLUX_METRIC_GRID_NAMES) host.ensure(g, npts);
215251 }
216252
217253 const initPlan = await inFunctionAsync('init', () =>
@@ -236,6 +272,13 @@ export class GpuModel {
236272 host.upload('Vpx', geometry.Vpx);
237273 host.upload('Vpy', geometry.Vpy);
238274 host.upload('Vpz', geometry.Vpz);
275+ host.upload('p1', geometry.p1);
276+ host.upload('p2', geometry.p2);
277+ host.upload('q2', geometry.q2);
278+ host.upload('r', geometry.r);
279+ host.upload('dp1', geometry.dp1);
280+ host.upload('dq2', geometry.dq2);
281+ host.upload('jinv', geometry.jinv);
239282 }
240283
241284 const readback = device.createBuffer({
@@ -249,15 +292,18 @@ export class GpuModel {
249292 usage: GPUBufferUsage.COPY_SRC | GPUBufferUsage.COPY_DST,
250293 });
251294
252- return new GpuModel({
295+ const gpu = new GpuModel({
253296 device, host, initPlan, stepPlan, readback, stash,
254297 paramNames, state, view, npts, nlm,
255298 });
299+ if (geometry) gpu.#jhat = geometry.Jhat;
300+ return gpu;
256301 }
257302
258303 setParams(params: ModelParams): void {
259- this.#initPlan.setParams(params);
260- this.#stepPlan.setParams(params);
304+ const merged = { jhat: this.#jhat, ...params };
305+ this.#initPlan.setParams(merged);
306+ this.#stepPlan.setParams(merged);
261307 }
262308
263309 /**
@@ -281,18 +327,40 @@ export class GpuModel {
281327 ['Gx', geometry.X], ['Gy', geometry.Y], ['Gz', geometry.Z],
282328 ['Vtx', geometry.Vtx], ['Vty', geometry.Vty], ['Vtz', geometry.Vtz],
283329 ['Vpx', geometry.Vpx], ['Vpy', geometry.Vpy], ['Vpz', geometry.Vpz],
330+ ['p1', geometry.p1], ['p2', geometry.p2],
331+ ['q2', geometry.q2], ['r', geometry.r],
332+ ['dp1', geometry.dp1], ['dq2', geometry.dq2], ['jinv', geometry.jinv],
284333 ];
285334 for (const [name, data] of fields) {
286335 if (this.#host.get(name)) this.#host.upload(name, data);
287336 }
337+ // The new surface's preconditioner scale takes effect on the next
338+ // setParams (the session re-applies its params after a swap).
339+ this.#jhat = geometry.Jhat;
288340 }
289341
290- /** Upload the seeded perturbation and run `init`. */
291- init(noise: Float32Array): void {
292- this.#host.upload('noise', noise);
293- const enc = this.#device.createCommandEncoder({ label: 'mgpu-init' });
294- this.#initPlan.encodeSteps(enc, 1);
295- 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');
296364 this.#lastRan = 'init';
297365 }
298366
src/mgpu/numbl.d.tsmodified+112−7View 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
@@ -137,16 +139,36 @@ declare module 'numbl-src/numbl-core/jit/lowering/ir.ts' {
137139 body: IRStmt[];
138140 span: Span;
139141 }
142+ /**
143+ * Multi-output call statement: `[a, b] = f(x, y)`. For `isBuiltin: true`
144+ * the builtin's `transfer(argTypes, nargout)` typed the slots during
145+ * lowering; args arrive ANF'd. The planner accepts this only for the
146+ * batched transforms (`synth`/`analys`), where output k is the transform
147+ * of argument k.
148+ */
149+ export interface MultiAssignCall {
150+ kind: 'MultiAssignCall';
151+ cName: string;
152+ name: string;
153+ isBuiltin?: boolean;
154+ args: IRExpr[];
155+ outputs: ReadonlyArray<{
156+ ty: Type;
157+ binding: { name: string; cName: string } | null;
158+ }>;
159+ span: Span;
160+ }
161+
140162 /** Any other IR statement kind — rejected by the planner. */
141163 export interface OtherStmt {
142164 kind:
143165 | 'ExprStmt' | 'If' | 'While' | 'ReturnFromFunction' | 'Break'
144- | 'Continue' | 'TypeComment' | 'MemberStore' | 'MultiAssignCall'
166+ | 'Continue' | 'TypeComment' | 'MemberStore'
145167 | 'IndexStore' | 'IndexSliceStore' | 'CellIndexStore';
146168 span: Span;
147169 }
148170
149- export type IRStmt = Assign | For | OtherStmt;
171+ export type IRStmt = Assign | For | MultiAssignCall | OtherStmt;
150172
151173 export interface IRFunc {
152174 name: string;
@@ -172,13 +194,96 @@ declare module 'numbl-src/numbl-core/jit/lowering/ir.ts' {
172194 }
173195
174196 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+
175222 export interface AbstractSyntaxTree {
176- body: unknown[];
223+ body: Stmt[];
177224 }
178225 export function parseMFile(input: string, fileName?: string): AbstractSyntaxTree;
179226 export class SyntaxError extends Error {}
180227 }
181228
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+
182287 declare module 'numbl-src/numbl-core/jit/index.ts' {
183288 import type { AbstractSyntaxTree } from 'numbl-src/numbl-core/parser/index.ts';
184289 import type { IRProgram, IRFunc, Span } from 'numbl-src/numbl-core/jit/lowering/ir.ts';
src/mgpu/plan.tsmodified+548−28View file
@@ -9,12 +9,25 @@
99 * encoded into one submit and keeps the CPU out of the loop.
1010 */
1111 import { isMultiElement, scalarDouble } from 'numbl-src/numbl-core/jit/lowering/types.ts';
12-import type { Assign, For, IRExpr, IRStmt } from 'numbl-src/numbl-core/jit/lowering/ir.ts';
12+import type {
13+ Assign,
14+ For,
15+ IRExpr,
16+ IRStmt,
17+ MultiAssignCall,
18+} from 'numbl-src/numbl-core/jit/lowering/ir.ts';
1319 import type { NumericType, Type } from 'numbl-src/numbl-core/jit/lowering/types.ts';
14-import { ShtPlan, type ShtBinding } from '../sht/sht.ts';
20+import { ShtPlan, type ShtBinding, type ShtBatchBinding, type ShtDphigBinding } from '../sht/sht.ts';
1521 import { DerivPlan, type DerivBinding } from '../sht/deriv.ts';
1622 import type { CompiledFunction } from './compile.ts';
1723 import { EXTERNAL_OPS } from './externals.ts';
24+import {
25+ MODE_BUFFER,
26+ INITIAL_MODES,
27+ modeTableLength,
28+ randnfun3Chunks,
29+ randnfun3WGSL,
30+} from './randnfun3.ts';
1831 import {
1932 buildKernel,
2033 UnsupportedOnGpu,
@@ -90,6 +103,35 @@ export class HostBuffers {
90103 return this.#slots.get(name);
91104 }
92105
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+
93135 /** Upload initial data for a host binding. */
94136 upload(name: string, data: Float32Array): void {
95137 const slot = this.#slots.get(name);
@@ -118,11 +160,112 @@ type Op =
118160 /** Set when the kernel had to write to scratch because its output
119161 * aliases one of its inputs; copied back after the dispatch. */
120162 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;
121166 }
122167 | { kind: 'synth' | 'analys'; binding: ShtBinding; label: string }
168+ | { kind: 'synth-batch' | 'analys-batch'; binding: ShtBatchBinding; labels: string[] }
123169 | { kind: 'dtheta' | 'dphi'; binding: DerivBinding; label: string }
170+ | { kind: 'dthetac' | 'dphic'; bindGroup: GPUBindGroup; label: string }
171+ | { kind: 'dphig'; binding: ShtDphigBinding; label: string }
124172 | { kind: 'copy'; from: GPUBuffer; to: GPUBuffer; bytes: number; label: string };
125173
174+/**
175+ * A transform op as planned, before bindings exist: `in`/`out` are the
176+ * caller-side buffers (spectral in / grid out for synth, the reverse for
177+ * analys). Kept unbound until every statement is planned so that adjacent
178+ * independent transforms of the same kind can be grouped into one batched
179+ * dispatch (ShtPlan.createSynthBatchBinding) — the Legendre recurrence is
180+ * the expensive shared part, and a batch walks it once for all lanes.
181+ */
182+interface PendingSht {
183+ pending: true;
184+ kind: 'synth' | 'analys';
185+ in: GPUBuffer;
186+ out: GPUBuffer;
187+ label: string;
188+}
189+
190+type Planned = Op | PendingSht;
191+
192+const isPending = (op: Planned): op is PendingSht => 'pending' in op;
193+
194+/**
195+ * Group maximal runs of adjacent same-kind transforms into batches of the
196+ * widest compiled lane count, and create all bindings. Only literal
197+ * adjacency in the op sequence is batched — no reordering — so the models
198+ * are written to keep batchable transforms consecutive (see the solve loops
199+ * in models/*.m). Batching changes dispatch shape only: per-lane arithmetic
200+ * is identical to the scalar kernels', so results do not depend on batchK.
201+ */
202+function materializeTransforms(planned: Planned[], sht: ShtPlan): Op[] {
203+ /** Lanes must not collide: distinct outputs, and no lane reading another's
204+ * output (repeated read-only inputs would be harmless, but WebGPU also
205+ * forbids aliasing a writable binding, so outputs are the hard rule). */
206+ const disjoint = (members: PendingSht[]): boolean => {
207+ const outs = new Set<GPUBuffer>();
208+ for (const m of members) {
209+ if (outs.has(m.out)) return false;
210+ outs.add(m.out);
211+ }
212+ return members.every((m) => !outs.has(m.in));
213+ };
214+ const bind = (m: PendingSht): Op =>
215+ m.kind === 'synth'
216+ ? { kind: 'synth', binding: sht.createSynthBinding(m.in, m.out), label: m.label }
217+ : { kind: 'analys', binding: sht.createAnalysBinding(m.in, m.out), label: m.label };
218+ const bindBatch = (members: PendingSht[]): Op =>
219+ members[0].kind === 'synth'
220+ ? {
221+ kind: 'synth-batch',
222+ binding: sht.createSynthBatchBinding(
223+ members.map((m) => ({ qlmIn: m.in, spatOut: m.out })),
224+ ),
225+ labels: members.map((m) => m.label),
226+ }
227+ : {
228+ kind: 'analys-batch',
229+ binding: sht.createAnalysBatchBinding(
230+ members.map((m) => ({ spatIn: m.in, qlmOut: m.out })),
231+ ),
232+ labels: members.map((m) => m.label),
233+ };
234+
235+ const out: Op[] = [];
236+ let i = 0;
237+ while (i < planned.length) {
238+ const op = planned[i];
239+ if (!isPending(op)) {
240+ out.push(op);
241+ i++;
242+ continue;
243+ }
244+ let j = i;
245+ while (j < planned.length) {
246+ const p = planned[j];
247+ if (!isPending(p) || p.kind !== op.kind) break;
248+ j++;
249+ }
250+ const run = planned.slice(i, j) as PendingSht[];
251+ let s = 0;
252+ while (s < run.length) {
253+ let take = 1;
254+ for (const K of [4, 2]) {
255+ if (K > sht.batchK || s + K > run.length) continue;
256+ if (disjoint(run.slice(s, s + K))) {
257+ take = K;
258+ break;
259+ }
260+ }
261+ out.push(take === 1 ? bind(run[s]) : bindBatch(run.slice(s, s + take)));
262+ s += take;
263+ }
264+ i = j;
265+ }
266+ return out;
267+}
268+
126269 export interface PlanSpec {
127270 /** The specialized function this plan executes. */
128271 fn: CompiledFunction;
@@ -191,6 +334,9 @@ async function makePipeline(
191334 export class ModelPlan {
192335 /** Scalar parameter names, in the order the params buffer expects them. */
193336 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;
194340
195341 #device: GPUDevice;
196342 #sht: ShtPlan;
@@ -199,6 +345,7 @@ export class ModelPlan {
199345 #owned: GPUBuffer[];
200346 #paramBuf: GPUBuffer;
201347 #paramData: Float32Array;
348+ #rebindRandnfun3: ((table: GPUBuffer) => void) | null;
202349 /** Public name -> buffer, for uploading initial state and reading results. */
203350 #byName: Map<string, Slot>;
204351
@@ -212,6 +359,8 @@ export class ModelPlan {
212359 paramBuf: GPUBuffer;
213360 paramData: Float32Array;
214361 paramNames: string[];
362+ randnfun3Lambda: Randnfun3Lambda | null;
363+ rebindRandnfun3: ((table: GPUBuffer) => void) | null;
215364 }) {
216365 this.#device = init.device;
217366 this.#sht = init.sht;
@@ -222,6 +371,30 @@ export class ModelPlan {
222371 this.#paramBuf = init.paramBuf;
223372 this.#paramData = init.paramData;
224373 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);
225398 }
226399
227400 static async create(
@@ -271,7 +444,15 @@ export class ModelPlan {
271444 usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST,
272445 });
273446
274- const ops: Op[] = [];
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+
455+ const planned: Planned[] = [];
275456 for (const stmt of fn.body) {
276457 await planStatement(stmt);
277458 }
@@ -294,7 +475,7 @@ export class ModelPlan {
294475 `'${to}' (${dst.count})`,
295476 );
296477 }
297- ops.push({
478+ planned.push({
298479 kind: 'copy',
299480 from: src.buffer,
300481 to: dst.buffer,
@@ -303,13 +484,19 @@ export class ModelPlan {
303484 });
304485 });
305486
487+ // Group adjacent independent transforms into batched dispatches and
488+ // create every binding.
489+ const ops = materializeTransforms(planned, sht);
490+
306491 return new ModelPlan({
307492 device, sht, deriv, ops, byName, owned, paramBuf, paramData, paramNames,
493+ randnfun3Lambda, rebindRandnfun3,
308494 });
309495
310496 async function planStatement(stmt: IRStmt): Promise<void> {
311497 if (stmt.kind === 'ReturnFromFunction') return; // nothing follows it
312498 if (stmt.kind === 'For') return planFor(stmt);
499+ if (stmt.kind === 'MultiAssignCall') return planMultiTransform(stmt);
313500 if (stmt.kind !== 'Assign') {
314501 throw new UnsupportedOnGpu(
315502 `a model function body may only contain assignments ` +
@@ -348,27 +535,43 @@ export class ModelPlan {
348535
349536 const ext = externalCall(stmt);
350537 if (ext) {
351- 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);
352544 if (!argSlot) {
353545 throw new UnsupportedOnGpu(
354- `'${ext.name}' reads '${ext.argName}', which has no buffer`,
546+ `'${ext.name}' reads '${arg.name}', which has no buffer`,
355547 stmt.span,
356548 );
357549 }
358- const label = `${stmt.name} = ${ext.name}(${ext.argName})`;
359- if (ext.name === 'synth') {
360- ops.push({
361- kind: 'synth',
362- binding: sht.createSynthBinding(argSlot.buffer, dest.buffer),
550+ const label = `${stmt.name} = ${ext.name}(${arg.name})`;
551+ if (ext.name === 'dphig') {
552+ // Grid -> grid, staged through the plan's fm scratch; safe even
553+ // in place, so no aliasing guard is needed.
554+ planned.push({
555+ kind: 'dphig',
556+ binding: sht.createDphigBinding(argSlot.buffer, dest.buffer),
363557 label,
364558 });
365- } else if (ext.name === 'analys') {
366- ops.push({
367- kind: 'analys',
368- binding: sht.createAnalysBinding(argSlot.buffer, dest.buffer),
559+ return;
560+ }
561+ if (ext.name === 'synth' || ext.name === 'analys') {
562+ // Left unbound until materializeTransforms has grouped adjacent
563+ // independent transforms into batched dispatches.
564+ planned.push({
565+ pending: true,
566+ kind: ext.name,
567+ in: argSlot.buffer,
568+ out: dest.buffer,
369569 label,
370570 });
371- } else if (ext.name === 'dtheta' || ext.name === 'dphi') {
571+ } else if (
572+ ext.name === 'dtheta' || ext.name === 'dphi' ||
573+ ext.name === 'dthetac' || ext.name === 'dphic'
574+ ) {
372575 if (!deriv) {
373576 throw new UnsupportedOnGpu(
374577 `'${ext.name}' needs the surface's derivative transforms, ` +
@@ -376,7 +579,30 @@ export class ModelPlan {
376579 stmt.span,
377580 );
378581 }
379- ops.push(
582+ if (ext.name === 'dthetac' || ext.name === 'dphic') {
583+ // Coefficient-space shuffles read at l+-1 (dthetac) or in place
584+ // (dphic) and cannot alias their output: WebGPU forbids one buffer
585+ // being readable and writable storage in the same dispatch, and
586+ // there is no scratch-copy fallback here — refuse rather than
587+ // silently reroute.
588+ if (argSlot.buffer === dest.buffer) {
589+ throw new UnsupportedOnGpu(
590+ `'${stmt.name} = ${ext.name}(${arg.name})' reads and ` +
591+ `writes the same buffer; assign to a new name instead`,
592+ stmt.span,
593+ );
594+ }
595+ planned.push({
596+ kind: ext.name,
597+ bindGroup:
598+ ext.name === 'dthetac'
599+ ? deriv.createDthetacBinding(argSlot.buffer, dest.buffer)
600+ : deriv.createDphicBinding(argSlot.buffer, dest.buffer),
601+ label,
602+ });
603+ return;
604+ }
605+ planned.push(
380606 ext.name === 'dtheta'
381607 ? { kind: 'dtheta', binding: deriv.createDthetaBinding(argSlot.buffer, dest.buffer), label }
382608 : { kind: 'dphi', binding: deriv.createDphiBinding(argSlot.buffer, dest.buffer), label },
@@ -431,7 +657,7 @@ export class ModelPlan {
431657 }
432658 entries.push({ binding: tensors.size + 1, resource: { buffer: paramBuf } });
433659
434- ops.push({
660+ planned.push({
435661 kind: 'kernel',
436662 pipeline,
437663 bindGroup: device.createBindGroup({
@@ -446,6 +672,184 @@ export class ModelPlan {
446672 });
447673 }
448674
675+ /**
676+ * `[a, b] = synth(x, y)` / `[a, b] = analys(x, y)`: an explicitly grouped
677+ * transform — output k is the transform of argument k. The group is
678+ * planned as consecutive pending transforms, which materializeTransforms
679+ * then chunks into whatever batched dispatch widths the device supports
680+ * (one x4 batch, two x2, or scalars with SHT_BATCH=0) — the syntax
681+ * promises grouping intent, never a lane width, so the same source
682+ * compiles everywhere.
683+ */
684+ function planMultiTransform(stmt: MultiAssignCall): void {
685+ if (stmt.name !== 'synth' && stmt.name !== 'analys') {
686+ throw new UnsupportedOnGpu(
687+ `'${stmt.name}' does not return multiple values here — only the ` +
688+ `transforms ('synth', 'analys') support [a, b] = op(x, y) grouping`,
689+ stmt.span,
690+ );
691+ }
692+ const kind = stmt.name;
693+ for (let i = 0; i < stmt.outputs.length; i++) {
694+ const slot = stmt.outputs[i];
695+ const arg = stmt.args[i];
696+ if (!slot.binding) {
697+ throw new UnsupportedOnGpu(
698+ `every output of '${kind}' must be bound to a name — output ` +
699+ `${i + 1} is dropped, but each input costs a transform`,
700+ stmt.span,
701+ );
702+ }
703+ if (!arg || arg.kind !== 'Var') {
704+ throw new UnsupportedOnGpu(
705+ `'${kind}' must be applied to variables (argument ${i + 1})`,
706+ stmt.span,
707+ );
708+ }
709+ const argSlot = slots.get(arg.cName);
710+ if (!argSlot) {
711+ throw new UnsupportedOnGpu(
712+ `'${kind}' reads '${arg.name}', which has no buffer`,
713+ stmt.span,
714+ );
715+ }
716+ if (!isNumeric(slot.ty) || !isTensor(slot.ty)) {
717+ throw new UnsupportedOnGpu(
718+ `'${slot.binding.name}' is not a numeric array`,
719+ stmt.span,
720+ );
721+ }
722+ const count = numel(slot.ty);
723+ let dest = slots.get(slot.binding.cName);
724+ if (!dest) {
725+ dest = alloc(`mgpu-${slot.binding.name}`, count);
726+ slots.set(slot.binding.cName, dest);
727+ } else if (dest.count !== count) {
728+ throw new UnsupportedOnGpu(
729+ `'${slot.binding.name}' changes size between assignments`,
730+ stmt.span,
731+ );
732+ }
733+ byName.set(slot.binding.name, dest);
734+ planned.push({
735+ pending: true,
736+ kind,
737+ in: argSlot.buffer,
738+ out: dest.buffer,
739+ label: `${slot.binding.name} = ${kind}(${arg.name})`,
740+ });
741+ }
742+ }
743+
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+
449853 /**
450854 * Unroll a counted loop into the op sequence.
451855 *
@@ -527,13 +931,64 @@ export class ModelPlan {
527931 return this.#byName.get(name)?.count;
528932 }
529933
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+
530980 /**
531981 * Record `steps` timesteps. Synchronous: no awaits, no readback. All of the
532982 * ops share one compute pass, which WebGPU executes in submission order
533983 * with a barrier between dispatches.
534984 */
535985 encodeSteps(encoder: GPUCommandEncoder, steps: number): void {
536- 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+ {
537992 let pass: GPUComputePassEncoder | null = null;
538993 const inPass = (): GPUComputePassEncoder => {
539994 if (!pass) pass = encoder.beginComputePass({ label: 'mgpu-step' });
@@ -545,7 +1000,7 @@ export class ModelPlan {
5451000 pass = null;
5461001 }
5471002 };
548- for (const op of this.#ops) {
1003+ for (const op of ops) {
5491004 switch (op.kind) {
5501005 case 'kernel': {
5511006 const p = inPass();
@@ -572,6 +1027,21 @@ export class ModelPlan {
5721027 case 'dphi':
5731028 this.#derivInto(inPass(), op);
5741029 break;
1030+ case 'dthetac':
1031+ this.#deriv!.encodeDthetacInto(inPass(), op.bindGroup);
1032+ break;
1033+ case 'dphic':
1034+ this.#deriv!.encodeDphicInto(inPass(), op.bindGroup);
1035+ break;
1036+ case 'dphig':
1037+ this.#sht.encodeDphigInto(inPass(), op.binding);
1038+ break;
1039+ case 'synth-batch':
1040+ this.#sht.encodeSynthBatchInto(inPass(), op.binding);
1041+ break;
1042+ case 'analys-batch':
1043+ this.#sht.encodeAnalysBatchInto(inPass(), op.binding);
1044+ break;
5751045 case 'copy':
5761046 endPass();
5771047 encoder.copyBufferToBuffer(op.from, 0, op.to, 0, op.bytes);
@@ -594,9 +1064,23 @@ export class ModelPlan {
5941064 else this.#deriv!.encodeDphiInto(pass, op.binding);
5951065 }
5961066
597- /** Human-readable op sequence — what the .m actually compiled to. */
1067+ /**
1068+ * Human-readable op sequence — what the .m actually compiled to. Batched
1069+ * transforms list one line per lane, annotated: the line count equals the
1070+ * logical op count regardless of the device's batch width, so op-count
1071+ * assertions in the tests are batch-invariant.
1072+ */
5981073 describe(): string[] {
599- return this.#ops.map((op) => `${op.kind.padEnd(7)} ${op.label}`);
1074+ return this.#ops.flatMap((op) => {
1075+ if ('labels' in op) {
1076+ const kind = op.kind === 'synth-batch' ? 'synth' : 'analys';
1077+ return op.labels.map(
1078+ (label, i) =>
1079+ `${kind.padEnd(7)} ${label} [batch lane ${i + 1}/${op.binding.size}]`,
1080+ );
1081+ }
1082+ return [`${op.kind.padEnd(7)} ${op.label}`];
1083+ });
6001084 }
6011085
6021086 destroy(): void {
@@ -606,22 +1090,58 @@ export class ModelPlan {
6061090 }
6071091 }
6081092
609-/** `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+ */
6101102 function externalCall(
6111103 stmt: Assign,
612-): { name: string; argCName: string; argName: string } | null {
1104+): { name: string; args: IRExpr[] } | null {
6131105 const e = stmt.expr;
6141106 if (e.kind !== 'Call' || !EXTERNAL_OPS.has(e.name)) return null;
615- if (e.args.length !== 1 || e.args[0].kind !== 'Var') {
1107+ const arity = e.name === 'randnfun3' ? 4 : 1;
1108+ if (e.args.length !== arity) {
6161109 throw new UnsupportedOnGpu(
617- `'${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}`,
6181113 stmt.span,
6191114 );
6201115 }
621- const arg = e.args[0];
622- 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 };
6231126 }
6241127
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+
6251145 function collectTensorVars(e: IRExpr, visit: (cName: string) => void): void {
6261146 const walk = (x: IRExpr): void => {
6271147 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+30−1View file
@@ -12,6 +12,7 @@
1212 * `U`, `V`, ... the corresponding spectral state (uppercase)
1313 */
1414 import schnakenbergSource from '../../models/schnakenberg.m?raw';
15+import schnakenbergAlg4Source from '../../models/schnakenberg_alg4.m?raw';
1516 import brusselatorSource from '../../models/brusselator.m?raw';
1617 import allencahnSource from '../../models/allencahn.m?raw';
1718
@@ -25,6 +26,13 @@ export interface ParamSpec {
2526 min: number;
2627 max: number;
2728 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;
2836 }
2937
3038 export interface MModel {
@@ -65,6 +73,22 @@ const schnakenberg: MModel = {
6573 source: schnakenbergSource,
6674 };
6775
76+/**
77+ * The same PDE and parameters as `schnakenberg`, with the implicit solve's
78+ * geometric correction in its original Cartesian-gradient form (Algorithm 4,
79+ * 12 transforms per species per iteration) instead of the flux form's 6
80+ * (docs/reduced-transforms.md). Shipped as a live reference:
81+ * the two must agree to fp32 accuracy on any surface, and the tests hold
82+ * them to that.
83+ */
84+const schnakenbergAlg4: MModel = {
85+ ...schnakenberg,
86+ key: 'schnakenberg-alg4',
87+ label: 'Schnakenberg (12-transform reference)',
88+ blurb: 'Same spots, Algorithm-4 Laplace-Beltrami — for A/B against the flux form.',
89+ source: schnakenbergAlg4Source,
90+};
91+
6892 const brusselator: MModel = {
6993 key: 'brusselator',
7094 label: 'Brusselator',
@@ -98,7 +122,7 @@ const allencahn: MModel = {
98122 source: allencahnSource,
99123 };
100124
101-export const mModels: MModel[] = [schnakenberg, brusselator, allencahn];
125+export const mModels: MModel[] = [schnakenberg, brusselator, allencahn, schnakenbergAlg4];
102126
103127 export const mModelByKey = (key: string): MModel | undefined =>
104128 mModels.find((m) => m.key === key);
@@ -133,4 +157,9 @@ export const presets: Preset[] = [
133157 },
134158 { key: 'brussel', label: 'Brusselator — stripes & spots', modelKey: 'brusselator' },
135159 { key: 'allencahn', label: 'Allen–Cahn — coarsening', modelKey: 'allencahn' },
160+ {
161+ key: 'schnak-alg4',
162+ label: 'Schnakenberg — spots (12-transform reference)',
163+ modelKey: 'schnakenberg-alg4',
164+ },
136165 ];
src/mgpu/session.tsmodified+150−27View 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 {
@@ -134,10 +145,10 @@ export class ModelSession {
134145 deriv = await DerivPlan.create(device, sht);
135146 // The surface is built before the model, because the model takes it as
136147 // an argument. It is a one-off: compiled, evaluated, read back, and its
137- // plan discarded — nothing of it survives into the timestep but twelve
138- // buffers of numbers (the embedding and the metric quantities built on it).
148+ // plan discarded — nothing of it survives into the timestep but sixteen
149+ // buffers of numbers (the embedding, and both metric formulations built
150+ // on it: the inverse metric quantities and the flux-form weights).
139151 const geometry = await Geometry.create({
140- device,
141152 sht,
142153 cfg,
143154 source: opts.geometrySource ?? geometryModel.source,
@@ -157,10 +168,11 @@ export class ModelSession {
157168 deriv,
158169 niter,
159170 });
160- gpu.setParams(params);
171+ const lam3 = opts.lam3 ?? DEFAULT_LAMBDA;
172+ gpu.setParams({ lam3, ...params });
161173 return new ModelSession({
162174 device, model, cfg, sht, displaySht, gpu, params, oversample,
163- geometry, geometryModel, deriv, niter,
175+ geometry, geometryModel, deriv, niter, lam3,
164176 });
165177 } catch (e) {
166178 // The transform plans own GPU buffers; do not leak them on a compile error.
@@ -193,7 +205,6 @@ export class ModelSession {
193205 source?: string,
194206 ): Promise<void> {
195207 const next = await Geometry.create({
196- device: this.device,
197208 sht: this.sht,
198209 cfg: this.cfg,
199210 source: source ?? geometryModel.source,
@@ -204,6 +215,9 @@ export class ModelSession {
204215 this.#geometry = next;
205216 this.#geometryModel = geometryModel;
206217 this.gpu.uploadGeometry(next);
218+ // The new surface brings a new preconditioner scale (GpuModel folds its
219+ // current geometry's jhat into every params upload).
220+ this.gpu.setParams(this.#params);
207221 }
208222
209223 /** The plan whose grid `readSpecies` samples on — the display plan when
@@ -220,32 +234,127 @@ export class ModelSession {
220234 */
221235 async setOversample(oversample: number): Promise<void> {
222236 const os = Math.max(1, Math.round(oversample));
223- if (os === this.#oversample) return;
224- const next =
225- os > 1
226- ? await ShtPlan.create(this.device, {
227- lmax: this.cfg.lmax,
228- mmax: this.cfg.mmax,
229- nlat: os * this.cfg.nlat,
230- nphi: os * this.cfg.nphi,
231- })
232- : null;
237+ await this.setDisplayGrid(os * this.cfg.nlat, os * this.cfg.nphi);
238+ }
239+
240+ /**
241+ * Point the display plan at an arbitrary grid, rather than an integer
242+ * multiple of the solver's. Same contract as setOversample — display-only,
243+ * no readback may be in flight — and the same exactness argument, which does
244+ * not care about the ratio: the state is band-limited at lmax, so
245+ * synthesizing it anywhere is evaluation, not resampling. What this adds is a
246+ * grid that need not be *finer*: several sessions at different lmax can be
247+ * put on one common grid, which is what makes their fields directly
248+ * comparable point by point and lets one mesh serve all of them.
249+ */
250+ async setDisplayGrid(nlat: number, nphi: number): Promise<void> {
251+ const view = this.viewSht.cfg;
252+ if (nlat === view.nlat && nphi === view.nphi) return;
253+ const onSolverGrid = nlat === this.cfg.nlat && nphi === this.cfg.nphi;
254+ const next = onSolverGrid
255+ ? null
256+ : await ShtPlan.create(this.device, {
257+ lmax: this.cfg.lmax,
258+ mmax: this.cfg.mmax,
259+ nlat,
260+ nphi,
261+ });
233262 const old = this.#displaySht;
234263 this.#displaySht = next;
235- this.#oversample = os;
264+ this.#oversample = nlat / this.cfg.nlat;
236265 old?.destroy();
237266 }
238267
268+ /**
269+ * The coefficient table a model that calls `randnfun3` seeds from — drawn
270+ * over the current surface's bounding box, at the wavelength its own .m asked
271+ * for — or null for a model that seeds some other way. The draw is host-side
272+ * MATLAB (a few ms); the evaluation at every grid point is the GPU kernel
273+ * inside `init`.
274+ *
275+ * Separate from `seed` because the table is a function of space, not of a
276+ * grid: drawn once, it is the *same field* wherever it is evaluated, which is
277+ * how every variant of a comparison across lmax seeds from one random field
278+ * (see src/compare/sharedStart.ts).
279+ */
280+ drawSeedModes(seed: number): Promise<Float32Array | null> {
281+ const lambda = this.gpu.randnfun3Lambda;
282+ if (!lambda) return Promise.resolve(null);
283+ // Drawn on a worker: at a fine wavelength this is seconds of interpreter
284+ // time, and it must not be seconds of frozen page.
285+ return drawModesAsync(
286+ resolveLambda(lambda, this.#mergedParams()),
287+ boundingBox(this.#geometry.x, this.#geometry.y, this.#geometry.z),
288+ seed,
289+ this.npts,
290+ );
291+ }
292+
239293 /** Run `init` from a seeded perturbation, resetting model time. */
240- seed(seed: number): void {
241- this.gpu.init(seededNoise(this.npts, this.model.seedAmp, seed));
294+ async seed(seed: number): Promise<void> {
295+ const modes = await this.drawSeedModes(seed);
296+ await this.seedWith(seededNoise(this.npts, this.model.seedAmp, seed), modes);
297+ this.#seed = seed;
298+ }
299+
300+ /**
301+ * Run `init` from a caller-supplied perturbation, resetting model time.
302+ * `seed()` is this with what this session would draw for itself: the host
303+ * RNG's field on its own grid, and its own random-field table. Supplying them
304+ * instead is how several sessions on *different* grids can be started from
305+ * the same initial condition, which is the only way a comparison across lmax
306+ * compares one problem rather than two (see src/compare/sharedStart.ts).
307+ */
308+ async seedWith(noise: Float32Array, modes: Float32Array | null = null): Promise<void> {
309+ if (noise.length !== this.npts) {
310+ throw new Error(`seedWith: noise must have length ${this.npts} (got ${noise.length})`);
311+ }
312+ await this.gpu.init(noise, modes);
313+ this.t = 0;
314+ this.steps = 0;
315+ }
316+
317+ /**
318+ * Push an exact spectral state into the running model, bypassing seeded
319+ * init, and reset model time like seed() does. `gpu.step(0)` flips which
320+ * of the init/step buffer aliases a read resolves to, without otherwise
321+ * touching the state — see GpuModel's `#lastRan`.
322+ */
323+ loadState(coeffs: Record<string, Float32Array>): void {
324+ for (const name of this.model.state) {
325+ const data = coeffs[name];
326+ if (!data) throw new Error(`loadState: missing state '${name}'`);
327+ this.gpu.upload(name, data);
328+ }
329+ this.gpu.step(0);
242330 this.t = 0;
243331 this.steps = 0;
244332 }
245333
334+ /** The model's parameters plus the ones the host owns. */
335+ #mergedParams(): ModelParams {
336+ return { lam3: this.#lam3, ...this.#params };
337+ }
338+
246339 setParams(params: ModelParams): void {
247340 this.#params = params;
248- this.gpu.setParams(params);
341+ this.gpu.setParams(this.#mergedParams());
342+ }
343+
344+ /** Wavelength of the seeded random field. Redraws on the next seed. */
345+ get lam3(): number {
346+ return this.#lam3;
347+ }
348+
349+ /**
350+ * Change the random field's wavelength. Nothing recompiles — lam3 is a
351+ * uniform and the coefficient table is host-drawn — but the field itself
352+ * only changes on the next `seed`, which is where it is drawn.
353+ */
354+ setLam3(lambda: number): void {
355+ if (lambda === this.#lam3) return;
356+ this.#lam3 = lambda;
357+ this.gpu.setParams(this.#mergedParams());
249358 }
250359
251360 /** Advance `n` steps. Synchronous: records and submits, nothing read back. */
@@ -289,18 +398,32 @@ export class ModelSession {
289398 }
290399
291400 /**
292- * Read species `k` at render resolution (`viewSht`'s grid). Without
293- * oversampling this is the grid field the .m returned. With oversampling the
294- * spectral state is synthesized on the finer grid instead — the same field,
295- * since the models define each species as synth of its state, evaluated
296- * exactly on more points.
401+ * Read every state species back to the CPU, in the shape `loadState`
402+ * consumes — the capture side of that method's reload, so a caller can
403+ * hold onto the current spectral state and restore it exactly later
404+ * (e.g. a "restart to this run's initial condition" control). One at a
405+ * time, not `Promise.all`: every `read()` copies into the same shared
406+ * readback buffer (`GpuModel#readback`, model.ts:448-452), so two in
407+ * flight at once race `mapAsync` against each other's `unmap`.
408+ */
409+ async readState(): Promise<Record<string, Float32Array>> {
410+ const out: Record<string, Float32Array> = {};
411+ for (const name of this.model.state) out[name] = await this.read(name);
412+ return out;
413+ }
414+
415+ /**
416+ * Read species `k` at render resolution (`viewSht`'s grid): the spectral
417+ * state synthesized there. The models define each species as synth of its
418+ * state, so this is the field the .m returned — evaluated exactly, whatever
419+ * the grid — and it is current however the state last changed, including a
420+ * `loadState`, which runs no kernel that would write the grid-space fields.
297421 */
298422 readSpecies(k: number): Promise<Float32Array> {
299- if (!this.#displaySht) return this.read(this.model.species[k]);
300423 const state = this.model.state[k];
301424 const buf = this.gpu.valueBuffer(state);
302425 if (!buf) throw new Error(`readSpecies: no buffer for state '${state}'`);
303- return this.#displaySht.synthFrom(buf);
426+ return this.viewSht.synthFrom(buf);
304427 }
305428
306429 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/colorbar.tsmodified+52−0View file
@@ -4,6 +4,58 @@ import type { ColormapFunc } from './colormaps.ts';
44 export const fmtValue = (v: number): string =>
55 Number.isFinite(v) ? v.toPrecision(3).replace(/\.?0+$/, '') : '—';
66
7+/**
8+ * Smallest span the colormap may be stretched across, relative to the field's
9+ * own magnitude.
10+ *
11+ * Set from measurement, not taste. A constant field analysed and re-synthesized
12+ * in fp32 comes back constant only to
13+ *
14+ * lmax 63: 2.9e-5 relative lmax 127: 9.6e-5 lmax 255: 2.4e-4
15+ *
16+ * and the residue is not white noise — it is concentrated in a few rings at the
17+ * poles (74x the equatorial level at lmax 63, 1710x at lmax 255), because what
18+ * survives the analysis is high-degree m = 0 content whose Legendre functions
19+ * all peak at the poles *and add in phase there*. The same round trip in f64 is
20+ * 2.4e-8 and flat, so this is fp32, not the algorithm.
21+ *
22+ * A floor of 1e-2 puts the worst of that (about 5e-4 of span at lmax 255) into
23+ * roughly 5% of the colormap rather than all of it, while the variation these
24+ * models actually carry — a few percent of the field's magnitude and up — is
25+ * left alone entirely.
26+ */
27+const RANGE_FLOOR_REL = 1e-2;
28+/** And an absolute floor, for a field whose magnitude is itself near zero. */
29+const RANGE_FLOOR_ABS = 1e-9;
30+
31+/**
32+ * Widen a value range so that a field which is uniform to numerical precision
33+ * is drawn as uniform.
34+ *
35+ * Scaling the colormap to a field's own extremes gives full contrast to
36+ * whatever variation it has — including none. Schnakenberg's `v` at t = 0 is
37+ * literally constant (`vs * ones(...)`), so its extremes are set purely by the
38+ * roundoff described above; painting that across the whole colormap produces a
39+ * vivid pole-capped picture that reads as structure, and since the residue
40+ * belongs to the grid, two runs at different lmax produce two entirely
41+ * different pictures of the same constant — which looks exactly like a broken
42+ * initial condition, and is not one.
43+ *
44+ * A floor rather than an "is this field constant?" test, so nothing ever jumps:
45+ * a real pattern growing up through the floor hands the range over from the
46+ * floor to its own data gradually, and once it is any larger than roundoff the
47+ * floor has no effect at all.
48+ */
49+export function floorRange(lo: number, hi: number): { lo: number; hi: number } {
50+ const minSpan = Math.max(
51+ RANGE_FLOOR_ABS,
52+ RANGE_FLOOR_REL * Math.max(Math.abs(lo), Math.abs(hi)),
53+ );
54+ if (hi - lo >= minSpan) return { lo, hi };
55+ const mid = (lo + hi) / 2;
56+ return { lo: mid - minSpan / 2, hi: mid + minSpan / 2 };
57+}
58+
759 /** Vertical colorbar drawn on a small canvas, with min/max labels. */
860 export class Colorbar {
961 #canvas: HTMLCanvasElement;
src/sht/deriv.tsmodified+82−26View file
@@ -10,6 +10,16 @@
1010 * pipeline (ShtPlan.createSynthBinding/encodeSynthInto) unchanged -- neither
1111 * derivative touches the Legendre recurrence stage itself. dtheta
1212 * additionally divides by sin(theta) on the grid afterwards.
13+ *
14+ * The two shuffles are also exposed on their own, coefficients -> coefficients
15+ * (`dthetac`, `dphic`), because the six-transform Laplace-Beltrami operator of
16+ * docs/reduced-transforms.md needs them apart from a
17+ * synthesis, and needs them twice: once on the field (steps 1-2) and once on
18+ * the two fluxes (step 5, which is the *same* alpha^+/alpha^- gather, not its
19+ * transpose). Everything above is then a composition of them:
20+ *
21+ * dphi(U) == synth(dphic(U))
22+ * dtheta(U) == synth(dthetac(U)) / sin(theta)
1323 */
1424 import type { ShtPlan, ShtBinding } from './sht.ts';
1525 import { derivCoeffs } from './derivCoeffs.ts';
@@ -120,17 +130,55 @@ export class DerivPlan {
120130 this.pipeDivide = pDivide;
121131 }
122132
123- /** Bindings for dtheta(qlmIn) -> spatOut, against caller-owned buffers. */
124- createDthetaBinding(qlmIn: GPUBuffer, spatOut: GPUBuffer): DerivBinding {
125- const shuffle = this.device.createBindGroup({
133+ /**
134+ * Bind group for the coefficient-space half of dtheta on its own:
135+ * v_l^m = alpha^+(l-1,m) u_{l-1}^m + alpha^-(l+1,m) u_{l+1}^m, the
136+ * coefficients of sin(theta) * dtheta(u). Input and output must be
137+ * different buffers -- WebGPU forbids binding one buffer as both readable
138+ * and writable storage in a dispatch, and the gather reads l+-1 anyway.
139+ */
140+ createDthetacBinding(qlmIn: GPUBuffer, qlmOut: GPUBuffer): GPUBindGroup {
141+ return this.device.createBindGroup({
126142 layout: this.pipeDtheta.getBindGroupLayout(0),
127143 entries: [
128144 { binding: 0, resource: { buffer: this.bufAPlus } },
129145 { binding: 1, resource: { buffer: this.bufAMinus } },
130146 { binding: 2, resource: { buffer: qlmIn } },
131- { binding: 3, resource: { buffer: this.scratch } },
147+ { binding: 3, resource: { buffer: qlmOut } },
148+ ],
149+ });
150+ }
151+
152+ /** Bind group for the coefficient-space half of dphi on its own:
153+ * (dphi u)_l^m = i*m*u_l^m. Same buffer restriction as dthetac. */
154+ createDphicBinding(qlmIn: GPUBuffer, qlmOut: GPUBuffer): GPUBindGroup {
155+ return this.device.createBindGroup({
156+ layout: this.pipeDphi.getBindGroupLayout(0),
157+ entries: [
158+ { binding: 0, resource: { buffer: this.bufMOf } },
159+ { binding: 1, resource: { buffer: qlmIn } },
160+ { binding: 2, resource: { buffer: qlmOut } },
132161 ],
133162 });
163+ }
164+
165+ /** Record the bare alpha^+/alpha^- shift into an existing compute pass. */
166+ encodeDthetacInto(pass: GPUComputePassEncoder, bindGroup: GPUBindGroup): void {
167+ pass.setPipeline(this.pipeDtheta);
168+ pass.setBindGroup(0, bindGroup);
169+ pass.dispatchWorkgroups(Math.ceil(this.nlm / WG));
170+ }
171+
172+ /** Record the bare i*m multiply into an existing compute pass. */
173+ encodeDphicInto(pass: GPUComputePassEncoder, bindGroup: GPUBindGroup): void {
174+ pass.setPipeline(this.pipeDphi);
175+ pass.setBindGroup(0, bindGroup);
176+ pass.dispatchWorkgroups(Math.ceil(this.nlm / WG));
177+ }
178+
179+ /** Bindings for dtheta(qlmIn) -> spatOut, against caller-owned buffers. */
180+ createDthetaBinding(qlmIn: GPUBuffer, spatOut: GPUBuffer): DerivBinding {
181+ const shuffle = this.createDthetacBinding(qlmIn, this.scratch);
134182 const sht = this.sht.createSynthBinding(this.scratch, spatOut);
135183 const divide = this.device.createBindGroup({
136184 layout: this.pipeDivide.getBindGroupLayout(0),
@@ -144,48 +192,54 @@ export class DerivPlan {
144192
145193 /** Bindings for dphi(qlmIn) -> spatOut, against caller-owned buffers. */
146194 createDphiBinding(qlmIn: GPUBuffer, spatOut: GPUBuffer): DerivBinding {
147- const shuffle = this.device.createBindGroup({
148- layout: this.pipeDphi.getBindGroupLayout(0),
149- entries: [
150- { binding: 0, resource: { buffer: this.bufMOf } },
151- { binding: 1, resource: { buffer: qlmIn } },
152- { binding: 2, resource: { buffer: this.scratch } },
153- ],
154- });
195+ const shuffle = this.createDphicBinding(qlmIn, this.scratch);
155196 const sht = this.sht.createSynthBinding(this.scratch, spatOut);
156197 return { shuffle, sht };
157198 }
158199
159200 /** Record dtheta into an existing compute pass. */
160201 encodeDthetaInto(pass: GPUComputePassEncoder, b: DerivBinding): void {
161- pass.setPipeline(this.pipeDtheta);
162- pass.setBindGroup(0, b.shuffle);
163- pass.dispatchWorkgroups(Math.ceil(this.nlm / WG));
164- this.sht.encodeSynthInto(pass, b.sht);
202+ this.encodeSinDthetaInto(pass, b);
165203 pass.setPipeline(this.pipeDivide);
166204 pass.setBindGroup(0, b.divide!);
167205 pass.dispatchWorkgroups(Math.ceil(this.npts / WG));
168206 }
169207
208+ /** Record dtheta *without* its final division: the grid values of
209+ * sin(theta) * dtheta(u), which unlike dtheta(u) itself is a smooth
210+ * function on the sphere. Takes a dtheta binding and simply stops early. */
211+ encodeSinDthetaInto(pass: GPUComputePassEncoder, b: DerivBinding): void {
212+ this.encodeDthetacInto(pass, b.shuffle);
213+ this.sht.encodeSynthInto(pass, b.sht);
214+ }
215+
170216 /** Record dphi into an existing compute pass. */
171217 encodeDphiInto(pass: GPUComputePassEncoder, b: DerivBinding): void {
172- pass.setPipeline(this.pipeDphi);
173- pass.setBindGroup(0, b.shuffle);
174- pass.dispatchWorkgroups(Math.ceil(this.nlm / WG));
218+ this.encodeDphicInto(pass, b.shuffle);
175219 this.sht.encodeSynthInto(pass, b.sht);
176220 }
177221
178222 /** CPU convenience: qlm (interleaved [re,im], length 2*nlm) -> grid field. */
179223 async dtheta(qlm: Float32Array): Promise<Float32Array> {
180- return this.#runToGrid(qlm, true);
224+ return this.#runToGrid(qlm, 'dtheta');
225+ }
226+
227+ /** CPU convenience: sin(theta) * dtheta(u) on the grid, the undivided
228+ * synthesis of the alpha shift. What the flux-form metric precompute
229+ * (src/geom/metric.ts) is built from. */
230+ async sinDtheta(qlm: Float32Array): Promise<Float32Array> {
231+ return this.#runToGrid(qlm, 'sinDtheta');
181232 }
182233
183234 /** CPU convenience: qlm (interleaved [re,im], length 2*nlm) -> grid field. */
184235 async dphi(qlm: Float32Array): Promise<Float32Array> {
185- return this.#runToGrid(qlm, false);
236+ return this.#runToGrid(qlm, 'dphi');
186237 }
187238
188- async #runToGrid(qlm: Float32Array, withDivide: boolean): Promise<Float32Array> {
239+ async #runToGrid(
240+ qlm: Float32Array,
241+ mode: 'dtheta' | 'sinDtheta' | 'dphi',
242+ ): Promise<Float32Array> {
189243 if (qlm.length !== 2 * this.nlm) throw new Error(`qlm must have length ${2 * this.nlm}`);
190244 const dev = this.device;
191245 const qlmIn = dev.createBuffer({
@@ -205,12 +259,14 @@ export class DerivPlan {
205259 });
206260 try {
207261 dev.queue.writeBuffer(qlmIn, 0, qlm as Float32Array<ArrayBuffer>);
208- const binding = withDivide
209- ? this.createDthetaBinding(qlmIn, spatOut)
210- : this.createDphiBinding(qlmIn, spatOut);
262+ const binding =
263+ mode === 'dphi'
264+ ? this.createDphiBinding(qlmIn, spatOut)
265+ : this.createDthetaBinding(qlmIn, spatOut);
211266 const enc = dev.createCommandEncoder({ label: 'deriv-run' });
212267 const pass = enc.beginComputePass({ label: 'deriv-run' });
213- if (withDivide) this.encodeDthetaInto(pass, binding);
268+ if (mode === 'dtheta') this.encodeDthetaInto(pass, binding);
269+ else if (mode === 'sinDtheta') this.encodeSinDthetaInto(pass, binding);
214270 else this.encodeDphiInto(pass, binding);
215271 pass.end();
216272 enc.copyBufferToBuffer(spatOut, 0, stage, 0, 4 * this.npts);
src/sht/reference.tsmodified+34−13View file
@@ -119,11 +119,15 @@ export class ShtReference {
119119 }
120120
121121 /**
122- * Theta-derivative, f64: v_l^m = alpha^+(l-1,m) u_{l-1}^m + alpha^-(l+1,m)
123- * u_{l+1}^m (algos.tex eq. v_coeffs), then synth(v_l^m) / sin(theta).
122+ * Coefficient-space theta shift, f64: v_l^m = alpha^+(l-1,m) u_{l-1}^m +
123+ * alpha^-(l+1,m) u_{l+1}^m (algos.tex eq. v_coeffs) — the coefficients of
124+ * sin(theta) * dtheta(u). The same shift serves the divergence side of the
125+ * six-transform Laplace-Beltrami scheme (step 5 of
126+ * docs/reduced-transforms.md), which is why it is exposed
127+ * apart from the synthesis.
124128 */
125- dtheta(qlm: ArrayLike<number>): Float64Array {
126- const { lmax, mmax, nlat, nphi } = this.cfg;
129+ dthetac(qlm: ArrayLike<number>): Float64Array {
130+ const { lmax, mmax } = this.cfg;
127131 const v = new Float64Array(2 * this.nlm);
128132 for (let m = 0; m <= mmax; m++) {
129133 for (let l = m; l <= lmax; l++) {
@@ -146,16 +150,11 @@ export class ShtReference {
146150 v[2 * lm + 1] = im;
147151 }
148152 }
149- const grid = this.synth(v);
150- for (let i = 0; i < nlat; i++) {
151- const st = this.st[i];
152- for (let j = 0; j < nphi; j++) grid[i * nphi + j] /= st;
153- }
154- return grid;
153+ return v;
155154 }
156155
157- /** Phi-derivative, f64: (dphi u)_l^m = i*m*u_l^m, then synthesize. */
158- dphi(qlm: ArrayLike<number>): Float64Array {
156+ /** Coefficient-space phi derivative, f64: (dphi u)_l^m = i*m*u_l^m. */
157+ dphic(qlm: ArrayLike<number>): Float64Array {
159158 const { lmax, mmax } = this.cfg;
160159 const v = new Float64Array(2 * this.nlm);
161160 for (let m = 0; m <= mmax; m++) {
@@ -165,7 +164,29 @@ export class ShtReference {
165164 v[2 * lm + 1] = m * qlm[2 * lm];
166165 }
167166 }
168- return this.synth(v);
167+ return v;
168+ }
169+
170+ /** sin(theta) * dtheta(u) on the grid, f64: the undivided synthesis of the
171+ * theta shift. Smooth on the sphere, unlike dtheta(u) itself. */
172+ sinDtheta(qlm: ArrayLike<number>): Float64Array {
173+ return this.synth(this.dthetac(qlm));
174+ }
175+
176+ /** Theta-derivative, f64: synth(dthetac(u)) / sin(theta). */
177+ dtheta(qlm: ArrayLike<number>): Float64Array {
178+ const { nlat, nphi } = this.cfg;
179+ const grid = this.sinDtheta(qlm);
180+ for (let i = 0; i < nlat; i++) {
181+ const st = this.st[i];
182+ for (let j = 0; j < nphi; j++) grid[i * nphi + j] /= st;
183+ }
184+ return grid;
185+ }
186+
187+ /** Phi-derivative, f64: (dphi u)_l^m = i*m*u_l^m, then synthesize. */
188+ dphi(qlm: ArrayLike<number>): Float64Array {
189+ return this.synth(this.dphic(qlm));
169190 }
170191 }
171192
src/sht/sht.tsmodified+286−4View file
@@ -9,7 +9,13 @@
99 import { gaussNodesWeights } from './gauss.ts';
1010 import { legendreCoeffs } from './coeffs.ts';
1111 import { nlmCalc, validateConfig, isPowerOfTwo, type ShtConfig } from './layout.ts';
12-import { legSynthWGSL, legAnalysWGSL } from './wgsl/leg.ts';
12+import {
13+ legSynthWGSL,
14+ legAnalysWGSL,
15+ legSynthBatchWGSL,
16+ legAnalysBatchWGSL,
17+} from './wgsl/leg.ts';
18+import { fmDphiWGSL } from './wgsl/deriv.ts';
1319 import {
1420 fftSynthWGSL,
1521 fftAnalysWGSL,
@@ -28,6 +34,27 @@ export interface ShtBinding {
2834 readonly bgFour: GPUBindGroup;
2935 }
3036
37+/** The three bind groups of one grid-space phi-derivative (see dphig). */
38+export interface ShtDphigBinding {
39+ readonly bgFourAnalys: GPUBindGroup;
40+ readonly bgMul: GPUBindGroup;
41+ readonly bgFourSynth: GPUBindGroup;
42+}
43+
44+/**
45+ * One batched transform: K fields through a single Legendre dispatch (the
46+ * recurrence walked once, K accumulator lanes) plus K per-field Fourier
47+ * dispatches — the Fourier stage shares nothing across fields, so batching
48+ * it would save only bind-group switches.
49+ */
50+export interface ShtBatchBinding {
51+ /** Lanes in this batch — selects the pipeline compiled for that width. */
52+ readonly size: number;
53+ readonly bgLeg: GPUBindGroup;
54+ /** Per-lane Fourier bind group, lane k against fm arena k. */
55+ readonly bgFour: GPUBindGroup[];
56+}
57+
3158 const bgEntries = (bufs: GPUBuffer[]) =>
3259 bufs.map((buffer, binding) => ({ binding, resource: { buffer } }));
3360
@@ -119,6 +146,16 @@ export class ShtPlan {
119146 readonly fourierMode: 'fft' | 'dft';
120147 /** Latitudes leg_synth walks: nlat/2 when parity folding. */
121148 readonly legLat: number = 0;
149+ /**
150+ * Widest transform batch this plan supports: the largest even K <= 4 whose
151+ * Legendre bind group (3 tables + K caller fields + the shared fm arena)
152+ * fits the device's storage-buffer limit. K = 4 needs exactly the WebGPU
153+ * default of 8, so batching is fully available on every stack; 1 (no
154+ * batching) if SHT_BATCH is turned off. Batched and scalar transforms
155+ * compute identical per-lane arithmetic, so this only affects speed,
156+ * never results.
157+ */
158+ readonly batchK: number = 1;
122159 /** Colatitudes theta_i (f64, increasing: north to south). */
123160 readonly theta: Float64Array;
124161 readonly cosTheta: Float64Array;
@@ -146,6 +183,18 @@ export class ShtPlan {
146183 private pipeLegAnalys!: GPUComputePipeline;
147184 private pipeFourSynth!: GPUComputePipeline;
148185 private pipeFourAnalys!: GPUComputePipeline;
186+ /** Fourier-space i*m multiply, the middle of dphig. */
187+ private pipeFmDphi!: GPUComputePipeline;
188+ /** Batched Legendre pipelines by lane count (even sizes up to batchK). */
189+ private pipeLegSynthB = new Map<number, GPUComputePipeline>();
190+ private pipeLegAnalysB = new Map<number, GPUComputePipeline>();
191+ /** One fm arena for all batch lanes (lane k at byte offset k * fmLaneBytes,
192+ * 256-aligned so the Fourier stage can bind a lane by buffer offset). A
193+ * single buffer keeps the batched Legendre bind group at 3 tables +
194+ * K fields + 1 arena — within WebGPU's default storage-buffer limit of 8
195+ * at K = 4, on every stack. */
196+ private fmArena: GPUBuffer | null = null;
197+ private fmLaneBytes = 0;
149198 private bgLegSynth!: GPUBindGroup;
150199 private bgLegAnalys!: GPUBindGroup;
151200 private bgFourSynth!: GPUBindGroup;
@@ -250,7 +299,7 @@ export class ShtPlan {
250299 this.fourierMode === 'fft' && nphi % 2 === 0 && tuning('SHT_REAL_FFT') !== false;
251300 const fftS = realFft ? fftSynthRealWGSL : fftSynthWGSL;
252301 const fftA = realFft ? fftAnalysRealWGSL : fftAnalysWGSL;
253- const [pLegS, pLegA, pFourS, pFourA] = await Promise.all([
302+ const [pLegS, pLegA, pFourS, pFourA, pFmDphi] = await Promise.all([
254303 makePipeline(dev, legSynthWGSL(legP), 'leg_synth'),
255304 makePipeline(dev, legAnalysWGSL(legP), 'leg_analys'),
256305 makePipeline(
@@ -263,11 +312,54 @@ export class ShtPlan {
263312 this.fourierMode === 'fft' ? fftA(fourP) : dftAnalysWGSL(fourP),
264313 this.fourierMode === 'fft' ? 'fft_analys' : 'dft_analys',
265314 ),
315+ // Mirrors filterMask: content at l >= lmax-2 is filtered on the
316+ // l-space route, so the m-space route keeps m <= lmax-3.
317+ makePipeline(
318+ dev,
319+ fmDphiWGSL({ mmax, nlat, nphi, mcut: lmax - 3 }),
320+ 'fm_dphi',
321+ ),
266322 ]);
267323 this.pipeLegSynth = pLegS;
268324 this.pipeLegAnalys = pLegA;
269325 this.pipeFourSynth = pFourS;
270326 this.pipeFourAnalys = pFourA;
327+ this.pipeFmDphi = pFmDphi;
328+
329+ // --- batched Legendre pipelines ---
330+ // The widest even K <= 4 whose bind group (3 tables + K fields + the fm
331+ // arena) fits the device's storage-buffer budget — K = 4 needs 8, the
332+ // WebGPU default, so batching is fully available everywhere unless
333+ // SHT_BATCH=0 disables it (SHT_BATCH=2 caps it, for A/B).
334+ const batchTuning = tuning('SHT_BATCH');
335+ const batchWant =
336+ batchTuning === false || batchTuning === 0
337+ ? 1
338+ : typeof batchTuning === 'number'
339+ ? batchTuning
340+ : 4;
341+ const batchFit = dev.limits.maxStorageBuffersPerShaderStage - 4;
342+ const batchK = Math.min(4, Math.max(1, batchWant), 2 * Math.floor(batchFit / 2));
343+ (this as { batchK: number }).batchK = batchK;
344+ if (batchK >= 2) {
345+ // Lane stride rounded to the 256-byte offset alignment buffer bindings
346+ // require; laneElems is that stride in vec2f units for the kernels.
347+ this.fmLaneBytes = Math.ceil((8 * (mmax + 1) * nlat) / 256) * 256;
348+ const laneElems = this.fmLaneBytes / 8;
349+ this.fmArena = mkBuf('sht-fm-arena', batchK * this.fmLaneBytes, GPUBufferUsage.STORAGE);
350+ const sizes = [];
351+ for (let k = 2; k <= batchK; k += 2) sizes.push(k);
352+ const pipes = await Promise.all(
353+ sizes.flatMap((k) => [
354+ makePipeline(dev, legSynthBatchWGSL(legP, k, laneElems), `leg_synth_batch`),
355+ makePipeline(dev, legAnalysBatchWGSL(legP, k, laneElems), `leg_analys_batch`),
356+ ]),
357+ );
358+ sizes.forEach((k, i) => {
359+ this.pipeLegSynthB.set(k, pipes[2 * i]);
360+ this.pipeLegAnalysB.set(k, pipes[2 * i + 1]);
361+ });
362+ }
271363
272364 const entries = bgEntries;
273365 this.bgLegSynth = dev.createBindGroup({
@@ -322,6 +414,184 @@ export class ShtPlan {
322414 };
323415 }
324416
417+ /**
418+ * Bind groups for one batched synthesis: members.length must be a compiled
419+ * lane count (an even size <= batchK). Member outputs must be distinct
420+ * buffers; each lane gets its own fm arena, so batches compose in a pass
421+ * exactly like sequential scalar transforms do.
422+ */
423+ /** The fm arena sliced at lane k, sized as one transform's fm. */
424+ #fmLane(k: number): GPUBufferBinding {
425+ const { mmax, nlat } = this.cfg;
426+ return {
427+ buffer: this.fmArena!,
428+ offset: k * this.fmLaneBytes,
429+ size: 8 * (mmax + 1) * nlat,
430+ };
431+ }
432+
433+ createSynthBatchBinding(
434+ members: { qlmIn: GPUBuffer; spatOut: GPUBuffer }[],
435+ ): ShtBatchBinding {
436+ const K = members.length;
437+ const pipe = this.pipeLegSynthB.get(K);
438+ if (!pipe) throw new Error(`no batched synthesis pipeline for ${K} lanes`);
439+ return {
440+ size: K,
441+ bgLeg: this.device.createBindGroup({
442+ layout: pipe.getBindGroupLayout(0),
443+ entries: [
444+ ...bgEntries([this.bufAb, this.bufAmm, this.bufCtstw, ...members.map((m) => m.qlmIn)]),
445+ { binding: 3 + K, resource: { buffer: this.fmArena! } },
446+ ],
447+ }),
448+ bgFour: members.map((m, k) =>
449+ this.device.createBindGroup({
450+ layout: this.pipeFourSynth.getBindGroupLayout(0),
451+ entries: [
452+ { binding: 0, resource: this.#fmLane(k) },
453+ { binding: 1, resource: { buffer: m.spatOut } },
454+ { binding: 2, resource: { buffer: this.bufTrig } },
455+ ],
456+ }),
457+ ),
458+ };
459+ }
460+
461+ createAnalysBatchBinding(
462+ members: { spatIn: GPUBuffer; qlmOut: GPUBuffer }[],
463+ ): ShtBatchBinding {
464+ const K = members.length;
465+ const pipe = this.pipeLegAnalysB.get(K);
466+ if (!pipe) throw new Error(`no batched analysis pipeline for ${K} lanes`);
467+ return {
468+ size: K,
469+ bgFour: members.map((m, k) =>
470+ this.device.createBindGroup({
471+ layout: this.pipeFourAnalys.getBindGroupLayout(0),
472+ entries: [
473+ { binding: 0, resource: { buffer: m.spatIn } },
474+ { binding: 1, resource: this.#fmLane(k) },
475+ { binding: 2, resource: { buffer: this.bufTrig } },
476+ ],
477+ }),
478+ ),
479+ bgLeg: this.device.createBindGroup({
480+ layout: pipe.getBindGroupLayout(0),
481+ entries: [
482+ ...bgEntries([this.bufAb, this.bufAmm, this.bufCtstw]),
483+ { binding: 3, resource: { buffer: this.fmArena! } },
484+ ...members.map((m, k) => ({
485+ binding: 4 + k,
486+ resource: { buffer: m.qlmOut },
487+ })),
488+ ],
489+ }),
490+ };
491+ }
492+
493+ /**
494+ * Bind groups for one grid-space phi-derivative, dphig: Fourier analysis
495+ * of each latitude row into fm (which truncates to m <= mmax for free),
496+ * the i*m/NPHI multiply (zeroing m past the top-degree filt's reach), and
497+ * Fourier synthesis back to the grid. No Legendre stage anywhere — this
498+ * is what lets the flux-form divergence drop the Q-flux's spherical-
499+ * harmonic analysis (docs/reduced-transforms.md Sec 5b's companion trick
500+ * in Sec 6-of-changes): d/dphi is diagonal in the Fourier index. Uses
501+ * fmBuf as scratch, sequentially like every transform in a pass.
502+ */
503+ createDphigBinding(spatIn: GPUBuffer, spatOut: GPUBuffer): ShtDphigBinding {
504+ return {
505+ bgFourAnalys: this.device.createBindGroup({
506+ layout: this.pipeFourAnalys.getBindGroupLayout(0),
507+ entries: bgEntries([spatIn, this.fmBuf, this.bufTrig]),
508+ }),
509+ bgMul: this.device.createBindGroup({
510+ layout: this.pipeFmDphi.getBindGroupLayout(0),
511+ entries: bgEntries([this.fmBuf]),
512+ }),
513+ bgFourSynth: this.device.createBindGroup({
514+ layout: this.pipeFourSynth.getBindGroupLayout(0),
515+ entries: bgEntries([this.fmBuf, spatOut, this.bufTrig]),
516+ }),
517+ };
518+ }
519+
520+ /** Record dphig into an existing compute pass: two Fourier stages and a
521+ * pointwise multiply — no Legendre work. */
522+ encodeDphigInto(pass: GPUComputePassEncoder, b: ShtDphigBinding): void {
523+ const { mmax, nlat, nphi } = this.cfg;
524+ pass.setPipeline(this.pipeFourAnalys);
525+ pass.setBindGroup(0, b.bgFourAnalys);
526+ if (this.fourierMode === 'fft') {
527+ pass.dispatchWorkgroups(nlat);
528+ } else {
529+ pass.dispatchWorkgroups(Math.ceil((mmax + 1) / 64), nlat);
530+ }
531+ pass.setPipeline(this.pipeFmDphi);
532+ pass.setBindGroup(0, b.bgMul);
533+ pass.dispatchWorkgroups(Math.ceil(((mmax + 1) * nlat) / 64));
534+ pass.setPipeline(this.pipeFourSynth);
535+ pass.setBindGroup(0, b.bgFourSynth);
536+ if (this.fourierMode === 'fft') {
537+ pass.dispatchWorkgroups(nlat);
538+ } else {
539+ pass.dispatchWorkgroups(Math.ceil(nphi / 64), nlat);
540+ }
541+ }
542+
543+ /** CPU convenience: grid field -> d/dphi of its trig interpolant, for tests. */
544+ async dphig(spat: Float32Array): Promise<Float32Array> {
545+ const { nlat, nphi } = this.cfg;
546+ if (spat.length !== nlat * nphi) throw new Error(`spat must have length ${nlat * nphi}`);
547+ this.device.queue.writeBuffer(this.spatBuf, 0, spat as Float32Array<ArrayBuffer>);
548+ const binding = this.createDphigBinding(this.spatBuf, this.spatBuf);
549+ const enc = this.device.createCommandEncoder({ label: 'sht-dphig' });
550+ const pass = enc.beginComputePass({ label: 'sht-dphig' });
551+ this.encodeDphigInto(pass, binding);
552+ pass.end();
553+ enc.copyBufferToBuffer(this.spatBuf, 0, this.stageSpat, 0, 4 * nlat * nphi);
554+ this.device.queue.submit([enc.finish()]);
555+ await this.stageSpat.mapAsync(GPUMapMode.READ);
556+ const out = new Float32Array(this.stageSpat.getMappedRange().slice(0));
557+ this.stageSpat.unmap();
558+ return out;
559+ }
560+
561+ /** Record a batched synthesis: one Legendre dispatch, K Fourier dispatches. */
562+ encodeSynthBatchInto(pass: GPUComputePassEncoder, b: ShtBatchBinding): void {
563+ const { mmax, nlat, nphi } = this.cfg;
564+ pass.setPipeline(this.pipeLegSynthB.get(b.size)!);
565+ pass.setBindGroup(0, b.bgLeg);
566+ pass.dispatchWorkgroups(Math.ceil(this.legLat / WG_SYNTH), mmax + 1);
567+ pass.setPipeline(this.pipeFourSynth);
568+ for (const bg of b.bgFour) {
569+ pass.setBindGroup(0, bg);
570+ if (this.fourierMode === 'fft') {
571+ pass.dispatchWorkgroups(nlat);
572+ } else {
573+ pass.dispatchWorkgroups(Math.ceil(nphi / 64), nlat);
574+ }
575+ }
576+ }
577+
578+ /** Record a batched analysis: K Fourier dispatches, one Legendre dispatch. */
579+ encodeAnalysBatchInto(pass: GPUComputePassEncoder, b: ShtBatchBinding): void {
580+ const { mmax, nlat } = this.cfg;
581+ pass.setPipeline(this.pipeFourAnalys);
582+ for (const bg of b.bgFour) {
583+ pass.setBindGroup(0, bg);
584+ if (this.fourierMode === 'fft') {
585+ pass.dispatchWorkgroups(nlat);
586+ } else {
587+ pass.dispatchWorkgroups(Math.ceil((mmax + 1) / 64), nlat);
588+ }
589+ }
590+ pass.setPipeline(this.pipeLegAnalysB.get(b.size)!);
591+ pass.setBindGroup(0, b.bgLeg);
592+ pass.dispatchWorkgroups(mmax + 1);
593+ }
594+
325595 /** Record synthesis into an existing compute pass. */
326596 encodeSynthInto(pass: GPUComputePassEncoder, b: ShtBinding): void {
327597 const { mmax, nlat, nphi } = this.cfg;
@@ -463,7 +733,7 @@ export class ShtPlan {
463733 destroy(): void {
464734 for (const b of [
465735 this.bufAb, this.bufAmm, this.bufCtstw, this.bufTrig, this.qlmIn, this.qlmOut,
466- this.fmBuf, this.spatBuf, this.stageSpat, this.stageQ,
736+ this.fmBuf, this.spatBuf, this.stageSpat, this.stageQ, this.fmArena,
467737 ]) b?.destroy();
468738 }
469739 }
@@ -506,8 +776,20 @@ export async function requestShtDevice(): Promise<GPUDevice> {
506776 // timestamp-query is only used by the profiling scripts, but it has to be
507777 // requested at device creation, and asking costs nothing when unused.
508778 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;
509787 return adapter.requestDevice({
510788 requiredFeatures: features,
511- requiredLimits: { maxComputeWorkgroupStorageSize: wgStorage },
789+ requiredLimits: {
790+ maxComputeWorkgroupStorageSize: wgStorage,
791+ maxStorageBufferBindingSize: maxStorage,
792+ maxBufferSize: maxBuffer,
793+ },
512794 });
513795 }
src/sht/wgsl/deriv.tsmodified+41−0View file
@@ -99,3 +99,44 @@ fn divide_sin_theta(@builtin(global_invocation_id) gid: vec3u) {
9999 }
100100 `;
101101 }
102+
103+export interface FmDphiParams {
104+ mmax: number;
105+ nlat: number;
106+ nphi: number;
107+ /** Highest m kept; modes above are zeroed (mirrors the l-space filt). */
108+ mcut: number;
109+}
110+
111+/**
112+ * The Fourier-space middle of the grid-space phi-derivative `dphig`:
113+ * fm holds the unnormalized DFT modes of each latitude row (what the
114+ * Fourier analysis stage produces), so d/dphi is fm[m] *= i*m/NPHI --
115+ * the 1/NPHI undoes the unnormalized analysis+synthesis round trip.
116+ * Modes above MCUT are zeroed: the Fourier analysis stage already
117+ * truncated m > mmax for free, and MCUT additionally mirrors the
118+ * top-degree filt so the differentiated field carries no content the
119+ * l-space route would not have kept.
120+ */
121+export function fmDphiWGSL(p: FmDphiParams): string {
122+ const count = (p.mmax + 1) * p.nlat;
123+ return /* wgsl */ `
124+const NLAT: u32 = ${p.nlat}u;
125+const COUNT: u32 = ${count}u;
126+const MCUT: u32 = ${Math.max(0, p.mcut)}u;
127+const INV_NPHI: f32 = ${1 / p.nphi};
128+
129+@group(0) @binding(0) var<storage, read_write> fm: array<vec2f>;
130+
131+@compute @workgroup_size(${WG})
132+fn fm_dphi(@builtin(global_invocation_id) gid: vec3u) {
133+ let i = gid.x;
134+ if (i >= COUNT) { return; }
135+ let m = i / NLAT;
136+ var k: f32 = 0.0;
137+ if (m <= MCUT) { k = f32(m) * INV_NPHI; }
138+ let c = fm[i];
139+ fm[i] = vec2f(-k * c.y, k * c.x);
140+}
141+`;
142+}
src/sht/wgsl/leg.tsmodified+336−0View file
@@ -350,3 +350,339 @@ ${
350350 }
351351 `;
352352 }
353+
354+/**
355+ * Batched transforms: K independent fields through ONE walk of the Legendre
356+ * recurrence. The recurrence state (y0, y1, rescaling) depends only on
357+ * (m, theta), never on the field, so a batch shares it and pays only the
358+ * extra data fetches and accumulators per lane — the same amortization
359+ * SHTNS's GPU backend gets from batching fields. Per-lane arithmetic is
360+ * textually identical to the scalar kernels' (same operations, same order),
361+ * so a batched transform reproduces the scalar transform's results.
362+ *
363+ * K is a codegen parameter. The bind group needs 3 tables + K inputs +
364+ * K outputs storage buffers, so K = 2 (7 bindings) fits WebGPU's default
365+ * limit of 8 on every stack including SwiftShader, and K = 4 (11) needs the
366+ * raised limit requestShtDevice asks for where the adapter offers it.
367+ * The recurrence bodies are kept in exactly the shape the scalar kernels
368+ * use — see the driver-workaround comment in legSynthWGSL before
369+ * "simplifying" either copy.
370+ */
371+export function legSynthBatchWGSL(p: LegParams, K: number, laneElems: number): string {
372+ const half = p.parity === true;
373+ const lanes = Array.from({ length: K }, (_, k) => k);
374+ // K caller-owned inputs, ONE plan-owned fm arena: lane k writes at a fixed
375+ // 256-byte-aligned offset (laneElems vec2f), which is what keeps the bind
376+ // group at 3 + K + 1 storage buffers -- within WebGPU's default limit of 8
377+ // at K = 4. The Fourier stage binds the arena per lane with a buffer
378+ // offset, so it needs no changes.
379+ const bind =
380+ lanes
381+ .map((k) => `@group(0) @binding(${3 + k}) var<storage, read> qlm${k}: array<vec2f>;`)
382+ .join('\n') +
383+ `\n@group(0) @binding(${3 + K}) var<storage, read_write> fm: array<vec2f>;`;
384+ const decl = lanes
385+ .map((k) =>
386+ half
387+ ? ` var accE${k} = vec2f(0.0);\n var accO${k} = vec2f(0.0);`
388+ : ` var acc${k} = vec2f(0.0);`,
389+ )
390+ .join('\n');
391+ const accEven = lanes
392+ .map((k) => (half ? ` accE${k} += y0 * qlm${k}[i0];` : ` acc${k} += y0 * qlm${k}[i0];`))
393+ .join('\n');
394+ const accOdd = lanes
395+ .map((k) => (half ? ` accO${k} += y1 * qlm${k}[i1];` : ` acc${k} += y1 * qlm${k}[i1];`))
396+ .join('\n');
397+ const store = lanes
398+ .map((k) =>
399+ half
400+ ? ` fm[${k}u * LANE + m * NLAT + ilat] = accE${k} + accO${k};\n` +
401+ ` fm[${k}u * LANE + m * NLAT + (NLAT - 1u - ilat)] = accE${k} - accO${k};`
402+ : ` fm[${k}u * LANE + m * NLAT + ilat] = acc${k};`,
403+ )
404+ .join('\n');
405+ return /* wgsl */ `
406+${RESCALE_WGSL}
407+const LMAX: u32 = ${p.lmax}u;
408+const NLAT: u32 = ${p.nlat}u;
409+const NLAT_2: u32 = ${p.nlat / 2}u;
410+const LANE: u32 = ${laneElems}u;
411+${BINDINGS}
412+${bind}
413+
414+@compute @workgroup_size(${p.wgSynth})
415+fn leg_synth_batch(@builtin(global_invocation_id) gid: vec3u,
416+ @builtin(workgroup_id) wid: vec3u) {
417+ let ilat = gid.x;
418+ let m = wid.y;
419+ if (ilat >= ${half ? 'NLAT_2' : 'NLAT'}) { return; }
420+
421+ let ct = ctstw[ilat];
422+ let st = ctstw[NLAT + ilat];
423+ let base = m * (LMAX + 1u) - (m * (m - 1u)) / 2u;
424+
425+ var seed = sinpow_rescaled(st, m);
426+ var y0 = seed.y0 * amm[m];
427+ var ny = seed.ny;
428+ var y1: f32 = 0.0;
429+ if (m < LMAX) {
430+ y1 = ab[base + 1u].x * ct * y0;
431+ }
432+
433+${decl}
434+ var l = m;
435+ loop {
436+ if (ny == 0) {
437+ let i0 = base + (l - m);
438+${accEven}
439+ if (l + 1u <= LMAX) {
440+ let i1 = base + (l + 1u - m);
441+${accOdd}
442+ }
443+ } else if (abs(y0) > RESCALE_THR) {
444+ ny += 1;
445+ y0 *= INV_SCALE;
446+ y1 *= INV_SCALE;
447+ }
448+ if (l + 2u > LMAX) { break; }
449+ // Same two-coefficient, temporary-carried shape as leg_synth (see the
450+ // driver-workaround comment there).
451+ let a0 = ab[base + (l + 2u - m)];
452+ var a1 = vec2f(0.0);
453+ if (l + 3u <= LMAX) {
454+ a1 = ab[base + (l + 3u - m)];
455+ }
456+ let t0 = a0.x * ct * y1 + a0.y * y0;
457+ y1 = a1.x * ct * t0 + a1.y * y1;
458+ y0 = t0;
459+ l += 2u;
460+ }
461+${store}
462+}
463+`;
464+}
465+
466+/** Batched analysis: K spatial-Fourier fields reduced against one Legendre
467+ * recurrence walk. Structure follows legAnalysWGSL; see legSynthBatchWGSL
468+ * for the batching rationale and the binding budget. */
469+export function legAnalysBatchWGSL(p: LegParams, K: number, laneElems: number): string {
470+ const half = p.parity === true;
471+ const lanes = Array.from({ length: K }, (_, k) => k);
472+ const Kl = Math.ceil((half ? p.nlat / 2 : p.nlat) / p.wgAnalys);
473+ const sg = p.subgroups === true;
474+ const nsubMax = Math.max(1, p.wgAnalys / 4);
475+ // Same 8 KB workgroup-storage budget as the scalar kernel, now split
476+ // across K lanes, so spans shorten as K grows: barriers per unit of work
477+ // stay level.
478+ const pairs = sg
479+ ? Math.max(1, Math.min(p.spanPairs ?? 16, Math.floor(8192 / (nsubMax * 16 * K))))
480+ : 1;
481+ const redLen = (sg ? nsubMax * pairs : p.wgAnalys) * K;
482+ // ONE fm arena in (lane offsets baked, as in legSynthBatchWGSL), K
483+ // caller-owned outputs: 3 + 1 + K storage buffers.
484+ const bind =
485+ `@group(0) @binding(3) var<storage, read> fm: array<vec2f>;\n` +
486+ lanes
487+ .map((k) => `@group(0) @binding(${4 + k}) var<storage, read_write> qout${k}: array<vec2f>;`)
488+ .join('\n');
489+ const laneState = lanes
490+ .map((k) =>
491+ half
492+ ? ` var wpv${k}: array<vec2f, ${Kl}>;\n var wmv${k}: array<vec2f, ${Kl}>;`
493+ : ` var wfv${k}: array<vec2f, ${Kl}>;`,
494+ )
495+ .join('\n');
496+ const laneLoad = lanes
497+ .map((k) =>
498+ half
499+ ? ` let gN${k} = fm[${k}u * LANE + m * NLAT + lat];
500+ let gS${k} = fm[${k}u * LANE + m * NLAT + (NLAT - 1u - lat)];
501+ wp${k} = (gN${k} + gS${k}) * w;
502+ wm${k} = (gN${k} - gS${k}) * w;`
503+ : ` wf${k} = fm[${k}u * LANE + m * NLAT + lat] * w;`,
504+ )
505+ .join('\n');
506+ const laneLoadDecl = lanes
507+ .map((k) =>
508+ half ? ` var wp${k} = vec2f(0.0);\n var wm${k} = vec2f(0.0);` : ` var wf${k} = vec2f(0.0);`,
509+ )
510+ .join('\n');
511+ const laneLoadStore = lanes
512+ .map((k) => (half ? ` wpv${k}[k] = wp${k};\n wmv${k}[k] = wm${k};` : ` wfv${k}[k] = wf${k};`))
513+ .join('\n');
514+ const cDecl = lanes.map((k) => ` var c0_${k} = vec2f(0.0);\n var c1_${k} = vec2f(0.0);`).join('\n');
515+ const cAcc = lanes
516+ .map((k) =>
517+ half
518+ ? ` c0_${k} += wpv${k}[k] * y0v[k];
519+ c1_${k} += wmv${k}[k] * y1v[k];`
520+ : ` c0_${k} += wfv${k}[k] * y0v[k];
521+ c1_${k} += wfv${k}[k] * y1v[k];`,
522+ )
523+ .join('\n');
524+ return /* wgsl */ `${sg ? 'enable subgroups;\n' : ''}
525+${RESCALE_WGSL}
526+const LMAX: u32 = ${p.lmax}u;
527+const NLAT: u32 = ${p.nlat}u;
528+const WG: u32 = ${p.wgAnalys}u;
529+const K: u32 = ${Kl}u;
530+const NLAT_2: u32 = ${p.nlat / 2}u;
531+const PAIRS: u32 = ${pairs}u;
532+const NB: u32 = ${K}u;
533+const LANE: u32 = ${laneElems}u;
534+${BINDINGS}
535+${bind}
536+
537+var<workgroup> red: array<vec4f, ${redLen}>;
538+
539+@compute @workgroup_size(${p.wgAnalys})
540+fn leg_analys_batch(@builtin(local_invocation_id) lid3: vec3u,
541+ @builtin(workgroup_id) wid: vec3u${
542+ sg
543+ ? ',\n @builtin(subgroup_size) sgSize: u32,\n @builtin(subgroup_invocation_id) sgLane: u32'
544+ : ''
545+ }) {
546+ let lid = lid3.x;
547+ let m = wid.x;
548+ let base = m * (LMAX + 1u) - (m * (m - 1u)) / 2u;
549+
550+ var y0v: array<f32, ${Kl}>;
551+ var y1v: array<f32, ${Kl}>;
552+ var nyv: array<i32, ${Kl}>;
553+ var ctv: array<f32, ${Kl}>;
554+${laneState}
555+
556+ for (var k = 0u; k < K; k++) {
557+ let lat = lid + k * WG;
558+ var ct: f32 = 0.0;
559+ var st: f32 = 0.0;
560+ var w: f32 = 0.0;
561+${laneLoadDecl}
562+ if (lat < ${half ? 'NLAT_2' : 'NLAT'}) {
563+ ct = ctstw[lat];
564+ st = ctstw[NLAT + lat];
565+ w = ctstw[2u * NLAT + lat];
566+${laneLoad}
567+ }
568+ ctv[k] = ct;
569+ let seed = sinpow_rescaled(st, m);
570+ y0v[k] = seed.y0 * amm[m];
571+ nyv[k] = seed.ny;
572+ y1v[k] = 0.0;
573+ if (m < LMAX) {
574+ y1v[k] = ab[base + 1u].x * ct * y0v[k];
575+ }
576+${laneLoadStore}
577+ }
578+
579+ var l = m;
580+${
581+ sg
582+ ? ` loop {
583+ let lstart = l;
584+ var npairs = 0u;
585+ var last = false;
586+ let sub = lid / sgSize;
587+ for (var jj = 0u; jj < PAIRS; jj++) {
588+${cDecl}
589+ for (var k = 0u; k < K; k++) {
590+ if (nyv[k] == 0) {
591+${cAcc}
592+ } else if (abs(y0v[k]) > RESCALE_THR) {
593+ nyv[k] += 1;
594+ y0v[k] *= INV_SCALE;
595+ y1v[k] *= INV_SCALE;
596+ }
597+ }
598+${lanes
599+ .map(
600+ (k) => ` let part${k} = subgroupAdd(vec4f(c0_${k}, c1_${k}));
601+ if (sgLane == 0u) { red[(sub * PAIRS + jj) * NB + ${k}u] = part${k}; }`,
602+ )
603+ .join('\n')}
604+ npairs = jj + 1u;
605+ if (l + 2u > LMAX) { last = true; break; }
606+ let a0 = ab[base + (l + 2u - m)];
607+ var a1 = vec2f(0.0);
608+ if (l + 3u <= LMAX) {
609+ a1 = ab[base + (l + 3u - m)];
610+ }
611+ for (var k = 0u; k < K; k++) {
612+ let t0 = a0.x * ctv[k] * y1v[k] + a0.y * y0v[k];
613+ y0v[k] = t0;
614+ y1v[k] = a1.x * ctv[k] * t0 + a1.y * y1v[k];
615+ }
616+ l += 2u;
617+ }
618+
619+ workgroupBarrier();
620+ if (lid == 0u) {
621+ let nsub = (WG + sgSize - 1u) / sgSize;
622+ for (var jj = 0u; jj < npairs; jj++) {
623+ let ll = lstart + 2u * jj;
624+${lanes
625+ .map(
626+ (k) => ` var tot${k} = vec4f(0.0);
627+ for (var i = 0u; i < nsub; i++) { tot${k} += red[(i * PAIRS + jj) * NB + ${k}u]; }
628+ qout${k}[base + (ll - m)] = tot${k}.xy;
629+ if (ll + 1u <= LMAX) {
630+ qout${k}[base + (ll + 1u - m)] = tot${k}.zw;
631+ }`,
632+ )
633+ .join('\n')}
634+ }
635+ }
636+ workgroupBarrier(); // red is reused by the next span
637+
638+ if (last) { break; }
639+ }`
640+ : ` loop {
641+${cDecl}
642+ for (var k = 0u; k < K; k++) {
643+ if (nyv[k] == 0) {
644+${cAcc.replace(/^ {10}/gm, ' ')}
645+ } else if (abs(y0v[k]) > RESCALE_THR) {
646+ nyv[k] += 1;
647+ y0v[k] *= INV_SCALE;
648+ y1v[k] *= INV_SCALE;
649+ }
650+ }
651+ // workgroup tree reduction, lane-strided
652+${lanes.map((k) => ` red[lid + ${k}u * WG] = vec4f(c0_${k}, c1_${k});`).join('\n')}
653+ workgroupBarrier();
654+ var s = WG / 2u;
655+ while (s > 0u) {
656+ if (lid < s) {
657+${lanes.map((k) => ` red[lid + ${k}u * WG] += red[lid + s + ${k}u * WG];`).join('\n')}
658+ }
659+ workgroupBarrier();
660+ s = s >> 1u;
661+ }
662+ if (lid == 0u) {
663+${lanes
664+ .map(
665+ (k) => ` qout${k}[base + (l - m)] = red[${k}u * WG].xy;
666+ if (l + 1u <= LMAX) {
667+ qout${k}[base + (l + 1u - m)] = red[${k}u * WG].zw;
668+ }`,
669+ )
670+ .join('\n')}
671+ }
672+ if (l + 2u > LMAX) { break; }
673+ let a0 = ab[base + (l + 2u - m)];
674+ var a1 = vec2f(0.0);
675+ if (l + 3u <= LMAX) {
676+ a1 = ab[base + (l + 3u - m)];
677+ }
678+ for (var k = 0u; k < K; k++) {
679+ let t0 = a0.x * ctv[k] * y1v[k] + a0.y * y0v[k];
680+ y0v[k] = t0;
681+ y1v[k] = a1.x * ctv[k] * t0 + a1.y * y1v[k];
682+ }
683+ l += 2u;
684+ }`
685+ }
686+}
687+`;
688+}
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.tsadded+371−0View file
@@ -0,0 +1,371 @@
1+/**
2+ * The two things a side-by-side comparison of solver settings rests on.
3+ *
4+ * Both are silent when broken: the panels still animate, the difference norm
5+ * still produces a number, and the number is simply wrong — it reports a
6+ * disagreement between two runs that were never solving the same problem, or
7+ * that were never at the same time. Neither failure looks like a failure, which
8+ * is exactly why they are pinned here.
9+ *
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.
17+ *
18+ * 2. One clock. dt varies by a power-of-two divisor, so `steps * dt` is
19+ * bit-identical across variants and no comparison is ever made across a
20+ * fraction of a timestep.
21+ *
22+ * Deliberately small — pairs of sessions at niter 1, lmax 31 and 63 — because a
23+ * session compiles its whole unrolled step and this suite has to stay short.
24+ */
25+import { ModelSession } from '../src/mgpu/session.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';
29+import { lmIndex, nlmCalc } from '../src/sht/layout.ts';
30+import { crossProduct, mostResolved } from '../src/compare/variants.ts';
31+import { floorRange } from '../src/render/colorbar.ts';
32+
33+type Check = (name: string, ok: boolean, detail: string) => void;
34+type Log = (line: string) => void;
35+
36+const COARSE = 31;
37+const FINE = 63;
38+
39+export async function compareChecks(
40+ device: GPUDevice,
41+ check: Check,
42+ log: Log,
43+): Promise<void> {
44+ log('\ncompare mode (convergence study):');
45+
46+ // ---- prolongCoeffs: every (l, m) lands on itself -------------------------
47+ {
48+ const src = new Float32Array(2 * nlmCalc(COARSE, COARSE));
49+ for (let m = 0; m <= COARSE; m++) {
50+ for (let l = m; l <= COARSE; l++) {
51+ const i = 2 * lmIndex(COARSE, l, m);
52+ src[i] = l + m / 100;
53+ src[i + 1] = -l - m / 100;
54+ }
55+ }
56+ const out = prolongCoeffs(src, COARSE, FINE);
57+ let moved = 0;
58+ let leaked = 0;
59+ for (let m = 0; m <= FINE; m++) {
60+ for (let l = m; l <= FINE; l++) {
61+ const j = 2 * lmIndex(FINE, l, m);
62+ if (l <= COARSE && m <= COARSE) {
63+ const i = 2 * lmIndex(COARSE, l, m);
64+ if (out[j] !== src[i] || out[j + 1] !== src[i + 1]) moved++;
65+ } else if (out[j] !== 0 || out[j + 1] !== 0) {
66+ leaked++;
67+ }
68+ }
69+ }
70+ check(
71+ 'compare: prolongation puts every coefficient at its own (l, m)',
72+ moved === 0 && leaked === 0,
73+ `${moved} misplaced, ${leaked} non-zero above the source band ` +
74+ `(${nlmCalc(COARSE, COARSE)} -> ${nlmCalc(FINE, FINE)} coefficients)`,
75+ );
76+ }
77+
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 -----------
146+ {
147+ const model = mModelByKey('schnakenberg')!;
148+ const params = defaultParams(model);
149+ const sessions: ModelSession[] = [];
150+ try {
151+ for (const lmax of [COARSE, FINE]) {
152+ sessions.push(await ModelSession.create({ device, model, params, lmax, niter: 1 }));
153+ }
154+ const [coarse, fine] = sessions;
155+
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.
161+ const noise = await sharedNoise(sessions, model.seedAmp, 1);
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]);
222+ const shared = compareStates(
223+ prolongCoeffs(await coarse.read('U'), COARSE, FINE),
224+ await fine.read('U'),
225+ );
226+ check(
227+ 'compare: one shared perturbation gives both grids the same state',
228+ shared.rel < 5e-5,
229+ `max |dU| = ${shared.abs.toExponential(2)} ` +
230+ `(${(100 * shared.rel).toFixed(4)}% of max |U| = ${shared.scale.toExponential(2)}) ` +
231+ `across lmax ${COARSE} vs ${FINE}`,
232+ );
233+
234+ await coarse.seed(1);
235+ await fine.seed(1);
236+ const plain = compareStates(
237+ prolongCoeffs(await coarse.read('U'), COARSE, FINE),
238+ await fine.read('U'),
239+ );
240+ check(
241+ 'compare: control — the same integer seed alone does not do it',
242+ plain.abs > 20 * shared.abs,
243+ `per-grid seeding differs by ${plain.abs.toExponential(2)}, ` +
244+ `${(plain.abs / Math.max(shared.abs, 1e-30)).toExponential(1)}x the shared start's ` +
245+ `(seed amplitude ${model.seedAmp})`,
246+ );
247+ log(
248+ ` shared start: ${shared.abs.toExponential(2)}, ` +
249+ `per-grid seeds: ${plain.abs.toExponential(2)}`,
250+ );
251+ } finally {
252+ for (const s of sessions) s.destroy();
253+ }
254+ }
255+
256+ // ---- one clock: steps * dt is bit-identical across the divisors ----------
257+ {
258+ const divisors = [1, 2, 4, 8];
259+ const steps = 4;
260+ let worst = 0;
261+ const cases: string[] = [];
262+ for (const model of ['schnakenberg', 'brusselator', 'allencahn']) {
263+ const dt = defaultParams(mModelByKey(model)!).dt;
264+ for (const div of divisors) {
265+ // A variant at dt/div takes div times as many steps to cover the same
266+ // span. Powers of two only touch the exponent, so both the divide and
267+ // the multiply back are exact and the two spans are the same float.
268+ const span = (steps * div) * (dt / div);
269+ const ulps = Math.abs(span - steps * dt);
270+ if (ulps > worst) worst = ulps;
271+ if (div === divisors[divisors.length - 1]) {
272+ cases.push(`${model} dt ${dt} -> ${dt / div}`);
273+ }
274+ }
275+ }
276+ check(
277+ 'compare: a power-of-two dt divisor keeps every variant on one clock',
278+ worst === 0,
279+ `exact for every shipped dt x ${divisors.join('/')} (${cases.join(', ')})`,
280+ );
281+ }
282+
283+ // ---- a uniform field is drawn uniform, on every grid --------------------
284+ // Schnakenberg seeds v as a literal constant (`vs * ones(...)`), so its whole
285+ // spread is the fp32 residue of the analys/synth round trip -- pole-localized
286+ // and grid-dependent, so scaled to its own extremes it paints two unrelated
287+ // pictures of the same constant, which is what a broken seeding would look
288+ // like. The spans below are measured (worst |deviation| x 2, on vs = 0.9):
289+ // lmax 63, 127, 255. See floorRange for where they come from.
290+ {
291+ const vs = 0.9;
292+ const spans = [5.2e-5, 1.8e-4, 4.4e-4];
293+ // Each must end up a small slice of the drawn range rather than all of it.
294+ const shares = spans.map((sp) => sp / (floorRange(vs - sp / 2, vs + sp / 2).hi -
295+ floorRange(vs - sp / 2, vs + sp / 2).lo));
296+ // ...while real structure keeps its own range exactly. v once the spots
297+ // have formed spans ~0.03 on the same 0.9, two orders above the residue.
298+ const real = floorRange(0.895, 0.924);
299+ check(
300+ 'compare: fp32 residue on a constant field does not become a picture',
301+ shares.every((s) => s < 0.1) && real.lo === 0.895 && real.hi === 0.924,
302+ `residue uses ${shares.map((s) => `${(100 * s).toFixed(1)}%`).join(', ')} ` +
303+ `of the colormap at lmax 63/127/255; real pattern ` +
304+ `[${real.lo}, ${real.hi}] left untouched`,
305+ );
306+ }
307+
308+ // ---- the variant grid and its reference ---------------------------------
309+ {
310+ const variants = crossProduct([1, 4], [31, 63], [1, 2]);
311+ const ref = variants[mostResolved(variants)];
312+ check(
313+ 'compare: the reference is the most-resolved corner of the grid',
314+ variants.length === 8 &&
315+ ref.niter === 4 && ref.lmax === 63 && ref.dtDiv === 2 &&
316+ new Set(variants.map((v) => `${v.niter}/${v.lmax}/${v.dtDiv}`)).size === 8,
317+ `${variants.length} distinct variants, reference niter ${ref.niter} · ` +
318+ `lmax ${ref.lmax} · dt/${ref.dtDiv}`,
319+ );
320+ }
321+}
322+
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+ */
359+function compareStates(
360+ a: Float32Array,
361+ b: Float32Array,
362+): { abs: number; rel: number; scale: number } {
363+ let abs = 0;
364+ let scale = 0;
365+ const n = Math.min(a.length, b.length);
366+ for (let i = 0; i < n; i++) {
367+ abs = Math.max(abs, Math.abs(a[i] - b[i]));
368+ if (i >= 2) scale = Math.max(scale, Math.abs(b[i]));
369+ }
370+ return { abs, rel: scale > 0 ? abs / scale : Infinity, scale };
371+}
test/fluxChecks.tsadded+476−0View file
@@ -0,0 +1,476 @@
1+/**
2+ * The flux-form (six-transform) Laplace-Beltrami scheme of
3+ * docs/reduced-transforms.md, against the two things that can
4+ * silently go wrong with it:
5+ *
6+ * 1. The smoothness claim (doc Sec 2, validation Sec 7.1). The whole scheme
7+ * rests on the analysed fluxes P and Qtilde being smooth functions on the
8+ * sphere — that is a mathematical property of the p1/p2/q2 weighting, so it
9+ * is checked in f64 on the CPU, where a failure is a wrong formula and not
10+ * round-off. The fields are synthesized and re-analysed on a grid with
11+ * twice the band limit: content beyond the band is exactly the non-smooth
12+ * residue the weighting is supposed to remove.
13+ *
14+ * Two surfaces split the claim's two halves. On the *round sphere* the
15+ * correctly weighted fluxes are exactly band-limited, so their beyond-band
16+ * tail is f64 round-off, while the doc's Sec 8 counterexample
17+ * Qtilde/sin(theta) — bounded but with a phi-dependent polar limit — keeps
18+ * an algebraically decaying tail orders of magnitude above it: the
19+ * decisive smooth-vs-non-smooth discrimination, plus the closed-form check
20+ * p1 = q2 = 1, p2 = 0, r = 1/sin^2(theta). On *bumpy* (non-axisymmetric,
21+ * so the off-diagonal p2 does real work) nothing is band-limited and every
22+ * smooth field's tail is set by the weights' own spectral decay, so the
23+ * check there is the doc's relative one: P and Qtilde must sit on the same
24+ * footing as the Cartesian gradient component Algorithm 4 analyses.
25+ *
26+ * 2. The operator identity (validation Sec 7.2/7.4). The flux form and the
27+ * Cartesian-gradient form (models/schnakenberg.m vs
28+ * models/schnakenberg_alg4.m) are the same operator, so a real simulation
29+ * driven by one must track the other to fp32 accumulation — checked on a
30+ * non-axisymmetric surface, where the off-diagonal weight p2 actually does
31+ * something. The headline transform count (6 vs 12 per species per
32+ * 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.
44+ */
45+import { ShtPlan } from '../src/sht/sht.ts';
46+import { DerivPlan } from '../src/sht/deriv.ts';
47+import { ShtReference } from '../src/sht/reference.ts';
48+import { gridForLmax, lmIndex, nlmCalc, type ShtConfig } from '../src/sht/layout.ts';
49+import { ModelSession } from '../src/mgpu/session.ts';
50+import { mModelByKey, defaultParams } from '../src/mgpu/registry.ts';
51+import { Geometry } from '../src/geom/geometry.ts';
52+import { mGeometryByKey, defaultGeometryParams } from '../src/geom/registry.ts';
53+import { computeFluxMetric } from '../src/geom/metric.ts';
54+import type { Check, Log } from './analyticChecks.ts';
55+
56+/** Band limit of the test surface and field. */
57+const LMAX = 24;
58+/** Band limit of the oversampled analysis grid the tails are measured on. */
59+const LMAX_HI = 63;
60+/** Degrees at and above this count as "beyond-band tail": LMAX+1 is the last
61+ * degree with direct content, and the smooth-but-not-band-limited metric
62+ * weights spread it upward with (their own) exponentially decaying spectra,
63+ * so the window starts well above the band edge. */
64+const TAIL_START = 44;
65+
66+/** The part of a transform layout the spectral helpers need. */
67+interface Band {
68+ lmax: number;
69+ mmax: number;
70+}
71+
72+/** Per-degree spectral amplitude: E(l) = sqrt(sum_m |q_l^m|^2). */
73+function degreeEnergy(band: Band, qlm: ArrayLike<number>): Float64Array {
74+ const E = new Float64Array(band.lmax + 1);
75+ for (let m = 0; m <= band.mmax; m++) {
76+ for (let l = m; l <= band.lmax; l++) {
77+ const i = lmIndex(band.lmax, l, m);
78+ E[l] += qlm[2 * i] ** 2 + qlm[2 * i + 1] ** 2;
79+ }
80+ }
81+ for (let l = 0; l <= band.lmax; l++) E[l] = Math.sqrt(E[l]);
82+ return E;
83+}
84+
85+/** max E(l) over l >= TAIL_START, relative to max E(l) overall. */
86+function tailRel(band: Band, qlm: ArrayLike<number>): number {
87+ const E = degreeEnergy(band, qlm);
88+ let bulk = 0;
89+ let tail = 0;
90+ for (let l = 0; l <= band.lmax; l++) {
91+ if (E[l] > bulk) bulk = E[l];
92+ if (l >= TAIL_START && E[l] > tail) tail = E[l];
93+ }
94+ return tail / Math.max(bulk, 1e-300);
95+}
96+
97+/** Re-index coefficients from the lo layout into the hi layout (zero-padded). */
98+function padSpectrum(qlo: ArrayLike<number>, lo: Band, hi: Band): Float64Array {
99+ const out = new Float64Array(2 * nlmCalc(hi.lmax, hi.mmax));
100+ for (let m = 0; m <= lo.mmax; m++) {
101+ for (let l = m; l <= lo.lmax; l++) {
102+ const src = lmIndex(lo.lmax, l, m);
103+ const dst = lmIndex(hi.lmax, l, m);
104+ out[2 * dst] = qlo[2 * src];
105+ out[2 * dst + 1] = qlo[2 * src + 1];
106+ }
107+ }
108+ return out;
109+}
110+
111+/** Deterministic random band-limited spectrum with O(1) coefficients. */
112+function flatSpectrum(band: Band, seed: number): Float64Array {
113+ const nlm = nlmCalc(band.lmax, band.mmax);
114+ const q = new Float64Array(2 * nlm);
115+ let s = seed >>> 0;
116+ const rnd = () => {
117+ s ^= s << 13; s >>>= 0;
118+ s ^= s >> 17;
119+ s ^= s << 5; s >>>= 0;
120+ return (s / 4294967296) * 2 - 1;
121+ };
122+ for (let k = 0; k < 2 * nlm; k++) q[k] = rnd();
123+ for (let l = 0; l <= band.lmax; l++) q[2 * lmIndex(band.lmax, l, 0) + 1] = 0;
124+ return q;
125+}
126+
127+export interface FluxCheckOptions {
128+ /**
129+ * Run the live flux-vs-Algorithm-4 A/B (4 sessions at lmax 63). On by
130+ * default, but — like geometryChecks' sweep, and for the same reason — a
131+ * browser recompiles every session's unrolled step from scratch on software
132+ * WebGPU, so the page leaves it out unless asked (?sweep=1) to keep CI
133+ * short. The f64 smoothness checks always run; they are CPU work.
134+ */
135+ ab?: boolean;
136+}
137+
138+export async function fluxChecks(
139+ device: GPUDevice,
140+ check: Check,
141+ log: Log,
142+ opts: FluxCheckOptions = {},
143+): Promise<void> {
144+ // ---- 1a. round sphere: closed-form weights, decisive discrimination -----
145+ {
146+ const hiGrid = gridForLmax(LMAX_HI, 1);
147+ const hi = { lmax: LMAX_HI, mmax: LMAX_HI, nlat: hiGrid.nlat, nphi: hiGrid.nphi };
148+ const ref = new ShtReference(hi);
149+ const npts = hi.nlat * hi.nphi;
150+
151+ // The unit sphere needs no GPU build: analyse the closed-form embedding
152+ // on the fine grid directly, in f64.
153+ const xg = new Float64Array(npts);
154+ const yg = new Float64Array(npts);
155+ const zg = new Float64Array(npts);
156+ for (let i = 0; i < hi.nlat; i++) {
157+ const ct = ref.ct[i];
158+ const st = ref.st[i];
159+ for (let j = 0; j < hi.nphi; j++) {
160+ const phi = (2 * Math.PI * j) / hi.nphi;
161+ const k = i * hi.nphi + j;
162+ xg[k] = st * Math.cos(phi);
163+ yg[k] = st * Math.sin(phi);
164+ zg[k] = ct;
165+ }
166+ }
167+ const X = ref.analys(xg);
168+ const Y = ref.analys(yg);
169+ const Z = ref.analys(zg);
170+
171+ const sXt = [ref.sinDtheta(X), ref.sinDtheta(Y), ref.sinDtheta(Z)];
172+ const Xp = [ref.dphi(X), ref.dphi(Y), ref.dphi(Z)];
173+ const { p1, p2, q2, r } = computeFluxMetric(
174+ npts, sXt[0], sXt[1], sXt[2], Xp[0], Xp[1], Xp[2],
175+ );
176+
177+ // On the sphere the weights have a closed form: p1 = q2 = 1, p2 = 0,
178+ // r = 1/sin^2(theta) — the flux-form counterpart of geometryChecks'
179+ // closed-form V check, pinning computeFluxMetric before it is buried
180+ // under the operator. f64 throughout, so the tolerance is conditioning
181+ // at the polar rings, not fp32.
182+ let worst = 0;
183+ for (let i = 0; i < hi.nlat; i++) {
184+ const st2 = ref.st[i] * ref.st[i];
185+ for (let j = 0; j < hi.nphi; j++) {
186+ const k = i * hi.nphi + j;
187+ worst = Math.max(
188+ worst,
189+ Math.abs(p1[k] - 1),
190+ Math.abs(p2[k]),
191+ Math.abs(q2[k] - 1),
192+ Math.abs(r[k] * st2 - 1),
193+ );
194+ }
195+ }
196+ check(
197+ 'flux: sphere weights match the closed form (p1 = q2 = 1, p2 = 0, r = 1/sin^2)',
198+ worst < 1e-9,
199+ `max deviation ${worst.toExponential(2)} in f64`,
200+ );
201+
202+ // Flat random u, band-limited at LMAX. The properly weighted fluxes are
203+ // then *exactly* band-limited (P = sin(theta) dtheta u, Qtilde = dphi u),
204+ // so their beyond-band tails are pure round-off; the Sec 8 control
205+ // Qtilde/sin(theta) is not a function on the sphere and keeps a fat tail.
206+ const band = { lmax: LMAX, mmax: LMAX };
207+ const u = padSpectrum(flatSpectrum(band, 777), band, hi);
208+ const A = ref.sinDtheta(u);
209+ const B = ref.dphi(u);
210+ const P = new Float64Array(npts);
211+ const Qt = new Float64Array(npts);
212+ const control = new Float64Array(npts);
213+ for (let i = 0; i < hi.nlat; i++) {
214+ const st = ref.st[i];
215+ for (let j = 0; j < hi.nphi; j++) {
216+ const k = i * hi.nphi + j;
217+ P[k] = p1[k] * A[k] + p2[k] * B[k];
218+ Qt[k] = p2[k] * A[k] + q2[k] * B[k];
219+ control[k] = Qt[k] / st;
220+ }
221+ }
222+ const tails = {
223+ P: tailRel(hi, ref.analys(P)),
224+ Qt: tailRel(hi, ref.analys(Qt)),
225+ control: tailRel(hi, ref.analys(control)),
226+ };
227+ log(
228+ ` flux smoothness on the sphere (f64, band ${LMAX}, analysed to ${LMAX_HI}, ` +
229+ `tail l >= ${TAIL_START}): P ${tails.P.toExponential(2)}, ` +
230+ `Qt ${tails.Qt.toExponential(2)}, control ${tails.control.toExponential(2)}`,
231+ );
232+ check(
233+ 'flux: on the sphere the fluxes are band-limited and the non-smooth control is not',
234+ tails.P < 1e-10 && tails.Qt < 1e-10 &&
235+ tails.control > 1e3 * Math.max(tails.P, tails.Qt, 1e-14),
236+ `P ${tails.P.toExponential(2)}, Qt ${tails.Qt.toExponential(2)}, ` +
237+ `control ${tails.control.toExponential(2)}`,
238+ );
239+ }
240+
241+ // ---- 1b. bumpy: the fluxes sit on the Cartesian gradient's footing ------
242+ {
243+ // The surface: bumpy, the one shipped geometry that is genuinely
244+ // non-axisymmetric (g_thetaphi != 0), so the off-diagonal weight p2 is
245+ // exercised. Built by the real pipeline at LMAX, then everything below is
246+ // CPU f64 from its band-limited coefficients.
247+ const g = mGeometryByKey('bumpy')!;
248+ const { nlat, nphi } = gridForLmax(LMAX, 3);
249+ const cfg = { lmax: LMAX, mmax: LMAX, nlat, nphi };
250+ const sht = await ShtPlan.create(device, cfg);
251+ const deriv = await DerivPlan.create(device, sht);
252+ const geometry = await Geometry.create({
253+ sht, cfg,
254+ source: g.source,
255+ paramNames: g.params.map((p) => p.key),
256+ params: defaultGeometryParams(g),
257+ deriv,
258+ });
259+ deriv.destroy();
260+ sht.destroy();
261+
262+ const hiGrid = gridForLmax(LMAX_HI, 1);
263+ const hi = { lmax: LMAX_HI, mmax: LMAX_HI, nlat: hiGrid.nlat, nphi: hiGrid.nphi };
264+ const ref = new ShtReference(hi);
265+ const npts = hi.nlat * hi.nphi;
266+
267+ // Embedding and test field, zero-padded into the fine layout. Both are
268+ // band-limited at LMAX, so on the fine grid every derived field's content
269+ // beyond the band is genuinely the non-band-limited part of the weights —
270+ // the thing being measured — and not aliasing.
271+ const X = padSpectrum(geometry.X, cfg, hi);
272+ const Y = padSpectrum(geometry.Y, cfg, hi);
273+ const Z = padSpectrum(geometry.Z, cfg, hi);
274+ const u = padSpectrum(flatSpectrum(cfg, 777), cfg, hi);
275+
276+ // Tangents, both weightings, all f64.
277+ const sXt = [ref.sinDtheta(X), ref.sinDtheta(Y), ref.sinDtheta(Z)];
278+ const Xp = [ref.dphi(X), ref.dphi(Y), ref.dphi(Z)];
279+ const Xt = [ref.dtheta(X), ref.dtheta(Y), ref.dtheta(Z)];
280+ const { p1, p2, q2 } = computeFluxMetric(
281+ npts, sXt[0], sXt[1], sXt[2], Xp[0], Xp[1], Xp[2],
282+ );
283+
284+ const A = ref.sinDtheta(u); // sin(theta) dtheta u
285+ const B = ref.dphi(u); // dphi u
286+
287+ // The two fluxes. No non-smooth control here: on a deformed surface
288+ // every smooth field's beyond-band tail is set by the weights' own
289+ // (slowly decaying) spectra, which swamps a pole singularity at this
290+ // resolution — the sphere block above is where the discrimination has
291+ // teeth. This block asserts the doc's relative criterion instead.
292+ const P = new Float64Array(npts);
293+ const Qt = new Float64Array(npts);
294+ for (let k = 0; k < npts; k++) {
295+ P[k] = p1[k] * A[k] + p2[k] * B[k];
296+ Qt[k] = p2[k] * A[k] + q2[k] * B[k];
297+ }
298+
299+ // The known-smooth yardstick (doc Sec 2): the x component of the
300+ // Cartesian surface gradient, built the Algorithm-4 way from the inverse
301+ // metric quantities, in f64.
302+ const gradx = new Float64Array(npts);
303+ {
304+ const ut = ref.dtheta(u);
305+ const up = ref.dphi(u);
306+ for (let k = 0; k < npts; k++) {
307+ const gtt = Xt[0][k] ** 2 + Xt[1][k] ** 2 + Xt[2][k] ** 2;
308+ const gtp = Xt[0][k] * Xp[0][k] + Xt[1][k] * Xp[1][k] + Xt[2][k] * Xp[2][k];
309+ const gpp = Xp[0][k] ** 2 + Xp[1][k] ** 2 + Xp[2][k] ** 2;
310+ const det = gtt * gpp - gtp * gtp;
311+ const Vtx = (gpp * Xt[0][k] - gtp * Xp[0][k]) / det;
312+ const Vpx = (gtt * Xp[0][k] - gtp * Xt[0][k]) / det;
313+ gradx[k] = ut[k] * Vtx + up[k] * Vpx;
314+ }
315+ }
316+
317+ const tails = {
318+ P: tailRel(hi, ref.analys(P)),
319+ Qt: tailRel(hi, ref.analys(Qt)),
320+ gradx: tailRel(hi, ref.analys(gradx)),
321+ };
322+ log(
323+ ` flux smoothness on bumpy (f64, band ${LMAX}, analysed to ${LMAX_HI}, ` +
324+ `tail l >= ${TAIL_START}): P ${tails.P.toExponential(2)}, ` +
325+ `Qt ${tails.Qt.toExponential(2)}, gradx ${tails.gradx.toExponential(2)}`,
326+ );
327+ // "Matching tails" (Sec 7.1): same footing as the Cartesian component,
328+ // with an order of magnitude of headroom on top of it. A wrong weighting
329+ // (a missing sin factor, say) puts genuinely non-smooth content into P or
330+ // Qtilde and the tail lands at O(bulk), far above this.
331+ const ceiling = Math.max(30 * tails.gradx, 1e-10);
332+ check(
333+ 'flux: on bumpy, P and Qtilde tails match the Cartesian gradient component',
334+ tails.P < ceiling && tails.Qt < ceiling,
335+ `P ${tails.P.toExponential(2)}, Qt ${tails.Qt.toExponential(2)} vs ` +
336+ `ceiling ${ceiling.toExponential(2)}`,
337+ );
338+ }
339+
340+ // ---- 2. flux form vs Algorithm 4, live, on a curved surface -------------
341+ if (!(opts.ab ?? true)) {
342+ log(
343+ ' flux A/B: skipped — run `npm run test:node` (desktop Dawn) or ' +
344+ '`npm run test:gpu -- --sweep` for the flux-vs-Algorithm-4 comparison.',
345+ );
346+ } else {
347+ const geometry = mGeometryByKey('bumpy')!;
348+ const geometryParams = defaultGeometryParams(geometry);
349+ const LMAX_AB = 63;
350+ const STEPS = 20;
351+ const states: Float32Array[] = [];
352+ const xformsPerIter: number[] = [];
353+
354+ for (const key of ['schnakenberg', 'schnakenberg-alg4']) {
355+ const model = mModelByKey(key)!;
356+ const params = defaultParams(model);
357+ // Real transforms added by one solve iteration: synth/analys ops plus
358+ // dtheta/dphi (each of which contains a synthesis); the coefficient-
359+ // space dthetac/dphic shuffles are O(nlm) index gathers, not transforms.
360+ const counts: number[] = [];
361+ for (const niter of [0, 1]) {
362+ const session = await ModelSession.create({
363+ device, model, params, lmax: LMAX_AB,
364+ geometry, geometryParams, niter,
365+ });
366+ counts.push(
367+ session.describe().step.filter((l) =>
368+ l.startsWith('synth') || l.startsWith('analys') ||
369+ l.startsWith('dtheta ') || l.startsWith('dphi '),
370+ ).length,
371+ );
372+ if (niter === 1) {
373+ await session.seed(1);
374+ session.step(STEPS);
375+ states.push(await session.read('U'));
376+ }
377+ session.destroy();
378+ }
379+ xformsPerIter.push(counts[1] - counts[0]);
380+ }
381+
382+ // The headline number, from the compiled op sequences: 6 Legendre
383+ // transforms per species per iteration against Algorithm 4's 12
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
387+ // Fourier stages, no Legendre work -- and is deliberately not counted.
388+ check(
389+ 'flux: 6 Legendre transforms per species per iteration, versus 12',
390+ xformsPerIter[0] === 12 && xformsPerIter[1] === 24,
391+ `flux form adds ${xformsPerIter[0]} transforms/iteration, ` +
392+ `Algorithm 4 adds ${xformsPerIter[1]}`,
393+ );
394+
395+ // Same operator, same discretization, different arithmetic path: after
396+ // STEPS steps the two states may differ only by fp32 accumulation. A
397+ // formulation error (wrong weight, wrong shift, missing sin) would show
398+ // up at O(1), not O(1e-3). Identical states would mean the A/B compared
399+ // one path to itself.
400+ let worst = 0;
401+ let identical = true;
402+ let finite = true;
403+ for (let i = 0; i < states[0].length; i++) {
404+ const d = Math.abs(states[0][i] - states[1][i]);
405+ if (d > worst) worst = d;
406+ if (states[0][i] !== states[1][i]) identical = false;
407+ if (!Number.isFinite(states[0][i]) || !Number.isFinite(states[1][i])) finite = false;
408+ }
409+ check(
410+ 'flux: tracks the Algorithm-4 reference through a real simulation',
411+ finite && !identical && worst < 5e-3,
412+ `max |U_flux - U_alg4| = ${worst.toExponential(2)} after ${STEPS} steps ` +
413+ `on bumpy at lmax ${LMAX_AB}`,
414+ );
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+ );
475+ }
476+}
test/geometryChecks.tsmodified+299−27View 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,
@@ -67,11 +67,26 @@ async function buildGeometry(device: GPUDevice, key: string) {
6767 return { g, sht, deriv, cfg, geometry };
6868 }
6969
70+export interface GeometryCheckOptions {
71+ /**
72+ * Run the niter x geometry sweep at the end. On by default, and nearly free
73+ * on desktop Dawn (~3 s for all 20 combinations), but in a browser it
74+ * dominates the whole suite: every session recompiles its unrolled step from
75+ * scratch — there is no pipeline cache across sessions — so the sweep costs
76+ * ~6 minutes on software WebGPU against ~30 s for every other check here put
77+ * together. The browser page therefore leaves it out unless asked (?sweep=1),
78+ * which is what keeps CI short.
79+ */
80+ sweep?: boolean;
81+}
82+
7083 export async function geometryChecks(
7184 device: GPUDevice,
7285 check: Check,
7386 log: Log,
87+ opts: GeometryCheckOptions = {},
7488 ): Promise<void> {
89+ const runSweep = opts.sweep ?? true;
7590 // ---- every geometry compiles and closes ---------------------------------
7691 for (const spec of mGeometries) {
7792 const { sht, deriv, geometry } = await buildGeometry(device, spec.key);
@@ -139,7 +154,25 @@ export async function geometryChecks(
139154 // V_phi = (-sin(phi)/sin(theta), cos(phi)/sin(theta), 0). Checking these
140155 // pins the sign convention of computeMetric (src/geom/metric.ts) before
141156 // it is buried under the Laplace-Beltrami operator built on top of it.
157+ //
158+ // Split by latitude, because the accuracy available here is not uniform.
159+ // computeMetric divides by det = g_tt*g_pp - g_tp^2, and on the sphere
160+ // g_pp and det are both O(sin^2 theta) -- 1.4e-3 at the outermost Gauss
161+ // latitude of this grid. The transforms deliver X_theta/X_phi with an
162+ // *absolute* fp32 error of ~1e-6, which is a large *relative* error once
163+ // it is squared into quantities that small, so the error in both V's grows
164+ // like 1/sin^2 theta toward the poles. That is conditioning, not a wrong
165+ // formula: measured worst cases are
166+ //
167+ // sin(theta) >= 0.2 sin(theta) < 0.2 (8 of 64 latitudes)
168+ // Dawn 3.2e-4 1.7e-3
169+ // SwiftShader 1.5e-3 8.8e-3
170+ //
171+ // and a sign or formula error would be O(1) in either band, so tolerances
172+ // a few times the looser stack still catch one.
173+ const POLE_SIN = 0.2;
142174 let maxMetricErr = 0;
175+ let maxPoleErr = 0;
143176 for (let i = 0; i < sht.cfg.nlat; i++) {
144177 const ct = sht.cosTheta[i];
145178 const st = Math.sqrt(Math.max(0, 1 - ct * ct));
@@ -154,8 +187,7 @@ export async function geometryChecks(
154187 const wantVpx = -sphi / st;
155188 const wantVpy = cphi / st;
156189 const wantVpz = 0;
157- maxMetricErr = Math.max(
158- maxMetricErr,
190+ const worst = Math.max(
159191 Math.abs(geometry.Vtx[k] - wantVtx),
160192 Math.abs(geometry.Vty[k] - wantVty),
161193 Math.abs(geometry.Vtz[k] - wantVtz),
@@ -163,12 +195,20 @@ export async function geometryChecks(
163195 Math.abs(geometry.Vpy[k] - wantVpy),
164196 Math.abs(geometry.Vpz[k] - wantVpz),
165197 );
198+ if (st < POLE_SIN) maxPoleErr = Math.max(maxPoleErr, worst);
199+ else maxMetricErr = Math.max(maxMetricErr, worst);
166200 }
167201 }
168202 check(
169203 'geometry: sphere.m has the closed-form inverse metric quantities',
170- maxMetricErr < 2e-3,
171- `max |V - closed form| = ${maxMetricErr.toExponential(2)}`,
204+ maxMetricErr < 4e-3,
205+ `max |V - closed form| = ${maxMetricErr.toExponential(2)} ` +
206+ `away from the poles (sin theta >= ${POLE_SIN})`,
207+ );
208+ check(
209+ 'geometry: the polar caps stay within their conditioning',
210+ maxPoleErr < 2e-2,
211+ `max |V - closed form| = ${maxPoleErr.toExponential(2)} at sin theta < ${POLE_SIN}`,
172212 );
173213
174214 deriv.destroy();
@@ -255,7 +295,7 @@ export async function geometryChecks(
255295 device, model, params, lmax: LMAX, niter,
256296 });
257297 ops.push(session.describe().step.length);
258- session.seed(1);
298+ await session.seed(1);
259299 session.step(STEPS);
260300 states.push(await session.read('U'));
261301 session.destroy();
@@ -270,14 +310,15 @@ export async function geometryChecks(
270310 `${ops.join(' < ')} ops for ${counts.join(', ')} iterations`,
271311 );
272312 // Unrolling has to be exactly linear in the trip count: the body planned
273- // once per iteration, no more and no less. Per species per iteration: 8
274- // dtheta/dphi + 4 analys transforms (Algorithm 3's cost, applied to the
275- // field and to each of its three Cartesian gradient components) plus 15
276- // generated kernels -- see test/modelChecks.ts's KERNELS_PER_ITERATION,
277- // which counts the kernels alone; this counts every op, transforms
278- // included.
313+ // once per iteration, no more and no less. Per species per iteration: 4
314+ // synths + 2 analyses (the flux-form matvec's five Legendre transforms,
315+ // docs/reduced-transforms.md Sec 4 with the dphig variation, plus the
316+ // round-sphere synthesis of the divergence split) + the grid-space
317+ // phi-derivative + 3 coefficient-space shuffles plus 8 generated kernels
318+ // -- see test/modelChecks.ts's KERNELS_PER_ITERATION, which counts the
319+ // kernels alone; this counts every op.
279320 const perIteration = ops[1] - ops[0];
280- const want = 54;
321+ const want = 36;
281322 check(
282323 'loop: unrolling is exactly linear in the trip count',
283324 perIteration === want && ops[2] - ops[0] === 4 * perIteration,
@@ -321,7 +362,7 @@ export async function geometryChecks(
321362 device, model, params, lmax: SWEEP_LMAX,
322363 geometry: peanut, geometryParams: peanutParams, niter,
323364 });
324- session.seed(1);
365+ await session.seed(1);
325366 session.step(STEPS);
326367 states.push(await session.read('U'));
327368 session.destroy();
@@ -339,20 +380,35 @@ export async function geometryChecks(
339380
340381 // ---- niter x geometry sweep: catch a "doesn't run" regression early -----
341382 // This is what actually turned up the two real issues found while building
342- // the correction: peanut diverging at niter >= 4 with schnak-spots'
343- // shipped default dt (a genuine Richardson-convergence-radius limit, not a
344- // bug -- see docs/richardson-iteration.md), and a since-fixed compiler bug
345- // where a loop-body statement could silently reuse a *different*
346- // statement's compiled kernel (test/modelChecks.ts's pipeline-cache check
347- // guards that one directly). Every shipped geometry x every niter the
348- // app's <select> actually offers, so a regression anywhere in that grid is
349- // caught -- without asserting away the one combination already known to be
350- // outside the convergence radius.
351- {
383+ // the correction: peanut diverging at niter >= 2 with schnak-spots'
384+ // shipped default dt (the plain round-sphere preconditioner's convergence
385+ // radius -- the mean-J preconditioner has since lifted it; see the control
386+ // check after the sweep), and a since-fixed compiler bug where a loop-body
387+ // statement could silently reuse a *different* statement's compiled kernel
388+ // (test/modelChecks.ts's pipeline-cache check guards that one directly).
389+ // Every shipped geometry x every niter the app's <select> actually offers,
390+ // so a regression anywhere in that grid is caught.
391+ //
392+ // SWEEP_LMAX stays at the app's default: the divergence the control check
393+ // pins down is lmax-dependent (at lmax 31 or 15 even the plain
394+ // preconditioner stays finite on peanut), so a smaller grid would stop
395+ // testing the thing the control exists to demonstrate.
396+ if (!runSweep) {
397+ log(
398+ ' sweep: skipped — run `npm run test:node` (desktop Dawn, ~3 s) or ' +
399+ '`npm run test:gpu -- --sweep` for the niter x geometry sweep.',
400+ );
401+ } else {
352402 const model = mModelByKey('schnakenberg')!;
353403 const params = defaultParams(model);
354404 const SWEEP_NITER = [0, 1, 2, 4, 8];
355- const KNOWN_DIVERGENT = new Set(['peanut/2', 'peanut/4', 'peanut/8']);
405+ // Empty since the symbol-based preconditioner: its high-degree
406+ // contraction rate (muMax - muMin)/(muMax + muMin) < 1 on any surface
407+ // (mu = the symbol eigenvalues, i.e. inverse squared principal
408+ // stretches), where the plain preconditioner diverges wherever mu > 2
409+ // -- which is exactly what used to make peanut/2, /4 and /8 diverge.
410+ // The mechanism stays: a regression lands here with its evidence.
411+ const KNOWN_DIVERGENT = new Set<string>([]);
356412
357413 for (const geomSpec of mGeometries) {
358414 for (const niter of SWEEP_NITER) {
@@ -361,7 +417,7 @@ export async function geometryChecks(
361417 geometry: geomSpec, geometryParams: defaultGeometryParams(geomSpec),
362418 niter,
363419 });
364- session.seed(1);
420+ await session.seed(1);
365421 session.step(STEPS);
366422 const values = await session.read('u');
367423 const finite = values.every((v) => Number.isFinite(v));
@@ -384,6 +440,49 @@ export async function geometryChecks(
384440 );
385441 }
386442 }
443+
444+ // ---- the mean-J control: what the sweep's health is owed to ----------
445+ // peanut at niter 4 was the canonical divergent case before the mean-J
446+ // preconditioner. Pinning jhat to 1 reproduces the plain round-sphere
447+ // preconditioner on today's code, so this asserts both directions at
448+ // once: mean-J converges where plain diverges, on the same operator,
449+ // same surface, same dt. If this check ever finds jhat = 1 finite, the
450+ // sweep above has stopped exercising the regime the preconditioner
451+ // exists for (e.g. someone lowered SWEEP_LMAX or dt).
452+ {
453+ const peanut = mGeometryByKey('peanut')!;
454+ const outcomes: boolean[] = [];
455+ let jstats = '';
456+ for (const jhat of [undefined, 1]) {
457+ const session = await ModelSession.create({
458+ device, model,
459+ params: jhat === undefined ? params : { ...params, jhat },
460+ lmax: SWEEP_LMAX,
461+ geometry: peanut, geometryParams: defaultGeometryParams(peanut),
462+ niter: 4,
463+ });
464+ if (jhat === undefined) {
465+ const g = session.geometry;
466+ jstats =
467+ `mu in [${g.muMin.toFixed(3)}, ${g.muMax.toFixed(3)}] ` +
468+ `(J in [${g.Jmin.toFixed(3)}, ${g.Jmax.toFixed(3)}]), ` +
469+ `Jhat ${g.Jhat.toFixed(3)}, ` +
470+ `rate ${((g.muMax - g.muMin) / (g.muMax + g.muMin)).toFixed(3)} ` +
471+ `vs plain ${(g.muMax - 1).toFixed(2)}`;
472+ }
473+ await session.seed(1);
474+ session.step(STEPS);
475+ const values = await session.read('u');
476+ outcomes.push(values.every((v) => Number.isFinite(v)));
477+ session.destroy();
478+ }
479+ log(` mean-J on peanut: ${jstats}`);
480+ check(
481+ 'mean-J: converges on peanut/4 where the plain preconditioner diverges',
482+ outcomes[0] && !outcomes[1],
483+ `mean-J finite: ${outcomes[0]}, jhat=1 finite: ${outcomes[1]}`,
484+ );
485+ }
387486 }
388487
389488 // ---- a loop whose length is not known at compile time is refused --------
@@ -414,7 +513,7 @@ export async function geometryChecks(
414513 const session = await ModelSession.create({
415514 device, model, params: defaultParams(model), lmax: LMAX,
416515 });
417- session.seed(1);
516+ await session.seed(1);
418517 session.step(STEPS);
419518 const before = await session.read('U');
420519
@@ -436,6 +535,179 @@ export async function geometryChecks(
436535 );
437536 session.destroy();
438537 }
538+
539+ await randnfun3Checks(device, check, log);
540+}
541+
542+/**
543+ * The seeded initial condition: chebfun's randnfun3, drawn on the host and
544+ * summed on the GPU (src/mgpu/randnfun3.ts).
545+ *
546+ * The split is the thing worth testing. The draw is MATLAB whose distribution
547+ * is checked directly, and the sum is a WGSL kernel checked against the same
548+ * modes evaluated in f64 on the CPU — if the kernel's indexing into the packed
549+ * mode table were wrong it would still produce a smooth random-looking field,
550+ * which is exactly the kind of wrong no "looks patterned" check would catch.
551+ */
552+async function randnfun3Checks(
553+ device: GPUDevice,
554+ check: Check,
555+ log: Log,
556+): Promise<void> {
557+ const model = mModelByKey('schnakenberg')!;
558+ const params = defaultParams(model);
559+ const make = (lam3: number): Promise<ModelSession> =>
560+ ModelSession.create({ device, model, params, lmax: LMAX, lam3 });
561+
562+ // ---- the GPU sum matches the same modes evaluated on the CPU -----------
563+ {
564+ const session = await make(DEFAULT_LAMBDA);
565+ await session.seed(3);
566+ // `u` after init is the steady state plus 0.01*f, so the field is
567+ // recovered by removing the model's own uniform offset.
568+ const u = await session.read('u');
569+ const g = session.geometry;
570+ const modes = drawModes(
571+ DEFAULT_LAMBDA,
572+ boundingBox(g.x, g.y, g.z),
573+ 3,
574+ g.x.length,
575+ );
576+ const nmodes = modes[0];
577+
578+ // The same sum in f64, straight from the packed table the GPU read.
579+ let maxErr = 0;
580+ let amp = 0;
581+ const us = params.a + params.b;
582+ for (let i = 0; i < g.x.length; i++) {
583+ let f = 0;
584+ for (let j = 0; j < nmodes; j++) {
585+ const b = 4 + 5 * j;
586+ const t = modes[b] * g.x[i] + modes[b + 1] * g.y[i] + modes[b + 2] * g.z[i];
587+ f += modes[b + 3] * Math.cos(t) - modes[b + 4] * Math.sin(t);
588+ }
589+ const want = us + 0.01 * f;
590+ maxErr = Math.max(maxErr, Math.abs(u[i] - want));
591+ amp = Math.max(amp, Math.abs(0.01 * f));
592+ }
593+ log(` randnfun3: ${nmodes} modes at lambda ${DEFAULT_LAMBDA}, |perturbation| up to ${amp.toExponential(2)}`);
594+ check(
595+ 'randnfun3: the GPU sum matches the same modes summed on the CPU',
596+ // fp32 over ~1400 terms against f64. What is being bounded is the
597+ // summation floor, and its size is the backend's accumulation order:
598+ // Metal lands at 2.0e-6, SwiftShader at 3.8e-6, so an absolute constant
599+ // tuned on one is a coin flip on the other. Scale it to the field
600+ // instead. The bug this exists to catch -- a mis-indexed read into the
601+ // packed table, which would still look like a smooth random field -- is
602+ // wrong by O(amp), a thousand times over the bound.
603+ maxErr < 1e-3 * amp && amp > 1e-3,
604+ `max |GPU - CPU| = ${maxErr.toExponential(2)}, perturbation amplitude ${amp.toExponential(2)}`,
605+ );
606+ session.destroy();
607+ }
608+
609+ // ---- a seed reproduces, a different seed does not ----------------------
610+ {
611+ const a = await make(DEFAULT_LAMBDA);
612+ await a.seed(11);
613+ const first = await a.read('u');
614+ await a.seed(11);
615+ const again = await a.read('u');
616+ await a.seed(12);
617+ const other = await a.read('u');
618+ let same = true;
619+ let differs = false;
620+ for (let i = 0; i < first.length; i++) {
621+ if (first[i] !== again[i]) same = false;
622+ if (first[i] !== other[i]) differs = true;
623+ }
624+ check(
625+ 'randnfun3: the same seed redraws the same field, a different one does not',
626+ same && differs,
627+ same ? (differs ? 'reproducible and seed-dependent' : 'seed 12 gave seed 11 back') : 'not reproducible',
628+ );
629+ a.destroy();
630+ }
631+
632+ // ---- the field is smooth, and lambda sets how smooth -------------------
633+ //
634+ // This is what randnfun3 buys over the white noise it replaced: the seed is
635+ // band-limited, so it is fully resolved by the grid instead of being
636+ // whatever the grid happened to alias. Measured as the share of spectral
637+ // energy above degree 20 — near zero for a smooth field, and larger for a
638+ // shorter wavelength, which is the direction lambda is supposed to move it.
639+ {
640+ const tail = async (lam3: number): Promise<number> => {
641+ const session = await make(lam3);
642+ await session.seed(5);
643+ const U = await session.read('U');
644+ let lo = 0;
645+ let hi = 0;
646+ for (let m = 0; m <= LMAX; m++) {
647+ for (let l = m; l <= LMAX; l++) {
648+ const i = lmIndex(LMAX, l, m);
649+ const e = U[2 * i] ** 2 + U[2 * i + 1] ** 2;
650+ if (l > 20) hi += e;
651+ else lo += e;
652+ }
653+ }
654+ session.destroy();
655+ return hi / (lo + hi);
656+ };
657+ const coarse = await tail(1);
658+ const fine = await tail(0.4);
659+ log(` randnfun3: energy above l=20 is ${coarse.toExponential(2)} at lambda 1, ${fine.toExponential(2)} at lambda 0.4`);
660+ check(
661+ 'randnfun3: the seed is band-limited, and lambda sets its scale',
662+ coarse < 1e-3 && fine > coarse,
663+ `tail ${coarse.toExponential(2)} (lambda 1) < ${fine.toExponential(2)} (lambda 0.4)`,
664+ );
665+ }
666+
667+ // ---- a finer wavelength grows the table rather than being capped -------
668+ //
669+ // The mode table is sized to the wavelength asked for, so going finer
670+ // reallocates it and rebinds the dispatch. Getting that wrong would leave
671+ // the kernel reading a destroyed buffer or a stale one, so check that a
672+ // fine field is actually there and actually different.
673+ {
674+ const session = await make(DEFAULT_LAMBDA);
675+ await session.seed(21);
676+ const coarse = await session.read('u');
677+ session.setLam3(0.12);
678+ await session.seed(21);
679+ const fine = await session.read('u');
680+ let differs = false;
681+ let finite = true;
682+ for (let i = 0; i < fine.length; i++) {
683+ if (!Number.isFinite(fine[i])) finite = false;
684+ if (fine[i] !== coarse[i]) differs = true;
685+ }
686+ check(
687+ 'randnfun3: a finer wavelength grows the mode table and rebinds',
688+ finite && differs,
689+ finite ? 'redrew finer, buffer rebound' : 'field went non-finite after resize',
690+ );
691+ session.destroy();
692+ }
693+
694+ // ---- a wavelength past the cost budget is refused, not truncated -------
695+ {
696+ const session = await make(DEFAULT_LAMBDA);
697+ let message = '';
698+ try {
699+ session.setLam3(1e-4);
700+ await session.seed(1);
701+ } catch (e) {
702+ message = e instanceof Error ? e.message : String(e);
703+ }
704+ check(
705+ 'randnfun3: a wavelength whose table could not be built is refused',
706+ message.includes('Fourier modes on this surface'),
707+ message ? `refused: ${message.slice(0, 62)}…` : 'drew it anyway',
708+ );
709+ session.destroy();
710+ }
439711 }
440712
441713 /** 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+123−15View file
@@ -10,7 +10,7 @@
1010 * but every operator becomes its own dispatch, which is invisible except here.
1111 */
1212 import { ModelSession } from '../src/mgpu/session.ts';
13-import { mModels, defaultParams } from '../src/mgpu/registry.ts';
13+import { mModels, mModelByKey, defaultParams } from '../src/mgpu/registry.ts';
1414 import {
1515 formatCommand,
1616 parseArgs,
@@ -25,9 +25,10 @@ import type { Check, Log } from './analyticChecks.ts';
2525 * (it cannot fuse into an external call).
2626 */
2727 const EXPECTED_KERNELS: Record<string, number> = {
28- schnakenberg: 7,
29- brusselator: 7,
30- allencahn: 3,
28+ schnakenberg: 8,
29+ brusselator: 8,
30+ allencahn: 4,
31+ 'schnakenberg-alg4': 8,
3132 };
3233
3334 /**
@@ -36,16 +37,27 @@ const EXPECTED_KERNELS: Record<string, number> = {
3637 * count is a byproduct of exactly how its expression tree happens to fuse,
3738 * not a clean per-species multiple, so this is measured per model rather
3839 * than derived from `model.species.length`). Each species' correction is
39- * Algorithm 3 of evolving_surface/notes/algos.tex: a surface gradient
40- * (dtheta/dphi contracted through the metric), reanalysed per Cartesian
41- * component and differentiated again, recombined into the divergence, plus
42- * the round-sphere eigenvalue added back — see models/schnakenberg.m and
43- * docs/richardson-iteration.md.
40+ * the flux-form Laplace-Beltrami matvec of
41+ * docs/reduced-transforms.md Sec 4: the two sin-weighted
42+ * derivative synths, the pointwise flux combination through p1/p2/q2, the
43+ * two flux analyses, the re-shifted divergence and its r-scaled synthesis,
44+ * the round-sphere share of the divergence subtracted off through jinv, plus
45+ * the round-sphere eigenvalue added back — see models/schnakenberg.m
46+ * and docs/richardson-iteration.md. `schnakenberg-alg4` keeps the original
47+ * Cartesian-gradient form (Algorithm 3/4 of evolving_surface/notes/algos.tex)
48+ * as a live reference, with its original counts.
4449 */
4550 const KERNELS_PER_ITERATION: Record<string, number> = {
46- schnakenberg: 30,
47- brusselator: 30,
48- allencahn: 14,
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,
57+ // 30 before the correction gained its band projection (.* filt on dLu):
58+ // that line fused into the state update in this model's expression shape,
59+ // and no longer does — one extra 2 x nlm kernel per species per iteration.
60+ 'schnakenberg-alg4': 32,
4961 };
5062
5163 const LMAX = 31;
@@ -57,7 +69,7 @@ export async function modelChecks(
5769 check: Check,
5870 log: Log,
5971 ): Promise<void> {
60- check('models: registry populated', mModels.length === 3, `${mModels.length} models`);
72+ check('models: registry populated', mModels.length === 4, `${mModels.length} models`);
6173
6274 // The app formats the run it is showing into a `npm run bench` command and
6375 // the benchmark parses it back. That is only worth anything if the round
@@ -111,7 +123,7 @@ export async function modelChecks(
111123 `${kernels} kernels (expected ${expected})`,
112124 );
113125
114- session.seed(1);
126+ await session.seed(1);
115127 session.step(STEPS);
116128
117129 // Every rendered field must be finite and have developed some contrast.
@@ -137,6 +149,102 @@ export async function modelChecks(
137149 session.destroy();
138150 }
139151
152+ // Batched transforms are an encoding of the same arithmetic, so a run with
153+ // batching disabled (SHT_BATCH=0 compiles scalar-only plans) must reproduce
154+ // the default run to shader-compiler latitude, and the default run must
155+ // actually be batching (the describe() lines say so). This is the guard
156+ // that the planner's adjacency grouping rewires buffers correctly — a lane
157+ // bound to the wrong field would miss by O(1), not O(1e-6).
158+ {
159+ const model = mModelByKey('schnakenberg')!;
160+ const params = defaultParams(model);
161+ const states: Float32Array[] = [];
162+ let batchedLanes = 0;
163+ for (const batch of [undefined, 0]) {
164+ const g = globalThis as Record<string, unknown>;
165+ if (batch !== undefined) g.SHT_BATCH = batch;
166+ try {
167+ const session = await ModelSession.create({
168+ device, model, params, lmax: LMAX, niter: NITER,
169+ });
170+ if (batch === undefined) {
171+ batchedLanes = session
172+ .describe()
173+ .step.filter((l) => l.includes('[batch lane')).length;
174+ }
175+ await session.seed(1);
176+ session.step(STEPS);
177+ states.push(await session.read('U'));
178+ session.destroy();
179+ } finally {
180+ delete g.SHT_BATCH;
181+ }
182+ }
183+ // Every batchable run at one solve iteration: the u/v syntheses and the
184+ // reaction analyses outside the loop (2 + 2), the four gradient
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.
191+ check(
192+ 'batch: the compiled step batches every adjacent transform pair',
193+ batchedLanes === 16,
194+ `${batchedLanes} batched transform lanes (expected 16)`,
195+ );
196+ let worst = 0;
197+ for (let i = 0; i < states[0].length; i++) {
198+ worst = Math.max(worst, Math.abs(states[0][i] - states[1][i]));
199+ }
200+ check(
201+ 'batch: batched and scalar plans agree through a real run',
202+ worst < 1e-4,
203+ `max |U_batched - U_scalar| = ${worst.toExponential(2)} after ${STEPS} steps`,
204+ );
205+ }
206+
207+ // Misusing the grouped-transform syntax is refused at compile time with a
208+ // message that says how to write it, not silently mis-planned: every input
209+ // must get an output (each one costs a transform), whether the mismatch is
210+ // an under-bound assignment or an ignored slot.
211+ {
212+ const model = mModelByKey('allencahn')!;
213+ const cases: [string, string, string][] = [
214+ [
215+ 'a single output bound to a grouped call',
216+ 'Ftu = synth(vtu, vpu, lam .* Fu);',
217+ 'bind each one',
218+ ],
219+ [
220+ 'an ignored output slot',
221+ // Fpu is reassigned so the only error left is the dropped slot
222+ // itself, which the planner refuses (numbl would otherwise catch
223+ // the undefined 'Fpu' first, masking the check under test).
224+ '[Ftu, ~, Su] = synth(vtu, vpu, lam .* Fu);\n Fpu = Ftu;',
225+ 'must be bound',
226+ ],
227+ ];
228+ for (const [what, bad, expect] of cases) {
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');
231+ let message = '';
232+ try {
233+ const session = await ModelSession.create({
234+ device, model, params: defaultParams(model), lmax: LMAX, source, niter: 1,
235+ });
236+ session.destroy();
237+ } catch (e) {
238+ message = e instanceof Error ? e.message : String(e);
239+ }
240+ check(
241+ `batch: ${what} is refused at compile time`,
242+ message.includes(expect),
243+ message ? `refused: ${message.slice(0, 76)}…` : 'compiled anyway',
244+ );
245+ }
246+ }
247+
140248 // The oversampled readback: readSpecies must be the state synthesized on the
141249 // display grid. Comparing against the display plan's own upload path
142250 // (read the state back, synth it from the CPU) exercises the GPU-to-GPU
@@ -150,7 +258,7 @@ export async function modelChecks(
150258 lmax: LMAX,
151259 oversample: 2,
152260 });
153- session.seed(1);
261+ await session.seed(1);
154262 session.step(STEPS);
155263
156264 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+15−3View file
@@ -23,10 +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';
31+import { fluxChecks } from './fluxChecks.ts';
32+import { compareChecks } from './compareChecks.ts';
33+import { referenceChecks, type H5Rt } from './referenceChecks.ts';
34+import { matlabExportChecks } from './matlabExportChecks.ts';
3035
3136 declare global {
3237 interface Window {
@@ -86,7 +91,7 @@ async function soak(steps: number, lmax: number): Promise<void> {
8691 geometryParams: defaultGeometryParams(geometry),
8792 niter: DEFAULT_NITER,
8893 });
89- session.seed(5);
94+ await session.seed(5);
9095 log(
9196 `soak: ${steps} steps at lmax ${lmax} ` +
9297 `(grid ${session.cfg.nlat}x${session.cfg.nphi}, ${geometry.key}, ` +
@@ -190,7 +195,7 @@ async function dumpState(q: URLSearchParams): Promise<void> {
190195 geometryParams: spec.geometryParams,
191196 niter: spec.niter,
192197 });
193- session.seed(spec.seed);
198+ await session.seed(spec.seed);
194199 session.step(spec.steps);
195200 await session.sync();
196201 const state = await session.read(model.state[0]);
@@ -215,7 +220,14 @@ async function main(): Promise<void> {
215220 await transformChecks(device, check, log);
216221 await analyticChecks(device, check, log);
217222 await modelChecks(device, check, log);
218- await geometryChecks(device, check, log);
223+ // The sweep is opt-in here (?sweep=1): it is a few seconds on desktop Dawn
224+ // but minutes in a browser, where each session recompiles its unrolled step.
225+ await geometryChecks(device, check, log, { sweep: q.has('sweep') });
226+ await fluxChecks(device, check, log, { ab: q.has('sweep') });
227+ 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);
219231
220232 window.__RESULTS__ = { ok: failures === 0, lines };
221233 log(failures === 0 ? 'ALL PASS' : `${failures} FAILURE(S)`);
test/transformChecks.tsmodified+125−0View file
@@ -110,8 +110,133 @@ export async function transformChecks(
110110 errDtheta < 1e-4 && errDphi < 1e-4,
111111 `dtheta ${errDtheta.toExponential(2)}, dphi ${errDphi.toExponential(2)}`,
112112 );
113+
114+ // The undivided theta derivative sin(theta)*dtheta(u) — the flux-form
115+ // Laplace-Beltrami scheme's step 1 and the flux-metric precompute's
116+ // input — is the same shuffle+synthesis with the divide skipped, so it
117+ // gets the same oracle.
118+ const sinDthetaGpu = await deriv.sinDtheta(new Float32Array(q64));
119+ const sinDthetaCpu = ref.sinDtheta(q64);
120+ const errSinDtheta = relL2(sinDthetaGpu, sinDthetaCpu);
121+ check(
122+ 'deriv: WGSL fp32 sinDtheta (undivided) vs f64 CPU reference',
123+ errSinDtheta < 1e-4,
124+ `sinDtheta ${errSinDtheta.toExponential(2)}`,
125+ );
113126 deriv.destroy();
114127 }
115128
129+ // ---- grid-space phi-derivative (dphig) vs the f64 reference -------------
130+ // dphig differentiates in phi with two Fourier stages and an i*m multiply,
131+ // no Legendre work. On a band-limited field whose m >= lmax-2 modes are
132+ // zero (dphig masks those, mirroring filt), it must agree with the
133+ // coefficient-space route dphi = synth(i*m*coeffs) to fp32.
134+ {
135+ const q64 = new Float64Array(randomSpectrum(cfg, 4242));
136+ for (let m = Math.max(0, lmax - 2); m <= lmax; m++) {
137+ for (let l = m; l <= lmax; l++) {
138+ const i = 2 * (m * (lmax + 1) - (m * (m - 1)) / 2 + (l - m));
139+ q64[i] = 0;
140+ q64[i + 1] = 0;
141+ }
142+ }
143+ const grid = ref.synth(q64);
144+ const dPhiGpu = await plan.dphig(new Float32Array(grid));
145+ const dPhiCpu = ref.dphi(q64);
146+ const err = relL2(dPhiGpu, dPhiCpu);
147+ check(
148+ 'dphig: grid-space FFT phi-derivative vs f64 CPU reference',
149+ err < 1e-4,
150+ `rel L2 ${err.toExponential(2)}`,
151+ );
152+ }
153+
154+ // ---- batched transforms reproduce the scalar transforms ------------------
155+ // A batch walks the Legendre recurrence once for K fields with per-lane
156+ // arithmetic textually identical to the scalar kernel's, so each lane must
157+ // agree with the scalar path to shader-compiler latitude (FMA contraction
158+ // may differ between the two modules; nothing else may).
159+ {
160+ const { nlat: gl, nphi: gp } = plan.cfg;
161+ const npts = gl * gp;
162+ const sizes = [];
163+ for (let k = 2; k <= plan.batchK; k += 2) sizes.push(k);
164+ check(
165+ 'batch: plan compiled batched pipelines',
166+ plan.batchK >= 2,
167+ `batchK = ${plan.batchK} (${sizes.map((s) => `x${s}`).join(', ') || 'none'})`,
168+ );
169+ for (const K of sizes) {
170+ const qs = Array.from({ length: K }, (_, k) => randomSpectrum(cfg, 1000 + k));
171+ const qBufs = qs.map((q, k) => {
172+ const b = device.createBuffer({
173+ label: `batch-test-q${k}`,
174+ size: 8 * plan.nlm,
175+ usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST,
176+ });
177+ device.queue.writeBuffer(b, 0, q as Float32Array<ArrayBuffer>);
178+ return b;
179+ });
180+ const spatBufs = qs.map((_, k) =>
181+ device.createBuffer({
182+ label: `batch-test-spat${k}`,
183+ size: 4 * npts,
184+ usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_SRC | GPUBufferUsage.COPY_DST,
185+ }),
186+ );
187+ const qOutBufs = qs.map((_, k) =>
188+ device.createBuffer({
189+ label: `batch-test-qout${k}`,
190+ size: 8 * plan.nlm,
191+ usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_SRC,
192+ }),
193+ );
194+ const stage = device.createBuffer({
195+ label: 'batch-test-stage',
196+ size: K * (4 * npts + 8 * plan.nlm),
197+ usage: GPUBufferUsage.MAP_READ | GPUBufferUsage.COPY_DST,
198+ });
199+
200+ // One pass: batched synthesis of all K, then batched analysis back.
201+ const synthB = plan.createSynthBatchBinding(
202+ qs.map((_, k) => ({ qlmIn: qBufs[k], spatOut: spatBufs[k] })),
203+ );
204+ const analysB = plan.createAnalysBatchBinding(
205+ qs.map((_, k) => ({ spatIn: spatBufs[k], qlmOut: qOutBufs[k] })),
206+ );
207+ const enc = device.createCommandEncoder({ label: 'batch-test' });
208+ const pass = enc.beginComputePass();
209+ plan.encodeSynthBatchInto(pass, synthB);
210+ plan.encodeAnalysBatchInto(pass, analysB);
211+ pass.end();
212+ for (let k = 0; k < K; k++) {
213+ enc.copyBufferToBuffer(spatBufs[k], 0, stage, k * 4 * npts, 4 * npts);
214+ enc.copyBufferToBuffer(qOutBufs[k], 0, stage, K * 4 * npts + k * 8 * plan.nlm, 8 * plan.nlm);
215+ }
216+ device.queue.submit([enc.finish()]);
217+ await stage.mapAsync(GPUMapMode.READ);
218+ const raw = new Float32Array(stage.getMappedRange().slice(0));
219+ stage.unmap();
220+
221+ let worstSynth = 0;
222+ let worstAnalys = 0;
223+ for (let k = 0; k < K; k++) {
224+ const spatLane = raw.subarray(k * npts, (k + 1) * npts);
225+ const qLane = raw.subarray(K * npts + k * 2 * plan.nlm, K * npts + (k + 1) * 2 * plan.nlm);
226+ const spatScalar = await plan.synth(qs[k]);
227+ const qScalar = await plan.analys(spatScalar);
228+ worstSynth = Math.max(worstSynth, relL2(spatLane, spatScalar));
229+ worstAnalys = Math.max(worstAnalys, relL2(qLane, qScalar));
230+ }
231+ check(
232+ `batch: x${K} lanes match the scalar transforms`,
233+ worstSynth < 1e-6 && worstAnalys < 1e-6,
234+ `synth ${worstSynth.toExponential(2)}, analys ${worstAnalys.toExponential(2)} ` +
235+ `across ${K} lanes`,
236+ );
237+ for (const b of [...qBufs, ...spatBufs, ...qOutBufs, stage]) b.destroy();
238+ }
239+ }
240+
116241 plan.destroy();
117242 }
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: './',