Carry every body's IAU rotational elements, read from NAIF's kernel of the 2015 report
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>
This commit is contained in:
+65
-1
@@ -1,8 +1,9 @@
|
||||
import { statSync } from 'node:fs';
|
||||
|
||||
import { BodyRecord, OrbitalElements } from '../../src/app/shared/models/body.model';
|
||||
import { eclipticToEquatorial, laplacePlaneToEquatorial } from '../../src/app/shared/astro/coordinates';
|
||||
import { eclipticToEquatorial, laplacePlaneToEquatorial, raDecToUnitVector } from '../../src/app/shared/astro/coordinates';
|
||||
import { meanElementsAt, positionAtEpoch } from '../../src/app/shared/astro/kepler';
|
||||
import { orientationAt } from '../../src/app/shared/astro/rotational-elements';
|
||||
import { DeepSkyRecord } from '../../src/app/shared/models/deepsky.model';
|
||||
import { ExoplanetRecord } from '../../src/app/shared/models/exoplanet.model';
|
||||
import { StarRecord, SUN_STAR_ID } from '../../src/app/shared/models/star.model';
|
||||
@@ -146,6 +147,7 @@ function validateMerge(stars: StarRecord[]): void {
|
||||
const MAX_PLANET_OFFSET_DEG = 0.25;
|
||||
const MAX_MOON_OFFSET_DEG = 2.5;
|
||||
const KM_PER_AU = 149597870.7;
|
||||
const DEG_TO_RAD = Math.PI / 180;
|
||||
|
||||
/**
|
||||
* The moons whose table row cannot come within that, each for a reason no mean ellipse carries,
|
||||
@@ -163,6 +165,33 @@ const KM_PER_AU = 149597870.7;
|
||||
*/
|
||||
const MOON_OFFSET_CEILINGS_DEG: Record<string, number> = { mimas: 46, hyperion: 21, iapetus: 11, nereid: 3 };
|
||||
|
||||
/**
|
||||
* The bodies the IAU WGCCRE 2015 report gives no rotational elements for: Hyperion tumbles, and
|
||||
* Nereid, Eris, Haumea and Makemake have no model. Every other body must carry them, or the
|
||||
* kernel was read wrongly and the body would be drawn on an invented pole.
|
||||
*/
|
||||
const WITHOUT_ROTATIONAL_ELEMENTS = new Set(['hyperion', 'nereid', 'eris', 'haumea', 'makemake']);
|
||||
|
||||
/**
|
||||
* How far the IAU's day, 360 degrees over W's rate, may be from the one Horizons states, as a
|
||||
* fraction of it. Measured on this catalogue: at most 1.8e-5 (Jupiter's System III, 9.92492 hours
|
||||
* against 9.92510). Neptune is 0.89 per cent out, because the report takes 15.9663 hours from the
|
||||
* cloud features Karkoschka (2011) tracked, where Horizons keeps Voyager's radio period, 16.11. What
|
||||
* this catches is a rate read in the wrong unit or for the wrong body: Oberon's day for Titania's is
|
||||
* 55 per cent out.
|
||||
*/
|
||||
const MAX_DAY_OFFSET = 1e-4;
|
||||
const DAY_OFFSET_CEILINGS: Record<string, number> = { neptune: 0.01 };
|
||||
|
||||
/**
|
||||
* How far the tilt of the IAU's spin axis from the orbit may be from the obliquity Horizons
|
||||
* states. The axis is the IAU's pole, turned end for end where W runs backwards: the report names
|
||||
* a planet's north pole by the side of the solar system it lies on, whichever way the planet turns.
|
||||
* Measured on this catalogue: at most 0.058 degrees (Venus, 177.358 against 177.3). Taken as the
|
||||
* pole alone, Venus comes out at 2.6 degrees and Uranus at 82.2, which is what this catches.
|
||||
*/
|
||||
const MAX_OBLIQUITY_OFFSET_DEG = 0.1;
|
||||
|
||||
function angleBetweenDeg(a: { x: number; y: number; z: number }, b: { x: number; y: number; z: number }): number {
|
||||
const cosine = (a.x * b.x + a.y * b.y + a.z * b.z) / (Math.hypot(a.x, a.y, a.z) * Math.hypot(b.x, b.y, b.z));
|
||||
return (Math.acos(Math.min(1, Math.max(-1, cosine))) * 180) / Math.PI;
|
||||
@@ -175,6 +204,7 @@ function validateBodies(bodies: BodyRecord[], horizonsOrbits: Map<string, Orbita
|
||||
assertCondition(ids.size === bodies.length, 'Duplicate body ids were found.');
|
||||
|
||||
const offsets: string[] = [];
|
||||
const spins: string[] = [];
|
||||
for (const body of bodies) {
|
||||
const orbitValues = Object.values(body.orbit);
|
||||
assertCondition(orbitValues.every(Number.isFinite), `Body ${body.id} has non-finite orbital elements.`);
|
||||
@@ -196,6 +226,39 @@ function validateBodies(bodies: BodyRecord[], horizonsOrbits: Map<string, Orbita
|
||||
// A radius of 0 is what a page whose radius no pattern reads comes out as — Charon's did.
|
||||
assertCondition(body.radiusKm > 0, `Body ${body.id} has no radius; its page states it in a form the ETL does not read.`);
|
||||
|
||||
const rotation = body.rotationalElements;
|
||||
assertCondition(
|
||||
(rotation === undefined) === WITHOUT_ROTATIONAL_ELEMENTS.has(body.id),
|
||||
`Body ${body.id} ${rotation ? 'has' : 'has no'} IAU rotational elements, which the report ${rotation ? 'does not give' : 'gives'} for it.`
|
||||
);
|
||||
if (rotation) {
|
||||
const rate = rotation.primeMeridianDeg[1];
|
||||
if (body.rotationPeriodHours !== undefined) {
|
||||
const dayOffset = Math.abs(((360 / Math.abs(rate)) * 24) / Math.abs(body.rotationPeriodHours) - 1);
|
||||
const dayCeiling = DAY_OFFSET_CEILINGS[body.id] ?? MAX_DAY_OFFSET;
|
||||
assertCondition(
|
||||
dayOffset <= dayCeiling,
|
||||
`${body.name}'s IAU day, ${((360 / Math.abs(rate)) * 24).toFixed(5)} hours, is ${dayOffset.toExponential(2)} of its length from Horizons' ${Math.abs(body.rotationPeriodHours).toFixed(5)} (at most ${dayCeiling} expected).`
|
||||
);
|
||||
spins.push(`${body.id} day ${dayOffset.toExponential(1)}`);
|
||||
}
|
||||
if (body.obliquityDeg !== undefined) {
|
||||
const pole = orientationAt(rotation, horizons!.epochJd);
|
||||
const pointing = raDecToUnitVector(pole.poleRaDeg / 15, pole.poleDecDeg);
|
||||
const axis = { x: Math.sign(rate) * pointing.x, y: Math.sign(rate) * pointing.y, z: Math.sign(rate) * pointing.z };
|
||||
const { inclinationDeg, longitudeOfAscendingNodeDeg } = meanElementsAt(body.orbit, body.rates, horizons!.epochJd);
|
||||
const tilt = inclinationDeg * DEG_TO_RAD;
|
||||
const node = longitudeOfAscendingNodeDeg * DEG_TO_RAD;
|
||||
const normal = { x: Math.sin(tilt) * Math.sin(node), y: -Math.sin(tilt) * Math.cos(node), z: Math.cos(tilt) };
|
||||
const obliquity = angleBetweenDeg(axis, body.laplacePole ? laplacePlaneToEquatorial(normal, body.laplacePole) : eclipticToEquatorial(normal));
|
||||
assertCondition(
|
||||
Math.abs(obliquity - body.obliquityDeg) <= MAX_OBLIQUITY_OFFSET_DEG,
|
||||
`${body.name}'s IAU spin axis is ${obliquity.toFixed(3)} degrees from its orbit's pole, where Horizons gives an obliquity of ${body.obliquityDeg} (at most ${MAX_OBLIQUITY_OFFSET_DEG} apart expected) — the pole or the sense of W was read wrongly.`
|
||||
);
|
||||
spins.push(`${body.id} tilt ${obliquity.toFixed(3)}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (body.kind === 'moon') {
|
||||
const parent = bodies.find((candidate) => candidate.id === body.parentBodyId);
|
||||
assertCondition(parent !== undefined, `Moon ${body.id} has no valid parentBodyId.`);
|
||||
@@ -233,6 +296,7 @@ function validateBodies(bodies: BodyRecord[], horizonsOrbits: Map<string, Orbita
|
||||
const dwarfCount = bodies.filter((body) => body.kind === 'dwarf').length;
|
||||
assertCondition(dwarfCount === 5, `Expected the IAU's 5 dwarf planets, found ${dwarfCount}.`);
|
||||
console.log(` mean elements against Horizons, degrees: ${offsets.join(', ')}.`);
|
||||
console.log(` IAU rotation against Horizons (day as a fraction of it, tilt in degrees): ${spins.join(', ')}.`);
|
||||
}
|
||||
|
||||
function validateExoplanets(exoplanets: ExoplanetRecord[], starIds: Set<number>): void {
|
||||
|
||||
@@ -5,6 +5,8 @@ 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;
|
||||
@@ -123,15 +125,17 @@ export const FREELY_SPINNING_MOONS = new Set(BODY_SPECS.filter((spec) => spec.sp
|
||||
* 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.
|
||||
* 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)...`);
|
||||
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) {
|
||||
@@ -183,6 +187,14 @@ export async function fetchSolarSystem(): Promise<{ bodies: BodyRecord[]; horizo
|
||||
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,
|
||||
@@ -197,7 +209,8 @@ export async function fetchSolarSystem(): Promise<{ bodies: BodyRecord[]; horizo
|
||||
...(spec.parentBodyId ? { parentBodyId: spec.parentBodyId } : {}),
|
||||
...(parentGm !== undefined ? { massRatio: result.gmKm3PerS2! / parentGm } : {}),
|
||||
...(rotationPeriodHours !== undefined ? { rotationPeriodHours } : {}),
|
||||
...((result.obliquityDeg ?? spec.obliquityDeg) !== undefined ? { obliquityDeg: result.obliquityDeg ?? spec.obliquityDeg } : {})
|
||||
...((result.obliquityDeg ?? spec.obliquityDeg) !== undefined ? { obliquityDeg: result.obliquityDeg ?? spec.obliquityDeg } : {}),
|
||||
...(rotation ? { rotationalElements: rotation.elements } : {})
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
import { fetchTextCached } from './http';
|
||||
|
||||
/**
|
||||
* NAIF's generic text PCK, which carries the IAU WGCCRE 2015 report's rotational elements
|
||||
* (Archinal et al. 2018, Celest Mech Dyn Astr 130:22) for every body here that has them, periodic
|
||||
* terms included, in a form a program can read rather than a table typeset in a paper. A released
|
||||
* kernel is never edited, only superseded under a new name, so the URL pins the numbers.
|
||||
*/
|
||||
const PCK_URL = 'https://naif.jpl.nasa.gov/pub/naif/generic_kernels/pck/pck00011.tpc';
|
||||
|
||||
export async function fetchPckText(): Promise<string> {
|
||||
return fetchTextCached(PCK_URL, 'naif-pck00011.tpc');
|
||||
}
|
||||
Reference in New Issue
Block a user