← Natura·Docs/Modules/Source

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