bodies.json now holds, for 33 of the 38 bodies, the pole right ascension and declination and the prime meridian W of the IAU WGCCRE 2015 report (Archinal et al. 2018), with their rates and the periodic terms. They are read from NAIF's pck00011.tpc, which carries the report in a form a program can read, periodic terms and their angles included. Hyperion (chaotic), Nereid, Eris, Haumea and Makemake have no model in the report. The parser, src/app/shared/astro/rotational-elements.ts, sits beside the other source readers so the unit suite covers it. It reads data blocks only where \begindata stands alone on a line, as the kernel's own prose mentions the token mid-sentence. It reads the Fortran exponent (the Moon's -1.4D-12 d² term) and the degree-2 angles of the Mars system, where Phobos's tidal acceleration lives. NAIF numbers a small body 2 000 000 past its catalogue number, so Ceres is 2000001. Periodic terms under 0.01 degrees are left out. 0.01 degrees moves a point by 0.11 px on the largest body ever drawn (Jupiter at 641 px of radius). That drops 32 terms: - Mercury: 4 (0.0011 degrees and less) - the Moon: 8 of 13 (0.0072 and less) - Mars: 13 (0.00024 and less); its three 0.42-1.59 degree long-period terms stay - Phobos: 1 (0.0063) - Jupiter: 5 (0.0022 and less) - Europa: 1 (0.009) Kept, among others: Mimas's 44.85-degree libration, Triton's 32-degree precession, Miranda's 4.4 and Phobos's 1.14-degree libration. build.ts now checks the elements against Horizons on the real catalogue: - Every body but those five carries elements, and they do not. - The IAU day, 360 over W's rate, is within 1e-4 of Horizons' period. Measured: at most 1.8e-5 (Jupiter). Neptune gets a 0.01 ceiling: 0.89 per cent, because the report takes Karkoschka's 15.9663 h where Horizons keeps Voyager's 16.11. - The spin axis, the pole turned end for end where W runs backwards, is within 0.1 degrees of Horizons' obliquity. Measured: at most 0.058 (Venus, 177.358 against 177.3); Uranus 97.771, Pluto 119.610, Earth 23.435. Full npm run etl passes. Three mutants each fail it on the named check: - W's sign dropped: "Venus's IAU spin axis is 2.642 degrees". - Ceres looked up by catalogue number: "Body ceres has no IAU rotational elements". - W's rate read per century: "Mercury's IAU day ... 3.65e+4". Nothing is drawn from these yet. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
229 lines
14 KiB
TypeScript
229 lines
14 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, 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 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 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.
|
|
{ id: 'eris', name: 'Eris', kind: 'dwarf', horizonsCommand: '136199;', center: '500@10', sbdb: 'Eris', radiusKm: 1163 },
|
|
{ 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' },
|
|
{ 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' },
|
|
{ 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' },
|
|
{ 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.
|
|
{ id: 'hyperion', name: 'Hyperion', kind: 'moon', horizonsCommand: '607', center: '500@699', parentBodyId: 'saturn', spinsFreely: true },
|
|
{ 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);
|
|
|
|
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 mean: 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
|
|
};
|
|
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, which keeps one
|
|
// face towards the parent however long the clock runs. Not every page says so — the Moon's
|
|
// gives a rate, Titan's and Proteus's nothing. The Kepler period of the osculating orbit this
|
|
// used to take, 27.70 days for the Moon, would now turn its face five degrees an orbit away
|
|
// from the orbit it is drawn on.
|
|
const rotationPeriodHours = result.tidallyLocked || (spec.kind === 'moon' && !spec.spinsFreely)
|
|
? (360 / mean.rates.meanMotionDegPerDay) * HOURS_PER_DAY
|
|
: 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.`);
|
|
}
|
|
// 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)}.`);
|
|
}
|
|
|
|
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.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;
|
|
});
|
|
}
|