← Natura·Docs/Modules/Source

wintering-test.js

213 lines · in natura-src-1.0.0.zip as natura/wintering-test.js · a test

// wintering-test.js -- the WINTERING system. Each assertion is a real-world fact about getting through a winter
// that should EMERGE from the one physiology, not be written in: the bear's shallow continuous sleep and the
// ground squirrel's deep one broken by arousals; that the arousals, not the cold, cost most of a small
// hibernator's winter; that bouts run longest in midwinter; that snow is a blanket; that a fat sow's embryos
// take and a thin one's do not, and that nursing in the den costs her; that the chipmunk comes up at its autumn
// weight because it ate from its larder; that the grey squirrel over-caches and the forgotten nuts come up as
// oaks, more of them under deep snow; that the chickadee cools deeper on colder nights; and that a failed mast
// crop is felt through the whole wood.
const N = require('./natura.js');
const W = require('./wintering.js');
const { World } = N;

let pass = 0, fail = 0;
const ok = (c, m) => { if (c) pass++; else { fail++; console.log('  FAIL: ' + m); } };

// run a year (hourly) from midsummer; collect events and a daily trace per animal
function year(cfg, days, seed) {
  days = days || 365;
  const w = World({ seed: seed || 11, t0: W.tFromDoy(180), systems: [W.makeWintering(cfg)] });
  const evs = [], trace = {};
  for (let i = 0; i < (days || 365) * 24; i++) {
    const r = w.step(3600); for (const e of r.events) evs.push(e);
    const st = w.fields().wintering;
    for (const a of st.animals) {
      const tr = trace[a.id] || (trace[a.id] = []);
      tr.push({ doy: st.doy, hour: st.hour, state: a.state, Tb: a.Tb, hr: a.hr, fat: a.fat, larder: a.larder, Ta: st.Ta, snow: st.snow, ice: a.ice });
    }
  }
  return { st: w.fields().wintering, evs, trace };
}
const A = (r, species) => r.st.animals.find(a => a.species === species);
const winterE = (a) => a.e.torpor + a.e.arousal + a.e.eutherm;

console.log('wintering assertions:');

// 1. DETERMINISM
{
  const cfg = { animals: [{ species: 'greySquirrel' }, { species: 'woodchuck' }] };
  const a = year(cfg, 200), b = year(cfg, 200);
  const ga = A(a, 'greySquirrel'), gb = A(b, 'greySquirrel'), wa = A(a, 'woodchuck'), wb = A(b, 'woodchuck');
  ok(ga.made === gb.made && ga.eaten === gb.eaten && ga.pilfered === gb.pilfered && wa.nArousals === wb.nArousals && Math.abs(wa.fat - wb.fat) < 1e-12,
    'same seed -> the same caches, the same arousals, the same fat to the gram');
}

// the northern hardwood community, one year
const HW = year({ animals: [
  { species: 'blackBear', sex: 'f' }, { species: 'blackBear', sex: 'f', id: 'loneSow', params: { implantFat: 9 } },
  { species: 'woodchuck', sex: 'm' }, { species: 'chipmunk' }, { species: 'redSquirrel' }, { species: 'greySquirrel' },
  { species: 'chickadee' }, { species: 'littleBrownBat', sex: 'f' }] });

// 2. EVERYONE COMES THROUGH an ordinary year
{
  const dead = HW.st.animals.filter(a => !a.alive).map(a => a.id + ' (' + a.cause + ')');
  ok(dead.length === 0, 'in an ordinary mast year every one of the eight comes through the winter' + (dead.length ? ' -- died: ' + dead.join(', ') : ''));
}

