vegetation-test.js
92 lines · in natura-src-1.0.0.zip as natura/vegetation-test.js · a test
// vegetation-test.js -- assertions for the Vegetation system, coupled to Weather. node vegetation-test.js.
const N = require('./natura.js');
const { makeWeather } = require('./weather.js');
const { makeVegetation } = require('./vegetation.js');
const { World, UNITS } = N;
let pass = 0, fail = 0;
const ok = (c, m) => { if (c) pass++; else { fail++; console.log(' FAIL: ' + m); } };
// same test terrain as weather-test: western OCEAN, a ridge at x=0, land east; prevailing wind east.
function terrain(x, y) { if (x < -0.55) return -0.2; return 0.15 + 0.6 * Math.exp(-(x * x) / 0.03); }
const GRID = 48;
const gx = (i) => (i / (GRID - 1)) * 2 - 1;
function mkWorld(seed) {
return World({ seed, systems: [
makeWeather({ N: GRID, elevation: terrain, seaLevel: 0 }),
makeVegetation({ N: GRID, weather: 'weather' })
] });
}
function run(seed, days) { const w = mkWorld(seed); const steps = days * 6; for (let i = 0; i < steps; i++) w.step(UNITS.HOUR * 4); return w; }
function meanBand(F, land, xlo, xhi) {
let s = 0, n = 0;
for (let j = 0; j < GRID; j++) for (let i = 0; i < GRID; i++) { const k = j * GRID + i; const x = gx(i); if (land[k] && x >= xlo && x <= xhi) { s += F[k]; n++; } }
return n ? s / n : 0;
}
console.log('vegetation system assertions:');
// grows, finite, bounded, no biomass on sea
{
const w = run(7, 120);
const v = w.get('vegetation');
let nan = false, mn = 9, mx = -9, seaBio = 0;
for (let k = 0; k < v.bio.length; k++) { const b = v.bio[k]; if (!isFinite(b)) nan = true; mn = Math.min(mn, b); mx = Math.max(mx, b); if (!v.land[k]) seaBio += b; }
ok(!nan, 'no NaN in biomass');
ok(mn >= 0 && mx <= 1, 'biomass bounded in [0,1], got ' + mn.toFixed(2) + '..' + mx.toFixed(2));
ok(mx > 0.2, 'vegetation grew somewhere (max biomass ' + mx.toFixed(2) + ')');
ok(seaBio === 0, 'no biomass on the sea');
}
// determinism
{
const a = run(11, 60).get('vegetation'), b = run(11, 60).get('vegetation');
let same = true; for (let k = 0; k < a.bio.length; k++) if (a.bio[k] !== b.bio[k]) { same = false; break; }
ok(same, 'same seed -> identical biomass (reproducible)');
}
// THE emergence test: vegetation INHERITS the rain-shadow -- windward (west) greener than lee (east)
{
const w = run(3, 200);
const v = w.get('vegetation');
const windward = meanBand(v.bio, v.land, -0.45, -0.1);
const lee = meanBand(v.bio, v.land, 0.1, 0.45);
ok(windward > lee * 1.3, 'vegetation greener on the windward slope than the lee (windward ' + windward.toFixed(3) + ' vs lee ' + lee.toFixed(3) + ')');
console.log(' windward biomass ' + windward.toFixed(3) + ', lee biomass ' + lee.toFixed(3) + ' (ratio ' + (windward / (lee + 1e-6)).toFixed(2) + 'x)');
}
// drought die-back: after growing, cut off the rain and biomass declines
{
const w = mkWorld(5);
for (let i = 0; i < 120 * 6; i++) w.step(UNITS.HOUR * 4); // grow
const before = meanBand(w.get('vegetation').bio, w.get('vegetation').land, -0.4, 0.4);
// manually starve: zero the weather rain field, keep stepping vegetation
const wf = w.get('weather');
for (let i = 0; i < 80 * 6; i++) { for (let k = 0; k < wf.rain.length; k++) wf.rain[k] = 0; w.step(UNITS.HOUR * 4); }
const after = meanBand(w.get('vegetation').bio, w.get('vegetation').land, -0.4, 0.4);
ok(after < before, 'biomass declines in drought (before ' + before.toFixed(3) + ' -> after ' + after.toFixed(3) + ')');
}
// coupling guard: vegetation alone (no weather) is a safe no-op
{
const w = World({ seed: 1, systems: [makeVegetation({ N: GRID })] });
for (let i = 0; i < 10; i++) w.step(UNITS.HOUR * 4);
const v = w.get('vegetation');
let allZero = true; for (let k = 0; k < v.bio.length; k++) if (v.bio[k] !== 0) allZero = false;
ok(allZero, 'vegetation with no weather present is a safe no-op (no growth from nothing)');
}
// DISTURBANCE regime (opt-in): a coupled disturbance mask kills live biomass into STANDING DEAD, which then
// rots; an uncoupled stand is untouched. (The Continuing Forest's disturbance-and-succession deepening.)
{
const disturb = (on) => ({ name: 'disturb', domain: 'physical', scale: 'landscape', params: {}, init() { return {}; }, step(s) { return s; }, fields() { return { N: GRID, disturbance: new Float32Array(GRID * GRID).fill(on ? 0.15 : 0) }; } });
const mk = (on) => World({ seed: 5, systems: [makeWeather({ N: GRID, elevation: terrain, seaLevel: 0 }), disturb(on), makeVegetation({ N: GRID, weather: 'weather', disturbance: 'disturb' })] });
const calm = mk(false), hit = mk(true);
for (let i = 0; i < 180; i++) { calm.step(UNITS.HOUR * 4); hit.step(UNITS.HOUR * 4); }
const tot = (w, f) => { const F = w.fields().vegetation[f]; let s = 0; for (let k = 0; k < GRID * GRID; k++) s += F[k]; return s; };
ok(tot(hit, 'biomass') < tot(calm, 'biomass') && tot(hit, 'standingDead') > 1 && tot(calm, 'standingDead') === 0,
'DISTURBANCE wounds the stand -- less live biomass, real standing dead (hit bio ' + tot(hit, 'biomass').toFixed(1) + ' vs calm ' + tot(calm, 'biomass').toFixed(1) + ', dead ' + tot(hit, 'standingDead').toFixed(1) + '); the calm stand is untouched');
}
console.log(`\n${pass} passed, ${fail} failed`);
process.exit(fail ? 1 : 0);