← Natura·Docs/Modules/Source

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);