src/physics/events.js

// ————————————————————————————————————————————————
// Astronomical event detection on the JD timeline.
// Phase 2 of #6.
//
// All scanners take a starting JD and a max-window (in days). They return
// either { jd, ...metadata } or `null` if no event is found inside the
// window. The scanners are pure: they do not mutate state and they call
// only into formulas.js + the planets data.
// ————————————————————————————————————————————————

import { keplerHeliocentric, meanAnomalyAtJD, elementsAtJD } from './formulas.js';
import { planetById, PLANETS } from '../data/planets.js';
import { phaseAngle, lunarPosition } from './moon.js';
import { PHASE_RATE } from '../data/moon.js';

const TWO_PI = 2 * Math.PI;
const SUN_ANGULAR_RADIUS_RAD = ((16 / 60) * Math.PI) / 180; // ~16 arcmin

// Wrap an angle to (-π, π].
function wrapPi(a) {
  let x = (a + Math.PI) % TWO_PI;
  if (x < 0) x += TWO_PI;
  return x - Math.PI;
}

// Heliocentric (ecliptic) position of a planet at JD. Returns {x, y, z}.
export function helioPositionAtJD(planet, jd) {
  if (planet.isStar) return { x: 0, y: 0, z: 0 };
  const M = meanAnomalyAtJD(planet, jd);
  const el = elementsAtJD(planet, jd);
  return keplerHeliocentric(el.a, el.e, M, el.argPerihelionRad, el.ascNodeRad, el.iRad);
}

// Geocentric position of a planet (heliocentric of planet minus heliocentric
// of Earth). Returns {x, y, z}.
export function geocentricPosition(planet, jd) {
  const earth = planetById('earth');
  const p = helioPositionAtJD(planet, jd);
  const e = helioPositionAtJD(earth, jd);
  return { x: p.x - e.x, y: p.y - e.y, z: p.z - e.z };
}

// Heliocentric ecliptic longitude (radians) — the projection angle in the
// orbital plane. Range (-π, π].
export function helioLongitude(planet, jd) {
  const p = helioPositionAtJD(planet, jd);
  return Math.atan2(p.z, p.x);
}

// Sun-Earth-planet angle (Earth's elongation from the Sun towards `planet`).
// Returns radians in [0, π].
export function sunEarthPlanetAngle(planet, jd) {
  const earth = planetById('earth');
  const eHelio = helioPositionAtJD(earth, jd);
  const sunFromEarth = { x: -eHelio.x, y: -eHelio.y, z: -eHelio.z };
  const planetFromEarth = geocentricPosition(planet, jd);
  return vectorAngle(sunFromEarth, planetFromEarth);
}

// Geocentric angular separation between two planets seen from Earth (rad).
export function geocentricAngle(planetA, planetB, jd) {
  const a = geocentricPosition(planetA, jd);
  const b = geocentricPosition(planetB, jd);
  return vectorAngle(a, b);
}

function vectorAngle(a, b) {
  const dot = a.x * b.x + a.y * b.y + a.z * b.z;
  const na = Math.sqrt(a.x * a.x + a.y * a.y + a.z * a.z);
  const nb = Math.sqrt(b.x * b.x + b.y * b.y + b.z * b.z);
  if (na === 0 || nb === 0) return 0;
  // Clamp against floating-point excursions.
  const c = Math.max(-1, Math.min(1, dot / (na * nb)));
  return Math.acos(c);
}

// Scan for a *local minimum* of `scalarFn(jd)` below `threshold`.
// `direction` = +1 walks forward in time (returns the *first* min after
// fromJD), −1 walks backward (returns the *first* min before fromJD).
// Coarse step locates the bucket; golden-section search refines.
function scanForLocalMin(
  scalarFn,
  fromJD,
  windowDays,
  stepDays,
  threshold = Infinity,
  direction = 1
) {
  const sign = direction >= 0 ? 1 : -1;
  let bestT = null;
  let bestV = Infinity;
  let t2 = fromJD;
  let t1 = fromJD + sign * stepDays;
  let prev2 = scalarFn(t2);
  let prev1 = scalarFn(t1);
  const end = fromJD + sign * windowDays;
  for (let t0 = fromJD + sign * 2 * stepDays; sign * (t0 - end) <= 0; t0 += sign * stepDays) {
    const cur = scalarFn(t0);
    if (prev1 < prev2 && prev1 < cur) {
      // Refine via golden section in [min(t2,t0), max(t2,t0)].
      const lo = Math.min(t2, t0),
        hi = Math.max(t2, t0);
      const t = goldenSectionMin(scalarFn, lo, hi);
      const v = scalarFn(t);
      if (v < bestV && v < threshold) {
        return { jd: t, value: v };
      }
    }
    prev2 = prev1;
    prev1 = cur;
    t2 = t1;
    t1 = t0;
  }
  return bestT == null ? null : { jd: bestT, value: bestV };
}

