soil-test.js
244 lines · in natura-src-1.0.0.zip as natura/soil-test.js · a test
// soil-test.js -- the SOIL system proven by RUNNING (society-test.js discipline: assert on NUMBERS, never on
// reasoning). Determinism + boundedness first, then the pedogenic emergences that must FALL OUT of the
// coupled mechanism rather than be written in:
// (a) a soil CATENA -- from a uniform start, soil deepens and enriches DOWNSLOPE (erosion strips the
// ridges, deposition fattens the valley floor);
// (b) fertile valley floors vs near-bare, thin steep slopes;
// (c) organic matter + nutrients ACCUMULATE where vegetation grew and died -- the land records its biology;
// (d) MASS BALANCE -- N and P are conserved to floating-point precision (final stock == rock + atmosphere
// sources minus leaching + erosion sinks); nothing is silently created;
// (e) STRIP the vegetation (a fire/clearing) and the now-bare ground loses its fertile topsoil far faster
// than a vegetated control -- the organic body it took centuries to build collapses.
//
// Runs standalone on the soil system's OWN drivers (terrain heightfield, climate, soil-responsive
// vegetation). `node soil-test.js` should print numbers and exit 0.
const NAT = require('./natura.js');
const { makeSoil } = require('./soil.js');
const { World, UNITS } = NAT;
const YEAR = UNITS.YEAR;
let pass = 0, fail = 0;
const ok = (c, m) => { if (c) pass++; else { fail++; console.log(' FAIL: ' + m); } };
// --- helpers ----------------------------------------------------------------------------------------
function tot(a) { let s = 0; for (let k = 0; k < a.length; k++) s += a[k]; return s; }
// mean of a field over a band of rows [jlo, jhi] (j=0 is the top ridge, j=N-1 the bottom); the built-in
// terrain runs a ridge (top) down to a valley floor (~72-88% down) so rows index the hillslope catena.
function bandMean(arr, N, jlo, jhi) {
let s = 0, n = 0;
for (let j = jlo; j <= jhi; j++) for (let i = 0; i < N; i++) { s += arr[j * N + i]; n++; }
return s / n;
}
function grow(seed, N, years, cfg) {
const w = World({ seed, systems: [makeSoil(Object.assign({ N }, cfg || {}))] });
for (let y = 0; y < years; y++) w.step(YEAR);
return w;
}
console.log('soil assertions:');
// ============================================================================================
// 1. DETERMINISM -- the whole pedogenic column reproduces exactly from the same seed.
// ============================================================================================
{
const a = grow(3, 14, 120).get('soil'), b = grow(3, 14, 120).get('soil');
let same = true;
for (const key of ['bedrock', 'regolith', 'mineral', 'humus', 'litter', 'availN', 'availP', 'organicN', 'organicP', 'clay', 'bio']) {
for (let k = 0; k < 14 * 14; k++) if (a[key][k] !== b[key][k]) { same = false; break; }
if (!same) break;
}
ok(same, 'same seed -> an identical soil (every layer + pool + nutrient reproduces)');
}
// ============================================================================================
// 2. BOUNDED + FINITE -- every pool stays finite and non-negative across a long deep-time run.
// ============================================================================================
{
const s = grow(9, 16, 400).get('soil');
let good = true, why = '';
for (const key of ['regolith', 'mineral', 'humus', 'litter', 'sand', 'silt', 'clay', 'availN', 'availP', 'organicN', 'organicP', 'plantN', 'plantP', 'bio', 'moistIdx', 'water']) {
for (let k = 0; k < 16 * 16; k++) { const v = s[key][k]; if (!isFinite(v) || v < -1e-6) { good = false; why = key + '[' + k + ']=' + v; break; } }
if (!good) break;
}
ok(good, 'every pool stays finite and non-negative over 400 yr' + (why ? ' (' + why + ')' : ''));
// moisture is a saturation fraction, must live in [0,1]
let mOk = true; for (let k = 0; k < 16 * 16; k++) { const v = s.moistIdx[k]; if (v < -1e-6 || v > 1 + 1e-6) { mOk = false; break; } }
ok(mOk, 'soil moisture is a saturation fraction bounded in [0,1]');
}
// ============================================================================================
// 3. WEATHERING BUILDS SOIL FROM BARE ROCK, and TEXTURE records the climate. From zero soil, a
// mineral column forms; warm+wet chemical weathering mints CLAY, cold/dry comminution stays SANDY.
// ============================================================================================
{
const N = 16;
// a warm, wet world vs a cold, dry world -- same rock, different climate
const warmWet = grow(4, N, 300, { climate: { meanTemp: 16, tempSwing: 8, moisture: 0.85 } }).get('soil');
const coldDry = grow(4, N, 300, { climate: { meanTemp: 2, tempSwing: 10, moisture: 0.2 } }).get('soil');
const minWW = tot(warmWet.mineral), minCD = tot(coldDry.mineral);
ok(minWW > 1 && minCD > 0.5, 'weathering builds a mineral soil column from bare bedrock (mineral mass warm-wet ' + minWW.toFixed(1) + ', cold-dry ' + minCD.toFixed(1) + ')');
const clayFracWW = tot(warmWet.clay) / Math.max(tot(warmWet.mineral), 1e-6);
const clayFracCD = tot(coldDry.clay) / Math.max(tot(coldDry.mineral), 1e-6);
ok(clayFracWW > clayFracCD * 1.5, 'TEXTURE records climate -- the warm wet soil is far CLAYIER, the cold dry one stays SANDY (clay fraction ' + clayFracWW.toFixed(3) + ' warm-wet vs ' + clayFracCD.toFixed(3) + ' cold-dry)');
}
// ============================================================================================
// (a) THE CATENA -- from a UNIFORM start the hillslope sorts itself: erosion strips the ridge and
// deposition fattens the valley floor, so soil DEEPENS downslope, and the fine, fertile topsoil
// ENRICHES downslope. Verified by RUNNING the built-in slope to a mature soil and reading the
// ridge/mid-slope/valley bands off the geomorphic budget.
// ============================================================================================
{
const N = 24;
const w = grow(1, N, 500);
const s = w.get('soil');
// bands: ridge (top), mid-slope, valley floor (the depositional sink at ~72-88% down)
const ridge = a => bandMean(a, N, 0, 3), mid = a => bandMean(a, N, 8, 12), valley = a => bandMean(a, N, 17, 19);
const dRidge = ridge(s.mineral) + ridge(s.humus), dMid = mid(s.mineral) + mid(s.humus), dValley = valley(s.mineral) + valley(s.humus);
ok(dValley > dMid && dValley > dRidge * 1.05, '(a) soil is DEEPEST on the valley floor -- deposition fattens the bottom (soil depth ridge ' + dRidge.toFixed(3) + ', mid ' + dMid.toFixed(3) + ', valley ' + dValley.toFixed(3) + ')');
// net geomorphic budget: cumulative deposition minus erosion. The ridge is a net LOSER, the valley the
// least-eroded (most depositional) band -- the erosion-strips-deposits-fills signature.
const net = new Float32Array(N * N); for (let k = 0; k < N * N; k++) net[k] = s.cumDeposited[k] - s.cumEroded[k];
const nRidge = ridge(net), nValley = valley(net);
ok(nValley > nRidge, '(a) the geomorphic budget runs downhill -- the ridge is net-eroded, the valley net-depositional (net dep-ero ridge ' + nRidge.toFixed(2) + ' -> valley ' + nValley.toFixed(2) + ')');
// enrichment: the fertile fine fraction (humus) and phosphorus concentrate downslope
const hRidge = ridge(s.humus), hValley = valley(s.humus);
ok(hValley > hRidge * 3, '(a) the HUMUS (dark fertile topsoil) enriches strongly downslope (' + hRidge.toFixed(4) + ' ridge -> ' + hValley.toFixed(4) + ' valley, ' + (hValley / Math.max(hRidge, 1e-9)).toFixed(1) + 'x)');
const pRidge = ridge(s.availP), pValley = valley(s.availP);
ok(pValley > pRidge, '(a) plant-available PHOSPHORUS concentrates downslope too (availP ' + pRidge.toFixed(4) + ' ridge -> ' + pValley.toFixed(4) + ' valley)');
console.log(' catena: depth ' + dRidge.toFixed(2) + '/' + dMid.toFixed(2) + '/' + dValley.toFixed(2) + ' (ridge/mid/valley) | humus ' + hRidge.toFixed(3) + ' -> ' + hValley.toFixed(3));
}
// ============================================================================================
// (b) FERTILE VALLEY FLOORS vs NET-ERODING STEEP SLOPES. The steep upper hillslope (steep in the
// underlying bedrock, and net-EROSIONAL) sheds its topsoil, so it carries less fertility than the
// deep, sheltered, net-DEPOSITIONAL valley floor. Steepness is read off the fixed BEDROCK profile
// (the driver of erosion), not the deposition-roughened evolved surface.
// ============================================================================================
{
const N = 24;
const w = grow(1, N, 500);
const s = w.get('soil'), f = w.fields().soil;
const mid = a => bandMean(a, N, 6, 11), valley = a => bandMean(a, N, 17, 19);
// initSlope = the terrain's INHERENT steepness (from the pristine relief), the clean driver of erosion,
// undistorted by the deposition that roughens the live evolved surface.
ok(mid(f.initSlope) > valley(f.initSlope) * 1.5, '(b) the upper hillslope is far STEEPER than the valley floor (inherent slope ' + mid(f.initSlope).toFixed(2) + ' hillslope vs ' + valley(f.initSlope).toFixed(2) + ' valley)');
// net geomorphic budget: the steep slope is net-eroding, the valley net-depositing
const net = new Float32Array(N * N); for (let k = 0; k < N * N; k++) net[k] = s.cumDeposited[k] - s.cumEroded[k];
ok(mid(net) < valley(net), '(b) the steep hillslope is NET-ERODING while the valley floor is NET-DEPOSITING (net dep-ero ' + mid(net).toFixed(2) + ' hillslope vs ' + valley(net).toFixed(2) + ' valley)');
// and the consequence: the valley floor is more FERTILE (deeper humus + more available nutrient)
const humMid = mid(s.humus), humValley = valley(s.humus);
const fertMid = mid(s.availN) + mid(s.availP), fertValley = valley(s.availN) + valley(s.availP);
ok(humValley > humMid * 1.3 && fertValley > fertMid, '(b) so the valley floor is FERTILE and the eroding slope thinner (humus ' + humMid.toFixed(3) + '->' + humValley.toFixed(3) + ', avail-nutrient ' + fertMid.toFixed(4) + '->' + fertValley.toFixed(4) + ')');
console.log(' slopes vs valley: inherent-slope ' + mid(f.initSlope).toFixed(2) + '/' + valley(f.initSlope).toFixed(2) + ' | net-budget ' + mid(net).toFixed(2) + '/' + valley(net).toFixed(2) + ' | humus ' + humMid.toFixed(3) + '/' + humValley.toFixed(3));
}
// ============================================================================================
// (c) THE LAND RECORDS ITS BIOLOGY -- organic matter + organic nutrients ACCUMULATE where vegetation
// has grown and died. Compared against a LIFELESS control (a world where nothing colonizes),
// the living world builds a humus body and an organic nutrient store; the dead one cannot.
// ============================================================================================
{
const N = 16;
const living = grow(6, N, 400).get('soil');
// a lifeless world: identical soil + climate, but STERILE -- no plants colonize at all. Weathering still
// runs (mineral soil + texture still form), but with no biology there is no litter, so no organic body.
const dead = grow(6, N, 400, { sterile: true }).get('soil');
const humLiving = tot(living.humus), humDead = tot(dead.humus);
ok(humLiving > 1 && humLiving > humDead * 10, '(c) a living world builds a deep HUMUS body; a lifeless one cannot (humus ' + humLiving.toFixed(2) + ' living vs ' + humDead.toFixed(3) + ' lifeless)');
const orgNLiving = tot(living.organicN), orgNDead = tot(dead.organicN);
ok(orgNLiving > orgNDead * 10, '(c) organic NITROGEN accumulates only where biology cycled it (organicN ' + orgNLiving.toFixed(3) + ' living vs ' + orgNDead.toFixed(3) + ' lifeless)');
// and the biomass really did establish and persist
ok(tot(living.bio) > 0.5, '(c) vegetation established and persists on the soil it helped build (biomass ' + tot(living.bio).toFixed(2) + ')');
console.log(' biology recorded: humus ' + humLiving.toFixed(2) + ' (living) vs ' + humDead.toFixed(3) + ' (lifeless) | organicN ' + orgNLiving.toFixed(2) + ' vs ' + orgNDead.toFixed(3));
}
// ============================================================================================
// (d) MASS BALANCE -- N and P are conserved to floating-point precision. Every atom is ledgered:
// final stock (available + organic + living plant tissue) == (rock source + atmospheric source)
// - (leaching sink + erosion-export sink). Nothing is silently created anywhere in the loop.
// ============================================================================================
{
const N = 16;
const s = grow(2, N, 600).get('soil');
const L = s.ledger;
const nStock = tot(s.availN) + tot(s.organicN) + tot(s.plantN);
const pStock = tot(s.availP) + tot(s.organicP) + tot(s.plantP);
const nExpected = (L.rockN + L.atmN) - (L.leachedN + L.erodedN);
const pExpected = L.rockP - (L.leachedP + L.erodedP);
const nRel = Math.abs(nStock - nExpected) / Math.max(nStock, 1e-9);
const pRel = Math.abs(pStock - pExpected) / Math.max(pStock, 1e-9);
ok(nRel < 1e-4, '(d) NITROGEN is conserved: stock ' + nStock.toFixed(4) + ' == sources-sinks ' + nExpected.toFixed(4) + ' (rel error ' + nRel.toExponential(2) + ')');
ok(pRel < 1e-4, '(d) PHOSPHORUS is conserved: stock ' + pStock.toFixed(4) + ' == sources-sinks ' + pExpected.toFixed(4) + ' (rel error ' + pRel.toExponential(2) + ')');
// sanity: the loop actually MOVED mass (sources and sinks are non-trivial, not a trivial 0==0)
ok(L.rockP > 1 && L.leachedN > 1 && L.atmN > 1, '(d) the loop is live, not a trivial identity (rockP ' + L.rockP.toFixed(1) + ', atmN ' + L.atmN.toFixed(1) + ', leachedN ' + L.leachedN.toFixed(1) + ' all moved)');
console.log(' mass balance: N residual ' + nRel.toExponential(2) + ', P residual ' + pRel.toExponential(2) + ' (exact to float precision)');
}
// ============================================================================================
// (e) FIRE / CLEARING -- strip the vegetation and hold the ground bare, and the fertile topsoil it took
// centuries to build collapses: the organic body decomposes and erodes away with nothing to replace
// it, while an identical VEGETATED control keeps building. Verified by RUNNING two identical mature
// worlds and clearing one.
// ============================================================================================
{
const N = 20;
// two identical mature worlds
const ctrl = grow(5, N, 300), fire = grow(5, N, 300);
const cst = ctrl.get('soil'), fst = fire.get('soil');
const body = st => ({ hum: tot(st.humus), orgN: tot(st.organicN) });
const c0 = body(cst), f0 = body(fst);
const bio0 = tot(fst.bio);
// FIRE: combust standing biomass to litter carbon + return its nutrients as ash (mass-conserving), then
// hold every cell bare (sustained clearing: no recruitment, no fixation, no cover).
fst.cleared = new Uint8Array(N * N).fill(1);
for (let k = 0; k < N * N; k++) { fst.litter[k] += fst.bio[k] * 0.1; fst.availN[k] += fst.plantN[k]; fst.availP[k] += fst.plantP[k]; fst.plantN[k] = 0; fst.plantP[k] = 0; fst.bio[k] = 0; }
// run both another 80 yr
for (let y = 0; y < 80; y++) { ctrl.step(YEAR); fire.step(YEAR); }
const c1 = body(cst), f1 = body(fst);
ok(bio0 > 0.5, '(e) both worlds were richly vegetated before the fire (standing biomass ' + bio0.toFixed(2) + ')');
ok(tot(fst.bio) < 0.01, '(e) the cleared ground stayed BARE -- nothing recolonized (biomass ' + tot(fst.bio).toFixed(4) + ' vs control ' + tot(cst.bio).toFixed(2) + ')');
// the vegetated control keeps or grows its humus body; the bare ground loses nearly all of it
ok(c1.hum >= c0.hum * 0.9, '(e) the vegetated control HOLDS its fertile topsoil (humus ' + c0.hum.toFixed(2) + ' -> ' + c1.hum.toFixed(2) + ')');
ok(f1.hum < f0.hum * 0.25, '(e) the BARE ground loses its humus body -- decomposed + eroded away with nothing replacing it (humus ' + f0.hum.toFixed(2) + ' -> ' + f1.hum.toFixed(2) + ', ' + (100 * (f1.hum - f0.hum) / f0.hum).toFixed(0) + '%)');
ok(f1.orgN < c1.orgN * 0.25, '(e) organic NITROGEN collapses on the bare ground vs the vegetated control (organicN ' + f1.orgN.toFixed(3) + ' bare vs ' + c1.orgN.toFixed(3) + ' vegetated)');
console.log(' fire: humus ' + f0.hum.toFixed(2) + ' -> ' + f1.hum.toFixed(2) + ' (bare) vs ' + c0.hum.toFixed(2) + ' -> ' + c1.hum.toFixed(2) + ' (vegetated)');
}
// ============================================================================================
// EVENTS -- the system reports named soil-scale happenings (an eroding land, a maturing soil) that a
// coupled world can react to, the way society reports panic/rally.
// ============================================================================================
{
const w = World({ seed: 1, systems: [makeSoil({ N: 16 })] });
const kinds = {};
for (let y = 0; y < 400; y++) { w.step(YEAR); for (const e of w.events()) kinds[e.kind] = (kinds[e.kind] || 0) + 1; }
ok(kinds['erosion-pulse'] > 0 && kinds['sediment-export'] > 0, 'the soil reports EROSION + SEDIMENT-EXPORT events as the land wears down (' + (kinds['erosion-pulse'] || 0) + ' erosion, ' + (kinds['sediment-export'] || 0) + ' export)');
ok(kinds['soil-matured'] === 1, 'a SOIL-MATURED event fires exactly once when a deep soil first forms (' + (kinds['soil-matured'] || 0) + ')');
}
// ============================================================================================
// COUPLING -- the soil is a proper Natura SYSTEM: it reads an upstream terrain + weather + vegetation
// through ctx.fields (so it can plug into the real systems), yet runs standalone on its own drivers.
// ============================================================================================
{
// minimal upstream stand-ins exposing the same field shapes the real systems do
const M = 12;
const terrain = { name: 'terrain', init() { const H = new Float32Array(M * M), land = new Uint8Array(M * M).fill(1); for (let j = 0; j < M; j++) for (let i = 0; i < M; i++) H[j * M + i] = 1 - j / (M - 1); return { N: M, H, land }; }, step(s) { return s; }, fields(s) { return { N: s.N, elevation: s.H, land: s.land }; } };
const weather = { name: 'weather', init() { return { N: M, temp: new Float32Array(M * M).fill(14), moisture: new Float32Array(M * M).fill(0.7) }; }, step(s) { return s; }, fields(s) { return { N: s.N, temp: s.temp, moisture: s.moisture }; } };
const veg = { name: 'vegetation', init() { return { N: M, bio: new Float32Array(M * M).fill(0.3) }; }, step(s) { for (let k = 0; k < s.bio.length; k++) s.bio[k] = Math.min(0.7, s.bio[k] + 0.002); return s; }, fields(s) { return { N: s.N, biomass: s.bio }; } };
const w = World({ seed: 1, systems: [terrain, weather, veg, makeSoil({ N: M })] });
for (let y = 0; y < 120; y++) w.step(YEAR);
const soil = w.get('soil'), f = w.fields().soil;
ok(soil._haveTerrain && soil._haveVeg, 'the soil COUPLES -- it ingested the upstream terrain and vegetation fields');
ok(Math.abs(bandMean(soil.temp, M, 0, M - 1) - 14) < 0.5, 'the soil read the WEATHER temperature through the coupling (mean soil temp ' + bandMean(soil.temp, M, 0, M - 1).toFixed(1) + ' == weather 14)');
ok(tot(soil.humus) > 0.5 && tot(f.availP) > 0, 'driven by the coupled vegetation + climate, the soil built a real humus + nutrient body (humus ' + tot(soil.humus).toFixed(2) + ', availP ' + tot(f.availP).toFixed(3) + ')');
}
console.log(`\n${pass} passed, ${fail} failed`);
process.exit(fail ? 1 : 0);