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