/** * Browser validation suite: fp32 WebGPU transforms vs the f64 reference. * Results are written to #log, console, and window.__RESULTS__ (read by * scripts/test-gpu.mjs). */ import { ShtPlan, requestShtDevice } from '../src/sht.ts'; import { ShtReference, randomSpectrum } from '../src/reference.ts'; import { lmIndex, type ShtConfig } from '../src/layout.ts'; import type { FourierMode } from '../src/sht.ts'; interface CaseResult { name: string; pass: boolean; detail: string; } const results: CaseResult[] = []; const logEl = document.getElementById('log')!; function log(line: string) { console.log(line); logEl.textContent += '\n' + line; } function relL2(a: ArrayLike, b: ArrayLike): number { let num = 0, den = 0; for (let i = 0; i < a.length; i++) { num += (a[i] - b[i]) ** 2; den += b[i] ** 2; } return Math.sqrt(num / (den || 1)); } async function runCase( device: GPUDevice, name: string, cfg: ShtConfig, fourier: FourierMode, tolSynth: number, tolAnalys: number, tolRound: number, ) { const t0 = performance.now(); const plan = await ShtPlan.create(device, cfg, { fourier }); const ref = new ShtReference(cfg); const q0 = randomSpectrum(cfg, 42 + cfg.lmax); // synthesis vs f64 reference const spatGpu = await plan.synth(q0); const spatRef = ref.synth(q0); const eSynth = relL2(spatGpu, spatRef); // analysis of the reference field vs f64 reference const spatRef32 = Float32Array.from(spatRef); const qGpu = await plan.analys(spatRef32); const qRef = ref.analys(spatRef32); const eAnalys = relL2(qGpu, qRef); // GPU round trip: synth -> analys, compare to original spectrum const qRound = await plan.analys(spatGpu); const eRound = relL2(qRound, q0); const dt = (performance.now() - t0).toFixed(0); const pass = eSynth < tolSynth && eAnalys < tolAnalys && eRound < tolRound; const detail = `mode=${plan.fourierMode} synth=${eSynth.toExponential(2)}/${tolSynth} ` + `analys=${eAnalys.toExponential(2)}/${tolAnalys} round=${eRound.toExponential(2)}/${tolRound} (${dt}ms)`; results.push({ name, pass, detail }); log(`${pass ? 'PASS' : 'FAIL'} ${name} ${detail}`); plan.destroy(); } async function runSingleModeCase(device: GPUDevice, name: string, cfg: ShtConfig, l: number, m: number) { // synthesize a single (l, m) mode and analyze it back: spectrum should // come back as the unit vector, and the field should match Y_lm exactly. const plan = await ShtPlan.create(device, cfg, {}); const q = new Float32Array(2 * plan.nlm); q[2 * lmIndex(cfg.lmax, l, m)] = 1.0; const spat = await plan.synth(q); const qBack = await plan.analys(spat); const e = relL2(qBack, q); const pass = e < 2e-5; results.push({ name, pass, detail: `round=${e.toExponential(2)}` }); log(`${pass ? 'PASS' : 'FAIL'} ${name} round=${e.toExponential(2)}`); plan.destroy(); } async function main() { if (!navigator.gpu) { (window as any).__RESULTS__ = { fatal: 'navigator.gpu undefined (WebGPU unavailable)' }; log('FATAL: WebGPU unavailable'); return; } const adapter = await navigator.gpu.requestAdapter(); const info = adapter ? `${adapter.info?.vendor ?? '?'} / ${adapter.info?.architecture ?? '?'}` : 'none'; log(`adapter: ${info}`); const device = await requestShtDevice(); device.addEventListener('uncapturederror', (ev) => { log(`UNCAPTURED GPU ERROR: ${(ev as GPUUncapturedErrorEvent).error.message}`); }); try { // small, FFT path await runCase(device, 'lmax=15 fft', { lmax: 15, mmax: 15, nlat: 32, nphi: 32 }, 'fft', 2e-6, 2e-6, 2e-6); // small, DFT path (cross-check of the Fourier stages) await runCase(device, 'lmax=15 dft', { lmax: 15, mmax: 15, nlat: 32, nphi: 36 }, 'dft', 2e-6, 2e-6, 2e-6); // moderate await runCase(device, 'lmax=63 fft', { lmax: 63, mmax: 63, nlat: 64, nphi: 128 }, 'auto', 5e-6, 5e-6, 5e-6); // the SHTNS fp32 comfort zone boundary (SHT_L_RESCALE_FLY_FLOAT = 128) await runCase(device, 'lmax=127 fft', { lmax: 127, mmax: 127, nlat: 128, nphi: 256 }, 'auto', 1e-5, 1e-5, 1e-5); // reduced mmax await runCase(device, 'lmax=127 mmax=40', { lmax: 127, mmax: 40, nlat: 144, nphi: 128 }, 'auto', 1e-5, 1e-5, 1e-5); // beyond the comfort zone: rescaling must kick in (sin^m underflows f32 // around m ~ 90 at mid-latitudes); accuracy degrades gracefully await runCase(device, 'lmax=255', { lmax: 255, mmax: 255, nlat: 256, nphi: 512 }, 'auto', 5e-5, 5e-5, 5e-5); await runCase(device, 'lmax=399', { lmax: 399, mmax: 399, nlat: 400, nphi: 1024 }, 'auto', 2e-4, 2e-4, 2e-4); // single-mode checks incl. a high-m sectoral mode (pure rescale territory) await runSingleModeCase(device, 'mode (l=3,m=2)', { lmax: 15, mmax: 15, nlat: 32, nphi: 32 }, 3, 2); await runSingleModeCase(device, 'mode (l=200,m=200)', { lmax: 200, mmax: 200, nlat: 224, nphi: 512 }, 200, 200); } catch (e) { results.push({ name: 'exception', pass: false, detail: String(e) }); log(`EXCEPTION: ${e instanceof Error ? e.stack ?? e.message : e}`); } const failed = results.filter((r) => !r.pass); log(failed.length === 0 ? 'ALL GPU TESTS PASSED' : `${failed.length} GPU TEST(S) FAILED`); (window as any).__RESULTS__ = { results, ok: failed.length === 0 }; } main();