mirror of
https://github.com/acamarata/moon-sighting.git
synced 2026-06-30 19:04:24 +00:00
- 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
429 lines
16 KiB
TypeScript
429 lines
16 KiB
TypeScript
/**
|
||
* 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;
|
||
}
|