← Natura·Docs/Modules/Source

fire.js

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

// fire.js -- Natura FIRE (SYSTEMS.md #9, ALGORITHMS.md): the drought -> fire -> regrowth loop, one of
// the most satisfying couplings in the framework. A single lightning strike in a dry season catches in
// the fuel (the vegetation), and the fire SPREADS cell to cell -- faster DOWNWIND (the weather's wind)
// and UPSLOPE (the terrain), stronger where the fuel is thick and the ground is dry, and it DIES against
// wet ground or where the fuel runs out. Burning consumes the vegetation (couples back: the biomass drops
// where it burns) and leaves a SCAR that slowly heals as the plants regrow -- so the same valley can burn
// again seasons later. Emergent: the burn follows the wind, wet regions survive as firebreaks, a dry year
// carries a strike across a whole catchment.
//
// A Natura system spec (init/step/fields/events). Reads vegetation (biomass=fuel, soilWater=dryness),
// weather (wind), and an elevation sampler (slope). Deterministic + node-testable. UMD.
(function (root, factory) {
  if (typeof module !== 'undefined' && module.exports) module.exports = factory();
  else root.Fire = factory();
})(typeof self !== 'undefined' ? self : this, function () {
  'use strict';

  const clamp = (v, a, b) => v < a ? a : v > b ? b : v;

  // make a Fire system. config: { N, vegetation, weather, elevation(x,y)->e, igniteP, spreadK, burnRate,
  //   windStr, slopeStr, wetDouse, scarFade }.
  function makeFire(config) {
    config = config || {};
    const N = config.N || 64;
    const vegName = config.vegetation || 'vegetation';
    const weatherName = config.weather || 'weather';
    const igniteP = config.igniteP != null ? config.igniteP : 0.06;   // lightning strikes / day, x dryness x fuel
    const spreadK = config.spreadK != null ? config.spreadK : 6.0;     // spread gain to a neighbour / day
    const burnRate = config.burnRate != null ? config.burnRate : 0.7;  // fuel consumed / day (a cell burns ~a day, long enough to spread)
    const windStr = config.windStr != null ? config.windStr : 0.9;     // downwind spread boost
    const slopeStr = config.slopeStr != null ? config.slopeStr : 5.0;  // upslope spread boost
    const wetDouse = config.wetDouse != null ? config.wetDouse : 0.7;  // soilWater above which rain douses fire
    const scarFade = config.scarFade != null ? config.scarFade : 0.02; // burn-scar heals / day
    const fuelMin = config.fuelMin != null ? config.fuelMin : 0.04;    // below this biomass a cell won't carry fire
    const emberK = config.emberK != null ? config.emberK : 0.5;        // embers thrown ~ per intensity per day (0 = off)
    const emberDist = config.emberDist != null ? config.emberDist : 4.5; // base downwind jump (cells) -- past the neighbours
    const emberWindMin = config.emberWindMin != null ? config.emberWindMin : 0.4; // min wind magnitude to loft an ember
    const elevFn = config.elevation || ((x, y) => 0.2);
    const gx = (i) => (i / (N - 1)) * 2 - 1;
    const dryOf = (sw) => 0.3 / (sw + 0.3);                            // 0 sodden .. 1 bone-dry (inverse of veg wetness)

    return {
      name: config.name || 'fire',
      domain: 'physical',
      scale: 'local',
      params: config,

      init(rng) {
        const elev = new Float32Array(N * N);
        for (let j = 0; j < N; j++) for (let i = 0; i < N; i++) elev[j * N + i] = elevFn(gx(i), gx(j));
        return { N, rng, elev,
                 burning: new Float32Array(N * N), scar: new Float32Array(N * N), burned: new Float32Array(N * N),
                 active: 0 };
      },

      step(st, ctx, dt) {
        const dd = clamp(dt / 86400, 0, 2); if (dd <= 0) return st;
        const veg = ctx.fields[vegName], wx = ctx.fields[weatherName];
        if (!veg || !veg.biomass) return st;
        const bio = veg.biomass, sw = veg.soilWater, land = veg.land, VN = veg.N || N;
        const wu = wx && wx.windU ? wx.windU : null, wv = wx && wx.windV ? wx.windV : null;
        const rng = st.rng, elev = st.elev, cur = st.burning, scar = st.scar, burned = st.burned;
        const at = (F, i, j) => F[clamp(j, 0, VN - 1) * VN + clamp(i, 0, VN - 1)];
        const nb = new Float32Array(N * N);           // next burning field
        burned.fill(0);
        let active = 0;

        for (let j = 0; j < N; j++) for (let i = 0; i < N; i++) {
          const k = j * N + i;
          if (land && !land[k]) continue;
          const fuel = at(bio, i, j), dry = dryOf(sw ? at(sw, i, j) : 0.3);
          if (cur[k] > 0) {
            // BURNING: consume fuel (the vegetation loses this), douse in the wet, spread to neighbours
            const consume = Math.min(fuel, burnRate * dd * cur[k]);
            burned[k] += consume;
            let inten = cur[k];
            if (sw && at(sw, i, j) > wetDouse) inten *= 0.25;         // rain / wet ground douses it
            if (fuel - consume < fuelMin) { inten = 0; scar[k] = 1; } // fuel spent -> burn scar
            nb[k] = Math.max(nb[k], inten * (1 - 0.25 * dd));         // fade as it consumes
            if (inten > 0) {
              const windU = wu ? at(wu, i, j) : 0, windV = wv ? at(wv, i, j) : 0;
              const wl = Math.hypot(windU, windV) || 1;
              for (let dy = -1; dy <= 1; dy++) for (let dx = -1; dx <= 1; dx++) {
                if (!dx && !dy) continue; const ni = i + dx, nj = j + dy;
                if (ni < 0 || ni >= N || nj < 0 || nj >= N) continue;
                const m = nj * N + ni; if (land && !land[m]) continue;
                if (cur[m] > 0 || scar[m] > 0.5) continue;            // already burning or a fresh scar
                const nfuel = at(bio, ni, nj); if (nfuel < fuelMin) continue;
                const ndry = dryOf(sw ? at(sw, ni, nj) : 0.3);
                const dl = Math.hypot(dx, dy);
                const wind = 1 + windStr * ((dx * windU + dy * windV) / (wl * dl));   // downwind boost
                const slope = 1 + slopeStr * clamp((elev[m] - elev[k]), -0.06, 0.12); // upslope boost
                const p = spreadK * dd * nfuel * ndry * Math.max(0.05, wind) * Math.max(0.1, slope) / dl;
                if (rng() < p) nb[m] = Math.max(nb[m], 0.9);
              }
              // EMBER SPOTTING: a hot cell in strong wind lofts embers DOWNWIND past its neighbours, so a fire
              // can JUMP a gap or a firebreak -- but an ember only catches in dry, fuelled ground (Downwind's mechanism).
              if (emberK > 0 && wl > emberWindMin && rng() < emberK * dd * inten * clamp(wl / 2, 0.2, 2)) {
                const ux = windU / wl, uy = windV / wl, dist = emberDist * (0.6 + rng() * 0.9), lat = (rng() - 0.5) * dist * 0.5;
                const ni = Math.round(i + ux * dist - uy * lat), nj = Math.round(j + uy * dist + ux * lat);
                if (ni >= 0 && ni < N && nj >= 0 && nj < N) { const m = nj * N + ni;
                  if ((!land || land[m]) && cur[m] === 0 && scar[m] < 0.5 && nb[m] === 0) {
                    const nfuel = at(bio, ni, nj), nswv = sw ? at(sw, ni, nj) : 0.3;
                    if (nfuel >= fuelMin && nswv <= wetDouse) nb[m] = 0.9;   // the ember catches downwind
                  } }
              }
            }
          }
          // IGNITION: a lightning strike in dry, well-fuelled ground that is not a fresh scar
          if (cur[k] === 0 && scar[k] < 0.5 && fuel > fuelMin) {
            if (rng() < igniteP * dd * dry * dry * fuel) nb[k] = 1;
          }
          if (nb[k] > 0) active++;
          if (scar[k] > 0) scar[k] = Math.max(0, scar[k] - scarFade * dd);  // scars heal as plants return
        }
        st.burning = nb; st.active = active;
        return st;
      },

      // OUT: fire (burning intensity 0..1), scar (recent-burn 0..1), burned (biomass consumed -> vegetation reads it)
      fields(st) { return { N: st.N, fire: st.burning, scar: st.scar, burned: st.burned }; },
      events(st) { return st.active > 6 ? [{ kind: 'wildfire', cells: st.active }] : []; }
    };
  }

  return { makeFire };
});