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:
2026-09-24 22:46:14 +02:00
co-authored by Claude Opus 5.5
parent ab7d454db1
commit 1c86584642
7 changed files with 1531 additions and 38 deletions
@@ -0,0 +1,133 @@
import { describe, expect, it } from 'vitest';
import { orientationAt, parsePckRotationalElements } from './rotational-elements';
// Excerpts of pck00011.tpc as NAIF publishes it: prose, then data blocks.
const KERNEL = String.raw`KPL/PCK
The portion of the file preceding the first data block is treated
as a comment.
\begindata
BODY399_POLE_RA = ( 0. -0.641 0. )
BODY399_POLE_DEC = ( 90. -0.557 0. )
BODY399_PM = ( 190.147 360.9856235 0. )
\begintext
A data block starts with the \begindata token only when that token
sits on a line by itself, so BODY399_PM = ( 1 2 3 ) here is prose.
\begindata
BODY301_POLE_RA = ( 269.9949 0.0031 0. )
BODY301_POLE_DEC = ( 66.5392 0.0130 0. )
BODY301_PM = ( 38.3213 13.17635815 -1.4D-12 )
BODY899_POLE_RA = ( 299.36 0. 0. )
BODY899_POLE_DEC = ( 43.46 0. 0. )
BODY899_PM = ( 249.978 541.1397757 0. )
BODY899_NUT_PREC_RA = ( 0.70 0. 0. 0. 0. 0. 0. 0. )
BODY899_NUT_PREC_DEC = ( -0.51 0. 0. 0. 0. 0. 0. 0. )
BODY899_NUT_PREC_PM = ( -0.48 0. 0. 0. 0. 0. 0. 0. )
BODY8_NUT_PREC_ANGLES = ( 357.85 52.316
323.92 62606.6 )
BODY199_POLE_RA = ( 281.0103 -0.0328 0. )
BODY199_POLE_DEC = ( 61.4155 -0.0049 0. )
BODY199_PM = ( 329.5988 6.1385108 0. )
BODY199_NUT_PREC_RA = ( 0. 0. )
BODY199_NUT_PREC_DEC = ( 0. 0. )
BODY199_NUT_PREC_PM = ( 0.01067257
-0.00112309 )
BODY1_NUT_PREC_ANGLES = ( 174.7910857 0.14947253587500003E+06
349.5821714 0.29894507175000006E+06 )
BODY401_POLE_RA = ( 317.67071657 -0.10844326 0. )
BODY401_POLE_DEC = ( 52.88627266 -0.06134706 0. )
BODY401_PM = ( 35.18774440 1128.84475928
9.536137031212154e-09 )
BODY401_NUT_PREC_RA = ( -1.78428399 )
BODY401_NUT_PREC_DEC = ( -1.07516537 )
BODY401_NUT_PREC_PM = ( 1.42421769
-1.143 )
BODY4_MAX_PHASE_DEGREE = 2
BODY4_NUT_PREC_ANGLES = (
190.72646643 15917.10818695 0
189.63271560 41215158.18420050 12.711923222 )
\begintext
`;
describe('parsePckRotationalElements', () => {
it('reads a pole and a prime meridian from the data blocks, not from the prose around them', () => {
expect(parsePckRotationalElements(KERNEL, 399)).toEqual({
elements: { poleRaDeg: [0, -0.641, 0], poleDecDeg: [90, -0.557, 0], primeMeridianDeg: [190.147, 360.9856235, 0] },
skippedDeg: []
});
});
it('reads the exponent the Fortran way, as the Moon’s quadratic is written', () => {
expect(parsePckRotationalElements(KERNEL, 301)!.elements.primeMeridianDeg).toEqual([38.3213, 13.17635815, -1.4e-12]);
});
it('pairs each periodic term with its system’s angle', () => {
expect(parsePckRotationalElements(KERNEL, 899)!.elements.terms).toEqual([{ angleDeg: [357.85, 52.316], ra: 0.7, dec: -0.51, pm: -0.48 }]);
});
it('reads angles to the degree the system states, Phobos’s quadratic among them', () => {
expect(parsePckRotationalElements(KERNEL, 401)!.elements.terms).toEqual([
{ angleDeg: [190.72646643, 15917.10818695, 0], ra: -1.78428399, dec: -1.07516537, pm: 1.42421769 },
{ angleDeg: [189.6327156, 41215158.1842005, 12.711923222], ra: 0, dec: 0, pm: -1.143 }
]);
});
it('leaves out a term under a hundredth of a degree, and says how large it was', () => {
const mercury = parsePckRotationalElements(KERNEL, 199)!;
expect(mercury.elements.terms).toEqual([{ angleDeg: [174.7910857, 149472.53587500003], ra: 0, dec: 0, pm: 0.01067257 }]);
expect(mercury.skippedDeg).toEqual([0.00112309]);
});
it('gives nothing for a body the kernel has no model for', () => {
expect(parsePckRotationalElements(KERNEL, 802)).toBeUndefined();
});
});
describe('orientationAt', () => {
const J2000 = 2451545.0;
it('turns the prime meridian at its rate per day and moves the pole at its rate per century', () => {
const earth = parsePckRotationalElements(KERNEL, 399)!.elements;
const epoch = orientationAt(earth, J2000);
expect([epoch.poleRaDeg, epoch.poleDecDeg]).toEqual([0, 90]);
expect(epoch.primeMeridianDeg).toBeCloseTo(190.147, 9);
const century = orientationAt(earth, J2000 + 36525);
expect(century.poleRaDeg).toBeCloseTo(-0.641, 12);
expect(century.poleDecDeg).toBeCloseTo(90 - 0.557, 12);
expect(orientationAt(earth, J2000 + 1).primeMeridianDeg).toBeCloseTo(190.147 + 360.9856235 - 360, 9);
});
it('adds a term as a sine to the right ascension and the meridian and a cosine to the declination', () => {
const neptune = parsePckRotationalElements(KERNEL, 899)!.elements;
const days = 9000;
const angle = ((357.85 + (52.316 * days) / 36525) * Math.PI) / 180;
const drawn = orientationAt(neptune, J2000 + days);
expect(drawn.poleRaDeg).toBeCloseTo(299.36 + 0.7 * Math.sin(angle), 12);
expect(drawn.poleDecDeg).toBeCloseTo(43.46 - 0.51 * Math.cos(angle), 12);
expect(drawn.primeMeridianDeg).toBeCloseTo((249.978 + 541.1397757 * days - 0.48 * Math.sin(angle)) % 360, 6);
});
it('carries the quadratic in the meridian and in the angle, which is how Phobos falls inward', () => {
const phobos = parsePckRotationalElements(KERNEL, 401)!.elements;
const days = 36525;
const first = (190.72646643 + 15917.10818695) * (Math.PI / 180);
const second = (189.6327156 + 41215158.1842005 + 12.711923222) * (Math.PI / 180);
const expected = 35.1877444 + 1128.84475928 * days + 9.536137031212154e-9 * days * days + 1.42421769 * Math.sin(first) - 1.143 * Math.sin(second);
expect(orientationAt(phobos, J2000 + days).primeMeridianDeg).toBeCloseTo(((expected % 360) + 360) % 360, 5);
});
});
+123
View File
@@ -0,0 +1,123 @@
import { RotationalElements } from '../models/body.model';
/**
* Reads the IAU WGCCRE 2015 rotational elements from NAIF's text kernel `pck00011.tpc`, which the
* ETL fetches (see `tools/etl/lib/pck.ts`), and evaluates them at a date.
*/
const J2000_JD = 2451545.0;
const DAYS_PER_JULIAN_CENTURY = 36525;
const DEG_TO_RAD = Math.PI / 180;
/**
* The smallest periodic term kept, in degrees. A term turns the body, or tips its pole, by at most
* its amplitude, and the largest a body is ever drawn is Jupiter filling the screen at 641 px of
* radius, where 0.01 degrees moves a point on its surface by 0.11 px. In `pck00011.tpc` this
* leaves out 32 terms: Mercury's four smaller librations (0.0011 degrees and less), eight of the
* Moon's thirteen (0.0072 and less), the thirteen short-period terms of Mars's pole and meridian
* (0.00024 and less; its three 0.42-1.59 degree long-period ones stay), one of Phobos's (0.0063),
* Jupiter's five (0.0022 and less) and one of Europa's (0.009). Mimas's 44.85-degree libration,
* Triton's 32-degree precession and Miranda's 4.4 are kept, down to Triton's 0.01.
*/
export const MIN_PERIODIC_TERM_DEG = 0.01;
/**
* Every `NAME = ( values )` assignment in the kernel's data blocks. A data block runs from a line
* holding only `\begindata` to one holding only `\begintext`; the kernel's own prose mentions both
* tokens mid-sentence, which is why they are only read alone on a line. Exponents are written
* the Fortran way, `-1.4D-12`.
*/
function pckVariables(text: string): Map<string, number[]> {
const data = text
.split(/^\s*\\begindata\s*$/m)
.slice(1)
.map((block) => block.split(/^\s*\\begintext\s*$/m)[0])
.join('\n');
const variables = new Map<string, number[]>();
for (const [, name, value] of data.matchAll(/(\w+)\s*=\s*(\([^)]*\)|\S+)/g)) {
variables.set(
name,
value
.replace(/[()]/g, ' ')
.trim()
.split(/[\s,]+/)
.filter(Boolean)
.map((token) => Number(token.replace(/d/i, 'e')))
);
}
return variables;
}
/**
* One body's elements, by its NAIF id: 399 for Earth, 301 for the Moon, 2000001 for Ceres.
* Undefined where the kernel has none.
*
* The periodic terms' angles belong to the planet's whole system, `BODY5_NUT_PREC_ANGLES` for
* Jupiter and its moons, each a polynomial in T whose degree `BODYn_MAX_PHASE_DEGREE` gives: 1
* unless stated, 2 for Mars, where Phobos's angle carries the tidal acceleration that is drawing
* it in. A term is kept if any of its three amplitudes reaches {@link MIN_PERIODIC_TERM_DEG};
* the largest amplitude of each term left out comes back in `skippedDeg`, for the ETL to say so.
*/
export function parsePckRotationalElements(text: string, naifId: number): { elements: RotationalElements; skippedDeg: number[] } | undefined {
const variables = pckVariables(text);
const poleRaDeg = variables.get(`BODY${naifId}_POLE_RA`);
const poleDecDeg = variables.get(`BODY${naifId}_POLE_DEC`);
const primeMeridianDeg = variables.get(`BODY${naifId}_PM`);
if (!poleRaDeg || !poleDecDeg || !primeMeridianDeg) {
return undefined;
}
if (![...poleRaDeg, ...poleDecDeg, ...primeMeridianDeg].every(Number.isFinite)) {
throw new Error(`Body ${naifId}'s pole or prime meridian did not parse.`);
}
const ra = variables.get(`BODY${naifId}_NUT_PREC_RA`) ?? [];
const dec = variables.get(`BODY${naifId}_NUT_PREC_DEC`) ?? [];
const pm = variables.get(`BODY${naifId}_NUT_PREC_PM`) ?? [];
const system = naifId < 1000 ? Math.floor(naifId / 100) : undefined;
const angles = system === undefined ? [] : (variables.get(`BODY${system}_NUT_PREC_ANGLES`) ?? []);
const coefficients = (variables.get(`BODY${system}_MAX_PHASE_DEGREE`)?.[0] ?? 1) + 1;
const terms: NonNullable<RotationalElements['terms']> = [];
const skippedDeg: number[] = [];
for (let index = 0; index < Math.max(ra.length, dec.length, pm.length); index++) {
const term = { ra: ra[index] ?? 0, dec: dec[index] ?? 0, pm: pm[index] ?? 0 };
const largest = Math.max(Math.abs(term.ra), Math.abs(term.dec), Math.abs(term.pm));
if (largest === 0) {
continue;
}
if (largest < MIN_PERIODIC_TERM_DEG) {
skippedDeg.push(largest);
continue;
}
const angleDeg = angles.slice(index * coefficients, (index + 1) * coefficients);
if (angleDeg.length !== coefficients || !angleDeg.every(Number.isFinite)) {
throw new Error(`Body ${naifId}'s periodic term ${index + 1} has no angle among BODY${system}_NUT_PREC_ANGLES.`);
}
terms.push({ angleDeg, ...term });
}
return {
elements: { poleRaDeg, poleDecDeg, primeMeridianDeg, ...(terms.length > 0 ? { terms } : {}) },
skippedDeg
};
}
function polynomial(coefficients: readonly number[], x: number): number {
return (coefficients[0] ?? 0) + (coefficients[1] ?? 0) * x + (coefficients[2] ?? 0) * x * x;
}
/** The pole's right ascension and declination and the prime meridian W, in degrees, at a TDB Julian date. */
export function orientationAt(elements: RotationalElements, jdTdb: number): { poleRaDeg: number; poleDecDeg: number; primeMeridianDeg: number } {
const days = jdTdb - J2000_JD;
const centuries = days / DAYS_PER_JULIAN_CENTURY;
let poleRaDeg = polynomial(elements.poleRaDeg, centuries);
let poleDecDeg = polynomial(elements.poleDecDeg, centuries);
let primeMeridianDeg = polynomial(elements.primeMeridianDeg, days);
for (const term of elements.terms ?? []) {
const angle = polynomial(term.angleDeg, centuries) * DEG_TO_RAD;
poleRaDeg += term.ra * Math.sin(angle);
poleDecDeg += term.dec * Math.cos(angle);
primeMeridianDeg += term.pm * Math.sin(angle);
}
return { poleRaDeg, poleDecDeg, primeMeridianDeg: ((primeMeridianDeg % 360) + 360) % 360 };
}
+30
View File
@@ -86,4 +86,34 @@ export interface BodyRecord {
*/ */
rotationPeriodHours?: number; rotationPeriodHours?: number;
obliquityDeg?: number; obliquityDeg?: number;
/**
* Where the body's pole points and which way its prime meridian faces at any date, from the IAU
* WGCCRE 2015 report (Archinal et al. 2018) as NAIF's `pck00011.tpc` carries it. Where present
* it alone sets how the body is drawn, and the ETL checks the period and obliquity above against
* it. Absent where the report gives none: Hyperion tumbles, and Nereid, Eris, Haumea and
* Makemake have no model.
*/
rotationalElements?: RotationalElements;
}
/**
* The IAU's rotational elements for one body: polynomials in time, plus periodic terms.
*
* The pole's right ascension and declination are in degrees in the ICRF, `[c0, c1, c2]` for
* `c0 + c1 T + c2 T²`, T in Julian centuries from J2000.0 TDB. The prime meridian W is the angle
* along the body's equator, anticlockwise seen from above that pole, from where the equator rises
* through the ICRF equator to the body's longitude 0, `c0 + c1 d + c2 d²` with d in days. A
* negative rate turns the body clockwise about the pole the IAU names: Venus, Uranus and its
* moons, Triton.
*/
export interface RotationalElements {
poleRaDeg: number[];
poleDecDeg: number[];
primeMeridianDeg: number[];
/**
* Each adds `ra sin θ` to the right ascension, `dec cos θ` to the declination and `pm sin θ` to
* W, θ being `angleDeg[0] + angleDeg[1] T + angleDeg[2] T²`. The smallest are left out; see
* `parsePckRotationalElements`.
*/
terms?: Array<{ angleDeg: number[]; ra: number; dec: number; pm: number }>;
} }
File diff suppressed because it is too large Load Diff
+65 -1
View File
@@ -1,8 +1,9 @@
import { statSync } from 'node:fs'; import { statSync } from 'node:fs';
import { BodyRecord, OrbitalElements } from '../../src/app/shared/models/body.model'; 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 { 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 { DeepSkyRecord } from '../../src/app/shared/models/deepsky.model';
import { ExoplanetRecord } from '../../src/app/shared/models/exoplanet.model'; import { ExoplanetRecord } from '../../src/app/shared/models/exoplanet.model';
import { StarRecord, SUN_STAR_ID } from '../../src/app/shared/models/star.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_PLANET_OFFSET_DEG = 0.25;
const MAX_MOON_OFFSET_DEG = 2.5; const MAX_MOON_OFFSET_DEG = 2.5;
const KM_PER_AU = 149597870.7; 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, * 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 }; 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 { 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)); 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; 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.'); assertCondition(ids.size === bodies.length, 'Duplicate body ids were found.');
const offsets: string[] = []; const offsets: string[] = [];
const spins: string[] = [];
for (const body of bodies) { for (const body of bodies) {
const orbitValues = Object.values(body.orbit); const orbitValues = Object.values(body.orbit);
assertCondition(orbitValues.every(Number.isFinite), `Body ${body.id} has non-finite orbital elements.`); 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. // 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.`); 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') { if (body.kind === 'moon') {
const parent = bodies.find((candidate) => candidate.id === body.parentBodyId); const parent = bodies.find((candidate) => candidate.id === body.parentBodyId);
assertCondition(parent !== undefined, `Moon ${body.id} has no valid 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; const dwarfCount = bodies.filter((body) => body.kind === 'dwarf').length;
assertCondition(dwarfCount === 5, `Expected the IAU's 5 dwarf planets, found ${dwarfCount}.`); assertCondition(dwarfCount === 5, `Expected the IAU's 5 dwarf planets, found ${dwarfCount}.`);
console.log(` mean elements against Horizons, degrees: ${offsets.join(', ')}.`); 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 { function validateExoplanets(exoplanets: ExoplanetRecord[], starIds: Set<number>): void {
+17 -4
View File
@@ -5,6 +5,8 @@ import { SUN_STAR_ID } from '../../src/app/shared/models/star.model';
import { fetchHorizonsBody } from './lib/horizons'; import { fetchHorizonsBody } from './lib/horizons';
import { MeanOrbit, parsePlanetMeanElements, parseSatelliteMeanElements, parseSmallBodyElements } from '../../src/app/shared/astro/mean-elements'; import { MeanOrbit, parsePlanetMeanElements, parseSatelliteMeanElements, parseSmallBodyElements } from '../../src/app/shared/astro/mean-elements';
import { fetchPlanetMeanElementsText, fetchSatelliteMeanElementsHtml, fetchSmallBodyAnswer } from './lib/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'; import { dataPath, ensureDataDir } from './lib/paths';
const HOURS_PER_DAY = 24; 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 * 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-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, * 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 * JPL Horizons for their size and spin, and the IAU's rotational elements for where their poles
* back alongside, for `build.ts` to check the mean ones against. * 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> }> { 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 bodies: BodyRecord[] = [];
const horizonsOrbits = new Map<string, OrbitalElements>(); const horizonsOrbits = new Map<string, OrbitalElements>();
const planetElements = await fetchPlanetMeanElementsText(); const planetElements = await fetchPlanetMeanElementsText();
const satelliteElements = await fetchSatelliteMeanElementsHtml(); const satelliteElements = await fetchSatelliteMeanElementsHtml();
const pck = await fetchPckText();
const gmById = new Map<string, number | undefined>(); const gmById = new Map<string, number | undefined>();
for (const spec of BODY_SPECS) { for (const spec of BODY_SPECS) {
@@ -183,6 +187,14 @@ export async function fetchSolarSystem(): Promise<{ bodies: BodyRecord[]; horizo
if (rotationPeriodHours === undefined) { if (rotationPeriodHours === undefined) {
console.warn(` no rotation period found for ${spec.name}; it will not turn.`); 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({ bodies.push({
id: spec.id, id: spec.id,
@@ -197,7 +209,8 @@ export async function fetchSolarSystem(): Promise<{ bodies: BodyRecord[]; horizo
...(spec.parentBodyId ? { parentBodyId: spec.parentBodyId } : {}), ...(spec.parentBodyId ? { parentBodyId: spec.parentBodyId } : {}),
...(parentGm !== undefined ? { massRatio: result.gmKm3PerS2! / parentGm } : {}), ...(parentGm !== undefined ? { massRatio: result.gmKm3PerS2! / parentGm } : {}),
...(rotationPeriodHours !== undefined ? { rotationPeriodHours } : {}), ...(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 } : {})
}); });
} }
+13
View File
@@ -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');
}