function goldenSectionMin(f, a, b, tol = 1e-3, maxIter = 60) {
  const phi = (Math.sqrt(5) - 1) / 2;
  let c = b - phi * (b - a);
  let d = a + phi * (b - a);
  let fc = f(c),
    fd = f(d);
  for (let i = 0; i < maxIter && b - a > tol; i++) {
    if (fc < fd) {
      b = d;
      d = c;
      fd = fc;
      c = b - phi * (b - a);
      fc = f(c);
    } else {
      a = c;
      c = d;
      fc = fd;
      d = a + phi * (b - a);
      fd = f(d);
    }
  }
  return 0.5 * (a + b);
}

// ————————————————————————————————————————————————
// Public scanners
// ————————————————————————————————————————————————

// Δλ-cosine. cos(Δλ) = +1 when planet and Earth share heliocentric
// longitude (opposition for outer / inferior conjunction for inner);
// cos(Δλ) = −1 at superior conjunction. Avoids the (-π, π] wrap that
// trips a sign-change scanner near superior conjunction.
function cosDeltaLambda(planet, jd) {
  const lp = helioLongitude(planet, jd);
  const le = helioLongitude(planetById('earth'), jd);
  return Math.cos(lp - le);
}

// Next (or previous) opposition of an *outer* planet: local maximum of
// cos(Δλ) (i.e. local minimum of −cos). `direction` = ±1 toggles search
// direction in time. Returns { jd, planet } or null.
export function nextOpposition(planet, fromJD, windowDays = 4 * 365, { direction = 1 } = {}) {
  const earth = planetById('earth');
  if (planet.id === 'earth' || planet.isStar) return null;
  if (planet.orbitKm < earth.orbitKm) return null; // outer only
  const f = (jd) => -cosDeltaLambda(planet, jd);
  const hit = scanForLocalMin(
    f,
    fromJD,
    windowDays,
    5,
    -Math.cos((0.5 * Math.PI) / 180),
    direction
  );
  if (hit == null) return null;
  return { jd: hit.jd, planet };
}

// Next (or previous) inferior conjunction of an *inner* planet
// (Mercury / Venus). Local maximum of cos(Δλ); planet sits between Sun
// and Earth.
export function nextInferiorConjunction(
  planet,
  fromJD,
  windowDays = 4 * 365,
  { direction = 1 } = {}
) {
  const earth = planetById('earth');
  if (planet.id === 'earth' || planet.orbitKm >= earth.orbitKm) return null;
  const f = (jd) => -cosDeltaLambda(planet, jd);
  const hit = scanForLocalMin(
    f,
    fromJD,
    windowDays,
    5,
    -Math.cos((0.5 * Math.PI) / 180),
    direction
  );
  if (hit == null) return null;
  return { jd: hit.jd, planet, kind: 'inferior' };
}

// Next (or previous) superior conjunction of any non-Earth planet. Local
// minimum of cos(Δλ); planet on the far side of the Sun from Earth.
export function nextSuperiorConjunction(
  planet,
  fromJD,
  windowDays = 4 * 365,
  { direction = 1 } = {}
) {
  if (planet.id === 'earth' || planet.isStar) return null;
  const f = (jd) => cosDeltaLambda(planet, jd);
  const hit = scanForLocalMin(
    f,
    fromJD,
    windowDays,
    5,
    Math.cos((179.5 * Math.PI) / 180),
    direction
  );
  if (hit == null) return null;
  return { jd: hit.jd, planet, kind: 'superior' };
}

