← Natura·Docs/Modules/Source

hydro-test.js

85 lines · in natura-src-1.0.0.zip as natura/hydro-test.js · a test

// hydro-test.js -- assertions for the WATER CYCLE (water as one conserved substance through its phases).
const N = require('./natura.js');
const { makeHydro } = require('./hydro.js');
const { World, UNITS } = N;
const GRID = 40, DAY = UNITS.DAY;

let pass = 0, fail = 0;
const ok = (c, m) => { if (c) pass++; else { fail++; console.log('  FAIL: ' + m); } };
const build = (cfg) => World({ seed: 3, systems: [makeHydro(Object.assign({ N: GRID }, cfg))] });
const days = (w, d) => { for (let i = 0; i < Math.round(d / 2); i++) w.step(2 * DAY); };
const bowl = (x, y) => 0.15 + 0.5 * (x * x + y * y);   // a valley: low at centre, high at the rim
const half = (arr, left) => { let s = 0; for (let j = 0; j < GRID; j++) for (let i = 0; i < GRID; i++) { const x = (i / (GRID - 1)) * 2 - 1; if ((left && x < 0) || (!left && x >= 0)) s += arr[j * GRID + i]; } return s; };
const totalOf = (w) => { const f = w.fields().hydro; return f.total; };

console.log('hydro assertions:');

// 1. DETERMINISM
{
  const a = build({ temperature: 18 }); const b = build({ temperature: 18 }); days(a, 200); days(b, 200);
  const la = a.fields().hydro.liquid, lb = b.fields().hydro.liquid;
  let same = true; for (let k = 0; k < GRID * GRID; k++) if (la[k] !== lb[k]) { same = false; break; }
  ok(same, 'same config -> identical water field (reproducible)');
}

// 2. THE EMERGENCE -- CONSERVATION: every drop is accounted for; the total never changes across all phases
{
  const w = build({ temperature: (x, y) => 22 - 14 * y, elevation: bowl });   // a warm/cool gradient + a valley
  const t0 = (w.step(2 * DAY), totalOf(w));                                    // total after the first settled step
  let maxDrift = 0;
  for (let i = 0; i < 400; i++) { w.step(2 * DAY); maxDrift = Math.max(maxDrift, Math.abs(totalOf(w) - t0) / t0); }
  ok(maxDrift < 0.005, 'water is CONSERVED across evaporate/condense/rain/runoff (max total drift ' + (maxDrift * 100).toFixed(3) + '% over 400 steps)');
}

// 3. THE CYCLE TURNS -- starting as all liquid, vapour + cloud appear and rain returns (it is not static)
{
  const w = build({ temperature: 20, elevation: bowl });
  let sawVap = 0, sawCld = 0;
  for (let i = 0; i < 120; i++) { w.step(2 * DAY); const f = w.fields().hydro;
    let v = 0, c = 0; for (let k = 0; k < GRID * GRID; k++) { v += f.vapour[k]; c += f.cloud[k]; }
    sawVap = Math.max(sawVap, v); sawCld = Math.max(sawCld, c); }
  ok(sawVap > 0.5 && sawCld > 0.1, 'the cycle turns: liquid rises to vapour and condenses to cloud (peak vapour ' + sawVap.toFixed(1) + ', cloud ' + sawCld.toFixed(1) + ')');
}

// 4. DROUGHT + FLOOD are the SAME water out of balance -- warm one side, its water falls on the cool side
{
  const w = build({ temperature: (x, y) => x < 0 ? 30 : 4, elevation: (x, y) => 0.3 });   // warm left, cold right, flat
  const liq0 = w.fields().hydro.liquid; const warm0 = half(liq0, true), cool0 = half(liq0, false);
  days(w, 600);
  const liq1 = w.fields().hydro.liquid; const warm1 = half(liq1, true), cool1 = half(liq1, false);
  // they start EQUAL; the warm side becomes the drought and the cool side ends holding the water -- a gap opens
  // from one conserved budget (the rest rides as vapour/cloud on its way across -- test 2 proves nothing is lost)
  ok(warm1 < warm0 * 0.85 && (cool1 - warm1) > 40 && (cool0 - warm0) < 5,
    'the warm side becomes DROUGHT while the cool side holds the water -- one budget redistributed (equal start; warm ' + warm0.toFixed(0) + '->' + warm1.toFixed(0) + ' vs cool ' + cool1.toFixed(0) + ')');
}

// 5. POOLING -- liquid runs downhill and collects in the valley, not on the ridge
{
  const w = build({ temperature: 10, elevation: bowl });   // cool (little evaporation) so runoff dominates
  days(w, 500);
  const f = w.fields().hydro, liq = f.liquid; let lowSum = 0, lowN = 0, hiSum = 0, hiN = 0;
  for (let j = 0; j < GRID; j++) for (let i = 0; i < GRID; i++) { const k = j * GRID + i, e = bowl((i / (GRID - 1)) * 2 - 1, (j / (GRID - 1)) * 2 - 1);
    if (e < 0.3) { lowSum += liq[k]; lowN++; } else if (e > 0.55) { hiSum += liq[k]; hiN++; } }
  ok(lowN && hiN && lowSum / lowN > (hiSum / hiN) * 1.5, 'liquid pools in the low valley, drains off the ridge (valley ' + (lowSum / lowN).toFixed(2) + ' vs ridge ' + (hiSum / hiN).toFixed(2) + ' per cell)');
}

// 6. THE FOURTH PHASE -- ICE locks water out of the flow when cold; warmth releases it; still conserved
{
  const cold = build({ temperature: -8, elevation: bowl });
  const t0 = (cold.step(2 * DAY), totalOf(cold)); days(cold, 200);
  const fc = cold.fields().hydro; let iceSum = 0; for (let k = 0; k < GRID * GRID; k++) iceSum += fc.ice[k];
  ok(iceSum > 5, 'cold LOCKS water into ice (' + iceSum.toFixed(0) + ' iced)');
  ok(Math.abs(totalOf(cold) - t0) / t0 < 0.005, 'the four-phase budget stays conserved with ice (drift ' + (Math.abs(totalOf(cold) - t0) / t0 * 100).toFixed(3) + '%)');
  const warm = build({ temperature: 18, elevation: bowl }); days(warm, 200);
  let iceW = 0; { const f = warm.fields().hydro; for (let k = 0; k < GRID * GRID; k++) iceW += f.ice[k]; }
  ok(iceW < 0.5, 'a warm world holds no ice (' + iceW.toFixed(2) + ')');
  // seed a frozen world, then warm it: the ice melts back to liquid (the thaw)
  const thaw = build({ temperature: 16, elevation: (x, y) => 0.3 }); const st = thaw.get('hydro');
  for (let k = 0; k < GRID * GRID; k++) { st.ice[k] = 0.3; st.liq[k] = 0.05; }
  let iceBefore = 0; for (let k = 0; k < GRID * GRID; k++) iceBefore += st.ice[k];
  days(thaw, 80); let iceAfter = 0; { const f = thaw.fields().hydro; for (let k = 0; k < GRID * GRID; k++) iceAfter += f.ice[k]; }
  ok(iceAfter < iceBefore * 0.3, 'the thaw RELEASES the ice back to liquid (' + iceBefore.toFixed(0) + ' -> ' + iceAfter.toFixed(0) + ' ice)');
}

console.log(`\n${pass} passed, ${fail} failed`);
process.exit(fail ? 1 : 0);