/ concept-collection / turing-surface-cache
Sign in
concept-collection / turing-surface-cache
turing-surface-cache / src / main.ts
1057 lines · 36.5 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';
21import { 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,
48 LAM3,
49 defaultChoiceParams,
50 fmtChoice,
51 type DiscreteChoice,
52} from './cache/options.ts';
53import { stepsFor, type CacheSpec, APP_NAME, FORMAT_VERSION } from './cache/spec.ts';
54import { lookupFor, fetchCached, uploadCacheFile, type CacheLookup } from './cache/client.ts';
55import { encodeCacheFile, decodeCacheFile, type DecodedCacheFile } from './cache/h5file.ts';
57const $ = <T extends HTMLElement>(id: string): T =>
58 document.getElementById(id) as T;
481eeb9Add Brusselator and Allen-Cahn modelsJeremy Magland 60const elModel = $<HTMLSelectElement>('model');
62const elGeometry = $<HTMLSelectElement>('geometry');
63const elGeomParams = $('geomparams');
64const elSeed = $<HTMLSelectElement>('seed');
65const elTend = $<HTMLSelectElement>('tend');
66const elSolve = $<HTMLButtonElement>('solve');
67const elStop = $<HTMLButtonElement>('stop');
68const elReset = $<HTMLButtonElement>('reset');
69const elCacheNote = $('cachenote');
70const elStatus = $('status');
71const elPanels = $('panels');
72const elResetView = $<HTMLButtonElement>('resetview');
73const elDownload = $<HTMLAnchorElement>('download');
74const elStats = $('stats');
75const elApiKey = $<HTMLInputElement>('apikey');
76const elUploadNote = $('uploadnote');
77const elErr = $('err');
79/**
80 * Test/debug hook: `?tend=5,10` replaces the end-time list with the given
81 * values (still cached under their own honest specs — a test end time hashes
82 * to its own object). The headless checks use this to keep their computed
83 * runs short; it is not part of the normal UI.
84 */
86 const param = new URLSearchParams(location.search).get('tend');
87 if (param) {
88 const values = param
89 .split(',')
90 .map(Number)
91 .filter((v) => Number.isFinite(v) && v > 0);
92 if (values.length) {
93 T_END_CHOICE.values = values;
94 T_END_CHOICE.value = values[0];
95 }
96 }
99const API_KEY_STORAGE = `${APP_NAME}:apiKey`;
100const COLORMAP = colormaps.viridis;
101/** Render on a 2x finer grid than the solver's; exact interpolation. */
102const OVERSAMPLE = 2;
103/** Cap on GPU dispatches per submission (watchdog safety; see turing-surface). */
104const DISPATCH_BUDGET = 1000;
105/** Steps between syncs during a computation: many small submissions queued
106 * back to back, one wait. The readbacks and renders that pace the live view
107 * happen per chunk, not per submission — that is what lets the run advance
108 * at close to the solver's own rate. */
109const CHUNK_STEPS = 32;
110/** How often the live view renders during a computation. */
111const RENDER_EVERY_MS = 250;
113// ---------------------------------------------------------------- state
481eeb9Add Brusselator and Allen-Cahn modelsJeremy Magland 114let model: MModel = mModelByKey(DEFAULT_MODEL_KEY)!;
116let session: ModelSession | null = null;
117let adapterName = '';
118/** Steps per GPU submission, sized in boot() so one submission stays under
119 * the dispatch budget however expensive niter has made a step. */
120let stepsPerSubmit = 4;
122/** The discrete selections, always exactly values from options.ts. */
481eeb9Add Brusselator and Allen-Cahn modelsJeremy Magland 123let 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 124let geometry: MGeometry = mGeometryByKey(DEFAULT_GEOMETRY_KEY)!;
125let geomParams: Params = Object.fromEntries(
126 GEOMETRY_CHOICES[DEFAULT_GEOMETRY_KEY].map((c) => [c.key, c.value]),
127);
128let seed = SEED_CHOICE.value;
129let tEnd = T_END_CHOICE.value;
131// The URL fragment carries the whole selection, so a reload comes back to it
132// and a shared link opens on the same spec (and, through refresh(), the same
133// cached solution). Read once at startup; rewritten on every change.
134readUrlState();
481eeb9Add Brusselator and Allen-Cahn modelsJeremy Magland 136/** What the session currently has applied. Params are cheap (uniforms); a
137 * geometry change re-evaluates the surface and rebuilds the mesh; a model
138 * change recompiles the whole session, since the model is compiled into the
139 * GPU step. */
140let sessionModelKey = '';
142let sessionGeomParams: Params = {};
144let topo: SphereMeshTopology | null = null;
145let scenes: SphereScene[] = [];
146let colorbars: Colorbar[] = [];
147/** The colorbar containers, hidden while the windows are empty. */
148let colorbarEls: HTMLElement[] = [];
149let valueBufs: Float32Array[] = [];
150let colorBufs: Float32Array[] = [];
151let ranges: { lo: number; hi: number }[] = [];
152let resizeObs: ResizeObserver | null = null;
153let coords: Float32Array | null = null;
154let posBuf: Float32Array | null = null;
156let generation = 0;
157let busy = false;
158/** True while computeLocally is stepping/reading back. Every read shares one
159 * staging buffer (GpuModel#readback), so a new solve must drain the old
160 * loop before issuing reads of its own. */
161let pumping = false;
162let stopRequested = false;
163/** Simulation time of the state on display (loadState resets session.t). */
164let shownT: number | null = null;
165let downloadUrl: string | null = null;
167const nextFrame = () => new Promise<number>(requestAnimationFrame);
169// ---------------------------------------------------------------- spec
170function currentSpec(): CacheSpec {
171 return {
172 app: APP_NAME,
173 formatVersion: FORMAT_VERSION,
174 model: model.key,
175 params: { ...params },
176 geometry: geometry.key,
177 geometryParams: { ...geomParams },
178 lmax: LMAX,
179 niter: NITER,
180 lam3: LAM3,
181 seed,
182 tEnd,
183 };
186// ---------------------------------------------------------------- URL state
187/**
188 * The selection lives in the URL fragment, every value written explicitly
189 * (`#a=0.1&b=0.9&…&geometry=ellipsoid&ax=1.5&…&seed=1&tend=100`), so a link
190 * keeps meaning the same spec even if a default changes later. The fragment
191 * is chosen over the query string to leave `?tend` to the test hook. Values
192 * are only accepted if they are exactly entries of the discrete lists;
193 * anything else keeps the default.
194 */
195function readUrlState(): void {
196 const hash = location.hash.replace(/^#/, '');
197 if (!hash) return;
198 const p = new URLSearchParams(hash);
199 // `name` is the key as it appears in the URL; it defaults to the choice's
200 // own key but is passed explicitly where the two differ (tEnd vs tend).
201 const pick = (choice: DiscreteChoice, current: number, name = choice.key): number => {
202 const raw = p.get(name);
203 if (raw === null) return current;
204 const v = Number(raw);
205 return choice.values.includes(v) ? v : current;
206 };
481eeb9Add Brusselator and Allen-Cahn modelsJeremy Magland 207 const m = p.get('model');
208 if (m && mModelByKey(m) && MODEL_CHOICES[m]) {
209 model = mModelByKey(m)!;
210 params = defaultChoiceParams(MODEL_CHOICES[m]);
211 }
213 if (g && mGeometryByKey(g) && GEOMETRY_CHOICES[g]) {
214 geometry = mGeometryByKey(g)!;
215 geomParams = defaultChoiceParams(GEOMETRY_CHOICES[g]);
216 }
481eeb9Add Brusselator and Allen-Cahn modelsJeremy Magland 217 for (const c of MODEL_CHOICES[model.key]) params[c.key] = pick(c, params[c.key]);
4f822e1turing-surface-cache: reaction-diffusion solutions at a chosen end time, shared through a cloud cacheJeremy Magland 218 for (const c of GEOMETRY_CHOICES[geometry.key]) geomParams[c.key] = pick(c, geomParams[c.key]);
219 seed = pick(SEED_CHOICE, seed);
220 tEnd = pick(T_END_CHOICE, tEnd, 'tend');
223function writeUrlState(): void {
224 const p = new URLSearchParams();
481eeb9Add Brusselator and Allen-Cahn modelsJeremy Magland 225 p.set('model', model.key);
226 for (const c of MODEL_CHOICES[model.key]) p.set(c.key, fmtChoice(params[c.key]));
228 for (const c of GEOMETRY_CHOICES[geometry.key]) p.set(c.key, fmtChoice(geomParams[c.key]));
229 p.set('seed', String(seed));
230 p.set('tend', fmtChoice(tEnd));
231 history.replaceState(null, '', `${location.pathname}${location.search}#${p.toString()}`);
234// ---------------------------------------------------------------- controls
235/** Every select made by makeSelect, so a reset can push new values into the
236 * ones still on the page. */
237const boundSelects: { el: HTMLSelectElement; get: () => number }[] = [];
239function syncSelects(): void {
240 for (const b of boundSelects) {
241 if (b.el.isConnected) b.el.value = String(b.get());
242 }
245function makeSelect(
246 choice: DiscreteChoice,
247 get: () => number,
248 set: (v: number) => void,
249): HTMLLabelElement {
250 const label = document.createElement('label');
251 label.textContent = `${choice.label} `;
252 const select = document.createElement('select');
253 for (const v of choice.values) {
254 const opt = document.createElement('option');
255 opt.value = String(v);
256 opt.textContent = fmtChoice(v);
257 select.append(opt);
258 }
259 select.value = String(get());
260 select.addEventListener('change', () => {
261 set(Number(select.value));
262 onSelectionChange();
263 });
264 label.append(select);
265 boundSelects.push({ el: select, get });
266 return label;
269/** Put every selection back to its default and refresh. */
270function resetDefaults(): void {
481eeb9Add Brusselator and Allen-Cahn modelsJeremy Magland 271 model = mModelByKey(DEFAULT_MODEL_KEY)!;
272 params = defaultChoiceParams(MODEL_CHOICES[DEFAULT_MODEL_KEY]);
4f822e1turing-surface-cache: reaction-diffusion solutions at a chosen end time, shared through a cloud cacheJeremy Magland 273 geometry = mGeometryByKey(DEFAULT_GEOMETRY_KEY)!;
481eeb9Add Brusselator and Allen-Cahn modelsJeremy Magland 274 elModel.value = model.key;
275 buildModelParamControls();
4f822e1turing-surface-cache: reaction-diffusion solutions at a chosen end time, shared through a cloud cacheJeremy Magland 276 geomParams = defaultChoiceParams(GEOMETRY_CHOICES[DEFAULT_GEOMETRY_KEY]);
277 seed = SEED_CHOICE.value;
278 tEnd = T_END_CHOICE.value;
279 elGeometry.value = geometry.key;
280 buildGeomParamControls();
281 elSeed.value = String(seed);
282 elTend.value = String(tEnd);
283 syncSelects();
284 onSelectionChange();
481eeb9Add Brusselator and Allen-Cahn modelsJeremy Magland 287function buildModelParamControls(): void {
288 elParams.replaceChildren();
289 for (const choice of MODEL_CHOICES[model.key]) {
291 makeSelect(choice, () => params[choice.key], (v) => (params[choice.key] = v)),
292 );
293 }
296function buildControls(): void {
297 for (const m of mModels) {
298 const opt = document.createElement('option');
299 opt.value = m.key;
300 opt.textContent = m.label;
301 elModel.append(opt);
302 }
303 elModel.value = model.key;
304 elModel.addEventListener('change', () => {
305 model = mModelByKey(elModel.value)!;
306 params = defaultChoiceParams(MODEL_CHOICES[model.key]);
307 buildModelParamControls();
308 onSelectionChange();
309 });
310 buildModelParamControls();
312 const opt = document.createElement('option');
313 opt.value = g.key;
314 opt.textContent = g.label.toLowerCase();
315 elGeometry.append(opt);
316 }
317 elGeometry.value = geometry.key;
318 elGeometry.addEventListener('change', () => {
319 geometry = mGeometryByKey(elGeometry.value)!;
320 geomParams = Object.fromEntries(
321 GEOMETRY_CHOICES[geometry.key].map((c) => [c.key, c.value]),
322 );
323 buildGeomParamControls();
324 onSelectionChange();
325 });
326 buildGeomParamControls();
328 for (const v of SEED_CHOICE.values) {
329 const opt = document.createElement('option');
330 opt.value = String(v);
331 opt.textContent = String(v);
332 elSeed.append(opt);
333 }
334 elSeed.value = String(seed);
335 elSeed.addEventListener('change', () => {
336 seed = Number(elSeed.value);
337 onSelectionChange();
338 });
340 for (const v of T_END_CHOICE.values) {
341 const opt = document.createElement('option');
342 opt.value = String(v);
343 opt.textContent = String(v);
344 elTend.append(opt);
345 }
346 elTend.value = String(tEnd);
347 elTend.addEventListener('change', () => {
348 tEnd = Number(elTend.value);
349 onSelectionChange();
350 });
353function buildGeomParamControls(): void {
354 elGeomParams.replaceChildren();
355 for (const choice of GEOMETRY_CHOICES[geometry.key]) {
356 elGeomParams.append(
357 makeSelect(choice, () => geomParams[choice.key], (v) => (geomParams[choice.key] = v)),
358 );
359 }
362/**
363 * A selection change refreshes the display: a cached solution loads and
364 * shows immediately, an uncached one shows empty surfaces until the user
365 * explicitly presses Compute solution. While a computation is running the
366 * change touches nothing — the run keeps going and only the is-it-cached
367 * note follows the dropdowns.
368 *
369 * Refreshes and button presses are chained so two flows never talk to the
370 * session at once.
371 */
372let flowChain: Promise<void> = Promise.resolve();
373function onSelectionChange(): void {
374 writeUrlState();
375 // During a computation the refresh is deferred until the run finishes; the
376 // is-it-cached note should follow the dropdowns right away regardless.
377 if (busy) void updateCacheNote();
378 flowChain = flowChain.then(() => refresh()).catch(() => undefined);
381// The note carries a token so a slow HEAD for a superseded selection never
382// overwrites the note for the current one.
383let cacheNoteToken = 0;
384async function updateCacheNote(): Promise<void> {
385 const token = ++cacheNoteToken;
386 elCacheNote.textContent = '';
387 let lookup: CacheLookup;
388 try {
389 lookup = await lookupFor(currentSpec());
390 } catch {
391 return;
392 }
393 let present: boolean | null = null;
394 try {
395 const res = await fetch(lookup.url, { method: 'HEAD', cache: 'no-store' });
396 present = res.ok ? true : res.status === 404 ? false : null;
397 } catch {
398 present = null;
399 }
400 if (token !== cacheNoteToken) return;
401 setCacheNote(present);
404function setCacheNote(present: boolean | null): void {
405 if (present === true) {
406 elCacheNote.innerHTML = '<b>✓ in the cloud cache</b>';
407 } else if (present === false) {
408 elCacheNote.textContent = 'not cached yet';
409 } else {
410 elCacheNote.textContent = '';
411 }
414// ---------------------------------------------------------------- view
415function disposeView(): void {
416 for (const s of scenes) s.dispose();
417 scenes = [];
418 colorbars = [];
419 colorbarEls = [];
420 topo = null;
421 coords = null;
422 posBuf = null;
423 resizeObs?.disconnect();
424 resizeObs = null;
425 elPanels.replaceChildren();
428function buildView(surface: Float32Array): void {
429 if (!session) return;
430 const view = session.viewSht;
431 const { nphi } = view.cfg;
432 const phi = new Float64Array(nphi);
433 for (let j = 0; j < nphi; j++) phi[j] = (2 * Math.PI * j) / nphi;
434 topo = buildTopology(view.cosTheta, phi);
435 coords = surface;
436 posBuf = new Float32Array(topo.numVertices * 3);
437 fillPositions(posBuf, coords, topo, 1);
439 const sphereBg = getComputedStyle(document.documentElement)
440 .getPropertyValue('--sphere-bg')
441 .trim();
442 for (let k = 0; k < model.species.length; k++) {
443 const panel = document.createElement('div');
444 panel.className = 'panel';
445 const box = document.createElement('div');
446 box.className = 'sphere-box';
447 const tag = document.createElement('div');
448 tag.className = 'species-tag';
449 tag.textContent = model.species[k];
450 box.append(tag);
451 const side = document.createElement('div');
452 panel.append(box, side);
453 elPanels.append(panel);
455 const scene = new SphereScene(
456 box,
457 topo.numVertices,
458 topo.indices,
459 Float32Array.from(posBuf),
460 sphereBg || undefined,
461 );
462 scene.fitCamera();
463 scenes.push(scene);
464 colorbars.push(new Colorbar(side));
465 colorbarEls.push(side);
466 valueBufs[k] = new Float32Array(topo.numVertices);
467 colorBufs[k] = new Float32Array(topo.numVertices * 3);
468 ranges[k] = { lo: NaN, hi: NaN };
469 }
470 for (let k = 1; k < scenes.length; k++) scenes[0].syncCamerasWith(scenes[k]);
472 resizeObs = new ResizeObserver(() => {
473 const boxes = elPanels.querySelectorAll<HTMLElement>('.sphere-box');
474 boxes.forEach((box, i) => {
475 scenes[i]?.resize(box.clientWidth, box.clientHeight);
476 });
477 });
478 elPanels
479 .querySelectorAll<HTMLElement>('.sphere-box')
480 .forEach((box) => resizeObs!.observe(box));
483async function draw(): Promise<void> {
484 if (!session || !topo) return;
485 const gen = generation;
486 for (let k = 0; k < model.species.length; k++) {
487 let field: Float32Array;
488 try {
489 field = await session.readSpecies(k);
490 } catch (e) {
491 if (gen !== generation) return;
492 throw e;
493 }
494 if (gen !== generation || !topo) return;
495 fillFieldValues(valueBufs[k], field, topo);
496 let lo = Infinity;
497 let hi = -Infinity;
498 for (const v of valueBufs[k]) {
499 if (v < lo) lo = v;
500 if (v > hi) hi = v;
501 }
502 // Smooth the color range in both directions so the shading evolves gently
503 // as the pattern grows (out-of-range values clamp meanwhile).
504 const r = ranges[k];
505 if (!Number.isFinite(r.lo)) {
506 r.lo = lo;
507 r.hi = hi;
508 } else {
509 const a = 0.15;
510 r.lo += a * (lo - r.lo);
511 r.hi += a * (hi - r.hi);
512 }
513 const shown = floorRange(r.lo, r.hi);
514 fillColors(colorBufs[k], valueBufs[k], shown.lo, shown.hi, COLORMAP);
515 scenes[k]?.updateColors(colorBufs[k]);
516 colorbars[k]?.update(COLORMAP, shown.lo, shown.hi);
517 if (colorbarEls[k]) colorbarEls[k].style.visibility = '';
518 }
521/** Empty windows: the selected surface with no field on it. Shown when the
522 * selection has no cached solution and nothing has been computed yet. */
523function clearDisplay(): void {
524 shownT = null;
525 elDownload.hidden = true;
526 if (!topo) return;
527 for (let k = 0; k < model.species.length; k++) {
528 // NaN renders as neutral gray in fillColors — the shape without a field.
529 valueBufs[k].fill(NaN);
530 fillColors(colorBufs[k], valueBufs[k], 0, 1, COLORMAP);
531 scenes[k]?.updateColors(colorBufs[k]);
532 if (colorbarEls[k]) colorbarEls[k].style.visibility = 'hidden';
533 }
534 updateStats();
537function resetRanges(): void {
538 for (const r of ranges) {
539 r.lo = NaN;
540 r.hi = NaN;
541 }
544function updateStats(): void {
545 if (!session) return;
546 const { nlat, nphi } = session.cfg;
547 const kind = `WebGPU fp32${adapterName ? ` — ${adapterName}` : ''}`;
548 const t = shownT !== null ? ` · showing t = <b>${fmtChoice(shownT)}</b>` : '';
549 elStats.innerHTML =
550 `<b>${kind}</b> · grid ${nlat}×${nphi} · lmax ${LMAX} · ` +
551 `solve iters ${NITER}${t}`;
554// ---------------------------------------------------------------- statuses
555function status(html: string): void {
556 elStatus.innerHTML = html;
559function setBusy(next: boolean): void {
560 busy = next;
561 elSolve.disabled = next;
562 elStop.hidden = !next;
565function offerDownload(bytes: Uint8Array, name: string): void {
566 if (downloadUrl) URL.revokeObjectURL(downloadUrl);
567 downloadUrl = URL.createObjectURL(new Blob([bytes as BlobPart], { type: 'application/x-hdf5' }));
568 elDownload.href = downloadUrl;
569 elDownload.download = name;
570 elDownload.hidden = false;
573// ---------------------------------------------------------------- solving
481eeb9Add Brusselator and Allen-Cahn modelsJeremy Magland 574/** Rebuild the mesh and panels from the session's current surface, keeping
575 * the camera. Fresh buffers render black until the first fill, so the bare
576 * surface is shown; the caller's draw or clearDisplay follows right behind. */
577async function rebuildViewFromSession(): Promise<void> {
578 if (!session) return;
579 const surface = await session.renderPositions();
580 const cam = scenes[0]?.cameraState();
581 disposeView();
582 buildView(surface);
583 if (cam) for (const s of scenes) s.setCameraState(cam);
584 clearDisplay();
587/**
588 * Compile a full session for the spec's model. The model is the one
589 * selection that cannot be swapped into a running session — its step is
590 * compiled into the GPU pipelines — so changing it pays a recompile
591 * (a second or two on a real GPU). The panel count follows the model's
592 * species (Allen–Cahn has one), so the view is rebuilt too.
593 */
594async function rebuildSession(spec: CacheSpec): Promise<void> {
595 if (!device) throw new Error('no GPU device');
596 const nextModel = mModelByKey(spec.model)!;
597 const geomModel = mGeometryByKey(spec.geometry)!;
598 session?.destroy();
599 session = null;
600 sessionModelKey = '';
601 status(`compiling ${nextModel.label}…`);
602 session = await ModelSession.create({
603 device,
604 model: nextModel,
605 params: spec.params,
606 lmax: spec.lmax,
607 oversample: OVERSAMPLE,
608 geometry: geomModel,
609 geometryParams: spec.geometryParams,
610 niter: spec.niter,
611 lam3: spec.lam3,
612 });
613 model = nextModel;
614 sessionModelKey = spec.model;
615 sessionGeomKey = spec.geometry;
616 sessionGeomParams = { ...spec.geometryParams };
617 // Never put more dispatches in one submission than the budget allows,
618 // however expensive this model's step is.
619 const opsPerStep = Math.max(1, session.describe().step.length);
620 stepsPerSubmit = Math.max(1, Math.floor(DISPATCH_BUDGET / opsPerStep));
621 await rebuildViewFromSession();
622 updateStats();
625/** Apply the current selection to the session: params are a uniform upload;
626 * a geometry change re-evaluates the surface and rebuilds the mesh; a model
627 * change recompiles the session entirely. */
4f822e1turing-surface-cache: reaction-diffusion solutions at a chosen end time, shared through a cloud cacheJeremy Magland 628async function applySelection(spec: CacheSpec): Promise<void> {
481eeb9Add Brusselator and Allen-Cahn modelsJeremy Magland 629 if (!session || spec.model !== sessionModelKey) {
630 await rebuildSession(spec);
631 return;
632 }
634 const geomChanged =
635 spec.geometry !== sessionGeomKey ||
636 JSON.stringify(spec.geometryParams) !== JSON.stringify(sessionGeomParams);
637 if (!geomChanged) return;
638 const geomModel = mGeometryByKey(spec.geometry)!;
639 await session.setGeometry(geomModel, spec.geometryParams);
640 sessionGeomKey = spec.geometry;
641 sessionGeomParams = { ...spec.geometryParams };
481eeb9Add Brusselator and Allen-Cahn modelsJeremy Magland 642 await rebuildViewFromSession();
645/** Decode a fetched cache file and put it on screen. */
646async function displayCached(
647 bytes: Uint8Array,
648 lookup: CacheLookup,
649 spec: CacheSpec,
650 gen: number,
651): Promise<void> {
652 if (!session) return;
653 const decoded = await decodeCacheFile(bytes, lookup.specJson, model.state);
654 if (gen !== generation) return;
655 session.loadState(decoded.final);
656 shownT = spec.tEnd;
657 resetRanges();
658 await draw();
659 updateStats();
660 const kb = (bytes.length / 1024).toFixed(0);
661 const from = decoded.adapter ? `, computed on ${decoded.adapter}` : '';
662 const when = decoded.created ? ` ${decoded.created.slice(0, 10)}` : '';
663 status(
664 `<b>t = ${fmtChoice(spec.tEnd)}</b> — from the <b>cloud cache</b> ` +
665 `(${kb} KB${from}${when}).`,
666 );
667 offerDownload(bytes, lookup.fileName.split('/').pop()!);
670/**
671 * Bring the display in line with the current selection, without ever
672 * starting a computation: a cached solution loads and shows, an uncached one
673 * shows empty surfaces and waits for the Compute solution button. Runs on
674 * startup and on every selection change; a no-op while a computation is
675 * running (the run is not disturbed — only the cache note follows).
676 */
677async function refresh(): Promise<void> {
481eeb9Add Brusselator and Allen-Cahn modelsJeremy Magland 678 // Before the GPU is up there is nothing to refresh; while a computation
679 // runs the note follows the dropdowns and the refresh waits its turn. A
680 // missing session is NOT a reason to bail: applySelection rebuilds it,
681 // which is also what recovers from a failed compile.
682 if (!device || busy) {
684 return;
685 }
686 generation++;
687 const gen = generation;
688 elErr.textContent = '';
689 const spec = currentSpec();
690 try {
691 const lookup = await lookupFor(spec);
692 status('checking the cloud cache…');
693 let bytes: Uint8Array | null = null;
694 let unreachable = false;
695 try {
696 bytes = await fetchCached(lookup);
697 } catch {
698 unreachable = true;
699 }
700 if (gen !== generation) return;
701 await applySelection(spec);
702 if (gen !== generation) return;
703 if (bytes) {
704 await displayCached(bytes, lookup, spec, gen);
705 setCacheNote(true);
706 return;
707 }
708 clearDisplay();
709 setCacheNote(unreachable ? null : false);
710 status(
711 unreachable
712 ? 'cloud cache unreachable — <b>Compute solution</b> runs it in your browser.'
713 : `not in the cloud cache — press <b>Compute solution</b> to run it in ` +
714 `your browser (up to ${stepsFor(spec).toLocaleString()} steps; a ` +
715 `cached shorter run of the same settings is picked up where it left off).`,
716 );
717 } catch (e) {
718 if (gen === generation) {
719 elErr.textContent = formatFailure(e, model.source);
720 status('failed.');
721 }
722 }
725/** The Compute solution button: cache lookup, then either load or compute. */
726async function solve(): Promise<void> {
481eeb9Add Brusselator and Allen-Cahn modelsJeremy Magland 727 if (!device || busy) return;
729 const gen = generation;
730 setBusy(true);
731 // A stopped run may still be inside an await; let it see the generation
732 // bump and finish before touching the session.
733 while (pumping) await nextFrame();
734 if (gen !== generation) return;
735 stopRequested = false;
736 elErr.textContent = '';
737 elDownload.hidden = true;
738 const spec = currentSpec();
739 try {
740 const lookup = await lookupFor(spec);
741 status('checking the cloud cache…');
742 let bytes: Uint8Array | null = null;
743 try {
744 bytes = await fetchCached(lookup);
745 } catch (e) {
746 // An unreachable cache degrades to computing locally, and says so.
747 status(`cache unreachable (${e instanceof Error ? e.message : e}) — computing locally`);
748 }
749 if (gen !== generation) return;
750 await applySelection(spec);
751 if (gen !== generation) return;
753 if (bytes) {
754 await displayCached(bytes, lookup, spec, gen);
755 return;
756 }
757 await computeLocally(spec, gen);
758 } catch (e) {
759 if (gen === generation) {
760 elErr.textContent = formatFailure(e, model.source);
761 status('failed.');
762 }
763 } finally {
764 if (gen === generation) setBusy(false);
765 void updateCacheNote();
766 }
769/** Run the solver to the spec's end time, watching the pattern form, and
770 * capture the state at every smaller listed end time on the way. */
771async function computeLocally(spec: CacheSpec, gen: number): Promise<void> {
772 if (!session) return;
773 pumping = true;
774 try {
775 await computeLocallyInner(spec, gen);
776 } finally {
777 pumping = false;
778 }
781async function computeLocallyInner(spec: CacheSpec, gen: number): Promise<void> {
782 if (!session) return;
783 const steps = stepsFor(spec);
784 const dt = spec.params.dt;
786 // Warm start: the state is Markovian in (U, V), so a cached run of the
787 // same spec at a smaller listed end time is an exact prefix of this one.
788 // Take the longest one there is and continue from its final state rather
789 // than recomputing it.
790 let warm: { tEnd: number; decoded: DecodedCacheFile } | null = null;
791 const earlier = T_END_CHOICE.values.filter((T) => T < spec.tEnd).sort((a, b) => b - a);
792 if (earlier.length) status('not in the cache — looking for a shorter cached run…');
793 for (const T of earlier) {
794 const lookup = await lookupFor({ ...spec, tEnd: T });
795 let bytes: Uint8Array | null = null;
796 try {
797 bytes = await fetchCached(lookup);
798 } catch {
799 break; // cache unreachable: no point probing further down the ladder
800 }
801 if (gen !== generation) return;
802 if (!bytes) continue;
803 try {
804 warm = { tEnd: T, decoded: await decodeCacheFile(bytes, lookup.specJson, model.state) };
805 break;
806 } catch {
807 continue; // an unreadable candidate is skipped, not fatal
808 }
809 }
810 if (gen !== generation) return;
812 let initial: Record<string, Float32Array>;
813 if (warm) {
814 session.loadState(warm.decoded.final);
815 // loadState resets the clock; put it at the cached run's end so the loop
816 // below computes only the remainder.
817 session.steps = Math.round(warm.tEnd / dt);
818 session.t = warm.tEnd;
819 // The t = 0 state travels with every file of the chain, so files written
820 // from this continuation carry the same initial state as the one resumed.
821 initial = warm.decoded.initial;
822 } else {
823 status(`not in the cache — <b>computing locally</b>: seeding…`);
824 await session.seed(spec.seed);
825 if (gen !== generation) return;
826 initial = await session.readState();
827 if (gen !== generation) return;
828 }
829 const startSteps = session.steps;
831 // Snapshot points: every listed end time strictly between the starting
832 // point and this run's end. The run passes through each exactly (all are
833 // whole multiples of every dt choice).
834 const snapshotAt = new Map<number, number>(); // step index -> tEnd value
835 for (const T of T_END_CHOICE.values) {
836 if (T < spec.tEnd && T > (warm?.tEnd ?? 0)) snapshotAt.set(Math.round(T / dt), T);
837 }
838 const snapshots: { tEnd: number; state: Record<string, Float32Array> }[] = [];
840 // Everything a cache file needs exists before the run starts, so a snapshot
841 // is encoded and uploaded the moment it is captured, overlapping the
842 // network with the GPU still stepping, rather than queued for the end.
843 const geometryCoeffs = {
844 X: session.geometry.X,
845 Y: session.geometry.Y,
846 Z: session.geometry.Z,
847 };
848 const encode = (t: number, state: Record<string, Float32Array>) =>
849 encodeCacheFile({
850 spec: { ...spec, tEnd: t },
851 grid: session!.cfg,
852 species: model.state,
853 geometry: geometryCoeffs,
854 initial,
855 final: state,
856 adapter: adapterName,
857 });
858 const uploadedTimes: number[] = [];
859 const uploadErrors: string[] = [];
860 let uploadsStarted = 0;
861 const pendingUploads: Promise<void>[] = [];
862 /** Encode + upload without the stepping loop waiting. A captured snapshot
863 * is a complete solution of its own spec, so this stays valid even if the
864 * run is stopped afterwards. */
865 const uploadInBackground = (
866 t: number,
867 state: Record<string, Float32Array>,
868 apiKey: string,
869 preEncoded?: Uint8Array,
870 ): void => {
871 uploadsStarted++;
872 pendingUploads.push(
873 (async () => {
874 const bytes = preEncoded ?? (await encode(t, state));
875 const lookup = await lookupFor({ ...spec, tEnd: t });
876 await uploadCacheFile(apiKey, lookup.fileName, bytes);
877 uploadedTimes.push(t);
878 })().catch((e) => {
879 uploadErrors.push(`t = ${fmtChoice(t)}: ${e instanceof Error ? e.message : e}`);
880 }),
881 );
882 };
884 shownT = null;
885 resetRanges();
886 const t0 = performance.now();
887 let lastStatus = 0;
888 let lastDraw = 0;
889 while (session.steps < steps) {
890 if (gen !== generation) return;
891 if (stopRequested) {
892 shownT = session.steps * dt;
893 await draw();
894 updateStats();
895 const up = uploadedTimes.length
896 ? ` ${uploadedTimes.length} snapshot${uploadedTimes.length > 1 ? 's' : ''} already uploaded.`
897 : ' Nothing uploaded.';
898 status(`stopped at t = ${(session.steps * dt).toFixed(2)}.${up}`);
899 return;
900 }
901 // One chunk: up to CHUNK_STEPS steps submitted back to back (each
902 // submission stays under the dispatch budget), then a single sync and at
903 // most one render. Reading back and drawing after every submission is
904 // what made the run advance at a fraction of the solver's rate — a
905 // readback costs several times the 3-4 steps it fenced. The chunk stops
906 // exactly at snapshot points so those states are still captured exactly.
907 let target = Math.min(steps, session.steps + CHUNK_STEPS);
908 for (const s of snapshotAt.keys()) {
909 if (s > session.steps && s < target) target = s;
910 }
911 while (session.steps < target) {
912 session.step(Math.min(stepsPerSubmit, target - session.steps));
913 }
914 // The sync bounds how far the CPU runs ahead of the GPU, and (being a
915 // promise) yields to the event loop, which is what keeps Stop clickable.
916 await session.sync();
917 if (gen !== generation) return;
918 const hit = snapshotAt.get(session.steps);
919 if (hit !== undefined) {
920 const state = await session.readState();
921 if (gen !== generation) return;
922 // With a key on hand the snapshot goes straight to the cache; without
923 // one it is kept, in case a key is entered before the run ends.
924 const apiKey = elApiKey.value.trim();
925 if (apiKey) uploadInBackground(hit, state, apiKey);
926 else snapshots.push({ tEnd: hit, state });
927 }
928 const now = performance.now();
929 if (now - lastDraw > RENDER_EVERY_MS || session.steps >= steps) {
930 lastDraw = now;
931 await draw();
932 if (gen !== generation) return;
933 await nextFrame();
934 }
935 if (now - lastStatus > 200) {
936 lastStatus = now;
937 const t = session.steps * dt;
938 const pct = ((100 * (session.steps - startSteps)) / (steps - startSteps)).toFixed(0);
939 const rate = (session.steps - startSteps) / ((now - t0) / 1000);
940 const from = warm ? `resumed from cached t = ${fmtChoice(warm.tEnd)} — ` : '';
941 const up = uploadsStarted
942 ? `, uploaded ${uploadedTimes.length}/${uploadsStarted} snapshots`
943 : '';
944 status(
945 `not in the cache — <b>computing locally</b> (${from}` +
946 `t = ${t.toFixed(2)} / ${fmtChoice(spec.tEnd)}, ${pct}%, ${rate.toFixed(0)} steps/s${up})`,
947 );
948 }
949 }
951 const final = await session.readState();
952 if (gen !== generation) return;
953 shownT = spec.tEnd;
954 await draw();
955 updateStats();
956 const secs = ((performance.now() - t0) / 1000).toFixed(1);
957 const doneLine =
958 `<b>t = ${fmtChoice(spec.tEnd)}</b> — computed locally in ${secs} s` +
959 (warm ? ` (resumed from cached t = ${fmtChoice(warm.tEnd)})` : '') +
960 `.`;
961 status(`${doneLine} Writing the cache file…`);
963 const finalBytes = await encode(spec.tEnd, final);
964 if (gen !== generation) return;
965 const finalLookup = await lookupFor(spec);
966 offerDownload(finalBytes, finalLookup.fileName.split('/').pop()!);
968 // The final solution, plus any snapshots captured before a key was entered.
969 const apiKey = elApiKey.value.trim();
970 if (apiKey) {
971 uploadInBackground(spec.tEnd, final, apiKey, finalBytes);
972 for (const snap of snapshots) uploadInBackground(snap.tEnd, snap.state, apiKey);
973 }
974 if (uploadsStarted === 0) {
975 status(`${doneLine} Not uploaded (no API key).`);
976 return;
977 }
978 status(`${doneLine} Uploading to the cache (${uploadedTimes.length}/${uploadsStarted})…`);
979 await Promise.all(pendingUploads);
980 if (gen !== generation) return;
982 if (uploadErrors.length) elErr.textContent = `upload: ${uploadErrors.join('; ')}`;
983 const n = uploadedTimes.length;
984 if (n > 0) {
985 const times = [...uploadedTimes].sort((a, b) => a - b).map(fmtChoice).join(', ');
986 const failed = uploadErrors.length ? ` (${uploadErrors.length} failed)` : '';
987 status(
988 `${doneLine} <b>Uploaded ${n} solution${n > 1 ? 's' : ''}</b> ` +
989 `to the shared cache (t = ${times})${failed}.`,
990 );
991 } else {
992 status(`${doneLine} Uploads failed.`);
993 }
996// ---------------------------------------------------------------- boot
997elSolve.addEventListener('click', () => {
998 flowChain = flowChain.then(() => solve()).catch(() => undefined);
999});
1000elStop.addEventListener('click', () => {
1001 stopRequested = true;
1002 setBusy(false);
1003});
1004elReset.addEventListener('click', () => resetDefaults());
1005elResetView.addEventListener('click', () => {
1006 for (const s of scenes) s.resetCamera();
1007});
1008elApiKey.addEventListener('change', () => {
1009 const key = elApiKey.value.trim();
1010 if (key) localStorage.setItem(API_KEY_STORAGE, key);
1011 else localStorage.removeItem(API_KEY_STORAGE);
1012 updateUploadNote();
1013});
1015function updateUploadNote(): void {
1016 elUploadNote.textContent = elApiKey.value.trim()
1017 ? 'uploads enabled — locally computed solutions will be contributed'
1018 : '';
1021async function boot(): Promise<void> {
1022 buildControls();
1023 // Written even before any change, so the address bar is always shareable.
1024 writeUrlState();
1025 elApiKey.value = localStorage.getItem(API_KEY_STORAGE) ?? '';
1026 updateUploadNote();
1027 void updateCacheNote();
1028 try {
1029 device = await requestShtDevice();
1030 adapterName = await describeAdapter(device);
1031 } catch (e) {
1032 device = null;
1033 elErr.textContent =
1034 `WebGPU is not available (${e instanceof Error ? e.message : e}). ` +
1035 `Use a WebGPU-capable browser such as Chrome or Edge.`;
1036 return;
1038 device.lost.then((info) => {
1039 if (info.reason !== 'destroyed') {
1040 elErr.textContent = `WebGPU device lost: ${info.message}`;
1042 });
1044 try {
481eeb9Add Brusselator and Allen-Cahn modelsJeremy Magland 1045 await rebuildSession(currentSpec());
1047 elErr.textContent = formatFailure(e, model.source);
1048 status('failed to compile.');
1049 return;
1051 // Bring up the default selection if it is cached; otherwise show empty
1052 // surfaces. Nothing is ever computed without pressing the button.
1053 flowChain = flowChain.then(() => refresh()).catch(() => undefined);
1054 await flowChain;
1057void boot();
moveopenescclose