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 { 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, * and JPL Horizons for their size and spin. 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 }> { console.log(`Fetching ${BODY_SPECS.length} solar-system bodies from JPL (mean elements, Horizons)...`); const bodies: BodyRecord[] = []; const horizonsOrbits = new Map(); const planetElements = await fetchPlanetMeanElementsText(); const satelliteElements = await fetchSatelliteMeanElementsHtml(); const gmById = new Map(); 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.`); } 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 } : {}) }); } 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; }); }