The card listed Hyperion's eccentricity under "Measured" as 0.023: the archived JPL satellite row
the orbit is drawn from gives 0.0232. JPL's current table (SAT441) gives 0.105, and Horizons'
osculating orbit ranges 0.074 to 0.132 from 1980 to 2100 (0.1099 on 2025-01-01). The row stays
the orbit: with 0.105 put into it, Hyperion is further from Horizons, not nearer (median 9.6
degrees against 7.8 over 1980-2100, as a reviewer measured), so only the card changes.
BodyRecord.measuredEccentricity carries the figure the card prints where it is not the orbit's
own; the ETL sets it for Hyperion, and buildBodyViewModel prints it. build.ts now checks every
card's eccentricity against Horizons' osculating one on 2025-01-01, within 0.03: measured at most
0.0151 (Phoebe, and the Moon, whose eccentricity swings) once Hyperion prints 0.105, where the
row put it 0.0867 out. The live Hyperion page reads "ECCENTRICITY 0.105".
Controls: the ETL with Hyperion on its row's figure fails ("Hyperion's card gives an eccentricity of
0.0232, where Horizons' osculating orbit has 0.1099"); the view model ignoring the field fails
"prints the eccentricity measured for a moon whose orbit keeps an older one".
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
259 lines
17 KiB
TypeScript
259 lines
17 KiB
TypeScript
import { writeFileSync } from 'node:fs';
|
|
|
|
import { BodyRecord, OrbitalElements } from '../../src/app/shared/models/body.model';
|
|
import { SUN_STAR_ID } from '../../src/app/shared/models/star.model';
|
|
import { fetchHorizonsBody } from './lib/horizons';
|
|
import { MeanOrbit, parsePlanetMeanElements, parseSatelliteMeanElements, parseSmallBodyElements } from '../../src/app/shared/astro/mean-elements';
|
|
import { fetchPlanetMeanElementsText, fetchSatelliteMeanElementsHtml, fetchSmallBodyAnswer } from './lib/mean-elements';
|
|
import { MIN_PERIODIC_TERM_DEG, orbitalTermsOfPrimeMeridian, parsePckRotationalElements } from '../../src/app/shared/astro/rotational-elements';
|
|
import { fetchPckText } from './lib/pck';
|
|
import { dataPath, ensureDataDir } from './lib/paths';
|
|
|
|
const HOURS_PER_DAY = 24;
|
|
|
|
interface BodySpec {
|
|
id: string;
|
|
name: string;
|
|
kind: BodyRecord['kind'];
|
|
horizonsCommand: string;
|
|
center: string;
|
|
parentBodyId?: string;
|
|
/**
|
|
* Obliquity to orbit, in degrees, where the Horizons page states none. Pluto's is from the IAU
|
|
* WGCCRE 2015 pole (RA 132.99, Dec -6.16), 119.6 degrees: past 90, so it turns retrograde.
|
|
*/
|
|
obliquityDeg?: number;
|
|
/** The periapsis turns backwards; see `parseSatelliteMeanElements`. */
|
|
apsidesRegress?: boolean;
|
|
/** The pole of the planet's equator, where JPL gives its moons against that plane. */
|
|
equatorPole?: { raDeg: number; decDeg: number };
|
|
/** The Small-Body Database's name for a dwarf planet past Standish's tables: its orbit comes from there. */
|
|
sbdb?: string;
|
|
/** A measured mean radius, in km, for a body neither Horizons nor the SBDB gives one for. */
|
|
radiusKm?: number;
|
|
/** A measured sidereal day, in hours, where a later measurement overturns the one its source gives. */
|
|
rotationPeriodHours?: number;
|
|
/** The eccentricity for the card, where the row the orbit is drawn from gives an outdated one; see `BodyRecord.measuredEccentricity`. */
|
|
measuredEccentricity?: number;
|
|
/** A moon that does not keep one face to its planet: its page's own spin, or none, is kept. */
|
|
spinsFreely?: boolean;
|
|
/** A moon heavy enough to move its planet round their barycentre visibly; see `BodyRecord.massRatio`. */
|
|
barycentric?: boolean;
|
|
/**
|
|
* Corrections to a row of the satellite table, each where the row disagrees with JPL's own
|
|
* Horizons ephemeris and the reason is known; see the specs that carry them.
|
|
*/
|
|
nodeOffsetDeg?: number;
|
|
epochJd?: number;
|
|
periodDays?: number;
|
|
/**
|
|
* The terms of the IAU's W that are this locked moon's motion along its orbit, which its row has
|
|
* no column for: W's quadratic, and the term whose angle turns at `angleRateDegPerCentury`, if
|
|
* given. See `orbitalTermsOfPrimeMeridian`.
|
|
*/
|
|
orbitFromW?: { angleRateDegPerCentury?: number };
|
|
}
|
|
|
|
/** S5 in pck00011.tpc, 316.45 + 506.2 T: the libration of Mimas and Tethys in their 4:2 resonance. */
|
|
const MIMAS_TETHYS_LIBRATION = { angleRateDegPerCentury: 506.2 };
|
|
|
|
/**
|
|
* The poles of the equators JPL refers Uranus's and Pluto's moons to, from the IAU WGCCRE 2015
|
|
* report, each taken at the end the table's inclinations are measured from (Titania 0.079
|
|
* degrees, Charon 0.080): the end the moons go round anticlockwise. For Pluto that is the pole
|
|
* the IAU gives, 132.993 / -6.163, which for dwarf planets follows the right-hand rule. For
|
|
* Uranus the IAU gives the other end, 257.311 / -15.175, named north because it lies on the
|
|
* ecliptic's north side; the table measures inclinations from 77.311 / 15.175 but counts its
|
|
* nodes from where the equator rises through the ICRF equator going round the IAU's pole, which
|
|
* is 180 degrees from where it rises going round this one: hence Uranus's moons' 180-degree node
|
|
* offset. Read with this pole and no offset, Ariel was 180 degrees from Horizons at every date
|
|
* from 1980 to 2100; read against the IAU's pole, anywhere from 1 to 179.
|
|
*/
|
|
const URANUS_EQUATOR_POLE = { raDeg: 77.311, decDeg: 15.175 };
|
|
const PLUTO_EQUATOR_POLE = { raDeg: 132.993, decDeg: -6.163 };
|
|
const URANUS_MOON = { kind: 'moon', center: '500@799', parentBodyId: 'uranus', equatorPole: URANUS_EQUATOR_POLE, nodeOffsetDeg: 180 } as const;
|
|
|
|
// Sun-centered planets/dwarf, then their major moons (planetocentric elements).
|
|
const BODY_SPECS: BodySpec[] = [
|
|
{ id: 'mercury', name: 'Mercury', kind: 'planet', horizonsCommand: '199', center: '500@10' },
|
|
{ id: 'venus', name: 'Venus', kind: 'planet', horizonsCommand: '299', center: '500@10' },
|
|
{ id: 'earth', name: 'Earth', kind: 'planet', horizonsCommand: '399', center: '500@10' },
|
|
{ id: 'mars', name: 'Mars', kind: 'planet', horizonsCommand: '499', center: '500@10' },
|
|
{ id: 'jupiter', name: 'Jupiter', kind: 'planet', horizonsCommand: '599', center: '500@10' },
|
|
{ id: 'saturn', name: 'Saturn', kind: 'planet', horizonsCommand: '699', center: '500@10' },
|
|
{ id: 'uranus', name: 'Uranus', kind: 'planet', horizonsCommand: '799', center: '500@10' },
|
|
{ id: 'neptune', name: 'Neptune', kind: 'planet', horizonsCommand: '899', center: '500@10' },
|
|
{ id: 'pluto', name: 'Pluto', kind: 'dwarf', horizonsCommand: '999', center: '500@10', obliquityDeg: 119.6 },
|
|
{ id: 'ceres', name: 'Ceres', kind: 'dwarf', horizonsCommand: '1;', center: '500@10', sbdb: 'Ceres' },
|
|
// Eris, Haumea and Makemake have no radius in the SBDB, the Horizons pages ("RAD= n.a.") or the
|
|
// IAU WGCCRE 2015 report, so each carries its stellar-occultation measurement. Eris: 1163 km,
|
|
// Sicardy et al. 2011 (Nature 478, 493). Haumea is triaxial, 1161 x 852 x 513 km, Ortiz et al.
|
|
// 2017 (Nature 550, 219); drawn as a sphere, at the radius of the sphere of the same volume.
|
|
// Makemake: 1434 km across its equator and 1422 across its projected pole, Brown 2013 (ApJ 767,
|
|
// L7); the same mean.
|
|
//
|
|
// Eris's day is not the SBDB's 25.9 hours, a light curve of partial coverage (Roe et al. 2008) the
|
|
// SBDB itself flags as "may be wrong by 30 percent or so": it turns once in 15.771 +/- 0.008 days
|
|
// (Bernstein et al. 2023, PSJ 4, 115), locked to Dysnomia's 15.786-day orbit (Szakáts et al.
|
|
// 2023, A&A 669, L3). On the SBDB's figure it turned 14.6 times too fast.
|
|
{ id: 'eris', name: 'Eris', kind: 'dwarf', horizonsCommand: '136199;', center: '500@10', sbdb: 'Eris', radiusKm: 1163, rotationPeriodHours: 15.771 * 24 },
|
|
{ id: 'haumea', name: 'Haumea', kind: 'dwarf', horizonsCommand: '136108;', center: '500@10', sbdb: 'Haumea', radiusKm: 797.6 },
|
|
{ id: 'makemake', name: 'Makemake', kind: 'dwarf', horizonsCommand: '136472;', center: '500@10', sbdb: 'Makemake', radiusKm: 715 },
|
|
{ id: 'moon', name: 'Moon', kind: 'moon', horizonsCommand: '301', center: '500@399', parentBodyId: 'earth' },
|
|
{ id: 'phobos', name: 'Phobos', kind: 'moon', horizonsCommand: '401', center: '500@499', parentBodyId: 'mars', orbitFromW: {} },
|
|
{ id: 'deimos', name: 'Deimos', kind: 'moon', horizonsCommand: '402', center: '500@499', parentBodyId: 'mars' },
|
|
{ id: 'io', name: 'Io', kind: 'moon', horizonsCommand: '501', center: '500@599', parentBodyId: 'jupiter', apsidesRegress: true },
|
|
{ id: 'europa', name: 'Europa', kind: 'moon', horizonsCommand: '502', center: '500@599', parentBodyId: 'jupiter', apsidesRegress: true },
|
|
{ id: 'ganymede', name: 'Ganymede', kind: 'moon', horizonsCommand: '503', center: '500@599', parentBodyId: 'jupiter' },
|
|
{ id: 'callisto', name: 'Callisto', kind: 'moon', horizonsCommand: '504', center: '500@599', parentBodyId: 'jupiter' },
|
|
{ id: 'mimas', name: 'Mimas', kind: 'moon', horizonsCommand: '601', center: '500@699', parentBodyId: 'saturn', orbitFromW: MIMAS_TETHYS_LIBRATION },
|
|
{ id: 'enceladus', name: 'Enceladus', kind: 'moon', horizonsCommand: '602', center: '500@699', parentBodyId: 'saturn' },
|
|
{ id: 'tethys', name: 'Tethys', kind: 'moon', horizonsCommand: '603', center: '500@699', parentBodyId: 'saturn', orbitFromW: MIMAS_TETHYS_LIBRATION },
|
|
{ id: 'dione', name: 'Dione', kind: 'moon', horizonsCommand: '604', center: '500@699', parentBodyId: 'saturn' },
|
|
{ id: 'rhea', name: 'Rhea', kind: 'moon', horizonsCommand: '605', center: '500@699', parentBodyId: 'saturn' },
|
|
{ id: 'titan', name: 'Titan', kind: 'moon', horizonsCommand: '606', center: '500@699', parentBodyId: 'saturn' },
|
|
// Hyperion tumbles ("Rotational period = Chaotic") and Phoebe, captured, turns in 9.27 hours.
|
|
// Hyperion's eccentricity is 0.105 in JPL's current table (ssd.jpl.nasa.gov/sats/elem, SAT441).
|
|
{ id: 'hyperion', name: 'Hyperion', kind: 'moon', horizonsCommand: '607', center: '500@699', parentBodyId: 'saturn', spinsFreely: true, measuredEccentricity: 0.105 },
|
|
{ id: 'iapetus', name: 'Iapetus', kind: 'moon', horizonsCommand: '608', center: '500@699', parentBodyId: 'saturn' },
|
|
// Phoebe's row gives a mean motion of 0.6569114 degrees a day, a 548.02-day year, where its
|
|
// Horizons page and JPL's current table (SAT441) give 550.30: the table's own note warns that
|
|
// its source misstated the mean motions of retrograde moons. On the row's figure Phoebe was
|
|
// 25 degrees from Horizons by 2025 and 100 by 2075; on the current period, within 2.0 from 1980
|
|
// to 2100.
|
|
{ id: 'phoebe', name: 'Phoebe', kind: 'moon', horizonsCommand: '609', center: '500@699', parentBodyId: 'saturn', spinsFreely: true, periodDays: 550.30391 },
|
|
{ id: 'miranda', name: 'Miranda', horizonsCommand: '705', ...URANUS_MOON },
|
|
{ id: 'ariel', name: 'Ariel', horizonsCommand: '701', ...URANUS_MOON },
|
|
{ id: 'umbriel', name: 'Umbriel', horizonsCommand: '702', ...URANUS_MOON },
|
|
{ id: 'titania', name: 'Titania', horizonsCommand: '703', ...URANUS_MOON },
|
|
{ id: 'oberon', name: 'Oberon', horizonsCommand: '704', ...URANUS_MOON },
|
|
{ id: 'triton', name: 'Triton', kind: 'moon', horizonsCommand: '801', center: '500@899', parentBodyId: 'neptune' },
|
|
// Nereid's eccentric orbit, 0.75, cannot hold a face to Neptune; its page states no spin.
|
|
{ id: 'nereid', name: 'Nereid', kind: 'moon', horizonsCommand: '802', center: '500@899', parentBodyId: 'neptune', spinsFreely: true },
|
|
{ id: 'proteus', name: 'Proteus', kind: 'moon', horizonsCommand: '808', center: '500@899', parentBodyId: 'neptune' },
|
|
// Pluto's section prints its epoch as 2000 Jan 1.0; JPL's current table gives Charon's as
|
|
// 2000-01-01.5, and read at 1.0 Charon sat 27.8 to 28.2 degrees — half a day of its motion is
|
|
// 28.2 — from Horizons at every date from 1980 to 2100. At 1.5 it is within 0.4.
|
|
{ id: 'charon', name: 'Charon', kind: 'moon', horizonsCommand: '901', center: '500@999', parentBodyId: 'pluto', equatorPole: PLUTO_EQUATOR_POLE, epochJd: 2451545.0, barycentric: true }
|
|
];
|
|
|
|
/** The moons whose day is not their orbit; `build.ts` holds every other moon to its lock. */
|
|
export const FREELY_SPINNING_MOONS = new Set(BODY_SPECS.filter((spec) => spec.spinsFreely).map((spec) => spec.id));
|
|
|
|
/**
|
|
* Writes `bodies.json` for the major planets, the five dwarf planets, and every moon in JPL's
|
|
* mean-element table more than 100 km in mean radius — Phoebe, at 106.6, the smallest: JPL's
|
|
* mean orbital elements for where they go, or the SBDB's osculating ones where there are none,
|
|
* JPL Horizons for their size and spin, and the IAU's rotational elements for where their poles
|
|
* point and which face is where. Horizons' osculating elements for the same date come back
|
|
* alongside, for `build.ts` to check the mean ones against.
|
|
*/
|
|
export async function fetchSolarSystem(): Promise<{ bodies: BodyRecord[]; horizonsOrbits: Map<string, OrbitalElements> }> {
|
|
console.log(`Fetching ${BODY_SPECS.length} solar-system bodies from JPL (mean elements, Horizons, NAIF's PCK)...`);
|
|
const bodies: BodyRecord[] = [];
|
|
const horizonsOrbits = new Map<string, OrbitalElements>();
|
|
const planetElements = await fetchPlanetMeanElementsText();
|
|
const satelliteElements = await fetchSatelliteMeanElementsHtml();
|
|
const pck = await fetchPckText();
|
|
const gmById = new Map<string, number | undefined>();
|
|
|
|
for (const spec of BODY_SPECS) {
|
|
const result = await fetchHorizonsBody({
|
|
command: spec.horizonsCommand,
|
|
center: spec.center,
|
|
cacheKey: `horizons-${spec.id}.txt`
|
|
});
|
|
|
|
horizonsOrbits.set(spec.id, result.orbit);
|
|
gmById.set(spec.id, result.gmKm3PerS2);
|
|
|
|
// NAIF numbers a small body 2 000 000 past its catalogue number: Ceres, "1;" to Horizons, is 2000001.
|
|
const naifId = spec.horizonsCommand.endsWith(';') ? 2_000_000 + Number.parseInt(spec.horizonsCommand, 10) : Number(spec.horizonsCommand);
|
|
const rotation = parsePckRotationalElements(pck, naifId);
|
|
if (!rotation) {
|
|
console.warn(` no IAU rotational elements for ${spec.name}; its pole and meridian are not known.`);
|
|
} else if (rotation.skippedDeg.length > 0) {
|
|
console.log(` ${spec.name}: ${rotation.skippedDeg.length} periodic terms under ${MIN_PERIODIC_TERM_DEG} degrees left out, the largest ${Math.max(...rotation.skippedDeg)}.`);
|
|
}
|
|
|
|
const parentName = BODY_SPECS.find((candidate) => candidate.id === spec.parentBodyId)?.name;
|
|
const smallBody = spec.sbdb ? parseSmallBodyElements(await fetchSmallBodyAnswer(spec.sbdb, `sbdb-${spec.id}.json`)) : undefined;
|
|
const read: MeanOrbit =
|
|
smallBody ??
|
|
(parentName
|
|
? parseSatelliteMeanElements(satelliteElements, parentName, spec.name, spec.apsidesRegress ?? false, spec.equatorPole)
|
|
: parsePlanetMeanElements(planetElements, spec.id));
|
|
const corrected: MeanOrbit = {
|
|
...read,
|
|
orbit: {
|
|
...read.orbit,
|
|
longitudeOfAscendingNodeDeg: read.orbit.longitudeOfAscendingNodeDeg + (spec.nodeOffsetDeg ?? 0),
|
|
epochJd: spec.epochJd ?? read.orbit.epochJd
|
|
},
|
|
rates: spec.periodDays ? { ...read.rates, meanMotionDegPerDay: 360 / spec.periodDays } : read.rates
|
|
};
|
|
if (spec.orbitFromW && !rotation) {
|
|
throw new Error(`${spec.name}'s orbit takes terms from a W the kernel does not give.`);
|
|
}
|
|
const fromW = spec.orbitFromW && orbitalTermsOfPrimeMeridian(rotation!.elements, corrected.orbit.epochJd, spec.orbitFromW.angleRateDegPerCentury);
|
|
const mean: MeanOrbit = fromW
|
|
? {
|
|
...corrected,
|
|
orbit: { ...corrected.orbit, meanAnomalyAtEpochDeg: corrected.orbit.meanAnomalyAtEpochDeg + fromW.meanAnomalyDeg },
|
|
rates: { ...corrected.rates, meanMotionDegPerDay: corrected.rates.meanMotionDegPerDay + fromW.meanMotionDegPerDay, meanAnomalyTerms: fromW.meanAnomalyTerms }
|
|
}
|
|
: corrected;
|
|
const radiusKm = smallBody?.radiusKm ?? spec.radiusKm ?? result.radiusKm;
|
|
if (radiusKm === undefined) {
|
|
console.warn(` no physical radius found for ${spec.name}; defaulting to 0.`);
|
|
}
|
|
|
|
// A moon listed here is tidally locked unless its spec says otherwise, so its day is its
|
|
// orbit: the sidereal period from the same mean motion that carries it round. Not every page
|
|
// says so — the Moon's gives a rate, Titan's and Proteus's nothing. Every locked moon here is
|
|
// turned by its IAU W rather than by this day, and `build.ts` checks that W and the orbit keep
|
|
// its face to its planet from 1950 to 2100; this day is what the renderer would turn a moon
|
|
// without W by.
|
|
const rotationPeriodHours = result.tidallyLocked || (spec.kind === 'moon' && !spec.spinsFreely)
|
|
? (360 / mean.rates.meanMotionDegPerDay) * HOURS_PER_DAY
|
|
: (spec.rotationPeriodHours ?? (smallBody ? smallBody.rotationPeriodHours : result.rotationPeriodHours));
|
|
const parentGm = spec.barycentric && spec.parentBodyId ? gmById.get(spec.parentBodyId) : undefined;
|
|
if (spec.barycentric && (result.gmKm3PerS2 === undefined || parentGm === undefined)) {
|
|
throw new Error(`${spec.name} and its planet need a GM each to place their barycentre.`);
|
|
}
|
|
if (rotationPeriodHours === undefined) {
|
|
console.warn(` no rotation period found for ${spec.name}; it will not turn.`);
|
|
}
|
|
|
|
bodies.push({
|
|
id: spec.id,
|
|
systemStarId: SUN_STAR_ID,
|
|
name: spec.name,
|
|
kind: spec.kind,
|
|
radiusKm: radiusKm ?? 0,
|
|
orbit: mean.orbit,
|
|
rates: mean.rates,
|
|
...(mean.laplacePole ? { laplacePole: mean.laplacePole } : {}),
|
|
orbitSource: mean.orbitSource,
|
|
...(spec.measuredEccentricity !== undefined ? { measuredEccentricity: spec.measuredEccentricity } : {}),
|
|
...(spec.parentBodyId ? { parentBodyId: spec.parentBodyId } : {}),
|
|
...(parentGm !== undefined ? { massRatio: result.gmKm3PerS2! / parentGm } : {}),
|
|
...(rotationPeriodHours !== undefined ? { rotationPeriodHours } : {}),
|
|
...((result.obliquityDeg ?? spec.obliquityDeg) !== undefined ? { obliquityDeg: result.obliquityDeg ?? spec.obliquityDeg } : {}),
|
|
...(rotation ? { rotationalElements: rotation.elements } : {})
|
|
});
|
|
}
|
|
|
|
ensureDataDir();
|
|
writeFileSync(dataPath('bodies.json'), JSON.stringify(bodies, null, 2));
|
|
console.log(` wrote ${bodies.length} bodies.`);
|
|
return { bodies, horizonsOrbits };
|
|
}
|
|
|
|
if (require.main === module) {
|
|
fetchSolarSystem().catch((error) => {
|
|
console.error(error);
|
|
process.exitCode = 1;
|
|
});
|
|
}
|