vegetation.js
106 lines · in natura-src-1.0.0.zip as natura/vegetation.js · a module · its entry in the module reference · its test: vegetation-test.js
// vegetation.js -- Natura VEGETATION (SYSTEMS.md #5, ALGORITHMS.md): plants grow, spread, and die by
// climate. The REAL "weather changes the world it falls on" -- it reads the weather's rain, holds a
// decaying SOIL-WATER, and grows BIOMASS logistically toward a carrying capacity set by water + warmth +
// season, spreading to neighbours and dying back in drought. So the windward slopes go green where the
// rain falls and the lee stays bare in the rain-shadow -- because the PLANTS respond, not a render hack.
//
// A Natura system spec (init/step/fields/events), deterministic + node-testable. Couples to WEATHER
// (rain, temp, land) and the CLOCK (season/light). UMD (node test / window.Vegetation).
(function (root, factory) {
if (typeof module !== 'undefined' && module.exports) module.exports = factory();
else root.Vegetation = factory();
})(typeof self !== 'undefined' ? self : this, function () {
'use strict';
const clamp = (v, a, b) => v < a ? a : v > b ? b : v;
// make a Vegetation system. config: { N, rate, respire, drainDays, waterK, tempOpt, tempWidth,
// spread, seed(min biomass) }. Reads ctx.fields[weatherName].
function makeVegetation(config) {
config = config || {};
const N = config.N || 48;
const weatherName = config.weather || 'weather';
const rate = config.rate != null ? config.rate : 0.5; // growth rate (per sim-day)
const respire = config.respire != null ? config.respire : 0.08; // baseline biomass loss/day
const drain = config.drainDays != null ? config.drainDays : 20; // soil-water e-folding time (days)
const waterK = config.waterK != null ? config.waterK : 6; // rain -> soil-water gain
const tOpt = config.tempOpt != null ? config.tempOpt : 20; // best growing temperature
const tWid = config.tempWidth != null ? config.tempWidth : 16; // temperature tolerance
const spread = config.spread != null ? config.spread : 0.06; // seed dispersal to neighbours
const seedB = config.seed != null ? config.seed : 0.03; // starting biomass on bare land (so it can grow)
const wrap = !!config.wrap; // torus: seeds spread across the world edges
const disturbFrom = config.disturbance || null; // OPTIONAL: a system exposing a `disturbance` mask (blowdown/storm/volcanic) beyond fire
const killRate = config.killRate != null ? config.killRate : 0.6; // fraction of biomass a full disturbance kills/day (-> STANDING DEAD)
const deadDecay = config.deadDecay != null ? config.deadDecay : 0.03; // standing dead / coarse woody debris rots away per day
return {
name: config.name || 'vegetation',
domain: 'life',
scale: 'ecological',
params: config,
init(rng, params, clock) {
const bio = new Float32Array(N * N), sw = new Float32Array(N * N), land = new Uint8Array(N * N);
return { N, bio, sw, land, haveLand: false, dead: new Float32Array(N * N) };
},
step(st, ctx, dt) {
const w = ctx.fields[weatherName];
if (!w || !w.rain) return st; // needs weather to be present
const dd = clamp(dt / 86400, 0, 2); // sim-days this step
if (dd <= 0) return st;
const { bio, sw, land } = st;
if (!st.haveLand && w.land) { for (let k = 0; k < N * N; k++) { land[k] = w.land[k]; if (land[k]) bio[k] = seedB; } st.haveLand = true; }
const seasonLight = 0.55 + 0.45 * Math.sin((ctx.clock.yearPhase() - 0.15) * 2 * Math.PI); // summer > winter
const decay = Math.exp(-dd / drain);
const grz = (ctx.fields.ecosystem && ctx.fields.ecosystem.grazing) || null; // herbivores eat biomass
const brn = (ctx.fields.fire && ctx.fields.fire.burned) || null; // fire consumes biomass
const nb = new Float32Array(N * N);
for (let j = 0; j < N; j++) for (let i = 0; i < N; i++) {
const k = j * N + i;
if (!land[k]) { nb[k] = 0; continue; }
// soil water: gains rain, drains exponentially
sw[k] = sw[k] * decay + (w.rain[k] || 0) * waterK * dd;
const wet = clamp(sw[k] / (sw[k] + 0.4), 0, 1); // 0 bone-dry .. ~1 saturated (saturating)
const T = w.temp ? w.temp[k] : 18;
const warm = Math.exp(-((T - tOpt) * (T - tOpt)) / (tWid * tWid)); // Gaussian temp suitability
const K = clamp(wet * warm * seasonLight, 0, 1); // carrying capacity from the climate
const b = bio[k];
const growth = rate * b * (1 - b / Math.max(K, 1e-3)) * dd; // logistic toward K
const loss = respire * b * dd + (wet < 0.12 ? 0.25 * b * dd : 0); // respiration + drought die-back
nb[k] = clamp(b + growth - loss - (grz ? grz[k] : 0) - (brn ? brn[k] : 0), 0, 1); // minus grazing + fire
}
// spread: a little of each cell's biomass seeds its land neighbours (discretized Laplacian)
if (spread > 0) {
for (let j = 0; j < N; j++) for (let i = 0; i < N; i++) {
const k = j * N + i; if (!land[k]) continue;
let acc = 0, n = 0;
let nb4;
if (wrap) { const im1 = (i + N - 1) % N, ip1 = (i + 1) % N, jm1 = (j + N - 1) % N, jp1 = (j + 1) % N;
nb4 = [j * N + im1, j * N + ip1, jm1 * N + i, jp1 * N + i]; }
else nb4 = [k - 1, k + 1, k - N, k + N];
for (const m of nb4) if (m >= 0 && m < N * N && land[m]) { acc += nb[m] - nb[k]; n++; }
if (n) nb[k] = clamp(nb[k] + spread * dd * (acc / n), 0, 1);
}
}
for (let k = 0; k < N * N; k++) bio[k] = nb[k];
// DISTURBANCE regime (opt-in): a storm/blowdown/volcanic-winter mask kills live biomass into STANDING DEAD
// (coarse woody debris) which then rots away -- so a stand carries the memory of its wounds, then succeeds
// back. Default (no disturbance source) leaves bio + dead untouched, so an uncoupled world is identical.
{ const dead = st.dead; const dd2 = Math.min(1, dt / 86400);
const dfield = disturbFrom && ctx.fields[disturbFrom] && ctx.fields[disturbFrom].disturbance;
for (let k = 0; k < N * N; k++) {
if (dfield) { const kill = Math.min(bio[k], killRate * dfield[k] * bio[k] * dd2); bio[k] -= kill; dead[k] += kill; }
if (dead[k] > 0) dead[k] *= (1 - deadDecay * dd2); // CWD rots (a soil system could read this as detritus)
} }
return st;
},
fields(st) {
return { N: st.N, biomass: st.bio, soilWater: st.sw, greenness: st.bio, land: st.land, standingDead: st.dead };
}
};
}
return { makeVegetation };
});