// Next (or previous) geocentric conjunction between two planets (angular
// separation drops below `thresholdRad`). Local minimum of geocentricAngle.
export function nextGeocentricConjunction(
  planetA,
  planetB,
  fromJD,
  {
    windowDays = 4 * 365,
    thresholdRad = (1 * Math.PI) / 180, // 1° default
    direction = 1,
  } = {}
) {
  if (planetA.id === planetB.id) return null;
  const f = (jd) => geocentricAngle(planetA, planetB, jd);
  const hit = scanForLocalMin(f, fromJD, windowDays, 5, thresholdRad, direction);
  if (hit == null) return null;
  return { jd: hit.jd, planetA, planetB, separationRad: hit.value };
}

// Next (or previous) heliocentric multi-planet alignment: local minimum
// of the std-dev of the chosen planets' heliocentric longitudes.
export function nextHeliocentricAlignment(
  planetIds,
  fromJD,
  {
    windowDays = 50 * 365,
    thresholdRad = (5 * Math.PI) / 180, // 5° default
    direction = 1,
  } = {}
) {
  const planets = planetIds.map(planetById).filter(Boolean);
  if (planets.length < 2) return null;
  const f = (jd) => longitudeStdDev(planets.map((p) => helioLongitude(p, jd)));
  const hit = scanForLocalMin(f, fromJD, windowDays, 30, thresholdRad, direction);
  if (hit == null) return null;
  return { jd: hit.jd, planetIds, stdRad: hit.value };
}

// Std-dev of a set of angles, computed on the unwrapped values around the
// circular mean. Good enough for "are these things clustered" — for
// multi-decade spreads we want a different metric, but multi-planet
// alignments cluster within a small arc by definition.
function longitudeStdDev(longitudes) {
  if (longitudes.length === 0) return 0;
  // Circular mean.
  let sx = 0,
    sy = 0;
  for (const l of longitudes) {
    sx += Math.cos(l);
    sy += Math.sin(l);
  }
  const mean = Math.atan2(sy / longitudes.length, sx / longitudes.length);
  let s = 0;
  for (const l of longitudes) {
    const d = wrapPi(l - mean);
    s += d * d;
  }
  return Math.sqrt(s / longitudes.length);
}

// Next (or previous) transit (Mercury / Venus across the Sun's disc, seen
// from Earth). Walks through inferior conjunctions; at each one, checks
// whether the geocentric angular distance from the Sun is inside the
// Sun's disc.
export function nextTransit(planet, fromJD, windowDays = 50 * 365, { direction = 1 } = {}) {
  const earth = planetById('earth');
  if (planet.orbitKm >= earth.orbitKm) return null;
  const sign = direction >= 0 ? 1 : -1;
  let cursor = fromJD;
  let remaining = windowDays;
  while (remaining > 0) {
    const ic = nextInferiorConjunction(planet, cursor, remaining, { direction });
    if (!ic) return null;
    const eHelio = helioPositionAtJD(earth, ic.jd);
    const sunFromEarth = { x: -eHelio.x, y: -eHelio.y, z: -eHelio.z };
    const planetFromEarth = geocentricPosition(planet, ic.jd);
    const sep = vectorAngle(sunFromEarth, planetFromEarth);
    if (sep < SUN_ANGULAR_RADIUS_RAD) {
      return { jd: ic.jd, planet, separationRad: sep };
    }
    // Step past this conjunction in the current scan direction.
    const newCursor = ic.jd + sign * 1;
    remaining -= sign * (newCursor - cursor);
    cursor = newCursor;
  }
  return null;
}

// Convenience: tag every orbital planet as 'inner' / 'outer' relative to Earth.
export function isOuterPlanet(planet) {
  const earth = planetById('earth');
  return planet.orbitKm > earth.orbitKm;
}
export function isInnerPlanet(planet) {
  const earth = planetById('earth');
  return planet.orbitKm > 0 && planet.orbitKm < earth.orbitKm;
}

// Re-export the static catalog of orbital planets for UI dropdowns.
export const ORBITAL_PLANETS = PLANETS.filter((p) => !p.isStar && p.id !== 'earth');