// 3. THE BEAR's winter: shallow and continuous -- a few degrees cool, a heart in the teens, no arousals
{
  const b = HW.st.animals.find(a => a.id === 'loneSow'), tr = HW.trace.loneSow.filter(x => x.state === 'torpor');
  const tbMin = Math.min(...tr.map(x => x.Tb)), tbMax = Math.max(...tr.map(x => x.Tb)), hrMin = Math.min(...tr.map(x => x.hr));
  const days = tr.length / 24, lost = 1 - b.massAtEmerge / b.massAtEntry;
  ok(tbMin > 29 && tbMin < 34, 'a denned bear cools only a few degrees (min body temperature ' + tbMin.toFixed(1) + ' C)');
  ok(tbMax - tbMin > 2.5, 'her temperature wanders in a slow band as she lets it fall and shivers it back (' + tbMin.toFixed(1) + '-' + tbMax.toFixed(1) + ' C)');
  ok(hrMin < 20, 'yet her heart slows to the teens (' + hrMin.toFixed(1) + ' beats a minute)');
  ok(b.nArousals === 0 && days > 150, 'one continuous sleep, no arousals, ' + days.toFixed(0) + ' days in the den');
  ok(lost > 0.10 && lost < 0.30, 'she comes out ' + (lost * 100).toFixed(0) + '% lighter, having not eaten, drunk, or passed anything all winter');
}

// 4. DELAYED IMPLANTATION + NURSING: a fat sow's embryos take and she gives birth in the den; nursing costs her
{
  const mom = HW.st.animals.find(a => a.id === 'blackBear0'), lone = HW.st.animals.find(a => a.id === 'loneSow');
  const born = HW.evs.find(e => e.type === 'born' && e.id === 'blackBear0');
  ok(born && (born.doy >= 20 && born.doy <= 45), 'the fat sow gives birth in the den in the dead of winter (day ' + (born && born.doy) + ', ' + (born && born.n) + ' cubs at ' + (born && born.kg) + ' kg)');
  const lostMom = 1 - mom.massAtEmerge / mom.massAtEntry, lostLone = 1 - lone.massAtEmerge / lone.massAtEntry;
  ok(lostMom > lostLone + 0.03, 'nursing on her own fat costs her: ' + (lostMom * 100).toFixed(0) + '% lost against ' + (lostLone * 100).toFixed(0) + '% for a sow with no cubs');
  const em = HW.evs.find(e => e.type === 'emerged' && e.id === 'blackBear0');
  ok(em && em.cubs.length && em.cubs.every(m => m > 1.5 && m < 6), 'her cubs come out of the den grown from a third of a kilo to ' + (em && em.cubs.join(', ')) + ' kg');
  const emLone = HW.evs.find(e => e.type === 'emerged' && e.id === 'loneSow');
  ok(em && emLone && em.doy > emLone.doy, 'a mother with cubs is the last out of her den (day ' + (em && em.doy) + ' vs ' + (emLone && emLone.doy) + ')');
}

// 5. THE DEEP SLEEPERS: near the floor, broken by arousals, which cost most of the winter; bouts longest in midwinter
{
  const wc = A(HW, 'woodchuck'), tr = HW.trace[wc.id].filter(x => x.state === 'torpor');
  const tbMin = Math.min(...tr.map(x => x.Tb)), hrMin = Math.min(...tr.map(x => x.hr));
  ok(tbMin < 6, 'a woodchuck lets its body fall to ' + tbMin.toFixed(1) + ' C, a few degrees above freezing');
  ok(hrMin < 5, 'its heart to ' + hrMin.toFixed(1) + ' beats a minute');
  ok(wc.nArousals >= 10, 'and wakes all the way back to 37 C again and again (' + wc.nArousals + ' arousals)');
  const share = (wc.e.arousal + wc.e.eutherm) / winterE(wc);
  ok(share > 0.5, 'those arousals, not the cold, take most of its winter fat (' + (share * 100).toFixed(0) + '%)');
  const b = wc.bouts, mid = Math.max(...b);
  ok(mid > 1.4 * b[0], 'its bouts of torpor lengthen into midwinter, when the burrow is coldest (' + b[0].toFixed(1) + ' days at first, ' + mid.toFixed(1) + ' at the longest)');
  const lost = 1 - wc.massAtEmerge / wc.massAtEntry;
  ok(lost > 0.12, 'it comes up ' + (lost * 100).toFixed(0) + '% lighter');
}

