/ concept-collection / turing-surface-cache
Sign in
concept-collection / turing-surface-cache
turing-surface-cache / src / main.ts
1082 lines · 36.1 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,
48 LAM3,
51 fmtChoice,
52 type DiscreteChoice,
53} from './cache/options.ts';
54import { stepsFor, type CacheSpec, APP_NAME, FORMAT_VERSION } from './cache/spec.ts';
795cdccMove the run and the walk out of the pageJeremy Magland 55import { lookupFor, fetchCached, headCached, type CacheLookup } from './cache/client.ts';
c2317d0Fill the cache from the command line, without a browserJeremy Magland 56import { autoOrder, specForTarget, type AutoTarget } from './cache/autoWalk.ts';
795cdccMove the run and the walk out of the pageJeremy Magland 57import { decodeCacheFile } from './cache/h5file.ts';
58import { SolverSession } from './cache/solver.ts';
59import { runSpec, type RunEvents, type RunOutcome, type RunSummary } from './cache/runSpec.ts';
60import { fillWalk } from './cache/fillWalk.ts';
62const $ = <T extends HTMLElement>(id: string): T =>
63 document.getElementById(id) as T;
481eeb9Add Brusselator and Allen-Cahn modelsJeremy Magland 65const elModel = $<HTMLSelectElement>('model');
67const elGeometry = $<HTMLSelectElement>('geometry');
68const elGeomParams = $('geomparams');
69const elSeed = $<HTMLSelectElement>('seed');
70const elTend = $<HTMLSelectElement>('tend');
71const elSolve = $<HTMLButtonElement>('solve');
72const elStop = $<HTMLButtonElement>('stop');
73const elReset = $<HTMLButtonElement>('reset');
74const elCacheNote = $('cachenote');
75const elStatus = $('status');
76const elPanels = $('panels');
77const elResetView = $<HTMLButtonElement>('resetview');
78const elDownload = $<HTMLAnchorElement>('download');
79const elStats = $('stats');
80const elApiKey = $<HTMLInputElement>('apikey');
81const elUploadNote = $('uploadnote');
1a1e473Add auto-fill: work through the parameter space on an idle machineJeremy Magland 82const elAutoBar = $('autobar');
83const elAuto = $<HTMLButtonElement>('auto');
84const elAutoNote = $('autonote');
87/**
88 * Test/debug hook: `?tend=5,10` replaces the end-time list with the given
89 * values (still cached under their own honest specs — a test end time hashes
90 * to its own object). The headless checks use this to keep their computed
91 * runs short; it is not part of the normal UI.
92 */
94 const param = new URLSearchParams(location.search).get('tend');
95 if (param) {
96 const values = param
97 .split(',')
98 .map(Number)
99 .filter((v) => Number.isFinite(v) && v > 0);
100 if (values.length) {
101 T_END_CHOICE.values = values;
102 T_END_CHOICE.value = values[0];
103 }
104 }
107const API_KEY_STORAGE = `${APP_NAME}:apiKey`;
108const COLORMAP = colormaps.viridis;
109/** Render on a 2x finer grid than the solver's; exact interpolation. */
110const OVERSAMPLE = 2;
111/** How often the live view renders during a computation. */
112const RENDER_EVERY_MS = 250;
795cdccMove the run and the walk out of the pageJeremy Magland 113/** How often the status line is rewritten during a computation. */
114const STATUS_EVERY_MS = 200;
116// ---------------------------------------------------------------- state
481eeb9Add Brusselator and Allen-Cahn modelsJeremy Magland 117let model: MModel = mModelByKey(DEFAULT_MODEL_KEY)!;
795cdccMove the run and the walk out of the pageJeremy Magland 119/** The compiled solver and what it has applied (src/cache/solver.ts). */
120let solver: SolverSession | null = null;
795cdccMove the run and the walk out of the pageJeremy Magland 122/** The live session, or null before the GPU is up. */
123function sess(): ModelSession | null {
124 return solver?.session ?? null;
127/** The discrete selections, always exactly values from options.ts. */
481eeb9Add Brusselator and Allen-Cahn modelsJeremy Magland 128let 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 129let geometry: MGeometry = mGeometryByKey(DEFAULT_GEOMETRY_KEY)!;
130let geomParams: Params = Object.fromEntries(
131 GEOMETRY_CHOICES[DEFAULT_GEOMETRY_KEY].map((c) => [c.key, c.value]),
132);
133let seed = SEED_CHOICE.value;
134let tEnd = T_END_CHOICE.value;
136// The URL fragment carries the whole selection, so a reload comes back to it
137// and a shared link opens on the same spec (and, through refresh(), the same
138// cached solution). Read once at startup; rewritten on every change.
139readUrlState();
141let topo: SphereMeshTopology | null = null;
142let scenes: SphereScene[] = [];
143let colorbars: Colorbar[] = [];
144/** The colorbar containers, hidden while the windows are empty. */
145let colorbarEls: HTMLElement[] = [];
146let valueBufs: Float32Array[] = [];
147let colorBufs: Float32Array[] = [];
148let ranges: { lo: number; hi: number }[] = [];
149let resizeObs: ResizeObserver | null = null;
150let coords: Float32Array | null = null;
151let posBuf: Float32Array | null = null;
153let generation = 0;
154let busy = false;
155/** True while computeLocally is stepping/reading back. Every read shares one
156 * staging buffer (GpuModel#readback), so a new solve must drain the old
157 * loop before issuing reads of its own. */
158let pumping = false;
159let stopRequested = false;
1a1e473Add auto-fill: work through the parameter space on an idle machineJeremy Magland 160/** Set while the auto-fill walk owns the page (see autoRun). */
161let autoRunning = false;
162let autoComputed = 0;
163let autoSkipped = 0;
164let autoFailed = 0;
4f822e1turing-surface-cache: reaction-diffusion solutions at a chosen end time, shared through a cloud cacheJeremy Magland 165/** Simulation time of the state on display (loadState resets session.t). */
166let shownT: number | null = null;
167let downloadUrl: string | null = null;
169const nextFrame = () => new Promise<number>(requestAnimationFrame);
171// ---------------------------------------------------------------- spec
172function currentSpec(): CacheSpec {
173 return {
174 app: APP_NAME,
175 formatVersion: FORMAT_VERSION,
176 model: model.key,
177 params: { ...params },
178 geometry: geometry.key,
179 geometryParams: { ...geomParams },
180 lmax: LMAX,
181 niter: NITER,
182 lam3: LAM3,
183 seed,
184 tEnd,
185 };
188// ---------------------------------------------------------------- URL state
189/**
190 * The selection lives in the URL fragment, every value written explicitly
191 * (`#a=0.1&b=0.9&…&geometry=ellipsoid&ax=1.5&…&seed=1&tend=100`), so a link
192 * keeps meaning the same spec even if a default changes later. The fragment
193 * is chosen over the query string to leave `?tend` to the test hook. Values
194 * are only accepted if they are exactly entries of the discrete lists;
195 * anything else keeps the default.
196 */
197function readUrlState(): void {
198 const hash = location.hash.replace(/^#/, '');
199 if (!hash) return;
200 const p = new URLSearchParams(hash);
201 // `name` is the key as it appears in the URL; it defaults to the choice's
202 // own key but is passed explicitly where the two differ (tEnd vs tend).
203 const pick = (choice: DiscreteChoice, current: number, name = choice.key): number => {
204 const raw = p.get(name);
205 if (raw === null) return current;
206 const v = Number(raw);
207 return choice.values.includes(v) ? v : current;
208 };
481eeb9Add Brusselator and Allen-Cahn modelsJeremy Magland 209 const m = p.get('model');
210 if (m && mModelByKey(m) && MODEL_CHOICES[m]) {
211 model = mModelByKey(m)!;
212 params = defaultChoiceParams(MODEL_CHOICES[m]);
213 }
215 if (g && mGeometryByKey(g) && GEOMETRY_CHOICES[g]) {
216 geometry = mGeometryByKey(g)!;
217 geomParams = defaultChoiceParams(GEOMETRY_CHOICES[g]);
218 }
481eeb9Add Brusselator and Allen-Cahn modelsJeremy Magland 219 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 220 for (const c of GEOMETRY_CHOICES[geometry.key]) geomParams[c.key] = pick(c, geomParams[c.key]);
221 seed = pick(SEED_CHOICE, seed);
222 tEnd = pick(T_END_CHOICE, tEnd, 'tend');
225function writeUrlState(): void {
226 const p = new URLSearchParams();
481eeb9Add Brusselator and Allen-Cahn modelsJeremy Magland 227 p.set('model', model.key);
228 for (const c of MODEL_CHOICES[model.key]) p.set(c.key, fmtChoice(params[c.key]));
230 for (const c of GEOMETRY_CHOICES[geometry.key]) p.set(c.key, fmtChoice(geomParams[c.key]));
231 p.set('seed', String(seed));
232 p.set('tend', fmtChoice(tEnd));
233 history.replaceState(null, '', `${location.pathname}${location.search}#${p.toString()}`);
236// ---------------------------------------------------------------- controls
237/** Every select made by makeSelect, so a reset can push new values into the
238 * ones still on the page. */
239const boundSelects: { el: HTMLSelectElement; get: () => number }[] = [];
241function syncSelects(): void {
1a1e473Add auto-fill: work through the parameter space on an idle machineJeremy Magland 242 // Pruned as it goes: auto mode rebuilds the parameter controls once per
243 // target, so entries for replaced selects would otherwise pile up.
244 for (let i = boundSelects.length - 1; i >= 0; i--) {
245 const b = boundSelects[i];
4f822e1turing-surface-cache: reaction-diffusion solutions at a chosen end time, shared through a cloud cacheJeremy Magland 246 if (b.el.isConnected) b.el.value = String(b.get());
1a1e473Add auto-fill: work through the parameter space on an idle machineJeremy Magland 247 else boundSelects.splice(i, 1);
251function makeSelect(
252 choice: DiscreteChoice,
253 get: () => number,
254 set: (v: number) => void,
255): HTMLLabelElement {
256 const label = document.createElement('label');
257 label.textContent = `${choice.label} `;
258 const select = document.createElement('select');
259 for (const v of choice.values) {
260 const opt = document.createElement('option');
261 opt.value = String(v);
262 opt.textContent = fmtChoice(v);
263 select.append(opt);
264 }
265 select.value = String(get());
266 select.addEventListener('change', () => {
267 set(Number(select.value));
268 onSelectionChange();
269 });
270 label.append(select);
271 boundSelects.push({ el: select, get });
272 return label;
1a1e473Add auto-fill: work through the parameter space on an idle machineJeremy Magland 275/** Put every selection back to its default, without refreshing the display. */
276function applyDefaults(): void {
481eeb9Add Brusselator and Allen-Cahn modelsJeremy Magland 277 model = mModelByKey(DEFAULT_MODEL_KEY)!;
278 params = defaultChoiceParams(MODEL_CHOICES[DEFAULT_MODEL_KEY]);
4f822e1turing-surface-cache: reaction-diffusion solutions at a chosen end time, shared through a cloud cacheJeremy Magland 279 geometry = mGeometryByKey(DEFAULT_GEOMETRY_KEY)!;
481eeb9Add Brusselator and Allen-Cahn modelsJeremy Magland 280 elModel.value = model.key;
281 buildModelParamControls();
4f822e1turing-surface-cache: reaction-diffusion solutions at a chosen end time, shared through a cloud cacheJeremy Magland 282 geomParams = defaultChoiceParams(GEOMETRY_CHOICES[DEFAULT_GEOMETRY_KEY]);
283 seed = SEED_CHOICE.value;
284 tEnd = T_END_CHOICE.value;
285 elGeometry.value = geometry.key;
286 buildGeomParamControls();
287 elSeed.value = String(seed);
288 elTend.value = String(tEnd);
289 syncSelects();
293/** The Reset button: back to the defaults, and show what is there. */
294function resetDefaults(): void {
295 applyDefaults();
1a1e473Add auto-fill: work through the parameter space on an idle machineJeremy Magland 299/** Point every control at one walk target (auto mode drives the same
300 * selection the user otherwise would, so the URL and the dropdowns always
c2317d0Fill the cache from the command line, without a browserJeremy Magland 301 * say what is being computed). The values come from the target's own spec,
302 * so currentSpec() reproduces exactly what the walk asked for. */
1a1e473Add auto-fill: work through the parameter space on an idle machineJeremy Magland 303function setSelection(t: AutoTarget): void {
c2317d0Fill the cache from the command line, without a browserJeremy Magland 304 const spec = specForTarget(t);
305 const nextModel = mModelByKey(spec.model)!;
1a1e473Add auto-fill: work through the parameter space on an idle machineJeremy Magland 306 if (nextModel !== model) {
307 model = nextModel;
308 elModel.value = model.key;
309 buildModelParamControls();
310 }
c2317d0Fill the cache from the command line, without a browserJeremy Magland 311 params = { ...spec.params };
312 const nextGeom = mGeometryByKey(spec.geometry)!;
1a1e473Add auto-fill: work through the parameter space on an idle machineJeremy Magland 313 if (nextGeom !== geometry) {
314 geometry = nextGeom;
315 elGeometry.value = geometry.key;
316 buildGeomParamControls();
317 }
c2317d0Fill the cache from the command line, without a browserJeremy Magland 318 geomParams = { ...spec.geometryParams };
319 seed = spec.seed;
1a1e473Add auto-fill: work through the parameter space on an idle machineJeremy Magland 320 elSeed.value = String(seed);
1a1e473Add auto-fill: work through the parameter space on an idle machineJeremy Magland 322 elTend.value = String(tEnd);
323 syncSelects();
324 writeUrlState();
481eeb9Add Brusselator and Allen-Cahn modelsJeremy Magland 327function buildModelParamControls(): void {
328 elParams.replaceChildren();
329 for (const choice of MODEL_CHOICES[model.key]) {
331 makeSelect(choice, () => params[choice.key], (v) => (params[choice.key] = v)),
332 );
333 }
336function buildControls(): void {
337 for (const m of mModels) {
338 const opt = document.createElement('option');
339 opt.value = m.key;
340 opt.textContent = m.label;
341 elModel.append(opt);
342 }
343 elModel.value = model.key;
344 elModel.addEventListener('change', () => {
345 model = mModelByKey(elModel.value)!;
346 params = defaultChoiceParams(MODEL_CHOICES[model.key]);
347 buildModelParamControls();
348 onSelectionChange();
349 });
350 buildModelParamControls();
352 const opt = document.createElement('option');
353 opt.value = g.key;
354 opt.textContent = g.label.toLowerCase();
355 elGeometry.append(opt);
356 }
357 elGeometry.value = geometry.key;
358 elGeometry.addEventListener('change', () => {
359 geometry = mGeometryByKey(elGeometry.value)!;
360 geomParams = Object.fromEntries(
361 GEOMETRY_CHOICES[geometry.key].map((c) => [c.key, c.value]),
362 );
363 buildGeomParamControls();
364 onSelectionChange();
365 });
366 buildGeomParamControls();
368 for (const v of SEED_CHOICE.values) {
369 const opt = document.createElement('option');
370 opt.value = String(v);
371 opt.textContent = String(v);
372 elSeed.append(opt);
373 }
374 elSeed.value = String(seed);
375 elSeed.addEventListener('change', () => {
376 seed = Number(elSeed.value);
377 onSelectionChange();
378 });
380 for (const v of T_END_CHOICE.values) {
381 const opt = document.createElement('option');
382 opt.value = String(v);
383 opt.textContent = String(v);
384 elTend.append(opt);
385 }
386 elTend.value = String(tEnd);
387 elTend.addEventListener('change', () => {
388 tEnd = Number(elTend.value);
389 onSelectionChange();
390 });
393function buildGeomParamControls(): void {
394 elGeomParams.replaceChildren();
395 for (const choice of GEOMETRY_CHOICES[geometry.key]) {
396 elGeomParams.append(
397 makeSelect(choice, () => geomParams[choice.key], (v) => (geomParams[choice.key] = v)),
398 );
399 }
402/**
403 * A selection change refreshes the display: a cached solution loads and
404 * shows immediately, an uncached one shows empty surfaces until the user
405 * explicitly presses Compute solution. While a computation is running the
406 * change touches nothing — the run keeps going and only the is-it-cached
407 * note follows the dropdowns.
408 *
409 * Refreshes and button presses are chained so two flows never talk to the
410 * session at once.
411 */
412let flowChain: Promise<void> = Promise.resolve();
413function onSelectionChange(): void {
414 writeUrlState();
415 // During a computation the refresh is deferred until the run finishes; the
416 // is-it-cached note should follow the dropdowns right away regardless.
417 if (busy) void updateCacheNote();
418 flowChain = flowChain.then(() => refresh()).catch(() => undefined);
421// The note carries a token so a slow HEAD for a superseded selection never
422// overwrites the note for the current one.
423let cacheNoteToken = 0;
424async function updateCacheNote(): Promise<void> {
425 const token = ++cacheNoteToken;
426 elCacheNote.textContent = '';
427 let lookup: CacheLookup;
428 try {
429 lookup = await lookupFor(currentSpec());
430 } catch {
431 return;
432 }
795cdccMove the run and the walk out of the pageJeremy Magland 433 const present = await headCached(lookup);
435 setCacheNote(present);
438function setCacheNote(present: boolean | null): void {
439 if (present === true) {
440 elCacheNote.innerHTML = '<b>✓ in the cloud cache</b>';
441 } else if (present === false) {
442 elCacheNote.textContent = 'not cached yet';
443 } else {
444 elCacheNote.textContent = '';
445 }
448// ---------------------------------------------------------------- view
449function disposeView(): void {
450 for (const s of scenes) s.dispose();
451 scenes = [];
452 colorbars = [];
453 colorbarEls = [];
454 topo = null;
455 coords = null;
456 posBuf = null;
457 resizeObs?.disconnect();
458 resizeObs = null;
459 elPanels.replaceChildren();
462function buildView(surface: Float32Array): void {
795cdccMove the run and the walk out of the pageJeremy Magland 463 const session = sess();
465 const view = session.viewSht;
466 const { nphi } = view.cfg;
467 const phi = new Float64Array(nphi);
468 for (let j = 0; j < nphi; j++) phi[j] = (2 * Math.PI * j) / nphi;
469 topo = buildTopology(view.cosTheta, phi);
470 coords = surface;
471 posBuf = new Float32Array(topo.numVertices * 3);
472 fillPositions(posBuf, coords, topo, 1);
474 const sphereBg = getComputedStyle(document.documentElement)
475 .getPropertyValue('--sphere-bg')
476 .trim();
477 for (let k = 0; k < model.species.length; k++) {
478 const panel = document.createElement('div');
479 panel.className = 'panel';
480 const box = document.createElement('div');
481 box.className = 'sphere-box';
482 const tag = document.createElement('div');
483 tag.className = 'species-tag';
484 tag.textContent = model.species[k];
485 box.append(tag);
486 const side = document.createElement('div');
487 panel.append(box, side);
488 elPanels.append(panel);
490 const scene = new SphereScene(
491 box,
492 topo.numVertices,
493 topo.indices,
494 Float32Array.from(posBuf),
495 sphereBg || undefined,
496 );
497 scene.fitCamera();
498 scenes.push(scene);
499 colorbars.push(new Colorbar(side));
500 colorbarEls.push(side);
501 valueBufs[k] = new Float32Array(topo.numVertices);
502 colorBufs[k] = new Float32Array(topo.numVertices * 3);
503 ranges[k] = { lo: NaN, hi: NaN };
504 }
505 for (let k = 1; k < scenes.length; k++) scenes[0].syncCamerasWith(scenes[k]);
507 resizeObs = new ResizeObserver(() => {
508 const boxes = elPanels.querySelectorAll<HTMLElement>('.sphere-box');
509 boxes.forEach((box, i) => {
510 scenes[i]?.resize(box.clientWidth, box.clientHeight);
511 });
512 });
513 elPanels
514 .querySelectorAll<HTMLElement>('.sphere-box')
515 .forEach((box) => resizeObs!.observe(box));
518async function draw(): Promise<void> {
795cdccMove the run and the walk out of the pageJeremy Magland 519 const session = sess();
521 const gen = generation;
522 for (let k = 0; k < model.species.length; k++) {
523 let field: Float32Array;
524 try {
525 field = await session.readSpecies(k);
526 } catch (e) {
527 if (gen !== generation) return;
528 throw e;
529 }
530 if (gen !== generation || !topo) return;
531 fillFieldValues(valueBufs[k], field, topo);
532 let lo = Infinity;
533 let hi = -Infinity;
534 for (const v of valueBufs[k]) {
535 if (v < lo) lo = v;
536 if (v > hi) hi = v;
537 }
538 // Smooth the color range in both directions so the shading evolves gently
539 // as the pattern grows (out-of-range values clamp meanwhile).
540 const r = ranges[k];
541 if (!Number.isFinite(r.lo)) {
542 r.lo = lo;
543 r.hi = hi;
544 } else {
545 const a = 0.15;
546 r.lo += a * (lo - r.lo);
547 r.hi += a * (hi - r.hi);
548 }
549 const shown = floorRange(r.lo, r.hi);
550 fillColors(colorBufs[k], valueBufs[k], shown.lo, shown.hi, COLORMAP);
551 scenes[k]?.updateColors(colorBufs[k]);
552 colorbars[k]?.update(COLORMAP, shown.lo, shown.hi);
553 if (colorbarEls[k]) colorbarEls[k].style.visibility = '';
554 }
557/** Empty windows: the selected surface with no field on it. Shown when the
558 * selection has no cached solution and nothing has been computed yet. */
559function clearDisplay(): void {
560 shownT = null;
561 elDownload.hidden = true;
562 if (!topo) return;
563 for (let k = 0; k < model.species.length; k++) {
564 // NaN renders as neutral gray in fillColors — the shape without a field.
565 valueBufs[k].fill(NaN);
566 fillColors(colorBufs[k], valueBufs[k], 0, 1, COLORMAP);
567 scenes[k]?.updateColors(colorBufs[k]);
568 if (colorbarEls[k]) colorbarEls[k].style.visibility = 'hidden';
569 }
570 updateStats();
573function resetRanges(): void {
574 for (const r of ranges) {
575 r.lo = NaN;
576 r.hi = NaN;
577 }
580function updateStats(): void {
795cdccMove the run and the walk out of the pageJeremy Magland 581 const session = sess();
583 const { nlat, nphi } = session.cfg;
584 const kind = `WebGPU fp32${adapterName ? ` — ${adapterName}` : ''}`;
585 const t = shownT !== null ? ` · showing t = <b>${fmtChoice(shownT)}</b>` : '';
586 elStats.innerHTML =
587 `<b>${kind}</b> · grid ${nlat}×${nphi} · lmax ${LMAX} · ` +
588 `solve iters ${NITER}${t}`;
591// ---------------------------------------------------------------- statuses
592function status(html: string): void {
593 elStatus.innerHTML = html;
596function setBusy(next: boolean): void {
597 busy = next;
598 elSolve.disabled = next;
599 elStop.hidden = !next;
602function offerDownload(bytes: Uint8Array, name: string): void {
603 if (downloadUrl) URL.revokeObjectURL(downloadUrl);
604 downloadUrl = URL.createObjectURL(new Blob([bytes as BlobPart], { type: 'application/x-hdf5' }));
605 elDownload.href = downloadUrl;
606 elDownload.download = name;
607 elDownload.hidden = false;
610// ---------------------------------------------------------------- solving
481eeb9Add Brusselator and Allen-Cahn modelsJeremy Magland 611/** Rebuild the mesh and panels from the session's current surface, keeping
612 * the camera. Fresh buffers render black until the first fill, so the bare
613 * surface is shown; the caller's draw or clearDisplay follows right behind. */
614async function rebuildViewFromSession(): Promise<void> {
795cdccMove the run and the walk out of the pageJeremy Magland 615 const session = sess();
481eeb9Add Brusselator and Allen-Cahn modelsJeremy Magland 616 if (!session) return;
617 const surface = await session.renderPositions();
618 const cam = scenes[0]?.cameraState();
619 disposeView();
620 buildView(surface);
621 if (cam) for (const s of scenes) s.setCameraState(cam);
622 clearDisplay();
625/**
795cdccMove the run and the walk out of the pageJeremy Magland 626 * Apply a selection to the solver. Which changes are cheap and which pay a
627 * recompile is the solver's business (src/cache/solver.ts); the page adds the
628 * compiling status and the rebuilt view through the events it installs in
629 * boot(), since the panel count follows the model's species (Allen–Cahn has
630 * one).
4f822e1turing-surface-cache: reaction-diffusion solutions at a chosen end time, shared through a cloud cacheJeremy Magland 632async function applySelection(spec: CacheSpec): Promise<void> {
795cdccMove the run and the walk out of the pageJeremy Magland 633 if (!solver) throw new Error('no GPU device');
634 await solver.apply(spec);
637/** Decode a fetched cache file and put it on screen. */
638async function displayCached(
639 bytes: Uint8Array,
640 lookup: CacheLookup,
641 spec: CacheSpec,
642 gen: number,
643): Promise<void> {
795cdccMove the run and the walk out of the pageJeremy Magland 644 const session = sess();
646 const decoded = await decodeCacheFile(bytes, lookup.specJson, model.state);
647 if (gen !== generation) return;
648 session.loadState(decoded.final);
649 shownT = spec.tEnd;
650 resetRanges();
651 await draw();
652 updateStats();
653 const kb = (bytes.length / 1024).toFixed(0);
654 const from = decoded.adapter ? `, computed on ${decoded.adapter}` : '';
655 const when = decoded.created ? ` ${decoded.created.slice(0, 10)}` : '';
656 status(
657 `<b>t = ${fmtChoice(spec.tEnd)}</b> — from the <b>cloud cache</b> ` +
658 `(${kb} KB${from}${when}).`,
659 );
660 offerDownload(bytes, lookup.fileName.split('/').pop()!);
663/**
664 * Bring the display in line with the current selection, without ever
665 * starting a computation: a cached solution loads and shows, an uncached one
666 * shows empty surfaces and waits for the Compute solution button. Runs on
667 * startup and on every selection change; a no-op while a computation is
668 * running (the run is not disturbed — only the cache note follows).
669 */
670async function refresh(): Promise<void> {
481eeb9Add Brusselator and Allen-Cahn modelsJeremy Magland 671 // Before the GPU is up there is nothing to refresh; while a computation
672 // runs the note follows the dropdowns and the refresh waits its turn. A
673 // missing session is NOT a reason to bail: applySelection rebuilds it,
674 // which is also what recovers from a failed compile.
675 if (!device || busy) {
677 return;
678 }
679 generation++;
680 const gen = generation;
681 elErr.textContent = '';
682 const spec = currentSpec();
683 try {
684 const lookup = await lookupFor(spec);
685 status('checking the cloud cache…');
686 let bytes: Uint8Array | null = null;
687 let unreachable = false;
688 try {
689 bytes = await fetchCached(lookup);
690 } catch {
691 unreachable = true;
692 }
693 if (gen !== generation) return;
694 await applySelection(spec);
695 if (gen !== generation) return;
696 if (bytes) {
697 await displayCached(bytes, lookup, spec, gen);
698 setCacheNote(true);
699 return;
700 }
701 clearDisplay();
702 setCacheNote(unreachable ? null : false);
703 status(
704 unreachable
705 ? 'cloud cache unreachable — <b>Compute solution</b> runs it in your browser.'
706 : `not in the cloud cache — press <b>Compute solution</b> to run it in ` +
707 `your browser (up to ${stepsFor(spec).toLocaleString()} steps; a ` +
708 `cached shorter run of the same settings is picked up where it left off).`,
709 );
710 } catch (e) {
711 if (gen === generation) {
712 elErr.textContent = formatFailure(e, model.source);
713 status('failed.');
714 }
715 }
718/** The Compute solution button: cache lookup, then either load or compute. */
719async function solve(): Promise<void> {
481eeb9Add Brusselator and Allen-Cahn modelsJeremy Magland 720 if (!device || busy) return;
722 const gen = generation;
723 setBusy(true);
724 // A stopped run may still be inside an await; let it see the generation
725 // bump and finish before touching the session.
726 while (pumping) await nextFrame();
727 if (gen !== generation) return;
728 stopRequested = false;
729 elErr.textContent = '';
730 elDownload.hidden = true;
731 const spec = currentSpec();
732 try {
733 const lookup = await lookupFor(spec);
734 status('checking the cloud cache…');
735 let bytes: Uint8Array | null = null;
736 try {
737 bytes = await fetchCached(lookup);
738 } catch (e) {
739 // An unreachable cache degrades to computing locally, and says so.
740 status(`cache unreachable (${e instanceof Error ? e.message : e}) — computing locally`);
741 }
742 if (gen !== generation) return;
743 await applySelection(spec);
744 if (gen !== generation) return;
746 if (bytes) {
747 await displayCached(bytes, lookup, spec, gen);
748 return;
749 }
750 await computeLocally(spec, gen);
751 } catch (e) {
752 if (gen === generation) {
753 elErr.textContent = formatFailure(e, model.source);
754 status('failed.');
755 }
756 } finally {
757 if (gen === generation) setBusy(false);
758 void updateCacheNote();
759 }
795cdccMove the run and the walk out of the pageJeremy Magland 763 * How the page tells a run in progress: the status line, the live view, and
764 * when to give up. The same events drive the Compute solution button and the
765 * auto-fill walk, so the two report a run identically.
766 *
767 * `gen` is read afresh at every check rather than captured, so the events a
768 * walk installs once still speak for whichever target is current.
795cdccMove the run and the walk out of the pageJeremy Magland 770function runEvents(gen: () => number): RunEvents {
772 let lastDraw = 0;
795cdccMove the run and the walk out of the pageJeremy Magland 773 return {
774 onPhase(phase) {
775 if (phase.kind === 'warm-search') {
776 status('not in the cache — looking for a shorter cached run…');
777 } else if (phase.kind === 'seeding') {
778 status('not in the cache — <b>computing locally</b>: seeding…');
779 } else if (phase.kind === 'encoding') {
780 status(`${doneLine(phase.run)} Writing the cache file…`);
781 } else {
782 status(
783 `${doneLine(phase.run)} Uploading to the cache ` +
784 `(${phase.uploaded}/${phase.started})…`,
785 );
786 }
787 },
788 onProgress(p) {
789 const now = performance.now();
790 if (now - lastStatus < STATUS_EVERY_MS) return;
795cdccMove the run and the walk out of the pageJeremy Magland 792 const from = p.warmFrom !== null ? `resumed from cached t = ${fmtChoice(p.warmFrom)} — ` : '';
793 const up = p.uploadsStarted
794 ? `, uploaded ${p.uploadsDone}/${p.uploadsStarted} snapshots`
796 status(
797 `not in the cache — <b>computing locally</b> (${from}` +
795cdccMove the run and the walk out of the pageJeremy Magland 798 `t = ${p.t.toFixed(2)} / ${fmtChoice(p.tEnd)}, ${(100 * p.fraction).toFixed(0)}%, ` +
799 `${p.rate.toFixed(0)} steps/s${up})`,
802 onStepping() {
803 shownT = null;
804 resetRanges();
805 },
806 async onTick() {
807 // Rendering is skipped entirely while the page is hidden, and the loop
808 // never waits on an animation frame there: a backgrounded tab throttles
809 // or stops requestAnimationFrame, which would stall an unattended run.
810 // The GPU sync inside the run already yields to the event loop, so Stop
811 // stays responsive either way.
812 const now = performance.now();
813 if (document.hidden || now - lastDraw <= RENDER_EVERY_MS) return;
814 lastDraw = now;
815 await draw();
816 if (gen() !== generation) return;
817 await nextFrame();
818 },
819 async onFinal(tEnd) {
820 shownT = tEnd;
821 await draw();
822 updateStats();
823 },
824 onFile(bytes, name) {
825 offerDownload(bytes, name);
826 },
827 cancelled: () => gen() !== generation,
828 stopRequested: () => stopRequested,
829 };
795cdccMove the run and the walk out of the pageJeremy Magland 832/** The first sentence of every finished run's status. */
833function doneLine(run: RunSummary): string {
834 return (
835 `<b>t = ${fmtChoice(run.tEnd)}</b> — computed locally in ${run.seconds.toFixed(1)} s` +
836 (run.warmFrom !== null ? ` (resumed from cached t = ${fmtChoice(run.warmFrom)})` : '') +
837 `.`
838 );
795cdccMove the run and the walk out of the pageJeremy Magland 841/** Say how a finished run ended. Returns nothing; the caller counts. */
842async function reportOutcome(outcome: RunOutcome): Promise<void> {
843 if (outcome.kind === 'abandoned') return;
844 if (outcome.kind === 'diverged') {
845 elErr.textContent =
846 `the solution went non-finite at t = ${outcome.t.toFixed(2)} — nothing uploaded ` +
847 `(this combination is unstable at dt = ${fmtChoice(AUTO_DT)})`;
848 status('diverged.');
849 return;
795cdccMove the run and the walk out of the pageJeremy Magland 851 if (outcome.kind === 'stopped') {
852 shownT = outcome.t;
853 await draw();
854 updateStats();
855 const n = outcome.uploaded.length;
856 const up = n ? ` ${n} snapshot${n > 1 ? 's' : ''} already uploaded.` : ' Nothing uploaded.';
857 status(`stopped at t = ${outcome.t.toFixed(2)}.${up}`);
859 }
795cdccMove the run and the walk out of the pageJeremy Magland 860 const line = doneLine(outcome);
861 if (outcome.uploadsStarted === 0) {
862 status(`${line} Not uploaded (no API key).`);
863 return;
864 }
865 if (outcome.uploadErrors.length) {
866 elErr.textContent = `upload: ${outcome.uploadErrors.join('; ')}`;
867 }
868 const n = outcome.uploaded.length;
795cdccMove the run and the walk out of the pageJeremy Magland 870 const times = [...outcome.uploaded].sort((a, b) => a - b).map(fmtChoice).join(', ');
871 const failed = outcome.uploadErrors.length
872 ? ` (${outcome.uploadErrors.length} failed)`
873 : '';
795cdccMove the run and the walk out of the pageJeremy Magland 875 `${line} <b>Uploaded ${n} solution${n > 1 ? 's' : ''}</b> ` +
877 );
878 } else {
795cdccMove the run and the walk out of the pageJeremy Magland 879 status(`${line} Uploads failed.`);
884 * Run the solver to the spec's end time, watching the pattern form, and
885 * capture the state at every smaller listed end time on the way
886 * (src/cache/runSpec.ts). Everything the page adds is in runEvents and
887 * reportOutcome.
888 */
889async function computeLocally(spec: CacheSpec, gen: number): Promise<RunOutcome> {
890 if (!solver?.session) return { kind: 'abandoned' };
891 pumping = true;
795cdccMove the run and the walk out of the pageJeremy Magland 893 const outcome = await runSpec({
894 solver,
895 spec,
896 adapter: adapterName,
897 apiKey: () => elApiKey.value.trim(),
898 events: runEvents(() => gen),
899 });
900 if (gen === generation) await reportOutcome(outcome);
901 return outcome;
902 } finally {
903 pumping = false;
795cdccMove the run and the walk out of the pageJeremy Magland 907// ---------------------------------------------------------------- auto-fill
1a1e473Add auto-fill: work through the parameter space on an idle machineJeremy Magland 908function autoNote(target: AutoTarget | null): void {
909 if (!autoRunning) {
910 elAutoNote.textContent = autoComputed || autoSkipped
911 ? `stopped — computed ${autoComputed}, skipped ${autoSkipped} already cached` +
912 (autoFailed ? `, ${autoFailed} failed` : '')
913 : '';
914 return;
915 }
916 const where = target
917 ? `${mModelByKey(target.model)!.label} on ${target.geometry}, ${target.distance} ` +
918 `knob${target.distance === 1 ? '' : 's'} from the defaults`
919 : '';
920 elAutoNote.textContent =
921 `auto-filling — computed ${autoComputed}, skipped ${autoSkipped}` +
922 (autoFailed ? `, ${autoFailed} failed` : '') + (where ? ` · ${where}` : '');
925function setAutoUi(on: boolean): void {
926 elAuto.textContent = on ? 'Auto-filling…' : 'Auto-fill the cache';
927 elAuto.disabled = on;
928 elReset.disabled = on;
931/**
932 * Walk the parameter space on this machine, computing and contributing
933 * whatever is not cached yet, nearest the defaults first and randomly within
795cdccMove the run and the walk out of the pageJeremy Magland 934 * a distance (src/cache/autoWalk.ts, src/cache/fillWalk.ts). Runs until
935 * stopped.
937 * Every target is driven through the same selection the user would set by
938 * 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 939 * the run itself is the ordinary local computation — including its background
940 * uploads, its warm start from a shorter cached run, and its divergence
941 * guard.
943async function autoRun(): Promise<void> {
795cdccMove the run and the walk out of the pageJeremy Magland 944 if (!device || !solver || busy || autoRunning) return;
1a1e473Add auto-fill: work through the parameter space on an idle machineJeremy Magland 945 if (!elApiKey.value.trim()) return;
946 autoRunning = true;
947 autoComputed = autoSkipped = autoFailed = 0;
948 setAutoUi(true);
949 setBusy(true);
950 elErr.textContent = '';
951 // Start from a defined point — which is also the first target, since the
952 // defaults are the one combination at distance zero.
953 applyDefaults();
954 autoNote(null);
795cdccMove the run and the walk out of the pageJeremy Magland 956 // The generation of the target being computed, read by the run events.
957 let walkGen = 0;
958 await fillWalk({
959 targets: autoOrder(),
960 solver,
961 adapter: adapterName,
962 apiKey: () => elApiKey.value.trim(),
963 beforeTarget(target) {
964 setSelection(target);
965 autoNote(target);
966 generation++;
967 walkGen = generation;
968 stopRequested = false;
969 return currentSpec();
970 },
971 events: {
972 ...runEvents(() => walkGen),
973 onTarget: () => status('checking the cloud cache…'),
974 onCached: (target) => {
976 setCacheNote(true);
795cdccMove the run and the walk out of the pageJeremy Magland 977 autoNote(target);
978 },
979 onComputing: () => setCacheNote(false),
980 onOutcome: (target, _spec, outcome) => {
981 if (outcome.kind === 'done') autoComputed++;
982 else if (outcome.kind === 'diverged') autoFailed++;
983 autoNote(target);
984 },
985 onFailure: (target, spec, e) => {
986 autoFailed++;
987 elErr.textContent =
988 `auto (${spec.model}, ${spec.geometry}): ${formatFailure(e, model.source)}`;
989 autoNote(target);
990 },
991 walkStopped: () => !autoRunning,
992 },
993 });
995 autoRunning = false;
996 setAutoUi(false);
997 setBusy(false);
998 autoNote(null);
4f822e1turing-surface-cache: reaction-diffusion solutions at a chosen end time, shared through a cloud cacheJeremy Magland 1001// ---------------------------------------------------------------- boot
1a1e473Add auto-fill: work through the parameter space on an idle machineJeremy Magland 1002elAuto.addEventListener('click', () => {
1003 flowChain = flowChain.then(() => autoRun()).catch(() => undefined);
1004});
1006 flowChain = flowChain.then(() => solve()).catch(() => undefined);
1007});
1008elStop.addEventListener('click', () => {
1009 stopRequested = true;
1012});
1013elReset.addEventListener('click', () => resetDefaults());
1014elResetView.addEventListener('click', () => {
1015 for (const s of scenes) s.resetCamera();
1016});
1a1e473Add auto-fill: work through the parameter space on an idle machineJeremy Magland 1017// The view is not drawn while the page is hidden, so it is stale on return.
1018// Not while a run is reading back: every read shares one staging buffer.
1019document.addEventListener('visibilitychange', () => {
795cdccMove the run and the walk out of the pageJeremy Magland 1020 if (!document.hidden && sess() && !pumping) void draw();
1023 const key = elApiKey.value.trim();
1024 if (key) localStorage.setItem(API_KEY_STORAGE, key);
1025 else localStorage.removeItem(API_KEY_STORAGE);
1026 updateUploadNote();
1027});
1029function updateUploadNote(): void {
1a1e473Add auto-fill: work through the parameter space on an idle machineJeremy Magland 1030 const hasKey = elApiKey.value.trim().length > 0;
1031 elUploadNote.textContent = hasKey
4f822e1turing-surface-cache: reaction-diffusion solutions at a chosen end time, shared through a cloud cacheJeremy Magland 1032 ? 'uploads enabled — locally computed solutions will be contributed'
1033 : '';
1a1e473Add auto-fill: work through the parameter space on an idle machineJeremy Magland 1034 // Auto-fill exists to contribute, so it is offered only to those who can.
1035 elAutoBar.hidden = !hasKey;
1036 if (!hasKey && autoRunning) autoRunning = false;
1039async function boot(): Promise<void> {
1040 buildControls();
1041 // Written even before any change, so the address bar is always shareable.
1042 writeUrlState();
1043 elApiKey.value = localStorage.getItem(API_KEY_STORAGE) ?? '';
1044 updateUploadNote();
1045 void updateCacheNote();
1046 try {
1047 device = await requestShtDevice();
795cdccMove the run and the walk out of the pageJeremy Magland 1048 // Before the adapter is even described, so a selection change during boot
1049 // finds a solver to apply itself to rather than an error.
1050 solver = new SolverSession(device, OVERSAMPLE, {
1051 onCompiling: (m) => status(`compiling ${m.label}…`),
1052 onSurface: () => rebuildViewFromSession(),
1053 });
1055 } catch (e) {
1056 device = null;
795cdccMove the run and the walk out of the pageJeremy Magland 1057 solver = null;
1059 `WebGPU is not available (${e instanceof Error ? e.message : e}). ` +
1060 `Use a WebGPU-capable browser such as Chrome or Edge.`;
1061 return;
1063 device.lost.then((info) => {
1064 if (info.reason !== 'destroyed') {
1065 elErr.textContent = `WebGPU device lost: ${info.message}`;
1067 });
1069 try {
795cdccMove the run and the walk out of the pageJeremy Magland 1070 await applySelection(currentSpec());
1072 elErr.textContent = formatFailure(e, model.source);
1073 status('failed to compile.');
1074 return;
1076 // Bring up the default selection if it is cached; otherwise show empty
1077 // surfaces. Nothing is ever computed without pressing the button.
1078 flowChain = flowChain.then(() => refresh()).catch(() => undefined);
1079 await flowChain;
1082void boot();
moveopenescclose