concept-collection / turing-surface
Check reference files in the browser's compare mode
Compare -> 'Reference file…' loads a turing-surface-test-data .h5 into the convergence study: the file defines the whole problem (model, parameters, geometry, exact initial state), every variant runs to the file's end time and stops there, and a static extra row shows the file's final state on the file's own surface, with each variant's relative-L2 distance to it updating live. The lmax chips are floored at the file's band, since a narrower one could not hold its initial state. The file-format knowledge moves into src/compare/referenceCase.ts, shared by scripts/ref.ts (h5wasm/node) and the new browser loader (h5wasm's wasm build, imported lazily so the page never pays its 4 MB until a file is opened). readSpecies now synthesizes from the spectral state on the view grid in the no-oversampling case too, instead of reading the step plan's grid field — same field by the models' own definition (species = synth of state), but correct immediately after a loadState, which runs no kernel that would write the grid-space fields. Tests: an h5 write/extract round trip (both harnesses, so the browser run also proves the wasm bundles), a refusal check for unknown models, and a loadState-across-grids check covering the display-plan-on-solver-grid branch.
Jeremy Magland <jmagland@flatironinstitute.org> committed commit c90d0e28a98b parent 327b4ec Browse files
13 changed files+797−117
README.mdmodified+11−0View file
@@ -609,6 +609,17 @@ the surface-correction iteration count independent of the file, and
609609 `--tolerance`/`--tolerance-linf` each independently turn their metric into a
610610 pass/fail for CI.
611611
612+The same check runs in the page: **Compare → Reference file…** loads a `.h5`
613+into the convergence study. The file then defines the whole problem — model,
614+parameters, geometry, initial state — so every variant starts from its exact
615+initial condition, runs to its end time, and stops there, measured against
616+one extra static row showing the file's final state on the file's own
617+surface. Watching *where* the variants leave the reference (rather than just
618+reading one number per run) is the point; the lmax choices are floored at the
619+file's own band, since a narrower one could not hold its initial state.
620+Reading the file uses [h5wasm](https://github.com/usnistgov/h5wasm)'s wasm
621+build, loaded lazily on the first file opened.
622+
612623 ## Development
613624
614625 ```
docs/ellipsoid-reference-spec.mdmodified+12−0View file
@@ -107,6 +107,18 @@ how much that correction term actually matters for a given run. `--tolerance
107107 <n>` and `--tolerance-linf <n>` each independently turn their metric into a
108108 pass/fail (nonzero exit code on failure), for use in CI.
109109
110+The browser demo runs the same check visually: **Compare → Reference file…**
111+loads a reference file into the convergence study, seeds every variant from
112+its exact initial state, runs them side by side to its end time, and shows
113+its final state as one extra static row — on the file's own surface, with
114+each variant's relative-L2 distance to it updating live. Both readers share
115+one parser (`src/compare/referenceCase.ts`), so the layout above is
116+interpreted identically on the CLI and in the page.
117+
118+Note the files record only the two endpoint states (`initial/`, `final/`) —
119+no intermediate snapshots — so the comparison is meaningful at the end time;
120+the live Δ before that reads as "distance still to the final state".
121+
110122 ## Caveat
111123
112124 This repo runs fp32 on GPU; expect ~1e-4–1e-6 relative floating-point noise
index.htmlmodified+7−0View file
@@ -300,6 +300,13 @@
300300 <label title="The run everything else is measured against">reference
301301 <select id="cmp-ref"></select>
302302 </label>
303+ <button id="cmp-load"
304+ title="Check the variants against a saved reference run (.h5, the layout in docs/ellipsoid-reference-spec.md): every variant starts from its exact initial state, runs to its end time, and is measured against its final state.">
305+ Reference file…</button>
306+ <input type="file" id="cmp-file" accept=".h5" hidden>
307+ <span id="cmp-fileinfo" class="stats" hidden></span>
308+ <button id="cmp-fileclear" hidden
309+ title="Drop the reference file and compare the variants against each other again">×</button>
303310 <button id="cmp-start" class="primary">Compare</button>
304311 <span id="cmp-count" class="stats"></span>
305312 </div>
scripts/ref.tsmodified+9−54View file
@@ -15,8 +15,7 @@
1515 */
1616 import { requestShtDevice, describeAdapter } from '../src/sht/sht.ts';
1717 import { ModelSession } from '../src/mgpu/session.ts';
18-import { mModelByKey, defaultParams, type Params } from '../src/mgpu/registry.ts';
19-import { mGeometryByKey, defaultGeometryParams } from '../src/geom/registry.ts';
18+import { extractReferenceCase, type H5Node } from '../src/compare/referenceCase.ts';
2019 import { relL2, relLinf } from '../src/mgpu/digest.ts';
2120 import { installWebGpu, errMsg, NO_ADAPTER_HINT } from './nodeWebGpu.ts';
2221 import * as h5wasm from 'h5wasm/node';
@@ -89,14 +88,6 @@ for (let i = 0; i < argv.length; i++) {
8988 }
9089 if (!inFile) fail(`--in <file> is required\n\n${USAGE}`, 2);
9190
92-const attrsOf = (entity: { attrs: Record<string, { value: unknown }> }): Record<string, unknown> =>
93- Object.fromEntries(Object.entries(entity.attrs).map(([k, v]) => [k, v.value]));
94-
95-const numberAttrs = (entity: { attrs: Record<string, { value: unknown }> }): Params =>
96- Object.fromEntries(
97- Object.entries(attrsOf(entity)).map(([k, v]) => [k, Number(v)]),
98- );
99-
10091 let device: GPUDevice | null = null;
10192 let session: ModelSession | null = null;
10293 let h5file: InstanceType<typeof h5wasm.File> | null = null;
@@ -104,52 +95,16 @@ let h5file: InstanceType<typeof h5wasm.File> | null = null;
10495 try {
10596 await h5wasm.ready;
10697 h5file = new h5wasm.File(inFile, 'r');
107-
108- const rootAttrs = attrsOf(h5file);
109- const modelKey = String(rootAttrs.model);
110- const model = mModelByKey(modelKey);
111- if (!model) fail(`unknown model '${modelKey}' in ${inFile}`);
112-
113- const specGroup = h5file.get('spec') as InstanceType<typeof h5wasm.Group>;
114- const specAttrs = attrsOf(specGroup);
115- const geometryKey = String(specAttrs.geometry);
116- const geometryModel = mGeometryByKey(geometryKey);
117- if (!geometryModel) fail(`unknown geometry '${geometryKey}' in ${inFile}`);
118-
119- const lmax = Number(specAttrs.lmax);
120- const steps = Number(specAttrs.steps);
121- const niter = niterOverride ?? Number(specAttrs.niter);
122-
123- const params: Params = {
124- ...defaultParams(model),
125- ...numberAttrs(specGroup.get('params') as InstanceType<typeof h5wasm.Group>),
126- };
127- const geometryParams: Params = {
128- ...defaultGeometryParams(geometryModel),
129- ...numberAttrs(specGroup.get('geometry_params') as InstanceType<typeof h5wasm.Group>),
130- };
131-
132- const geomGroup = h5file.get('geometry') as InstanceType<typeof h5wasm.Group>;
133- const fileGeom = {
134- X: (geomGroup.get('Gx') as InstanceType<typeof h5wasm.Dataset>).value as Float32Array,
135- Y: (geomGroup.get('Gy') as InstanceType<typeof h5wasm.Dataset>).value as Float32Array,
136- Z: (geomGroup.get('Gz') as InstanceType<typeof h5wasm.Dataset>).value as Float32Array,
137- };
138-
139- const initialGroup = h5file.get('initial') as InstanceType<typeof h5wasm.Group>;
140- const finalGroup = h5file.get('final') as InstanceType<typeof h5wasm.Group>;
141- const fileInitial: Record<string, Float32Array> = {};
142- const fileFinal: Record<string, Float32Array> = {};
143- for (const name of model.state) {
144- fileInitial[name] = (initialGroup.get(name) as InstanceType<typeof h5wasm.Dataset>)
145- .value as Float32Array;
146- fileFinal[name] = (finalGroup.get(name) as InstanceType<typeof h5wasm.Dataset>)
147- .value as Float32Array;
148- }
149-
98+ const rc = extractReferenceCase(h5file as H5Node, inFile);
15099 h5file.close();
151100 h5file = null;
152101
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+
153108 const runtime = await installWebGpu();
154109 device = await requestShtDevice().catch((e: unknown) => {
155110 throw new Error(`${errMsg(e)}\n${NO_ADAPTER_HINT}`);
@@ -226,7 +181,7 @@ try {
226181 ` geometry ${geometryModel.label} ` +
227182 geometryModel.params.map((p) => `${p.key}=${geometryParams[p.key]}`).join(' ') +
228183 `\n grid lmax ${lmax} · nlm ${session.sht.nlm}\n` +
229- ` niter ${niter}${niterOverride !== null ? ` (file: ${specAttrs.niter})` : ''}\n` +
184+ ` niter ${niter}${niterOverride !== null ? ` (file: ${rc.niter})` : ''}\n` +
230185 ` run ${steps} steps, dt=${params.dt} (T=${(steps * (params.dt ?? 0)).toFixed(2)})\n`,
231186 );
232187 const fmtErr = (v: { relL2: number; relLinf: number }) =>
scripts/test-node.tsmodified+10−0View file
@@ -8,6 +8,9 @@
88 *
99 * npm run test:node
1010 */
11+import { tmpdir } from 'node:os';
12+import { join } from 'node:path';
13+import * as h5wasm from 'h5wasm/node';
1114 import { requestShtDevice } from '../src/sht/sht.ts';
1215 import { installWebGpu, errMsg, NO_ADAPTER_HINT } from './nodeWebGpu.ts';
1316 import { transformChecks } from '../test/transformChecks.ts';
@@ -16,6 +19,7 @@ import { modelChecks } from '../test/modelChecks.ts';
1619 import { geometryChecks } from '../test/geometryChecks.ts';
1720 import { fluxChecks } from '../test/fluxChecks.ts';
1821 import { compareChecks } from '../test/compareChecks.ts';
22+import { referenceChecks, type H5Rt } from '../test/referenceChecks.ts';
1923
2024 let failures = 0;
2125 const check = (name: string, ok: boolean, detail: string): void => {
@@ -55,6 +59,12 @@ await modelChecks(device, check, log);
5559 await geometryChecks(device, check, log);
5660 await fluxChecks(device, check, log);
5761 await compareChecks(device, check, log);
62+await referenceChecks(
63+ h5wasm as unknown as H5Rt,
64+ (name) => join(tmpdir(), `turing-surface-${process.pid}-${name}`),
65+ check,
66+ log,
67+);
5868
5969 console.log(failures === 0 ? '\nAll tests passed.' : `\n${failures} failed.`);
6070 process.exit(failures === 0 ? 0 : 1);
src/compare/compareRun.tsmodified+254−32View file
@@ -41,8 +41,9 @@ import {
4141 import { SphereScene } from '../render/SphereScene.ts';
4242 import { colormaps } from '../render/colormaps.ts';
4343 import { fmtValue, floorRange } from '../render/colorbar.ts';
44-import { sharedModes, sharedNoise } from './sharedStart.ts';
44+import { prolongCoeffs, sharedModes, sharedNoise } from './sharedStart.ts';
4545 import { variantLabel, VARIANT_COLORS, type Variant } from './variants.ts';
46+import type { ReferenceCase } from './referenceCase.ts';
4647
4748 /**
4849 * Latitudes of the shared display grid. 256 is the same target the single-run
@@ -76,8 +77,19 @@ export interface CompareOptions {
7677 geometryParams: Params;
7778 geometrySource: string;
7879 variants: Variant[];
79- /** Index into `variants` of the run everything else is measured against. */
80+ /** Index into `variants` of the run everything else is measured against.
81+ * Ignored when `refFile` is given — the file is the reference then. */
8082 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;
8193 seed: number;
8294 /** Wavelength of the seeded random field, shared by every variant — one
8395 * initial condition means one wavelength as much as one seed. */
@@ -111,9 +123,32 @@ interface Row {
111123 statEl: HTMLElement;
112124 }
113125
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+
114144 export class CompareRun {
115145 #opts: CompareOptions;
116146 #rows: Row[] = [];
147+ #fileRow: FileRow | null = null;
148+ /** Base steps taken since the initial state — the refFile clock. */
149+ #stepsDone = 0;
150+ /** True once a refFile run has reached the file's end time. */
151+ #finished = false;
117152 #topo: SphereMeshTopology;
118153 /** Quadrature weight per grid point of the shared grid, for the L2 norm. */
119154 #weights: Float64Array;
@@ -137,6 +172,7 @@ export class CompareRun {
137172 private constructor(init: {
138173 opts: CompareOptions;
139174 rows: Row[];
175+ fileRow: FileRow | null;
140176 topo: SphereMeshTopology;
141177 weights: Float64Array;
142178 rangeBars: { fill: (lo: number, hi: number) => void }[];
@@ -145,6 +181,7 @@ export class CompareRun {
145181 }) {
146182 this.#opts = init.opts;
147183 this.#rows = init.rows;
184+ this.#fileRow = init.fileRow;
148185 this.#topo = init.topo;
149186 this.#weights = init.weights;
150187 this.#rangeBars = init.rangeBars;
@@ -168,6 +205,11 @@ export class CompareRun {
168205 return this.#opts.reference;
169206 }
170207
208+ /** The reference file this study is checking against, if any. */
209+ get refFile(): ReferenceCase | null {
210+ return this.#opts.refFile ?? null;
211+ }
212+
171213 /** The base timestep a variant's dtDiv divides. */
172214 static baseDt(params: Params): number {
173215 return params.dt ?? 0;
@@ -182,6 +224,7 @@ export class CompareRun {
182224 // after the grid is up has to take them down explicitly — removing their
183225 // canvases from the DOM would leave both running.
184226 let built: Row[] = [];
227+ let builtFile: FileRow | null = null;
185228
186229 try {
187230 for (let i = 0; i < variants.length; i++) {
@@ -211,7 +254,7 @@ export class CompareRun {
211254
212255 // ---- the shared display grid ----------------------------------------
213256 const maxLmax = Math.max(...variants.map((v) => v.lmax));
214- const panels = variants.length * model.species.length;
257+ const panels = (variants.length + (opts.refFile ? 1 : 0)) * model.species.length;
215258 const target = panels > CROWDED_PANELS ? RENDER_NLAT_CROWDED : RENDER_NLAT;
216259 // Never below what the finest band needs to be representable at all
217260 // (ShtPlan requires nlat > lmax), whatever the panel count says.
@@ -221,12 +264,23 @@ export class CompareRun {
221264 for (const s of sessions) await s.setDisplayGrid(nlat, nphi);
222265
223266 // ---- one initial condition, on every grid ---------------------------
224- opts.onStatus('seeding all variants from one band-limited perturbation…');
225- const noise = await sharedNoise(sessions, model.seedAmp, opts.seed);
226- const modes = await sharedModes(sessions[opts.reference] ?? sessions[0], opts.seed);
227- // One at a time: a seed submits its whole mode sum in pieces, and there
228- // is nothing to gain from interleaving several variants' worth of it.
229- for (let i = 0; i < sessions.length; i++) await sessions[i].seedWith(noise[i], modes);
267+ if (opts.refFile) {
268+ // The file's exact spectral state, prolonged into each variant's band.
269+ // Exact, not approximate: the state is band-limited at the file's lmax
270+ // and every variant's band contains it, so each session starts from
271+ // the very field the reference run started from.
272+ opts.onStatus('loading the initial state from the reference file…');
273+ for (const s of sessions) {
274+ s.loadState(prolongState(opts.refFile.initial, model.state, opts.refFile.lmax, s.cfg.lmax));
275+ }
276+ } else {
277+ opts.onStatus('seeding all variants from one band-limited perturbation…');
278+ const noise = await sharedNoise(sessions, model.seedAmp, opts.seed);
279+ const modes = await sharedModes(sessions[opts.reference] ?? sessions[0], opts.seed);
280+ // One at a time: a seed submits its whole mode sum in pieces, and there
281+ // is nothing to gain from interleaving several variants' worth of it.
282+ for (let i = 0; i < sessions.length; i++) await sessions[i].seedWith(noise[i], modes);
283+ }
230284
231285 // ---- the mesh, shared; the surface, per variant ---------------------
232286 const view = sessions[0].viewSht;
@@ -256,8 +310,9 @@ export class CompareRun {
256310 frameSteps = Math.max(1, frameSteps);
257311
258312 // ---- the grid of panels ---------------------------------------------
259- const { rows, rangeBars } = await buildGrid(opts, sessions, topo, showDt);
313+ const { rows, fileRow, rangeBars } = await buildGrid(opts, sessions, topo, showDt);
260314 built = rows;
315+ builtFile = fileRow;
261316
262317 const solverGrid = sessions.map((s) => `${s.cfg.nlat}×${s.cfg.nphi}`);
263318 const note =
@@ -269,7 +324,7 @@ export class CompareRun {
269324 ` · ops/step ${ops.join(', ')}`;
270325
271326 const run = new CompareRun({
272- opts, rows, topo, weights, rangeBars, frameSteps, note,
327+ opts, rows, fileRow, topo, weights, rangeBars, frameSteps, note,
273328 });
274329 await run.draw();
275330 run.#observeResize();
@@ -277,6 +332,7 @@ export class CompareRun {
277332 return run;
278333 } catch (e) {
279334 for (const r of built) for (const s of r.scenes) s.dispose();
335+ for (const s of builtFile?.scenes ?? []) s.dispose();
280336 for (const s of sessions) s.destroy();
281337 opts.container.replaceChildren();
282338 opts.container.classList.remove('compare');
@@ -294,27 +350,39 @@ export class CompareRun {
294350 return this.#running;
295351 }
296352
297- /** Re-seed every variant from one new shared perturbation. */
353+ /** Re-seed every variant from one new shared perturbation — or, against a
354+ * reference file, restart from its initial state (there is nothing to
355+ * draw; the seed is ignored). */
298356 async reseed(seed: number): Promise<void> {
299357 const wasRunning = this.#running;
300358 this.#running = false;
301359 while (this.#pumping) await nextFrame();
302360 if (this.#disposed) return;
303361 const sessions = this.#rows.map((r) => r.session);
304- const noise = await sharedNoise(sessions, this.#opts.model.seedAmp, seed);
305- const modes = await sharedModes(this.referenceSession ?? sessions[0], seed);
306- // Checked per variant, not once: a seed awaits its own submission, so a
307- // dispose can land between two of them and destroy the sessions left.
308- for (let i = 0; i < sessions.length; i++) {
309- if (this.#disposed) return;
310- await sessions[i].seedWith(noise[i], modes);
362+ const refFile = this.#opts.refFile;
363+ if (refFile) {
364+ for (const s of sessions) {
365+ s.loadState(prolongState(refFile.initial, this.#opts.model.state, refFile.lmax, s.cfg.lmax));
366+ }
367+ } else {
368+ const noise = await sharedNoise(sessions, this.#opts.model.seedAmp, seed);
369+ const modes = await sharedModes(this.referenceSession ?? sessions[0], seed);
370+ // Checked per variant, not once: a seed awaits its own submission, so a
371+ // dispose can land between two of them and destroy the sessions left.
372+ for (let i = 0; i < sessions.length; i++) {
373+ if (this.#disposed) return;
374+ await sessions[i].seedWith(noise[i], modes);
375+ }
311376 }
312377 this.#t = 0;
378+ this.#stepsDone = 0;
379+ this.#finished = false;
313380 for (const r of this.#ranges) {
314381 r.lo = NaN;
315382 r.hi = NaN;
316383 }
317384 await this.draw();
385+ this.#status();
318386 if (!this.#disposed && wasRunning) this.setRunning(true);
319387 }
320388
@@ -333,6 +401,10 @@ export class CompareRun {
333401
334402 /** Model parameters changed. Each variant keeps its own dt. */
335403 setParams(params: Params): void {
404+ // Against a reference file the parameters *are* the file's — they define
405+ // the problem being checked — and the page's parameter panel edits the
406+ // page's own model, which need not even be this one. Nothing to apply.
407+ if (this.#opts.refFile) return;
336408 this.#opts.params = params;
337409 const baseDt = CompareRun.baseDt(params);
338410 for (const r of this.#rows) {
@@ -346,10 +418,15 @@ export class CompareRun {
346418 fillPositions(r.posBuf, r.coords, this.#topo, morph);
347419 for (const s of r.scenes) s.updatePositions(r.posBuf);
348420 }
421+ const f = this.#fileRow;
422+ if (f) {
423+ fillPositions(f.posBuf, f.coords, this.#topo, morph);
424+ for (const s of f.scenes) s.updatePositions(f.posBuf);
425+ }
349426 }
350427
351428 resetView(): void {
352- for (const r of this.#rows) for (const s of r.scenes) s.resetCamera();
429+ for (const s of this.#allScenes()) s.resetCamera();
353430 }
354431
355432 dispose(): void {
@@ -361,11 +438,17 @@ export class CompareRun {
361438 for (const s of r.scenes) s.dispose();
362439 r.session.destroy();
363440 }
441+ for (const s of this.#fileRow?.scenes ?? []) s.dispose();
364442 this.#rows = [];
443+ this.#fileRow = null;
365444 this.#opts.container.replaceChildren();
366445 this.#opts.container.classList.remove('compare');
367446 }
368447
448+ #allScenes(): SphereScene[] {
449+ return [...this.#rows.flatMap((r) => r.scenes), ...(this.#fileRow?.scenes ?? [])];
450+ }
451+
369452 // ----------------------------------------------------------------- drawing
370453 /**
371454 * One frame's readback: every variant's every species, on the shared grid.
@@ -429,7 +512,14 @@ export class CompareRun {
429512 });
430513
431514 for (let k = 0; k < species.length; k++) {
432- const anchor = leastPeak(this.#rows.map((r, i) => (r.healthy ? bounds[i][k] : null)));
515+ // The file row, when there is one, is a candidate like any healthy
516+ // variant: early on the variants' small fields set the scale (it merely
517+ // clips), and if every variant diverges it is the row that keeps the
518+ // grid readable.
519+ const anchor = leastPeak([
520+ ...this.#rows.map((r, i) => (r.healthy ? bounds[i][k] : null)),
521+ this.#fileRow?.bounds[k] ?? null,
522+ ]);
433523 const range = this.#ranges[k];
434524 if (anchor) {
435525 if (!Number.isFinite(range.lo)) {
@@ -456,6 +546,12 @@ export class CompareRun {
456546 fillColors(r.colorBufs[k], r.valueBufs[k], shown.lo, shown.hi, cmap);
457547 r.scenes[k]?.updateColors(r.colorBufs[k]);
458548 }
549+ const f = this.#fileRow;
550+ if (f) {
551+ // Its values never change; only its coloring follows the shared range.
552+ fillColors(f.colorBufs[k], f.valueBufs[k], shown.lo, shown.hi, cmap);
553+ f.scenes[k]?.updateColors(f.colorBufs[k]);
554+ }
459555 }
460556
461557 this.#measureDifference();
@@ -470,8 +566,11 @@ export class CompareRun {
470566 * a physical quantity, which is all it is used for.
471567 */
472568 #measureDifference(): void {
473- const ref = this.#rows[this.#opts.reference];
474- if (!ref) return;
569+ // Against a reference file, every row is measured against its final state;
570+ // otherwise against the chosen reference variant, whose own Δ is zero.
571+ const ref = this.#fileRow ? null : this.#rows[this.#opts.reference];
572+ const refFields = this.#fileRow?.fields ?? ref?.fields;
573+ if (!refFields) return;
475574 const species = this.#opts.model.species;
476575 for (const r of this.#rows) {
477576 for (let k = 0; k < species.length; k++) {
@@ -480,7 +579,7 @@ export class CompareRun {
480579 continue;
481580 }
482581 const a = r.fields[k];
483- const b = ref.fields[k];
582+ const b = refFields[k];
484583 if (!a || !b || a.length !== b.length) {
485584 r.err[k] = NaN;
486585 continue;
@@ -507,7 +606,7 @@ export class CompareRun {
507606 */
508607 #updateRowStats(): void {
509608 const species = this.#opts.model.species;
510- const ref = this.#rows[this.#opts.reference];
609+ const ref = this.#fileRow ? null : this.#rows[this.#opts.reference];
511610 for (const r of this.#rows) {
512611 const per = species
513612 .map((s, k) => `${s} ${Number.isFinite(r.err[k]) ? r.err[k].toExponential(2) : '—'}`)
@@ -525,15 +624,22 @@ export class CompareRun {
525624 }
526625
527626 #status(): void {
627+ const refFile = this.#opts.refFile;
628+ const clock = refFile
629+ ? `<b>t = ${this.#t.toFixed(2)} / ${(refFile.steps * CompareRun.baseDt(this.#opts.params)).toFixed(2)}</b>` +
630+ (this.#finished
631+ ? ` — <b>at the file's end time</b>: Δ is the final comparison against its final state`
632+ : ` · Δ is the distance still to the file's <i>final</i> state — read it at the end time`)
633+ : `<b>t = ${this.#t.toFixed(2)}</b> (same for every variant)`;
528634 this.#opts.onStatus(
529- `<b>t = ${this.#t.toFixed(2)}</b> (same for every variant) · ` +
635+ `${clock} · ` +
530636 (this.#frameMs > 0 ? `${this.#frameMs.toFixed(1)} ms/frame · ` : '') +
531637 this.#note,
532638 );
533639 }
534640
535641 #observeResize(): void {
536- const scenes = this.#rows.flatMap((r) => r.scenes);
642+ const scenes = this.#allScenes();
537643 this.#resizeObs = new ResizeObserver(() => {
538644 for (const s of scenes) {
539645 const box = s.canvas.parentElement;
@@ -560,13 +666,34 @@ export class CompareRun {
560666 this.#pumping = true;
561667 try {
562668 while (this.#running && !this.#disposed) {
669+ // Against a reference file the run is finite: the last frame takes
670+ // however many base steps remain, so every variant lands exactly on
671+ // the file's end time — where Δ against its final state is the
672+ // comparison — and stops there rather than drifting past it.
673+ const refFile = this.#opts.refFile;
674+ const n = refFile
675+ ? Math.min(this.#frameSteps, refFile.steps - this.#stepsDone)
676+ : this.#frameSteps;
677+ if (n <= 0) {
678+ this.#running = false;
679+ this.#opts.onFinished?.();
680+ break;
681+ }
563682 const t0 = performance.now();
564- for (const r of this.#rows) r.session.step(this.#frameSteps * r.variant.dtDiv);
565- this.#t += this.#frameSteps * CompareRun.baseDt(this.#opts.params);
683+ for (const r of this.#rows) r.session.step(n * r.variant.dtDiv);
684+ this.#stepsDone += n;
685+ this.#t += n * CompareRun.baseDt(this.#opts.params);
566686 await this.draw();
567687 if (this.#disposed) break;
568688 const dt = performance.now() - t0;
569689 this.#frameMs = this.#frameMs === 0 ? dt : this.#frameMs + 0.05 * (dt - this.#frameMs);
690+ if (refFile && this.#stepsDone >= refFile.steps) {
691+ this.#finished = true;
692+ this.#running = false;
693+ this.#status();
694+ this.#opts.onFinished?.();
695+ break;
696+ }
570697 this.#status();
571698 await nextFrame();
572699 }
@@ -582,6 +709,19 @@ export class CompareRun {
582709
583710 const nextFrame = (): Promise<number> => new Promise(requestAnimationFrame);
584711
712+/** A whole spectral state re-indexed into a (wider) band's layout — the
713+ * reference file's initial condition, in the form loadState takes. */
714+function prolongState(
715+ coeffs: Record<string, Float32Array>,
716+ names: string[],
717+ lmaxFrom: number,
718+ lmaxTo: number,
719+): Record<string, Float32Array> {
720+ const out: Record<string, Float32Array> = {};
721+ for (const name of names) out[name] = prolongCoeffs(coeffs[name], lmaxFrom, lmaxTo);
722+ return out;
723+}
724+
585725 /** Whether every entry is an ordinary number — false once a variant has left
586726 * its convergence radius and saturated to infinity or NaN. */
587727 function allFinite(f: Float32Array | undefined): boolean {
@@ -626,12 +766,20 @@ function finiteRange(f: Float32Array | undefined): { lo: number; hi: number } |
626766 * the same bar repeated, and would suggest each panel had its own scaling,
627767 * which is exactly the thing that would make the comparison a lie.
628768 */
769+/** The file row's label color — none of the variant palette, since it is not
770+ * a variant: it is the thing they are all measured against. */
771+const FILE_ROW_COLOR = '#57606a';
772+
629773 async function buildGrid(
630774 opts: CompareOptions,
631775 sessions: ModelSession[],
632776 topo: SphereMeshTopology,
633777 showDt: boolean,
634-): Promise<{ rows: Row[]; rangeBars: { fill: (lo: number, hi: number) => void }[] }> {
778+): Promise<{
779+ rows: Row[];
780+ fileRow: FileRow | null;
781+ rangeBars: { fill: (lo: number, hi: number) => void }[];
782+}> {
635783 const { container, model } = opts;
636784 container.replaceChildren();
637785 container.classList.add('compare');
@@ -732,10 +880,84 @@ async function buildGrid(
732880 });
733881 }
734882
883+ // ---- the reference file's final state, as one more (static) row ---------
884+ let fileRow: FileRow | null = null;
885+ if (opts.refFile) {
886+ const rf = opts.refFile;
887+ // Synthesized through the coarsest session's display plan — exact, like
888+ // every other use of the shared grid: the file's coefficients are
889+ // band-limited at its lmax, which every variant's band contains.
890+ const view = sessions[0].viewSht;
891+ const lmaxTo = sessions[0].cfg.lmax;
892+ const on = (q: Float32Array): Promise<Float32Array> =>
893+ view.synth(prolongCoeffs(q, rf.lmax, lmaxTo));
894+ const [gx, gy, gz] = [
895+ await on(rf.geometryCoeffs.X),
896+ await on(rf.geometryCoeffs.Y),
897+ await on(rf.geometryCoeffs.Z),
898+ ];
899+ // The file's own surface, not a regeneration of it — interleaved xyz, the
900+ // same layout renderPositions() hands back.
901+ const coords = new Float32Array(3 * gx.length);
902+ for (let i = 0; i < gx.length; i++) {
903+ coords[3 * i] = gx[i];
904+ coords[3 * i + 1] = gy[i];
905+ coords[3 * i + 2] = gz[i];
906+ }
907+ const posBuf = new Float32Array(topo.numVertices * 3);
908+ fillPositions(posBuf, coords, topo, opts.morph);
909+
910+ const rowEl = document.createElement('div');
911+ rowEl.className = 'cmp-row';
912+ const labelEl = document.createElement('div');
913+ labelEl.className = 'cmp-rowlabel';
914+ labelEl.style.setProperty('--c', FILE_ROW_COLOR);
915+ const nameEl = document.createElement('div');
916+ nameEl.className = 'cmp-rowname';
917+ nameEl.textContent = 'reference file';
918+ nameEl.title = rf.label;
919+ const statEl = document.createElement('div');
920+ statEl.className = 'cmp-rowstat';
921+ statEl.innerHTML = `${rf.steps.toLocaleString()} steps<br><b>final state</b>`;
922+ labelEl.append(nameEl, statEl);
923+ const colsEl = document.createElement('div');
924+ colsEl.className = 'cmp-cols';
925+ rowEl.append(labelEl, colsEl);
926+ container.append(rowEl);
927+
928+ const scenes: SphereScene[] = [];
929+ const valueBufs: Float32Array[] = [];
930+ const colorBufs: Float32Array[] = [];
931+ const fields: Float32Array[] = [];
932+ const bounds: (Bounds | null)[] = [];
933+ for (let k = 0; k < model.species.length; k++) {
934+ const box = document.createElement('div');
935+ box.className = 'sphere-box cmp-box';
936+ colsEl.append(box);
937+ const scene = new SphereScene(
938+ box,
939+ topo.numVertices,
940+ topo.indices,
941+ Float32Array.from(posBuf),
942+ sphereBg || undefined,
943+ );
944+ scene.fitCamera();
945+ scenes.push(scene);
946+ const field = await on(rf.final[model.state[k]]);
947+ fields.push(field);
948+ bounds.push(finiteRange(field));
949+ const valueBuf = new Float32Array(topo.numVertices);
950+ fillFieldValues(valueBuf, field, topo);
951+ valueBufs.push(valueBuf);
952+ colorBufs.push(new Float32Array(topo.numVertices * 3));
953+ }
954+ fileRow = { coords, posBuf, scenes, valueBufs, colorBufs, fields, bounds };
955+ }
956+
735957 // Every panel shares one camera: the study is about the fields, and looking
736958 // at two of them from different angles is not comparing them.
737- const all = rows.flatMap((r) => r.scenes);
959+ const all = [...rows.flatMap((r) => r.scenes), ...(fileRow?.scenes ?? [])];
738960 for (let i = 1; i < all.length; i++) all[0].syncCamerasWith(all[i]);
739961
740- return { rows, rangeBars };
962+ return { rows, fileRow, rangeBars };
741963 }
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/main.tsmodified+132−24View file
@@ -39,6 +39,8 @@ import {
3939 variantLabel,
4040 type Variant,
4141 } from './compare/variants.ts';
42+import { loadReferenceFile } from './compare/referenceFile.ts';
43+import type { ReferenceCase } from './compare/referenceCase.ts';
4244
4345 const $ = <T extends HTMLElement>(id: string): T =>
4446 document.getElementById(id) as T;
@@ -67,6 +69,10 @@ const elCmpNiter = $('cmp-niter');
6769 const elCmpLmax = $('cmp-lmax');
6870 const elCmpDt = $('cmp-dt');
6971 const elCmpRef = $<HTMLSelectElement>('cmp-ref');
72+const elCmpLoad = $<HTMLButtonElement>('cmp-load');
73+const elCmpFile = $<HTMLInputElement>('cmp-file');
74+const elCmpFileInfo = $('cmp-fileinfo');
75+const elCmpFileClear = $<HTMLButtonElement>('cmp-fileclear');
7076 const elCmpStart = $<HTMLButtonElement>('cmp-start');
7177 const elCmpCount = $('cmp-count');
7278 const elParams = $('params');
@@ -403,6 +409,12 @@ function currentSpec(): RunSpec {
403409 }
404410
405411 function updateCommand(): void {
412+ // A study against a reference file replays the file, so its desktop
413+ // equivalent is the ref checker, not the benchmark.
414+ if (compareRun?.refFile) {
415+ elCmd.textContent = `npm run ref -- --in ${compareRun.refFile.label}`;
416+ return;
417+ }
406418 elCmd.textContent = formatCommand(currentSpec());
407419 }
408420
@@ -1203,6 +1215,15 @@ function buildChips(host: HTMLElement, values: number[], selected: Set<number>,
12031215 const cmpVariants = (): Variant[] =>
12041216 crossProduct([...cmpSelected.niter], [...cmpSelected.lmax], [...cmpSelected.dt]);
12051217
1218+/**
1219+ * A loaded reference file, or null. While one is loaded the study checks the
1220+ * variants against it instead of against each other: the file defines the
1221+ * whole problem (model, parameters, geometry, initial state, end time), so
1222+ * the page's own model and geometry choices do not enter the study at all —
1223+ * only the solver knobs above do.
1224+ */
1225+let refCase: ReferenceCase | null = null;
1226+
12061227 /** The reference the user picked, clamped to the current variant list. */
12071228 let cmpRefKey = '';
12081229
@@ -1215,19 +1236,32 @@ function compareRefIndex(): number {
12151236 function refreshVariants(): void {
12161237 const variants = cmpVariants();
12171238 const showDt = cmpSelected.dt.size > 1;
1218- const panels = variants.length * model.species.length;
1239+ // With a file loaded the study's model is the file's, and its final state
1240+ // is one more row of panels.
1241+ const cmpModel = refCase?.model ?? model;
1242+ const rowCount = variants.length + (refCase ? 1 : 0);
1243+ const panels = rowCount * cmpModel.species.length;
12191244
12201245 const prev = cmpRefKey;
12211246 elCmpRef.replaceChildren();
1222- for (const v of variants) {
1247+ if (refCase) {
1248+ // The file is the reference; the pick among variants means nothing here.
12231249 const o = document.createElement('option');
1224- o.value = variantKey(v);
1225- o.textContent = variantLabel(v, showDt);
1250+ o.textContent = `the file's final state`;
12261251 elCmpRef.append(o);
1252+ elCmpRef.disabled = true;
1253+ } else {
1254+ elCmpRef.disabled = false;
1255+ for (const v of variants) {
1256+ const o = document.createElement('option');
1257+ o.value = variantKey(v);
1258+ o.textContent = variantLabel(v, showDt);
1259+ elCmpRef.append(o);
1260+ }
1261+ const keys = variants.map(variantKey);
1262+ cmpRefKey = keys.includes(prev) ? prev : keys[mostResolved(variants)];
1263+ elCmpRef.value = cmpRefKey;
12271264 }
1228- const keys = variants.map(variantKey);
1229- cmpRefKey = keys.includes(prev) ? prev : keys[mostResolved(variants)];
1230- elCmpRef.value = cmpRefKey;
12311265
12321266 const tooMany =
12331267 variants.length > MAX_VARIANTS
@@ -1237,23 +1271,38 @@ function refreshVariants(): void {
12371271 : '';
12381272 elCmpCount.textContent = tooMany
12391273 ? `too many: ${tooMany}`
1240- : `${variants.length} variants × ${model.species.length} species = ${panels} panels`;
1274+ : `${variants.length} variants${refCase ? ' + the file' : ''} × ` +
1275+ `${cmpModel.species.length} species = ${panels} panels`;
12411276 elCmpCount.style.color = tooMany ? '#b35900' : '';
12421277 elCmpStart.disabled = tooMany !== '' && compareRun === null;
12431278 }
12441279
1280+/**
1281+ * The lmax chips on offer. A loaded reference file floors them at its own
1282+ * band: a variant below it could not even hold the file's initial state
1283+ * (prolongation only widens), so those values are not offered rather than
1284+ * offered and refused.
1285+ */
1286+function rebuildLmaxChips(): void {
1287+ const all = [...elLmax.options].map((o) => Number(o.value));
1288+ let values = all;
1289+ if (refCase) {
1290+ const floor = refCase.lmax;
1291+ values = all.filter((v) => v >= floor);
1292+ if (!values.includes(floor)) values = [floor, ...values];
1293+ for (const v of [...cmpSelected.lmax]) if (!values.includes(v)) cmpSelected.lmax.delete(v);
1294+ if (cmpSelected.lmax.size === 0) cmpSelected.lmax.add(floor);
1295+ }
1296+ buildChips(elCmpLmax, values, cmpSelected.lmax, String);
1297+}
1298+
12451299 buildChips(
12461300 elCmpNiter,
12471301 [...elNiter.options].map((o) => Number(o.value)),
12481302 cmpSelected.niter,
12491303 String,
12501304 );
1251-buildChips(
1252- elCmpLmax,
1253- [...elLmax.options].map((o) => Number(o.value)),
1254- cmpSelected.lmax,
1255- String,
1256-);
1305+rebuildLmaxChips();
12571306 buildChips(elCmpDt, DT_DIVISORS, cmpSelected.dt, (v) => (v === 1 ? 'dt' : `dt/${v}`));
12581307 refreshVariants();
12591308
@@ -1262,6 +1311,50 @@ elCmpRef.addEventListener('change', () => {
12621311 if (compareRun) void rebuildCompare();
12631312 });
12641313
1314+/** Reflect the loaded (or cleared) reference file in the compare bar. */
1315+function applyRefUi(): void {
1316+ elCmpFileInfo.hidden = elCmpFileClear.hidden = refCase === null;
1317+ if (refCase) {
1318+ const rc = refCase;
1319+ const geomParamText = rc.geometry.params
1320+ .map((p) => `${p.key}=${rc.geometryParams[p.key]}`)
1321+ .join(' ');
1322+ const name = document.createElement('b');
1323+ name.textContent = rc.label;
1324+ const info = document.createElement('span');
1325+ info.textContent =
1326+ ` — ${rc.model.label} on ${rc.geometry.label.toLowerCase()}` +
1327+ (geomParamText ? ` (${geomParamText})` : '') +
1328+ `, lmax ${rc.lmax}, T = ${(rc.steps * (rc.params.dt ?? 0)).toFixed(2)}` +
1329+ ` (${rc.steps} × dt ${rc.params.dt})`;
1330+ elCmpFileInfo.replaceChildren(name, info);
1331+ }
1332+ rebuildLmaxChips();
1333+ refreshVariants();
1334+}
1335+
1336+elCmpLoad.addEventListener('click', () => elCmpFile.click());
1337+elCmpFile.addEventListener('change', () => {
1338+ const file = elCmpFile.files?.[0];
1339+ // Cleared so picking the same file again still fires a change event.
1340+ elCmpFile.value = '';
1341+ if (!file) return;
1342+ void (async () => {
1343+ try {
1344+ refCase = await loadReferenceFile(file);
1345+ elErr.textContent = '';
1346+ } catch (e) {
1347+ refCase = null;
1348+ elErr.textContent = `reference file ${file.name}: ${e instanceof Error ? e.message : e}`;
1349+ }
1350+ applyRefUi();
1351+ })();
1352+});
1353+elCmpFileClear.addEventListener('click', () => {
1354+ refCase = null;
1355+ applyRefUi();
1356+});
1357+
12651358 elCompareToggle.addEventListener('click', () => {
12661359 elCompareBar.hidden = !elCompareBar.hidden;
12671360 });
@@ -1273,7 +1366,12 @@ elCmpStart.addEventListener('click', () => {
12731366
12741367 /** Controls the study supersedes or cannot honour while it is running. */
12751368 function setCompareUi(on: boolean): void {
1276- for (const el of [elNiter, elLmax, elOversample, elBenchmark, elMovieToggle]) {
1369+ for (const el of [
1370+ elNiter, elLmax, elOversample, elBenchmark, elMovieToggle,
1371+ // Swapping the reference file out from under a running study would leave
1372+ // it checking against a file that is no longer the loaded one.
1373+ elCmpLoad, elCmpFileClear,
1374+ ]) {
12771375 el.disabled = on;
12781376 }
12791377 elCmpNiter.querySelectorAll('button').forEach((b) => (b.disabled = on));
@@ -1286,8 +1384,13 @@ function setCompareUi(on: boolean): void {
12861384
12871385 async function startCompare(): Promise<void> {
12881386 if (compareRun || !device) return;
1387+ // Snapshotted for the whole study: `refCase` itself only changes while no
1388+ // study is up (the load and clear buttons are disabled during one).
1389+ const rc = refCase;
1390+ const cmpModel = rc?.model ?? model;
12891391 const variants = cmpVariants();
1290- if (variants.length > MAX_VARIANTS || variants.length * model.species.length > MAX_PANELS) {
1392+ const rowCount = variants.length + (rc ? 1 : 0);
1393+ if (variants.length > MAX_VARIANTS || rowCount * cmpModel.species.length > MAX_PANELS) {
12911394 return;
12921395 }
12931396 // Take down the single run first: its pump, its scenes, its session. The
@@ -1303,18 +1406,23 @@ async function startCompare(): Promise<void> {
13031406 setCompareUi(true);
13041407
13051408 try {
1409+ // Against a reference file, the problem is the file's — its model,
1410+ // parameters and geometry, from the registry sources (the editor's
1411+ // working copies describe the page's run, not the file's).
13061412 compareRun = await CompareRun.create({
13071413 device,
1308- model,
1309- params,
1310- source: source(),
1311- geometry,
1312- geometryParams: geomParams,
1313- geometrySource: geomSource(),
1414+ model: cmpModel,
1415+ params: rc ? rc.params : params,
1416+ source: rc ? rc.model.source : source(),
1417+ geometry: rc ? rc.geometry : geometry,
1418+ geometryParams: rc ? rc.geometryParams : geomParams,
1419+ geometrySource: rc ? rc.geometry.source : geomSource(),
13141420 variants,
1315- reference: compareRefIndex(),
1421+ reference: rc ? 0 : compareRefIndex(),
1422+ refFile: rc ?? undefined,
1423+ onFinished: () => setRunning(false),
13161424 seed,
1317- lam3: Number(elLam3.value),
1425+ lam3: rc ? undefined : Number(elLam3.value),
13181426 morph,
13191427 colormapName: () => elColormap.value,
13201428 container: elPanels,
src/mgpu/session.tsmodified+6−7View file
@@ -398,18 +398,17 @@ export class ModelSession {
398398 }
399399
400400 /**
401- * Read species `k` at render resolution (`viewSht`'s grid). Without
402- * oversampling this is the grid field the .m returned. With oversampling the
403- * spectral state is synthesized on the finer grid instead — the same field,
404- * since the models define each species as synth of its state, evaluated
405- * exactly on more points.
401+ * Read species `k` at render resolution (`viewSht`'s grid): the spectral
402+ * state synthesized there. The models define each species as synth of its
403+ * state, so this is the field the .m returned — evaluated exactly, whatever
404+ * the grid — and it is current however the state last changed, including a
405+ * `loadState`, which runs no kernel that would write the grid-space fields.
406406 */
407407 readSpecies(k: number): Promise<Float32Array> {
408- if (!this.#displaySht) return this.read(this.model.species[k]);
409408 const state = this.model.state[k];
410409 const buf = this.gpu.valueBuffer(state);
411410 if (!buf) throw new Error(`readSpecies: no buffer for state '${state}'`);
412- return this.#displaySht.synthFrom(buf);
411+ return this.viewSht.synthFrom(buf);
413412 }
414413
415414 describe(): { init: string[]; step: string[] } {
test/compareChecks.tsmodified+67−0View file
@@ -75,6 +75,73 @@ export async function compareChecks(
7575 );
7676 }
7777
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+
78145 // ---- one random field across lmax: the shipped models' seeding -----------
79146 {
80147 const model = mModelByKey('schnakenberg')!;
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+4−0View file
@@ -23,12 +23,14 @@ import {
2323 defaultGeometryParams,
2424 DEFAULT_GEOMETRY_KEY,
2525 } from '../src/geom/registry.ts';
26+import * as h5wasm from 'h5wasm';
2627 import { transformChecks } from './transformChecks.ts';
2728 import { analyticChecks } from './analyticChecks.ts';
2829 import { modelChecks } from './modelChecks.ts';
2930 import { geometryChecks } from './geometryChecks.ts';
3031 import { fluxChecks } from './fluxChecks.ts';
3132 import { compareChecks } from './compareChecks.ts';
33+import { referenceChecks, type H5Rt } from './referenceChecks.ts';
3234
3335 declare global {
3436 interface Window {
@@ -222,6 +224,8 @@ async function main(): Promise<void> {
222224 await geometryChecks(device, check, log, { sweep: q.has('sweep') });
223225 await fluxChecks(device, check, log, { ab: q.has('sweep') });
224226 await compareChecks(device, check, log);
227+ // '/' is the wasm module's in-memory filesystem — nothing touches disk.
228+ await referenceChecks(h5wasm as unknown as H5Rt, (name) => `/${name}`, check, log);
225229
226230 window.__RESULTS__ = { ok: failures === 0, lines };
227231 log(failures === 0 ? 'ALL PASS' : `${failures} FAILURE(S)`);