// 6. THE LARDER: the chipmunk fills a pantry in autumn, eats from it at each arousal, and comes up at its autumn weight
{
  const c = A(HW, 'chipmunk'), tr = HW.trace[c.id];
  const lEntry = tr.find(x => x.state === 'torpor').larder, lEnd = tr.filter(x => x.state !== 'active').slice(-1)[0].larder;
  ok(c.larderPeak > 5e6, 'the chipmunk lays by a larder worth ' + (c.larderPeak / 1e6).toFixed(1) + ' MJ (' + Math.round(c.larderPeak / 50e3) + ' acorns)');
  ok(lEnd < lEntry, 'and eats it down through the winter (' + (lEntry / 1e6).toFixed(1) + ' -> ' + (lEnd / 1e6).toFixed(1) + ' MJ)');
  const lost = 1 - c.massAtEmerge / c.massAtEntry, wc = A(HW, 'woodchuck'), lostW = 1 - wc.massAtEmerge / wc.massAtEntry;
  ok(lost < 0.08 && lostW > lost + 0.08, 'so it comes up at nearly its autumn weight (' + (lost * 100).toFixed(0) + '% lost) where the woodchuck, living on its body, loses ' + (lostW * 100).toFixed(0) + '%');
}

// 7. THE MIDDEN: the red squirrel never sleeps the winter away; it lives awake on its cones
{
  const r = A(HW, 'redSquirrel'), tr = HW.trace[r.id].filter(x => x.doy > 340 || x.doy < 60);
  ok(tr.every(x => x.state === 'active') && Math.min(...tr.map(x => x.Tb)) > 37, 'the red squirrel stays awake and warm all winter');
  const lDec = HW.trace[r.id].find(x => Math.round(x.doy) === 340).larder, lMar = HW.trace[r.id].find(x => Math.round(x.doy) === 80).larder;
  ok(r.larderPeak > 20e6 && lMar < lDec, 'on a midden of cones (' + (r.larderPeak / 1e6).toFixed(0) + ' MJ at its fullest) drawn down through the winter');
}

// 8. SCATTER: thousands buried one by one, most found again, some stolen, the forgotten ones come up as oaks
{
  const g = A(HW, 'greySquirrel');
  ok(g.made > 1500, 'the grey squirrel buries ' + g.made + ' nuts, one at a time');
  ok(g.eaten > 0.4 * g.made && g.pilfered > 0.05 * g.made, 'digs up ' + g.eaten + ' of them again, and loses ' + g.pilfered + ' to thieves');
  ok(g.sprouted > 30, 'and ' + g.sprouted + ' it never came back for come up in spring as oak seedlings');
  const sp = HW.evs.find(e => e.type === 'sprouted');
  ok(sp && sp.doy >= 120 && sp.doy < 160, 'in the spring (day ' + (sp && sp.doy) + ')');
}

// 9. THE FOREST IS PLANTED BY FORGETTING: a squirrel with a poorer memory leaves more nuts in the ground
{
  const sharp = year({ animals: [{ species: 'greySquirrel' }] });
  const vague = year({ animals: [{ species: 'greySquirrel', params: { mem0: 0.35, memTau: 25 } }] });
  const s = A(sharp, 'greySquirrel'), v = A(vague, 'greySquirrel');
  ok(v.sprouted > 1.3 * s.sprouted, 'a squirrel that forgets more plants more oaks (' + v.sprouted + ' vs ' + s.sprouted + (v.alive ? ', and still gets through on smell and on having buried far more than it needed)' : '; it did not live to see them)'));
}

// 10. THE SNOW IS A BLANKET: without it the burrows go colder and the deep sleepers pay for it
{
  const snowy = year({ animals: [{ species: 'woodchuck' }] }), bare = year({ animals: [{ species: 'woodchuck' }], placeOver: { snowRate: 0 } });
  const minB = (r) => Math.min(...r.trace[A(r, 'woodchuck').id].map(x => x.Tb));
  const ws = A(snowy, 'woodchuck'), wb = A(bare, 'woodchuck');
  ok(winterE(wb) > 1.05 * winterE(ws), 'a snowless winter costs the woodchuck more (' + (winterE(wb) / 1e6).toFixed(1) + ' MJ vs ' + (winterE(ws) / 1e6).toFixed(1) + ' under snow)');
  ok(wb.bouts.reduce((a, b) => a + b, 0) / wb.bouts.length !== ws.bouts.reduce((a, b) => a + b, 0) / ws.bouts.length, 'and changes the rhythm of its bouts');
  void minB;
}

