← Natura·Docs/Modules/Source

celestial-test.js

102 lines · in natura-src-1.0.0.zip as natura/celestial-test.js · a test

// celestial-test.js -- assertions for the Celestial system (orbits -> sky, seasons, moon, tides).
const N = require('./natura.js');
const { makeCelestial } = require('./celestial.js');
const { World, UNITS } = N;
const DAY = UNITS.DAY, HOUR = UNITS.HOUR, YEAR = UNITS.YEAR;

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

// run a celestial world, sampling its fields every `stride` steps of `dt`
function run(cfg, steps, dt, stride) {
  const cel = makeCelestial(cfg || {});
  const w = World({ seed: 1, systems: [cel] });
  const series = [], events = [];
  for (let i = 0; i < steps; i++) {
    const r = w.step(dt);
    for (const e of r.events) events.push(e);
    if (i % (stride || 1) === 0) series.push(Object.assign({ t: w.clock.t }, w.fields().celestial));
  }
  return { w, series, events };
}
const mn = a => Math.min(...a), mx = a => Math.max(...a);

console.log('celestial assertions:');

// 1. DAY/NIGHT: over a day the sun rises and sets -- daylight hits ~0 at night and a clear max by day
{
  const { series } = run({ latitude: 45 * Math.PI / 180 }, 48, HOUR, 1);
  const dl = series.map(s => s.daylight);
  ok(mn(dl) < 0.02 && mx(dl) > 0.3, 'daylight cycles from night (~0) to day over 24h (min ' + mn(dl).toFixed(2) + ', max ' + mx(dl).toFixed(2) + ')');
}

// 2. DETERMINISM: analytic in time -> two runs match exactly
{
  const a = run({}, 30, DAY, 1).series, b = run({}, 30, DAY, 1).series;
  ok(a.every((s, i) => Math.abs(s.decl - b[i].decl) < 1e-12 && Math.abs(s.tide - b[i].tide) < 1e-12), 'same config -> identical sky (reproducible)');
}

// 3. SEASONS from axial tilt: solar declination swings positive (summer) and negative (winter) over a year
{
  const { series } = run({}, 366, DAY, 1);
  const dec = series.map(s => s.decl), tilt = 23.44 * Math.PI / 180;
  ok(mx(dec) > tilt * 0.9 && mn(dec) < -tilt * 0.9, 'declination reaches +/- the axial tilt over a year (seasons)');
}

// 4. DAY LENGTH: at mid-latitude summer days are long and winter days short; at the equator ~12h always
{
  const { series } = run({ latitude: 50 * Math.PI / 180 }, 366, DAY, 1);
  const dls = series.map(s => s.dayLengthHours);
  ok(mx(dls) > 14 && mn(dls) < 10, 'mid-latitude day length swings long-summer / short-winter (' + mn(dls).toFixed(1) + 'h..' + mx(dls).toFixed(1) + 'h)');
  const eq = run({ latitude: 0 }, 366, DAY, 30).series.map(s => s.dayLengthHours);
  ok(mx(eq) - mn(eq) < 0.5, 'at the equator day length stays ~12h year-round (' + mn(eq).toFixed(1) + '..' + mx(eq).toFixed(1) + ')');
}

// 5. MOON PHASE cycles new->full->new over a synodic month
{
  const { series } = run({}, 40, DAY, 1);
  const il = series.map(s => s.moonIllum);
  ok(mn(il) < 0.1 && mx(il) > 0.9, 'moon waxes and wanes new(~0)..full(~1) over a month (min ' + mn(il).toFixed(2) + ', max ' + mx(il).toFixed(2) + ')');
}

// 6. TIDES are semidiurnal and spring/neap tracks the moon phase
{
  const day = run({}, 48, HOUR, 1).series.map(s => s.tide);
  ok(mn(day) < -0.3 && mx(day) > 0.3, 'the tide rises and falls over a day (' + mn(day).toFixed(2) + '..' + mx(day).toFixed(2) + ')');
  const month = run({}, 30, DAY, 1).series.map(s => s.springNeap);
  ok(mx(month) > 0.85 && mn(month) < 0.2, 'spring/neap: strong tides at new/full, weak at the quarters');
}

// 7. EVENTS: over a year the equinoxes and solstices fire; over a month the new & full moon fire
{
  const yr = run({}, 366, DAY, 1).events;
  ok(yr.filter(e => e.kind === 'equinox').length >= 2, 'both equinoxes fire over a year');
  ok(yr.filter(e => e.kind === 'solstice').length >= 2, 'both solstices fire over a year');
  const mo = run({}, 60, DAY, 1).events;
  ok(mo.some(e => e.kind === 'moon' && e.which === 'new') && mo.some(e => e.kind === 'moon' && e.which === 'full'), 'new and full moon fire');
}

// 8. ECLIPSES: with a coplanar moon (no inclination) every syzygy aligns -> solar & lunar eclipses fire
{
  const bodies = [
    { name: 'star', parent: null, a: 0, period: 1, phase0: 0, e: 0, radius: 0.0047 },
    { name: 'planet', parent: 'star', a: 1, period: YEAR, phase0: 0, e: 0, radius: 4.3e-5 },
    { name: 'moon', parent: 'planet', a: 0.00257, period: 27.32 * DAY, phase0: 0, e: 0, radius: 1.2e-5 }  // inc: 0
  ];
  const ev = run({ bodies }, 120 * 4, HOUR * 6, 1).events;   // fine steps to catch the alignments
  ok(ev.some(e => e.kind === 'eclipse' && e.which === 'solar'), 'coplanar moon -> solar eclipse at new moon');
  ok(ev.some(e => e.kind === 'eclipse' && e.which === 'lunar'), 'coplanar moon -> lunar eclipse at full moon');
}

// 9. ORRERY: the planet orbits the star at ~1 AU, the moon orbits the planet
{
  const { series } = run({}, 1, DAY, 1);
  const b = series[0].bodies, byName = {}; b.forEach(x => byName[x.name] = x);
  const pd = Math.hypot(byName.planet.x, byName.planet.y);
  const md = Math.hypot(byName.moon.x - byName.planet.x, byName.moon.y - byName.planet.y);
  ok(pd > 0.9 && pd < 1.1, 'planet orbits the star at ~1 AU (' + pd.toFixed(3) + ')');
  ok(md > 0.002 && md < 0.004, 'moon orbits the planet (' + md.toFixed(4) + ' AU)');
}

console.log(`\n${pass} passed, ${fail} failed`);
process.exit(fail ? 1 : 0);