/ concept-collection / turing-surface
concept-collection / turing-surface
434 lines · 16.9 KBBlameHistoryRaw
1/**
2 * The surface: a .m shape file, compiled and evaluated into spherical-harmonic
3 * coefficients.
4 *
5 * A geometry file is ordinary MATLAB defining one function,
6 *
7 * function [gx, gy, gz] = shape(theta, phi, <parameters>)
8 *
9 * over the solver's (theta, phi) grid. Unlike the models it is *not* compiled
10 * to WGSL: a model's step runs every frame and must lower to a fixed sequence
11 * of GPU dispatches, but a shape is evaluated exactly once at build time and
12 * survives only as coefficients. So it runs through numbl's CPU interpreter
13 * instead, which buys the full MATLAB subset — loops, arrays, reductions,
14 * `legendre`, seeded randomness via `rng`/`randn` — and f64 evaluation, where
15 * the step dialect is element-wise f32. The result is then *analysed*: the
16 * canonical geometry this project carries is the three sets of coefficients
17 * `X`, `Y`, `Z`, one per Cartesian component of the embedding.
18 *
19 * Going through the coefficients rather than keeping the pointwise values is
20 * what makes the geometry usable by a spectral method, for two reasons:
21 *
22 * - it is exactly band-limited at lmax afterwards, so the surface has as many
23 * derivatives as the scheme needs and no aliased content the solver cannot
24 * see. `x`, `y`, `z` below are the synthesis of the coefficients, not the
25 * raw output of the .m — the shape actually being solved on, which for a
26 * shape with sharp features is not quite the shape that was written down.
27 * - it can be evaluated on any grid. The renderer draws the surface on the
28 * (possibly finer) display grid by synthesizing the same coefficients
29 * there, which is exact interpolation rather than subdivision — the same
30 * argument that lets the species fields be oversampled.
31 *
32 * The unit sphere is the case where `x`, `y`, `z` are pure degree-1 harmonics
33 * and everything downstream reduces to turing-sphere.
34 */
35import { parseMFile, type FunctionStmt } from 'numbl-src/numbl-core/parser/index.ts';
36import { executeCode } from 'numbl-src/numbl-core/executeCode.ts';
37import {
38 RuntimeTensor,
39 isRuntimeTensor,
40 type RuntimeValue,
41} from 'numbl-src/numbl-core/runtime/types.ts';
42import { ShtPlan } from '../sht/sht.ts';
43import type { ShtConfig } from '../sht/layout.ts';
44import type { DerivPlan } from '../sht/deriv.ts';
45import { computeMetric, computeFluxMetric } from './metric.ts';
46import { toolFiles } from '../tools.ts';
47import { inFunction, inModel, ModelCompileError } from '../mgpu/errors.ts';
48import type { ModelParams } from '../mgpu/model.ts';
50/** The function a geometry file must define. */
51export const SHAPE_FN = 'shape';
53export interface GeometryOptions {
54 /** The solver's transform plan — the grid the shape is evaluated on. */
55 sht: ShtPlan;
56 cfg: ShtConfig;
57 /** Geometry source (.m text). */
58 source: string;
59 /** Parameter names the .m may take beyond `theta` and `phi`. */
60 paramNames: string[];
61 params: ModelParams;
62 /** Computes the theta/phi derivatives the inverse metric quantities need. */
63 deriv: DerivPlan;
66export class Geometry {
67 /** Coordinates on the solver grid, npts each — synthesis of the coefficients. */
68 readonly x: Float32Array;
69 readonly y: Float32Array;
70 readonly z: Float32Array;
71 /** Their spherical-harmonic coefficients, 2 x nlm each. */
72 readonly X: Float32Array;
73 readonly Y: Float32Array;
74 readonly Z: Float32Array;
75 /**
76 * Inverse metric quantities (src/geom/metric.ts), grid space, npts each.
77 * Depend only on the geometry, so — like x,y,z,X,Y,Z above — these are a
78 * one-off computed here, not per-solve-step work. Used by the Algorithm-4
79 * (12-transform) Laplace-Beltrami path.
80 */
81 readonly Vtx: Float32Array;
82 readonly Vty: Float32Array;
83 readonly Vtz: Float32Array;
84 readonly Vpx: Float32Array;
85 readonly Vpy: Float32Array;
86 readonly Vpz: Float32Array;
87 /**
88 * Flux-form metric weights (src/geom/metric.ts computeFluxMetric), grid
89 * space, npts each — the six-transform Laplace-Beltrami scheme's
90 * replacement for the six V arrays (docs/reduced-transforms.md
91 * Sec 3). Both sets are carried so either operator formulation can run.
92 */
93 readonly p1: Float32Array;
94 readonly p2: Float32Array;
95 readonly q2: Float32Array;
96 readonly r: Float32Array;
97 /**
98 * The same flux weights with the round sphere subtracted off, plus the
99 * bounded 1/J — what lets a model evaluate lap_g without ever multiplying
100 * the *whole* flux divergence by r ~ 1/sin^2(theta). Writing p1 = 1 + dp1,
101 * q2 = 1 + dq2 (p2 is already a pure deviation, zero on the sphere) splits
102 * the divergence into a round-sphere part, whose cancelling bracket
103 * sin(theta) dtheta(A) + dphi(B) = -sin^2(theta) lap_s u is known exactly in
104 * spectral space, and a remainder:
105 *
106 * lap_g u = -jinv * lap_s u + r * (sin(theta) dtheta(P') + dphi(Q'))
107 *
108 * with P' = dp1*A + p2*B, Q' = p2*A + dq2*B. Only the remainder meets the
109 * concentrated division, so the polar roundoff gain drops by |P'|/|P|
110 * instead of applying to the full flux. Subtracting 1 in f64 here is the
111 * point: on a near-sphere dp1 is the small quantity, and forming it as an
112 * f32 difference in the .m would lose it. See docs/reduced-transforms.md
113 * Sec 5 and models/schnakenberg.m.
114 */
115 readonly dp1: Float32Array;
116 readonly dq2: Float32Array;
117 readonly jinv: Float32Array;
118 /**
119 * Preconditioner scale for the implicit solve (docs/reduced-transforms.md
120 * Sec 10). At high degree the Richardson iteration's per-mode factor is
121 * governed by the operator's principal symbol: in the orthonormal frame
122 * the surface symbol matrix is S = (1/J)[[p1, p2], [p2, q2]], whose
123 * eigenvalues mu(x) are the inverse squared principal stretches of the
124 * embedding — the round sphere has mu = 1. Preconditioning with lam/Jhat
125 * contracts every mode and every direction iff Jhat*mu stays in (0, 2),
126 * so the minimax constant is the harmonic mean of the symbol extremes,
127 *
128 * Jhat = 2/(muMin + muMax), rate = (muMax - muMin)/(muMax + muMin) < 1.
129 *
130 * The direction dependence is the point: a det-based mean of the area
131 * factor J (mu's geometric mean, exact only for conformal surfaces)
132 * under-corrects anisotropic stretching — on the shipped ellipsoid it
133 * leaves a band of directional high-degree modes with amplification > 1,
134 * which inflates the pattern's spectrum at moderate niter/lmax and
135 * diverges at larger ones. The plain scheme (Jhat = 1) diverges wherever
136 * muMax > 2. The solve's fixed point never depends on Jhat; only the
137 * convergence rate does.
138 */
139 readonly Jhat: number;
140 /** Symbol-eigenvalue range over the grid (see Jhat), for diagnostics. */
141 readonly muMin: number;
142 readonly muMax: number;
143 /** Area-factor range over the grid, for diagnostics. */
144 readonly Jmin: number;
145 readonly Jmax: number;
147 private constructor(init: {
148 x: Float32Array; y: Float32Array; z: Float32Array;
149 X: Float32Array; Y: Float32Array; Z: Float32Array;
150 Vtx: Float32Array; Vty: Float32Array; Vtz: Float32Array;
151 Vpx: Float32Array; Vpy: Float32Array; Vpz: Float32Array;
152 p1: Float32Array; p2: Float32Array; q2: Float32Array; r: Float32Array;
153 dp1: Float32Array; dq2: Float32Array; jinv: Float32Array;
154 Jhat: number; muMin: number; muMax: number; Jmin: number; Jmax: number;
155 }) {
156 this.x = init.x;
157 this.y = init.y;
158 this.z = init.z;
159 this.X = init.X;
160 this.Y = init.Y;
161 this.Z = init.Z;
162 this.Vtx = init.Vtx;
163 this.Vty = init.Vty;
164 this.Vtz = init.Vtz;
165 this.Vpx = init.Vpx;
166 this.Vpy = init.Vpy;
167 this.Vpz = init.Vpz;
168 this.p1 = init.p1;
169 this.p2 = init.p2;
170 this.q2 = init.q2;
171 this.r = init.r;
172 this.dp1 = init.dp1;
173 this.dq2 = init.dq2;
174 this.jinv = init.jinv;
175 this.Jhat = init.Jhat;
176 this.muMin = init.muMin;
177 this.muMax = init.muMax;
178 this.Jmin = init.Jmin;
179 this.Jmax = init.Jmax;
180 }
182 /**
183 * Evaluate the shape file once on the solver grid and reduce it to
184 * coefficients. Everything here happens at build time — a geometry never
185 * takes part in the timestep — so the .m runs on the CPU (see
186 * `evaluateShape`) and only the analysis onward touches the GPU.
187 */
188 static async create(opts: GeometryOptions): Promise<Geometry> {
189 const { sht, cfg, source, paramNames, params, deriv } = opts;
190 const npts = cfg.nlat * cfg.nphi;
192 const { theta, phi } = gridAngles(sht, cfg);
193 const raw = evaluateShape(source, paramNames, params, theta, phi, npts);
195 // Coefficients first, then back to the grid: what the solver and the
196 // renderer both see is the band-limited surface, not the raw .m output.
197 const [X, Y, Z] = [
198 await sht.analys(raw[0]),
199 await sht.analys(raw[1]),
200 await sht.analys(raw[2]),
201 ];
202 const [x, y, z] = [
203 await sht.synth(X),
204 await sht.synth(Y),
205 await sht.synth(Z),
206 ];
208 // Inverse metric quantities (algos.tex Algorithm 2): theta/phi
209 // derivatives of the embedding's coefficients, contracted through the
210 // inverse first fundamental form. Depends only on the geometry, so
211 // this is a one-off alongside x,y,z above, not per-step work.
212 const Xt = await deriv.dtheta(X);
213 const Xp = await deriv.dphi(X);
214 const Yt = await deriv.dtheta(Y);
215 const Yp = await deriv.dphi(Y);
216 const Zt = await deriv.dtheta(Z);
217 const Zp = await deriv.dphi(Z);
218 const { Vtx, Vty, Vtz, Vpx, Vpy, Vpz } = computeMetric(npts, Xt, Xp, Yt, Yp, Zt, Zp);
220 // Flux-form metric weights for the six-transform scheme, built from the
221 // *undivided* theta tangents sin(theta)*X_theta (smooth on the sphere,
222 // unlike X_theta itself) and the same X_phi as above. Also a one-off;
223 // the f64 combination happens on the CPU, rounded to f32 for upload.
224 const sXtx = await deriv.sinDtheta(X);
225 const sXty = await deriv.sinDtheta(Y);
226 const sXtz = await deriv.sinDtheta(Z);
227 const flux = computeFluxMetric(npts, sXtx, sXty, sXtz, Xp, Yp, Zp);
229 // The preconditioner scale — see the Jhat field comment. The symbol
230 // matrix in the orthonormal frame is S = (1/J)[[p1,p2],[p2,q2]] with
231 // 1/J = r sin^2(theta); its entries are the bounded quantities
232 // g^tt, sin g^tp, sin^2 g^pp, so the eigenvalue extremes are clean to
233 // take over the grid. det S = 1/J^2, so the area factor comes along
234 // for free. f64 throughout.
235 let muMin = Infinity;
236 let muMax = 0;
237 let Jmin = Infinity;
238 let Jmax = 0;
239 // The sphere-subtracted weights ride along on this loop: 1/J is already
240 // being formed here, and dp1/dq2 want the same f64 arithmetic.
241 const dp1 = new Float32Array(npts);
242 const dq2 = new Float32Array(npts);
243 const jinv = new Float32Array(npts);
244 for (let i = 0; i < cfg.nlat; i++) {
245 const ct = sht.cosTheta[i];
246 const st2 = Math.max(0, 1 - ct * ct);
247 for (let j = 0; j < cfg.nphi; j++) {
248 const k = i * cfg.nphi + j;
249 const invJ = flux.r[k] * st2;
250 dp1[k] = flux.p1[k] - 1;
251 dq2[k] = flux.q2[k] - 1;
252 jinv[k] = invJ;
253 const s11 = flux.p1[k] * invJ;
254 const s12 = flux.p2[k] * invJ;
255 const s22 = flux.q2[k] * invJ;
256 const mean = (s11 + s22) / 2;
257 const disc = Math.sqrt(((s11 - s22) / 2) ** 2 + s12 * s12);
258 if (mean - disc < muMin) muMin = mean - disc;
259 if (mean + disc > muMax) muMax = mean + disc;
260 const J = 1 / invJ;
261 if (J < Jmin) Jmin = J;
262 if (J > Jmax) Jmax = J;
263 }
264 }
265 const Jhat = 2 / (muMin + muMax);
267 return new Geometry({
268 x, y, z, X, Y, Z, Vtx, Vty, Vtz, Vpx, Vpy, Vpz,
269 p1: new Float32Array(flux.p1),
270 p2: new Float32Array(flux.p2),
271 q2: new Float32Array(flux.q2),
272 r: new Float32Array(flux.r),
273 dp1, dq2, jinv,
274 Jhat, muMin, muMax, Jmin, Jmax,
275 });
276 }
278 /**
279 * The surface evaluated on another plan's grid, as interleaved xyz vertex
280 * positions (nlat * nphi * 3) — for rendering at display resolution. Exact
281 * interpolation: the same coefficients, more evaluation points.
282 */
283 async positionsOn(view: ShtPlan): Promise<Float32Array> {
284 const [x, y, z] = [
285 await view.synth(this.X),
286 await view.synth(this.Y),
287 await view.synth(this.Z),
288 ];
289 const out = new Float32Array(x.length * 3);
290 for (let i = 0; i < x.length; i++) {
291 out[3 * i] = x[i];
292 out[3 * i + 1] = y[i];
293 out[3 * i + 2] = z[i];
294 }
295 return out;
296 }
298 /** How far the surface departs from the unit sphere, as min/max radius. */
299 radiusRange(): { lo: number; hi: number } {
300 let lo = Infinity;
301 let hi = -Infinity;
302 for (let i = 0; i < this.x.length; i++) {
303 const r = Math.hypot(this.x[i], this.y[i], this.z[i]);
304 if (r < lo) lo = r;
305 if (r > hi) hi = r;
306 }
307 return { lo, hi };
308 }
311/** The (theta, phi) of every grid point, flattened phi-fastest as the fields
312 * are — in f64, the precision the shape is evaluated at. */
313function gridAngles(
314 sht: ShtPlan,
315 cfg: ShtConfig,
316): { theta: Float64Array; phi: Float64Array } {
317 const { nlat, nphi } = cfg;
318 const theta = new Float64Array(nlat * nphi);
319 const phi = new Float64Array(nlat * nphi);
320 for (let i = 0; i < nlat; i++) {
321 const th = Math.acos(Math.max(-1, Math.min(1, sht.cosTheta[i])));
322 for (let j = 0; j < nphi; j++) {
323 theta[i * nphi + j] = th;
324 phi[i * nphi + j] = (2 * Math.PI * j) / nphi;
325 }
326 }
327 return { theta, phi };
330/**
331 * Evaluate the shape file on the grid, through numbl's CPU interpreter.
332 *
333 * The .m keeps the same contract it had as a compiled model: it names the
334 * arguments it wants — `theta`, `phi`, and any of the registry's parameters —
335 * and the host supplies them by name, so their order in the signature is the
336 * .m's own business. A one-line driver script calls `shape` with exactly the
337 * arguments its signature declares, with those names pre-bound in the
338 * driver's workspace.
339 */
340function evaluateShape(
341 source: string,
342 paramNames: string[],
343 params: ModelParams,
344 theta: Float64Array,
345 phi: Float64Array,
346 npts: number,
347): [Float32Array, Float32Array, Float32Array] {
348 const file = `${SHAPE_FN}.m`;
349 const ast = inModel(() => parseMFile(source, file));
350 const fn = ast.body.find(
351 (s): s is FunctionStmt =>
352 s.type === 'Function' && (s as FunctionStmt).name === SHAPE_FN,
353 );
354 if (!fn) {
355 throw new ModelCompileError(
356 `the geometry defines no function named '${SHAPE_FN}'`,
357 );
358 }
359 if (fn.outputs.length !== 3) {
360 throw new ModelCompileError(
361 `'${SHAPE_FN}' must return three outputs [gx, gy, gz], not ${fn.outputs.length}`,
362 { fn: SHAPE_FN, start: fn.span.start, end: fn.span.end },
363 );
364 }
365 const known = new Set(['theta', 'phi', ...paramNames]);
366 for (const p of fn.params) {
367 if (!known.has(p)) {
368 throw new ModelCompileError(
369 `'${SHAPE_FN}' takes an argument '${p}' that is neither the grid ` +
370 `(theta, phi) nor one of this geometry's parameters` +
371 (paramNames.length ? ` (${paramNames.join(', ')})` : ''),
372 { fn: SHAPE_FN, start: fn.span.start, end: fn.span.end },
373 );
374 }
375 }
377 const vars: Record<string, RuntimeValue> = {
378 theta: new RuntimeTensor(theta, [npts, 1]),
379 phi: new RuntimeTensor(phi, [npts, 1]),
380 };
381 for (const name of paramNames) {
382 const v = params[name];
383 // Missing parameters read as 0, as ModelPlan.setParams has it.
384 vars[name] = Number.isFinite(v) ? v : 0;
385 }
387 const driver = `[gx__, gy__, gz__] = ${SHAPE_FN}(${fn.params.join(', ')});`;
388 const result = inFunction(SHAPE_FN, () =>
389 executeCode(
390 driver,
391 { initialVariableValues: vars, displayResults: false, implicitCwdPath: null },
392 [...toolFiles, { name: file, source }],
393 'geometry-driver.m',
394 ),
395 );
397 return [
398 toGridField(result.variableValues['gx__'], fn.outputs[0], npts),
399 toGridField(result.variableValues['gy__'], fn.outputs[1], npts),
400 toGridField(result.variableValues['gz__'], fn.outputs[2], npts),
401 ];
404/** One returned coordinate → npts values, rounded to the transforms' f32. */
405function toGridField(
406 value: RuntimeValue | undefined,
407 name: string,
408 npts: number,
409): Float32Array {
410 // A constant coordinate stays scalar in MATLAB; spread it over the grid.
411 if (typeof value === 'number') return new Float32Array(npts).fill(value);
412 if (value !== undefined && isRuntimeTensor(value)) {
413 if (value.imag) {
414 throw new ModelCompileError(
415 `the geometry's '${name}' is complex; coordinates must be real`,
416 { fn: SHAPE_FN },
417 );
418 }
419 // A vector of npts values, either orientation. A 2-D reshape is refused
420 // rather than reordered: the tensor's column-major layout would not match
421 // the grid's phi-fastest rows.
422 if (value.data.length === npts && value.shape.every((d) => d === 1 || d === npts)) {
423 return new Float32Array(value.data);
424 }
425 throw new ModelCompileError(
426 `the geometry's '${name}' is ${value.shape.join(' x ')}, but the grid ` +
427 `wants one value per point (${npts} x 1)`,
428 { fn: SHAPE_FN },
429 );
430 }
431 throw new ModelCompileError(`the geometry's '${name}' is not numeric`, {
432 fn: SHAPE_FN,
433 });