// 11. SUPERCOOLING: on the tundra the arctic ground squirrel holds its body BELOW freezing, and pays to hold it there
{
  const tun = year({ place: 'arcticTundra', animals: [{ species: 'arcticGroundSquirrel' }] });
  const kat = year({ place: 'katmai', animals: [{ species: 'arcticGroundSquirrel' }] });
  const at = A(tun, 'arcticGroundSquirrel'), ak = A(kat, 'arcticGroundSquirrel');
  ok(at.tbMinWinter < 0, 'in a tundra burrow its body falls below freezing (' + at.tbMinWinter.toFixed(1) + ' C) and does not freeze');
  const perDay = (a) => a.e.torpor / (a.bouts.reduce((x, y) => x + y, 0) * 86400);
  ok(perDay(at) > 1.2 * perDay(ak), 'and while torpid it burns more to hold that floor in the colder ground (' + perDay(at).toFixed(3) + ' W vs ' + perDay(ak).toFixed(3) + ' W at Katmai)');
}

// 12. THE CHICKADEE: a fat store that lasts one night; the colder the night the deeper the dip
{
  const tr = HW.trace[A(HW, 'chickadee').id];
  const nightMin = (d0, d1) => { const xs = tr.filter(x => x.doy >= d0 && x.doy < d1 && (x.hour > 20 || x.hour < 6)); return Math.min(...xs.map(x => x.Tb)); };
  const warm = nightMin(200, 215), cold = nightMin(355, 365);
  ok(cold < warm - 5, 'on a midwinter night it lets itself cool to ' + cold.toFixed(1) + ' C; on a summer night it barely dips (' + warm.toFixed(1) + ')');
  const jan = tr.filter(x => x.doy >= 10 && x.doy < 11);
  const fmax = Math.max(...jan.map(x => x.fat)), fmin = Math.min(...jan.map(x => x.fat));
  ok((fmax - fmin) / (0.0104 + fmax) > 0.05, 'in January it puts on ' + ((fmax - fmin) * 1000).toFixed(2) + ' g of fat by day and burns it by night (' + (((fmax - fmin) / 0.0107) * 100).toFixed(0) + '% of its weight)');
}

// 13. THE BAT: fattens in late summer, hibernates in the cave on it, and comes out light
{
  const b = A(HW, 'littleBrownBat'), lost = 1 - b.massAtEmerge / b.massAtEntry;
  ok(b.fatAtEntry > 0.0018, 'the bat goes into the cave carrying ' + (b.fatAtEntry * 1000).toFixed(1) + ' g of fat');
  ok(b.nArousals >= 5 && lost > 0.15, 'wakes ' + b.nArousals + ' times over the winter and comes out ' + (lost * 100).toFixed(0) + '% lighter');
}

// 14. A FAILED MAST: the whole wood feels it
{
  const bust = year({ mast: 0.12, animals: [{ species: 'blackBear', sex: 'f' }, { species: 'greySquirrel' }, { species: 'chipmunk' }] });
  const bear = A(bust, 'blackBear'), g = A(bust, 'greySquirrel'), c = A(bust, 'chipmunk');
  const gHW = A(HW, 'greySquirrel'), cHW = A(HW, 'chipmunk');
  ok(bear.implanted === 0, 'a sow who could not fatten on acorns carries no cubs through the winter (fat ' + (bear.fatAtEntry / bear.massAtEntry * 100).toFixed(0) + '% of her weight at denning)');
  ok(g.made < 0.4 * gHW.made, 'the grey squirrel buries only ' + g.made + ' nuts (against ' + gHW.made + ' in a mast year)');
  ok(c.larderPeak < 0.7 * cHW.larderPeak, 'the chipmunk\'s larder holds ' + (c.larderPeak / 1e6).toFixed(1) + ' MJ against ' + (cHW.larderPeak / 1e6).toFixed(1));
  ok(g.sprouted < 0.5 * gHW.sprouted, 'and next spring there are fewer oak seedlings (' + g.sprouted + ' vs ' + gHW.sprouted + ')');
}