const PHASE_TARGETS = {
  new: 0,
  firstQuarter: Math.PI / 2,
  full: Math.PI,
  lastQuarter: (3 * Math.PI) / 2,
};

// Newton iteration on the Meeus-precision phaseAngle. The first step is the
// synodic-rate estimate from fromJD's current phase to the next target in the
// chosen direction (≤ ~30 days). Subsequent steps clamp to ±3 days so a
// correction can't accidentally jump across a lunation. Converges to ~1e-7
// rad in 3–4 iterations.

const NEWTON_TOL_RAD = 1e-9;
const NEWTON_MAX_ITER = 8;
const NEWTON_MAX_STEP_DAYS = 3;

export function nextLunarPhase(kind, fromJD, windowDays = 365, { direction = 1 } = {}) {
  const target = PHASE_TARGETS[kind];
  if (target === undefined) return null;
  const current = phaseAngle(fromJD);
  // Normalize delta to (-π, π] so the EPS rollover behaves correctly
  // regardless of which side of the target Newton converged to last call.
  // Without this, a previous Newton pass that landed at phase = 2π − tiny
  // (i.e. just shy of target=0 in unwrapped form) yields delta = −2π + tiny,
  // which < EPS triggers a +2π rollover that collapses to a tiny advance —
  // and Newton re-converges to the same lunation (#87).
  let delta = target - current;
  delta = ((((delta + Math.PI) % (2 * Math.PI)) + 2 * Math.PI) % (2 * Math.PI)) - Math.PI;
  // Force advance to the *next* target in `direction`. EPS << real phase
  // advance (~0.21 rad/day) but >> Newton tolerance, so a fromJD already
  // at the target rolls over to the next lunation instead of returning ~0.
  const EPS = 1e-6;
  if (direction >= 0) {
    if (delta < EPS) delta += 2 * Math.PI;
  } else {
    if (delta > -EPS) delta -= 2 * Math.PI;
  }
  let jd = fromJD + delta / PHASE_RATE;
  for (let i = 0; i < NEWTON_MAX_ITER; i++) {
    const phase = phaseAngle(jd);
    let d = target - phase;
    if (d > Math.PI) d -= 2 * Math.PI;
    else if (d < -Math.PI) d += 2 * Math.PI;
    if (Math.abs(d) < NEWTON_TOL_RAD) break;
    let step = d / PHASE_RATE;
    if (step > NEWTON_MAX_STEP_DAYS) step = NEWTON_MAX_STEP_DAYS;
    else if (step < -NEWTON_MAX_STEP_DAYS) step = -NEWTON_MAX_STEP_DAYS;
    jd += step;
  }
  if (Math.abs(jd - fromJD) > windowDays) return null;
  return { jd, kind };
}

// ─────────────────────────────────────────────────────────────────────────
// Solstice / equinox scanner (#82). The four cardinal solar events
// correspond to the Sun's geocentric ecliptic longitude crossing 0,
// π/2, π, 3π/2. Sun's geocentric longitude = Earth's heliocentric
// longitude + π.
//
// Newton iteration on the residual; same direction/window contract as
// nextLunarPhase.
// ─────────────────────────────────────────────────────────────────────────

const SOLAR_EVENT_TARGETS = {
  marchEquinox: 0,
  juneSolstice: Math.PI / 2,
  septemberEquinox: Math.PI,
  decemberSolstice: (3 * Math.PI) / 2,
};

// Sun's geocentric ecliptic longitude (rad), wrapped to [0, 2π).
function sunGeocentricLongitude(jd) {
  const earth = planetById('earth');
  let lon = helioLongitude(earth, jd) + Math.PI;
  // helioLongitude returns (-π, π]; +π pushes us into (0, 2π]. Wrap to [0, 2π).
  lon = ((lon % (2 * Math.PI)) + 2 * Math.PI) % (2 * Math.PI);
  return lon;
}

// Earth's mean longitude rate, expressed in rad/day (the data file stores
// rad per Julian century).
const EARTH_MEAN_MOTION_RAD_PER_DAY = (() => {
  const earth = planetById('earth');
  return earth.meanLongitudeRadRate / 36525;
})();

const SOLAR_NEWTON_TOL_RAD = 1e-9;
const SOLAR_NEWTON_MAX_ITER = 8;
const SOLAR_NEWTON_MAX_STEP_DAYS = 5;

