/ concept-collection / turing-surface-cache
concept-collection / turing-surface-cache
turing-surface-cache / src / main.ts
1117 lines · 37.3 KBCodeBlameHistory
2 * turing-surface-cache: reaction-diffusion solutions at a chosen end time,
3 * from a shared cloud cache when someone has computed them before, and from
4 * the local GPU when not.
5 *
6 * Every control is a choice from a short list (src/cache/options.ts), so the
7 * page's whole state is one small spec object. Get solution hashes that spec
8 * into a cache object name (src/cache/spec.ts) and fetches it; a 404 means
9 * nobody has computed it, so the solver runs here — live, watching the
10 * pattern form — and stops at exactly the requested time. A run to T passes
11 * exactly through every smaller listed end time, so those states are captured
12 * along the way; with an upload API key entered, all of them are contributed
13 * back to the cache.
14 *
15 * The solver is turing-surface's, unchanged: the model and geometry are
16 * MATLAB compiled (model) or interpreted (geometry) by numbl, the transforms
17 * are WGSL compute shaders. lmax, niter and the seed wavelength are fixed in
18 * this app (options.ts) — fewer knobs, same machinery.
19 */
20import { requestShtDevice, describeAdapter } from './sht/sht.ts';
795cdccMove the run and the walk out of the pageJeremy Magland 21import type { ModelSession } from './mgpu/session.ts';
481eeb9Add Brusselator and Allen-Cahn modelsJeremy Magland 22import { mModels, mModelByKey, type MModel, type Params } from './mgpu/registry.ts';
4f822e1turing-surface-cache: reaction-diffusion solutions at a chosen end time, shared through a cloud cacheJeremy Magland 23import { formatFailure } from './mgpu/errors.ts';
24import {
25 mGeometryByKey,
26 DEFAULT_GEOMETRY_KEY,
27 mGeometries,
28 type MGeometry,
29} from './geom/registry.ts';
30import {
31 buildTopology,
32 fillPositions,
33 fillFieldValues,
34 fillColors,
35 type SphereMeshTopology,
36} from './render/sphereMesh.ts';
37import { SphereScene } from './render/SphereScene.ts';
38import { Colorbar, floorRange } from './render/colorbar.ts';
39import { colormaps } from './render/colormaps.ts';
40import {
41 MODEL_CHOICES,
481eeb9Add Brusselator and Allen-Cahn modelsJeremy Magland 42 DEFAULT_MODEL_KEY,
44 SEED_CHOICE,
45 T_END_CHOICE,
46 LMAX,
47 NITER,
50 fmtChoice,
51 type DiscreteChoice,
52} from './cache/options.ts';
e53205fShow one parameter across its range, not only at a pointJeremy Magland 53import { stepsFor, type CacheSpec, APP_NAME } from './cache/spec.ts';
54import {
55 fragmentFor,
56 readSelection,
57 selectionToParams,
58 specForSelection,
59 type Selection,
60} from './cache/selection.ts';
795cdccMove the run and the walk out of the pageJeremy Magland 61import { lookupFor, fetchCached, headCached, type CacheLookup } from './cache/client.ts';
c2317d0Fill the cache from the command line, without a browserJeremy Magland 62import { autoOrder, specForTarget, type AutoTarget } from './cache/autoWalk.ts';
795cdccMove the run and the walk out of the pageJeremy Magland 63import { decodeCacheFile } from './cache/h5file.ts';
64import { SolverSession } from './cache/solver.ts';
65import { runSpec, type RunEvents, type RunOutcome, type RunSummary } from './cache/runSpec.ts';
66import { fillWalk } from './cache/fillWalk.ts';
68const $ = <T extends HTMLElement>(id: string): T =>
69 document.getElementById(id) as T;
481eeb9Add Brusselator and Allen-Cahn modelsJeremy Magland 71const elModel = $<HTMLSelectElement>('model');
73const elGeometry = $<HTMLSelectElement>('geometry');
74const elGeomParams = $('geomparams');
75const elSeed = $<HTMLSelectElement>('seed');
76const elTend = $<HTMLSelectElement>('tend');
77const elSolve = $<HTMLButtonElement>('solve');
78const elStop = $<HTMLButtonElement>('stop');
79const elReset = $<HTMLButtonElement>('reset');
80const elCacheNote = $('cachenote');
81const elStatus = $('status');
82const elPanels = $('panels');
83const elResetView = $<HTMLButtonElement>('resetview');
84const elDownload = $<HTMLAnchorElement>('download');
85const elStats = $('stats');
86const elApiKey = $<HTMLInputElement>('apikey');
87const elUploadNote = $('uploadnote');
1a1e473Add auto-fill: work through the parameter space on an idle machineJeremy Magland 88const elAutoBar = $('autobar');
89const elAuto = $<HTMLButtonElement>('auto');
90const elAutoNote = $('autonote');
b32cc02Offer the fill command in the page, ready to copyJeremy Magland 91const elCliBar = $('clibar');
92const elCliCmd = $('clicmd');
93const elCliCopy = $<HTMLButtonElement>('clicopy');
94const elCliCopied = $('clicopied');
d71391eFold the troubleshooting into the page, beside the commandJeremy Magland 95const elCliHelp = $('clihelp');
e53205fShow one parameter across its range, not only at a pointJeremy Magland 96const elSweepLink = $<HTMLAnchorElement>('sweeplink');
99/**
100 * Test/debug hook: `?tend=5,10` replaces the end-time list with the given
101 * values (still cached under their own honest specs — a test end time hashes
102 * to its own object). The headless checks use this to keep their computed
103 * runs short; it is not part of the normal UI.
104 */
106 const param = new URLSearchParams(location.search).get('tend');
107 if (param) {
108 const values = param
109 .split(',')
110 .map(Number)
111 .filter((v) => Number.isFinite(v) && v > 0);
112 if (values.length) {
113 T_END_CHOICE.values = values;
114 T_END_CHOICE.value = values[0];
115 }
116 }
119const API_KEY_STORAGE = `${APP_NAME}:apiKey`;
120const COLORMAP = colormaps.viridis;
121/** Render on a 2x finer grid than the solver's; exact interpolation. */
122const OVERSAMPLE = 2;
123/** How often the live view renders during a computation. */
124const RENDER_EVERY_MS = 250;
795cdccMove the run and the walk out of the pageJeremy Magland 125/** How often the status line is rewritten during a computation. */
126const STATUS_EVERY_MS = 200;
128// ---------------------------------------------------------------- state
481eeb9Add Brusselator and Allen-Cahn modelsJeremy Magland 129let model: MModel = mModelByKey(DEFAULT_MODEL_KEY)!;
795cdccMove the run and the walk out of the pageJeremy Magland 131/** The compiled solver and what it has applied (src/cache/solver.ts). */
132let solver: SolverSession | null = null;
795cdccMove the run and the walk out of the pageJeremy Magland 134/** The live session, or null before the GPU is up. */
135function sess(): ModelSession | null {
136 return solver?.session ?? null;
139/** The discrete selections, always exactly values from options.ts. */
481eeb9Add Brusselator and Allen-Cahn modelsJeremy Magland 140let params: Params = defaultChoiceParams(MODEL_CHOICES[DEFAULT_MODEL_KEY]);
4f822e1turing-surface-cache: reaction-diffusion solutions at a chosen end time, shared through a cloud cacheJeremy Magland 141let geometry: MGeometry = mGeometryByKey(DEFAULT_GEOMETRY_KEY)!;
142let geomParams: Params = Object.fromEntries(
143 GEOMETRY_CHOICES[DEFAULT_GEOMETRY_KEY].map((c) => [c.key, c.value]),
144);
145let seed = SEED_CHOICE.value;
146let tEnd = T_END_CHOICE.value;
148// The URL fragment carries the whole selection, so a reload comes back to it
149// and a shared link opens on the same spec (and, through refresh(), the same
150// cached solution). Read once at startup; rewritten on every change.
151readUrlState();
153let topo: SphereMeshTopology | null = null;
154let scenes: SphereScene[] = [];
155let colorbars: Colorbar[] = [];
156/** The colorbar containers, hidden while the windows are empty. */
157let colorbarEls: HTMLElement[] = [];
158let valueBufs: Float32Array[] = [];
159let colorBufs: Float32Array[] = [];
160let ranges: { lo: number; hi: number }[] = [];
161let resizeObs: ResizeObserver | null = null;
162let coords: Float32Array | null = null;
163let posBuf: Float32Array | null = null;
165let generation = 0;
166let busy = false;
167/** True while computeLocally is stepping/reading back. Every read shares one
168 * staging buffer (GpuModel#readback), so a new solve must drain the old
169 * loop before issuing reads of its own. */
170let pumping = false;
171let stopRequested = false;
1a1e473Add auto-fill: work through the parameter space on an idle machineJeremy Magland 172/** Set while the auto-fill walk owns the page (see autoRun). */
173let autoRunning = false;
174let autoComputed = 0;
175let autoSkipped = 0;
176let autoFailed = 0;
4f822e1turing-surface-cache: reaction-diffusion solutions at a chosen end time, shared through a cloud cacheJeremy Magland 177/** Simulation time of the state on display (loadState resets session.t). */
178let shownT: number | null = null;
179let downloadUrl: string | null = null;
181const nextFrame = () => new Promise<number>(requestAnimationFrame);
183// ---------------------------------------------------------------- spec
e53205fShow one parameter across its range, not only at a pointJeremy Magland 184function currentSelection(): Selection {
186 model: model.key,
187 params: { ...params },
188 geometry: geometry.key,
189 geometryParams: { ...geomParams },
190 seed,
191 tEnd,
192 };
e53205fShow one parameter across its range, not only at a pointJeremy Magland 195function currentSpec(): CacheSpec {
196 return specForSelection(currentSelection());
4f822e1turing-surface-cache: reaction-diffusion solutions at a chosen end time, shared through a cloud cacheJeremy Magland 199// ---------------------------------------------------------------- URL state
200/**
201 * The selection lives in the URL fragment, every value written explicitly
202 * (`#a=0.1&b=0.9&…&geometry=ellipsoid&ax=1.5&…&seed=1&tend=100`), so a link
203 * keeps meaning the same spec even if a default changes later. The fragment
e53205fShow one parameter across its range, not only at a pointJeremy Magland 204 * is chosen over the query string to leave `?tend` to the test hook. The
205 * serialization is shared with the sweep page and the command line
206 * (src/cache/selection.ts).
208function readUrlState(): void {
209 const hash = location.hash.replace(/^#/, '');
210 if (!hash) return;
e53205fShow one parameter across its range, not only at a pointJeremy Magland 211 const sel = readSelection(new URLSearchParams(hash));
212 model = mModelByKey(sel.model)!;
213 params = sel.params;
214 geometry = mGeometryByKey(sel.geometry)!;
215 geomParams = sel.geometryParams;
216 seed = sel.seed;
217 tEnd = sel.tEnd;
220function writeUrlState(): void {
e53205fShow one parameter across its range, not only at a pointJeremy Magland 221 const p = fragmentFor(selectionToParams(currentSelection()));
222 history.replaceState(null, '', `${location.pathname}${location.search}#${p}`);
223 // The sweep page opens on the same selection (the search part keeps the
224 // ?tend test hook alive across the two pages).
225 elSweepLink.href = `sweep.html${location.search}#${p}`;
228// ---------------------------------------------------------------- controls
229/** Every select made by makeSelect, so a reset can push new values into the
230 * ones still on the page. */
231const boundSelects: { el: HTMLSelectElement; get: () => number }[] = [];
233function syncSelects(): void {
1a1e473Add auto-fill: work through the parameter space on an idle machineJeremy Magland 234 // Pruned as it goes: auto mode rebuilds the parameter controls once per
235 // target, so entries for replaced selects would otherwise pile up.
236 for (let i = boundSelects.length - 1; i >= 0; i--) {
237 const b = boundSelects[i];
4f822e1turing-surface-cache: reaction-diffusion solutions at a chosen end time, shared through a cloud cacheJeremy Magland 238 if (b.el.isConnected) b.el.value = String(b.get());
1a1e473Add auto-fill: work through the parameter space on an idle machineJeremy Magland 239 else boundSelects.splice(i, 1);
243function makeSelect(
244 choice: DiscreteChoice,
245 get: () => number,
246 set: (v: number) => void,
247): HTMLLabelElement {
248 const label = document.createElement('label');
249 label.textContent = `${choice.label} `;
250 const select = document.createElement('select');
251 for (const v of choice.values) {
252 const opt = document.createElement('option');
253 opt.value = String(v);
254 opt.textContent = fmtChoice(v);
255 select.append(opt);
256 }
257 select.value = String(get());
258 select.addEventListener('change', () => {
259 set(Number(select.value));
260 onSelectionChange();
261 });
262 label.append(select);
263 boundSelects.push({ el: select, get });
264 return label;
1a1e473Add auto-fill: work through the parameter space on an idle machineJeremy Magland 267/** Put every selection back to its default, without refreshing the display. */
268function applyDefaults(): void {
481eeb9Add Brusselator and Allen-Cahn modelsJeremy Magland 269 model = mModelByKey(DEFAULT_MODEL_KEY)!;
270 params = defaultChoiceParams(MODEL_CHOICES[DEFAULT_MODEL_KEY]);
4f822e1turing-surface-cache: reaction-diffusion solutions at a chosen end time, shared through a cloud cacheJeremy Magland 271 geometry = mGeometryByKey(DEFAULT_GEOMETRY_KEY)!;
481eeb9Add Brusselator and Allen-Cahn modelsJeremy Magland 272 elModel.value = model.key;
273 buildModelParamControls();
4f822e1turing-surface-cache: reaction-diffusion solutions at a chosen end time, shared through a cloud cacheJeremy Magland 274 geomParams = defaultChoiceParams(GEOMETRY_CHOICES[DEFAULT_GEOMETRY_KEY]);
275 seed = SEED_CHOICE.value;
276 tEnd = T_END_CHOICE.value;
277 elGeometry.value = geometry.key;
278 buildGeomParamControls();
279 elSeed.value = String(seed);
280 elTend.value = String(tEnd);
281 syncSelects();
285/** The Reset button: back to the defaults, and show what is there. */
286function resetDefaults(): void {
287 applyDefaults();
1a1e473Add auto-fill: work through the parameter space on an idle machineJeremy Magland 291/** Point every control at one walk target (auto mode drives the same
292 * selection the user otherwise would, so the URL and the dropdowns always
c2317d0Fill the cache from the command line, without a browserJeremy Magland 293 * say what is being computed). The values come from the target's own spec,
294 * so currentSpec() reproduces exactly what the walk asked for. */
1a1e473Add auto-fill: work through the parameter space on an idle machineJeremy Magland 295function setSelection(t: AutoTarget): void {
c2317d0Fill the cache from the command line, without a browserJeremy Magland 296 const spec = specForTarget(t);
297 const nextModel = mModelByKey(spec.model)!;
1a1e473Add auto-fill: work through the parameter space on an idle machineJeremy Magland 298 if (nextModel !== model) {
299 model = nextModel;
300 elModel.value = model.key;
301 buildModelParamControls();
302 }
c2317d0Fill the cache from the command line, without a browserJeremy Magland 303 params = { ...spec.params };
304 const nextGeom = mGeometryByKey(spec.geometry)!;
1a1e473Add auto-fill: work through the parameter space on an idle machineJeremy Magland 305 if (nextGeom !== geometry) {
306 geometry = nextGeom;
307 elGeometry.value = geometry.key;
308 buildGeomParamControls();
309 }
c2317d0Fill the cache from the command line, without a browserJeremy Magland 310 geomParams = { ...spec.geometryParams };
311 seed = spec.seed;
1a1e473Add auto-fill: work through the parameter space on an idle machineJeremy Magland 312 elSeed.value = String(seed);
1a1e473Add auto-fill: work through the parameter space on an idle machineJeremy Magland 314 elTend.value = String(tEnd);
315 syncSelects();
316 writeUrlState();
481eeb9Add Brusselator and Allen-Cahn modelsJeremy Magland 319function buildModelParamControls(): void {
320 elParams.replaceChildren();
321 for (const choice of MODEL_CHOICES[model.key]) {
323 makeSelect(choice, () => params[choice.key], (v) => (params[choice.key] = v)),
324 );
325 }
328function buildControls(): void {
329 for (const m of mModels) {
330 const opt = document.createElement('option');
331 opt.value = m.key;
332 opt.textContent = m.label;
333 elModel.append(opt);
334 }
335 elModel.value = model.key;
336 elModel.addEventListener('change', () => {
337 model = mModelByKey(elModel.value)!;
338 params = defaultChoiceParams(MODEL_CHOICES[model.key]);
339 buildModelParamControls();
340 onSelectionChange();
341 });
342 buildModelParamControls();
344 const opt = document.createElement('option');
345 opt.value = g.key;
346 opt.textContent = g.label.toLowerCase();
347 elGeometry.append(opt);
348 }
349 elGeometry.value = geometry.key;
350 elGeometry.addEventListener('change', () => {
351 geometry = mGeometryByKey(elGeometry.value)!;
352 geomParams = Object.fromEntries(
353 GEOMETRY_CHOICES[geometry.key].map((c) => [c.key, c.value]),
354 );
355 buildGeomParamControls();
356 onSelectionChange();
357 });
358 buildGeomParamControls();
360 for (const v of SEED_CHOICE.values) {
361 const opt = document.createElement('option');
362 opt.value = String(v);
363 opt.textContent = String(v);
364 elSeed.append(opt);
365 }
366 elSeed.value = String(seed);
367 elSeed.addEventListener('change', () => {
368 seed = Number(elSeed.value);
369 onSelectionChange();
370 });
372 for (const v of T_END_CHOICE.values) {
373 const opt = document.createElement('option');
374 opt.value = String(v);
375 opt.textContent = String(v);
376 elTend.append(opt);
377 }
378 elTend.value = String(tEnd);
379 elTend.addEventListener('change', () => {
380 tEnd = Number(elTend.value);
381 onSelectionChange();
382 });
385function buildGeomParamControls(): void {
386 elGeomParams.replaceChildren();
387 for (const choice of GEOMETRY_CHOICES[geometry.key]) {
388 elGeomParams.append(
389 makeSelect(choice, () => geomParams[choice.key], (v) => (geomParams[choice.key] = v)),
390 );
391 }
394/**
395 * A selection change refreshes the display: a cached solution loads and
396 * shows immediately, an uncached one shows empty surfaces until the user
397 * explicitly presses Compute solution. While a computation is running the
398 * change touches nothing — the run keeps going and only the is-it-cached
399 * note follows the dropdowns.
400 *
401 * Refreshes and button presses are chained so two flows never talk to the
402 * session at once.
403 */
404let flowChain: Promise<void> = Promise.resolve();
405function onSelectionChange(): void {
406 writeUrlState();
407 // During a computation the refresh is deferred until the run finishes; the
408 // is-it-cached note should follow the dropdowns right away regardless.
409 if (busy) void updateCacheNote();
410 flowChain = flowChain.then(() => refresh()).catch(() => undefined);
413// The note carries a token so a slow HEAD for a superseded selection never
414// overwrites the note for the current one.
415let cacheNoteToken = 0;
416async function updateCacheNote(): Promise<void> {
417 const token = ++cacheNoteToken;
418 elCacheNote.textContent = '';
419 let lookup: CacheLookup;
420 try {
421 lookup = await lookupFor(currentSpec());
422 } catch {
423 return;
424 }
795cdccMove the run and the walk out of the pageJeremy Magland 425 const present = await headCached(lookup);
427 setCacheNote(present);
430function setCacheNote(present: boolean | null): void {
431 if (present === true) {
432 elCacheNote.innerHTML = '<b>✓ in the cloud cache</b>';
433 } else if (present === false) {
434 elCacheNote.textContent = 'not cached yet';
435 } else {
436 elCacheNote.textContent = '';
437 }
440// ---------------------------------------------------------------- view
441function disposeView(): void {
442 for (const s of scenes) s.dispose();
443 scenes = [];
444 colorbars = [];
445 colorbarEls = [];
446 topo = null;
447 coords = null;
448 posBuf = null;
449 resizeObs?.disconnect();
450 resizeObs = null;
451 elPanels.replaceChildren();
454function buildView(surface: Float32Array): void {
795cdccMove the run and the walk out of the pageJeremy Magland 455 const session = sess();
457 const view = session.viewSht;
458 const { nphi } = view.cfg;
459 const phi = new Float64Array(nphi);
460 for (let j = 0; j < nphi; j++) phi[j] = (2 * Math.PI * j) / nphi;
461 topo = buildTopology(view.cosTheta, phi);
462 coords = surface;
463 posBuf = new Float32Array(topo.numVertices * 3);
464 fillPositions(posBuf, coords, topo, 1);
466 const sphereBg = getComputedStyle(document.documentElement)
467 .getPropertyValue('--sphere-bg')
468 .trim();
469 for (let k = 0; k < model.species.length; k++) {
470 const panel = document.createElement('div');
471 panel.className = 'panel';
472 const box = document.createElement('div');
473 box.className = 'sphere-box';
474 const tag = document.createElement('div');
475 tag.className = 'species-tag';
476 tag.textContent = model.species[k];
477 box.append(tag);
478 const side = document.createElement('div');
479 panel.append(box, side);
480 elPanels.append(panel);
482 const scene = new SphereScene(
483 box,
484 topo.numVertices,
485 topo.indices,
486 Float32Array.from(posBuf),
487 sphereBg || undefined,
488 );
489 scene.fitCamera();
490 scenes.push(scene);
491 colorbars.push(new Colorbar(side));
492 colorbarEls.push(side);
493 valueBufs[k] = new Float32Array(topo.numVertices);
494 colorBufs[k] = new Float32Array(topo.numVertices * 3);
495 ranges[k] = { lo: NaN, hi: NaN };
496 }
497 for (let k = 1; k < scenes.length; k++) scenes[0].syncCamerasWith(scenes[k]);
499 resizeObs = new ResizeObserver(() => {
500 const boxes = elPanels.querySelectorAll<HTMLElement>('.sphere-box');
501 boxes.forEach((box, i) => {
502 scenes[i]?.resize(box.clientWidth, box.clientHeight);
503 });
504 });
505 elPanels
506 .querySelectorAll<HTMLElement>('.sphere-box')
507 .forEach((box) => resizeObs!.observe(box));
510async function draw(): Promise<void> {
795cdccMove the run and the walk out of the pageJeremy Magland 511 const session = sess();
513 const gen = generation;
514 for (let k = 0; k < model.species.length; k++) {
515 let field: Float32Array;
516 try {
517 field = await session.readSpecies(k);
518 } catch (e) {
519 if (gen !== generation) return;
520 throw e;
521 }
522 if (gen !== generation || !topo) return;
523 fillFieldValues(valueBufs[k], field, topo);
524 let lo = Infinity;
525 let hi = -Infinity;
526 for (const v of valueBufs[k]) {
527 if (v < lo) lo = v;
528 if (v > hi) hi = v;
529 }
530 // Smooth the color range in both directions so the shading evolves gently
531 // as the pattern grows (out-of-range values clamp meanwhile).
532 const r = ranges[k];
533 if (!Number.isFinite(r.lo)) {
534 r.lo = lo;
535 r.hi = hi;
536 } else {
537 const a = 0.15;
538 r.lo += a * (lo - r.lo);
539 r.hi += a * (hi - r.hi);
540 }
541 const shown = floorRange(r.lo, r.hi);
542 fillColors(colorBufs[k], valueBufs[k], shown.lo, shown.hi, COLORMAP);
543 scenes[k]?.updateColors(colorBufs[k]);
544 colorbars[k]?.update(COLORMAP, shown.lo, shown.hi);
545 if (colorbarEls[k]) colorbarEls[k].style.visibility = '';
546 }
549/** Empty windows: the selected surface with no field on it. Shown when the
550 * selection has no cached solution and nothing has been computed yet. */
551function clearDisplay(): void {
552 shownT = null;
553 elDownload.hidden = true;
554 if (!topo) return;
555 for (let k = 0; k < model.species.length; k++) {
556 // NaN renders as neutral gray in fillColors — the shape without a field.
557 valueBufs[k].fill(NaN);
558 fillColors(colorBufs[k], valueBufs[k], 0, 1, COLORMAP);
559 scenes[k]?.updateColors(colorBufs[k]);
560 if (colorbarEls[k]) colorbarEls[k].style.visibility = 'hidden';
561 }
562 updateStats();
565function resetRanges(): void {
566 for (const r of ranges) {
567 r.lo = NaN;
568 r.hi = NaN;
569 }
572function updateStats(): void {
795cdccMove the run and the walk out of the pageJeremy Magland 573 const session = sess();
575 const { nlat, nphi } = session.cfg;
576 const kind = `WebGPU fp32${adapterName ? ` — ${adapterName}` : ''}`;
577 const t = shownT !== null ? ` · showing t = <b>${fmtChoice(shownT)}</b>` : '';
578 elStats.innerHTML =
579 `<b>${kind}</b> · grid ${nlat}×${nphi} · lmax ${LMAX} · ` +
580 `solve iters ${NITER}${t}`;
583// ---------------------------------------------------------------- statuses
584function status(html: string): void {
585 elStatus.innerHTML = html;
588function setBusy(next: boolean): void {
589 busy = next;
590 elSolve.disabled = next;
591 elStop.hidden = !next;
594function offerDownload(bytes: Uint8Array, name: string): void {
595 if (downloadUrl) URL.revokeObjectURL(downloadUrl);
596 downloadUrl = URL.createObjectURL(new Blob([bytes as BlobPart], { type: 'application/x-hdf5' }));
597 elDownload.href = downloadUrl;
598 elDownload.download = name;
599 elDownload.hidden = false;
602// ---------------------------------------------------------------- solving
481eeb9Add Brusselator and Allen-Cahn modelsJeremy Magland 603/** Rebuild the mesh and panels from the session's current surface, keeping
604 * the camera. Fresh buffers render black until the first fill, so the bare
605 * surface is shown; the caller's draw or clearDisplay follows right behind. */
606async function rebuildViewFromSession(): Promise<void> {
795cdccMove the run and the walk out of the pageJeremy Magland 607 const session = sess();
481eeb9Add Brusselator and Allen-Cahn modelsJeremy Magland 608 if (!session) return;
609 const surface = await session.renderPositions();
610 const cam = scenes[0]?.cameraState();
611 disposeView();
612 buildView(surface);
613 if (cam) for (const s of scenes) s.setCameraState(cam);
614 clearDisplay();
617/**
795cdccMove the run and the walk out of the pageJeremy Magland 618 * Apply a selection to the solver. Which changes are cheap and which pay a
619 * recompile is the solver's business (src/cache/solver.ts); the page adds the
620 * compiling status and the rebuilt view through the events it installs in
621 * boot(), since the panel count follows the model's species (Allen–Cahn has
622 * one).
4f822e1turing-surface-cache: reaction-diffusion solutions at a chosen end time, shared through a cloud cacheJeremy Magland 624async function applySelection(spec: CacheSpec): Promise<void> {
795cdccMove the run and the walk out of the pageJeremy Magland 625 if (!solver) throw new Error('no GPU device');
626 await solver.apply(spec);
629/** Decode a fetched cache file and put it on screen. */
630async function displayCached(
631 bytes: Uint8Array,
632 lookup: CacheLookup,
633 spec: CacheSpec,
634 gen: number,
635): Promise<void> {
795cdccMove the run and the walk out of the pageJeremy Magland 636 const session = sess();
638 const decoded = await decodeCacheFile(bytes, lookup.specJson, model.state);
639 if (gen !== generation) return;
640 session.loadState(decoded.final);
641 shownT = spec.tEnd;
642 resetRanges();
643 await draw();
644 updateStats();
645 const kb = (bytes.length / 1024).toFixed(0);
646 const from = decoded.adapter ? `, computed on ${decoded.adapter}` : '';
647 const when = decoded.created ? ` ${decoded.created.slice(0, 10)}` : '';
648 status(
649 `<b>t = ${fmtChoice(spec.tEnd)}</b> — from the <b>cloud cache</b> ` +
650 `(${kb} KB${from}${when}).`,
651 );
652 offerDownload(bytes, lookup.fileName.split('/').pop()!);
655/**
656 * Bring the display in line with the current selection, without ever
657 * starting a computation: a cached solution loads and shows, an uncached one
658 * shows empty surfaces and waits for the Compute solution button. Runs on
659 * startup and on every selection change; a no-op while a computation is
660 * running (the run is not disturbed — only the cache note follows).
661 */
662async function refresh(): Promise<void> {
481eeb9Add Brusselator and Allen-Cahn modelsJeremy Magland 663 // Before the GPU is up there is nothing to refresh; while a computation
664 // runs the note follows the dropdowns and the refresh waits its turn. A
665 // missing session is NOT a reason to bail: applySelection rebuilds it,
666 // which is also what recovers from a failed compile.
667 if (!device || busy) {
669 return;
670 }
671 generation++;
672 const gen = generation;
673 elErr.textContent = '';
674 const spec = currentSpec();
675 try {
676 const lookup = await lookupFor(spec);
677 status('checking the cloud cache…');
678 let bytes: Uint8Array | null = null;
679 let unreachable = false;
680 try {
681 bytes = await fetchCached(lookup);
682 } catch {
683 unreachable = true;
684 }
685 if (gen !== generation) return;
686 await applySelection(spec);
687 if (gen !== generation) return;
688 if (bytes) {
689 await displayCached(bytes, lookup, spec, gen);
690 setCacheNote(true);
691 return;
692 }
693 clearDisplay();
694 setCacheNote(unreachable ? null : false);
695 status(
696 unreachable
697 ? 'cloud cache unreachable — <b>Compute solution</b> runs it in your browser.'
698 : `not in the cloud cache — press <b>Compute solution</b> to run it in ` +
699 `your browser (up to ${stepsFor(spec).toLocaleString()} steps; a ` +
700 `cached shorter run of the same settings is picked up where it left off).`,
701 );
702 } catch (e) {
703 if (gen === generation) {
704 elErr.textContent = formatFailure(e, model.source);
705 status('failed.');
706 }
707 }
710/** The Compute solution button: cache lookup, then either load or compute. */
711async function solve(): Promise<void> {
481eeb9Add Brusselator and Allen-Cahn modelsJeremy Magland 712 if (!device || busy) return;
714 const gen = generation;
715 setBusy(true);
716 // A stopped run may still be inside an await; let it see the generation
717 // bump and finish before touching the session.
718 while (pumping) await nextFrame();
719 if (gen !== generation) return;
720 stopRequested = false;
721 elErr.textContent = '';
722 elDownload.hidden = true;
723 const spec = currentSpec();
724 try {
725 const lookup = await lookupFor(spec);
726 status('checking the cloud cache…');
727 let bytes: Uint8Array | null = null;
728 try {
729 bytes = await fetchCached(lookup);
730 } catch (e) {
731 // An unreachable cache degrades to computing locally, and says so.
732 status(`cache unreachable (${e instanceof Error ? e.message : e}) — computing locally`);
733 }
734 if (gen !== generation) return;
735 await applySelection(spec);
736 if (gen !== generation) return;
738 if (bytes) {
739 await displayCached(bytes, lookup, spec, gen);
740 return;
741 }
742 await computeLocally(spec, gen);
743 } catch (e) {
744 if (gen === generation) {
745 elErr.textContent = formatFailure(e, model.source);
746 status('failed.');
747 }
748 } finally {
749 if (gen === generation) setBusy(false);
750 void updateCacheNote();
751 }
795cdccMove the run and the walk out of the pageJeremy Magland 755 * How the page tells a run in progress: the status line, the live view, and
756 * when to give up. The same events drive the Compute solution button and the
757 * auto-fill walk, so the two report a run identically.
758 *
759 * `gen` is read afresh at every check rather than captured, so the events a
760 * walk installs once still speak for whichever target is current.
795cdccMove the run and the walk out of the pageJeremy Magland 762function runEvents(gen: () => number): RunEvents {
764 let lastDraw = 0;
795cdccMove the run and the walk out of the pageJeremy Magland 765 return {
766 onPhase(phase) {
767 if (phase.kind === 'warm-search') {
768 status('not in the cache — looking for a shorter cached run…');
769 } else if (phase.kind === 'seeding') {
770 status('not in the cache — <b>computing locally</b>: seeding…');
771 } else if (phase.kind === 'encoding') {
772 status(`${doneLine(phase.run)} Writing the cache file…`);
773 } else {
774 status(
775 `${doneLine(phase.run)} Uploading to the cache ` +
776 `(${phase.uploaded}/${phase.started})…`,
777 );
778 }
779 },
780 onProgress(p) {
781 const now = performance.now();
782 if (now - lastStatus < STATUS_EVERY_MS) return;
795cdccMove the run and the walk out of the pageJeremy Magland 784 const from = p.warmFrom !== null ? `resumed from cached t = ${fmtChoice(p.warmFrom)} — ` : '';
785 const up = p.uploadsStarted
786 ? `, uploaded ${p.uploadsDone}/${p.uploadsStarted} snapshots`
788 status(
789 `not in the cache — <b>computing locally</b> (${from}` +
795cdccMove the run and the walk out of the pageJeremy Magland 790 `t = ${p.t.toFixed(2)} / ${fmtChoice(p.tEnd)}, ${(100 * p.fraction).toFixed(0)}%, ` +
791 `${p.rate.toFixed(0)} steps/s${up})`,
794 onStepping() {
795 shownT = null;
796 resetRanges();
797 },
798 async onTick() {
799 // Rendering is skipped entirely while the page is hidden, and the loop
800 // never waits on an animation frame there: a backgrounded tab throttles
801 // or stops requestAnimationFrame, which would stall an unattended run.
802 // The GPU sync inside the run already yields to the event loop, so Stop
803 // stays responsive either way.
804 const now = performance.now();
805 if (document.hidden || now - lastDraw <= RENDER_EVERY_MS) return;
806 lastDraw = now;
807 await draw();
808 if (gen() !== generation) return;
809 await nextFrame();
810 },
811 async onFinal(tEnd) {
812 shownT = tEnd;
813 await draw();
814 updateStats();
815 },
816 onFile(bytes, name) {
817 offerDownload(bytes, name);
818 },
819 cancelled: () => gen() !== generation,
820 stopRequested: () => stopRequested,
821 };
795cdccMove the run and the walk out of the pageJeremy Magland 824/** The first sentence of every finished run's status. */
825function doneLine(run: RunSummary): string {
826 return (
827 `<b>t = ${fmtChoice(run.tEnd)}</b> — computed locally in ${run.seconds.toFixed(1)} s` +
828 (run.warmFrom !== null ? ` (resumed from cached t = ${fmtChoice(run.warmFrom)})` : '') +
829 `.`
830 );
795cdccMove the run and the walk out of the pageJeremy Magland 833/** Say how a finished run ended. Returns nothing; the caller counts. */
834async function reportOutcome(outcome: RunOutcome): Promise<void> {
835 if (outcome.kind === 'abandoned') return;
836 if (outcome.kind === 'diverged') {
837 elErr.textContent =
838 `the solution went non-finite at t = ${outcome.t.toFixed(2)} — nothing uploaded ` +
839 `(this combination is unstable at dt = ${fmtChoice(AUTO_DT)})`;
840 status('diverged.');
841 return;
795cdccMove the run and the walk out of the pageJeremy Magland 843 if (outcome.kind === 'stopped') {
844 shownT = outcome.t;
845 await draw();
846 updateStats();
847 const n = outcome.uploaded.length;
848 const up = n ? ` ${n} snapshot${n > 1 ? 's' : ''} already uploaded.` : ' Nothing uploaded.';
849 status(`stopped at t = ${outcome.t.toFixed(2)}.${up}`);
851 }
795cdccMove the run and the walk out of the pageJeremy Magland 852 const line = doneLine(outcome);
853 if (outcome.uploadsStarted === 0) {
854 status(`${line} Not uploaded (no API key).`);
855 return;
856 }
857 if (outcome.uploadErrors.length) {
858 elErr.textContent = `upload: ${outcome.uploadErrors.join('; ')}`;
859 }
860 const n = outcome.uploaded.length;
795cdccMove the run and the walk out of the pageJeremy Magland 862 const times = [...outcome.uploaded].sort((a, b) => a - b).map(fmtChoice).join(', ');
863 const failed = outcome.uploadErrors.length
864 ? ` (${outcome.uploadErrors.length} failed)`
865 : '';
795cdccMove the run and the walk out of the pageJeremy Magland 867 `${line} <b>Uploaded ${n} solution${n > 1 ? 's' : ''}</b> ` +
869 );
870 } else {
795cdccMove the run and the walk out of the pageJeremy Magland 871 status(`${line} Uploads failed.`);
876 * Run the solver to the spec's end time, watching the pattern form, and
877 * capture the state at every smaller listed end time on the way
878 * (src/cache/runSpec.ts). Everything the page adds is in runEvents and
879 * reportOutcome.
880 */
881async function computeLocally(spec: CacheSpec, gen: number): Promise<RunOutcome> {
882 if (!solver?.session) return { kind: 'abandoned' };
883 pumping = true;
795cdccMove the run and the walk out of the pageJeremy Magland 885 const outcome = await runSpec({
886 solver,
887 spec,
888 adapter: adapterName,
b0546a9Fix the command line's file writing, and how it gets updatedJeremy Magland 889 runtime: 'browser-webgpu',
795cdccMove the run and the walk out of the pageJeremy Magland 890 apiKey: () => elApiKey.value.trim(),
891 events: runEvents(() => gen),
892 });
893 if (gen === generation) await reportOutcome(outcome);
894 return outcome;
895 } finally {
896 pumping = false;
795cdccMove the run and the walk out of the pageJeremy Magland 900// ---------------------------------------------------------------- auto-fill
1a1e473Add auto-fill: work through the parameter space on an idle machineJeremy Magland 901function autoNote(target: AutoTarget | null): void {
902 if (!autoRunning) {
903 elAutoNote.textContent = autoComputed || autoSkipped
904 ? `stopped — computed ${autoComputed}, skipped ${autoSkipped} already cached` +
905 (autoFailed ? `, ${autoFailed} failed` : '')
906 : '';
907 return;
908 }
909 const where = target
910 ? `${mModelByKey(target.model)!.label} on ${target.geometry}, ${target.distance} ` +
911 `knob${target.distance === 1 ? '' : 's'} from the defaults`
912 : '';
913 elAutoNote.textContent =
914 `auto-filling — computed ${autoComputed}, skipped ${autoSkipped}` +
915 (autoFailed ? `, ${autoFailed} failed` : '') + (where ? ` · ${where}` : '');
918function setAutoUi(on: boolean): void {
919 elAuto.textContent = on ? 'Auto-filling…' : 'Auto-fill the cache';
920 elAuto.disabled = on;
921 elReset.disabled = on;
924/**
925 * Walk the parameter space on this machine, computing and contributing
926 * whatever is not cached yet, nearest the defaults first and randomly within
795cdccMove the run and the walk out of the pageJeremy Magland 927 * a distance (src/cache/autoWalk.ts, src/cache/fillWalk.ts). Runs until
928 * stopped.
930 * Every target is driven through the same selection the user would set by
931 * hand, so the dropdowns and the URL always say what is being computed, and
795cdccMove the run and the walk out of the pageJeremy Magland 932 * the run itself is the ordinary local computation — including its background
933 * uploads, its warm start from a shorter cached run, and its divergence
934 * guard.
936async function autoRun(): Promise<void> {
795cdccMove the run and the walk out of the pageJeremy Magland 937 if (!device || !solver || busy || autoRunning) return;
1a1e473Add auto-fill: work through the parameter space on an idle machineJeremy Magland 938 if (!elApiKey.value.trim()) return;
939 autoRunning = true;
940 autoComputed = autoSkipped = autoFailed = 0;
941 setAutoUi(true);
942 setBusy(true);
943 elErr.textContent = '';
944 // Start from a defined point — which is also the first target, since the
945 // defaults are the one combination at distance zero.
946 applyDefaults();
947 autoNote(null);
795cdccMove the run and the walk out of the pageJeremy Magland 949 // The generation of the target being computed, read by the run events.
950 let walkGen = 0;
951 await fillWalk({
952 targets: autoOrder(),
953 solver,
954 adapter: adapterName,
b0546a9Fix the command line's file writing, and how it gets updatedJeremy Magland 955 runtime: 'browser-webgpu',
795cdccMove the run and the walk out of the pageJeremy Magland 956 apiKey: () => elApiKey.value.trim(),
957 beforeTarget(target) {
958 setSelection(target);
959 autoNote(target);
960 generation++;
961 walkGen = generation;
962 stopRequested = false;
963 return currentSpec();
964 },
965 events: {
966 ...runEvents(() => walkGen),
967 onTarget: () => status('checking the cloud cache…'),
968 onCached: (target) => {
970 setCacheNote(true);
795cdccMove the run and the walk out of the pageJeremy Magland 971 autoNote(target);
972 },
973 onComputing: () => setCacheNote(false),
974 onOutcome: (target, _spec, outcome) => {
975 if (outcome.kind === 'done') autoComputed++;
976 else if (outcome.kind === 'diverged') autoFailed++;
977 autoNote(target);
978 },
979 onFailure: (target, spec, e) => {
980 autoFailed++;
981 elErr.textContent =
982 `auto (${spec.model}, ${spec.geometry}): ${formatFailure(e, model.source)}`;
983 autoNote(target);
984 },
985 walkStopped: () => !autoRunning,
986 },
987 });
989 autoRunning = false;
990 setAutoUi(false);
991 setBusy(false);
992 autoNote(null);
4f822e1turing-surface-cache: reaction-diffusion solutions at a chosen end time, shared through a cloud cacheJeremy Magland 995// ---------------------------------------------------------------- boot
1a1e473Add auto-fill: work through the parameter space on an idle machineJeremy Magland 996elAuto.addEventListener('click', () => {
997 flowChain = flowChain.then(() => autoRun()).catch(() => undefined);
998});
1000 flowChain = flowChain.then(() => solve()).catch(() => undefined);
1001});
1002elStop.addEventListener('click', () => {
1003 stopRequested = true;
1006});
1007elReset.addEventListener('click', () => resetDefaults());
1008elResetView.addEventListener('click', () => {
1009 for (const s of scenes) s.resetCamera();
1010});
1a1e473Add auto-fill: work through the parameter space on an idle machineJeremy Magland 1011// The view is not drawn while the page is hidden, so it is stale on return.
1012// Not while a run is reading back: every read shares one staging buffer.
1013document.addEventListener('visibilitychange', () => {
795cdccMove the run and the walk out of the pageJeremy Magland 1014 if (!document.hidden && sess() && !pumping) void draw();
1017 const key = elApiKey.value.trim();
1018 if (key) localStorage.setItem(API_KEY_STORAGE, key);
1019 else localStorage.removeItem(API_KEY_STORAGE);
1020 updateUploadNote();
1021});
1023function updateUploadNote(): void {
1a1e473Add auto-fill: work through the parameter space on an idle machineJeremy Magland 1024 const hasKey = elApiKey.value.trim().length > 0;
1025 elUploadNote.textContent = hasKey
4f822e1turing-surface-cache: reaction-diffusion solutions at a chosen end time, shared through a cloud cacheJeremy Magland 1026 ? 'uploads enabled — locally computed solutions will be contributed'
1027 : '';
b32cc02Offer the fill command in the page, ready to copyJeremy Magland 1028 // Auto-fill exists to contribute, so it is offered only to those who can,
1029 // and so is the command that does the same thing elsewhere.
1a1e473Add auto-fill: work through the parameter space on an idle machineJeremy Magland 1030 elAutoBar.hidden = !hasKey;
b32cc02Offer the fill command in the page, ready to copyJeremy Magland 1031 elCliBar.hidden = !hasKey;
d71391eFold the troubleshooting into the page, beside the commandJeremy Magland 1032 elCliHelp.hidden = !hasKey;
b32cc02Offer the fill command in the page, ready to copyJeremy Magland 1033 elCliCmd.textContent = fillCommand('…');
1034 elCliCopied.textContent = '';
1a1e473Add auto-fill: work through the parameter space on an idle machineJeremy Magland 1035 if (!hasKey && autoRunning) autoRunning = false;
1039 * The command that runs this same walk outside a browser. The tarball is
1040 * deployed beside the page, so the URL is derived from this one and a preview
1041 * deployment hands out its own command rather than main's.
1043 * The key travels in the environment rather than in an option because argv is
1044 * visible to every user on the machine through `ps`, while another process's
1045 * environment is not. It is masked on screen and real in the clipboard: the
1046 * displayed command would otherwise put the key in any screenshot of a page
1047 * that has one, which is what the password field exists to prevent.
1048 */
1049function fillCommand(key: string): string {
b0546a9Fix the command line's file writing, and how it gets updatedJeremy Magland 1050 // The build id is not decoration: npx keys its install directory on the whole
1051 // spec string, so a URL that never changes keeps running whatever it first
1052 // installed. This one changes with every deployment.
1053 const url = new URL(`fill.tgz?v=${__BUILD_ID__}`, location.href).href;
b32cc02Offer the fill command in the page, ready to copyJeremy Magland 1054 return `TURING_SURFACE_CACHE_KEY=${key} npx ${url}`;
1057elCliCopy.addEventListener('click', () => {
1058 const key = elApiKey.value.trim();
1059 if (!key) return;
1060 navigator.clipboard.writeText(fillCommand(key)).then(
1061 () => {
1062 elCliCopied.textContent = 'copied';
1063 setTimeout(() => (elCliCopied.textContent = ''), 4000);
1064 },
1065 () => {
1066 // No clipboard (an insecure origin, usually). Copying was the intent, so
1067 // show the whole thing and let it be selected by hand.
1068 elCliCmd.textContent = fillCommand(key);
1069 elCliCopied.textContent = 'clipboard unavailable — the key is now shown above';
1070 },
1071 );
1072});
1075 buildControls();
1076 // Written even before any change, so the address bar is always shareable.
1077 writeUrlState();
1078 elApiKey.value = localStorage.getItem(API_KEY_STORAGE) ?? '';
1079 updateUploadNote();
1080 void updateCacheNote();
1081 try {
1082 device = await requestShtDevice();
795cdccMove the run and the walk out of the pageJeremy Magland 1083 // Before the adapter is even described, so a selection change during boot
1084 // finds a solver to apply itself to rather than an error.
1085 solver = new SolverSession(device, OVERSAMPLE, {
1086 onCompiling: (m) => status(`compiling ${m.label}…`),
1087 onSurface: () => rebuildViewFromSession(),
1088 });
1090 } catch (e) {
1091 device = null;
795cdccMove the run and the walk out of the pageJeremy Magland 1092 solver = null;
1094 `WebGPU is not available (${e instanceof Error ? e.message : e}). ` +
1095 `Use a WebGPU-capable browser such as Chrome or Edge.`;
1096 return;
1098 device.lost.then((info) => {
1099 if (info.reason !== 'destroyed') {
1100 elErr.textContent = `WebGPU device lost: ${info.message}`;
1102 });
1104 try {
795cdccMove the run and the walk out of the pageJeremy Magland 1105 await applySelection(currentSpec());
1107 elErr.textContent = formatFailure(e, model.source);
1108 status('failed to compile.');
1109 return;
1111 // Bring up the default selection if it is cached; otherwise show empty
1112 // surfaces. Nothing is ever computed without pressing the button.
1113 flowChain = flowChain.then(() => refresh()).catch(() => undefined);
1114 await flowChain;
1117void boot();