// 15. FREEZE: the wood frog lets itself freeze in the leaf litter, stops its heart, and thaws back
{
  const r = year({ weather: true, animals: [{ species: 'woodFrog', id: 'frog' }] }, 330, 1);
  const f = A(r, 'woodFrog'), tr = r.trace.frog;
  ok(f.alive && f.nFreezes >= 2, 'in a weathered winter the wood frog freezes and thaws ' + f.nFreezes + ' times and lives');
  ok(f.iceMax > 0.2 && f.iceMax < 0.68, 'at the worst, ' + Math.round(f.iceMax * 100) + '% of its body water is ice');
  const frozen = tr.filter(x => x.hr === 0 && x.state === 'frozen'); ok(frozen.length > 12, 'and for ' + frozen.length + ' hours its heart does not beat at all');
  let run = 0, best = 0; for (const x of tr) { if (x.state === 'frozen' && x.Tb > -2.3 && x.Tb < -0.5) { run++; best = Math.max(best, run); } else run = 0; }
  ok(best >= 3, 'while the ice spreads, its own latent heat holds it at the freezing point (' + best + ' hours on the plateau)');
  const call = r.evs.find(e => e.type === 'calling');
  ok(call && call.doy >= 80 && call.doy <= 132, 'in spring it thaws and goes to the pools to call (day ' + (call && call.doy) + ')');
  const bare = year({ weather: true, placeOver: { snowRate: 0 }, animals: [{ species: 'woodFrog', id: 'frog' }] }, 330, 1), fb = A(bare, 'woodFrog');
  ok(!fb.alive && fb.cause === 'froze', 'the same winter without snow over the litter freezes it past what a frog can bear (' + Math.round(fb.iceMax * 100) + '% ice), and it dies: the snow is what it lives under');
}

// 16. A LARDER UNDER THE ICE: the beaver cuts and sinks its winter, the pond closes over, the lodge stays above freezing
{
  const W2 = World({ seed: 3, t0: W.tFromDoy(152), systems: [W.makeWintering({ weather: true, place: 'katmai', animals: [{ species: 'beaver', id: 'beaver' }] })] });
  let iceDays = 0, lodgeMin = 99, airMin = 99, mFreeze = null;
  for (let i = 0; i < 24 * 360; i++) { W2.step(3600); const st = W2.fields().wintering, a = st.animals[0];
    if (st.pondIce > 0.04) { iceDays += 1 / 24; if (mFreeze == null) mFreeze = a.lean + a.fat; }
    if (st.Ta < -15) { airMin = Math.min(airMin, st.Ta); lodgeMin = Math.min(lodgeMin, 2.2 + 0.1 * st.Ta); } }
  const bv = W2.fields().wintering.animals[0], lost = 1 - (bv.lean + bv.fat) / mFreeze, pondIceMax = W2.fields().wintering.pondIce;
  ok(iceDays > 150, 'at the Brooks River the pond is frozen over for ' + Math.round(iceDays) + ' days');
  ok(bv.alive && bv.larderPeak > 250e6 && bv.larder < 0.25 * bv.larderPeak, 'the beaver lives through it on the branches it sank in autumn (' + Math.round(bv.larderPeak / 1e6) + ' MJ, almost all eaten)');
  ok(lost > 0.06 && lost < 0.3, 'and on the fat in its tail: it comes out ' + Math.round(lost * 100) + '% lighter');
  ok(lodgeMin > -1, 'the lodge, full of beavers, stays near freezing (' + lodgeMin.toFixed(1) + ' C) while the air falls to ' + airMin.toFixed(0) + ' C');
  void pondIceMax;
}

console.log(pass + ' passed, ' + fail + ' failed');
if (fail) process.exitCode = 1;