/ concept-collection / turing-surface
Sign in
concept-collection / turing-surface
1263 lines · 50.8 KBBlameHistoryRaw
1/**
2 * Several solver settings, one problem, one clock.
3 *
4 * A convergence study of the knobs that decide how well the implicit solve is
5 * resolved — `niter`, `lmax`, `dt` — run side by side so the answer to "does it
6 * matter?" is visible rather than argued. Every variant is its own
7 * `ModelSession` (both `niter` and `lmax` are structural: they change the
8 * compiled step and the grid), and what makes the set a comparison rather than
9 * a collection is three things they are forced to share:
10 *
11 * - **One initial condition.** Band-limited at the coarsest variant's lmax and
12 * evaluated on each variant's own grid, so every session starts from the same
13 * *function* rather than from the same random seed — see sharedStart.ts for
14 * why the seed alone is not enough.
15 *
16 * - **One clock.** Variants differ in dt only by an integer power-of-two
17 * divisor, and a frame advances each of them by `frameSteps * dtDiv` steps.
18 * Every variant therefore lands on exactly the same model time at the end of
19 * every frame, having taken a different number of steps to get there. Nothing
20 * is ever compared across a time offset.
21 *
22 * - **One grid to look at.** Each session's *display* plan is pointed at a
23 * common grid (ModelSession.setDisplayGrid), which is exact evaluation rather
24 * than resampling because the state is band-limited. So the fields come back
25 * directly comparable point by point, one mesh topology serves every panel,
26 * and the difference norm is an ordinary weighted sum.
27 *
28 * What is *not* shared is the surface: each variant carries the geometry
29 * band-limited at its own lmax, and renders the surface it actually solves on.
30 */
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';
43import { fmtValue, floorRange } from '../render/colorbar.ts';
44import { prolongCoeffs, sharedModes, sharedNoise } from './sharedStart.ts';
45import { variantLabel, VARIANT_COLORS, type Variant } from './variants.ts';
46import type { ReferenceCase } from './referenceCase.ts';
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 /** 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[];
95 variants: Variant[];
96 /** Index into `variants` of the run everything else is measured against.
97 * Ignored when `refFile` is given — the file is the reference then. */
98 reference: number;
99 /**
100 * Check against a reference file instead of against each other: its exact
101 * initial state seeds every variant (so `seed` and `lam3` go unused), a
102 * static extra row shows its final state, every Δ is measured against that
103 * row, and the clock stops at the file's end time. Every variant's lmax must
104 * be >= the file's — a narrower band could not hold the initial state.
105 */
106 refFile?: ReferenceCase;
107 /** Called when a refFile run reaches the file's end time and stops. */
108 onFinished?: () => void;
109 seed: number;
110 /** Wavelength of the seeded random field, shared by every variant — one
111 * initial condition means one wavelength as much as one seed. */
112 lam3?: number;
113 morph: number;
114 colormapName: () => string;
115 /** Where the variant grid goes (the app's #panels). */
116 container: HTMLElement;
117 /** Where the error-vs-time chart goes (the app's #cmp-chart). */
118 chartContainer: HTMLElement;
119 /** Progress and, afterwards, the standing description of the study. */
120 onStatus: (html: string) => void;
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[];
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[];
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;
159/**
160 * The reference file's final state, as one more row of panels — with no
161 * session behind it: its surface and fields are the file's coefficients
162 * synthesized once on the shared display grid, fixed for the whole run. Only
163 * its coloring changes, with the shared range.
164 */
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)[];
177export class CompareRun {
178 #opts: CompareOptions;
179 #rows: Row[] = [];
180 #fileRow: FileRow | null = null;
181 /** What restart() reloads: the file's fixed state if opts.refFile is set,
182 * otherwise a snapshot of the coarsest variant's state as of the last
183 * (re-)seed — see the capture in create() and in reseed()'s plain branch. */
184 #initial: Record<string, Float32Array>;
185 #initialLmax: number;
186 /** Base steps taken since the initial state — the refFile clock. */
187 #stepsDone = 0;
188 /** True once a refFile run has reached the file's end time. */
189 #finished = false;
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 }[] = [];
197 #errorChart: ErrorChart | null = null;
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[];
214 fileRow: FileRow | null;
215 topo: SphereMeshTopology;
216 weights: Float64Array;
217 rangeBars: { fill: (lo: number, hi: number) => void }[];
218 frameSteps: number;
219 note: string;
220 initial: Record<string, Float32Array>;
221 initialLmax: number;
222 }) {
223 this.#opts = init.opts;
224 this.#rows = init.rows;
225 this.#fileRow = init.fileRow;
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 }));
233 this.#initial = init.initial;
234 this.#initialLmax = init.initialLmax;
235 }
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 }
251 /** The reference file this study is checking against, if any. */
252 get refFile(): ReferenceCase | null {
253 return this.#opts.refFile ?? null;
254 }
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[] = [];
270 let builtFile: FileRow | null = null;
271 let errorChart: ErrorChart | null = null;
273 try {
274 for (let i = 0; i < variants.length; i++) {
275 const v = variants[i];
276 const g = opts.geometries?.[i];
277 opts.onStatus(
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,
291 geometry: g?.geometry ?? opts.geometry,
292 geometryParams: g?.geometryParams ?? opts.geometryParams,
293 geometrySource: g?.geometrySource ?? opts.geometrySource,
294 niter: v.niter,
295 lam3: opts.lam3,
296 }),
297 );
298 }
300 // ---- the shared display grid ----------------------------------------
301 const maxLmax = Math.max(...variants.map((v) => v.lmax));
302 const panels = (variants.length + (opts.refFile ? 1 : 0)) * model.species.length;
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 ---------------------------
312 // Also what restart() reloads later — the file's fixed state, or (for
313 // the plain case) a snapshot of the coarsest variant's own state,
314 // taken after seeding it: the same lowest-lmax session sharedNoise
315 // itself draws from, so prolonging it up to any other variant later is
316 // always widening a band, never narrowing one.
317 let initial: Record<string, Float32Array>;
318 let initialLmax: number;
319 if (opts.refFile) {
320 // The file's exact spectral state, prolonged into each variant's band.
321 // Exact, not approximate: the state is band-limited at the file's lmax
322 // and every variant's band contains it, so each session starts from
323 // the very field the reference run started from.
324 opts.onStatus('loading the initial state from the reference file…');
325 for (const s of sessions) {
326 s.loadState(prolongState(opts.refFile.initial, model.state, opts.refFile.lmax, s.cfg.lmax));
327 }
328 initial = opts.refFile.initial;
329 initialLmax = opts.refFile.lmax;
330 } else {
331 opts.onStatus('seeding all variants from one band-limited perturbation…');
332 const noise = await sharedNoise(sessions, model.seedAmp, opts.seed);
333 const modes = await sharedModes(sessions[opts.reference] ?? sessions[0], opts.seed);
334 // One at a time: a seed submits its whole mode sum in pieces, and there
335 // is nothing to gain from interleaving several variants' worth of it.
336 for (let i = 0; i < sessions.length; i++) await sessions[i].seedWith(noise[i], modes);
337 if (opts.geometries) {
338 // Several geometries, not several lmax bands: the seeding above
339 // drew a spatially shared field, but evaluated it on each row's
340 // own (geometry-dependent) points — a different field per row in
341 // coefficient space. The reference's exact resulting coefficients
342 // replace that for every other row, so every row starts from the
343 // identical spectral state and only the operator applied to it
344 // differs from then on.
345 const refSession = sessions[opts.reference];
346 const refState = await refSession.readState();
347 for (let i = 0; i < sessions.length; i++) {
348 if (i === opts.reference) continue;
349 sessions[i].loadState(
350 prolongState(refState, model.state, refSession.cfg.lmax, sessions[i].cfg.lmax),
351 );
352 }
353 initial = refState;
354 initialLmax = refSession.cfg.lmax;
355 } else {
356 let coarsest = sessions[0];
357 for (const s of sessions) if (s.cfg.lmax < coarsest.cfg.lmax) coarsest = s;
358 initial = await coarsest.readState();
359 initialLmax = coarsest.cfg.lmax;
360 }
361 }
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 ---------------------------------------------
391 const { rows, fileRow, rangeBars } = await buildGrid(opts, sessions, topo, showDt);
392 built = rows;
393 builtFile = fileRow;
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;
406 const solverGrid = sessions.map((s) => `${s.cfg.nlat}×${s.cfg.nphi}`);
407 const note =
408 `${variants.length} variant${variants.length === 1 ? '' : 's'} · ` +
409 `display grid ${nlat}×${nphi}` +
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({
417 opts, rows, fileRow, topo, weights, rangeBars, frameSteps, note, initial, initialLmax,
418 });
419 run.#errorChart = errorChart;
420 await run.draw();
421 run.#observeResize();
422 run.#status();
423 return run;
424 } catch (e) {
425 for (const r of built) {
426 for (const s of r.scenes) s.dispose();
427 for (const s of r.diffScenes) s.dispose();
428 }
429 for (const s of builtFile?.scenes ?? []) s.dispose();
430 for (const s of sessions) s.destroy();
431 errorChart?.dispose();
432 opts.chartContainer.hidden = true;
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 }
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). */
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;
457 const sessions = this.#rows.map((r) => r.session);
458 const refFile = this.#opts.refFile;
459 if (refFile) {
460 for (const s of sessions) {
461 s.loadState(prolongState(refFile.initial, this.#opts.model.state, refFile.lmax, s.cfg.lmax));
462 }
463 } else {
464 const noise = await sharedNoise(sessions, this.#opts.model.seedAmp, seed);
465 const modes = await sharedModes(this.referenceSession ?? sessions[0], seed);
466 // Checked per variant, not once: a seed awaits its own submission, so a
467 // dispose can land between two of them and destroy the sessions left.
468 for (let i = 0; i < sessions.length; i++) {
469 if (this.#disposed) return;
470 await sessions[i].seedWith(noise[i], modes);
471 }
472 if (this.#disposed) return;
473 // This draw becomes what restart() rewinds to from now on — see the
474 // identical selection in create(). Recaptured here rather than left
475 // pointing at the pre-reseed field.
476 if (this.#opts.geometries) {
477 // Mirrors create()'s IC block: copy the reference's exact resulting
478 // coefficients into every other row rather than trusting their own
479 // (geometry-dependent) seeding to have landed on the same state.
480 const refSession = sessions[this.#opts.reference];
481 const refState = await refSession.readState();
482 for (let i = 0; i < sessions.length; i++) {
483 if (i === this.#opts.reference) continue;
484 sessions[i].loadState(
485 prolongState(refState, this.#opts.model.state, refSession.cfg.lmax, sessions[i].cfg.lmax),
486 );
487 }
488 this.#initial = refState;
489 this.#initialLmax = refSession.cfg.lmax;
490 } else {
491 let coarsest = sessions[0];
492 for (const s of sessions) if (s.cfg.lmax < coarsest.cfg.lmax) coarsest = s;
493 this.#initial = await coarsest.readState();
494 this.#initialLmax = coarsest.cfg.lmax;
495 }
496 }
497 this.#t = 0;
498 this.#stepsDone = 0;
499 this.#finished = false;
500 for (const r of this.#ranges) {
501 r.lo = NaN;
502 r.hi = NaN;
503 }
504 for (const r of this.#rows) {
505 for (const rr of r.diffRanges) {
506 rr.lo = NaN;
507 rr.hi = NaN;
508 }
509 }
510 this.#errorChart?.reset();
511 await this.draw();
512 this.#status();
513 if (!this.#disposed && wasRunning) this.setRunning(true);
514 }
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 );
528 }
529 this.#t = 0;
530 this.#stepsDone = 0;
531 this.#finished = false;
532 for (const r of this.#ranges) {
533 r.lo = NaN;
534 r.hi = NaN;
535 }
536 for (const r of this.#rows) {
537 for (const rr of r.diffRanges) {
538 rr.lo = NaN;
539 rr.hi = NaN;
540 }
541 }
542 this.#errorChart?.reset();
543 await this.draw();
544 this.#status();
545 if (!this.#disposed && wasRunning) this.setRunning(true);
546 }
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 }
561 /** Model parameters changed. Each variant keeps its own dt. */
562 setParams(params: Params): void {
563 // Against a reference file the parameters *are* the file's — they define
564 // the problem being checked — and the page's parameter panel edits the
565 // page's own model, which need not even be this one. Nothing to apply.
566 if (this.#opts.refFile) return;
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);
579 // The diff row sits on the exact same mesh as the value row above it
580 // (same coords, same posBuf) — it just never got told to re-render
581 // when this method was first written, so it stayed fixed at whatever
582 // shape the study was compiled with.
583 for (const s of r.diffScenes) s.updatePositions(r.posBuf);
584 }
585 const f = this.#fileRow;
586 if (f) {
587 fillPositions(f.posBuf, f.coords, this.#topo, morph);
588 for (const s of f.scenes) s.updatePositions(f.posBuf);
589 }
590 }
592 resetView(): void {
593 for (const s of this.#allScenes()) s.resetCamera();
594 }
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();
603 for (const s of r.diffScenes) s.dispose();
604 r.session.destroy();
605 }
606 for (const s of this.#fileRow?.scenes ?? []) s.dispose();
607 this.#rows = [];
608 this.#fileRow = null;
609 this.#errorChart?.dispose();
610 this.#errorChart = null;
611 this.#opts.chartContainer.hidden = true;
612 this.#opts.container.replaceChildren();
613 this.#opts.container.classList.remove('compare');
614 }
616 #allScenes(): SphereScene[] {
617 return [
618 ...this.#rows.flatMap((r) => [...r.scenes, ...r.diffScenes]),
619 ...(this.#fileRow?.scenes ?? []),
620 ];
621 }
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++) {
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 ]);
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;
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);
715 for (const r of this.#rows) {
716 fillFieldValues(r.valueBufs[k], r.fields[k], this.#topo);
717 fillColors(r.colorBufs[k], r.valueBufs[k], shown.lo, shown.hi, cmap);
718 r.scenes[k]?.updateColors(r.colorBufs[k]);
719 }
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 }
726 }
728 this.#measureDifference();
729 this.#colorDiffPanels();
730 this.#updateRowStats();
731 this.#errorChart?.push(
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 }
777 }
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 {
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;
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];
800 const b = refFields[k];
801 if (!a || !b || a.length !== b.length) {
802 r.err[k] = NaN;
803 continue;
804 }
805 const diff = r.diffFields[k];
806 let num = 0;
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];
811 diff[i] = d;
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 {
828 const ref = this.#fileRow ? null : this.#rows[this.#opts.reference];
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
832 // as a converged uniform state. Δ itself now lives in the error chart,
833 // not here.
834 const body = !r.healthy
835 ? '<b class="cmp-diverged">diverged</b>'
836 : r === ref
837 ? '<b>reference</b>'
838 : '';
839 r.statEl.innerHTML =
840 `${r.session.steps.toLocaleString()} steps` + (body ? `<br>${body}` : '');
841 }
842 }
844 #status(): void {
845 const refFile = this.#opts.refFile;
846 const clock = refFile
847 ? `<b>t = ${this.#t.toFixed(2)} / ${(refFile.steps * CompareRun.baseDt(this.#opts.params)).toFixed(2)}</b>` +
848 ` · NOTE: vertical axis measures difference from uploaded simulation's end state.`
849 : `<b>t = ${this.#t.toFixed(2)}</b> (same for every variant)`;
850 this.#opts.onStatus(
851 `${clock} · ` +
852 (this.#frameMs > 0 ? `${this.#frameMs.toFixed(1)} ms/frame · ` : '') +
853 this.#note,
854 );
855 }
857 #observeResize(): void {
858 const scenes = this.#allScenes();
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 }
864 this.#errorChart?.redraw();
865 });
866 for (const s of scenes) {
867 const box = s.canvas.parentElement;
868 if (box) this.#resizeObs.observe(box);
869 }
870 this.#resizeObs.observe(this.#opts.chartContainer);
871 }
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) {
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 }
900 const t0 = performance.now();
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);
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);
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 }
915 this.#status();
916 await nextFrame();
917 }
918 if (!this.#disposed) {
919 await this.draw();
920 this.#status();
921 }
922 } finally {
923 this.#pumping = false;
924 }
925 }
928const nextFrame = (): Promise<number> => new Promise(requestAnimationFrame);
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;
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;
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;
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;
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;
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 */
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';
1007/**
1008 * One sphere panel: a boxed SphereScene plus the value/color buffers that
1009 * feed it. Shared by the value row, the diff row, and the file row — all
1010 * three build a panel the same way, only differing in the class on the box
1011 * (for styling) and in what fills the buffers afterward.
1012 */
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 };
1039async function buildGrid(
1040 opts: CompareOptions,
1041 sessions: ModelSession[],
1042 topo: SphereMeshTopology,
1043 showDt: boolean,
1044): Promise<{
1045 rows: Row[];
1046 fileRow: FileRow | null;
1047 rangeBars: { fill: (lo: number, hi: number) => void }[];
1048}> {
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);
1088 lab.textContent = `${fmtValue(lo)}${fmtValue(hi)}`;
1089 },
1090 };
1091 });
1093 const sphereBg = getComputedStyle(document.documentElement)
1094 .getPropertyValue('--sphere-bg')
1095 .trim();
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;
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];
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;
1118 const coords = sharedCoords ?? (await session.renderPositions());
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';
1129 nameEl.textContent = opts.rowLabels?.[i] ?? variantLabel(variant, showDt);
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++) {
1142 const { scene, valueBuf, colorBuf } = makeSpherePanel(colsEl, topo, posBuf, sphereBg || undefined);
1143 scenes.push(scene);
1144 valueBufs.push(valueBuf);
1145 colorBufs.push(colorBuf);
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);
1183 const diffRanges = model.species.map(() => ({ lo: NaN, hi: NaN }));
1184 rows.push({
1185 variant, session, color, coords, posBuf, scenes, valueBufs, colorBufs,
1186 fields: [], diffFields, diffScenes, diffValueBufs, diffColorBufs, diffCaps, diffRanges,
1187 err: model.species.map(() => 0), healthy: true, statEl,
1188 });
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];
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++) {
1242 const { scene, valueBuf, colorBuf } = makeSpherePanel(colsEl, topo, posBuf, sphereBg || undefined);
1243 scenes.push(scene);
1244 const field = await on(rf.final[model.state[k]]);
1245 fields.push(field);
1246 bounds.push(finiteRange(field));
1247 fillFieldValues(valueBuf, field, topo);
1248 valueBufs.push(valueBuf);
1249 colorBufs.push(colorBuf);
1251 fileRow = { coords, posBuf, scenes, valueBufs, colorBufs, fields, bounds };
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.
1256 const all = [
1257 ...rows.flatMap((r) => [...r.scenes, ...r.diffScenes]),
1258 ...(fileRow?.scenes ?? []),
1259 ];
1260 for (let i = 1; i < all.length; i++) all[0].syncCamerasWith(all[i]);
1262 return { rows, fileRow, rangeBars };
moveopenescclose