wintering.js
500 lines · in natura-src-1.0.0.zip as natura/wintering.js · a module · its entry in the module reference · its test: wintering-test.js
// wintering.js -- Natura WINTERING: how a warm-blooded life gets through the lean season. The seasons made
// more than colour: the same autumn that turns the leaves is, for the animals, a race to lay something by,
// and the winter is a long ledger drawn down against it. Four ways to keep the ledger, all on ONE physiology:
//
// FAT -- the store is the BODY. Fatten in autumn (hyperphagia), then hibernate on it. Two ways to sleep:
// SHALLOW (a bear: body only a few degrees cool, metabolism suppressed to a quarter anyway, one
// long continuous sleep, cubs born and nursed in it) and DEEP (a ground squirrel, a woodchuck, a
// bat: body down to a degree or two above freezing, or below it, with a full ARousal back to 37
// every week or two -- and the arousals, not the cold, are where most of the winter's fat goes).
// LARDER -- the store is ONE PLACE. A chipmunk fills a pantry in its burrow and eats from it at each arousal;
// a red squirrel keeps a midden of cones and stays awake all winter on it.
// SCATTER -- the store is EVERYWHERE. A grey squirrel buries thousands of nuts one by one, finds most again by
// memory and smell, loses some to thieves, forgets the rest -- and the forgotten ones come up as
// oaks in spring. The forest is planted by what the squirrels forget.
// DAILY -- no store that lasts. A chickadee eats all day to put on a tenth of its weight in fat, lets its
// body cool a regulated ten degrees each night in a tree hole, and burns nearly all of it by dawn.
//
// The physiology (per individual, integrated exactly with an exponential step so tiny birds and big bears are
// both stable): basal metabolism by Kleiber (70 kcal/day per kg^0.75 for mammals, 129 for passerines); heat
// lost through the coat as a conductance C = 0.2 M^0.5 W/K, cut by the den/burrow/nest's insulation; torpid
// metabolism = suppression x BMR x Q10^((Tb-Tnorm)/10); a defended floor (a deep hibernator in a burrow colder
// than its floor burns fuel to hold the floor); arousal = thermogenesis at several times BMR until warm. Deep
// torpor ends when a DEBT fills, at a rate that is itself temperature dependent (Q10), so bouts run longest in
// the coldest midwinter and shorten at both ends of the season -- as real ones do. Heart and breath rates
// follow metabolism from their allometric resting values.
//
// The place: air temperature (seasonal + diurnal), day length from latitude, SNOW that falls below freezing and
// melts above it, and the ground: a burrow's temperature lags the surface by weeks, and under deep snow the
// surface stays near zero whatever the air does -- the snow is the blanket the sleepers under it depend on. The
// food: seasonal kinds (mast, forage, berries, seeds, cones, insects, salmon) and one master knob, the year's
// MAST crop, on which a whole northern hardwood community's winter hangs.
//
// Deterministic (seeded rng, clock time as input) + node-testable. UMD. See wintering-test.js.
(function (root, factory) {
if (typeof module !== 'undefined' && module.exports) module.exports = factory();
else root.Wintering = factory();
})(typeof self !== 'undefined' ? self : this, function () {
'use strict';
const clamp = (v, a, b) => v < a ? a : v > b ? b : v;
const TAU = Math.PI * 2, DAY = 86400, YEAR = 365 * DAY;
const FAT_J = 33.4e6; // J per kg of adipose tissue (85% lipid at 39.3 MJ/kg)
const LEAN_J = 5.0e6; // J per kg of lean tissue burnt (mostly water; protein 17 kJ/g)
const CP = 3500; // body heat capacity, J/kg/K
const DOY0 = 79; // Natura's clock year starts at the spring equinox (yearPhase 0 = 20 March)
// Natura clock <-> day of the calendar year (Jan 1 = 0)
const doyOf = (clock) => ((clock.yearPhase() * 365 + DOY0) % 365 + 365) % 365;
const tFromDoy = (doy, year) => ((((doy - DOY0) % 365) + 365) % 365) * DAY + (year || 0) * YEAR;
const dd = (a, b) => { let d = a - b; d = ((d % 365) + 365) % 365; return d > 182.5 ? d - 365 : d; }; // signed day gap on the circle
const bump = (doy, c, w) => Math.exp(-0.5 * (dd(doy, c) / w) ** 2);
const between = (doy, a, b) => a <= b ? doy >= a && doy < b : doy >= a || doy < b; // wraps the new year
// ---- places ----------------------------------------------------------------------------------------------
const PLACES = {
// an oak-beech-hemlock hillside, northern Appalachians / New England
northernHardwood: { lat: 44, tMean: 6.5, tAmp: 14, tPeakDoy: 200, diurnal: 5, snowRate: 0.008, melt: 0.009, caveT: 6.5, soilDamp: 2.2, kappa: 0.55e-6 },
// a salmon river at the base of the Alaska Peninsula (the Brooks River country)
katmai: { lat: 58.6, tMean: 2.5, tAmp: 11, tPeakDoy: 205, diurnal: 4, snowRate: 0.0065, melt: 0.008, caveT: 3, soilDamp: 2.0, kappa: 0.5e-6 },
// the tundra edge: shallow soil over permafrost, a long hard winter
arcticTundra: { lat: 68.6, tMean: -8, tAmp: 19, tPeakDoy: 200, diurnal: 3, snowRate: 0.006, melt: 0.01, caveT: -4, soilDamp: 1.6, kappa: 0.6e-6 }
};
// ---- foods: availability 0..1 through the year (before the mast crop and the snow) ------------------------
const FOODS = {
mast: (d) => d < 250 ? 0 : d < 275 ? (d - 250) / 25 : Math.exp(-(d - 275) / 34), // acorns and beechnuts: ripen, fall, lie on the ground
forage: (d) => between(d, 105, 285) ? clamp(Math.min((d - 105) / 22, (285 - d) / 25), 0, 1) * 0.85 : 0, // greens, grasses, clover
berries: (d) => 0.85 * bump(d, 215, 18),
seeds: (d) => 0.22 + 0.35 * bump(d, 250, 45) + 0.35 * bump(d, 275, 18),
cones: (d) => between(d, 228, 320) ? clamp(Math.min((d - 228) / 12, (320 - d) / 30), 0, 1) : 0,
insects: (d) => between(d, 118, 280) ? clamp(Math.min((d - 118) / 20, (280 - d) / 20), 0, 1) : 0,
salmon: (d) => clamp(1.0 * bump(d, 188, 12) + 0.75 * bump(d, 250, 18), 0, 1),
bark: (d) => 0.34 + 0.12 * bump(d, 190, 60), // insects, eggs, pupae wintering in bark crevices: the chickadee's winter staple
browse: (d) => 0.4 + 0.45 * (between(d, 130, 280) ? clamp(Math.min((d - 130) / 25, (280 - d) / 30), 0, 1) : 0) // willow and aspen bark, and in summer the pond's green plants
};
const GROUND_FOODS = { mast: 1, forage: 1, seeds: 0.5 };
// how much energy a mouthful of each is worth: acorns and salmon are dense, greens are not (a bear cannot fatten on grass)
const QUALITY = { mast: 1, salmon: 1, seeds: 0.8, cones: 0.7, bark: 0.6, berries: 0.55, insects: 0.5, forage: 0.25, browse: 0.4 }; // how much snow buries it (1 = fully at ~15 cm)
// ---- species: the parameters that make each a different life on the same physiology ----------------------
// lean/fat kg at the start (midsummer); tNorm; strategy fat|larder|scatter|daily; torpor none|shallow|deep|daily;
// home den|burrow|nest|cave|cavity (+depth m, insul = fraction of the coat's conductance left); foods (preference);
// maxIntakeX / activeX (x BMR); fatCap (max fat fraction of body); hyper [start doy] (autumn fattening);
// enter / emerge (doy windows); deep torpor: tMin (defended floor), supp, boutBase (s of debt at tNorm), eutherm (s)
const SPECIES = {
blackBear: { label: 'black bear', klass: 'mammal', lean: 78, fat: 8, halfSat: 0.6, tNorm: 37.5, strategy: 'fat', torpor: 'shallow', active: 'any',
home: 'den', depth: 0.5, insul: 0.72, homeOutside: 0.8, foods: { mast: 1, berries: 0.7, forage: 0.15, insects: 0.28 }, maxIntakeX: 7, activeX: 2.5, fatCap: 0.40, starveAt: 0.6,
hyper: 222, enter: [300, 340], emerge: [95, 120], supp: 0.38, tLow: 31, tHigh: 35.5, bumpX: 0.8,
implantFat: 0.24, birthDoy: 25, cub: { birth: 0.33, K: 9, r: 0.03, max: 3 } },
brownBear: { label: 'brown bear', klass: 'mammal', lean: 190, fat: 20, halfSat: 0.6, tNorm: 37.5, strategy: 'fat', torpor: 'shallow', active: 'any',
home: 'den', depth: 0.7, insul: 0.72, homeOutside: 0.8, foods: { salmon: 1, berries: 0.9, forage: 0.3 }, maxIntakeX: 7, activeX: 2.5, fatCap: 0.40, starveAt: 0.6,
hyper: 180, enter: [305, 345], emerge: [105, 135], supp: 0.38, tLow: 30.5, tHigh: 35.5, bumpX: 0.8,
implantFat: 0.24, birthDoy: 28, cub: { birth: 0.5, K: 14, r: 0.03, max: 3 } },
woodchuck: { label: 'woodchuck', klass: 'mammal', lean: 2.6, fat: 0.25, tNorm: 37, strategy: 'fat', torpor: 'deep', active: 'day',
home: 'burrow', depth: 1.2, insul: 0.45, foods: { forage: 1, berries: 0.3 }, maxIntakeX: 4.5, activeX: 2.2, fatCap: 0.38,
hyper: 195, enter: [288, 310], emerge: [55, 120], supp: 0.32, tMin: 4.5, boutBase: 15 * 3600, eutherm: 14 * 3600, arouseX: 6 },
arcticGroundSquirrel: { label: 'arctic ground squirrel', klass: 'mammal', lean: 0.62, fat: 0.05, tNorm: 36.5, strategy: 'fat', torpor: 'deep', active: 'day',
home: 'burrow', depth: 0.8, insul: 0.45, foods: { forage: 1, seeds: 0.6, berries: 0.4 }, maxIntakeX: 4.5, activeX: 2.4, fatCap: 0.35,
hyper: 195, enter: [245, 275], emerge: [100, 125], supp: 0.33, tMin: -2.9, boutBase: 12 * 3600, eutherm: 15 * 3600, arouseX: 6 },
littleBrownBat: { label: 'little brown bat', klass: 'mammal', lean: 0.0072, fat: 0.0008, tNorm: 37, strategy: 'fat', torpor: 'deep', active: 'night',
home: 'cave', summerHome: 'roost', depth: 0, insul: 0.8, foods: { insects: 1 }, maxIntakeX: 5, activeX: 3.5, fatCap: 0.30,
restTorpor: 12, hyper: 215, enter: [262, 300], emerge: [105, 125], supp: 0.3, tMin: 2, boutBase: 32 * 3600, eutherm: 1.8 * 3600, arouseX: 8 },
chipmunk: { label: 'eastern chipmunk', klass: 'mammal', lean: 0.09, fat: 0.008, tNorm: 37, strategy: 'larder', torpor: 'deep', active: 'day',
home: 'burrow', depth: 0.9, insul: 0.45, foods: { mast: 1, seeds: 1, berries: 0.5, insects: 0.3 }, maxIntakeX: 4.5, activeX: 2.5, fatCap: 0.12,
cacheKinds: { mast: 1, seeds: 0.6 }, cacheRate: 0.35e6, cacheWindow: [215, 310], enter: [305, 330], emerge: [65, 90],
supp: 0.3, tMin: 4.5, boutBase: 7 * 3600, eutherm: 10 * 3600, arouseX: 6 },
redSquirrel: { label: 'red squirrel', klass: 'mammal', lean: 0.2, fat: 0.012, tNorm: 39, strategy: 'larder', torpor: 'none', active: 'day',
home: 'nest', depth: 0, insul: 0.38, foods: { cones: 1, mast: 0.5, seeds: 0.4, berries: 0.3, forage: 0.3, insects: 0.2 }, maxIntakeX: 4, activeX: 2.4, fatCap: 0.08,
cacheKinds: { cones: 1 }, cacheRate: 1.0e6, cacheWindow: [228, 320] },
greySquirrel: { label: 'grey squirrel', klass: 'mammal', lean: 0.5, fat: 0.04, tNorm: 38, strategy: 'scatter', torpor: 'none', active: 'day',
home: 'nest', depth: 0, insul: 0.42, foods: { mast: 1, seeds: 0.6, berries: 0.5, forage: 0.5, insects: 0.3 }, maxIntakeX: 4, activeX: 2.4, fatCap: 0.12,
cacheKinds: { mast: 1 }, cachePerDay: 80, cacheWindow: [250, 330], nutJ: 50e3, memTau: 55, mem0: 0.85, smell: 0.35, pilfer: 0.0022, germ: 0.3, sproutDoy: 125 },
chickadee: { label: 'black-capped chickadee', klass: 'bird', lean: 0.0104, fat: 0.0003, tNorm: 41, strategy: 'daily', torpor: 'daily', active: 'day',
home: 'cavity', depth: 0, insul: 0.55, foods: { seeds: 0.8, insects: 0.9, bark: 1, berries: 0.4 }, maxIntakeX: 6.5, activeX: 2.6, fatCap: 0.10,
tMin: 31.5 },
// FREEZE: a cold-blooded way through, and the strangest. The wood frog winters a few centimetres down in the leaf
// litter, above the frost line, and simply lets itself freeze: ice spreads through the spaces between its cells
// (latent heat holds it at the freezing point while it does), its liver floods its cells with glucose as
// antifreeze, its heart stops. Two thirds of its body water can be ice and it lives; much more and it does not.
// Snow over the litter keeps it near zero, so a deep early snow means few freezes, a bare cold snap many.
// a LARDER under the ice: a beaver family cuts a raft of branches in autumn and sinks it by the lodge; when the pond
// freezes over they swim out under the ice to it. The lodge, walls of mud and sticks and a family breathing inside,
// stays above freezing through the worst cold; the rest of the winter's reserve rides in the beaver's tail, as fat.
beaver: { label: 'beaver', klass: 'mammal', lean: 17, fat: 2.4, tNorm: 37, strategy: 'larder', torpor: 'none', active: 'night',
home: 'lodge', depth: 0, insul: 0.24, aquatic: true, foods: { browse: 1, forage: 0.35 }, maxIntakeX: 3.6, activeX: 2.2, fatCap: 0.3,
hyper: 230, cacheKinds: { browse: 1 }, cacheRate: 16e6, cacheWindow: [245, 312] },
woodFrog: { label: 'wood frog', klass: 'ecto', lean: 0.0068, fat: 0.0006, tNorm: 20, strategy: 'freeze', torpor: 'ecto', active: 'day',
home: 'burrow', depth: 0.04, insul: 0.35, tFreeze: -0.7, tNucleate: -1.2, iceLethal: 0.68, hr20: 48, callDays: 12, enter: [285, 320], emerge: [80, 115] }
};
// allometry
const bmrW = (sp, M) => sp.klass === 'ecto' ? 1.5e-3 * Math.pow(M / 0.007, 0.8) : (sp.klass === 'bird' ? 129 : 70) * Math.pow(M, sp.klass === 'bird' ? 0.724 : 0.75) * 4184 / DAY;
const condW = (M) => 0.2 * Math.sqrt(M);
const hrBase = (sp, M) => sp.klass === 'bird' ? 156 * Math.pow(M, -0.28) : 241 * Math.pow(M, -0.25);
const rrBase = (sp, M) => sp.klass === 'bird' ? 17.2 * Math.pow(M, -0.31) : 53.5 * Math.pow(M, -0.26);
function makeWintering(config) {
config = config || {};
const place = Object.assign({}, typeof config.place === 'string' ? PLACES[config.place] : (config.place || PLACES.northernHardwood), config.placeOver || {});
const mastCrop = config.mast == null ? 1 : config.mast;
const salmonRun = config.salmon == null ? 1 : config.salmon; // the strength of the year's salmon run, the river's own master knob
const foodFn = config.food || null;
const sub = config.substep || 900;
// optional WEATHER: cold snaps and thaws (an AR(1) anomaly on the seasonal curve) and storms that bring the snow
// in falls -- so the snowpack builds in layers, one per storm, and a hard week bites the small birds
const wx = config.weather ? Object.assign({ anomSD: 5, anomDays: 4, stormEvery: 5.5, stormDays: 1.4, stormSnow: 1.9 }, config.weather === true ? {} : config.weather) : null;
function airT(doy, hour) {
if (config.airTemp) return config.airTemp(doy, hour);
return place.tMean + place.tAmp * Math.cos(TAU * (doy - place.tPeakDoy) / 365) - place.diurnal * Math.cos(TAU * (hour - 3) / 24);
}
function dayLen(doy) {
const dec = 23.44 * Math.PI / 180 * Math.sin(TAU * (doy - 80) / 365), lat = place.lat * Math.PI / 180;
return 24 * Math.acos(clamp(-Math.tan(lat) * Math.tan(dec), -1, 1)) / Math.PI;
}
function foodAvail(kind, doy, snow, ctx) {
if (foodFn) { const v = foodFn(kind, doy, ctx); if (v != null) return clamp(v, 0, 1); }
let v = FOODS[kind] ? FOODS[kind](doy) : 0;
if (kind === 'mast') v *= mastCrop;
if (kind === 'salmon') v *= salmonRun;
if (GROUND_FOODS[kind]) v *= Math.exp(-snow / 0.15 * GROUND_FOODS[kind]);
return clamp(v, 0, 1);
}
function newAnimal(spec, i, rng) {
const sp = Object.assign({}, SPECIES[spec.species], spec.params || {});
if (!SPECIES[spec.species] && !spec.params) throw new Error('unknown species ' + spec.species);
const lean = spec.lean != null ? spec.lean : sp.lean, fat = spec.fat != null ? spec.fat : sp.fat;
return {
id: spec.id || (spec.species + i), species: spec.species, sp, sex: spec.sex || (i % 2 ? 'm' : 'f'),
lean, lean0: lean, fat, fat0: fat, Tb: sp.klass === 'ecto' ? 18 : sp.tNorm, state: 'active', where: 'out', alive: true, cause: null,
debt: 0, bumping: false, mr: 0, hr: 0, rr: 0, mrAvg: bmrW(sp, lean + fat) * sp.activeX,
larder: 0, larderPeak: 0, caches: [], live: [], owed: 0, made: 0, eaten: 0, pilfered: 0, sprouted: 0, sproutDone: false, nuts: 0,
cubs: [], implanted: 0, bornDone: false, fatAtEntry: null, massAtEntry: null, massAtEmerge: null,
nArousals: 0, entryT: null, emergeT: null, winterDone: false,
e: { active: 0, torpor: 0, arousal: 0, eutherm: 0, lact: 0 },
tbMin: sp.tNorm, tbMinWinter: sp.tNorm, hrMinWinter: 1e9, bouts: [], boutStart: null, seed: Math.floor(rng() * 1e9)
};
}
return {
name: config.name || 'wintering',
domain: 'life',
scale: 'local',
params: config,
init(rng, params, clock) {
const ws = Math.floor(rng() * 4294967296) >>> 0; // the weather's own stream, drawn first: who lives here does not change the sky
const specs = config.animals || [{ species: 'blackBear', sex: 'f' }];
const animals = [];
specs.forEach((s) => { for (let k = 0; k < (s.n || 1); k++) animals.push(newAnimal(s, animals.length, rng)); });
const d0 = clock ? doyOf(clock) : 180, tBurrow = {};
// each burrow depth starts on the ground's own annual wave (damped and lagged with depth)
for (const a of animals) if (a.sp.home === 'den' || a.sp.home === 'burrow') {
const z = a.sp.depth || 0, key = z.toFixed(2), dmp = place.soilDamp;
tBurrow[key] = place.tMean + place.tAmp * Math.exp(-z / dmp) * Math.cos(TAU * (d0 - place.tPeakDoy) / 365 - z / dmp);
}
return { place, animals, snow: 0, snowPeak: 0, layers: [], anom: 0, storm: 0, stormN: 0, raining: false, snowing: false, _wseed: ws,
tBurrow, doy: d0, Ta: airT(d0, 12), dayLen: dayLen(d0), food: {}, evs: [], year: 0 };
},
step(st, ctx, dt) {
st.evs.length = 0;
const rng = ctx.rng || Math.random, clock = ctx.clock;
const tEnd = clock.t, n = Math.max(1, Math.ceil(dt / sub)), h = dt / n;
for (let k = 0; k < n; k++) {
const t = tEnd - dt + (k + 1) * h;
const yp = ((t % YEAR) + YEAR) % YEAR / YEAR, doy = (yp * 365 + DOY0) % 365, hour = ((t % DAY) + DAY) % DAY / 3600;
let anom = 0;
if (wx) {
const r = wrng(st), tau = wx.anomDays * DAY;
st.anom += -st.anom * h / tau + wx.anomSD * Math.sqrt(2 * h / tau) * gauss(r);
if (st.storm > 0) st.storm -= h; else if (r() < h / (wx.stormEvery * DAY)) { st.storm = wx.stormDays * DAY * (0.4 + 1.2 * r()); st.stormN++; }
anom = st.anom;
}
const Ta = airT(doy, hour) + anom, Tday = airT(doy, 3 + 6) + anom; // hourly air, and the day's mean (the diurnal term is zero at 9h/21h)
// snow: falls through the cold months (or in storms, when it is cold enough), melts in the warm (a degree-day melt)
const fall = wx ? (st.storm > 0 && Tday < 1 ? place.snowRate * wx.stormSnow * wx.stormEvery / wx.stormDays : 0) : (Tday < -0.5 && between(doy, 290, 110) ? place.snowRate : 0);
st.snowing = fall > 0; st.raining = !!wx && st.storm > 0 && !st.snowing;
if (fall) {
const add = fall * h / DAY, id = wx ? st.stormN : Math.floor(t / (7 * DAY)), top = st.layers[st.layers.length - 1];
if (top && top.id === id) top.amount += add; else st.layers.push({ id, t, doy: Math.round(doy), amount: add });
}
let melt = Tday > 0 ? place.melt * Tday * h / DAY : 0; // a degree-day melt on the day's mean
while (melt > 0 && st.layers.length) { const top = st.layers[st.layers.length - 1], m = Math.min(melt, top.amount); top.amount -= m; melt -= m; if (top.amount <= 1e-9) st.layers.pop(); }
st.snow = 0; for (const l of st.layers) st.snow += l.amount;
st.snowPeak = Math.max(st.snowPeak, st.snow);
// the ground: the surface under snow holds near zero; a burrow at depth z lags it by z^2/kappa, pulled to the mean
const Tsurf = Ta < 0 ? Ta * Math.exp(-st.snow / 0.12) : st.snow > 0.05 ? 0 : Ta;
for (const key in st.tBurrow) {
const z = +key;
if (z < 0.1) { // the leaf litter: loose leaves insulate like several centimetres of snow, and lag the air by hours
const target = Ta < 0 ? Ta * Math.exp(-(st.snow + 0.08) / 0.12) : st.snow > 0.05 ? 0 : Ta;
st.tBurrow[key] += (target - st.tBurrow[key]) * (1 - Math.exp(-h / (10 * 3600)));
continue;
}
const w = Math.exp(-z / place.soilDamp), target = Tsurf * w + place.tMean * (1 - w), tau = Math.max(3600, z * z / place.kappa);
st.tBurrow[key] += (target - st.tBurrow[key]) * (1 - Math.exp(-h / tau));
}
const dl = dayLen(doy), isDay = Math.abs(hour - 12) < dl / 2;
// pond ice by Stefan's law: it thickens as the square root of the frost it has had (d(h^2) = k^2 x freezing
// degree-days, k ~ 1.7 cm per sqrt(degree-day) under snow), and melts on the thaw's degree-days
{ const hi = st.pondIce || 0; st.pondIce = Tday < 0 ? Math.sqrt(hi * hi + 0.017 * 0.017 * (-Tday) * h / DAY) : Math.max(0, hi - 0.009 * Tday * h / DAY); }
const fresh = {}; for (const kd in FOODS) fresh[kd] = foodAvail(kd, doy, st.snow, ctx);
if (st.pondIce > 0.04) fresh.browse = 0; // frozen over: the bank is out of reach from under the ice
st.doy = doy; st.Ta = Ta; st.dayLen = dl; st.food = fresh; st.hour = hour; st.Tsurf = Tsurf;
for (const a of st.animals) { const E = { t, doy, hour, Ta, Tday, isDay, dl, fresh, h, rng }; if (a.alive) stepAnimal(a, st, E); else cacheFate(a, st, E); }
}
if (clock && Math.floor(tEnd / YEAR) !== st.year) { st.year = Math.floor(tEnd / YEAR); }
return st;
},
fields(st) { return st; },
events(st) { return st.evs; }
};
// ---- the buried nuts: thieves through the winter, and in spring the ones nobody came back for ----------------
// (the wood's business, not the squirrel's: a dead squirrel's whole hoard still comes up)
function cacheFate(a, st, E) {
const sp = a.sp; if (sp.strategy !== 'scatter' || !a.made) return;
const r = rngFor(a), p = 1 - Math.exp(-sp.pilfer * E.h / DAY);
for (let j = a.live.length - 1; j >= 0; j--) if (r() < p) { a.caches[a.live[j]].s = 'stolen'; a.caches[a.live[j]].tf = E.t; a.live[j] = a.live[a.live.length - 1]; a.live.pop(); a.pilfered++; }
if (!a.sproutDone && between(E.doy, sp.sproutDoy, sp.sproutDoy + 30)) {
a.sproutDone = true;
for (const j of a.live) { const c = a.caches[j]; c.tf = E.t; if (r() < sp.germ) { c.s = 'sprout'; a.sprouted++; } else c.s = 'rotted'; }
a.live.length = 0; st.evs.push({ type: 'sprouted', id: a.id, species: a.species, doy: Math.round(E.doy), n: a.sprouted, of: a.made });
}
}
// ---- a cold-blooded sleeper: body at the litter's temperature, and ice -----------------------------------
function stepEcto(a, st, E) {
const sp = a.sp, h = E.h, M = a.lean + a.fat, doy = E.doy, water = 0.8 * M, LF = 334e3;
const ev = (type, extra) => st.evs.push(Object.assign({ type, id: a.id, species: a.species, doy: Math.round(doy) }, extra || {}));
const litter = st.tBurrow[(sp.depth || 0).toFixed(2)];
const out = a.state === 'active' && E.isDay && E.Ta > 6;
const Tenv = out ? E.Ta : litter, C = 0.6 * Math.sqrt(M) * (out ? 1 : sp.insul), Hc = CP * M;
a.where = out ? 'out' : sp.home;
if (a.ice == null) { a.ice = 0; a.glucose = 5; a.nFreezes = 0; a.iceMax = 0; a.frozenDays = 0; }
// freezing and thawing run on latent heat: while ice is growing or melting the body sits at its freezing point
const fp = sp.tFreeze - 1.3 * clamp((a.glucose - 5) / 250, 0, 1); // the sugar itself lowers the freezing point, and so the ice
if (a.ice > 0 || a.Tb <= Math.min(sp.tNucleate, fp - 0.4)) { // it must supercool a little before ice takes hold
if (a.ice === 0) { a.nFreezes++; a.state = 'frozen'; ev('froze', { n: a.nFreezes }); }
const eq = Tenv < fp ? clamp(1 - fp / Tenv, 0, 0.95) : 0; // how much of its water the cold would make ice
const q = C * (fp - Tenv) * h; // heat leaving (+) or arriving (-) this step
if (q > 0 && a.ice < eq) { a.ice = Math.min(eq, a.ice + q / (LF * water)); a.Tb = fp; }
else if (q > 0) { a.Tb = Math.min(fp, Tenv + (a.Tb - Tenv) * Math.exp(-h * C / Hc)); a.ice = Math.max(a.ice, clamp(1 - fp / Math.min(a.Tb, fp - 1e-6), 0, 0.95)); }
else { a.ice = Math.max(0, a.ice + q / (LF * water)); a.Tb = fp; }
a.iceMax = Math.max(a.iceMax, a.ice); a.frozenDays += h / DAY;
if (a.ice <= 0) { a.ice = 0; a.state = 'dormant'; a.Tb = fp + 0.2; ev('thawed', { n: a.nFreezes }); }
if (a.ice > sp.iceLethal) { a.alive = false; a.cause = 'froze'; a.state = 'dead'; ev('died', { cause: 'froze', ice: +a.ice.toFixed(2) }); return; }
} else {
a.Tb = Tenv + (a.Tb - Tenv) * Math.exp(-h * C / Hc);
}
// the liver's antifreeze: glucose floods in within hours of the first ice, and ebbs over days after the thaw
const gTarget = a.ice > 0 ? 250 : 5; a.glucose += (gTarget - a.glucose) * (1 - Math.exp(-h / (a.ice > 0 ? 6 * 3600 : 3 * DAY)));
a.tbMin = Math.min(a.tbMin, a.Tb); if (a.state !== 'active') a.tbMinWinter = Math.min(a.tbMinWinter, a.Tb);
// heart and breath: stopped while frozen; otherwise slow with the cold (Q10 about 2.2)
const warm = Math.pow(2.2, (a.Tb - 20) / 10);
a.hr = a.ice > 0.02 ? 0 : sp.hr20 * warm * (a.state === 'active' ? 1 : 0.7); a.rr = a.ice > 0.02 ? 0 : 60 * warm * (a.state === 'active' ? 1 : 0.4);
a.mr = 1.5e-3 * Math.pow(M / 0.007, 0.8) * warm * (a.ice > 0 ? 0.05 : 1);
// the year: into the litter in autumn; in spring, when the litter thaws and the snow is gone, to the pools to call
if (a.state === 'active' && !a.winterDone && between(doy, sp.enter[0], sp.enter[1] + 1) && (E.Tday < 5 || dd(doy, sp.enter[1]) >= 0)) {
a.state = 'dormant'; a.entryT = E.t; ev('entered', {});
}
if ((a.state === 'dormant') && between(doy, sp.emerge[0], sp.emerge[1] + 30) && litter > 3 && st.snow < 0.05) {
a.state = 'active'; a.winterDone = true; a.emergeT = E.t; a.callUntil = E.t + sp.callDays * DAY; ev('emerged', { freezes: a.nFreezes, iceMax: +a.iceMax.toFixed(2) }); ev('calling', {});
}
a.calling = a.state === 'active' && a.callUntil && E.t < a.callUntil && E.Ta > 3;
if (a.winterDone && between(doy, 150, 200)) a.winterDone = false;
}
// ---- one animal, one substep ---------------------------------------------------------------------------
function stepAnimal(a, st, E) {
const sp = a.sp, h = E.h, M = a.lean + a.fat;
if (sp.klass === 'ecto') return stepEcto(a, st, E);
const BMR = bmrW(sp, M), C = condW(M), Hc = CP * M;
const doy = E.doy, ev = (type, extra) => st.evs.push(Object.assign({ type, id: a.id, species: a.species, doy: Math.round(doy) }, extra || {}));
const homeT = sp.home === 'cave' ? place.caveT : sp.home === 'den' || sp.home === 'burrow' ? st.tBurrow[(sp.depth || 0).toFixed(2)] : sp.home === 'cavity' ? E.Ta + 4
: sp.home === 'lodge' ? (E.Ta < 0 ? 2.2 + 0.1 * E.Ta : 2.2 + 0.6 * E.Ta) : E.Ta + 1; // a lodge full of beavers stays above freezing
const winterish = between(doy, 240, 150);
let fAv = 0; for (const kd in sp.foods) fAv += sp.foods[kd] * E.fresh[kd] * (QUALITY[kd] || 0.6); fAv = clamp(fAv, 0, 1);
// --- where it is and what it is doing ---
let out = false;
if (a.state === 'active') {
out = sp.active === 'any' ? (E.hour > 5 && E.hour < 22) : sp.active === 'night' ? (!E.isDay && (sp.aquatic ? (st.pondIce > 0.04 ? E.hour < 2 : true) : E.Ta > 7)) : E.isDay; // under the ice, a beaver swims out to the cache only briefly
if (sp.torpor === 'daily' || sp.torpor === 'none') out = out && !(E.Ta < -30); // even the hardy stay in on the worst nights
if (fAv < 0.05 && !(sp.strategy === 'scatter' && a.live.length) && !(sp.strategy === 'larder' && a.larder > 0)) out = false; // nothing out there: stay in
}
a.where = out ? 'out' : (sp.home === 'den' && a.state === 'active' ? 'bed' : sp.summerHome && a.state === 'active' ? sp.summerHome : sp.home);
const underIce = out && sp.aquatic && st.pondIce > 0.04; // out, for a beaver in winter, is a swim under the ice to the cache
const Tenv = underIce ? 0.5 : out ? E.Ta : a.where === 'bed' ? E.Ta : a.where === 'roost' ? E.Ta + 4 : homeT;
const Ceff = C * (underIce ? 1.3 : out ? 1 : a.where === 'bed' ? (sp.homeOutside || 1) : a.where === 'roost' ? 0.7 : sp.insul);
// --- decide the metabolic mode + the set point ---
let mr = 0, tSet = sp.tNorm, floor = -99, cap = sp.tNorm + 0.5, mode = 'eu';
const q10 = (tb) => Math.pow(2.5, (tb - sp.tNorm) / 10);
if (a.state === 'active') {
const act = out ? 1 : 0.15, hypo = (sp.aquatic && st.pondIce > 0.04 && !out ? 0.7 : 1) // a beaver in the lodge in winter turns its metabolism down
* (sp.torpor === 'shallow' && a.emergeT != null && E.t - a.emergeT < 21 * DAY ? 0.65 : 1); // and a bear new out of the den is still half asleep for weeks ('walking hibernation')
mr = Math.max(BMR * hypo * (1 + (sp.activeX - 1) * act), Ceff * (sp.tNorm - Tenv));
// resting torpor: the chickadee's night (a regulated dip, as deep as the cold asks: none on a warm night, the full
// ten degrees on a -10 one); a bat's day in the roost (it lets itself go torpid whenever it is not feeding)
const restT = sp.torpor === 'daily' ? sp.tMin : sp.restTorpor;
const resting = restT != null && !out && (sp.torpor === 'daily' ? !E.isDay : true);
if (resting) {
const depth = sp.torpor === 'daily' ? clamp((16 - E.Ta) / 26, 0, 1) : 1;
floor = sp.tNorm - (sp.tNorm - restT) * depth; mr = BMR * q10(a.Tb) * (sp.torpor === 'daily' ? 1 : 0.5); mode = 'night';
} else if (restT != null && a.Tb < sp.tNorm - 0.5) { mr = BMR * 5; mode = 'rewarm'; }
} else if (a.state === 'torpor') {
mode = 'torpor';
if (sp.torpor === 'shallow') { // a bear: suppressed, a band of Tb, a slow shiver back up
mr = sp.supp * BMR * q10(a.Tb) * (a.fat <= 0 ? 0.72 : 1); // out of fat, she turns the burn down further and spares her muscle
if (a.Tb <= sp.tLow) a.bumping = true;
if (a.bumping) { mr = Math.max(mr, sp.bumpX * BMR); if (a.Tb >= sp.tHigh) a.bumping = false; }
cap = sp.tHigh + 0.3;
} else { // deep: near ambient, a defended floor
mr = sp.supp * BMR * q10(a.Tb); floor = sp.tMin;
if (a.Tb < sp.tNorm - 12) a.debt += h / sp.boutBase * Math.pow(2.5, (a.Tb - sp.tNorm) / 10);
}
} else if (a.state === 'arousal') {
mode = 'arousal'; mr = (sp.arouseX || 6) * BMR;
} else if (a.state === 'eutherm') {
mode = 'eutherm'; mr = Math.max(BMR * 1.1, Ceff * (sp.tNorm - Tenv));
a.debt -= h / sp.eutherm;
}
// --- lactation (a bear sow nursing in the den on her own fat) ---
let lact = 0;
if (a.cubs.length) {
for (const c of a.cubs) {
const g = sp.cub.r * c.m * (1 - c.m / sp.cub.K) / DAY; // kg/s
lact += (2 * 3.4 * Math.pow(c.m, 0.75) + g * 8e6) / 0.8;
c.m += g * h;
}
if (a.state === 'active') lact *= 0.3; // out of the den they forage too
}
mr += lact;
// --- body temperature: an exact exponential step toward the equilibrium this heat and this loss would hold ---
const tau = Hc / Ceff, Teq = Tenv + mr / Ceff;
let Tb = Teq + (a.Tb - Teq) * Math.exp(-h / tau);
if (Tb < floor) { mr += Math.max(0, Ceff * (floor - Tenv) - (mr - lact)); Tb = floor; } // held at the floor: burn what the floor costs
if ((mode === 'eu' || mode === 'eutherm') && Tb > cap) Tb = cap;
if (mode === 'eu' && Tb < sp.tNorm - 0.3 && sp.torpor !== 'daily') { mr += (sp.tNorm - 0.3 - Tb) * Hc / h; Tb = sp.tNorm - 0.3; }
if (mode === 'torpor' && sp.torpor === 'shallow' && Tb > cap) Tb = cap;
if (Tb > sp.tNorm + 0.5) Tb = sp.tNorm + 0.5;
a.Tb = Tb; a.tbMin = Math.min(a.tbMin, Tb);
a.mr = mr;
const rel = clamp((mr - lact) / BMR, 0.005, 12);
a.hr = Math.max(1, hrBase(sp, M) * (rel < 1 ? rel : Math.sqrt(rel))); a.rr = Math.max(0.15, rrBase(sp, M) * (rel < 1 ? rel : Math.pow(rel, 0.6)));
if (a.state !== 'active') { a.tbMinWinter = Math.min(a.tbMinWinter, Tb); if (a.state === 'torpor') a.hrMinWinter = Math.min(a.hrMinWinter, a.hr); }
a.mrAvg += (mr - a.mrAvg) * (1 - Math.exp(-h / DAY));
const spent = mr * h;
const book = a.state === 'active' ? 'active' : a.state === 'torpor' ? 'torpor' : a.state === 'arousal' ? 'arousal' : 'eutherm';
a.e[book] += spent - lact * h; a.e.lact += lact * h;
// --- food in: fresh food (eaten continuously as a daily mean), then the stores ---
const hyper = sp.hyper != null && a.state === 'active' && between(doy, sp.hyper - 25, (sp.enter ? sp.enter[1] : 330)) && !between(doy, 60, sp.hyper - 25)
? clamp((dd(doy, sp.hyper) + 25) / 25, 0, 1) : 0;
const fatMax = sp.fatCap * a.lean / (1 - sp.fatCap), room = fatMax - a.fat;
let intake = 0;
if (a.state === 'active') {
const want = a.mrAvg * 1.08 / 0.8 + (room > 0 ? hyper * sp.maxIntakeX * BMR : 0);
const K = sp.halfSat || 0.2, sat = (1 + K) * fAv / (fAv + K); // a forager's functional response; a big body needs dense food to saturate
intake = Math.min(sp.maxIntakeX * BMR * sat, want);
if (sp.torpor === 'daily') intake = E.isDay ? Math.min(sp.maxIntakeX * BMR * sat * 24 / E.dl, (a.mrAvg * 24 / E.dl) * 1.12 / 0.8 + (room > 0 ? 0.5 * sp.maxIntakeX * BMR : 0)) : 0;
}
let gain = intake * 0.8 * h; // assimilated energy this substep (J)
// laying by: a larder or a thousand scattered nuts, while the crop lasts
if (a.state === 'active' && sp.cacheKinds && sp.cacheWindow && between(doy, sp.cacheWindow[0], sp.cacheWindow[1])) {
let fc = 0; for (const kd in sp.cacheKinds) fc += sp.cacheKinds[kd] * E.fresh[kd]; fc = clamp(fc, 0, 1);
if (sp.strategy === 'larder') { a.larder += sp.cacheRate * fc * h / DAY; a.larderPeak = Math.max(a.larderPeak, a.larder); }
if (sp.strategy === 'scatter') {
a.owed += sp.cachePerDay * fc * h / DAY;
const r = rngFor(a);
while (a.owed >= 1) { a.owed -= 1; const c = { x: r(), d: 0.02 + r() * 0.03, t: E.t, doy, s: 'buried' }; a.caches.push(c); a.live.push(a.caches.length - 1); a.made++; }
}
}
// the shortfall, from the stores
let need = spent - gain;
if (need > 0 && sp.strategy === 'larder' && a.larder > 0 && (a.state === 'active' || a.state === 'eutherm')) {
const refill = a.state === 'eutherm' && room > 0 ? Math.min(room * FAT_J / 0.85, sp.maxIntakeX * BMR * h) : 0;
const take = Math.min(a.larder, need / 0.8 + refill / 0.8); a.larder -= take; gain += take * 0.8; need = spent - gain;
}
if (need > 0 && sp.strategy === 'scatter' && a.state === 'active' && out && a.live.length) {
const refill = room > 0 ? Math.min(room * FAT_J / 0.85, sp.maxIntakeX * BMR * h) : 0;
a.nuts += (need + refill) / (sp.nutJ * 0.8);
const r = rngFor(a); let tries = 0;
while (a.nuts >= 1 && a.live.length && tries < 12) {
tries++;
const j = Math.floor(r() * a.live.length), c = a.caches[a.live[j]];
const age = (E.t - c.t) / DAY, known = sp.mem0 * Math.exp(-age / sp.memTau), sniff = sp.smell * Math.exp(-st.snow / 0.1);
if (r() < known + (1 - known) * sniff) { c.s = 'eaten'; c.tf = E.t; a.live[j] = a.live[a.live.length - 1]; a.live.pop(); a.eaten++; a.nuts -= 1; gain += sp.nutJ * 0.8; }
}
need = spent - gain;
}
// what was gained or spent lands on the body
if (need < 0) {
const add = -need / FAT_J * 0.85; a.fat = Math.min(fatMax, a.fat + add);
} else {
a.fat -= need / FAT_J;
if (a.fat < 0) { a.lean += a.fat * FAT_J / LEAN_J; a.fat = 0; }
}
cacheFate(a, st, E);
if (a.lean < (sp.starveAt || 0.7) * a.lean0) { a.alive = false; a.cause = 'starved'; a.state = 'dead'; ev('died', { cause: 'starved' }); return; }
// --- the calendar of a winter: going down, the bouts, the births, coming up ---
if (sp.torpor === 'shallow' || sp.torpor === 'deep') {
if (a.state === 'active' && !a.winterDone && between(doy, sp.enter[0], sp.enter[1] + 1)) {
const late = dd(doy, sp.enter[1]) >= 0, snowed = st.snow > 0.15;
const hungry = fAv < 0.12; // (bears den EARLIER when food fails, to save what they have)
const ready = sp.strategy === 'larder' ? (a.larder > 0.5 * sp.cacheRate * 10 || late) : true;
if ((hungry || snowed || late || (a.sex === 'f' && a.fat >= 0.95 * fatMax)) && ready) {
a.state = 'torpor'; a.entryT = E.t; a.fatAtEntry = a.fat; a.massAtEntry = a.lean + a.fat; a.debt = 0; a.boutStart = E.t;
ev('entered', { fat: +a.fat.toFixed(3), mass: +(a.lean + a.fat).toFixed(3) });
if (sp.implantFat && a.sex === 'f') { // delayed implantation: the embryos take only if she is fat enough
const ff = a.fat / (a.lean + a.fat);
a.implanted = ff < sp.implantFat ? 0 : Math.min(sp.cub.max, 1 + (ff >= sp.implantFat + 0.05) + (ff >= sp.implantFat + 0.1));
ev(a.implanted ? 'implanted' : 'not-implanted', { n: a.implanted, fatFrac: +ff.toFixed(3) });
}
}
}
if (a.state !== 'active' && a.implanted && !a.bornDone && between(doy, sp.birthDoy, sp.birthDoy + 10)) {
a.bornDone = true; for (let c = 0; c < a.implanted; c++) a.cubs.push({ m: sp.cub.birth, born: E.t });
ev('born', { n: a.implanted, kg: sp.cub.birth });
}
const spring = between(doy, sp.emerge[0], sp.emerge[1] + 30);
const wantUp = spring && (dd(doy, sp.emerge[0] + (a.cubs.length ? 30 : 0) + (a.sex === 'f' && sp.torpor === 'deep' ? 6 : 0)) >= 0)
&& (fAv > 0.06 || (sp.strategy === 'larder' && a.larder > 0) || dd(doy, sp.emerge[1] + (sp.torpor === 'deep' ? 40 : 0)) >= 0); // a deep sleeper keeps sleeping until there is green to come up to
if (a.state === 'torpor' && sp.torpor === 'deep' && a.debt >= 1) { a.state = 'arousal'; a.nArousals++; a.bouts.push((E.t - a.boutStart) / DAY); ev('arousal', { n: a.nArousals, tbMin: +a.Tb.toFixed(1) }); }
if (a.state === 'arousal' && a.Tb >= sp.tNorm - 0.6) a.state = 'eutherm';
if (a.state === 'eutherm' && a.debt <= 0) {
if (wantUp) emergeNow(); else { a.state = 'torpor'; a.boutStart = E.t; a.debt = 0; }
}
if (a.state === 'torpor' && sp.torpor === 'shallow' && wantUp) emergeNow();
}
function emergeNow() {
a.state = 'active'; a.winterDone = true; a.emergeT = E.t; a.massAtEmerge = a.lean + a.fat; a.bumping = false;
ev('emerged', { mass: +(a.lean + a.fat).toFixed(3), lost: +(1 - (a.lean + a.fat) / a.massAtEntry).toFixed(3), cubs: a.cubs.map(c => +c.m.toFixed(2)), arousals: a.nArousals });
}
if (a.winterDone && between(doy, 150, 200)) { a.winterDone = false; } // a new year's cycle can begin
}
}
function wrng(st) {
if (!st._wr) { let s = st._wseed >>> 0; st._wr = function () { s = (s + 0x6D2B79F5) | 0; let t = Math.imul(s ^ (s >>> 15), 1 | s); t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t; return ((t ^ (t >>> 14)) >>> 0) / 4294967296; }; }
return st._wr;
}
function gauss(r) { let u = 0; while (u === 0) u = r(); return Math.sqrt(-2 * Math.log(u)) * Math.cos(TAU * r()); }
// each animal carries its own rng stream (deterministic, independent of the world's draw order)
function rngFor(a) {
if (!a._r) { let s = a.seed >>> 0; a._r = function () { s = (s + 0x6D2B79F5) | 0; let t = Math.imul(s ^ (s >>> 15), 1 | s); t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t; return ((t ^ (t >>> 14)) >>> 0) / 4294967296; }; }
return a._r;
}
return { makeWintering, SPECIES, PLACES, FOODS, tFromDoy, doyOf, bmrW, condW, DAY, YEAR };
});