export function nextSolarEvent(kind, fromJD, windowDays = 365, { direction = 1 } = {}) {
  const target = SOLAR_EVENT_TARGETS[kind];
  if (target === undefined) return null;
  const current = sunGeocentricLongitude(fromJD);
  // Normalize delta to (-π, π] before the rollover — see nextLunarPhase for
  // the same wrap-around stall (#87).
  let delta = target - current;
  delta = ((((delta + Math.PI) % (2 * Math.PI)) + 2 * Math.PI) % (2 * Math.PI)) - Math.PI;
  // Force advance to the *next* target in `direction` (same EPS rollover
  // logic as nextLunarPhase).
  const EPS = 1e-6;
  if (direction >= 0) {
    if (delta < EPS) delta += 2 * Math.PI;
  } else {
    if (delta > -EPS) delta -= 2 * Math.PI;
  }
  let jd = fromJD + delta / EARTH_MEAN_MOTION_RAD_PER_DAY;
  for (let i = 0; i < SOLAR_NEWTON_MAX_ITER; i++) {
    const lon = sunGeocentricLongitude(jd);
    let d = target - lon;
    if (d > Math.PI) d -= 2 * Math.PI;
    else if (d < -Math.PI) d += 2 * Math.PI;
    if (Math.abs(d) < SOLAR_NEWTON_TOL_RAD) break;
    let step = d / EARTH_MEAN_MOTION_RAD_PER_DAY;
    if (step > SOLAR_NEWTON_MAX_STEP_DAYS) step = SOLAR_NEWTON_MAX_STEP_DAYS;
    else if (step < -SOLAR_NEWTON_MAX_STEP_DAYS) step = -SOLAR_NEWTON_MAX_STEP_DAYS;
    jd += step;
  }
  if (Math.abs(jd - fromJD) > windowDays) return null;
  return { jd, kind };
}

// ─────────────────────────────────────────────────────────────────────────
// Eclipse scanners (#17). A lunar eclipse happens at full moon when the
// Moon is close enough to the ecliptic that it enters Earth's shadow
// cone; a solar eclipse happens at new moon when the Moon crosses the
// Sun-Earth line within the Moon-disc + Sun-disc + parallax window.
//
// We piggyback on nextLunarPhase to step through new/full instants, then
// reject lunations whose geocentric ecliptic latitude exceeds the
// eclipse-possibility threshold. ~1.5° covers any-contact for both
// types (lunar penumbra + Moon disc; solar disc + Moon disc + parallax).
// ─────────────────────────────────────────────────────────────────────────

// 1.5° in radians — generous "any eclipse visible somewhere on Earth"
// threshold. Sources: Meeus §54 (penumbra geometry), §51 (solar parallax).
const ECLIPSE_LAT_THRESHOLD_RAD = (1.5 * Math.PI) / 180;

function nextEclipse(kind, fromJD, windowDays, direction) {
  const phaseKind = kind === 'lunar' ? 'full' : 'new';
  let cursor = fromJD;
  // Walk lunations until we find one that qualifies or the window runs out.
  // ~13 lunations per year covers any plausible windowDays argument.
  const MAX_STEPS = Math.ceil(windowDays / 29 + 5);
  for (let i = 0; i < MAX_STEPS; i++) {
    const seed = nextLunarPhase(phaseKind, cursor, windowDays, { direction });
    if (!seed) return null;
    const { latRad } = lunarPosition(seed.jd);
    if (Math.abs(latRad) < ECLIPSE_LAT_THRESHOLD_RAD) {
      return { jd: seed.jd, kind, latRad };
    }
    // Step past this lunation so the next nextLunarPhase moves on.
    cursor = seed.jd + (direction >= 0 ? 1 : -1);
  }
  return null;
}

export function nextLunarEclipse(fromJD, windowDays = 3650, { direction = 1 } = {}) {
  return nextEclipse('lunar', fromJD, windowDays, direction);
}

export function nextSolarEclipse(fromJD, windowDays = 3650, { direction = 1 } = {}) {
  return nextEclipse('solar', fromJD, windowDays, direction);
}
← Astrarium