/ concept-collection / turing-surface-cache
Sign in
concept-collection / turing-surface-cache
turing-surface-cache / src / cache / h5file.ts
237 lines · 9.1 KBCodeBlameHistory
2 * Cache files are HDF5, in the layout of turing-surface's reference files
3 * (docs/ellipsoid-reference-spec.md there) extended with the cache's own
4 * identity at the root: the canonical spec JSON that was hashed into the
5 * object name, the app name, and the format version. A cache file is thereby
6 * also a valid reference file — turing-surface's "Compare to reference…" mode
7 * opens one as-is — and readable from Python with h5py.
8 *
9 * / attrs: app, format_version, spec_json, model, species,
10 * created_utc, adapter
11 * /backend attrs: adapter, runtime, precision
12 * /spec attrs: geometry, lmax, seed, steps, niter, lam3, t_end
13 * /spec/params attrs: a, b, D1, D2, dt
14 * /spec/geometry_params attrs: the geometry's params
15 * /grid attrs: lmax, mmax, nlat, nphi, nlm
16 * /geometry Gx, Gy, Gz float32[2*nlm]
17 * /initial one dataset per species (U, V) float32[2*nlm]
18 * /final one dataset per species (U, V) float32[2*nlm]
19 *
20 * h5wasm's browser build carries the whole HDF5 library as embedded wasm
21 * (~4 MB), so it is imported dynamically and only here: the page pays for it
22 * on the first cache hit or upload, never on startup.
23 */
24import type { ShtConfig } from '../sht/layout.ts';
25import { nlmCalc } from '../sht/layout.ts';
26import { APP_NAME, FORMAT_VERSION, canonicalJson, stepsFor, type CacheSpec } from './spec.ts';
28export interface CacheFileData {
29 spec: CacheSpec;
30 grid: ShtConfig;
31 /** Spectral state names, in order — Schnakenberg's ['U', 'V']. */
32 species: string[];
33 /** The band-limited surface's own coefficients, [re, im] per (l, m). */
34 geometry: { X: Float32Array; Y: Float32Array; Z: Float32Array };
35 /** Spectral state at t = 0 (immediately after seeding). */
36 initial: Record<string, Float32Array>;
37 /** The same, at the spec's end time. */
38 final: Record<string, Float32Array>;
39 /** Provenance: which GPU computed it. */
40 adapter: string;
43interface H5Module {
44 ready: Promise<unknown>;
45 File: new (path: string, mode: string) => H5WFile;
46 FS?: unknown;
49interface H5Attr {
50 value: unknown;
53interface H5Obj {
54 attrs: Record<string, H5Attr>;
55 get(name: string): unknown;
56 create_group(name: string): unknown;
57 create_attribute(name: string, data: unknown, shape?: unknown, dtype?: unknown): void;
58 create_dataset(args: { name: string; data: unknown; shape?: number[]; dtype?: string }): void;
61interface H5WFile extends H5Obj {
62 close(): void;
65interface EmFS {
66 writeFile(path: string, data: Uint8Array): void;
67 readFile(path: string): Uint8Array;
68 unlink(path: string): void;
71let scratchCounter = 0;
73async function withH5<T>(fn: (h5: H5Module, fs: EmFS) => T | Promise<T>): Promise<T> {
c2317d0Fill the cache from the command line, without a browserJeremy Magland 74 // Two builds of the same library: the browser one carries the wasm inside
75 // the bundle, the node one reads it off disk. __NODE_BUILD__ is a build-time
76 // constant (see vite.config.ts and vite.cli.config.ts), so whichever branch
77 // this build is not takes no part in it.
78 const h5 = (await (__NODE_BUILD__
79 ? import('h5wasm/node')
80 : import('h5wasm'))) as unknown as H5Module;
4f822e1turing-surface-cache: reaction-diffusion solutions at a chosen end time, shared through a cloud cacheJeremy Magland 81 const { FS } = (await h5.ready) as { FS: EmFS };
82 return fn(h5, FS);
85const groupOf = (node: H5Obj, name: string): H5Obj => {
86 const g = node.get(name) as H5Obj | null;
87 if (!g || typeof g.get !== 'function') throw new Error(`no '${name}/' group`);
88 return g;
89};
91const coeffsOf = (group: H5Obj, groupName: string, name: string, nlm: number): Float32Array => {
92 const v = (group.get(name) as { value?: unknown } | null)?.value;
93 if (!(v instanceof Float32Array)) {
94 throw new Error(`'${groupName}/${name}' is not a float32 dataset`);
95 }
96 if (v.length !== 2 * nlm) {
97 throw new Error(`'${groupName}/${name}' has ${v.length} values, expected 2*nlm = ${2 * nlm}`);
98 }
99 return v;
100};
102/** Serialize one solution to HDF5 bytes. */
103export function encodeCacheFile(data: CacheFileData): Promise<Uint8Array> {
104 return withH5((h5, FS) => {
105 const path = `/encode-${scratchCounter++}.h5`;
106 const file = new h5.File(path, 'w');
107 try {
108 const { spec, grid } = data;
109 file.create_attribute('app', APP_NAME);
110 file.create_attribute('format_version', FORMAT_VERSION);
111 file.create_attribute('spec_json', canonicalJson(spec));
112 file.create_attribute('model', spec.model);
113 file.create_attribute('species', data.species);
114 file.create_attribute('created_utc', new Date().toISOString());
115 file.create_attribute('adapter', data.adapter);
117 file.create_group('backend');
118 const backend = groupOf(file, 'backend');
119 backend.create_attribute('adapter', data.adapter);
120 backend.create_attribute('runtime', 'browser-webgpu');
121 backend.create_attribute('precision', 'fp32');
123 file.create_group('spec');
124 const specGroup = groupOf(file, 'spec');
125 specGroup.create_attribute('geometry', spec.geometry);
126 specGroup.create_attribute('lmax', spec.lmax);
127 specGroup.create_attribute('seed', spec.seed);
128 specGroup.create_attribute('steps', stepsFor(spec));
129 specGroup.create_attribute('niter', spec.niter);
130 specGroup.create_attribute('lam3', spec.lam3);
131 specGroup.create_attribute('t_end', spec.tEnd);
132 specGroup.create_group('params');
133 const params = groupOf(specGroup, 'params');
134 for (const [k, v] of Object.entries(spec.params)) params.create_attribute(k, v);
135 specGroup.create_group('geometry_params');
136 const gparams = groupOf(specGroup, 'geometry_params');
137 for (const [k, v] of Object.entries(spec.geometryParams)) gparams.create_attribute(k, v);
139 file.create_group('grid');
140 const gridGroup = groupOf(file, 'grid');
141 const nlm = nlmCalc(grid.lmax, grid.mmax);
142 gridGroup.create_attribute('lmax', grid.lmax);
143 gridGroup.create_attribute('mmax', grid.mmax);
144 gridGroup.create_attribute('nlat', grid.nlat);
145 gridGroup.create_attribute('nphi', grid.nphi);
146 gridGroup.create_attribute('nlm', nlm);
148 file.create_group('geometry');
149 const geom = groupOf(file, 'geometry');
150 geom.create_dataset({ name: 'Gx', data: data.geometry.X });
151 geom.create_dataset({ name: 'Gy', data: data.geometry.Y });
152 geom.create_dataset({ name: 'Gz', data: data.geometry.Z });
154 for (const [groupName, states] of [
155 ['initial', data.initial],
156 ['final', data.final],
157 ] as const) {
158 file.create_group(groupName);
159 const g = groupOf(file, groupName);
160 for (const name of data.species) {
161 const coeffs = states[name];
162 if (!coeffs) throw new Error(`missing ${groupName} state '${name}'`);
163 if (coeffs.length !== 2 * nlm) {
164 throw new Error(`${groupName}/${name}: ${coeffs.length} values, expected ${2 * nlm}`);
165 }
166 g.create_dataset({ name, data: coeffs });
167 }
168 }
169 } finally {
170 file.close();
171 }
172 const bytes = FS.readFile(path);
173 FS.unlink(path);
174 return bytes;
175 });
178export interface DecodedCacheFile {
179 /** Parsed from the file's own spec_json — the identity it was stored under. */
180 spec: CacheSpec;
181 species: string[];
182 initial: Record<string, Float32Array>;
183 final: Record<string, Float32Array>;
184 /** Provenance, when recorded. */
185 adapter: string;
186 created: string;
189/**
190 * Read cache-file bytes back. `expectSpecJson` is the canonical JSON this
191 * client asked the cache for; a mismatch with the file's own means the object
192 * store handed back something other than what the key promised (corruption,
193 * or a stale format), and is an error rather than a silent wrong answer.
194 */
195export function decodeCacheFile(
196 bytes: Uint8Array,
197 expectSpecJson: string,
198 expectSpecies: string[],
199): Promise<DecodedCacheFile> {
200 return withH5((h5, FS) => {
201 const path = `/decode-${scratchCounter++}.h5`;
202 FS.writeFile(path, bytes);
203 const file = new h5.File(path, 'r');
204 try {
205 const attr = (name: string): unknown => file.attrs[name]?.value;
206 const app = String(attr('app') ?? '');
207 if (app !== APP_NAME) throw new Error(`not a ${APP_NAME} file (app='${app}')`);
208 const version = Number(attr('format_version'));
209 if (version !== FORMAT_VERSION) throw new Error(`format version ${version}, expected ${FORMAT_VERSION}`);
210 const specJson = String(attr('spec_json') ?? '');
211 if (specJson !== expectSpecJson) {
212 throw new Error('file spec does not match the requested spec');
213 }
214 const spec = JSON.parse(specJson) as CacheSpec;
215 const nlm = nlmCalc(spec.lmax, spec.lmax);
216 const initialGroup = groupOf(file, 'initial');
217 const finalGroup = groupOf(file, 'final');
218 const initial: Record<string, Float32Array> = {};
219 const final: Record<string, Float32Array> = {};
220 for (const name of expectSpecies) {
221 initial[name] = coeffsOf(initialGroup, 'initial', name, nlm);
222 final[name] = coeffsOf(finalGroup, 'final', name, nlm);
223 }
224 return {
225 spec,
226 species: expectSpecies,
227 initial,
228 final,
229 adapter: String(attr('adapter') ?? ''),
230 created: String(attr('created_utc') ?? ''),
231 };
232 } finally {
233 file.close();
234 FS.unlink(path);
235 }
236 });
moveopenescclose