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 */
31import { ModelSession } from '../mgpu/session.ts';
32import type { MModel, Params } from '../mgpu/registry.ts';
33import type { MGeometry } from '../geom/registry.ts';
34import {
35 buildTopology,
36 fillFieldValues,
37 fillPositions,
38 fillColors,
39 type SphereMeshTopology,
40} from '../render/sphereMesh.ts';
41import { SphereScene } from '../render/SphereScene.ts';
42import { colormaps } from '../render/colormaps.ts';
ef3ae33Do not stretch the colormap across a constant field's roundoffJeremy Magland 43import { fmtValue, floorRange } from '../render/colorbar.ts';
c90d0e2Check reference files in the browser's compare modeJeremy Magland 44import { prolongCoeffs, sharedModes, sharedNoise } from './sharedStart.ts';
f467ffaCompare several solver settings side by side, on one clockJeremy Magland 45import { variantLabel, VARIANT_COLORS, type Variant } from './variants.ts';
c90d0e2Check reference files in the browser's compare modeJeremy Magland 46import type { ReferenceCase } from './referenceCase.ts';
02e7a36Update comparison UIOwen Melia 47import { ErrorChart, type ErrorChartRow } from '../render/errorChart.ts';
49/**
50 * Latitudes of the shared display grid. 256 is the same target the single-run
51 * view uses for 'auto' oversampling, and for the same reason — beyond it a
52 * finer mesh costs vertices without showing anything.
53 *
54 * Here it is a ceiling as well as a target, in two directions. At lmax 255 the
55 * solver grid is finer than this, so the panels sample the (exact) state more
56 * coarsely than the solver carries it; and past a handful of panels the mesh is
57 * paid for once per panel, in vertices, normals and a WebGL context each, so it
58 * halves. Both are display choices, both are reported in the status line, and
59 * neither touches the difference norm's meaning: that is computed on this same
60 * grid for every variant, so it stays a consistent comparison whatever the grid.
61 */
62const RENDER_NLAT = 256;
63const RENDER_NLAT_CROWDED = 128;
64const CROWDED_PANELS = 6;
66/** See main.ts's DISPATCH_BUDGET — the same watchdog argument, per variant. */
67const DISPATCH_BUDGET = 1000;
68const STEPS_PER_FRAME_BASE = 4;
70export interface CompareOptions {
71 device: GPUDevice;
72 model: MModel;
73 /** The model's parameters, with `dt` read as the *base* timestep that each
74 * variant's dtDiv divides. */
75 params: Params;
76 source: string;
77 geometry: MGeometry;
78 geometryParams: Params;
79 geometrySource: string;
cf4af12WIP: comparison with unit sphereOwen Melia 80 /** Per-row geometry override, parallel-indexed to `variants` — the
81 * "several rows on several geometries" case (vs-sphere mode), as opposed
82 * to every other mode's "several rows, one geometry." Rows without an
83 * entry (or when this is omitted entirely) fall back to the single
84 * `geometry`/`geometryParams`/`geometrySource` above. */
85 geometries?: { geometry: MGeometry; geometryParams: Params; geometrySource: string }[];
86 /** Render every row — value panels and diff panels alike — on the
87 * *reference* row's own surface (`coords`/`posBuf`) instead of each row's
88 * own. For comparing two different geometries where only the *field*
89 * should read as different, not the displayed shape. */
90 renderOnReferenceGeometry?: boolean;
91 /** Row names, overriding `variantLabel(variant, showDt)` — for a mode
92 * where every row shares the same niter/lmax/dt, so that label alone
93 * wouldn't tell the rows apart. */
94 rowLabels?: string[];
f467ffaCompare several solver settings side by side, on one clockJeremy Magland 95 variants: Variant[];
c90d0e2Check reference files in the browser's compare modeJeremy Magland 96 /** Index into `variants` of the run everything else is measured against.
97 * Ignored when `refFile` is given — the file is the reference then. */
f467ffaCompare several solver settings side by side, on one clockJeremy Magland 98 reference: number;
100 * Check against a reference file instead of against each other: its exact
101 * initial state seeds every variant (so `seed` and `lam3` go unused), a
102 * static extra row shows its final state, every Δ is measured against that
103 * row, and the clock stops at the file's end time. Every variant's lmax must
104 * be >= the file's — a narrower band could not hold the initial state.
105 */
106 refFile?: ReferenceCase;
107 /** Called when a refFile run reaches the file's end time and stops. */
108 onFinished?: () => void;
beac00aMerge main into random-fieldsJeremy Magland 110 /** Wavelength of the seeded random field, shared by every variant — one
111 * initial condition means one wavelength as much as one seed. */
112 lam3?: number;
114 colormapName: () => string;
115 /** Where the variant grid goes (the app's #panels). */
116 container: HTMLElement;
02e7a36Update comparison UIOwen Melia 117 /** Where the error-vs-time chart goes (the app's #cmp-chart). */
118 chartContainer: HTMLElement;
f467ffaCompare several solver settings side by side, on one clockJeremy Magland 119 /** Progress and, afterwards, the standing description of the study. */
120 onStatus: (html: string) => void;
121}
123interface Row {
124 variant: Variant;
125 session: ModelSession;
126 color: string;
127 /** Surface coordinates on the shared render grid — this variant's own. */
128 coords: Float32Array;
129 posBuf: Float32Array;
130 scenes: SphereScene[];
131 valueBufs: Float32Array[];
132 colorBufs: Float32Array[];
133 /** Fields read this frame, one per species, on the shared grid. */
134 fields: Float32Array[];
02e7a36Update comparison UIOwen Melia 135 /** Pointwise difference from the reference, one per species, on the shared
136 * grid — filled by #measureDifference as it accumulates the norm below.
137 * Unused (and never touched) for the reference row itself. */
138 diffFields: Float32Array[];
139 /** The diff row's own panels, one per species — empty for the reference
140 * row, whose diff against itself is trivially zero. */
141 diffScenes: SphereScene[];
142 diffValueBufs: Float32Array[];
143 diffColorBufs: Float32Array[];
144 /** This row's own smoothed symmetric color range per species — each row
145 * is scaled to its own diff extent, not a range shared across the
146 * column, so one row's diff panels never affect another's coloring. */
147 diffRanges: { lo: number; hi: number }[];
148 /** Each diff panel's "± magnitude" caption, parallel to diffScenes. */
149 diffCaps: HTMLElement[];
f467ffaCompare several solver settings side by side, on one clockJeremy Magland 150 /** Relative difference from the reference, one per species. */
151 err: number[];
152 /** False once any species has left the floating-point numbers — the shape a
153 * variant outside the convergence radius eventually fails in. Such a row is
154 * never used to scale a column, and its label says so. */
155 healthy: boolean;
156 statEl: HTMLElement;
157}
160 * The reference file's final state, as one more row of panels — with no
161 * session behind it: its surface and fields are the file's coefficients
162 * synthesized once on the shared display grid, fixed for the whole run. Only
163 * its coloring changes, with the shared range.
164 */
165interface FileRow {
166 coords: Float32Array;
167 posBuf: Float32Array;
168 scenes: SphereScene[];
169 valueBufs: Float32Array[];
170 colorBufs: Float32Array[];
171 /** The file's final state on the shared grid, one per species. */
172 fields: Float32Array[];
173 /** Its extent, precomputed — a candidate for the shared color range. */
174 bounds: (Bounds | null)[];
175}
f467ffaCompare several solver settings side by side, on one clockJeremy Magland 177export class CompareRun {
178 #opts: CompareOptions;
179 #rows: Row[] = [];
c90d0e2Check reference files in the browser's compare modeJeremy Magland 180 #fileRow: FileRow | null = null;
ca37955Adding a way to reset simulation from same random IC.Owen Melia 181 /** What restart() reloads: the file's fixed state if opts.refFile is set,
182 * otherwise a snapshot of the coarsest variant's state as of the last
183 * (re-)seed — see the capture in create() and in reseed()'s plain branch. */
184 #initial: Record<string, Float32Array>;
185 #initialLmax: number;
c90d0e2Check reference files in the browser's compare modeJeremy Magland 186 /** Base steps taken since the initial state — the refFile clock. */
187 #stepsDone = 0;
188 /** True once a refFile run has reached the file's end time. */
189 #finished = false;
f467ffaCompare several solver settings side by side, on one clockJeremy Magland 190 #topo: SphereMeshTopology;
191 /** Quadrature weight per grid point of the shared grid, for the L2 norm. */
192 #weights: Float64Array;
193 #rangeBars: { fill: (lo: number, hi: number) => void }[] = [];
194 /** Smoothed color range per species, shared by every variant so the panels
195 * in a column are directly comparable by eye and not just by number. */
196 #ranges: { lo: number; hi: number }[] = [];
f467ffaCompare several solver settings side by side, on one clockJeremy Magland 198 #resizeObs: ResizeObserver | null = null;
200 #running = false;
201 #pumping = false;
202 #disposed = false;
203 #morph: number;
204 /** Base steps per frame; variant i takes this times its dtDiv. */
205 #frameSteps = STEPS_PER_FRAME_BASE;
206 /** Model time all variants are at — one number, by construction. */
207 #t = 0;
208 #frameMs = 0;
209 #note: string;
211 private constructor(init: {
212 opts: CompareOptions;
213 rows: Row[];
c90d0e2Check reference files in the browser's compare modeJeremy Magland 214 fileRow: FileRow | null;
f467ffaCompare several solver settings side by side, on one clockJeremy Magland 215 topo: SphereMeshTopology;
216 weights: Float64Array;
217 rangeBars: { fill: (lo: number, hi: number) => void }[];
218 frameSteps: number;
219 note: string;
ca37955Adding a way to reset simulation from same random IC.Owen Melia 220 initial: Record<string, Float32Array>;
221 initialLmax: number;
223 this.#opts = init.opts;
224 this.#rows = init.rows;
c90d0e2Check reference files in the browser's compare modeJeremy Magland 225 this.#fileRow = init.fileRow;
f467ffaCompare several solver settings side by side, on one clockJeremy Magland 226 this.#topo = init.topo;
227 this.#weights = init.weights;
228 this.#rangeBars = init.rangeBars;
229 this.#frameSteps = init.frameSteps;
230 this.#note = init.note;
231 this.#morph = init.opts.morph;
232 this.#ranges = init.opts.model.species.map(() => ({ lo: NaN, hi: NaN }));
ca37955Adding a way to reset simulation from same random IC.Owen Melia 233 this.#initial = init.initial;
234 this.#initialLmax = init.initialLmax;
237 get variants(): Variant[] {
238 return this.#rows.map((r) => r.variant);
239 }
241 /** The variant everything else is measured against — the one whose numbers
242 * stand on their own, so the one the app quotes when it has to quote one. */
243 get referenceSession(): ModelSession | null {
244 return this.#rows[this.#opts.reference]?.session ?? null;
245 }
247 get referenceIndex(): number {
248 return this.#opts.reference;
249 }
c90d0e2Check reference files in the browser's compare modeJeremy Magland 251 /** The reference file this study is checking against, if any. */
252 get refFile(): ReferenceCase | null {
253 return this.#opts.refFile ?? null;
254 }
f467ffaCompare several solver settings side by side, on one clockJeremy Magland 256 /** The base timestep a variant's dtDiv divides. */
257 static baseDt(params: Params): number {
258 return params.dt ?? 0;
259 }
261 static async create(opts: CompareOptions): Promise<CompareRun> {
262 const { device, model, variants } = opts;
263 const baseDt = CompareRun.baseDt(opts.params);
264 const showDt = variants.some((v) => v.dtDiv !== variants[0].dtDiv);
265 const sessions: ModelSession[] = [];
266 // Scenes own a WebGL context and an animation frame each, so a failure
267 // after the grid is up has to take them down explicitly — removing their
268 // canvases from the DOM would leave both running.
269 let built: Row[] = [];
c90d0e2Check reference files in the browser's compare modeJeremy Magland 270 let builtFile: FileRow | null = null;
273 try {
274 for (let i = 0; i < variants.length; i++) {
275 const v = variants[i];
278 `compiling ${i + 1}/${variants.length} — ${variantLabel(v, showDt)} ` +
279 `(a solve iteration is ~15 kernels per species, and there is no ` +
280 `pipeline cache across sessions)`,
281 );
282 // Yield, so the status actually paints before the compile blocks.
283 await new Promise<number>(requestAnimationFrame);
284 sessions.push(
285 await ModelSession.create({
286 device,
287 model,
288 params: { ...opts.params, dt: baseDt / v.dtDiv },
289 lmax: v.lmax,
290 source: opts.source,
292 geometryParams: g?.geometryParams ?? opts.geometryParams,
293 geometrySource: g?.geometrySource ?? opts.geometrySource,
297 );
298 }
300 // ---- the shared display grid ----------------------------------------
301 const maxLmax = Math.max(...variants.map((v) => v.lmax));
c90d0e2Check reference files in the browser's compare modeJeremy Magland 302 const panels = (variants.length + (opts.refFile ? 1 : 0)) * model.species.length;
f467ffaCompare several solver settings side by side, on one clockJeremy Magland 303 const target = panels > CROWDED_PANELS ? RENDER_NLAT_CROWDED : RENDER_NLAT;
304 // Never below what the finest band needs to be representable at all
305 // (ShtPlan requires nlat > lmax), whatever the panel count says.
306 const nlat = Math.max(target, 2 * Math.ceil((maxLmax + 2) / 2));
307 let nphi = 1;
308 while (nphi < Math.max(2 * nlat, 2 * maxLmax + 1)) nphi *= 2;
309 for (const s of sessions) await s.setDisplayGrid(nlat, nphi);
311 // ---- one initial condition, on every grid ---------------------------
ca37955Adding a way to reset simulation from same random IC.Owen Melia 312 // Also what restart() reloads later — the file's fixed state, or (for
313 // the plain case) a snapshot of the coarsest variant's own state,
314 // taken after seeding it: the same lowest-lmax session sharedNoise
315 // itself draws from, so prolonging it up to any other variant later is
316 // always widening a band, never narrowing one.
317 let initial: Record<string, Float32Array>;
318 let initialLmax: number;
320 // The file's exact spectral state, prolonged into each variant's band.
321 // Exact, not approximate: the state is band-limited at the file's lmax
322 // and every variant's band contains it, so each session starts from
323 // the very field the reference run started from.
324 opts.onStatus('loading the initial state from the reference file…');
325 for (const s of sessions) {
326 s.loadState(prolongState(opts.refFile.initial, model.state, opts.refFile.lmax, s.cfg.lmax));
327 }
ca37955Adding a way to reset simulation from same random IC.Owen Melia 328 initial = opts.refFile.initial;
329 initialLmax = opts.refFile.lmax;
331 opts.onStatus('seeding all variants from one band-limited perturbation…');
332 const noise = await sharedNoise(sessions, model.seedAmp, opts.seed);
333 const modes = await sharedModes(sessions[opts.reference] ?? sessions[0], opts.seed);
334 // One at a time: a seed submits its whole mode sum in pieces, and there
335 // is nothing to gain from interleaving several variants' worth of it.
336 for (let i = 0; i < sessions.length; i++) await sessions[i].seedWith(noise[i], modes);
338 // Several geometries, not several lmax bands: the seeding above
339 // drew a spatially shared field, but evaluated it on each row's
340 // own (geometry-dependent) points — a different field per row in
341 // coefficient space. The reference's exact resulting coefficients
342 // replace that for every other row, so every row starts from the
343 // identical spectral state and only the operator applied to it
344 // differs from then on.
345 const refSession = sessions[opts.reference];
346 const refState = await refSession.readState();
347 for (let i = 0; i < sessions.length; i++) {
348 if (i === opts.reference) continue;
349 sessions[i].loadState(
350 prolongState(refState, model.state, refSession.cfg.lmax, sessions[i].cfg.lmax),
351 );
352 }
353 initial = refState;
354 initialLmax = refSession.cfg.lmax;
355 } else {
356 let coarsest = sessions[0];
357 for (const s of sessions) if (s.cfg.lmax < coarsest.cfg.lmax) coarsest = s;
358 initial = await coarsest.readState();
359 initialLmax = coarsest.cfg.lmax;
360 }
363 // ---- the mesh, shared; the surface, per variant ---------------------
364 const view = sessions[0].viewSht;
365 const phi = new Float64Array(nphi);
366 for (let j = 0; j < nphi; j++) phi[j] = (2 * Math.PI * j) / nphi;
367 const topo = buildTopology(view.cosTheta, phi);
368 // Gauss weights carry the sin(theta) of the area element; the constant
369 // 2*pi/nphi is common to every point and cancels in the relative norm.
370 const weights = new Float64Array(nlat * nphi);
371 for (let i = 0; i < nlat; i++) {
372 for (let j = 0; j < nphi; j++) weights[i * nphi + j] = view.gaussWeights[i];
373 }
375 // ---- how many steps a frame may submit ------------------------------
376 // Per variant: its own unrolled step size times its dtDiv, since a ÷K
377 // variant takes K times as many steps to reach the same time.
378 let frameSteps = STEPS_PER_FRAME_BASE;
379 const ops: number[] = [];
380 for (let i = 0; i < sessions.length; i++) {
381 const n = Math.max(1, sessions[i].describe().step.length);
382 ops.push(n);
383 frameSteps = Math.min(
384 frameSteps,
385 Math.max(1, Math.floor(DISPATCH_BUDGET / (n * variants[i].dtDiv))),
386 );
387 }
388 frameSteps = Math.max(1, frameSteps);
390 // ---- the grid of panels ---------------------------------------------
c90d0e2Check reference files in the browser's compare modeJeremy Magland 391 const { rows, fileRow, rangeBars } = await buildGrid(opts, sessions, topo, showDt);
02e7a36Update comparison UIOwen Melia 395 // ---- the error-vs-time chart, one line per row with a diff panel ----
396 const chartRows = rows.filter((r) => r.diffScenes.length > 0);
397 errorChart = new ErrorChart(
398 opts.chartContainer,
399 model.species,
400 chartRows.map((r): ErrorChartRow => ({ label: variantLabel(r.variant, showDt), color: r.color })),
401 );
402 // Nothing to chart with a single variant and no reference file — the
403 // one row present is the reference itself.
404 opts.chartContainer.hidden = chartRows.length === 0;
f467ffaCompare several solver settings side by side, on one clockJeremy Magland 406 const solverGrid = sessions.map((s) => `${s.cfg.nlat}×${s.cfg.nphi}`);
407 const note =
3210e7dOpen the reference comparison in one clickJeremy Magland 408 `${variants.length} variant${variants.length === 1 ? '' : 's'} · ` +
409 `display grid ${nlat}×${nphi}` +
f467ffaCompare several solver settings side by side, on one clockJeremy Magland 410 (sessions.some((s) => s.cfg.nlat > nlat)
411 ? ` (below the finest solver grid ${solverGrid[solverGrid.length - 1]} — display only)`
412 : '') +
413 ` · ${frameSteps} base step${frameSteps === 1 ? '' : 's'}/frame` +
414 ` · ops/step ${ops.join(', ')}`;
416 const run = new CompareRun({
ca37955Adding a way to reset simulation from same random IC.Owen Melia 417 opts, rows, fileRow, topo, weights, rangeBars, frameSteps, note, initial, initialLmax,
f467ffaCompare several solver settings side by side, on one clockJeremy Magland 420 await run.draw();
421 run.#observeResize();
422 run.#status();
423 return run;
424 } catch (e) {
426 for (const s of r.scenes) s.dispose();
427 for (const s of r.diffScenes) s.dispose();
428 }
c90d0e2Check reference files in the browser's compare modeJeremy Magland 429 for (const s of builtFile?.scenes ?? []) s.dispose();
f467ffaCompare several solver settings side by side, on one clockJeremy Magland 430 for (const s of sessions) s.destroy();
432 opts.chartContainer.hidden = true;
f467ffaCompare several solver settings side by side, on one clockJeremy Magland 433 opts.container.replaceChildren();
434 opts.container.classList.remove('compare');
435 throw e;
436 }
437 }
439 // ------------------------------------------------------------------ state
440 setRunning(next: boolean): void {
441 this.#running = next;
442 if (next) void this.#pump();
443 }
445 get running(): boolean {
446 return this.#running;
447 }
c90d0e2Check reference files in the browser's compare modeJeremy Magland 449 /** Re-seed every variant from one new shared perturbation — or, against a
450 * reference file, restart from its initial state (there is nothing to
451 * draw; the seed is ignored). */
f467ffaCompare several solver settings side by side, on one clockJeremy Magland 452 async reseed(seed: number): Promise<void> {
453 const wasRunning = this.#running;
454 this.#running = false;
455 while (this.#pumping) await nextFrame();
456 if (this.#disposed) return;
beac00aMerge main into random-fieldsJeremy Magland 457 const sessions = this.#rows.map((r) => r.session);
c90d0e2Check reference files in the browser's compare modeJeremy Magland 458 const refFile = this.#opts.refFile;
459 if (refFile) {
460 for (const s of sessions) {
461 s.loadState(prolongState(refFile.initial, this.#opts.model.state, refFile.lmax, s.cfg.lmax));
462 }
463 } else {
464 const noise = await sharedNoise(sessions, this.#opts.model.seedAmp, seed);
465 const modes = await sharedModes(this.referenceSession ?? sessions[0], seed);
466 // Checked per variant, not once: a seed awaits its own submission, so a
467 // dispose can land between two of them and destroy the sessions left.
468 for (let i = 0; i < sessions.length; i++) {
469 if (this.#disposed) return;
470 await sessions[i].seedWith(noise[i], modes);
471 }
ca37955Adding a way to reset simulation from same random IC.Owen Melia 472 if (this.#disposed) return;
473 // This draw becomes what restart() rewinds to from now on — see the
474 // identical selection in create(). Recaptured here rather than left
475 // pointing at the pre-reseed field.
477 // Mirrors create()'s IC block: copy the reference's exact resulting
478 // coefficients into every other row rather than trusting their own
479 // (geometry-dependent) seeding to have landed on the same state.
480 const refSession = sessions[this.#opts.reference];
481 const refState = await refSession.readState();
482 for (let i = 0; i < sessions.length; i++) {
483 if (i === this.#opts.reference) continue;
484 sessions[i].loadState(
485 prolongState(refState, this.#opts.model.state, refSession.cfg.lmax, sessions[i].cfg.lmax),
486 );
487 }
488 this.#initial = refState;
489 this.#initialLmax = refSession.cfg.lmax;
490 } else {
491 let coarsest = sessions[0];
492 for (const s of sessions) if (s.cfg.lmax < coarsest.cfg.lmax) coarsest = s;
493 this.#initial = await coarsest.readState();
494 this.#initialLmax = coarsest.cfg.lmax;
495 }
497 this.#t = 0;
498 this.#stepsDone = 0;
499 this.#finished = false;
500 for (const r of this.#ranges) {
501 r.lo = NaN;
502 r.hi = NaN;
503 }
505 for (const rr of r.diffRanges) {
506 rr.lo = NaN;
507 rr.hi = NaN;
508 }
509 }
510 this.#errorChart?.reset();
512 this.#status();
513 if (!this.#disposed && wasRunning) this.setRunning(true);
514 }
516 /** Rewind every variant to the saved initial condition — the file's fixed
517 * state, or (for the plain case) the last (re-)seed, not necessarily the
518 * very first one — without drawing anything new. */
519 async restart(): Promise<void> {
520 const wasRunning = this.#running;
521 this.#running = false;
522 while (this.#pumping) await nextFrame();
523 if (this.#disposed) return;
524 for (const r of this.#rows) {
525 r.session.loadState(
526 prolongState(this.#initial, this.#opts.model.state, this.#initialLmax, r.session.cfg.lmax),
527 );
531 this.#finished = false;
f467ffaCompare several solver settings side by side, on one clockJeremy Magland 532 for (const r of this.#ranges) {
533 r.lo = NaN;
534 r.hi = NaN;
535 }
537 for (const rr of r.diffRanges) {
538 rr.lo = NaN;
539 rr.hi = NaN;
540 }
541 }
542 this.#errorChart?.reset();
f467ffaCompare several solver settings side by side, on one clockJeremy Magland 543 await this.draw();
f467ffaCompare several solver settings side by side, on one clockJeremy Magland 545 if (!this.#disposed && wasRunning) this.setRunning(true);
546 }
beac00aMerge main into random-fieldsJeremy Magland 548 /** Wavelength of the seeded random field. One number for the study: every
549 * variant seeds from the same field, so they seed at the same wavelength. */
550 get lam3(): number {
551 return this.#rows[0]?.session.lam3 ?? 0;
552 }
554 /** Change it on every variant. Like the single run's, this only takes effect
555 * on the next reseed, which is where the field is drawn. */
556 setLam3(lambda: number): void {
557 this.#opts.lam3 = lambda;
558 for (const r of this.#rows) r.session.setLam3(lambda);
559 }
f467ffaCompare several solver settings side by side, on one clockJeremy Magland 561 /** Model parameters changed. Each variant keeps its own dt. */
562 setParams(params: Params): void {
c90d0e2Check reference files in the browser's compare modeJeremy Magland 563 // Against a reference file the parameters *are* the file's — they define
564 // the problem being checked — and the page's parameter panel edits the
565 // page's own model, which need not even be this one. Nothing to apply.
566 if (this.#opts.refFile) return;
f467ffaCompare several solver settings side by side, on one clockJeremy Magland 567 this.#opts.params = params;
568 const baseDt = CompareRun.baseDt(params);
569 for (const r of this.#rows) {
570 r.session.setParams({ ...params, dt: baseDt / r.variant.dtDiv });
571 }
572 }
574 setMorph(morph: number): void {
575 this.#morph = morph;
576 for (const r of this.#rows) {
577 fillPositions(r.posBuf, r.coords, this.#topo, morph);
578 for (const s of r.scenes) s.updatePositions(r.posBuf);
cf4af12WIP: comparison with unit sphereOwen Melia 579 // The diff row sits on the exact same mesh as the value row above it
580 // (same coords, same posBuf) — it just never got told to re-render
581 // when this method was first written, so it stayed fixed at whatever
582 // shape the study was compiled with.
583 for (const s of r.diffScenes) s.updatePositions(r.posBuf);
c90d0e2Check reference files in the browser's compare modeJeremy Magland 585 const f = this.#fileRow;
586 if (f) {
587 fillPositions(f.posBuf, f.coords, this.#topo, morph);
588 for (const s of f.scenes) s.updatePositions(f.posBuf);
589 }
592 resetView(): void {
c90d0e2Check reference files in the browser's compare modeJeremy Magland 593 for (const s of this.#allScenes()) s.resetCamera();
596 dispose(): void {
597 this.#disposed = true;
598 this.#running = false;
599 this.#resizeObs?.disconnect();
600 this.#resizeObs = null;
601 for (const r of this.#rows) {
602 for (const s of r.scenes) s.dispose();
f467ffaCompare several solver settings side by side, on one clockJeremy Magland 604 r.session.destroy();
605 }
c90d0e2Check reference files in the browser's compare modeJeremy Magland 606 for (const s of this.#fileRow?.scenes ?? []) s.dispose();
f467ffaCompare several solver settings side by side, on one clockJeremy Magland 607 this.#rows = [];
610 this.#errorChart = null;
611 this.#opts.chartContainer.hidden = true;
f467ffaCompare several solver settings side by side, on one clockJeremy Magland 612 this.#opts.container.replaceChildren();
613 this.#opts.container.classList.remove('compare');
614 }
c90d0e2Check reference files in the browser's compare modeJeremy Magland 616 #allScenes(): SphereScene[] {
618 ...this.#rows.flatMap((r) => [...r.scenes, ...r.diffScenes]),
619 ...(this.#fileRow?.scenes ?? []),
620 ];
f467ffaCompare several solver settings side by side, on one clockJeremy Magland 623 // ----------------------------------------------------------------- drawing
624 /**
625 * One frame's readback: every variant's every species, on the shared grid.
626 * Read first, then color — the range is shared down a column, so no panel can
627 * be filled until the column's range is known.
628 */
629 async draw(): Promise<void> {
630 if (this.#disposed) return;
631 const species = this.#opts.model.species;
632 // Sessions are independent, so their readbacks can be in flight together;
633 // within one session they must not be (they share its staging buffers).
634 await Promise.all(
635 this.#rows.map(async (r) => {
636 for (let k = 0; k < species.length; k++) {
637 r.fields[k] = await r.session.readSpecies(k);
638 }
639 }),
640 );
641 if (this.#disposed) return;
643 const cmap = colormaps[this.#opts.colormapName()] ?? colormaps.viridis;
645 /**
646 * What scales a column is the whole question, and it has three wrong
647 * answers.
648 *
649 * Per panel is wrong: a range each rescales every variant to itself and
650 * hides exactly the difference the grid exists to show. The union over
651 * variants is wrong for the opposite reason: a variant outside the
652 * iteration's convergence radius runs away to 1e20 and then to NaN, and a
653 * union range rescales the *whole column* to it, flattening every panel to
654 * one colour — which reads as "they all blew up" when only one did.
655 *
656 * The reference alone is wrong too, less obviously, and it is the case that
657 * actually bites: outside the convergence radius *more* Richardson
658 * iterations diverge *faster*, so the row that goes first is usually the
659 * highest-niter one — which is the reference.
660 *
661 * So the column is scaled by whichever variant **reaches least far from
662 * zero** — the least-blown-up one. That is a comparison between the rows,
663 * not a threshold on any of them, and the distinction is the whole point:
664 * any "is this value too big?" test has a window in which a diverging field
665 * is still under the limit, and for as long as that window lasts it drags
666 * the scale and flattens the grid, until it finally trips and everything
667 * springs back. A comparison has no such window — a run-away only has to be
668 * *larger* than a healthy row to stop setting the scale, which it is from
669 * its first bad step, and it stays larger no matter how many other rows go
670 * with it. One healthy variant is enough to keep the grid readable.
671 *
672 * The cost is a slight bias: among healthy variants the scale comes from
673 * the one with the smallest peak, so the others clip by however much they
674 * exceed it. They are approximations of the same solution, so that is a
675 * fraction of a percent, and the alternative is a display that a single
676 * divergence can take away.
677 */
678 const bounds = this.#rows.map((r) => species.map((_, k) => finiteRange(r.fields[k])));
679 this.#rows.forEach((r, i) => {
680 // A row with any non-finite value is out of the running entirely: its
681 // finite entries are whatever survived, and no rank over them means much.
682 r.healthy = species.every((_, k) => allFinite(r.fields[k]) && bounds[i][k] !== null);
683 });
685 for (let k = 0; k < species.length; k++) {
c90d0e2Check reference files in the browser's compare modeJeremy Magland 686 // The file row, when there is one, is a candidate like any healthy
687 // variant: early on the variants' small fields set the scale (it merely
688 // clips), and if every variant diverges it is the row that keeps the
689 // grid readable.
690 const anchor = leastPeak([
691 ...this.#rows.map((r, i) => (r.healthy ? bounds[i][k] : null)),
692 this.#fileRow?.bounds[k] ?? null,
693 ]);
f467ffaCompare several solver settings side by side, on one clockJeremy Magland 694 const range = this.#ranges[k];
695 if (anchor) {
696 if (!Number.isFinite(range.lo)) {
697 range.lo = anchor.lo;
698 range.hi = anchor.hi;
699 } else {
700 // Smooth in both directions so the shading evolves gently as the
701 // pattern grows, as the single-run view does.
702 const a = 0.15;
703 range.lo += a * (anchor.lo - range.lo);
704 range.hi += a * (anchor.hi - range.hi);
705 }
706 }
707 // With every row gone, the last good range is kept rather than replaced
708 // by nothing: the panels freeze at a readable scale and the row labels
709 // say what happened, instead of the grid going blank.
710 if (!Number.isFinite(range.lo) || !Number.isFinite(range.hi)) continue;
ef3ae33Do not stretch the colormap across a constant field's roundoffJeremy Magland 711 // The floor is applied to what is drawn, not to what is tracked, so it
712 // never feeds back into the smoothing above.
713 const shown = floorRange(range.lo, range.hi);
714 this.#rangeBars[k]?.fill(shown.lo, shown.hi);
f467ffaCompare several solver settings side by side, on one clockJeremy Magland 715 for (const r of this.#rows) {
716 fillFieldValues(r.valueBufs[k], r.fields[k], this.#topo);
ef3ae33Do not stretch the colormap across a constant field's roundoffJeremy Magland 717 fillColors(r.colorBufs[k], r.valueBufs[k], shown.lo, shown.hi, cmap);
f467ffaCompare several solver settings side by side, on one clockJeremy Magland 718 r.scenes[k]?.updateColors(r.colorBufs[k]);
719 }
c90d0e2Check reference files in the browser's compare modeJeremy Magland 720 const f = this.#fileRow;
721 if (f) {
722 // Its values never change; only its coloring follows the shared range.
723 fillColors(f.colorBufs[k], f.valueBufs[k], shown.lo, shown.hi, cmap);
724 f.scenes[k]?.updateColors(f.colorBufs[k]);
725 }
728 this.#measureDifference();
f467ffaCompare several solver settings side by side, on one clockJeremy Magland 730 this.#updateRowStats();
732 this.#t,
733 this.#rows.filter((r) => r.diffScenes.length > 0).map((r) => r.err),
734 );
735 }
737 /**
738 * Color every diff panel from #measureDifference's pointwise diffFields, on
739 * a *symmetric* range that is each row's own — not shared across the
740 * column. A diverging row's diff explodes, but with a per-row range that
741 * only saturates its own panels; it can no longer affect how any other
742 * row's diff panels are scaled, which is a simpler fix for exactly the
743 * flooding problem the value panels' shared #ranges/leastPeak logic exists
744 * to manage there. The cost: diff-panel color alone no longer tells you
745 * which row has more error than another — that comparison now lives in the
746 * error chart, which has actual numbers. Always drawn with a diverging
747 * colormap regardless of the user's chosen (sequential) one — a signed
748 * quantity centered at zero needs a diverging map to read correctly, which
749 * coolwarm is and viridis etc. are not.
750 */
751 #colorDiffPanels(): void {
752 const species = this.#opts.model.species;
753 for (const r of this.#rows) {
754 if (r.diffScenes.length === 0) continue;
755 for (let k = 0; k < species.length; k++) {
756 const m = r.healthy ? maxAbsFinite(r.diffFields[k]) : null;
757 const range = r.diffRanges[k];
758 if (m !== null) {
759 if (!Number.isFinite(range.lo)) {
760 range.lo = -m;
761 range.hi = m;
762 } else {
763 const a = 0.15;
764 range.lo += a * (-m - range.lo);
765 range.hi += a * (m - range.hi);
766 }
767 }
768 if (!Number.isFinite(range.lo) || !Number.isFinite(range.hi)) continue;
769 const shown = floorRange(range.lo, range.hi);
770 fillFieldValues(r.diffValueBufs[k], r.diffFields[k], this.#topo);
771 fillColors(r.diffColorBufs[k], r.diffValueBufs[k], shown.lo, shown.hi, colormaps.coolwarm);
772 r.diffScenes[k]?.updateColors(r.diffColorBufs[k]);
773 const cap = r.diffCaps[k];
774 if (cap) cap.textContent = `± ${fmtValue(shown.hi)}`;
775 }
776 }
779 /**
780 * Relative L2 difference from the reference, per species, on the shared
781 * grid. Weighted by the Gauss weights, so it is the norm on the parameter
782 * sphere — not on the embedded surface, which would weight by the area
783 * element. That makes it a consistent diagnostic across variants rather than
784 * a physical quantity, which is all it is used for.
785 */
786 #measureDifference(): void {
c90d0e2Check reference files in the browser's compare modeJeremy Magland 787 // Against a reference file, every row is measured against its final state;
788 // otherwise against the chosen reference variant, whose own Δ is zero.
789 const ref = this.#fileRow ? null : this.#rows[this.#opts.reference];
790 const refFields = this.#fileRow?.fields ?? ref?.fields;
791 if (!refFields) return;
f467ffaCompare several solver settings side by side, on one clockJeremy Magland 792 const species = this.#opts.model.species;
793 for (const r of this.#rows) {
794 for (let k = 0; k < species.length; k++) {
795 if (r === ref) {
796 r.err[k] = 0;
797 continue;
798 }
799 const a = r.fields[k];
c90d0e2Check reference files in the browser's compare modeJeremy Magland 800 const b = refFields[k];
f467ffaCompare several solver settings side by side, on one clockJeremy Magland 801 if (!a || !b || a.length !== b.length) {
802 r.err[k] = NaN;
803 continue;
804 }
807 let den = 0;
808 for (let i = 0; i < a.length; i++) {
809 const w = this.#weights[i];
810 const d = a[i] - b[i];
f467ffaCompare several solver settings side by side, on one clockJeremy Magland 812 num += w * d * d;
813 den += w * b[i] * b[i];
814 }
815 r.err[k] = den > 0 ? Math.sqrt(num / den) : NaN;
816 }
817 }
818 }
820 /**
821 * Each row's standing line: how many of its own steps it took to reach the
822 * common time, and how far it is from the reference right now, per species.
823 * Per species rather than a single worst-case number because the two are
824 * genuinely different questions on a two-species model — the slow species is
825 * usually the one that has converged and the fast one the one that has not.
826 */
827 #updateRowStats(): void {
c90d0e2Check reference files in the browser's compare modeJeremy Magland 828 const ref = this.#fileRow ? null : this.#rows[this.#opts.reference];
f467ffaCompare several solver settings side by side, on one clockJeremy Magland 829 for (const r of this.#rows) {
830 // Divergence is said, not implied. Scaled to a healthy row, a blown-up
831 // variant is a flat saturated panel, which on its own is easy to misread
02e7a36Update comparison UIOwen Melia 832 // as a converged uniform state. Δ itself now lives in the error chart,
833 // not here.
f467ffaCompare several solver settings side by side, on one clockJeremy Magland 834 const body = !r.healthy
835 ? '<b class="cmp-diverged">diverged</b>'
836 : r === ref
837 ? '<b>reference</b>'
839 r.statEl.innerHTML =
840 `${r.session.steps.toLocaleString()} steps` + (body ? `<br>${body}` : '');
842 }
844 #status(): void {
c90d0e2Check reference files in the browser's compare modeJeremy Magland 845 const refFile = this.#opts.refFile;
846 const clock = refFile
847 ? `<b>t = ${this.#t.toFixed(2)} / ${(refFile.steps * CompareRun.baseDt(this.#opts.params)).toFixed(2)}</b>` +
02e7a36Update comparison UIOwen Melia 848 ` · NOTE: vertical axis measures difference from uploaded simulation's end state.`
c90d0e2Check reference files in the browser's compare modeJeremy Magland 849 : `<b>t = ${this.#t.toFixed(2)}</b> (same for every variant)`;
f467ffaCompare several solver settings side by side, on one clockJeremy Magland 850 this.#opts.onStatus(
f467ffaCompare several solver settings side by side, on one clockJeremy Magland 852 (this.#frameMs > 0 ? `${this.#frameMs.toFixed(1)} ms/frame · ` : '') +
853 this.#note,
854 );
855 }
857 #observeResize(): void {
c90d0e2Check reference files in the browser's compare modeJeremy Magland 858 const scenes = this.#allScenes();
f467ffaCompare several solver settings side by side, on one clockJeremy Magland 859 this.#resizeObs = new ResizeObserver(() => {
860 for (const s of scenes) {
861 const box = s.canvas.parentElement;
862 if (box) s.resize(box.clientWidth, box.clientHeight);
863 }
866 for (const s of scenes) {
867 const box = s.canvas.parentElement;
868 if (box) this.#resizeObs.observe(box);
869 }
873 // -------------------------------------------------------------- the clock
874 /**
875 * One frame advances every variant by the *same model time*: `frameSteps`
876 * base steps, which a ÷K variant covers in K times as many of its own. That
877 * is the whole reason dt varies by an integer divisor — the alternative is
878 * rounding each variant to the nearest step and comparing fields that are a
879 * fraction of a timestep apart, which would show up as a difference and be
880 * indistinguishable from a real one.
881 */
882 async #pump(): Promise<void> {
883 if (this.#pumping) return;
884 this.#pumping = true;
885 try {
886 while (this.#running && !this.#disposed) {
c90d0e2Check reference files in the browser's compare modeJeremy Magland 887 // Against a reference file the run is finite: the last frame takes
888 // however many base steps remain, so every variant lands exactly on
889 // the file's end time — where Δ against its final state is the
890 // comparison — and stops there rather than drifting past it.
891 const refFile = this.#opts.refFile;
892 const n = refFile
893 ? Math.min(this.#frameSteps, refFile.steps - this.#stepsDone)
894 : this.#frameSteps;
895 if (n <= 0) {
896 this.#running = false;
897 this.#opts.onFinished?.();
898 break;
899 }
f467ffaCompare several solver settings side by side, on one clockJeremy Magland 900 const t0 = performance.now();
c90d0e2Check reference files in the browser's compare modeJeremy Magland 901 for (const r of this.#rows) r.session.step(n * r.variant.dtDiv);
902 this.#stepsDone += n;
903 this.#t += n * CompareRun.baseDt(this.#opts.params);
f467ffaCompare several solver settings side by side, on one clockJeremy Magland 904 await this.draw();
905 if (this.#disposed) break;
906 const dt = performance.now() - t0;
907 this.#frameMs = this.#frameMs === 0 ? dt : this.#frameMs + 0.05 * (dt - this.#frameMs);
c90d0e2Check reference files in the browser's compare modeJeremy Magland 908 if (refFile && this.#stepsDone >= refFile.steps) {
909 this.#finished = true;
910 this.#running = false;
911 this.#status();
912 this.#opts.onFinished?.();
913 break;
914 }
916 await nextFrame();
917 }
918 if (!this.#disposed) {
919 await this.draw();
920 this.#status();
921 }
922 } finally {
923 this.#pumping = false;
924 }
925 }
926}
928const nextFrame = (): Promise<number> => new Promise(requestAnimationFrame);
c90d0e2Check reference files in the browser's compare modeJeremy Magland 930/** A whole spectral state re-indexed into a (wider) band's layout — the
931 * reference file's initial condition, in the form loadState takes. */
932function prolongState(
933 coeffs: Record<string, Float32Array>,
934 names: string[],
935 lmaxFrom: number,
936 lmaxTo: number,
937): Record<string, Float32Array> {
938 const out: Record<string, Float32Array> = {};
939 for (const name of names) out[name] = prolongCoeffs(coeffs[name], lmaxFrom, lmaxTo);
940 return out;
941}
f467ffaCompare several solver settings side by side, on one clockJeremy Magland 943/** Whether every entry is an ordinary number — false once a variant has left
944 * its convergence radius and saturated to infinity or NaN. */
945function allFinite(f: Float32Array | undefined): boolean {
946 if (!f) return false;
947 for (let i = 0; i < f.length; i++) if (!Number.isFinite(f[i])) return false;
948 return true;
949}
951type Bounds = { lo: number; hi: number };
953/** How far a field reaches from zero — the one number the rows are ranked by
954 * when deciding which of them sets a column's scale. */
955const peak = (b: Bounds): number => Math.max(Math.abs(b.lo), Math.abs(b.hi));
957/** Whichever of the given bounds reaches least far from zero; null if none. */
958function leastPeak(all: (Bounds | null)[]): Bounds | null {
959 let best: Bounds | null = null;
960 for (const b of all) {
961 if (b !== null && (best === null || peak(b) < peak(best))) best = b;
962 }
963 return best;
964}
02e7a36Update comparison UIOwen Melia 966/** Max |value| over the finite entries of a field; null if none are finite —
967 * the diff panels' analogue of finiteRange below, since a symmetric range
968 * only needs the one number. */
969function maxAbsFinite(f: Float32Array): number | null {
970 let m = -Infinity;
971 let any = false;
972 for (let i = 0; i < f.length; i++) {
973 const v = f[i];
974 if (!Number.isFinite(v)) continue;
975 any = true;
976 const a = Math.abs(v);
977 if (a > m) m = a;
978 }
979 return any ? m : null;
980}
f467ffaCompare several solver settings side by side, on one clockJeremy Magland 982/** Min and max over the finite entries only; null when there are none. */
983function finiteRange(f: Float32Array | undefined): { lo: number; hi: number } | null {
984 if (!f) return null;
985 let lo = Infinity;
986 let hi = -Infinity;
987 for (let i = 0; i < f.length; i++) {
988 const v = f[i];
989 if (!Number.isFinite(v)) continue;
990 if (v < lo) lo = v;
991 if (v > hi) hi = v;
992 }
993 return lo <= hi ? { lo, hi } : null;
994}
996/**
997 * The DOM: a header row naming each species and carrying that column's shared
998 * color range, then one row per variant. The colorbar is per *column* rather
999 * than per panel because the range is shared — a bar on every panel would be
1000 * the same bar repeated, and would suggest each panel had its own scaling,
1001 * which is exactly the thing that would make the comparison a lie.
1002 */
c90d0e2Check reference files in the browser's compare modeJeremy Magland 1003/** The file row's label color — none of the variant palette, since it is not
1004 * a variant: it is the thing they are all measured against. */
1005const FILE_ROW_COLOR = '#57606a';
1008 * One sphere panel: a boxed SphereScene plus the value/color buffers that
1009 * feed it. Shared by the value row, the diff row, and the file row — all
1010 * three build a panel the same way, only differing in the class on the box
1011 * (for styling) and in what fills the buffers afterward.
1012 */
1013function makeSpherePanel(
1014 colsEl: HTMLElement,
1015 topo: SphereMeshTopology,
1016 posBuf: Float32Array,
1017 background: string | undefined,
1018 extraClass = '',
1019): { box: HTMLElement; scene: SphereScene; valueBuf: Float32Array; colorBuf: Float32Array } {
1020 const box = document.createElement('div');
1021 box.className = extraClass ? `sphere-box cmp-box ${extraClass}` : 'sphere-box cmp-box';
1022 colsEl.append(box);
1023 const scene = new SphereScene(
1024 box,
1025 topo.numVertices,
1026 topo.indices,
1027 Float32Array.from(posBuf),
1028 background,
1029 );
1030 scene.fitCamera();
1031 return {
1032 box,
1033 scene,
1034 valueBuf: new Float32Array(topo.numVertices),
1035 colorBuf: new Float32Array(topo.numVertices * 3),
1036 };
1037}
f467ffaCompare several solver settings side by side, on one clockJeremy Magland 1039async function buildGrid(
1040 opts: CompareOptions,
1041 sessions: ModelSession[],
1042 topo: SphereMeshTopology,
1043 showDt: boolean,
1045 rows: Row[];
1046 fileRow: FileRow | null;
1047 rangeBars: { fill: (lo: number, hi: number) => void }[];
1048}> {
f467ffaCompare several solver settings side by side, on one clockJeremy Magland 1049 const { container, model } = opts;
1050 container.replaceChildren();
1051 container.classList.add('compare');
1053 const head = document.createElement('div');
1054 head.className = 'cmp-row cmp-head';
1055 const headSpacer = document.createElement('div');
1056 headSpacer.className = 'cmp-rowlabel';
1057 const headCols = document.createElement('div');
1058 headCols.className = 'cmp-cols';
1059 head.append(headSpacer, headCols);
1060 container.append(head);
1062 const rangeBars = model.species.map((name) => {
1063 const col = document.createElement('div');
1064 col.className = 'cmp-colhead';
1065 const tag = document.createElement('b');
1066 tag.textContent = name;
1067 const canvas = document.createElement('canvas');
1068 canvas.width = 160;
1069 canvas.height = 8;
1070 canvas.className = 'cmp-rangebar';
1071 const lab = document.createElement('span');
1072 lab.className = 'cmp-rangelab';
1073 col.append(tag, canvas, lab);
1074 headCols.append(col);
1075 let painted = false;
1076 return {
1077 fill: (lo: number, hi: number): void => {
1078 const ctx = canvas.getContext('2d');
1079 if (ctx && !painted) {
1080 painted = true;
1081 const cmap = colormaps[opts.colormapName()] ?? colormaps.viridis;
1082 for (let x = 0; x < canvas.width; x++) {
1083 const [r, g, b] = cmap(x / (canvas.width - 1));
1084 ctx.fillStyle = `rgb(${r},${g},${b})`;
1085 ctx.fillRect(x, 0, 1, canvas.height);
1086 }
1087 }
1088 lab.textContent = `${fmtValue(lo)} … ${fmtValue(hi)}`;
1089 },
1090 };
1091 });
1093 const sphereBg = getComputedStyle(document.documentElement)
1094 .getPropertyValue('--sphere-bg')
1095 .trim();
cf4af12WIP: comparison with unit sphereOwen Melia 1097 // When every row should be drawn on the same shape (vs-sphere: the point
1098 // is to isolate the field, not the surface), fetch that shape once from
1099 // the reference row's session and hand the identical array to every row.
1100 // fillPositions/fillFieldValues already treat "whose mesh this is" and
1101 // "whose field this is" as fully independent buffers, so this is the only
1102 // place that needs to know about it — every method downstream that reads
1103 // r.coords/r.posBuf just sees one row's shape reused by every other.
1104 const sharedCoords = opts.renderOnReferenceGeometry
1105 ? await sessions[opts.reference].renderPositions()
1106 : null;
f467ffaCompare several solver settings side by side, on one clockJeremy Magland 1108 const rows: Row[] = [];
1109 for (let i = 0; i < sessions.length; i++) {
1110 const session = sessions[i];
1111 const variant = opts.variants[i];
1112 const color = VARIANT_COLORS[i % VARIANT_COLORS.length];
02e7a36Update comparison UIOwen Melia 1113 // The reference itself never gets a diff row — its diff against itself
1114 // is trivially zero, and a flat zero panel would just spend a WebGL
1115 // context for nothing.
1116 const isRef = !opts.refFile && i === opts.reference;
cf4af12WIP: comparison with unit sphereOwen Melia 1118 const coords = sharedCoords ?? (await session.renderPositions());
f467ffaCompare several solver settings side by side, on one clockJeremy Magland 1119 const posBuf = new Float32Array(topo.numVertices * 3);
1120 fillPositions(posBuf, coords, topo, opts.morph);
1122 const rowEl = document.createElement('div');
1123 rowEl.className = 'cmp-row';
1124 const labelEl = document.createElement('div');
1125 labelEl.className = 'cmp-rowlabel';
1126 labelEl.style.setProperty('--c', color);
1127 const nameEl = document.createElement('div');
1128 nameEl.className = 'cmp-rowname';
cf4af12WIP: comparison with unit sphereOwen Melia 1129 nameEl.textContent = opts.rowLabels?.[i] ?? variantLabel(variant, showDt);
f467ffaCompare several solver settings side by side, on one clockJeremy Magland 1130 const statEl = document.createElement('div');
1131 statEl.className = 'cmp-rowstat';
1132 labelEl.append(nameEl, statEl);
1133 const colsEl = document.createElement('div');
1134 colsEl.className = 'cmp-cols';
1135 rowEl.append(labelEl, colsEl);
1136 container.append(rowEl);
1138 const scenes: SphereScene[] = [];
1139 const valueBufs: Float32Array[] = [];
1140 const colorBufs: Float32Array[] = [];
1141 for (let k = 0; k < model.species.length; k++) {
02e7a36Update comparison UIOwen Melia 1142 const { scene, valueBuf, colorBuf } = makeSpherePanel(colsEl, topo, posBuf, sphereBg || undefined);
f467ffaCompare several solver settings side by side, on one clockJeremy Magland 1143 scenes.push(scene);
1145 colorBufs.push(colorBuf);
02e7a36Update comparison UIOwen Melia 1148 // ---- its diff row, right underneath ----------------------------------
1149 const diffFields = model.species.map(() => new Float32Array(topo.nlat * topo.nphi));
1150 const diffScenes: SphereScene[] = [];
1151 const diffValueBufs: Float32Array[] = [];
1152 const diffColorBufs: Float32Array[] = [];
1153 const diffCaps: HTMLElement[] = [];
1154 if (!isRef) {
1155 const diffRowEl = document.createElement('div');
1156 diffRowEl.className = 'cmp-row cmp-diffrow';
1157 const diffLabelEl = document.createElement('div');
1158 diffLabelEl.className = 'cmp-rowlabel';
1159 diffLabelEl.style.setProperty('--c', color);
1160 const diffNameEl = document.createElement('div');
1161 diffNameEl.className = 'cmp-rowname';
1162 diffNameEl.textContent = 'Δ vs reference';
1163 diffLabelEl.append(diffNameEl);
1164 const diffColsEl = document.createElement('div');
1165 diffColsEl.className = 'cmp-cols';
1166 diffRowEl.append(diffLabelEl, diffColsEl);
1167 container.append(diffRowEl);
1169 for (let k = 0; k < model.species.length; k++) {
1170 const { box, scene, valueBuf, colorBuf } = makeSpherePanel(
1171 diffColsEl, topo, posBuf, sphereBg || undefined, 'cmp-diffbox',
1172 );
1173 diffScenes.push(scene);
1174 diffValueBufs.push(valueBuf);
1175 diffColorBufs.push(colorBuf);
1176 const cap = document.createElement('span');
1177 cap.className = 'cmp-diffcap';
1178 box.append(cap);
1179 diffCaps.push(cap);
1180 }
1181 }
1183 const diffRanges = model.species.map(() => ({ lo: NaN, hi: NaN }));
1185 variant, session, color, coords, posBuf, scenes, valueBufs, colorBufs,
02e7a36Update comparison UIOwen Melia 1186 fields: [], diffFields, diffScenes, diffValueBufs, diffColorBufs, diffCaps, diffRanges,
1187 err: model.species.map(() => 0), healthy: true, statEl,
1189 }
c90d0e2Check reference files in the browser's compare modeJeremy Magland 1191 // ---- the reference file's final state, as one more (static) row ---------
1192 let fileRow: FileRow | null = null;
1193 if (opts.refFile) {
1194 const rf = opts.refFile;
1195 // Synthesized through the coarsest session's display plan — exact, like
1196 // every other use of the shared grid: the file's coefficients are
1197 // band-limited at its lmax, which every variant's band contains.
1198 const view = sessions[0].viewSht;
1199 const lmaxTo = sessions[0].cfg.lmax;
1200 const on = (q: Float32Array): Promise<Float32Array> =>
1201 view.synth(prolongCoeffs(q, rf.lmax, lmaxTo));
1202 const [gx, gy, gz] = [
1203 await on(rf.geometryCoeffs.X),
1204 await on(rf.geometryCoeffs.Y),
1205 await on(rf.geometryCoeffs.Z),
1206 ];
1207 // The file's own surface, not a regeneration of it — interleaved xyz, the
1208 // same layout renderPositions() hands back.
1209 const coords = new Float32Array(3 * gx.length);
1210 for (let i = 0; i < gx.length; i++) {
1211 coords[3 * i] = gx[i];
1212 coords[3 * i + 1] = gy[i];
1213 coords[3 * i + 2] = gz[i];
1214 }
1215 const posBuf = new Float32Array(topo.numVertices * 3);
1216 fillPositions(posBuf, coords, topo, opts.morph);
1218 const rowEl = document.createElement('div');
1219 rowEl.className = 'cmp-row';
1220 const labelEl = document.createElement('div');
1221 labelEl.className = 'cmp-rowlabel';
1222 labelEl.style.setProperty('--c', FILE_ROW_COLOR);
1223 const nameEl = document.createElement('div');
1224 nameEl.className = 'cmp-rowname';
1225 nameEl.textContent = 'reference file';
1226 nameEl.title = rf.label;
1227 const statEl = document.createElement('div');
1228 statEl.className = 'cmp-rowstat';
1229 statEl.innerHTML = `${rf.steps.toLocaleString()} steps<br><b>final state</b>`;
1230 labelEl.append(nameEl, statEl);
1231 const colsEl = document.createElement('div');
1232 colsEl.className = 'cmp-cols';
1233 rowEl.append(labelEl, colsEl);
1234 container.append(rowEl);
1236 const scenes: SphereScene[] = [];
1237 const valueBufs: Float32Array[] = [];
1238 const colorBufs: Float32Array[] = [];
1239 const fields: Float32Array[] = [];
1240 const bounds: (Bounds | null)[] = [];
1241 for (let k = 0; k < model.species.length; k++) {
02e7a36Update comparison UIOwen Melia 1242 const { scene, valueBuf, colorBuf } = makeSpherePanel(colsEl, topo, posBuf, sphereBg || undefined);
1244 const field = await on(rf.final[model.state[k]]);
1245 fields.push(field);
1246 bounds.push(finiteRange(field));
1247 fillFieldValues(valueBuf, field, topo);
1248 valueBufs.push(valueBuf);
1251 fileRow = { coords, posBuf, scenes, valueBufs, colorBufs, fields, bounds };
1252 }
f467ffaCompare several solver settings side by side, on one clockJeremy Magland 1254 // Every panel shares one camera: the study is about the fields, and looking
1255 // at two of them from different angles is not comparing them.
1257 ...rows.flatMap((r) => [...r.scenes, ...r.diffScenes]),
1258 ...(fileRow?.scenes ?? []),
1259 ];
f467ffaCompare several solver settings side by side, on one clockJeremy Magland 1260 for (let i = 1; i < all.length; i++) all[0].syncCamerasWith(all[i]);
c90d0e2Check reference files in the browser's compare modeJeremy Magland 1262 return { rows, fileRow, rangeBars };