WIP: comparison with unit sphere
3 changed files+181−38
index.htmlmodified+11−3View file
@@ -177,6 +177,12 @@
177177 /* The bar's own layout: three chip rows stacked, then the reference
178178 picker and the button beside them. */
179179 .cmp-axes { display: flex; flex-direction: column; gap: 4px; }
180+ /* Without this, JS setting the `hidden` attribute (vs-sphere mode,
181+ which has no chip grid) does nothing: [hidden] and .cmp-axes tie on
182+ specificity, and the author rule above wins the tie over the
183+ browser's default — the exact opposite of what `hidden` is supposed
184+ to mean. */
185+ .cmp-axes[hidden] { display: none; }
180186 .cmp-axis { display: flex; align-items: center; gap: 8px; }
181187 .cmp-axis > span:first-child {
182188 color: var(--ink-2); font-size: 13px; width: 5.5em; text-align: right;
@@ -293,7 +299,9 @@
293299 <button type="button" class="chip" id="mode-simulate" aria-pressed="true">Simulate</button>
294300 <button type="button" class="chip" id="mode-effort"
295301 title="Run several solver settings side by side on one clock">Compare computational effort</button>
296- <button type="button" class="chip" id="mode-vs-sphere" disabled title="Coming soon">Compare against sphere (Coming soon)</button>
302+ <button type="button" class="chip" id="mode-vs-sphere"
303+ title="Run this model once on the true geometry and once on the unit sphere, from the same starting state">
304+ Compare against sphere</button>
297305 <button type="button" class="chip" id="mode-vs-upload"
298306 title="Check this solver against a saved reference run">
299307 Compare against uploaded data</button>
@@ -313,7 +321,7 @@
313321 <div class="controls" id="geomparams"></div>
314322 </div>
315323 <div class="controls" id="comparebar" hidden>
316- <div class="cmp-axes">
324+ <div class="cmp-axes" id="cmp-axes">
317325 <div class="cmp-axis">
318326 <span title="Iterations of the implicit diffusion solve">solve iters</span>
319327 <span id="cmp-niter" class="chips"></span>
@@ -327,7 +335,7 @@
327335 <span id="cmp-dt" class="chips"></span>
328336 </div>
329337 </div>
330- <label title="The run everything else is measured against">reference
338+ <label title="The run everything else is measured against" id="cmp-reflabel">reference
331339 <select id="cmp-ref"></select>
332340 </label>
333341 <span id="cmp-fileinfo" class="stats" hidden></span>
src/compare/compareRun.tsmodified+81−13View file
@@ -77,6 +77,21 @@ export interface CompareOptions {
7777 geometry: MGeometry;
7878 geometryParams: Params;
7979 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[];
8095 variants: Variant[];
8196 /** Index into `variants` of the run everything else is measured against.
8297 * Ignored when `refFile` is given — the file is the reference then. */
@@ -258,6 +273,7 @@ export class CompareRun {
258273 try {
259274 for (let i = 0; i < variants.length; i++) {
260275 const v = variants[i];
276+ const g = opts.geometries?.[i];
261277 opts.onStatus(
262278 `compiling ${i + 1}/${variants.length} — ${variantLabel(v, showDt)} ` +
263279 `(a solve iteration is ~15 kernels per species, and there is no ` +
@@ -272,9 +288,9 @@ export class CompareRun {
272288 params: { ...opts.params, dt: baseDt / v.dtDiv },
273289 lmax: v.lmax,
274290 source: opts.source,
275- geometry: opts.geometry,
276- geometryParams: opts.geometryParams,
277- geometrySource: opts.geometrySource,
291+ geometry: g?.geometry ?? opts.geometry,
292+ geometryParams: g?.geometryParams ?? opts.geometryParams,
293+ geometrySource: g?.geometrySource ?? opts.geometrySource,
278294 niter: v.niter,
279295 lam3: opts.lam3,
280296 }),
@@ -318,10 +334,30 @@ export class CompareRun {
318334 // One at a time: a seed submits its whole mode sum in pieces, and there
319335 // is nothing to gain from interleaving several variants' worth of it.
320336 for (let i = 0; i < sessions.length; i++) await sessions[i].seedWith(noise[i], modes);
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;
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+ }
325361 }
326362
327363 // ---- the mesh, shared; the surface, per variant ---------------------
@@ -437,10 +473,26 @@ export class CompareRun {
437473 // This draw becomes what restart() rewinds to from now on — see the
438474 // identical selection in create(). Recaptured here rather than left
439475 // 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;
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+ }
444496 }
445497 this.#t = 0;
446498 this.#stepsDone = 0;
@@ -524,6 +576,11 @@ export class CompareRun {
524576 for (const r of this.#rows) {
525577 fillPositions(r.posBuf, r.coords, this.#topo, morph);
526578 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);
527584 }
528585 const f = this.#fileRow;
529586 if (f) {
@@ -1037,6 +1094,17 @@ async function buildGrid(
10371094 .getPropertyValue('--sphere-bg')
10381095 .trim();
10391096
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;
1107+
10401108 const rows: Row[] = [];
10411109 for (let i = 0; i < sessions.length; i++) {
10421110 const session = sessions[i];
@@ -1047,7 +1115,7 @@ async function buildGrid(
10471115 // context for nothing.
10481116 const isRef = !opts.refFile && i === opts.reference;
10491117
1050- const coords = await session.renderPositions();
1118+ const coords = sharedCoords ?? (await session.renderPositions());
10511119 const posBuf = new Float32Array(topo.numVertices * 3);
10521120 fillPositions(posBuf, coords, topo, opts.morph);
10531121
@@ -1058,7 +1126,7 @@ async function buildGrid(
10581126 labelEl.style.setProperty('--c', color);
10591127 const nameEl = document.createElement('div');
10601128 nameEl.className = 'cmp-rowname';
1061- nameEl.textContent = variantLabel(variant, showDt);
1129+ nameEl.textContent = opts.rowLabels?.[i] ?? variantLabel(variant, showDt);
10621130 const statEl = document.createElement('div');
10631131 statEl.className = 'cmp-rowstat';
10641132 labelEl.append(nameEl, statEl);
src/main.tsmodified+89−22View file
@@ -18,6 +18,7 @@ import {
1818 mGeometryByKey,
1919 defaultGeometryParams,
2020 DEFAULT_GEOMETRY_KEY,
21+ SPHERE_KEY,
2122 type MGeometry,
2223 } from './geom/registry.ts';
2324 import {
@@ -66,12 +67,15 @@ const elMovieRotate = $<HTMLInputElement>('movierotate');
6667 const elMovie = $<HTMLButtonElement>('movie');
6768 const elModeSimulate = $<HTMLButtonElement>('mode-simulate');
6869 const elModeEffort = $<HTMLButtonElement>('mode-effort');
70+const elModeVsSphere = $<HTMLButtonElement>('mode-vs-sphere');
6971 const elModeVsUpload = $<HTMLButtonElement>('mode-vs-upload');
7072 const elModeDesc = $('mode-desc');
7173 const elCompareBar = $('comparebar');
74+const elCmpAxes = $('cmp-axes');
7275 const elCmpNiter = $('cmp-niter');
7376 const elCmpLmax = $('cmp-lmax');
7477 const elCmpDt = $('cmp-dt');
78+const elCmpRefLabel = $('cmp-reflabel');
7579 const elCmpRef = $<HTMLSelectElement>('cmp-ref');
7680 const elCmpFile = $<HTMLInputElement>('cmp-file');
7781 const elCmpFileInfo = $('cmp-fileinfo');
@@ -1287,6 +1291,15 @@ function compareRefIndex(): number {
12871291 }
12881292
12891293 function refreshVariants(): void {
1294+ // vs-sphere has no chip grid feeding cmpVariants() — always exactly two
1295+ // fixed rows plus one diff row, regardless of MAX_VARIANTS/MAX_PANELS —
1296+ // so the grid-count logic below doesn't apply here at all.
1297+ if (currentMode === 'vs-sphere') {
1298+ elCmpCount.textContent = `2 rows × ${model.species.length} species + 1 diff row`;
1299+ elCmpCount.style.color = '';
1300+ elCmpStart.disabled = false;
1301+ return;
1302+ }
12901303 const variants = cmpVariants();
12911304 const showDt = cmpSelected.dt.size > 1;
12921305 // With a file loaded the study's model is the file's, and its final state
@@ -1375,6 +1388,25 @@ function rebuildLmaxChips(): void {
13751388 buildChips(elCmpLmax, values, cmpSelected.lmax, String);
13761389 }
13771390
1391+/**
1392+ * The four top-level modes and which control groups each shows (see
1393+ * GROUP_NAMES/groupEls above; `.ctrl-group` wrappers in index.html).
1394+ * `currentMode` tracks which configuration is on screen — the compare bar
1395+ * being open, and in which flavor — not whether a study has actually been
1396+ * started inside it. That match matters: without it, opening the bar
1397+ * (which already shows the right groups) leaves its top-row button
1398+ * unhighlighted until a study happens to start, which is inconsistent with
1399+ * `vs-upload`'s one-click flow and reads as broken.
1400+ *
1401+ * Declared here, ahead of the top-level `refreshVariants()` call just below
1402+ * — that call reads `currentMode` (to know whether to skip its chip-grid
1403+ * counting for vs-sphere), so the declaration has to be in scope by the time
1404+ * this file's top-level code actually runs, not merely by the time
1405+ * `refreshVariants` is later invoked from an event handler.
1406+ */
1407+type Mode = 'simulate' | 'compute-effort' | 'vs-sphere' | 'vs-upload';
1408+let currentMode: Mode = 'simulate';
1409+
13781410 rebuildNiterChips();
13791411 rebuildLmaxChips();
13801412 buildChips(elCmpDt, DT_DIVISORS, cmpSelected.dt, (v) => (v === 1 ? 'dt' : `dt/${v}`));
@@ -1408,23 +1440,13 @@ function applyRefUi(): void {
14081440 refreshVariants();
14091441 }
14101442
1411-/**
1412- * The four top-level modes and which control groups each shows (see
1413- * GROUP_NAMES/groupEls above; `.ctrl-group` wrappers in index.html).
1414- * `currentMode` tracks which configuration is on screen — the compare bar
1415- * being open, and in which flavor — not whether a study has actually been
1416- * started inside it. That match matters: without it, opening the bar
1417- * (which already shows the right groups) leaves its top-row button
1418- * unhighlighted until a study happens to start, which is inconsistent with
1419- * `vs-upload`'s one-click flow and reads as broken.
1420- */
1421-type Mode = 'simulate' | 'compute-effort' | 'vs-sphere' | 'vs-upload';
1422-let currentMode: Mode = 'simulate';
1423-
14241443 const MODE_GROUPS: Record<Mode, readonly GroupName[]> = {
14251444 simulate: ['surface', 'surface-params', 'solver', 'display', 'playback', 'benchmark', 'seed', 'movie'],
14261445 'compute-effort': ['surface', 'surface-params', 'display', 'playback', 'seed'],
1427- 'vs-sphere': [], // unreachable — the button is disabled, no listener ever calls setMode with this
1446+ // Unlike compute-effort, there is no separate chip grid for this mode —
1447+ // both rows always mirror whatever niter/lmax the Simulate controls have,
1448+ // so `solver` has to stay visible; it's the only place those get set.
1449+ 'vs-sphere': ['surface', 'surface-params', 'solver', 'display', 'playback', 'seed'],
14281450 // No `seed` here: nothing in that group does anything useful against a
14291451 // loaded file (lam3 is silently absorbed, and Restart already covers what
14301452 // Re-seed would otherwise be doing — reloading the file's fixed initial
@@ -1439,7 +1461,11 @@ const MODE_DESCRIPTIONS: Record<Mode, string> = {
14391461 'When we change the computational effort of the solver by varying solve iterations, lmax, or timestep, ' +
14401462 'how does the solution change? Find out by running several ' +
14411463 'so you can see how each setting trades accuracy for speed.',
1442- 'vs-sphere': '',
1464+ 'vs-sphere':
1465+ 'Run this model once on the selected geometry and once on the plain unit sphere, from the exact same ' +
1466+ 'starting state and the same solve iterations, lmax and timestep, so geometry is the only thing that ' +
1467+ 'differs. See how much resolving the true shape actually changes the pattern, versus approximating it ' +
1468+ 'as a sphere.',
14431469 'vs-upload':
14441470 'Load a saved reference run (an .h5 file) and run this solver to the ' +
14451471 'same physical end time from the same initial condition, to check how ' +
@@ -1450,6 +1476,7 @@ const MODE_DESCRIPTIONS: Record<Mode, string> = {
14501476 function setModeButtons(mode: Mode): void {
14511477 elModeSimulate.setAttribute('aria-pressed', String(mode === 'simulate'));
14521478 elModeEffort.setAttribute('aria-pressed', String(mode === 'compute-effort'));
1479+ elModeVsSphere.setAttribute('aria-pressed', String(mode === 'vs-sphere'));
14531480 elModeVsUpload.setAttribute('aria-pressed', String(mode === 'vs-upload'));
14541481 elModeDesc.textContent = MODE_DESCRIPTIONS[mode];
14551482 }
@@ -1474,26 +1501,33 @@ function applyModeVisibility(mode: Mode): void {
14741501 function enterMode(mode: Mode): void {
14751502 applyModeVisibility(mode);
14761503 elCompareBar.hidden = mode === 'simulate';
1504+ // vs-sphere has no chip grid to pick from — both rows mirror the Simulate
1505+ // panel's own niter/lmax — and no reference to pick among variants either,
1506+ // since the reference is always "the selected geometry." Just the
1507+ // description and the Compile button apply.
1508+ elCmpAxes.hidden = mode === 'vs-sphere';
1509+ elCmpRefLabel.hidden = mode === 'vs-sphere';
14771510 }
14781511
14791512 /** Entering a mode from the top row. */
14801513 function setMode(mode: Mode): void {
1481- if (mode === 'vs-sphere') return; // unreachable — button is disabled
14821514 if (mode === 'simulate') {
14831515 if (compareRun) void stopCompare();
14841516 enterMode('simulate');
14851517 return;
14861518 }
1487- if (mode === 'compute-effort') {
1519+ if (mode === 'compute-effort' || mode === 'vs-sphere') {
14881520 // Tear down whatever study is running first (mirrors Simulate above) —
14891521 // stopCompare's synchronous prefix disposes it and nulls `compareRun`
14901522 // before its first `await`, so `refCase` is safe to drop right after.
1523+ // vs-sphere never uses a reference file either — its "reference" is
1524+ // always the selected geometry — so the same drop applies there too.
14911525 if (compareRun) void stopCompare();
14921526 if (refCase) {
14931527 refCase = null;
14941528 applyRefUi();
14951529 }
1496- enterMode('compute-effort');
1530+ enterMode(mode);
14971531 return;
14981532 }
14991533 // vs-upload: opens the file picker; entering the mode itself happens once
@@ -1504,6 +1538,7 @@ function setMode(mode: Mode): void {
15041538
15051539 elModeSimulate.addEventListener('click', () => setMode('simulate'));
15061540 elModeEffort.addEventListener('click', () => setMode('compute-effort'));
1541+elModeVsSphere.addEventListener('click', () => setMode('vs-sphere'));
15071542 elModeVsUpload.addEventListener('click', () => setMode('vs-upload'));
15081543
15091544 elCmpFile.addEventListener('change', () => {
@@ -1584,13 +1619,42 @@ async function startCompare(): Promise<void> {
15841619 if (compareRun || !device) return;
15851620 // Snapshotted for the whole study: `refCase` only changes with no study up
15861621 // (clearing is disabled during one, and loading tears it down first).
1587- const rc = refCase;
1622+ // vs-sphere never has one — its "reference" is always the selected
1623+ // geometry, not a file.
1624+ const rc = currentMode === 'vs-sphere' ? null : refCase;
15881625 const cmpModel = rc?.model ?? model;
1589- const variants = cmpVariants();
1626+
1627+ // vs-sphere: exactly two rows — the selected geometry (the reference) and
1628+ // the plain unit sphere — mirroring whatever niter/lmax the Simulate
1629+ // controls have, so geometry is the only thing that differs between them.
1630+ // Every other mode still drives its rows from the chip grid.
1631+ const isVsSphere = currentMode === 'vs-sphere';
1632+ let variants: Variant[];
1633+ let reference: number;
1634+ let geometries: { geometry: MGeometry; geometryParams: Params; geometrySource: string }[] | undefined;
1635+ let renderOnReferenceGeometry: boolean | undefined;
1636+ let rowLabels: string[] | undefined;
1637+ if (isVsSphere) {
1638+ const niter = Number(elNiter.value);
1639+ const lmax = Number(elLmax.value);
1640+ variants = [{ niter, lmax, dtDiv: 1 }, { niter, lmax, dtDiv: 1 }];
1641+ reference = 0;
1642+ const sphereGeom = mGeometryByKey(SPHERE_KEY)!;
1643+ geometries = [
1644+ { geometry, geometryParams: geomParams, geometrySource: geomSource() },
1645+ { geometry: sphereGeom, geometryParams: defaultGeometryParams(sphereGeom), geometrySource: sphereGeom.source },
1646+ ];
1647+ renderOnReferenceGeometry = true;
1648+ rowLabels = [geometry.label, 'unit sphere'];
1649+ } else {
1650+ variants = cmpVariants();
1651+ reference = rc ? 0 : compareRefIndex();
1652+ }
1653+
15901654 const rowCount = variants.length + (rc ? 1 : 0);
15911655 const diffRowCount = rc ? variants.length : Math.max(0, variants.length - 1);
15921656 const panels = (rowCount + diffRowCount) * cmpModel.species.length;
1593- if (variants.length > MAX_VARIANTS || panels > MAX_PANELS) {
1657+ if (!isVsSphere && (variants.length > MAX_VARIANTS || panels > MAX_PANELS)) {
15941658 return;
15951659 }
15961660 // Take down the single run first: its pump, its scenes, its session. The
@@ -1618,7 +1682,10 @@ async function startCompare(): Promise<void> {
16181682 geometryParams: rc ? rc.geometryParams : geomParams,
16191683 geometrySource: rc ? rc.geometry.source : geomSource(),
16201684 variants,
1621- reference: rc ? 0 : compareRefIndex(),
1685+ reference,
1686+ geometries,
1687+ renderOnReferenceGeometry,
1688+ rowLabels,
16221689 refFile: rc ?? undefined,
16231690 onFinished: () => setRunning(false),
16241691 seed,