Do not stretch the colormap across a constant field's roundoff
A constant field's extremes are set entirely by numerical residue, so scaling
the colormap to them paints that residue at full contrast. Schnakenberg seeds
v as a literal constant (vs * ones(...)), and the result was two variants at
different lmax showing two vivid, unrelated pictures of the same 0.9 -- which
looks exactly like a broken initial condition and is not one. The states agree:
V matches across lmax to 3.15e-6 against a magnitude of 3.19.
What the residue is, measured: a constant analysed and re-synthesized in fp32
comes back constant to 2.9e-5 relative at lmax 63, 9.6e-5 at 127, 2.4e-4 at
255. It is not white -- it sits in a few rings at the poles, 74x the equatorial
level at lmax 63 and 1710x at lmax 255. Attributed directly: zeroing Q00 and
synthesizing what is left reproduces the pole error to the digit (2.57e-5 and
2.19e-4). The cause is that analysing a constant leaves ~1e-6 of high-degree
m = 0 content where exact cancellation would leave none, and those Legendre
functions all peak at the poles and add in phase there. Synthesis is not
implicated: synth of the exact single Y00 mode is uniform at 3.6e-8, and the
same round trip in f64 is 2.4e-8 and flat.
So: floorRange gives the drawn range a floor of 1e-2 relative to the field's
magnitude, which puts the worst of that residue in about 5% of the colormap
instead of all of it and leaves anything these models actually carry untouched.
A floor rather than an "is it constant?" test, so a pattern growing up through
it hands over gradually. Applied in both the compare grid and the single-run
view, which had the same flaw.
4 changed files+91−16
src/compare/compareRun.tsmodified+6−8View file
@@ -40,7 +40,7 @@ import {
4040 } from '../render/sphereMesh.ts';
4141 import { SphereScene } from '../render/SphereScene.ts';
4242 import { colormaps } from '../render/colormaps.ts';
43-import { fmtValue } from '../render/colorbar.ts';
43+import { fmtValue, floorRange } from '../render/colorbar.ts';
4444 import { sharedNoise } from './sharedStart.ts';
4545 import { variantLabel, VARIANT_COLORS, type Variant } from './variants.ts';
4646
@@ -425,15 +425,13 @@ export class CompareRun {
425425 // by nothing: the panels freeze at a readable scale and the row labels
426426 // say what happened, instead of the grid going blank.
427427 if (!Number.isFinite(range.lo) || !Number.isFinite(range.hi)) continue;
428- if (range.hi - range.lo < 1e-9) {
429- const mid = (range.hi + range.lo) / 2;
430- range.lo = mid - 5e-10;
431- range.hi = mid + 5e-10;
432- }
433- this.#rangeBars[k]?.fill(range.lo, range.hi);
428+ // The floor is applied to what is drawn, not to what is tracked, so it
429+ // never feeds back into the smoothing above.
430+ const shown = floorRange(range.lo, range.hi);
431+ this.#rangeBars[k]?.fill(shown.lo, shown.hi);
434432 for (const r of this.#rows) {
435433 fillFieldValues(r.valueBufs[k], r.fields[k], this.#topo);
436- fillColors(r.colorBufs[k], r.valueBufs[k], range.lo, range.hi, cmap);
434+ fillColors(r.colorBufs[k], r.valueBufs[k], shown.lo, shown.hi, cmap);
437435 r.scenes[k]?.updateColors(r.colorBufs[k]);
438436 }
439437 }
src/main.tsmodified+7−8View file
@@ -29,7 +29,7 @@ import {
2929 type SphereMeshTopology,
3030 } from './render/sphereMesh.ts';
3131 import { SphereScene } from './render/SphereScene.ts';
32-import { Colorbar, fmtValue } from './render/colorbar.ts';
32+import { Colorbar, fmtValue, floorRange } from './render/colorbar.ts';
3333 import { colormaps, colormapNames } from './render/colormaps.ts';
3434 import { MovieRecorder } from './render/movie.ts';
3535 import { CompareRun } from './compare/compareRun.ts';
@@ -775,14 +775,13 @@ async function draw(): Promise<void> {
775775 r.lo += a * (lo - r.lo);
776776 r.hi += a * (hi - r.hi);
777777 }
778- if (r.hi - r.lo < 1e-9) {
779- const mid = (r.hi + r.lo) / 2;
780- r.lo = mid - 5e-10;
781- r.hi = mid + 5e-10;
782- }
783- fillColors(colorBufs[k], valueBufs[k], r.lo, r.hi, cmap);
778+ // A field that is uniform to fp32 precision — Schnakenberg's v at t = 0 is
779+ // exactly constant — would otherwise have the colormap stretched across its
780+ // roundoff and be drawn as vivid noise. See floorRange.
781+ const shown = floorRange(r.lo, r.hi);
782+ fillColors(colorBufs[k], valueBufs[k], shown.lo, shown.hi, cmap);
784783 scenes[k]?.updateColors(colorBufs[k]);
785- colorbars[k]?.update(cmap, r.lo, r.hi);
784+ colorbars[k]?.update(cmap, shown.lo, shown.hi);
786785 }
787786 }
788787
src/render/colorbar.tsmodified+52−0View file
@@ -4,6 +4,58 @@ import type { ColormapFunc } from './colormaps.ts';
44 export const fmtValue = (v: number): string =>
55 Number.isFinite(v) ? v.toPrecision(3).replace(/\.?0+$/, '') : '—';
66
7+/**
8+ * Smallest span the colormap may be stretched across, relative to the field's
9+ * own magnitude.
10+ *
11+ * Set from measurement, not taste. A constant field analysed and re-synthesized
12+ * in fp32 comes back constant only to
13+ *
14+ * lmax 63: 2.9e-5 relative lmax 127: 9.6e-5 lmax 255: 2.4e-4
15+ *
16+ * and the residue is not white noise — it is concentrated in a few rings at the
17+ * poles (74x the equatorial level at lmax 63, 1710x at lmax 255), because what
18+ * survives the analysis is high-degree m = 0 content whose Legendre functions
19+ * all peak at the poles *and add in phase there*. The same round trip in f64 is
20+ * 2.4e-8 and flat, so this is fp32, not the algorithm.
21+ *
22+ * A floor of 1e-2 puts the worst of that (about 5e-4 of span at lmax 255) into
23+ * roughly 5% of the colormap rather than all of it, while the variation these
24+ * models actually carry — a few percent of the field's magnitude and up — is
25+ * left alone entirely.
26+ */
27+const RANGE_FLOOR_REL = 1e-2;
28+/** And an absolute floor, for a field whose magnitude is itself near zero. */
29+const RANGE_FLOOR_ABS = 1e-9;
30+
31+/**
32+ * Widen a value range so that a field which is uniform to numerical precision
33+ * is drawn as uniform.
34+ *
35+ * Scaling the colormap to a field's own extremes gives full contrast to
36+ * whatever variation it has — including none. Schnakenberg's `v` at t = 0 is
37+ * literally constant (`vs * ones(...)`), so its extremes are set purely by the
38+ * roundoff described above; painting that across the whole colormap produces a
39+ * vivid pole-capped picture that reads as structure, and since the residue
40+ * belongs to the grid, two runs at different lmax produce two entirely
41+ * different pictures of the same constant — which looks exactly like a broken
42+ * initial condition, and is not one.
43+ *
44+ * A floor rather than an "is this field constant?" test, so nothing ever jumps:
45+ * a real pattern growing up through the floor hands the range over from the
46+ * floor to its own data gradually, and once it is any larger than roundoff the
47+ * floor has no effect at all.
48+ */
49+export function floorRange(lo: number, hi: number): { lo: number; hi: number } {
50+ const minSpan = Math.max(
51+ RANGE_FLOOR_ABS,
52+ RANGE_FLOOR_REL * Math.max(Math.abs(lo), Math.abs(hi)),
53+ );
54+ if (hi - lo >= minSpan) return { lo, hi };
55+ const mid = (lo + hi) / 2;
56+ return { lo: mid - minSpan / 2, hi: mid + minSpan / 2 };
57+}
58+
759 /** Vertical colorbar drawn on a small canvas, with min/max labels. */
860 export class Colorbar {
961 #canvas: HTMLCanvasElement;
test/compareChecks.tsmodified+26−0View file
@@ -24,6 +24,7 @@ import { mModelByKey, defaultParams } from '../src/mgpu/registry.ts';
2424 import { prolongCoeffs, sharedNoise } from '../src/compare/sharedStart.ts';
2525 import { lmIndex, nlmCalc } from '../src/sht/layout.ts';
2626 import { crossProduct, mostResolved } from '../src/compare/variants.ts';
27+import { floorRange } from '../src/render/colorbar.ts';
2728
2829 type Check = (name: string, ok: boolean, detail: string) => void;
2930 type Log = (line: string) => void;
@@ -148,6 +149,31 @@ export async function compareChecks(
148149 );
149150 }
150151
152+ // ---- a uniform field is drawn uniform, on every grid --------------------
153+ // Schnakenberg seeds v as a literal constant (`vs * ones(...)`), so its whole
154+ // spread is the fp32 residue of the analys/synth round trip -- pole-localized
155+ // and grid-dependent, so scaled to its own extremes it paints two unrelated
156+ // pictures of the same constant, which is what a broken seeding would look
157+ // like. The spans below are measured (worst |deviation| x 2, on vs = 0.9):
158+ // lmax 63, 127, 255. See floorRange for where they come from.
159+ {
160+ const vs = 0.9;
161+ const spans = [5.2e-5, 1.8e-4, 4.4e-4];
162+ // Each must end up a small slice of the drawn range rather than all of it.
163+ const shares = spans.map((sp) => sp / (floorRange(vs - sp / 2, vs + sp / 2).hi -
164+ floorRange(vs - sp / 2, vs + sp / 2).lo));
165+ // ...while real structure keeps its own range exactly. v once the spots
166+ // have formed spans ~0.03 on the same 0.9, two orders above the residue.
167+ const real = floorRange(0.895, 0.924);
168+ check(
169+ 'compare: fp32 residue on a constant field does not become a picture',
170+ shares.every((s) => s < 0.1) && real.lo === 0.895 && real.hi === 0.924,
171+ `residue uses ${shares.map((s) => `${(100 * s).toFixed(1)}%`).join(', ')} ` +
172+ `of the colormap at lmax 63/127/255; real pattern ` +
173+ `[${real.lo}, ${real.hi}] left untouched`,
174+ );
175+ }
176+
151177 // ---- the variant grid and its reference ---------------------------------
152178 {
153179 const variants = crossProduct([1, 4], [31, 63], [1, 2]);