reaction-diffusion-test.js
106 lines · in natura-src-1.0.0.zip as natura/reaction-diffusion-test.js · a test
// reaction-diffusion-test.js -- assertions for the Gray-Scott RD system (a Natura field system).
const N = require('./natura.js');
const { rngFrom, World } = N;
const { makeReactionDiffusion, PRESETS } = require('./reaction-diffusion.js');
let pass = 0, fail = 0;
const ok = (c, m) => { if (c) pass++; else { fail++; console.log(' FAIL: ' + m); } };
console.log('reaction-diffusion assertions:');
const DAY = 86400; // seconds -- the Natura dt unit (UNITS.DAY); one day-step = stepsPerDay sub-steps
function run(cfg, days) {
const sys = makeReactionDiffusion(Object.assign({ stepsPerDay: 900 }, cfg)); // enough sub-steps for the pattern to actually organize at Du=0.16
const st = sys.init(rngFrom(cfg.seed != null ? cfg.seed : 7), sys.params, null);
for (let d = 0; d < days; d++) sys.step(st, {}, DAY); // dt in SECONDS -> stepsPerDay sub-steps per day
return { sys, st };
}
function stats(V) { let mn = 1, mx = 0, sum = 0, cov = 0; for (let i = 0; i < V.length; i++) { const v = V[i]; if (v < mn) mn = v; if (v > mx) mx = v; sum += v; if (v > 0.3) cov++; } return { mn: mn, mx: mx, mean: sum / V.length, cov: cov / V.length }; }
function fieldDiff(A, B) { let s = 0; for (let i = 0; i < A.length; i++) s += Math.abs(A[i] - B[i]); return s / A.length; }
// 1. DETERMINISTIC: same seed + preset -> identical field
{
const a = run({ N: 48, preset: 'spots', seed: 3 }, 4).st, b = run({ N: 48, preset: 'spots', seed: 3 }, 4).st;
let same = true; for (let i = 0; i < a.V.length; i++) if (a.V[i] !== b.V[i]) { same = false; break; }
ok(same, 'same seed + preset -> identical field (deterministic)');
const c = run({ N: 48, preset: 'spots', seed: 9 }, 4).st;
ok(fieldDiff(a.V, c.V) > 0.02, 'a different seed -> a different field');
}
// 2. SELF-ORGANISES: from a seeded perturbation the field grows real STRUCTURE (not uniform, not empty, not full),
// and it EVOLVES away from the seed it started as (Turing emergence)
{
const sys = makeReactionDiffusion({ N: 48, preset: 'coral', stepsPerDay: 900 }); // coral is a mid-coverage regime -> an unambiguous "structure formed"
const st = sys.init(rngFrom(5), sys.params, null);
const seed = Float32Array.from(st.V); // snapshot the initial blobs
for (let d = 0; d < 6; d++) sys.step(st, {}, DAY);
const s = stats(st.V);
ok(s.mx - s.mn > 0.4, 'the field spans low AND high -> a real pattern formed (range ' + (s.mx - s.mn).toFixed(2) + ')');
ok(s.cov > 0.05 && s.cov < 0.7, 'the pattern is neither empty nor saturated (coverage ' + (s.cov * 100).toFixed(0) + '%)');
ok(fieldDiff(seed, st.V) > 0.05, 'the field EVOLVED away from its seed (emergence, not the seed frozen)');
}
// 3. BOUNDED: no blow-up -- U and V stay within [0,1]
{
const st = run({ N: 48, preset: 'worms', seed: 2 }, 8).st;
let good = true; for (let i = 0; i < st.V.length; i++) { if (st.V[i] < -1e-6 || st.V[i] > 1 + 1e-6 || st.U[i] < -1e-6 || st.U[i] > 1 + 1e-6) { good = false; break; } }
ok(good, 'U and V stay bounded in [0,1] over a long run (stable, no blow-up)');
}
// 4. the SAME law at different (feed,kill) makes DIFFERENT patterns
{
const spots = run({ N: 48, preset: 'spots', seed: 4 }, 6).st;
const maze = run({ N: 48, preset: 'maze', seed: 4 }, 6).st;
ok(fieldDiff(spots.V, maze.V) > 0.05, 'spots vs maze from the same seed -> substantially different fields (' + fieldDiff(spots.V, maze.V).toFixed(3) + ')');
const coral = run({ N: 48, preset: 'coral', seed: 4 }, 6).st;
const sc = stats(spots.V).cov, cc = stats(coral.V).cov; // sparse spots vs dense coral -> a clear density gap
ok(cc - sc > 0.05, 'the regimes differ in coverage too (spots ' + (sc * 100).toFixed(0) + '% vs coral ' + (cc * 100).toFixed(0) + '%)');
ok(Object.keys(PRESETS).length >= 6, 'a whole atlas of named regimes (' + Object.keys(PRESETS).length + ')');
}
// 5. COMPOSES in a Natura World and exposes its field
{
const sys = makeReactionDiffusion({ N: 48, preset: 'coral', name: 'rd' });
const w = World({ seed: 3, systems: [sys] });
for (let d = 0; d < 4; d++) w.step(N.UNITS ? N.UNITS.DAY : 1);
const F = w.fields().rd;
ok(F && typeof F.at === 'function' && F.V && F.N === 48, 'RD composes in a Natura World and exposes V + at()');
const v = F.at(0.5, 0.5);
ok(v >= 0 && v <= 1, 'fields().at(x,y) returns a bounded concentration');
ok(Math.abs(F.at(1.5, 0.5) - F.at(0.5, 0.5)) < 1e-6, 'at() wraps on the torus (x=1.5 == x=0.5)');
}
// 6. setFK steers the chemistry live (move through the Pearson plane without rebuilding)
{
const sys = makeReactionDiffusion({ N: 32, preset: 'spots' });
const st = sys.init(rngFrom(1), sys.params, null);
sys.setFK(st, PRESETS.waves.feed, PRESETS.waves.kill);
ok(st.feed === PRESETS.waves.feed && st.kill === PRESETS.waves.kill, 'setFK moves the live (feed,kill) -> the pattern can morph regime mid-run');
}
// 7. seedAt paints V into the field mid-run (It Paints Itself's interactive drag-to-seed)
{
const sys = makeReactionDiffusion({ N: 48, preset: 'spots' });
const st = sys.init(rngFrom(1), sys.params, null);
for (let i = 0; i < st.V.length; i++) { st.V[i] = 0; st.U[i] = 1; } // wipe to a blank field
const before = st.V[24 * 48 + 24];
sys.seedAt(st, 0.5, 0.5, 4, 0.6); // paint a V blob at the centre
ok(before === 0 && Math.abs(st.V[24 * 48 + 24] - 0.6) < 1e-6, 'seedAt paints V at a normalized point (blank -> painted)'); // Float32 tolerance
let touched = 0; for (let i = 0; i < st.V.length; i++) if (st.V[i] > 0) touched++;
ok(touched > 20 && touched < 120, 'seedAt paints a bounded disc, not the whole grid (' + touched + ' cells)');
}
// 8. gradAt exposes the local slope of V -- the relief-render data a piece shades ridges from
{
const { st, sys } = run({ N: 48, preset: 'coral', seed: 2 }, 6);
const F = sys.fields(st);
let maxg = 0; for (let y = 0; y < 48; y++) for (let x = 0; x < 48; x++) { const g = F.gradAt(x / 48, y / 48); const m = Math.abs(g.gx) + Math.abs(g.gy); if (m > maxg) maxg = m; }
ok(maxg > 0.05, 'gradAt reports a real slope where the pattern has edges (max |grad| ' + maxg.toFixed(3) + ')');
const flat = makeReactionDiffusion({ N: 16 });
const fst = flat.init(rngFrom(1), flat.params, null);
for (let i = 0; i < fst.V.length; i++) fst.V[i] = 0.3; // a perfectly flat field
const fg = flat.fields(fst).gradAt(0.5, 0.5);
ok(Math.abs(fg.gx) < 1e-6 && Math.abs(fg.gy) < 1e-6, 'gradAt is zero on a flat field (no false relief)');
}
console.log(`\n${pass} passed, ${fail} failed`);
process.exit(fail ? 1 : 0);