ecosystem-test.js
99 lines · in natura-src-1.0.0.zip as natura/ecosystem-test.js · a test
// ecosystem-test.js -- assertions for the Ecosystem (predator-prey agents on the vegetated world).
const N = require('./natura.js');
const { makeWeather } = require('./weather.js');
const { makeVegetation } = require('./vegetation.js');
const { makeEcosystem } = require('./ecosystem.js');
const { World, UNITS } = N;
let pass = 0, fail = 0;
const ok = (c, m) => { if (c) pass++; else { fail++; console.log(' FAIL: ' + m); } };
function terrain(x, y) { if (x < -0.55) return -0.2; return 0.15 + 0.6 * Math.exp(-(x * x) / 0.03); }
const GRID = 40;
function mkWorld(seed, withEco) {
const sys = [ makeWeather({ N: GRID, elevation: terrain, seaLevel: 0 }), makeVegetation({ N: GRID, weather: 'weather', waterK: 13, drainDays: 40 }) ];
if (withEco) sys.push(makeEcosystem({ N: GRID }));
return World({ seed, systems: sys });
}
// spin up vegetation first, then run the ecosystem, recording the population series
function runEco(seed, days) {
const w = mkWorld(seed, true);
for (let i = 0; i < 60 * 6; i++) w.step(UNITS.HOUR * 4); // grow the vegetation first
const series = [];
for (let d = 0; d < days; d++) { for (let s = 0; s < 6; s++) w.step(UNITS.HOUR * 4); const p = w.get('ecosystem').pop; series.push([p.herbivore, p.predator]); }
return { w, series };
}
const std = (xs) => { const m = xs.reduce((a, b) => a + b, 0) / xs.length; return Math.sqrt(xs.reduce((a, b) => a + (b - m) * (b - m), 0) / xs.length); };
console.log('ecosystem assertions:');
// persistence + variation: both species survive a long run and their numbers MOVE (dynamic, not static or dead)
{
const { w, series } = runEco(7, 500);
const herbs = series.map(s => s[0]), preds = series.map(s => s[1]);
const finalH = herbs[herbs.length - 1], finalP = preds[preds.length - 1];
ok(finalH > 0 && finalP > 0, 'both herbivores and predators persist to the end (h=' + finalH + ', p=' + finalP + ')');
ok(std(herbs) > 2 && std(preds) > 1, 'populations VARY over time (not static): std herb ' + std(herbs).toFixed(1) + ', pred ' + std(preds).toFixed(1));
ok(Math.max(...herbs) < 900, 'herbivores do not explode past the cap');
console.log(' herb range ' + Math.min(...herbs) + '..' + Math.max(...herbs) + ', pred range ' + Math.min(...preds) + '..' + Math.max(...preds));
}
// determinism
{
const a = runEco(11, 120).series, b = runEco(11, 120).series;
let same = a.length === b.length && a.every((s, i) => s[0] === b[i][0] && s[1] === b[i][1]);
ok(same, 'same seed -> identical population series (reproducible)');
}
// GRAZING couples back: with herbivores, vegetation is lower than without (they eat it)
{
const noEco = mkWorld(5, false); for (let i = 0; i < 260 * 6; i++) noEco.step(UNITS.HOUR * 4);
const eco = mkWorld(5, true); for (let i = 0; i < 260 * 6; i++) eco.step(UNITS.HOUR * 4);
const totBio = (wld) => { const v = wld.get('vegetation'); let s = 0; for (let k = 0; k < v.bio.length; k++) s += v.bio[k]; return s; };
ok(totBio(eco) < totBio(noEco), 'herbivore grazing reduces total vegetation (' + totBio(eco).toFixed(1) + ' with vs ' + totBio(noEco).toFixed(1) + ' without)');
}
// TROPHIC DEPENDENCY: the food web is grounded in the prey base. Take the herbivores away (and shut off
// the migration flux) and the predators cannot persist -- they are sustained BY the herbivores, not
// free-floating. (The indirect "predators green the world" cascade is real but regime- and phase-
// dependent in this model, so it is demonstrated in the scenarios, not asserted as a brittle hard test.)
{
const w = World({ seed: 3, systems: [ makeWeather({ N: GRID, elevation: terrain, seaLevel: 0 }), makeVegetation({ N: GRID, weather: 'weather', waterK: 13, drainDays: 40 }), makeEcosystem({ N: GRID, herb0: 0, pred0: 30, immHerb: 0, immPred: 0 }) ] });
for (let i = 0; i < 200 * 6; i++) w.step(UNITS.HOUR * 4);
const p = w.get('ecosystem').pop;
ok(p.predator < 3 && p.herbivore === 0, 'trophic dependency: with no prey base the predators collapse (pred=' + p.predator + ', herb=' + p.herbivore + ')');
console.log(' no-prey world: predators fall to ' + p.predator);
}
// DISEASE / EPIDEMIC: a density-dependent self-regulating check. Verified by RUNNING (ON vs OFF), never
// by reasoning that it "should" work (the balance-trap lesson: a young-as-small-meal once did the OPPOSITE).
{
function runD(seed, disease, days) {
const w = World({ seed, systems: [ makeWeather({ N: GRID, elevation: terrain, seaLevel: 0 }), makeVegetation({ N: GRID, weather: 'weather', waterK: 13, drainDays: 40 }), makeEcosystem({ N: GRID, disease }) ] });
for (let i = 0; i < 60 * 6; i++) w.step(UNITS.HOUR * 4);
let hmax = 0, peakSick = 0, peakSickPred = 0;
for (let d = 0; d < days; d++) { for (let s = 0; s < 6; s++) w.step(UNITS.HOUR * 4); const st = w.get('ecosystem'); hmax = Math.max(hmax, st.pop.herbivore); peakSick = Math.max(peakSick, st.pop.sick || 0); peakSickPred = Math.max(peakSickPred, st.pop.sickPred || 0); }
const st = w.get('ecosystem'); return { hmax, peakSick, peakSickPred, disDeaths: st.disDeaths || 0, finalH: st.pop.herbivore };
}
const off3 = runD(3, false, 450), on3 = runD(3, true, 450);
ok(on3.peakSick > 0 && on3.disDeaths > 0, 'disease is a real epidemic -- infections spread AND some die (peakSick=' + on3.peakSick + ', disDeaths=' + on3.disDeaths + ')');
ok(on3.hmax < off3.hmax * 0.7, 'the epidemic CHECKS a boom (seed-3 herb max ' + off3.hmax + ' -> ' + on3.hmax + ' with disease)');
ok(on3.finalH > 0, 'the population PERSISTS through the epidemics -- self-regulates, never extincts (finalH=' + on3.finalH + ')');
ok(on3.peakSickPred > 0, 'disease crosses the FOOD WEB -- predators catch it TROPHICALLY from eating sick prey / infected carcasses (peak sick predators=' + on3.peakSickPred + ')');
const offD = runD(5, false, 160); ok(offD.disDeaths === 0 && offD.peakSick === 0, 'disease OFF -> a clean toggle: no infections, no disease deaths');
console.log(' disease seed-3: OFF herb max ' + off3.hmax + ' -> ON ' + on3.hmax + ' (disDeaths ' + on3.disDeaths + ', peak sick ' + on3.peakSick + ')');
}
// CHORUS coupling (opt-in): a calling species flashes by a coupled sync system's rhythm; off by default so the
// population dynamics above are untouched (11 assertions unchanged), on it reads the sync coherence.
{
const G = 32;
const sync = { name: 'sync', domain: 'physical', scale: 'ecological', params: {}, init() { return { r: 0 }; }, step(s) { s.r = Math.min(1, s.r + 0.34); return s; }, fields(s) { return { N: G, R: s.r, meanPhase: 0.5 }; } };
const veg = { name: 'vegetation', domain: 'life', scale: 'ecological', params: {}, init() { return {}; }, step(s) { return s; }, fields() { const o = new Float32Array(G * G).fill(1), land = new Uint8Array(G * G).fill(1); return { N: G, biomass: o, soilWater: o, land, greenness: o, food: o, canopy: o }; } };
const w = World({ seed: 1, systems: [sync, veg, makeEcosystem({ N: G, vegetation: 'vegetation', sync: 'sync' })] });
const r0 = w.fields().ecosystem.chorusR; for (let i = 0; i < 5; i++) w.step(UNITS.DAY);
ok(r0 === 0 && w.fields().ecosystem.chorusR > 0.9, 'CHORUS coupling: the ecosystem reads a locked sync’s rhythm to flash by (R ' + r0 + ' -> ' + w.fields().ecosystem.chorusR.toFixed(2) + ')');
}
console.log(`\n${pass} passed, ${fail} failed`);
process.exit(fail ? 1 : 0);