/ concept-collection / turing-surface-cache
Sign in
concept-collection / turing-surface-cache
turing-surface-cache / src / sweep.ts
1111 lines · 37.1 KBCodeBlameHistory
2 * The parameter sweep page: one model parameter runs over its whole list of
3 * values while every other choice stays fixed, and a knob steps the display
4 * through the range.
5 *
6 * The cache is what makes the knob instant. Each value of the swept parameter
7 * names one solution (the same specs the main page uses, so the two pages and
8 * the walk all share one cache), and on any selection change the page fetches
9 * all of them at once — a sweep is three to five files of ~90 KB. Each one is
10 * decoded and synthesized to the render grid immediately, so moving the knob
11 * afterwards touches no network and no solver: it recolors the mesh from
12 * values already in memory. One color scale is computed over the whole sweep
13 * and held fixed, so what changes under the knob is the pattern and not the
14 * palette.
15 *
16 * Values nobody has computed show as gaps. Compute missing values runs them
17 * here, one after another through the ordinary local run
18 * (src/cache/runSpec.ts — warm start, background uploads with a key,
19 * divergence guard), and the copyable command at the bottom hands the same
20 * sweep to a machine with no browser on it (src/cli/fill.ts `sweep`). Both
21 * read the sweep from this page's URL fragment, which carries the whole
22 * selection plus which parameter is swept (src/cache/selection.ts).
23 */
24import { requestShtDevice, describeAdapter } from './sht/sht.ts';
25import type { ModelSession } from './mgpu/session.ts';
26import { mModels, mModelByKey, type MModel } from './mgpu/registry.ts';
27import { formatFailure } from './mgpu/errors.ts';
28import { mGeometries, mGeometryByKey } from './geom/registry.ts';
29import {
30 buildTopology,
31 fillPositions,
32 fillFieldValues,
33 fillColors,
34 type SphereMeshTopology,
35} from './render/sphereMesh.ts';
36import { SphereScene } from './render/SphereScene.ts';
37import { Colorbar, floorRange } from './render/colorbar.ts';
38import { colormaps } from './render/colormaps.ts';
39import {
40 MODEL_CHOICES,
41 GEOMETRY_CHOICES,
42 SEED_CHOICE,
43 T_END_CHOICE,
44 LMAX,
45 NITER,
46 defaultChoiceParams,
47 fmtChoice,
48 type DiscreteChoice,
49} from './cache/options.ts';
50import { APP_NAME, stepsFor, type CacheSpec } from './cache/spec.ts';
51import { lookupFor, fetchCached } from './cache/client.ts';
52import { decodeCacheFile } from './cache/h5file.ts';
53import { SolverSession } from './cache/solver.ts';
54import { type RunEvents, type RunSummary } from './cache/runSpec.ts';
55import { fillWalk } from './cache/fillWalk.ts';
56import type { AutoTarget } from './cache/autoWalk.ts';
57import {
58 defaultSelection,
59 fragmentFor,
60 parseValueList,
61 readSelection,
62 readSweep,
63 selectionToParams,
64 specForSelection,
65 sweepChoice,
66 sweepToParams,
67 specsForSweep,
68 type Selection,
69 type SweepSelection,
70} from './cache/selection.ts';
72const $ = <T extends HTMLElement>(id: string): T =>
73 document.getElementById(id) as T;
75const elModel = $<HTMLSelectElement>('model');
76const elSweepOver = $<HTMLSelectElement>('sweepover');
77const elParams = $('params');
78const elGeometry = $<HTMLSelectElement>('geometry');
79const elGeomParams = $('geomparams');
80const elSeed = $<HTMLSelectElement>('seed');
81const elTend = $<HTMLSelectElement>('tend');
82const elCompute = $<HTMLButtonElement>('compute');
83const elStop = $<HTMLButtonElement>('stop');
84const elReset = $<HTMLButtonElement>('reset');
85const elValues = $<HTMLInputElement>('values');
86const elValuesNote = $('valuesnote');
87const elKnob = $<HTMLInputElement>('knob');
88const elTicks = $('ticks');
89const elKnobVal = $('knobval');
90const elStatus = $('status');
91const elPanels = $('panels');
92const elResetView = $<HTMLButtonElement>('resetview');
93const elStats = $('stats');
94const elApiKey = $<HTMLInputElement>('apikey');
95const elUploadNote = $('uploadnote');
96const elCliBar = $('clibar');
97const elCliCmd = $('clicmd');
98const elCliCopy = $<HTMLButtonElement>('clicopy');
99const elCliCopied = $('clicopied');
100const elCliNote = $('clinote');
101const elBackLink = $<HTMLAnchorElement>('backlink');
102const elErr = $('err');
104/** The main page's ?tend test hook, honored here too (src/main.ts). */
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 }
119/** Shared with the main page: one key entered once covers both. */
120const API_KEY_STORAGE = `${APP_NAME}:apiKey`;
121const COLORMAP = colormaps.viridis;
122const OVERSAMPLE = 2;
123const RENDER_EVERY_MS = 250;
124const STATUS_EVERY_MS = 200;
126// ---------------------------------------------------------------- state
127let sel: Selection = defaultSelection();
128/** Which of the model's parameters the knob runs over. */
129let sweepKey = MODEL_CHOICES[sel.model][0].key;
130/** The values it runs over: the parameter's own list until the values box
131 * says otherwise (src/cache/selection.ts). */
132let sweepValues: number[] = [...MODEL_CHOICES[sel.model][0].values];
134/** One value of the sweep: its solution, and where it stands. `fields` is
135 * the decoded final state synthesized onto the render mesh, one array per
136 * species — everything the knob needs, with the session out of the loop.
137 * 'cached' came from the cloud; 'computed' was run here this session (and
138 * is in the cloud too only if a key was present for the uploads). */
139interface SweepEntry {
140 value: number;
141 spec: CacheSpec;
142 status:
143 | 'loading'
144 | 'cached'
145 | 'computed'
146 | 'missing'
147 | 'failed'
148 /** No run can reach the end time from this value (a dt that does not
149 * divide it), so there is nothing to fetch or compute. */
150 | 'unusable'
151 | 'refetch';
152 fields: Float32Array[] | null;
154let entries: SweepEntry[] = [];
156let device: GPUDevice | null = null;
157let solver: SolverSession | null = null;
158let adapterName = '';
159function sess(): ModelSession | null {
160 return solver?.session ?? null;
162const curModel = (): MModel => mModelByKey(sel.model)!;
163const curChoice = (): DiscreteChoice => sweepChoice({ sel, key: sweepKey });
164const curSweep = (): SweepSelection => ({ sel, key: sweepKey, values: sweepValues });
166let generation = 0;
167let busy = false;
168let stopRequested = false;
170// view
171let topo: SphereMeshTopology | null = null;
172let scenes: SphereScene[] = [];
173let colorbars: Colorbar[] = [];
174let colorbarEls: HTMLElement[] = [];
175let colorBufs: Float32Array[] = [];
176/** Scratch per-vertex values for the live view while a value is computing. */
177let liveBufs: Float32Array[] = [];
178/** Smoothed display ranges for that live view (main.ts does the same). */
179let liveRanges: { lo: number; hi: number }[] = [];
180/** The sweep-wide color range per species, fixed while the knob moves. */
181let ranges: { lo: number; hi: number }[] = [];
182let resizeObs: ResizeObserver | null = null;
184const nextFrame = () => new Promise<number>(requestAnimationFrame);
186// ---------------------------------------------------------------- URL state
188 const hash = location.hash.replace(/^#/, '');
189 if (hash) {
190 const p = new URLSearchParams(hash);
191 const sweep = readSweep(p);
192 if (sweep) {
193 sel = sweep.sel;
194 sweepKey = sweep.key;
195 sweepValues = sweep.values;
196 } else {
197 // A main-page link: same selection, sweeping the first parameter over
198 // its own list.
199 sel = readSelection(p);
200 sweepKey = MODEL_CHOICES[sel.model][0].key;
201 sweepValues = [...MODEL_CHOICES[sel.model][0].values];
202 }
203 }
206function writeUrlState(): void {
207 const p = fragmentFor(sweepToParams(curSweep()));
208 history.replaceState(null, '', `${location.pathname}${location.search}#${p}`);
209 // Back to the main page on the same selection (the knob's value travels as
210 // the swept parameter's value; the search part keeps the ?tend test hook).
211 elBackLink.href = `index.html${location.search}#${fragmentFor(selectionToParams(sel))}`;
212 updateCliCommand();
215// ---------------------------------------------------------------- controls
216function makeSelect(
217 choice: DiscreteChoice,
218 get: () => number,
219 set: (v: number) => void,
220): HTMLLabelElement {
221 const label = document.createElement('label');
222 label.textContent = `${choice.label} `;
223 const select = document.createElement('select');
224 for (const v of choice.values) {
225 const opt = document.createElement('option');
226 opt.value = String(v);
227 opt.textContent = fmtChoice(v);
228 select.append(opt);
229 }
230 select.value = String(get());
231 select.addEventListener('change', () => {
232 set(Number(select.value));
233 onSelectionChange();
234 });
235 label.append(select);
236 return label;
239/** The fixed parameters: every model parameter except the swept one, which
240 * lives on the knob instead. */
241function buildParamControls(): void {
242 elParams.replaceChildren();
243 for (const choice of MODEL_CHOICES[sel.model]) {
244 if (choice.key === sweepKey) continue;
245 elParams.append(
246 makeSelect(choice, () => sel.params[choice.key], (v) => (sel.params[choice.key] = v)),
247 );
248 }
251function buildSweepOverControl(): void {
252 elSweepOver.replaceChildren();
253 for (const choice of MODEL_CHOICES[sel.model]) {
254 const opt = document.createElement('option');
255 opt.value = choice.key;
256 opt.textContent = choice.label;
257 elSweepOver.append(opt);
258 }
259 elSweepOver.value = sweepKey;
262/**
263 * The values box: what the knob runs over, written out. It starts as the
264 * parameter's own list, which is what the main page's dropdown offers and
265 * what the auto-fill walk surveys, but anything may be typed in its place.
266 * This is the one control in the app that is not a choice from a list. A
267 * value off the list still names one exact solution and one exact cache
268 * entry, since the spec is hashed from the number rather than from the list
269 * position, so a sweep over typed values is cached and shared like any
270 * other. Of course, the walk only fills the listed combinations, so such a
271 * sweep will not already be there.
272 */
273function showValues(): void {
274 elValues.value = sweepValues.map(fmtChoice).join(', ');
275 const listed = curChoice().values;
276 const custom =
277 sweepValues.length !== listed.length || sweepValues.some((v, i) => v !== listed[i]);
278 elValuesNote.textContent = custom
279 ? `${sweepValues.length} values (the offered list is ${listed.map(fmtChoice).join(', ')})`
280 : 'the offered values';
283/** Read the box back. An empty box means the parameter's own list; values
284 * that are not numbers are dropped by parseValueList and the box is
285 * rewritten with what was understood, so it never disagrees with the knob. */
286function applyValues(): void {
287 const parsed = parseValueList(elValues.value);
288 sweepValues = parsed.length ? parsed : [...curChoice().values];
289 if (!sweepValues.includes(sel.params[sweepKey])) sel.params[sweepKey] = sweepValues[0];
290 showValues();
291 onSelectionChange();
294function buildGeomParamControls(): void {
295 elGeomParams.replaceChildren();
296 for (const choice of GEOMETRY_CHOICES[sel.geometry]) {
297 elGeomParams.append(
298 makeSelect(
299 choice,
300 () => sel.geometryParams[choice.key],
301 (v) => (sel.geometryParams[choice.key] = v),
302 ),
303 );
304 }
307function buildControls(): void {
308 for (const m of mModels) {
309 const opt = document.createElement('option');
310 opt.value = m.key;
311 opt.textContent = m.label;
312 elModel.append(opt);
313 }
314 elModel.value = sel.model;
315 elModel.addEventListener('change', () => {
316 sel.model = elModel.value;
317 sel.params = defaultChoiceParams(MODEL_CHOICES[sel.model]);
318 if (!MODEL_CHOICES[sel.model].some((c) => c.key === sweepKey)) {
319 sweepKey = MODEL_CHOICES[sel.model][0].key;
320 }
321 // Another model's parameter means another quantity: a typed list for the
322 // old one would rarely be meaningful for the new one, so the values go
323 // back to what this model offers.
324 sweepValues = [...curChoice().values];
325 buildSweepOverControl();
326 buildParamControls();
327 showValues();
328 onSelectionChange();
329 });
330 buildSweepOverControl();
331 elSweepOver.addEventListener('change', () => {
332 // The previously swept parameter keeps the value the knob was on and
333 // returns to the fixed row; the newly swept one moves onto the knob,
334 // over its own list.
335 sweepKey = elSweepOver.value;
336 sweepValues = [...curChoice().values];
337 if (!sweepValues.includes(sel.params[sweepKey])) sel.params[sweepKey] = sweepValues[0];
338 buildParamControls();
339 showValues();
340 onSelectionChange();
341 });
342 buildParamControls();
343 showValues();
344 // Applied on Enter or on leaving the box, not per keystroke: each change
345 // refetches the whole sweep.
346 elValues.addEventListener('change', () => applyValues());
348 for (const g of mGeometries) {
349 const opt = document.createElement('option');
350 opt.value = g.key;
351 opt.textContent = g.label.toLowerCase();
352 elGeometry.append(opt);
353 }
354 elGeometry.value = sel.geometry;
355 elGeometry.addEventListener('change', () => {
356 sel.geometry = elGeometry.value;
357 sel.geometryParams = defaultChoiceParams(GEOMETRY_CHOICES[sel.geometry]);
358 buildGeomParamControls();
359 onSelectionChange();
360 });
361 buildGeomParamControls();
363 for (const v of SEED_CHOICE.values) {
364 const opt = document.createElement('option');
365 opt.value = String(v);
366 opt.textContent = String(v);
367 elSeed.append(opt);
368 }
369 elSeed.value = String(sel.seed);
370 elSeed.addEventListener('change', () => {
371 sel.seed = Number(elSeed.value);
372 onSelectionChange();
373 });
375 for (const v of T_END_CHOICE.values) {
376 const opt = document.createElement('option');
377 opt.value = String(v);
378 opt.textContent = String(v);
379 elTend.append(opt);
380 }
381 elTend.value = String(sel.tEnd);
382 elTend.addEventListener('change', () => {
383 sel.tEnd = Number(elTend.value);
384 onSelectionChange();
385 });
388function resetDefaults(): void {
389 sel = defaultSelection();
390 sweepKey = MODEL_CHOICES[sel.model][0].key;
391 sweepValues = [...curChoice().values];
392 elModel.value = sel.model;
393 buildSweepOverControl();
394 buildParamControls();
395 showValues();
396 elGeometry.value = sel.geometry;
397 buildGeomParamControls();
398 elSeed.value = String(sel.seed);
399 elTend.value = String(sel.tEnd);
400 onSelectionChange();
403/** Selection changes reload the whole sweep; chained so two flows never talk
404 * to the session at once (same discipline as src/main.ts). */
405let flowChain: Promise<void> = Promise.resolve();
406function onSelectionChange(): void {
407 writeUrlState();
408 flowChain = flowChain.then(() => reloadSweep()).catch(() => undefined);
411// ---------------------------------------------------------------- the knob
412function knobIndex(): number {
413 const i = sweepValues.indexOf(sel.params[sweepKey]);
414 return i >= 0 ? i : 0;
417function rebuildKnob(): void {
418 elKnob.min = '0';
419 elKnob.max = String(Math.max(0, sweepValues.length - 1));
420 elKnob.step = '1';
421 elKnob.disabled = busy || sweepValues.length < 2;
422 elKnob.value = String(knobIndex());
423 elTicks.replaceChildren(
424 ...sweepValues.map((v, i) => {
425 const b = document.createElement('button');
426 b.className = 'tick';
427 b.textContent = fmtChoice(v);
428 b.addEventListener('click', () => {
429 if (!busy) setKnob(i);
430 });
431 return b;
432 }),
433 );
434 updateTicks();
437function updateTicks(): void {
438 const idx = knobIndex();
439 const label = curChoice().label;
440 elTicks.querySelectorAll<HTMLButtonElement>('.tick').forEach((b, i) => {
441 const e = entries[i];
442 b.classList.toggle('cached', e?.status === 'cached' || e?.status === 'computed');
443 b.classList.toggle('current', i === idx);
444 b.title =
445 e?.status === 'cached'
446 ? 'in the cloud cache'
447 : e?.status === 'computed'
448 ? 'computed here'
449 : e?.status === 'missing'
450 ? 'not computed yet'
451 : e?.status === 'unusable'
452 ? `no whole number of steps reaches t = ${fmtChoice(sel.tEnd)} at this dt`
453 : e?.status === 'failed'
454 ? 'unavailable'
455 : '';
456 });
457 elKnobVal.textContent = sweepValues.length
458 ? `${label} = ${fmtChoice(sweepValues[idx])}`
459 : `no values to sweep ${label} over`;
462/** Point the knob at value index `i` and show what is there. Pure display:
463 * no network, no solver — that is what the up-front loading bought. */
464function setKnob(i: number): void {
465 if (!sweepValues.length) return;
466 sel.params[sweepKey] = sweepValues[i];
467 elKnob.value = String(i);
468 writeUrlState();
469 showCurrent();
472elKnob.addEventListener('input', () => {
473 if (busy) return;
474 setKnob(Number(elKnob.value));
475});
477// ---------------------------------------------------------------- view
478function disposeView(): void {
479 for (const s of scenes) s.dispose();
480 scenes = [];
481 colorbars = [];
482 colorbarEls = [];
483 topo = null;
484 resizeObs?.disconnect();
485 resizeObs = null;
486 elPanels.replaceChildren();
489function buildView(surface: Float32Array): void {
490 const session = sess();
491 if (!session) return;
492 const view = session.viewSht;
493 const { nphi } = view.cfg;
494 const phi = new Float64Array(nphi);
495 for (let j = 0; j < nphi; j++) phi[j] = (2 * Math.PI * j) / nphi;
496 topo = buildTopology(view.cosTheta, phi);
497 const posBuf = new Float32Array(topo.numVertices * 3);
498 fillPositions(posBuf, surface, topo, 1);
500 const sphereBg = getComputedStyle(document.documentElement)
501 .getPropertyValue('--sphere-bg')
502 .trim();
503 const model = curModel();
504 colorBufs = [];
505 liveBufs = [];
506 liveRanges = [];
507 ranges = [];
508 for (let k = 0; k < model.species.length; k++) {
509 const panel = document.createElement('div');
510 panel.className = 'panel';
511 const box = document.createElement('div');
512 box.className = 'sphere-box';
513 const tag = document.createElement('div');
514 tag.className = 'species-tag';
515 tag.textContent = model.species[k];
516 box.append(tag);
517 const side = document.createElement('div');
518 panel.append(box, side);
519 elPanels.append(panel);
521 const scene = new SphereScene(
522 box,
523 topo.numVertices,
524 topo.indices,
525 Float32Array.from(posBuf),
526 sphereBg || undefined,
527 );
528 scene.fitCamera();
529 scenes.push(scene);
530 colorbars.push(new Colorbar(side));
531 colorbarEls.push(side);
532 colorBufs.push(new Float32Array(topo.numVertices * 3));
533 liveBufs.push(new Float32Array(topo.numVertices));
534 liveRanges.push({ lo: NaN, hi: NaN });
535 ranges.push({ lo: NaN, hi: NaN });
536 }
537 for (let k = 1; k < scenes.length; k++) scenes[0].syncCamerasWith(scenes[k]);
539 resizeObs = new ResizeObserver(() => {
540 const boxes = elPanels.querySelectorAll<HTMLElement>('.sphere-box');
541 boxes.forEach((box, i) => {
542 scenes[i]?.resize(box.clientWidth, box.clientHeight);
543 });
544 });
545 elPanels
546 .querySelectorAll<HTMLElement>('.sphere-box')
547 .forEach((box) => resizeObs!.observe(box));
550/** Rebuild mesh and panels from the session's surface, keeping the camera. */
551async function rebuildViewFromSession(): Promise<void> {
552 const session = sess();
553 if (!session) return;
554 const surface = await session.renderPositions();
555 const cam = scenes[0]?.cameraState();
556 disposeView();
557 buildView(surface);
558 if (cam) for (const s of scenes) s.setCameraState(cam);
559 grayDisplay();
562/** The shape with no field on it (NaN renders neutral gray in fillColors). */
563function grayDisplay(): void {
564 if (!topo) return;
565 for (let k = 0; k < scenes.length; k++) {
566 liveBufs[k].fill(NaN);
567 fillColors(colorBufs[k], liveBufs[k], 0, 1, COLORMAP);
568 scenes[k].updateColors(colorBufs[k]);
569 colorbarEls[k].style.visibility = 'hidden';
570 }
573/**
574 * The sweep-wide color range, per species, over every loaded value. Fixed
575 * while the knob moves, so colors mean the same thing at every position;
576 * recomputed only when the set of loaded values changes.
577 */
578function recomputeRanges(): void {
579 for (let k = 0; k < ranges.length; k++) {
580 let lo = Infinity;
581 let hi = -Infinity;
582 for (const e of entries) {
583 const f = e.fields?.[k];
584 if (!f) continue;
585 for (const v of f) {
586 if (v < lo) lo = v;
587 if (v > hi) hi = v;
588 }
589 }
590 ranges[k] = lo <= hi ? floorRange(lo, hi) : { lo: NaN, hi: NaN };
591 }
594/** Show the knob's current value from the in-memory fields. */
595function showCurrent(): void {
596 updateTicks();
597 const entry = entries[knobIndex()];
598 if (topo && entry?.fields) {
599 for (let k = 0; k < scenes.length; k++) {
600 fillColors(colorBufs[k], entry.fields[k], ranges[k].lo, ranges[k].hi, COLORMAP);
601 scenes[k].updateColors(colorBufs[k]);
602 colorbars[k].update(COLORMAP, ranges[k].lo, ranges[k].hi);
603 colorbarEls[k].style.visibility = '';
604 }
605 } else {
606 grayDisplay();
607 }
608 updateStats();
609 if (!busy) updateSweepNote();
612function updateStats(): void {
613 const session = sess();
614 if (!session) return;
615 const { nlat, nphi } = session.cfg;
616 const entry = entries[knobIndex()];
617 const showing = entry?.fields
618 ? ` · showing <b>${curChoice().label} = ${fmtChoice(entry.value)}</b>` +
619 ` at t = <b>${fmtChoice(sel.tEnd)}</b>`
620 : '';
621 elStats.innerHTML =
622 `<b>WebGPU fp32${adapterName ? ` — ${adapterName}` : ''}</b> · ` +
623 `grid ${nlat}×${nphi} · lmax ${LMAX} · solve iters ${NITER}${showing}`;
626// ---------------------------------------------------------------- statuses
627function status(html: string): void {
628 elStatus.innerHTML = html;
631const isMissing = (e: SweepEntry): boolean =>
632 e.status === 'missing' || e.status === 'failed';
634/** The idle status line: how much of the sweep is there, and what to do
635 * about the rest. */
636function updateSweepNote(): void {
637 if (entries.some((e) => e.status === 'loading')) return;
638 const n = entries.length;
639 const loaded = entries.filter((e) => e.fields).length;
640 const cloud = entries.filter((e) => e.status === 'cached').length;
641 const unusable = entries.filter((e) => e.status === 'unusable').length;
642 const label = curChoice().label;
643 // "in the cloud cache" only when that is where they all came from: a
644 // keyless local compute loads a value without contributing it.
645 const what = cloud === loaded ? 'in the cloud cache' : 'loaded';
646 const aside = unusable
647 ? ` ${unusable} of them cannot be solved to t = ${fmtChoice(sel.tEnd)} at all.`
648 : '';
649 if (loaded === n) {
650 const computedHere = n - cloud ? ` (${n - cloud} computed here)` : '';
651 status(`all <b>${n} values</b> of ${label} are ${what}${computedHere} — drag the knob.`);
652 return;
653 }
654 const entry = entries[knobIndex()];
655 const here =
656 entry && !entry.fields && entry.status !== 'unusable'
657 ? ` <b>${label} = ${fmtChoice(entry.value)}</b> is one of them.`
658 : '';
659 const todo = n - loaded - unusable;
660 status(
661 `<b>${loaded} of ${n}</b> values ${what}; ${todo} not computed yet.${here}${aside} ` +
662 (todo
663 ? `<b>Compute missing values</b> runs them in your browser, one after another.`
664 : ''),
665 );
668function setBusy(next: boolean): void {
669 busy = next;
670 elStop.hidden = !next;
671 elReset.disabled = next;
672 elKnob.disabled = next || sweepValues.length < 2;
673 elValues.disabled = next;
674 document
675 .querySelectorAll<HTMLSelectElement>('main .controls select')
676 .forEach((s) => (s.disabled = next));
677 updateComputeButton();
680function updateComputeButton(): void {
681 elCompute.disabled = busy || !device || !entries.some(isMissing);
684// ---------------------------------------------------------------- loading
685/** Synthesize per-vertex render values from the state the session holds. */
686async function fieldsFromSession(): Promise<Float32Array[]> {
687 const session = sess();
688 if (!session || !topo) throw new Error('no view to synthesize into');
689 const out: Float32Array[] = [];
690 for (let k = 0; k < curModel().species.length; k++) {
691 const field = await session.readSpecies(k);
692 const vals = new Float32Array(topo.numVertices);
693 fillFieldValues(vals, field, topo);
694 out.push(vals);
695 }
696 return out;
699/** The same, for a decoded cache file: load its final state first. */
700async function fieldsFromState(state: Record<string, Float32Array>): Promise<Float32Array[]> {
701 const session = sess();
702 if (!session) throw new Error('no solver session');
703 session.loadState(state);
704 return fieldsFromSession();
707/**
708 * Bring the page in line with the selection: build the sweep's entries, apply
709 * the base spec to the solver (recompiling or re-evaluating the surface only
710 * when the model or geometry changed), then fetch every value's cache file at
711 * once. Fetches run in parallel; the GPU synthesis of whatever arrives is
712 * serialized through one chain, since the session is one machine.
713 */
714async function reloadSweep(): Promise<void> {
715 if (!device || !solver || busy) return;
716 generation++;
717 const gen = generation;
718 elErr.textContent = '';
719 // A typed dt that does not divide the end time names a run that cannot land
720 // on it, which stepsFor refuses. Caught here rather than in the middle of a
721 // walk, where it would arrive as a failure per value.
722 entries = specsForSweep(curSweep()).map(({ value, spec }) => {
723 let usable = true;
724 try {
725 stepsFor(spec);
726 } catch {
727 usable = false;
728 }
729 return {
730 value,
731 spec,
732 status: usable ? ('loading' as const) : ('unusable' as const),
733 fields: null,
734 };
735 });
736 const unusable = entries.filter((e) => e.status === 'unusable');
737 if (unusable.length) {
738 elErr.textContent =
739 `${unusable.map((e) => `${curChoice().label} = ${fmtChoice(e.value)}`).join(', ')}: ` +
740 `the end time ${fmtChoice(sel.tEnd)} is not a whole number of steps at this dt`;
741 }
742 rebuildKnob();
743 updateComputeButton();
744 status('checking the cloud cache…');
745 try {
746 await solver.apply(specForSelection(sel));
747 } catch (e) {
748 if (gen === generation) {
749 elErr.textContent = formatFailure(e, curModel().source);
750 status('failed.');
751 }
752 return;
753 }
754 if (gen !== generation) return;
755 grayDisplay();
756 updateStats();
758 let synth: Promise<void> = Promise.resolve();
759 await Promise.all(
760 entries.map(async (entry) => {
761 if (entry.status === 'unusable') return;
762 let bytes: Uint8Array | null = null;
763 let unreachable = false;
764 const lookup = await lookupFor(entry.spec);
765 try {
766 bytes = await fetchCached(lookup);
767 } catch {
768 unreachable = true;
769 }
770 if (gen !== generation) return;
771 if (!bytes) {
772 entry.status = unreachable ? 'failed' : 'missing';
773 if (unreachable) elErr.textContent = 'cloud cache unreachable';
774 entrySettled(gen, entry);
775 return;
776 }
777 const data = bytes;
778 synth = synth.then(async () => {
779 if (gen !== generation) return;
780 try {
781 const decoded = await decodeCacheFile(data, lookup.specJson, curModel().state);
782 if (gen !== generation) return;
783 entry.fields = await fieldsFromState(decoded.final);
784 entry.status = 'cached';
785 } catch (e) {
786 entry.status = 'failed';
787 elErr.textContent = `${curChoice().label} = ${fmtChoice(entry.value)}: ${
788 e instanceof Error ? e.message : e
789 }`;
790 }
791 entrySettled(gen, entry);
792 });
793 await synth;
794 }),
795 );
796 if (gen !== generation) return;
797 updateComputeButton();
798 updateSweepNote();
801/** A value's fate is known (loaded, missing, or broken): fold it into the
802 * common color range and the display as it lands, not at the end. */
803function entrySettled(gen: number, entry: SweepEntry): void {
804 if (gen !== generation) return;
805 if (entry.fields) recomputeRanges();
806 showCurrent();
809// ---------------------------------------------------------------- computing
810/** The live view while a value computes (main.ts's draw, with the smoothed
811 * self-scaling range — the sweep-wide scale takes over once it is done). */
812async function drawLive(gen: number): Promise<void> {
813 const session = sess();
814 if (!session || !topo) return;
815 for (let k = 0; k < scenes.length; k++) {
816 let field: Float32Array;
817 try {
818 field = await session.readSpecies(k);
819 } catch (e) {
820 if (gen !== generation) return;
821 throw e;
822 }
823 if (gen !== generation || !topo) return;
824 fillFieldValues(liveBufs[k], field, topo);
825 let lo = Infinity;
826 let hi = -Infinity;
827 for (const v of liveBufs[k]) {
828 if (v < lo) lo = v;
829 if (v > hi) hi = v;
830 }
831 const r = liveRanges[k];
832 if (!Number.isFinite(r.lo)) {
833 r.lo = lo;
834 r.hi = hi;
835 } else {
836 const a = 0.15;
837 r.lo += a * (lo - r.lo);
838 r.hi += a * (hi - r.hi);
839 }
840 const shown = floorRange(r.lo, r.hi);
841 fillColors(colorBufs[k], liveBufs[k], shown.lo, shown.hi, COLORMAP);
842 scenes[k].updateColors(colorBufs[k]);
843 colorbars[k].update(COLORMAP, shown.lo, shown.hi);
844 colorbarEls[k].style.visibility = '';
845 }
848/**
849 * Compute the sweep's uncached values here, in value order, watching each
850 * pattern form. Every run is the ordinary local computation (warm start from
851 * a shorter cached run, snapshots uploaded in the background when a key is
852 * present, divergence guard). The knob follows along so the URL and the
853 * readout always say which value is being computed.
854 */
855async function computeMissing(): Promise<void> {
856 if (!device || !solver || busy) return;
857 const missing = entries.filter(isMissing);
858 if (!missing.length) return;
859 setBusy(true);
860 stopRequested = false;
861 elErr.textContent = '';
862 generation++;
863 const gen = generation;
864 const label = curChoice().label;
865 let computing: SweepEntry | null = null;
866 let uploads = 0;
867 let lastStatus = 0;
868 let lastDraw = 0;
870 const runLine = (run: RunSummary): string =>
871 `<b>${label} = ${fmtChoice(computing?.value ?? NaN)}</b> — computed in ` +
872 `${run.seconds.toFixed(1)} s` +
873 (run.warmFrom !== null ? ` (resumed from cached t = ${fmtChoice(run.warmFrom)})` : '') +
874 '.';
876 const runEvents: RunEvents = {
877 onPhase(phase) {
878 const v = fmtChoice(computing?.value ?? NaN);
879 if (phase.kind === 'warm-search') {
880 status(`${label} = ${v}: looking for a shorter cached run…`);
881 } else if (phase.kind === 'seeding') {
882 status(`<b>computing ${label} = ${v}</b>: seeding…`);
883 } else if (phase.kind === 'encoding') {
884 status(`${runLine(phase.run)} Writing the cache file…`);
885 } else {
886 status(`${runLine(phase.run)} Uploading (${phase.uploaded}/${phase.started})…`);
887 }
888 },
889 onProgress(p) {
890 const now = performance.now();
891 if (now - lastStatus < STATUS_EVERY_MS) return;
892 lastStatus = now;
893 const from = p.warmFrom !== null ? `resumed from cached t = ${fmtChoice(p.warmFrom)} — ` : '';
894 const up = p.uploadsStarted
895 ? `, uploaded ${p.uploadsDone}/${p.uploadsStarted} snapshots`
896 : '';
897 status(
898 `<b>computing ${label} = ${fmtChoice(computing?.value ?? NaN)}</b> (${from}` +
899 `t = ${p.t.toFixed(2)} / ${fmtChoice(p.tEnd)}, ${(100 * p.fraction).toFixed(0)}%, ` +
900 `${p.rate.toFixed(0)} steps/s${up})`,
901 );
902 },
903 onStepping() {
904 for (const r of liveRanges) {
905 r.lo = NaN;
906 r.hi = NaN;
907 }
908 },
909 async onTick() {
910 // As on the main page: no rendering while hidden, and never a wait on
911 // an animation frame there, so a background tab computes at full speed.
912 const now = performance.now();
913 if (document.hidden || now - lastDraw <= RENDER_EVERY_MS) return;
914 lastDraw = now;
915 await drawLive(gen);
916 if (gen !== generation) return;
917 await nextFrame();
918 },
919 async onFinal() {
920 // The session holds the finished state: synthesize it into the sweep
921 // while it is there, and the value joins the knob's range.
922 if (!computing) return;
923 computing.fields = await fieldsFromSession();
924 computing.status = 'computed';
925 recomputeRanges();
926 updateTicks();
927 },
928 onUploaded: () => void uploads++,
929 cancelled: () => gen !== generation,
930 stopRequested: () => stopRequested,
931 };
933 await fillWalk({
934 targets: missing.map(
935 (e): AutoTarget => ({
936 model: sel.model,
937 params: { ...e.spec.params },
938 geometry: sel.geometry,
939 geometryParams: { ...e.spec.geometryParams },
940 distance: 0,
941 }),
942 ),
943 solver,
944 adapter: adapterName,
945 runtime: 'browser-webgpu',
946 apiKey: () => elApiKey.value.trim(),
947 beforeTarget(target) {
948 const entry = entries.find((e) => e.spec.params[sweepKey] === target.params[sweepKey])!;
949 computing = entry;
950 // The knob follows the walk, so the page always says what is running.
951 sel.params[sweepKey] = entry.value;
952 elKnob.value = String(knobIndex());
953 writeUrlState();
954 updateTicks();
955 return entry.spec;
956 },
957 events: {
958 ...runEvents,
959 onTarget: () => status('checking the cloud cache…'),
960 onCached(_target) {
961 // Somebody else computed it since the page loaded: fetch it after
962 // the walk rather than recomputing it here.
963 if (computing) computing.status = 'refetch';
964 },
965 onOutcome(_target, _spec, outcome) {
966 if (outcome.kind === 'diverged' && computing) {
967 computing.status = 'failed';
968 elErr.textContent =
969 `${label} = ${fmtChoice(computing.value)}: the solution went non-finite at ` +
970 `t = ${outcome.t.toFixed(2)} — nothing uploaded (unstable at this dt)`;
971 }
972 updateTicks();
973 },
974 onFailure(_target, spec, e) {
975 if (computing) computing.status = 'failed';
976 elErr.textContent = `${label} = ${fmtChoice(spec.params[sweepKey])}: ${formatFailure(
977 e,
978 curModel().source,
979 )}`;
980 updateTicks();
981 },
982 walkStopped: () => stopRequested || gen !== generation,
983 },
984 });
985 if (gen !== generation) return;
987 // Values that turned out to be cached meanwhile (or that a stopped run
988 // uploaded on the way past) are fetched like any other cache hit.
989 for (const entry of entries) {
990 if (entry.status !== 'refetch') continue;
991 try {
992 const lookup = await lookupFor(entry.spec);
993 const bytes = await fetchCached(lookup);
994 if (gen !== generation) return;
995 if (!bytes) {
996 entry.status = 'missing';
997 continue;
998 }
999 const decoded = await decodeCacheFile(bytes, lookup.specJson, curModel().state);
1000 if (gen !== generation) return;
1001 entry.fields = await fieldsFromState(decoded.final);
1002 entry.status = 'cached';
1003 } catch {
1004 entry.status = 'failed';
1007 if (gen !== generation) return;
1009 setBusy(false);
1010 recomputeRanges();
1011 showCurrent();
1012 if (stopRequested) {
1013 status('stopped.' + (uploads ? ` ${uploads} file${uploads > 1 ? 's' : ''} uploaded.` : ''));
1014 } else {
1015 updateSweepNote();
1019// ---------------------------------------------------------------- cloud
1020function updateUploadNote(): void {
1021 const hasKey = elApiKey.value.trim().length > 0;
1022 elUploadNote.textContent = hasKey
1023 ? 'uploads enabled — locally computed solutions will be contributed'
1024 : '';
1025 elCliBar.hidden = !hasKey;
1026 elCliNote.hidden = !hasKey;
1027 updateCliCommand();
1028 elCliCopied.textContent = '';
1031/**
1032 * The command that fills exactly this sweep on a machine with no browser: the
1033 * page's own URL is the argument, so there is one serialization of what a
1034 * sweep is (src/cache/selection.ts) and a colleague can paste the same URL
1035 * into a browser to see the result. Key masked on screen, real in the
1036 * clipboard, as on the main page.
1037 */
1038function sweepFillCommand(key: string): string {
1039 const url = new URL(`fill.tgz?v=${__BUILD_ID__}`, location.href).href;
1040 return `TURING_SURFACE_CACHE_KEY=${key} npx ${url} sweep '${location.href}'`;
1043function updateCliCommand(): void {
1044 if (!elCliBar.hidden) elCliCmd.textContent = sweepFillCommand('…');
1047elCliCopy.addEventListener('click', () => {
1048 const key = elApiKey.value.trim();
1049 if (!key) return;
1050 navigator.clipboard.writeText(sweepFillCommand(key)).then(
1051 () => {
1052 elCliCopied.textContent = 'copied';
1053 setTimeout(() => (elCliCopied.textContent = ''), 4000);
1054 },
1055 () => {
1056 elCliCmd.textContent = sweepFillCommand(key);
1057 elCliCopied.textContent = 'clipboard unavailable — the key is now shown above';
1058 },
1059 );
1060});
1062elApiKey.addEventListener('change', () => {
1063 const key = elApiKey.value.trim();
1064 if (key) localStorage.setItem(API_KEY_STORAGE, key);
1065 else localStorage.removeItem(API_KEY_STORAGE);
1066 updateUploadNote();
1067});
1069// ---------------------------------------------------------------- boot
1070elCompute.addEventListener('click', () => {
1071 flowChain = flowChain.then(() => computeMissing()).catch(() => undefined);
1072});
1073elStop.addEventListener('click', () => {
1074 stopRequested = true;
1075});
1076elReset.addEventListener('click', () => resetDefaults());
1077elResetView.addEventListener('click', () => {
1078 for (const s of scenes) s.resetCamera();
1079});
1081async function boot(): Promise<void> {
1082 buildControls();
1083 rebuildKnob();
1084 writeUrlState();
1085 elApiKey.value = localStorage.getItem(API_KEY_STORAGE) ?? '';
1086 updateUploadNote();
1087 try {
1088 device = await requestShtDevice();
1089 solver = new SolverSession(device, OVERSAMPLE, {
1090 onCompiling: (m) => status(`compiling ${m.label}…`),
1091 onSurface: () => rebuildViewFromSession(),
1092 });
1093 adapterName = await describeAdapter(device);
1094 } catch (e) {
1095 device = null;
1096 solver = null;
1097 elErr.textContent =
1098 `WebGPU is not available (${e instanceof Error ? e.message : e}). ` +
1099 `Use a WebGPU-capable browser such as Chrome or Edge.`;
1100 return;
1102 device.lost.then((info) => {
1103 if (info.reason !== 'destroyed') {
1104 elErr.textContent = `WebGPU device lost: ${info.message}`;
1106 });
1107 flowChain = flowChain.then(() => reloadSweep()).catch(() => undefined);
1108 await flowChain;
1111void boot();
moveopenescclose