← Natura·Docs/Modules/Source

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 };
});