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