moon-sighting/src/bodies/index.ts
Aric Camarata df4dbfe53e ci: fix eslint parser devDeps, add files patterns, format src
- Add @typescript-eslint/parser and @typescript-eslint/eslint-plugin as
  explicit devDependencies so pnpm hoists them for eslint.config.mjs
- Add files: ['**/*.ts'] to eslint config entries so ESLint 10 processes
  TS sources instead of ignoring them
- Add parserOptions.project for typed-lint rules
- Run prettier --write to fix pre-existing format issues in 12 src files
2026-05-31 08:48:31 -04:00

429 lines
16 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* bodies — Moon and Sun state computation and illumination geometry.
*
* This module assembles the full pipeline from ephemeris evaluation to
* geocentric and topocentric body positions. It uses the SpkKernel's
* segment-chaining getState() to retrieve Moon/Sun positions relative to Earth.
*
* When the DE442S kernel is not available, getMoonSunApproximate() provides
* low-accuracy positions using Meeus Ch. 25 (Sun) and Ch. 47 (Moon).
*
* References:
* Meeus, J. (1998). Astronomical Algorithms, 2nd ed. Willmann-Bell.
* Odeh, M. (2006). New Criterion for Lunar Crescent Visibility.
* Experimental Astronomy, 17(1-3), 117-138.
* Yallop, B.D. (1997). A Method for Predicting the First Sighting of the
* New Crescent Moon. NAO Technical Note 69. HM Nautical Almanac Office.
*/
import type { StateVector, Vec3 } from "../types.js";
import type { SpkKernel } from "../spk/index.js";
import { NAIF_IDS } from "../spk/index.js";
import { J2000, DAYS_PER_JULIAN_CENTURY } from "../time/index.js";
import { DEG2RAD, vdot, vnorm } from "../math/index.js";
// ─── Constants ────────────────────────────────────────────────────────────────
const AU_KM = 149597870.7;
/** Mean radius of the Moon in km (IAU 2015 nominal value) */
const MOON_RADIUS_KM = 1737.4;
/** Mean radius of the Sun in km */
const _SUN_RADIUS_KM = 696000.0;
void _SUN_RADIUS_KM; // reserved for future solar semi-diameter calculations
// ─── Geocentric state ─────────────────────────────────────────────────────────
/**
* Compute the geocentric state of the Moon in GCRS at the given ET.
*
* The SpkKernel handles DE442S segment chaining automatically:
* Moon→Earth = Moon→EMB Earth→EMB
*
* @param kernel - Loaded DE442S kernel
* @param et - Ephemeris time, seconds past J2000 TDB
* @returns Moon state vector relative to Earth center, km and km/s, GCRS
*/
export function getMoonGeocentricState(kernel: SpkKernel, et: number): StateVector {
return kernel.getState(NAIF_IDS.MOON, NAIF_IDS.EARTH, et);
}
/**
* Compute the geocentric state of the Sun in GCRS at the given ET.
*
* The SpkKernel handles DE442S segment chaining automatically:
* Sun→Earth = Sun→SSB (EMB→SSB Earth→EMB)
*
* @param kernel - Loaded DE442S kernel
* @param et - Ephemeris time, seconds past J2000 TDB
* @returns Sun state vector relative to Earth center, km and km/s, GCRS
*/
export function getSunGeocentricState(kernel: SpkKernel, et: number): StateVector {
return kernel.getState(NAIF_IDS.SUN, NAIF_IDS.EARTH, et);
}
// ─── Moon illumination ────────────────────────────────────────────────────────
/**
* Compute the Moon's illumination fraction and related phase quantities.
*
* Phase angle i = angle at Moon between Earth and Sun directions.
* Illumination k = (1 + cos(i)) / 2 — 0 at new moon, 1 at full moon.
* Elongation ψ = angle at Earth between Moon and Sun.
*
* Since the Sun is ~400× farther from Earth than the Moon:
* i ≈ π ψ (they are approximately supplementary)
* cos(i) ≈ cos(ψ) → k = (1 cos(ψ)) / 2
*
* Reference: Meeus §48.
*
* @param moonGCRS - Moon geocentric position (km)
* @param sunGCRS - Sun geocentric position (km)
* @returns illumination [0-1], phaseAngleDeg, elongationDeg, isWaxing
*/
export function computeIllumination(
moonGCRS: Vec3,
sunGCRS: Vec3,
): { illumination: number; phaseAngleDeg: number; elongationDeg: number; isWaxing: boolean } {
const rMoon = vnorm(moonGCRS);
const rSun = vnorm(sunGCRS);
// Elongation ψ: angle at Earth between Moon and Sun
const cosElong = vdot(moonGCRS, sunGCRS) / (rMoon * rSun);
const elongationDeg = Math.acos(Math.max(-1, Math.min(1, cosElong))) / DEG2RAD;
// Phase angle i: angle at Moon between Earth and Sun
// Vector from Moon to Earth: -moonGCRS
// Vector from Moon to Sun: sunGCRS - moonGCRS
const moonToSun: Vec3 = [
sunGCRS[0] - moonGCRS[0],
sunGCRS[1] - moonGCRS[1],
sunGCRS[2] - moonGCRS[2],
];
const moonToEarth: Vec3 = [-moonGCRS[0], -moonGCRS[1], -moonGCRS[2]];
const rMoonToSun = vnorm(moonToSun);
const cosPhase = vdot(moonToEarth, moonToSun) / (rMoon * rMoonToSun);
const phaseAngleDeg = Math.acos(Math.max(-1, Math.min(1, cosPhase))) / DEG2RAD;
const illumination = (1 + Math.cos(phaseAngleDeg * DEG2RAD)) / 2;
// Moon is waxing when it is east of the Sun (elongation increasing).
// Cross product sunGCRS × moonGCRS z-component: positive when Moon is east of Sun.
const crossZ = sunGCRS[0] * moonGCRS[1] - sunGCRS[1] * moonGCRS[0];
const isWaxing = crossZ > 0;
return { illumination, phaseAngleDeg, elongationDeg, isWaxing };
}
/**
* Compute the topocentric crescent width W (Odeh) and W' (Yallop) in arc minutes.
*
* The crescent width is the angular thickness of the illuminated limb at its widest,
* measured in the direction perpendicular to the cusps axis.
*
* For a sphere of topocentric semi-diameter SD and topocentric elongation ARCL:
* W = SD × (1 cos ARCL) [SD and W in the same angular units]
*
* This is exact for a spherical Moon model and gives:
* W = 0 at new moon (ARCL = 0°) — correct, no crescent
* W = SD at ARCL = 90°
* W = 2·SD at full moon (ARCL = 180°)
*
* Both Odeh W and Yallop W' use this formula with topocentric ARCL and SD.
*
* @param moonTopoVec - Topocentric Moon position vector (km)
* @param ARCL - Topocentric Sun-Moon angular separation (degrees)
* @returns W and Wprime: topocentric crescent width in arc minutes
*/
export function computeCrescentWidth(
moonTopoVec: Vec3,
ARCL: number,
): { W: number; Wprime: number } {
const rMoon = Math.sqrt(moonTopoVec[0] ** 2 + moonTopoVec[1] ** 2 + moonTopoVec[2] ** 2);
// Topocentric semi-diameter in arc minutes
const SDmoon_arcmin = (Math.atan(MOON_RADIUS_KM / rMoon) / DEG2RAD) * 60;
// Crescent width in arc minutes
const ARCL_rad = ARCL * DEG2RAD;
const W = SDmoon_arcmin * (1 - Math.cos(ARCL_rad));
// Wprime ≡ W for both Odeh and Yallop in this formulation
return { W, Wprime: W };
}
// ─── Approximate positions (no kernel) ────────────────────────────────────────
/**
* Low-accuracy Sun and Moon positions using Meeus Ch. 25 (Sun) and Ch. 47 (Moon).
*
* Error budget:
* Sun: < 0.01° in ecliptic longitude (main terms only)
* Moon: < 0.3° in ecliptic longitude, < 0.2° in latitude
*
* Not suitable for crescent sighting reports. Intended for moon phase displays
* and for bootstrapping event-time searches.
*
* @param jdTT - Julian Date in TT
* @returns Geocentric GCRS positions in km (approximate, light-time not corrected)
*/
export function getMoonSunApproximate(jdTT: number): {
moonGCRS: Vec3;
sunGCRS: Vec3;
} {
const T = (jdTT - J2000) / DAYS_PER_JULIAN_CENTURY;
// ── Sun (Meeus Ch. 25) ──────────────────────────────────────────────────────
// Mean longitude L0 and mean anomaly M (degrees)
const L0 = 280.46646 + 36000.76983 * T + 0.0003032 * T * T;
const M_sun = 357.52911 + 35999.05029 * T - 0.0001537 * T * T;
const M_sun_rad = (M_sun % 360) * DEG2RAD;
const e_sun = 0.016708634 - 0.000042037 * T - 0.0000001267 * T * T;
// Equation of center (degrees)
const C =
(1.914602 - 0.004817 * T - 0.000014 * T * T) * Math.sin(M_sun_rad) +
(0.019993 - 0.000101 * T) * Math.sin(2 * M_sun_rad) +
0.000289 * Math.sin(3 * M_sun_rad);
// True longitude and anomaly
const sunLonDeg = L0 + C;
const nu_rad = M_sun_rad + C * DEG2RAD;
// Geometric distance in AU
const R_AU = (1.000001018 * (1 - e_sun * e_sun)) / (1 + e_sun * Math.cos(nu_rad));
const R_km = R_AU * AU_KM;
// Nutation correction for apparent longitude (simplified)
const omega = (125.04 - 1934.136 * T) * DEG2RAD;
const sunLonApp = sunLonDeg - 0.00569 - 0.00478 * Math.sin(omega);
const sunLon_rad = sunLonApp * DEG2RAD;
// Mean obliquity of the ecliptic (IAU 1980 approximation, degrees)
const eps =
(23.439291111 - 0.013004167 * T - 0.0000001638 * T * T + 0.0000005036 * T * T * T) * DEG2RAD;
const sunGCRS: Vec3 = [
R_km * Math.cos(sunLon_rad),
R_km * Math.sin(sunLon_rad) * Math.cos(eps),
R_km * Math.sin(sunLon_rad) * Math.sin(eps),
];
// ── Moon (Meeus Ch. 47) ─────────────────────────────────────────────────────
// Fundamental arguments (degrees)
const Lp =
218.3164477 +
481267.88123421 * T -
0.0015786 * T * T +
(T * T * T) / 538841 -
(T * T * T * T) / 65194000;
const D =
297.8501921 +
445267.1114034 * T -
0.0018819 * T * T +
(T * T * T) / 545868 -
(T * T * T * T) / 113065000;
const M = 357.5291092 + 35999.0502909 * T - 0.0001536 * T * T + (T * T * T) / 24490000;
const Mp =
134.9633964 +
477198.8675055 * T +
0.0087414 * T * T +
(T * T * T) / 69699 -
(T * T * T * T) / 14712000;
const F =
93.272095 +
483202.0175233 * T -
0.0036539 * T * T -
(T * T * T) / 3526000 +
(T * T * T * T) / 863310000;
// Additive terms for longitude/latitude
const A1 = (119.75 + 131.849 * T) * DEG2RAD;
const A2 = (53.09 + 479264.29 * T) * DEG2RAD;
const A3 = (313.45 + 481266.484 * T) * DEG2RAD;
// Convert to radians for accumulation
const D_r = (D % 360) * DEG2RAD;
const M_r = (M % 360) * DEG2RAD;
const Mp_r = (Mp % 360) * DEG2RAD;
const F_r = (F % 360) * DEG2RAD;
// Eccentricity correction for terms involving M (Earth's orbital eccentricity)
const E = 1 - 0.002516 * T - 0.0000074 * T * T;
// Longitude and distance accumulation — 30 main terms from Meeus Table 47.A
// [d, m, mp, f, Σl (0.000001°), Σr (0.001 km)]
const LD: ReadonlyArray<readonly [number, number, number, number, number, number]> = [
[0, 0, 1, 0, 6288774, -20905355],
[2, 0, -1, 0, 1274027, -3699111],
[2, 0, 0, 0, 658314, -2955968],
[0, 0, 2, 0, 213618, -569925],
[0, 1, 0, 0, -185116, 48888],
[0, 0, 0, 2, -114332, -3149],
[2, 0, -2, 0, 58793, 246158],
[2, -1, -1, 0, 57066, -152138],
[2, 0, 1, 0, 53322, -170733],
[2, -1, 0, 0, 45758, -204586],
[0, 1, -1, 0, -40923, -129620],
[1, 0, 0, 0, -34720, 108743],
[0, 1, 1, 0, -30383, 104755],
[2, 0, 0, -2, 15327, 10321],
[0, 0, 1, 2, -12528, 0],
[0, 0, 1, -2, 10980, 79661],
[4, 0, -1, 0, 10675, -34782],
[0, 0, 3, 0, 10034, -23210],
[4, 0, -2, 0, 8548, -21636],
[2, 1, -1, 0, -7888, 24208],
[2, 1, 0, 0, -6766, 30824],
[1, 0, -1, 0, -5163, -8379],
[1, 1, 0, 0, 4987, -16675],
[2, -1, 1, 0, 4036, -12831],
[2, 0, 2, 0, 3994, -10445],
[4, 0, 0, 0, 3861, -11650],
[2, 0, -3, 0, 3665, 14403],
[0, 1, -2, 0, -2689, -7003],
[2, 0, -1, 2, -2602, 0],
[2, -1, -2, 0, 2390, 10056],
];
let Sl = 0,
Sr = 0;
for (const [d, m, mp, f, sl, sr] of LD) {
const arg = d * D_r + m * M_r + mp * Mp_r + f * F_r;
const eCorr = Math.abs(m) === 2 ? E * E : Math.abs(m) === 1 ? E : 1;
Sl += sl * eCorr * Math.sin(arg);
Sr += sr * eCorr * Math.cos(arg);
}
// Additive longitude corrections (Meeus §47)
Sl += 3958 * Math.sin(A1) + 1962 * Math.sin((Lp - F) * DEG2RAD) + 318 * Math.sin(A2);
// Latitude accumulation — 20 main terms from Meeus Table 47.B
// [d, m, mp, f, Σb (0.000001°)]
const FB: ReadonlyArray<readonly [number, number, number, number, number]> = [
[0, 0, 0, 1, 5128122],
[0, 0, 1, 1, 280602],
[0, 0, 1, -1, 277693],
[2, 0, 0, -1, 173237],
[2, 0, -1, 1, 55413],
[2, 0, -1, -1, 46271],
[2, 0, 0, 1, 32573],
[0, 0, 2, 1, 17198],
[2, 0, 1, -1, 9266],
[0, 0, 2, -1, 8822],
[2, -1, 0, -1, 8216],
[2, 0, -2, -1, 4324],
[2, 0, 1, 1, 4200],
[2, 1, 0, -1, -3359],
[2, -1, -1, 1, 2463],
[2, -1, 0, 1, 2211],
[2, -1, -1, -1, 2065],
[0, 1, -1, -1, -1870],
[4, 0, -1, -1, 1828],
[0, 1, 0, 1, -1794],
];
let Sb = 0;
for (const [d, m, mp, f, sb] of FB) {
const arg = d * D_r + m * M_r + mp * Mp_r + f * F_r;
const eCorr = Math.abs(m) === 2 ? E * E : Math.abs(m) === 1 ? E : 1;
Sb += sb * eCorr * Math.sin(arg);
}
// Additive latitude corrections
Sb +=
-2235 * Math.sin(Lp * DEG2RAD) +
382 * Math.sin(A3) +
175 * Math.sin(A1 - F_r) +
175 * Math.sin(A1 + F_r) +
127 * Math.sin((Lp - Mp) * DEG2RAD) -
115 * Math.sin((Lp + Mp) * DEG2RAD);
// Moon ecliptic coordinates
const moonLonDeg = Lp + Sl * 1e-6;
const moonLatDeg = Sb * 1e-6;
const moonDistKm = 385000.56 + Sr * 0.001;
const moonLon_rad = moonLonDeg * DEG2RAD;
const moonLat_rad = moonLatDeg * DEG2RAD;
// Ecliptic to equatorial (GCRS ≈ J2000 equatorial for this accuracy level)
const moonGCRS: Vec3 = [
moonDistKm * Math.cos(moonLat_rad) * Math.cos(moonLon_rad),
moonDistKm *
(Math.cos(eps) * Math.cos(moonLat_rad) * Math.sin(moonLon_rad) -
Math.sin(eps) * Math.sin(moonLat_rad)),
moonDistKm *
(Math.sin(eps) * Math.cos(moonLat_rad) * Math.sin(moonLon_rad) +
Math.cos(eps) * Math.sin(moonLat_rad)),
];
return { moonGCRS, sunGCRS };
}
/**
* Estimate the time of the nearest new moon using Meeus Ch. 49.
* Accurate to within ~2 hours; sufficient for phase age calculations.
*
* @param jdTT - Julian Date in TT near the desired new moon
* @returns Julian Date in TT of the nearest new moon
*/
export function nearestNewMoon(jdTT: number): number {
// Convert JD to approximate decimal year
const Y = 2000.0 + (jdTT - J2000) / 365.25;
// k = approximate lunation number (0 = Jan 6, 2000 new moon)
const k = Math.round((Y - 2000.0) * 12.3685);
const T = k / 1236.85;
// JDE of mean new moon (Meeus Eq. 49.1)
let JDE =
2451550.09766 +
29.530588861 * k +
0.00015437 * T * T -
0.00000015 * T * T * T +
0.00000000073 * T * T * T * T;
// Fundamental arguments for the corrections (degrees → radians)
const M = (2.5534 + 29.1053567 * k - 0.0000014 * T * T - 0.00000011 * T * T * T) * DEG2RAD;
const Mp = (201.5643 + 385.81693528 * k + 0.0107582 * T * T + 0.00001238 * T * T * T) * DEG2RAD;
const Fc = (160.7108 + 390.67050284 * k - 0.0016118 * T * T - 0.00000227 * T * T * T) * DEG2RAD;
const Om = (124.7746 - 1.56375588 * k + 0.0020672 * T * T + 0.00000215 * T * T * T) * DEG2RAD;
// Eccentricity of Earth's orbit
const E = 1 - 0.002516 * T - 0.0000074 * T * T;
// Corrections from Meeus Table 49.A (new moon)
JDE +=
-0.4072 * Math.sin(Mp) +
0.17241 * E * Math.sin(M) +
0.01608 * Math.sin(2 * Mp) +
0.01039 * Math.sin(2 * Fc) +
0.00739 * E * Math.sin(Mp - M) -
0.00514 * E * Math.sin(Mp + M) +
0.00208 * E * E * Math.sin(2 * M) -
0.00111 * Math.sin(Mp - 2 * Fc) -
0.00057 * Math.sin(Mp + 2 * Fc) +
0.00056 * E * Math.sin(2 * Mp + M) -
0.00042 * Math.sin(3 * Mp) +
0.00042 * E * Math.sin(M + 2 * Fc) +
0.00038 * E * Math.sin(M - 2 * Fc) -
0.00024 * E * Math.sin(2 * Mp - M) -
0.00017 * Math.sin(Om) -
0.00007 * Math.sin(Mp + 2 * M) +
0.00004 * Math.sin(2 * Mp - 2 * Fc) +
0.00004 * Math.sin(3 * M) +
0.00003 * Math.sin(Mp + M - 2 * Fc) +
0.00003 * Math.sin(2 * Mp + 2 * Fc) -
0.00003 * Math.sin(Mp + M + 2 * Fc) +
0.00003 * Math.sin(Mp - M + 2 * Fc) -
0.00002 * Math.sin(Mp - M - 2 * Fc) -
0.00002 * Math.sin(3 * Mp + M) +
0.00002 * Math.sin(4 * Mp);
return JDE;
}