watershed-test.js
108 lines · in natura-src-1.0.0.zip as natura/watershed-test.js · a test
// watershed-test.js -- assertions for GROUNDWATER + DRAINAGE (the drainage network + the aquifer beneath it,
// coupled to recharge + warmth). Run: node watershed-test.js
const N = require('./natura.js');
const { makeWatershed } = require('./watershed.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); } };
// a south-draining plane with bumps, so tributaries CONVERGE into a real network (deterministic, no rng)
const land = (x, y) => 0.10 + 0.80 * (0.5 - 0.5 * y) + 0.12 * Math.sin(4 * x + 1.3) * Math.cos(3 * y + 0.7) + 0.06 * Math.sin(9 * x) * Math.sin(7 * y);
const build = (cfg) => World({ seed: 5, systems: [makeWatershed(Object.assign({ N: GRID, elevation: land }, cfg))] });
const days = (w, d) => { for (let i = 0; i < Math.round(d / 4); i++) w.step(4 * DAY); };
console.log('watershed assertions:');
// 1. DETERMINISM -- same config -> identical drainage + table
{
const a = build({}), b = build({}); days(a, 200); days(b, 200);
const fa = a.fields().watershed, fb = b.fields().watershed;
let same = true; for (let k = 0; k < GRID * GRID; k++) if (fa.flowAccum[k] !== fb.flowAccum[k] || fa.waterTable[k] !== fb.waterTable[k]) { same = false; break; }
ok(same, 'same config -> identical drainage + water table (reproducible)');
}
// 2. A DRAINAGE NETWORK EMERGES -- tributaries gather into a trunk (not parallel sheet-flow)
{
const f = build({}).fields().watershed;
ok(f.maxAcc > 20, 'flow ACCUMULATES into a trunk channel (maxAcc ' + f.maxAcc.toFixed(0) + ' cells)');
ok(f.riverCount > 3 && f.riverCount < f.land * 0.5, 'a river NETWORK forms, not everything nor nothing (' + f.riverCount + ' river cells of ' + f.land + ' land)');
}
// 3. FLOW IS CONSERVED -- every land cell contributes 1 unit of rain; all of it exits through the terminals
{
const w = build({}); const st = w.get('watershed'); const f = w.fields().watershed;
let term = 0; for (let i = 0; i < GRID * GRID; i++) { if (st.elev[i] < st.SEA) continue; const d = st.down[i];
if (d < 0 || st.elev[d] < st.SEA) term += st.acc[i]; }
ok(Math.abs(term - f.land) < 0.5, 'flow accumulation is conserved: terminal outflow (' + term.toFixed(0) + ') == land cells (' + f.land + ')');
}
// 4. THE WATER TABLE HAS SHAPE -- deep to water under the ridges, near the surface in the valleys
{
const f = build({}).fields().watershed; let hiS = 0, hiN = 0, loS = 0, loN = 0;
// rank land cells by elevation; compare depth-to-water of the top third vs the bottom third
const es = []; for (let i = 0; i < GRID * GRID; i++) if (f.elev[i] >= 0.34) es.push(f.elev[i]);
es.sort((a, b) => a - b); const loT = es[(es.length * 0.33) | 0], hiT = es[(es.length * 0.66) | 0];
for (let i = 0; i < GRID * GRID; i++) { if (f.elev[i] < 0.34) continue; if (f.elev[i] >= hiT) { hiS += f.depthToWater[i]; hiN++; } else if (f.elev[i] <= loT) { loS += f.depthToWater[i]; loN++; } }
ok(hiN && loN && (hiS / hiN) > (loS / loN) + 0.01, 'the table lies DEEP under high ground, SHALLOW in the valleys (ridge depth ' + (hiS / hiN).toFixed(3) + ' vs valley ' + (loS / loN).toFixed(3) + ')');
}
// 5. SPRINGS flow where the table breaks the surface (on land, never on a river or lake cell)
{
const w = build({ recharge: 0.6 }); days(w, 120); const f = w.fields().watershed;
ok(f.springCount > 0, 'springs flow where the table meets the surface (' + f.springCount + ')');
let clean = true; for (let i = 0; i < GRID * GRID; i++) if (f.springMask[i] && (f.riverMask[i] || f.lake[i] || f.elev[i] < 0.34)) clean = false;
ok(clean, 'every spring is on open land -- never on a river, a lake, or the sea');
}
// 6. THE EMERGENCE -- a WET+COOL country floods the table (more springs); a DRY+WARM one draws it down (drought)
{
const wet = build({ recharge: 1.0, temperature: 4 }); const dry = build({ recharge: 0.08, temperature: 30 });
days(wet, 400); days(dry, 400);
const fw = wet.fields().watershed, fd = dry.fields().watershed;
ok(fw.offset > fd.offset + 0.01, 'wet+cool RAISES the table, dry+warm LOWERS it (offset wet ' + fw.offset.toFixed(3) + ' vs dry ' + fd.offset.toFixed(3) + ')');
ok(fw.springCount > fd.springCount, 'the wet country runs many springs, the drought few (' + fw.springCount + ' vs ' + fd.springCount + ')');
let sawDrought = false; for (let i = 0; i < 120; i++) { dry.step(4 * DAY); if (dry.get('watershed') && wet.systems) {} const ev = (dry.events ? dry.events() : []); }
// drought event fires when the excursion bottoms out
const evs = dry.events ? dry.events() : []; sawDrought = evs.some(e => e.type === 'drought');
ok(sawDrought, 'the dry+warm country tips into a DROUGHT event');
}
// 7. REAL CROSS-SYSTEM COUPLING -- read RECHARGE from the water cycle (hydro): a wet cycle lifts the table
{
const w = World({ seed: 5, systems: [
makeHydro({ N: GRID, initLiquid: 0.5, temperature: 10 }),
makeWatershed({ N: GRID, elevation: land, rechargeFrom: 'hydro', temperature: 10 })
] });
const off0 = w.fields().watershed.offset;
for (let i = 0; i < 60; i++) w.step(4 * DAY);
const off1 = w.fields().watershed.offset;
ok(off1 > off0, 'coupled to hydro: the water cycle\'s moisture RECHARGES the aquifer (offset ' + off0.toFixed(3) + ' -> ' + off1.toFixed(3) + ')');
}
// 8. COUPLE TO THE FORMING LAND -- rivers are geology's agents (terrain erodes); watershed re-derives its
// drainage from a coupled, EVOLVING elevation rather than reinventing the ground
{
const stub = { name: 'terrain', domain: 'physical', scale: 'landscape', params: {},
init() { const M = GRID * GRID, el = new Float32Array(M);
for (let j = 0; j < GRID; j++) for (let i = 0; i < GRID; i++) { const y = j / (GRID - 1); el[j * GRID + i] = y > 0.85 ? 0.20 : 0.40 + 0.40 * (1 - y) + 0.06 * Math.sin(6 * i / GRID) * Math.cos(5 * j / GRID); } return { el }; },
step(s) { return s; }, fields(s) { return { N: GRID, elevation: s.el }; } };
const w = World({ seed: 5, systems: [stub, makeWatershed({ N: GRID, elevationFrom: 'terrain' })] });
w.step(4 * DAY); // the first coupled step re-solves the drainage from terrain's elevation
const f = w.fields().watershed, st = w.get('terrain');
let expLand = 0; for (let k = 0; k < GRID * GRID; k++) if (st.el[k] >= 0.34) expLand++;
ok(f.land === expLand && f.maxAcc > 10, 'coupled to terrain: watershed re-carves its drainage from the FORMING land (land ' + f.land + ' == ' + expLand + ', maxAcc ' + f.maxAcc.toFixed(0) + ')');
}
// 9. THE TABLE IS SPATIAL -- recharge the WEST and starve the EAST, and the springs gather where the water is
{
const w = build({ recharge: (x, y) => x < 0 ? 1.0 : 0.06, temperature: 8 }); days(w, 400);
const f = w.fields().watershed; let wL = 0, wR = 0, sL = 0, sR = 0;
for (let j = 0; j < GRID; j++) for (let i = 0; i < GRID; i++) { const k = j * GRID + i; if (f.elev[k] < 0.34) continue;
const x = (i / (GRID - 1)) * 2 - 1; if (x < 0) { wL += f.adj[k]; if (f.springMask[k]) sL++; } else { wR += f.adj[k]; if (f.springMask[k]) sR++; } }
ok(wL > wR && sL > sR, 'the table is SPATIAL: the recharged west holds a higher table + more springs than the starved east (adj W>E, springs ' + sL + '>' + sR + ')');
}
console.log(`\n${pass} passed, ${fail} failed`);
process.exit(fail ? 1 : 0);