← Natura·Docs/Modules/Source

hydro.js

118 lines · in natura-src-1.0.0.zip as natura/hydro.js · a module · its entry in the module reference · its test: hydro-test.js

// hydro.js -- Natura WATER CYCLE (ONTOLOGY-FOLD-IN #5): water as ONE CONSERVED substance moving through its
// phases. Weather already rains and ocean already routes rivers, but nowhere is the water a single conserved
// budget -- so here every drop is accounted for across LIQUID (pools, soil), VAPOUR (in the air), and CLOUD
// (condensed aloft), and the total never changes. Warmth EVAPORATES liquid to vapour; vapour drifts and, where
// the air is cool enough to be over-full, CONDENSES to cloud; cloud RAINS back to liquid; liquid RUNS downhill
// and pools in the valleys -- then evaporates again. The emergence is the CONSERVATION itself: drought and flood
// are not water made or destroyed, they are the same water out of balance (warm one place, dry it, and the water
// it loses falls as rain and flood somewhere cooler). Couples to WEATHER (temperature) + TERRAIN (runoff) and,
// naturally, to the CRYOSPHERE (ice is water locked out of the cycle). Deterministic, node-testable. UMD.
(function (root, factory) {
  if (typeof module !== 'undefined' && module.exports) module.exports = factory();
  else root.Hydro = factory();
})(typeof self !== 'undefined' ? self : this, function () {
  'use strict';

  const clamp = (v, a, b) => v < a ? a : v > b ? b : v;
  const YEAR = 365 * 86400;

  // config: { N, elevation(x,y), temperature (degC | fn | via weather), evapK, satBase, satSlope, condenseK,
  //   rainK, vapDiffuse, runoffK, initLiquid, weather, maxDtDays }
  function makeHydro(config) {
    config = config || {};
    const N = config.N || 48;
    const evapK = config.evapK != null ? config.evapK : 0.06;      // liquid -> vapour per warm-day
    const satBase = config.satBase != null ? config.satBase : 0.015;   // vapour the air holds when cold
    const satSlope = config.satSlope != null ? config.satSlope : 0.004; // ...more per degree warmer
    const condenseK = config.condenseK != null ? config.condenseK : 0.5;  // over-full vapour -> cloud
    const rainK = config.rainK != null ? config.rainK : 0.25;      // cloud -> rain (liquid) per day
    const vapDiffuse = config.vapDiffuse != null ? config.vapDiffuse : 0.14; // vapour spreads through the air
    const runoffK = config.runoffK != null ? config.runoffK : 0.5;  // liquid creeps downhill
    const freezeC = config.freezeC != null ? config.freezeC : 0;    // liquid freezes to ice below this
    const meltC = config.meltC != null ? config.meltC : 2;          // ice melts to liquid above this
    const freezeK = config.freezeK != null ? config.freezeK : 0.35; // freeze/melt rate per degree past the threshold
    const meltK = config.meltK != null ? config.meltK : 0.35;
    const initLiquid = config.initLiquid != null ? config.initLiquid : 0.4;
    const weatherName = config.weather || null;
    const maxDd = config.maxDtDays != null ? config.maxDtDays : 2;
    const tempCfg = config.temperature != null ? config.temperature : 15;
    const elevFn = config.elevation || ((x, y) => 0.3);
    const gx = (i) => (i / (N - 1)) * 2 - 1;
    const scal = (c, x, y) => typeof c === 'function' ? c(x, y) : c;

    return {
      name: config.name || 'hydro',
      domain: 'physical',
      scale: 'landscape',
      params: config,

      init() {
        const liq = new Float32Array(N * N), vap = new Float32Array(N * N), cld = new Float32Array(N * N), ice = new Float32Array(N * N), elev = new Float32Array(N * N);
        for (let j = 0; j < N; j++) for (let i = 0; i < N; i++) { const k = j * N + i; elev[k] = elevFn(gx(i), gx(j)); liq[k] = initLiquid; }
        return { N, liq, vap, cld, ice, elev, total: 0 };
      },

      step(st, ctx, dt) {
        const dd = clamp(dt / 86400, 0, maxDd); if (dd <= 0) return st;
        const { liq, vap, cld, ice, elev } = st;
        const w = weatherName && ctx.fields[weatherName] ? ctx.fields[weatherName] : null;
        const wTemp = w && w.temp ? w.temp : null;
        // 1. PHASE CHANGES per cell: evaporate -> (later diffuse) -> condense -> rain (all conserving)
        for (let j = 0; j < N; j++) for (let i = 0; i < N; i++) {
          const k = j * N + i;
          const temp = wTemp ? wTemp[k] : scal(tempCfg, gx(i), gx(j));
          const e = clamp(evapK * clamp((temp - 2) / 25, 0, 1) * liq[k] * dd, 0, liq[k]);   // warmth lifts liquid to vapour
          liq[k] -= e; vap[k] += e;
        }
        // 2. VAPOUR DIFFUSES through the air (exactly conservative: each adjacency exchanged once)
        if (vapDiffuse > 0) {
          const d = st._d || (st._d = new Float32Array(N * N)); d.fill(0);
          const D = Math.min(0.2, vapDiffuse * dd);   // cap for explicit 4-neighbour stability (>0.25 diverges)
          for (let j = 0; j < N; j++) for (let i = 0; i < N; i++) { const k = j * N + i;
            if (i < N - 1) { const f = D * (vap[k] - vap[k + 1]); d[k] -= f; d[k + 1] += f; }
            if (j < N - 1) { const f = D * (vap[k] - vap[k + N]); d[k] -= f; d[k + N] += f; }
          }
          for (let k = 0; k < N * N; k++) vap[k] += d[k];
        }
        // 3. CONDENSE (where the cool air is over-full) + RAIN
        for (let j = 0; j < N; j++) for (let i = 0; i < N; i++) {
          const k = j * N + i;
          const temp = wTemp ? wTemp[k] : scal(tempCfg, gx(i), gx(j));
          const sat = Math.max(0, satBase + satSlope * temp);
          const excess = vap[k] - sat;
          if (excess > 0) { const c = clamp(condenseK * excess * dd, 0, vap[k]); vap[k] -= c; cld[k] += c; }
          const r = clamp(rainK * cld[k] * dd, 0, cld[k]); cld[k] -= r; liq[k] += r;
          // freeze / melt: the fourth phase (liquid <-> ICE), conserving -- cold LOCKS water out of the flow
          if (temp < freezeC) { const fr = clamp(freezeK * (freezeC - temp) / 8 * liq[k] * dd, 0, liq[k]); liq[k] -= fr; ice[k] += fr; }
          else if (temp > meltC && ice[k] > 0) { const m = clamp(meltK * (temp - meltC) / 8 * ice[k] * dd, 0, ice[k]); ice[k] -= m; liq[k] += m; }
        }
        // 4. RUNOFF: liquid creeps to lower ground and pools in the valleys (conservative flux)
        if (runoffK > 0) {
          const d = st._r || (st._r = new Float32Array(N * N)); d.fill(0);
          for (let j = 0; j < N; j++) for (let i = 0; i < N; i++) { const k = j * N + i; if (liq[k] <= 1e-6) continue;
            const nb = [i > 0 ? k - 1 : -1, i < N - 1 ? k + 1 : -1, j > 0 ? k - N : -1, j < N - 1 ? k + N : -1];
            let tot = 0; const wt = [0, 0, 0, 0];
            for (let n = 0; n < 4; n++) { const kk = nb[n]; if (kk < 0) continue; const dh = (elev[k] + liq[k]) - (elev[kk] + liq[kk]); if (dh > 0) { wt[n] = dh; tot += dh; } }
            if (tot <= 0) continue;
            const out = Math.min(liq[k] * 0.25, runoffK * dd * tot);   // capped for stability; flows on the WATER surface (bed+liquid)
            for (let n = 0; n < 4; n++) { if (wt[n] <= 0) continue; const amt = out * (wt[n] / tot); d[k] -= amt; d[nb[n]] += amt; }
          }
          for (let k = 0; k < N * N; k++) liq[k] += d[k];
        }
        let tot = 0; for (let k = 0; k < N * N; k++) tot += liq[k] + vap[k] + cld[k] + ice[k];
        st.total = tot;
        return st;
      },

      // OUT: the four phases + total; other systems read `liquid` (surface water), `cloud`, `ice`, and a
      // normalized `moisture` 0..1 (so e.g. CHEMISTRY can rust iron where the ground is wet -- systems compose)
      fields(st) {
        const M = st.N * st.N, moist = st._moist || (st._moist = new Float32Array(M));
        for (let k = 0; k < M; k++) moist[k] = Math.min(1, st.liq[k] / 0.5);
        return { N: st.N, liquid: st.liq, vapour: st.vap, cloud: st.cld, ice: st.ice, moisture: moist, elev: st.elev, total: st.total };
      }
    };
  }

  return { makeHydro };
});