ootk-core
Version:
Orbital Object Toolkit. A modern typed replacement for satellite.js including SGP4 propagation, TLE parsing, Sun and Moon calculations, and more.
557 lines (511 loc) • 17 kB
text/typescript
/**
* @author Theodore Kruczek.
* @license MIT
* @copyright (c) 2022-2025 Theodore Kruczek Permission is
* hereby granted, free of charge, to any person obtaining a copy of this
* software and associated documentation files (the "Software"), to deal in the
* Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of
* the Software, and to permit persons to whom the Software is furnished to do
* so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in
* all copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
* SOFTWARE.
* @copyright (c) 2011-2015, Vladimir Agafonkin
* @copyright (c) 2022 Robert Gester https://github.com/hypnos3/suncalc3
* @see suncalc.LICENSE.md
* Some of the math in this file was originally created by Vladimir Agafonkin.
* Robert Gester's update was referenced for documentation. There were a couple
* of bugs in both versions so there will be some differences if you are
* migrating from either to this library.
*
* suncalc is a JavaScript library for calculating sun/moon position and light
* phases. https://github.com/mourner/suncalc
* It was reworked and enhanced by Robert Gester.
*
* The original suncalc is released under the terms of the BSD 2-Clause License.
* @see http://aa.quae.nl/en/reken/hemelpositie.html
* moon calculations are based on formulas from this website
*/
import { AngularDiameterMethod, Celestial, Degrees, Kilometers, RaDec, Radians } from '../main.js';
import { Vector3D } from '../operations/Vector3D.js';
import { EpochUTC } from '../time/EpochUTC.js';
import { DEG2RAD, MS_PER_DAY } from '../utils/constants.js';
import { angularDiameter } from '../utils/functions.js';
import { Earth } from './Earth.js';
import { Sun } from './Sun.js';
type MoonIlluminationData = {
fraction: number;
phase: {
from: number;
to: number;
id: string;
emoji: string;
code: string;
name: string;
weight: number;
css: string;
};
phaseValue: number;
angle: number;
next: {
value: number;
date: string;
type: string;
newMoon: {
value: number;
date: string;
};
fullMoon: {
value: number;
date: string;
};
firstQuarter: {
value: number;
date: string;
};
thirdQuarter: {
value: number;
date: string;
};
};
};
// / Moon metrics and operations.
export class Moon {
private constructor() {
// disable constructor
}
// / Moon gravitational parameter _(km³/s²)_.
static readonly mu = 4902.799;
// / Moon equatorial radius _(km)_.
static readonly radiusEquator = 1738.0;
// / Calculate the Moon's ECI position _(km)_ for a given UTC [epoch].
static eci(epoch: EpochUTC = EpochUTC.fromDateTime(new Date())): Vector3D<Kilometers> {
const jc = epoch.toJulianCenturies();
const dtr = DEG2RAD;
const lamEcl =
218.32 +
481267.8813 * jc +
6.29 * Math.sin((134.9 + 477198.85 * jc) * dtr) -
1.27 * Math.sin((259.2 - 413335.38 * jc) * dtr) +
0.66 * Math.sin((235.7 + 890534.23 * jc) * dtr) +
0.21 * Math.sin((269.9 + 954397.7 * jc) * dtr) -
0.19 * Math.sin((357.5 + 35999.05 * jc) * dtr) -
0.11 * Math.sin((186.6 + 966404.05 * jc) * dtr);
const phiEcl =
5.13 * Math.sin((93.3 + 483202.03 * jc) * dtr) +
0.28 * Math.sin((228.2 + 960400.87 * jc) * dtr) -
0.28 * Math.sin((318.3 + 6003.18 * jc) * dtr) -
0.17 * Math.sin((217.6 - 407332.2 * jc) * dtr);
const pllx =
0.9508 +
0.0518 * Math.cos((134.9 + 477198.85 * jc) * dtr) +
0.0095 * Math.cos((259.2 - 413335.38 * jc) * dtr) +
0.0078 * Math.cos((235.7 + 890534.23 * jc) * dtr) +
0.0028 * Math.cos((269.9 + 954397.7 * jc) * dtr);
const obq = 23.439291 - 0.0130042 * jc;
const rMag = 1 / Math.sin(pllx * dtr);
const r = new Vector3D(
rMag * Math.cos(phiEcl * dtr) * Math.cos(lamEcl * dtr),
rMag *
(Math.cos(obq * dtr) * Math.cos(phiEcl * dtr) * Math.sin(lamEcl * dtr) -
Math.sin(obq * dtr) * Math.sin(phiEcl * dtr)),
rMag *
(Math.sin(obq * dtr) * Math.cos(phiEcl * dtr) * Math.sin(lamEcl * dtr) +
Math.cos(obq * dtr) * Math.sin(phiEcl * dtr)),
);
const rMOD = r.scale(Earth.radiusEquator);
const p = Earth.precession(epoch);
return rMOD
.rotZ(p.zed)
.rotY(-p.theta as Radians)
.rotZ(p.zeta);
}
/**
* Calculates the illumination of the Moon at a given epoch.
* @param epoch - The epoch in UTC.
* @param origin - The origin vector. Defaults to the origin vector if not provided.
* @returns The illumination of the Moon, ranging from 0 to 1.
*/
static illumination(epoch: EpochUTC, origin?: Vector3D<Kilometers>): number {
const orig = origin ?? (Vector3D.origin as Vector3D<Kilometers>);
const sunPos = Sun.position(epoch).subtract(orig);
const moonPos = this.eci(epoch).subtract(orig);
const phaseAngle = sunPos.angle(moonPos);
return 0.5 * (1 - Math.cos(phaseAngle));
}
/**
* Calculates the diameter of the Moon.
* @param obsPos - The position of the observer.
* @param moonPos - The position of the Moon.
* @returns The diameter of the Moon.
*/
static diameter(obsPos: Vector3D, moonPos: Vector3D): number {
return angularDiameter(this.radiusEquator * 2, obsPos.subtract(moonPos).magnitude(), AngularDiameterMethod.Sphere);
}
/**
* calculations for illumination parameters of the moon, based on
* http://idlastro.gsfc.nasa.gov/ftp/pro/astro/mphase.pro formulas and Chapter 48 of "Astronomical Algorithms" 2nd
* edition by Jean Meeus (Willmann-Bell, Richmond) 1998.
* @param date Date object or timestamp for calculating moon-illumination
* @returns result object of moon-illumination
*/
// eslint-disable-next-line max-statements
static getMoonIllumination(date: number | Date): MoonIlluminationData {
const dateValue = date instanceof Date ? date.getTime() : date;
const lunarDaysMs = 2551442778; // The duration in days of a lunar cycle is 29.53058770576 days.
const firstNewMoon2000 = 947178840000; // first newMoon in the year 2000 2000-01-06 18:14
const dateObj = new Date(dateValue);
const d = Sun.date2jSince2000(dateObj);
const s = Sun.raDec(dateObj);
const m = Moon.moonCoords(d);
const sdist = 149598000; // distance from Earth to Sun in km
const phi = Math.acos(
Math.sin(s.dec) * Math.sin(m.dec) + Math.cos(s.dec) * Math.cos(m.dec) * Math.cos(s.ra - m.ra),
);
const inc = Math.atan2(sdist * Math.sin(phi), m.dist - sdist * Math.cos(phi));
const angle = Math.atan2(
Math.cos(s.dec) * Math.sin(s.ra - m.ra),
Math.sin(s.dec) * Math.cos(m.dec) - Math.cos(s.dec) * Math.sin(m.dec) * Math.cos(s.ra - m.ra),
);
const phaseValue = 0.5 + (0.5 * inc * (angle < 0 ? -1 : 1)) / Math.PI;
/*
* calculates the difference in ms between the sirst fullMoon 2000 and given
* Date
*/
const diffBase = dateValue - firstNewMoon2000;
// Calculate modulus to drop completed cycles
let cycleModMs = diffBase % lunarDaysMs;
// If negative number (date before new moon 2000) add lunarDaysMs
if (cycleModMs < 0) {
cycleModMs += lunarDaysMs;
}
const nextNewMoon = lunarDaysMs - cycleModMs + dateValue;
let nextFullMoon = lunarDaysMs / 2 - cycleModMs + dateValue;
if (nextFullMoon < dateValue) {
nextFullMoon += lunarDaysMs;
}
const quater = lunarDaysMs / 4;
let nextFirstQuarter = quater - cycleModMs + dateValue;
if (nextFirstQuarter < dateValue) {
nextFirstQuarter += lunarDaysMs;
}
let nextThirdQuarter = lunarDaysMs - quater - cycleModMs + dateValue;
if (nextThirdQuarter < dateValue) {
nextThirdQuarter += lunarDaysMs;
}
/*
* Calculate the fraction of the moon cycle const currentfrac = cycleModMs /
* lunarDaysMs;
*/
const next = Math.min(nextNewMoon, nextFirstQuarter, nextFullMoon, nextThirdQuarter);
// eslint-disable-next-line init-declarations
let phase: (typeof Moon.moonCycles_)[0] | null = null;
for (const moonCycle of Moon.moonCycles_) {
if (phaseValue >= moonCycle.from && phaseValue <= moonCycle.to) {
phase = moonCycle;
break;
}
}
if (!phase) {
throw new Error('Moon phase not found');
}
let type = '';
if (next === nextNewMoon) {
type = 'newMoon';
} else if (next === nextFirstQuarter) {
type = 'firstQuarter';
} else if (next === nextFullMoon) {
type = 'fullMoon';
} else {
type = 'thirdQuarter';
}
return {
fraction: (1 + Math.cos(inc)) / 2,
phase,
phaseValue,
angle,
next: {
value: next,
date: new Date(next).toISOString(),
type,
newMoon: {
value: nextNewMoon,
date: new Date(nextNewMoon).toISOString(),
},
fullMoon: {
value: nextFullMoon,
date: new Date(nextFullMoon).toISOString(),
},
firstQuarter: {
value: nextFirstQuarter,
date: new Date(nextFirstQuarter).toISOString(),
},
thirdQuarter: {
value: nextThirdQuarter,
date: new Date(nextThirdQuarter).toISOString(),
},
},
};
}
static rae(
date: Date,
lat: Degrees,
lon: Degrees,
): {
az: Radians;
el: Radians;
rng: Kilometers;
parallacticAngle: Radians;
} {
const lw = <Radians>(DEG2RAD * -lon);
const phi = <Radians>(DEG2RAD * lat);
const d = Sun.date2jSince2000(date);
const c = Moon.moonCoords(d);
const H = Sun.siderealTime(d, lw) - c.ra;
let h = Celestial.elevation(H, phi, c.dec);
/*
* formula 14.1 of "Astronomical Algorithms" 2nd edition by Jean Meeus
* (Willmann-Bell, Richmond) 1998.
*/
const pa = Math.atan2(Math.sin(H), Math.tan(phi) * Math.cos(c.dec) - Math.sin(c.dec) * Math.cos(H));
h = <Radians>(h + Celestial.atmosphericRefraction(h)); // altitude correction for refraction
return {
az: Celestial.azimuth(H, phi, c.dec),
el: h,
rng: c.dist,
parallacticAngle: pa as Radians,
};
}
/**
* calculations for moon rise/set times are based on http://www.stargazing.net/kepler/moonrise.html article
* @param date Date object or timestamp for calculating moon rise/set
* @param lat Latitude of observer in degrees
* @param lon Longitude of observer in degrees
* @param isUtc If true, date will be interpreted as UTC
* @returns result object of moon rise/set
*/
static getMoonTimes(date: Date, lat: Degrees, lon: Degrees, isUtc = false) {
// Clone the date so we don't change the original
const date_ = new Date(date);
if (isUtc) {
date_.setUTCHours(0, 0, 0, 0);
} else {
date_.setHours(0, 0, 0, 0);
}
const { rise, set, ye } = Moon.calculateRiseSetTimes_(date_, lat, lon);
const result = {
rise: null as Date | null,
set: null as Date | null,
ye: null as number | null,
alwaysUp: null as boolean | null,
alwaysDown: null as boolean | null,
highest: null as Date | null,
};
if (rise) {
result.rise = new Date(Moon.hoursLater_(date_, rise));
}
if (set) {
result.set = new Date(Moon.hoursLater_(date_, set));
}
if (!rise && !set) {
if (ye > 0) {
result.alwaysUp = true;
result.alwaysDown = false;
} else {
result.alwaysUp = false;
result.alwaysDown = true;
}
} else if (rise && set) {
result.alwaysUp = false;
result.alwaysDown = false;
result.highest = new Date(Moon.hoursLater_(date_, Math.min(rise, set) + Math.abs(set - rise) / 2));
} else {
result.alwaysUp = false;
result.alwaysDown = false;
}
return result;
}
private static hoursLater_(date: Date, h: number) {
return new Date(date.getTime() + (h * MS_PER_DAY) / 24);
}
/**
* Calculates the geocentric ecliptic coordinates of the moon.
* @param d - The number of days since year 2000.
* @returns An object containing the right ascension, declination, and
* distance to the moon.
*/
static moonCoords(d: number): RaDec {
const L = DEG2RAD * (218.316 + 13.176396 * d); // ecliptic longitude
const M = DEG2RAD * (134.963 + 13.064993 * d); // mean anomaly
const F = DEG2RAD * (93.272 + 13.22935 * d); // mean distance
const l = L + DEG2RAD * 6.289 * Math.sin(M); // longitude
const b = DEG2RAD * 5.128 * Math.sin(F); // latitude
const dt = 385001 - 20905 * Math.cos(M); // distance to the moon in km
return {
ra: Celestial.rightAscension(l, b),
dec: Celestial.declination(l, b),
dist: dt as Kilometers,
};
}
private static calculateRiseSetTimes_(t: Date, lat: Degrees, lon: Degrees) {
const hc = 0.133 * DEG2RAD;
let h0 = Moon.rae(t, lat, lon).el - hc;
let h1 = 0;
let h2 = 0;
let rise = 0;
let set = 0;
let a = 0;
let b = 0;
let xe = 0;
let ye = 0;
let d = 0;
let roots = 0;
let x1 = 0;
let x2 = 0;
let dx = 0;
/*
* go in 2-hour chunks, each time seeing if a 3-point quadratic curve
* crosses zero (which means rise or set)
*/
for (let i = 1; i <= 24; i += 2) {
h1 = Moon.rae(Moon.hoursLater_(t, i), lat, lon).el - hc;
h2 = Moon.rae(Moon.hoursLater_(t, i + 1), lat, lon).el - hc;
a = (h0 + h2) / 2 - h1;
b = (h2 - h0) / 2;
xe = -b / (2 * a);
ye = (a * xe + b) * xe + h1;
d = b * b - 4 * a * h1;
roots = 0;
if (d >= 0) {
dx = Math.sqrt(d) / (Math.abs(a) * 2);
x1 = xe - dx;
x2 = xe + dx;
if (Math.abs(x1) <= 1) {
roots++;
}
if (Math.abs(x2) <= 1) {
roots++;
}
if (x1 < -1) {
x1 = x2;
}
}
if (roots === 1) {
if (h0 < 0) {
rise = i + x1;
} else {
set = i + x1;
}
} else if (roots === 2) {
rise = i + (ye < 0 ? x2 : x1);
set = i + (ye < 0 ? x1 : x2);
}
if (rise && set) {
break;
}
h0 = h2;
}
return { rise, set, ye };
}
private static readonly moonCycles_ = [
{
from: 0,
to: 0.033863193308711,
id: 'newMoon',
emoji: '🌚',
code: ':new_moon_with_face:',
name: 'New Moon',
weight: 1,
css: 'wi-moon-new',
},
{
from: 0.033863193308711,
to: 0.216136806691289,
id: 'waxingCrescentMoon',
emoji: '🌒',
code: ':waxing_crescent_moon:',
name: 'Waxing Crescent',
weight: 6.3825,
css: 'wi-moon-wax-cres',
},
{
from: 0.216136806691289,
to: 0.283863193308711,
id: 'firstQuarterMoon',
emoji: '🌓',
code: ':first_quarter_moon:',
name: 'First Quarter',
weight: 1,
css: 'wi-moon-first-quart',
},
{
from: 0.283863193308711,
to: 0.466136806691289,
id: 'waxingGibbousMoon',
emoji: '🌔',
code: ':waxing_gibbous_moon:',
name: 'Waxing Gibbous',
weight: 6.3825,
css: 'wi-moon-wax-gibb',
},
{
from: 0.466136806691289,
to: 0.533863193308711,
id: 'fullMoon',
emoji: '🌝',
code: ':full_moon_with_face:',
name: 'Full Moon',
weight: 1,
css: 'wi-moon-full',
},
{
from: 0.533863193308711,
to: 0.716136806691289,
id: 'waningGibbousMoon',
emoji: '🌖',
code: ':waning_gibbous_moon:',
name: 'Waning Gibbous',
weight: 6.3825,
css: 'wi-moon-wan-gibb',
},
{
from: 0.716136806691289,
to: 0.783863193308711,
id: 'thirdQuarterMoon',
emoji: '🌗',
code: ':last_quarter_moon:',
name: 'third Quarter',
weight: 1,
css: 'wi-moon-third-quart',
},
{
from: 0.783863193308711,
to: 0.966136806691289,
id: 'waningCrescentMoon',
emoji: '🌘',
code: ':waning_crescent_moon:',
name: 'Waning Crescent',
weight: 6.3825,
css: 'wi-moon-wan-cres',
},
{
from: 0.966136806691289,
to: 1,
id: 'newMoon',
emoji: '🌚',
code: ':new_moon_with_face:',
name: 'New Moon',
weight: 1,
css: 'wi-moon-new',
},
];
}