Merge the solar-system branch, so the star catalogue lands on the sky it now shares
Both branches changed the system view's star, the body card's provenance line, the exoplanet fetch and the ETL's validators. Resolved by keeping both sides: - The system view's star is the catalogue's (its own radius and temperature, a limb-darkened surface in its colour) and turns like a planet when it is the Sun (the solar branch's IAU pole and 25.38-day turn), keyed on SUN_STAR_ID, since the catalogue branch dropped the scene's own SOL_STAR_ID. Framing takes the outermost thing drawn (an eccentric orbit's aphelion, from the solar branch) and the star's radius for a giant (from the catalogue). The solar branch's comment about a halo is dropped: there has been none since #33. - The card's no-temperature sentence is the catalogue's (the host's luminosity or the orbit's size, not "not in the catalogue", which holds for 27 planets) and ends with the solar branch's reason why no image is used (a point of light for the 101 imaged planets, none for the rest). - fetchExoplanets reads the composite table and the distance errors (catalogue) and the imaged list (solar); build.ts runs both branches' validators. The data were regenerated by the full ETL on the merged code, from cache (nothing refetched): stars.bin, stars-meta.bin, stars-index.json and deepsky.json come out byte for byte the catalogue branch's, bodies.json the solar branch's, and exoplanets.json the catalogue branch's but for the imaged flag on 101 planets, WASP-108 b not among them. Unit suite 977 passed, the two branches' 870 and 837 over their shared 730, so no test was lost. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
+357
-6
@@ -1,12 +1,17 @@
|
||||
import { statSync } from 'node:fs';
|
||||
|
||||
import { BodyRecord } from '../../src/app/shared/models/body.model';
|
||||
import { BodyRecord, OrbitalElements, RotationalElements } from '../../src/app/shared/models/body.model';
|
||||
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';
|
||||
import { fetchDeepSky } from './fetchDeepSky';
|
||||
import { fetchExoplanets } from './fetchExoplanets';
|
||||
import { fetchSolarSystem } from './fetchSolarSystem';
|
||||
import { fetchSolarSystem, FREELY_SPINNING_MOONS, offsetFromTrackDeg } from './fetchSolarSystem';
|
||||
import { TrackPoint } from './lib/horizons';
|
||||
import { subPlanetLongitudeDeg } from './lib/locked-spin';
|
||||
import { BYTES_PER_STAR_META, BYTES_PER_STAR_POSITION, decodeStarCatalog, encodeStarCatalog, isDesignation } from '../../src/app/shared/models/star-catalog';
|
||||
import { fetchStars, glieseGaiaDesignations } from './fetchStars';
|
||||
import { ARCHIVE_EPOCH, archiveStarId, CATALOGUE_EPOCH } from '../../src/app/shared/astro/host-star-matching';
|
||||
@@ -354,23 +359,353 @@ function validateMerge(stars: StarRecord[], gaiaDesignationById: ReadonlyMap<num
|
||||
console.log(` ${survivors} HYG stars have no Gaia counterpart; ${twins} unmerged cross-catalogue pairs within an arcsecond; ${beside.length} Gliese stars beside their own Gaia source.`);
|
||||
}
|
||||
|
||||
function validateBodies(bodies: BodyRecord[]): void {
|
||||
/**
|
||||
* How far a body's mean elements may put it from where Horizons has it, on the one date the ETL
|
||||
* asks Horizons about (2025-01-01), seen from the Sun for a planet and from its planet for a moon.
|
||||
*
|
||||
* Measured on this catalogue: the planets at most 0.10 degrees (Uranus; Standish's own stated
|
||||
* error for his fit is 2 000 arcseconds, 0.56 degrees), the moons at most 1.41 (the Moon, whose
|
||||
* evection and variation, 1.27 and 0.66 degrees, no mean ellipse has). What this catches is a
|
||||
* table read wrongly: a moon read against the ecliptic instead of its Laplace plane, a node run the
|
||||
* wrong way, or a column taken for its neighbour, which put Triton 26 degrees out. Io's periapsis
|
||||
* run forwards put it 0.9 out here, which passes; {@link TRACK_OFFSET_CEILINGS_DEG} catches that.
|
||||
*/
|
||||
const MAX_PLANET_OFFSET_DEG = 0.25;
|
||||
/** Measured on this catalogue: at most 0.0151 (Phoebe and the Moon) once Hyperion prints its current 0.105. */
|
||||
const MAX_ECCENTRICITY_OFFSET = 0.03;
|
||||
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 on this one date, each with a ceiling just
|
||||
* above its offset here (Hyperion 9.41, Iapetus 9.56, Nereid 2.58); see
|
||||
* {@link TRACK_OFFSET_CEILINGS_DEG} for what they reach from 1950 to 2100. Hyperion's was 21, its
|
||||
* worst over twelve dates, which let a row misread by twice its offset through.
|
||||
*/
|
||||
const MOON_OFFSET_CEILINGS_DEG: Record<string, number> = { hyperion: 10, iapetus: 11, nereid: 3 };
|
||||
|
||||
/**
|
||||
* How far a moon's or dwarf planet's orbit may stray from Horizons from 1950 to 2100, sampled every
|
||||
* other day (Nereid and Hyperion daily). One date showed each at its best: twelve New Year's Days
|
||||
* gave Nereid 2.6 degrees, and 2025-01-01 alone is all the check above sees. Each card says how
|
||||
* far its own orbit strays over the span (`fetchSolarSystem`), and this holds that figure to
|
||||
* account.
|
||||
*
|
||||
* Measured on this catalogue: at most 2.62 degrees (the Moon, 2010 March 27: no mean ellipse has
|
||||
* its evection or variation; Phoebe reaches 2.58 in 1969, where "within 2.0" was once claimed for
|
||||
* it). Five need their own:
|
||||
*
|
||||
* - Hyperion, 22.23 (2055 Feb 26): held in a 4:3 resonance by Titan; the row's eccentricity,
|
||||
* 0.0232, is less than a quarter of the 0.105 JPL's current table gives.
|
||||
* - Nereid, 11.19 (2039 Nov 1): an eccentricity of 0.75, the largest here, which a mean ellipse
|
||||
* follows least well near periapsis, where the true anomaly runs ten times faster than the mean;
|
||||
* its 360-day year kept every New Year's Day far from one.
|
||||
* - Iapetus, 10.34: the row sits 9.4 degrees behind Horizons at its own epoch, 2000 Jan 1.5, and
|
||||
* keeps that offset; its plane agrees with Horizons' to 0.07 degrees and its period to 0.001 per
|
||||
* cent, so the fault is in the row's longitude, which this has no second source to correct.
|
||||
* - Mimas, 7.42: its orbit carries the 44-degree libration of its resonance with Tethys (see
|
||||
* `orbitFromW` in `fetchSolarSystem.ts`), but not the rest of what Horizons integrates.
|
||||
* - Ceres, 7.12 (1953): the SBDB's elements are osculating, exact at 2026 Jun 9 and drifting
|
||||
* either side; 1.9 by 2050, 5.3 by 2100, and 39 at 1600 on Horizons' own figures.
|
||||
*
|
||||
* And four are held tighter than the rest, each where one reading of its row is all that keeps it
|
||||
* close, and without it the card would quietly restate itself under the general ceiling:
|
||||
*
|
||||
* - Tethys, 0.28, which takes the other half of that libration, 2.23 degrees, from its W. Without
|
||||
* it Tethys strays 2.09.
|
||||
* - Io, 0.07, and Europa, 0.23, whose periapses turn backwards, held by the Laplace resonance at
|
||||
* 2n(Europa) - n(Io), -0.7395 degrees a day (`apsidesRegress`). Read as advancing, Io strays 0.96
|
||||
* and Europa 2.24, and their cards said "within 1.0" and "within 2.3".
|
||||
* - Callisto, 0.08, whose node turns at JPL's current rate and its periapsis's longitude at the
|
||||
* row's (`nodePeriodYears`). On the row's argument its periapsis moves 44 degrees by 2100 and it
|
||||
* strays 0.71; on the row's node, 0.19.
|
||||
*/
|
||||
const MAX_TRACK_OFFSET_DEG = 3;
|
||||
const TRACK_OFFSET_CEILINGS_DEG: Record<string, number> = { hyperion: 23, nereid: 12, iapetus: 11, mimas: 8, ceres: 8, tethys: 0.5, io: 0.2, europa: 0.5, callisto: 0.15 };
|
||||
|
||||
/**
|
||||
* 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']);
|
||||
|
||||
/**
|
||||
* The one moon drawn still: Hyperion, whose page says "Rotational period = Chaotic". Every other
|
||||
* moon without a lock has a measured day; Nereid's page states none, and it was drawn still until
|
||||
* its K2 light curve's 11.594 hours was taken (see its spec).
|
||||
*/
|
||||
const TUMBLING = new Set(['hyperion']);
|
||||
|
||||
/**
|
||||
* How far the IAU's day, 360 degrees over W's rate, may be from the period the body's record
|
||||
* carries, as a fraction of it. That period is not always a second source:
|
||||
*
|
||||
* - The eight planets and Phoebe: the one Horizons states. 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.
|
||||
* - Pluto and Ceres: the IAU's own rate restated. Horizons' 153.29335198 hours for Pluto is 360 over
|
||||
* its W (8.5e-12), and the SBDB's 9.074170 for Ceres, which Horizons prints too, is noted as
|
||||
* derived from the report's 952.1532 degrees a day (3.3e-10).
|
||||
* - The 22 locked moons: their orbit's period, from JPL's satellite table, not a figure from their
|
||||
* Horizons pages ("Synchronous" on eighteen of them, nothing on Titan's or Proteus's). Their W is
|
||||
* turned at that rate (see `lockedToOrbit`, which first holds the kernel's own rate to it within
|
||||
* 1e-5), so here they are 0, but for the Moon and Phobos, whose W keeps its own rate and its
|
||||
* quadratic (1.1e-8 and 3.1e-7).
|
||||
*
|
||||
* 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.
|
||||
*
|
||||
* Pluto's Horizons page states no obliquity: its 119.6 is worked out from the IAU pole itself (see
|
||||
* `BodySpec.obliquityDeg`), so for Pluto this checks only that the kernel's pole and W were read as
|
||||
* written, not the pole against a second source.
|
||||
*/
|
||||
const MAX_OBLIQUITY_OFFSET_DEG = 0.1;
|
||||
|
||||
/**
|
||||
* How far from its planet a locked moon's drawn face may turn: the east longitude, on the IAU's
|
||||
* body-fixed frame, of the direction to the planet from where the mean elements put the moon,
|
||||
* sampled every 135 days over the clock's AD 1 to 3000. Every locked moon's W turns at its orbit's
|
||||
* own rate (see `lockedToOrbit`); at the IAU's own rates, and sampled only from 1950 to 2100, this
|
||||
* let Proteus turn its far side to Neptune at AD 1 (146 degrees), Iapetus 87 degrees, Mimas 52 and
|
||||
* Miranda 23, on dates the clock offers.
|
||||
*
|
||||
* Measured on this catalogue: at most 5.36 degrees (Titan) but for three. The Moon 7.62, at AD 1:
|
||||
* its longitude swings 6.3 either way with its eccentricity, Horizons' too, and W's quadratic, the
|
||||
* tidal slowing its orbit here does not carry, adds 0.75 by then. Mimas 8.89: about 6.3 off on
|
||||
* average because the IAU's W and JPL's mean longitude disagree, and swung 2.3 either way (2e) by
|
||||
* its eccentricity. None of that is Mimas: its measured physical libration is 0.84 degrees
|
||||
* (Tajeddine et al. 2014, Science 346, 322), and W carries none; Horizons, on the same W against its
|
||||
* integrated orbit, runs from -2.7 to 12.7 degrees over 1950-2100 with the 71-year S5 term the
|
||||
* orbit here cancels. Iapetus 15.95, whose row sits 9.4 degrees behind Horizons. What this catches
|
||||
* is an orbit and a W that go round at different rates: the tidal acceleration W carried and the
|
||||
* orbit did not turned Phobos 13.8 degrees from Mars by 2100, and the Mimas-Tethys libration Mimas
|
||||
* 54.5.
|
||||
*/
|
||||
const MAX_SUB_PLANET_LONGITUDE_DEG = 7;
|
||||
const SUB_PLANET_CEILINGS_DEG: Record<string, number> = { moon: 8, mimas: 9.5, iapetus: 16.5 };
|
||||
/**
|
||||
* How far a locked moon's spin axis may lean from the normal of the orbit it is drawn going round,
|
||||
* over the same dates. A locked moon sits in a Cassini state, its axis on its orbit normal as the
|
||||
* node carries both round the Laplace pole, and the IAU's pole goes round on a term of the node's
|
||||
* angle; at the rate the IAU's source had for it and not the drawn orbit's, Rhea's axis is 0.77
|
||||
* degrees off by AD 1 and Triton's 0.51, and an Iapetus pole left on the Laplace pole is 8.30 off
|
||||
* at every date (see `lockedToOrbit`).
|
||||
*
|
||||
* Measured on this catalogue: at most 0.97 degrees (Tethys, whose IAU pole sits 0.69 from its orbit
|
||||
* normal today; Titan 0.94, whose pole the IAU holds still while its node turns in 687 years) but
|
||||
* for four. The Moon 6.98, its real 6.7-degree tilt to its orbit. Phobos 1.81 and Deimos 1.74, and
|
||||
* Proteus 1.09: their IAU poles nod with Mars's and Neptune's precessing poles, the Laplace poles
|
||||
* their orbits are drawn round are fixed.
|
||||
*
|
||||
* And six are held tighter, each where its node terms turned at the node's rate, or its node at
|
||||
* JPL's current rate, are what keep it close: Europa 0.13, Ganymede 0.16, Callisto 0.22, Rhea 0.17,
|
||||
* Miranda 0.23 and Triton 0.15. On the IAU's rates they are 0.33, 0.21, 0.33, 0.77, 0.59 and 0.51,
|
||||
* on a tolerance of 1 per cent Callisto and Rhea are left there, on the node's angle alone and not
|
||||
* its harmonics Triton is 0.29, and on the archived table's node periods Callisto is 0.56 and
|
||||
* Miranda 0.42: all under the general ceiling.
|
||||
*/
|
||||
const MAX_AXIS_FROM_ORBIT_DEG = 1;
|
||||
const AXIS_FROM_ORBIT_CEILINGS_DEG: Record<string, number> = {
|
||||
moon: 7.1,
|
||||
phobos: 2,
|
||||
deimos: 2,
|
||||
proteus: 1.2,
|
||||
europa: 0.25,
|
||||
ganymede: 0.25,
|
||||
callisto: 0.25,
|
||||
rhea: 0.25,
|
||||
miranda: 0.25,
|
||||
triton: 0.25
|
||||
};
|
||||
/** The clock's window, AD 1 to 3000 (`CLOCK_WINDOW` in `time.store.ts`), as Julian dates. */
|
||||
const CLOCK_START_JD = Date.parse('0001-01-01T00:00Z') / 86400000 + 2440587.5;
|
||||
const CLOCK_END_JD = Date.parse('3000-01-01T00:00Z') / 86400000 + 2440587.5;
|
||||
const LOCK_DATES_JD = Array.from({ length: Math.floor((CLOCK_END_JD - CLOCK_START_JD) / 135) + 1 }, (_, index) => CLOCK_START_JD + index * 135);
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
/** Degrees between a body's spin axis — its IAU pole, turned over where W runs backwards — and the normal of the orbit it is drawn going round, at a TDB date. */
|
||||
function axisFromOrbitDeg(body: BodyRecord, rotation: RotationalElements, jd: number): number {
|
||||
const pole = orientationAt(rotation, jd);
|
||||
const pointing = raDecToUnitVector(pole.poleRaDeg / 15, pole.poleDecDeg);
|
||||
const sense = Math.sign(rotation.primeMeridianDeg[1]);
|
||||
const axis = { x: sense * pointing.x, y: sense * pointing.y, z: sense * pointing.z };
|
||||
const { inclinationDeg, longitudeOfAscendingNodeDeg } = meanElementsAt(body.orbit, body.rates, jd);
|
||||
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) };
|
||||
return angleBetweenDeg(axis, body.laplacePole ? laplacePlaneToEquatorial(normal, body.laplacePole) : eclipticToEquatorial(normal));
|
||||
}
|
||||
|
||||
function validateBodies(bodies: BodyRecord[], horizonsOrbits: Map<string, OrbitalElements>, horizonsTracks: Map<string, TrackPoint[]>): void {
|
||||
assertCondition(bodies.length > 0, 'No solar-system bodies were produced.');
|
||||
|
||||
const ids = new Set(bodies.map((body) => body.id));
|
||||
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.`);
|
||||
assertCondition(body.rates.meanMotionDegPerDay > 0, `Body ${body.id} has no mean motion.`);
|
||||
|
||||
// Horizons' elements are osculating, exact at their own epoch; both sets are placed there.
|
||||
const horizons = horizonsOrbits.get(body.id);
|
||||
assertCondition(horizons !== undefined, `Body ${body.id} has no Horizons elements to be checked against.`);
|
||||
const truth = eclipticToEquatorial(positionAtEpoch(horizons!));
|
||||
const mean = positionAtEpoch(meanElementsAt(body.orbit, body.rates, horizons!.epochJd));
|
||||
const offset = angleBetweenDeg(body.laplacePole ? laplacePlaneToEquatorial(mean, body.laplacePole) : eclipticToEquatorial(mean), truth);
|
||||
const ceiling = body.kind === 'moon' ? (MOON_OFFSET_CEILINGS_DEG[body.id] ?? MAX_MOON_OFFSET_DEG) : MAX_PLANET_OFFSET_DEG;
|
||||
assertCondition(
|
||||
offset <= ceiling,
|
||||
`${body.name}'s mean elements put it ${offset.toFixed(2)} degrees from where Horizons has it (at most ${ceiling} expected) — the elements were read wrongly.`
|
||||
);
|
||||
offsets.push(`${body.id} ${offset.toFixed(3)}`);
|
||||
|
||||
// Standish's fit names its own span; every other orbit is measured over 1950-2100, and says so.
|
||||
const track = horizonsTracks.get(body.id);
|
||||
assertCondition(
|
||||
(track !== undefined) === !body.orbitSource.startsWith('JPL approximate mean elements (Standish)'),
|
||||
`${body.name}'s orbit, "${body.orbitSource}", ${track ? 'names its own span' : 'names no span it holds over'}.`
|
||||
);
|
||||
// JPL's satellite table carries no periodic terms: a moon's are from its IAU W, and its card
|
||||
// names the kernel they come from as well as the table.
|
||||
assertCondition(
|
||||
!body.parentBodyId || !body.rates.meanAnomalyTerms || body.orbitSource.includes('NAIF pck00011'),
|
||||
`${body.name}'s orbit carries terms taken from its IAU W, and its card, "${body.orbitSource}", credits only the table.`
|
||||
);
|
||||
if (track) {
|
||||
const worst = Math.max(...track.map((point) => offsetFromTrackDeg(body, point)));
|
||||
const trackCeiling = TRACK_OFFSET_CEILINGS_DEG[body.id] ?? MAX_TRACK_OFFSET_DEG;
|
||||
const stated = Number(body.orbitSource.match(/within ([\d.]+) degrees of Horizons/)?.[1]);
|
||||
assertCondition(
|
||||
worst <= trackCeiling && stated >= worst,
|
||||
`${body.name}'s mean elements put it up to ${worst.toFixed(2)} degrees from Horizons between 1950 and 2100 (at most ${trackCeiling} expected), and its card says "${body.orbitSource}".`
|
||||
);
|
||||
offsets.push(`${body.id} ${worst.toFixed(2)} at worst`);
|
||||
}
|
||||
// The card prints this under "Measured". An osculating eccentricity swings about its mean — the
|
||||
// Moon's by 0.015 here, Phoebe's by as much — but not by the 0.087 Hyperion's older row was out.
|
||||
const printed = body.measuredEccentricity ?? body.orbit.eccentricity;
|
||||
assertCondition(
|
||||
Math.abs(printed - horizons!.eccentricity) <= MAX_ECCENTRICITY_OFFSET,
|
||||
`${body.name}'s card gives an eccentricity of ${printed}, where Horizons' osculating orbit has ${horizons!.eccentricity.toFixed(4)} (at most ${MAX_ECCENTRICITY_OFFSET} apart expected).`
|
||||
);
|
||||
|
||||
// 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.`);
|
||||
// A triaxial body's card gives its mean radius beside its semi-axes, so the two must agree: the
|
||||
// radius of the sphere of the same volume. Measured: Haumea's 797.6 against 797.62.
|
||||
if (body.semiAxesKm) {
|
||||
const volumeRadius = Math.cbrt(body.semiAxesKm[0] * body.semiAxesKm[1] * body.semiAxesKm[2]);
|
||||
assertCondition(
|
||||
Math.abs(volumeRadius / body.radiusKm - 1) < 0.001,
|
||||
`${body.name}'s radius, ${body.radiusKm} km, is not the mean of its semi-axes ${body.semiAxesKm.join(' x ')}, ${volumeRadius.toFixed(1)} km.`
|
||||
);
|
||||
}
|
||||
|
||||
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 the ${Math.abs(body.rotationPeriodHours).toFixed(5)} its record carries (at most ${dayCeiling} expected).`
|
||||
);
|
||||
spins.push(`${body.id} day ${dayOffset.toExponential(1)}`);
|
||||
}
|
||||
if (body.obliquityDeg !== undefined) {
|
||||
const obliquity = axisFromOrbitDeg(body, rotation, horizons!.epochJd);
|
||||
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 ${body.id === 'pluto' ? 'its IAU pole' : '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') {
|
||||
assertCondition(!!body.parentBodyId && ids.has(body.parentBodyId), `Moon ${body.id} has no valid parentBodyId.`);
|
||||
const parent = bodies.find((candidate) => candidate.id === body.parentBodyId);
|
||||
assertCondition(parent !== undefined, `Moon ${body.id} has no valid parentBodyId.`);
|
||||
const orbitHours = (360 / body.rates.meanMotionDegPerDay) * 24;
|
||||
if (FREELY_SPINNING_MOONS.has(body.id)) {
|
||||
// Hyperion tumbles, and has no period; Nereid turns in 11.594 hours against a 360-day orbit,
|
||||
// and Phoebe in 9.27 against 550 days. A lock here would be the rule below misapplied.
|
||||
assertCondition(
|
||||
body.rotationPeriodHours !== undefined || TUMBLING.has(body.id),
|
||||
`Moon ${body.id} is drawn not turning, and is not known to tumble: its day was measured somewhere, find it.`
|
||||
);
|
||||
assertCondition(
|
||||
body.rotationPeriodHours === undefined || Math.abs(body.rotationPeriodHours - orbitHours) > orbitHours * 0.1,
|
||||
`Moon ${body.id} does not keep one face to its planet, yet turns once in ${body.rotationPeriodHours} hours against an orbit of ${orbitHours}.`
|
||||
);
|
||||
} else {
|
||||
// Every other moon here is tidally locked, and drawn by its orbit and its IAU W: the two
|
||||
// have to agree, or its face turns away from its planet.
|
||||
assertCondition(rotation !== undefined, `Moon ${body.id} is locked but has no W to keep its face to its planet by.`);
|
||||
const ceiling = SUB_PLANET_CEILINGS_DEG[body.id] ?? MAX_SUB_PLANET_LONGITUDE_DEG;
|
||||
const worst = Math.max(...LOCK_DATES_JD.map((jd) => Math.abs(subPlanetLongitudeDeg(body, rotation!, jd))));
|
||||
assertCondition(
|
||||
worst <= ceiling,
|
||||
`Moon ${body.id} turns its face up to ${worst.toFixed(2)} degrees from its planet between AD 1 and 3000 (at most ${ceiling} expected) — its orbit and its W disagree.`
|
||||
);
|
||||
spins.push(`${body.id} faces ${worst.toFixed(2)}`);
|
||||
const axisCeiling = AXIS_FROM_ORBIT_CEILINGS_DEG[body.id] ?? MAX_AXIS_FROM_ORBIT_DEG;
|
||||
const worstAxis = Math.max(...LOCK_DATES_JD.map((jd) => axisFromOrbitDeg(body, rotation!, jd)));
|
||||
assertCondition(
|
||||
worstAxis <= axisCeiling,
|
||||
`Moon ${body.id}'s spin axis leans up to ${worstAxis.toFixed(2)} degrees from its orbit's normal between AD 1 and 3000 (at most ${axisCeiling} expected) — its pole does not go round with its node.`
|
||||
);
|
||||
spins.push(`${body.id} axis ${worstAxis.toFixed(2)}`);
|
||||
}
|
||||
if (body.massRatio !== undefined) {
|
||||
// The pair's barycentre, which the planet's elements place, must lie outside the planet —
|
||||
// that is why the two are drawn going round it — and nearer the planet than the moon.
|
||||
const offsetKm = (body.orbit.semiMajorAxisAu * KM_PER_AU * body.massRatio) / (1 + body.massRatio);
|
||||
assertCondition(
|
||||
body.massRatio > 0 && body.massRatio < 1 && offsetKm > parent!.radiusKm,
|
||||
`${body.name}'s mass ratio ${body.massRatio} puts its barycentre ${offsetKm.toFixed(0)} km from ${parent!.name}'s centre, which is not between its surface, ${parent!.radiusKm} km out, and the moon.`
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const planetCount = bodies.filter((body) => body.kind === 'planet').length;
|
||||
assertCondition(planetCount === 8, `Expected 8 planets, found ${planetCount}.`);
|
||||
const dwarfCount = bodies.filter((body) => body.kind === 'dwarf').length;
|
||||
assertCondition(dwarfCount === 5, `Expected the IAU's 5 dwarf planets, found ${dwarfCount}.`);
|
||||
// Eris keeps one face to Dysnomia, whose orbit takes 15.78590 days (Holler et al. 2021); its light
|
||||
// curve gives 15.771 +/- 0.008 (Bernstein et al. 2023). The SBDB still gives 25.9 hours.
|
||||
const erisDays = (bodies.find((body) => body.id === 'eris')?.rotationPeriodHours ?? NaN) / 24;
|
||||
assertCondition(
|
||||
Math.abs(erisDays / 15.7859 - 1) < 0.002,
|
||||
`Eris turns once in ${erisDays.toFixed(3)} days; it is locked to Dysnomia's 15.786-day orbit — the SBDB's 25.9-hour period, which it flags as possibly 30 per cent wrong, was taken.`
|
||||
);
|
||||
console.log(` mean elements against Horizons, degrees: ${offsets.join(', ')}.`);
|
||||
console.log(` IAU rotation against each record's day and tilt (see MAX_DAY_OFFSET for where each comes from; day as a fraction of it, tilt and a locked moon's face in degrees): ${spins.join(', ')}.`);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -503,8 +838,24 @@ function validateExoplanets(exoplanets: ExoplanetRecord[], stars: StarRecord[]):
|
||||
const withPeriod = exoplanets.filter((exoplanet) => exoplanet.periodDays !== undefined).length;
|
||||
const withHostMass = exoplanets.filter((exoplanet) => exoplanet.hostStarMassSolar !== undefined).length;
|
||||
console.log(` ${withPeriod}/${exoplanets.length} have a measured period, ${withHostMass} a host star mass.`);
|
||||
|
||||
// The planets photographed by direct imaging, whose card must not say no image of them exists.
|
||||
// Measured: 101 of 101 flagged in the archive, and not transiting, are in the catalogue. What this
|
||||
// catches is the join by name failing, which would put every one of them back under "no image".
|
||||
const imaged = exoplanets.filter((exoplanet) => exoplanet.imaged).length;
|
||||
assertCondition(imaged >= MIN_IMAGED_EXOPLANETS, `Only ${imaged} exoplanets are marked as imaged (at least ${MIN_IMAGED_EXOPLANETS} expected).`);
|
||||
// And the one the flag is wrong on, which the count cannot see: a 2.68-day transiting hot Jupiter
|
||||
// 0.15 mas from its star, flagged for the companion star a survey imaged beside it. Its record
|
||||
// carries neither a period nor an axis, so no separation check could catch it either.
|
||||
assertCondition(
|
||||
!exoplanets.some((exoplanet) => exoplanet.id === 'WASP-108 b' && exoplanet.imaged),
|
||||
'WASP-108 b is marked as imaged; it transits, and only a companion star beside it was imaged (Bohn et al. 2020).'
|
||||
);
|
||||
console.log(` ${imaged} were imaged directly.`);
|
||||
}
|
||||
|
||||
const MIN_IMAGED_EXOPLANETS = 95;
|
||||
|
||||
const UNIT_VECTOR_TOLERANCE = 1e-6;
|
||||
|
||||
/**
|
||||
@@ -573,7 +924,7 @@ async function build(): Promise<void> {
|
||||
|
||||
const catalogueStars = await fetchStars();
|
||||
console.log();
|
||||
const bodies = await fetchSolarSystem();
|
||||
const { bodies, horizonsOrbits, horizonsTracks } = await fetchSolarSystem();
|
||||
console.log();
|
||||
// Adds the hosts the catalogue lacks, so it is this list, not the one above, that is published.
|
||||
const { exoplanets, stars } = await fetchExoplanets(catalogueStars);
|
||||
@@ -584,7 +935,7 @@ async function build(): Promise<void> {
|
||||
console.log('Validating output...');
|
||||
validateStars(stars);
|
||||
validateMerge(stars, await glieseGaiaDesignations());
|
||||
validateBodies(bodies);
|
||||
validateBodies(bodies, horizonsOrbits, horizonsTracks);
|
||||
validateExoplanets(exoplanets, stars);
|
||||
validateDeepSky(deepSky);
|
||||
|
||||
|
||||
@@ -44,6 +44,16 @@ const TAP_URL = `${TAP_BASE_URL}?query=${TAP_QUERY}`;
|
||||
// silently wrong rather than visibly broken.
|
||||
const CACHE_FILE = `exoplanet-archive-ps-${createHash('sha1').update(TAP_URL).digest('hex').slice(0, 8)}.csv`;
|
||||
|
||||
// The planets the archive flags as detected by imaging (`ima_flag`): 101 of them in September 2026,
|
||||
// HR 8799's four and 51 Eri b among them, and bet Pic c and eps Ind A b, found by radial velocity
|
||||
// and imaged since. Asked for on its own, so adding it did not refetch the table above and move
|
||||
// every other planet to a newer snapshot. A transiting planet is left out: the one flagged,
|
||||
// WASP-108 b, takes its flag from Bohn et al. 2020, a VLT/SPHERE survey of transiting planets' host
|
||||
// stars that imaged a 0.35 solar-mass companion 0.124" from its star. The planet goes round in 2.68
|
||||
// days, 0.04 AU out, 0.15 mas at its 259 pc, and no imager has resolved it.
|
||||
const IMAGED_URL = `${TAP_BASE_URL}?query=select+pl_name+from+ps+where+default_flag=1+and+ima_flag=1+and+tran_flag=0+order+by+pl_name&format=csv`;
|
||||
const IMAGED_CACHE_FILE = `exoplanet-archive-imaged-${createHash('sha1').update(IMAGED_URL).digest('hex').slice(0, 8)}.csv`;
|
||||
|
||||
/**
|
||||
* The host's columns from the Planetary Systems Composite table, for the cells a planet's
|
||||
* default row leaves blank and for the columns that query does not ask for.
|
||||
@@ -112,6 +122,7 @@ export async function fetchExoplanets(stars?: StarRecord[]): Promise<{ exoplanet
|
||||
const rows = parseCsvObjects(csv);
|
||||
const composite = new Map(parseCsvObjects(await fetchTextCached(COMPOSITE_URL, COMPOSITE_CACHE_FILE)).map((row) => [row['pl_name'], row]));
|
||||
const distanceErrors = new Map(parseCsvObjects(await fetchTextCached(DISTANCE_ERRORS_URL, DISTANCE_ERRORS_CACHE_FILE)).map((row) => [row['pl_name'], row]));
|
||||
const imaged = new Set(parseCsvObjects(await fetchTextCached(IMAGED_URL, IMAGED_CACHE_FILE)).map((row) => row['pl_name']));
|
||||
|
||||
let matched = 0;
|
||||
// Catalogue stars known only by their Gaia designation, which take the archive's host name —
|
||||
@@ -188,6 +199,7 @@ export async function fetchExoplanets(stars?: StarRecord[]): Promise<{ exoplanet
|
||||
radiusEarth: parseOptionalNumber(row['pl_rade']),
|
||||
massEarth: parseOptionalNumber(row['pl_bmasse']),
|
||||
discoveryYear: parseOptionalNumber(row['disc_year']),
|
||||
imaged: imaged.has(row['pl_name']) || undefined,
|
||||
// The period was already being downloaded and thrown away. With the semi-major axis it
|
||||
// determines the host's gravitational parameter, so keeping it is the difference between
|
||||
// propagating a planet at its real rate and pretending every host is the Sun.
|
||||
|
||||
+275
-29
@@ -1,13 +1,19 @@
|
||||
import { writeFileSync } from 'node:fs';
|
||||
|
||||
import { BodyRecord } from '../../src/app/shared/models/body.model';
|
||||
import { gmForParent } from '../../src/app/shared/astro/constants';
|
||||
import { orbitalPeriodDays } from '../../src/app/shared/astro/kepler';
|
||||
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 { fetchHorizonsBody, fetchHorizonsTrack, TRACK_START_YEAR, TRACK_STOP_YEAR, TrackPoint } from './lib/horizons';
|
||||
import { eclipticToEquatorial, laplacePlaneToEquatorial } from '../../src/app/shared/astro/coordinates';
|
||||
import { meanElementsAt, positionAtEpoch } from '../../src/app/shared/astro/kepler';
|
||||
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, SUN_ROTATIONAL_ELEMENTS } from '../../src/app/shared/astro/rotational-elements';
|
||||
import { fetchPckText } from './lib/pck';
|
||||
import { lockedToOrbit } from './lib/locked-spin';
|
||||
import { dataPath, ensureDataDir } from './lib/paths';
|
||||
|
||||
const HOURS_PER_DAY = 24;
|
||||
const DAYS_PER_JULIAN_YEAR = 365.25;
|
||||
|
||||
interface BodySpec {
|
||||
id: string;
|
||||
@@ -21,8 +27,95 @@ interface BodySpec {
|
||||
* 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 triaxial body's semi-axes, in km, largest first, where its card should give its shape; see `BodyRecord.semiAxesKm`. */
|
||||
semiAxesKm?: [number, number, 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 node's period, in Julian years, where the row's is out of date. The archived table the rows
|
||||
* are read from (see `fetchSatelliteMeanElementsHtml`) has older node periods for four moons than
|
||||
* JPL's current one (ssd.jpl.nasa.gov/sats/elem), and Horizons and the IAU agree with the current
|
||||
* ones: Miranda 17.787 years there (URA182) against the row's 17.727, Ganymede 137.812 (JUP365)
|
||||
* against 132.654, Callisto 577.264 against 338.82, Titan 687.370 (SAT441) against 704.60. Fitted
|
||||
* to Horizons' osculating elements on Uranus's equator over 1601-2399, Miranda's node turns
|
||||
* 2023.97 degrees a century (rms 0.04): the current table's 2023.95, the IAU's U11 2024.22, the
|
||||
* row's 2030.80. On the row's rate Miranda's drawn orbit was 2.0 degrees from Horizons' at 1601
|
||||
* and 2.1 at 2399, and the axis `lockedToOrbit` turned after it 2.4 at 1601; on this one, at most 0.12 and 0.34 over 1601-2399.
|
||||
*
|
||||
* The row's periapsis turns at its argument's rate from that node, and it is the longitude, node
|
||||
* plus argument, the row gives the rate of: Callisto's turns 68.7 degrees a century in the row and
|
||||
* 67.2 in the current table, where their arguments turn at 175.0 and 129.5. So the argument takes
|
||||
* up what the node's rate gives: on the new node and the row's argument, Callisto's periapsis
|
||||
* moved 44 degrees by 2100 and Callisto strayed 0.71 degrees from Horizons over 1950-2100 (0.19
|
||||
* before); keeping the row's longitude, 0.08.
|
||||
*/
|
||||
nodePeriodYears?: 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 };
|
||||
/** A locked moon whose pole is carried round with its orbit's, as Iapetus's; see `lockedToOrbit`. */
|
||||
poleFollowsOrbit?: boolean;
|
||||
/**
|
||||
* Days between the Horizons positions the orbit is checked against from 1950 to 2100; 2 unless
|
||||
* the error changes faster than that. Nereid, at an eccentricity of 0.75, sweeps through its
|
||||
* periapsis, where the mean ellipse is furthest out, in days; Hyperion, on a row whose
|
||||
* eccentricity is a quarter of its real one, peaks within a day too (22.23 degrees sampled daily
|
||||
* where every other day gave 22.14).
|
||||
*/
|
||||
trackStepDays?: 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 };
|
||||
|
||||
/**
|
||||
* Mimas's node period, 0.986 years in both JPL's tables, is given to three figures: anywhere from
|
||||
* 36 493 to 36 530 degrees a century. It takes the IAU's S3, 36 505.5, which a fit to Horizons'
|
||||
* osculating elements on Saturn's equator over 1750-2249, 36 506.7, is 1.2 from; the row's figure,
|
||||
* 36 511.2, is 4.5.
|
||||
*/
|
||||
const MIMAS_NODE_PERIOD_YEARS = 36000 / 36505.5;
|
||||
|
||||
/**
|
||||
* 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' },
|
||||
@@ -34,25 +127,95 @@ const BODY_SPECS: BodySpec[] = [
|
||||
{ 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.
|
||||
//
|
||||
// Makemake's day, the SBDB's 22.83 hours, carries the same flag and is not settled either: it is
|
||||
// the double-peaked reading Hromakina et al. 2019 (A&A 625, A46) give as "possible" of a light
|
||||
// curve that repeats every 11.4 hours. Kiss et al. 2024 (ApJL, arXiv:2410.22544) find that 11.40
|
||||
// +/- 0.08 hour single peak again with TESS and Gaia, cannot confirm the 22.8, and take 11.4 as
|
||||
// their default. Neither overturns the other; the SBDB's 22.83 is kept, and may be twice the day.
|
||||
{ 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, semiAxesKm: [1161, 852, 513] },
|
||||
{ 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: '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' },
|
||||
{ id: 'europa', name: 'Europa', kind: 'moon', horizonsCommand: '502', center: '500@599', parentBodyId: 'jupiter' },
|
||||
{ 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: 'titan', name: 'Titan', kind: 'moon', horizonsCommand: '606', center: '500@699', parentBodyId: 'saturn' },
|
||||
{ id: 'triton', name: 'Triton', kind: 'moon', horizonsCommand: '801', center: '500@899', parentBodyId: 'neptune' }
|
||||
{ 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', nodePeriodYears: 137.812 },
|
||||
{ id: 'callisto', name: 'Callisto', kind: 'moon', horizonsCommand: '504', center: '500@599', parentBodyId: 'jupiter', nodePeriodYears: 577.264 },
|
||||
{ id: 'mimas', name: 'Mimas', kind: 'moon', horizonsCommand: '601', center: '500@699', parentBodyId: 'saturn', orbitFromW: MIMAS_TETHYS_LIBRATION, nodePeriodYears: MIMAS_NODE_PERIOD_YEARS },
|
||||
{ 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', nodePeriodYears: 687.37 },
|
||||
// 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, trackStepDays: 1 },
|
||||
{ id: 'iapetus', name: 'Iapetus', kind: 'moon', horizonsCommand: '608', center: '500@699', parentBodyId: 'saturn', poleFollowsOrbit: true },
|
||||
// Phoebe's row gives n = 0.6569114 degrees a day as the table defines it, the rate of its mean
|
||||
// longitude, node plus periapsis plus mean anomaly, and its P, 548.02 days, is 360 over that. Its
|
||||
// sidereal period is 550.30 (its Horizons page and JPL's current table, SAT441): n less twice its
|
||||
// node's rate, 0.6541855, against 360 / 550.30391 = 0.6541840. Triton's row gives its sidereal
|
||||
// rate as n instead, and the propagator reads a retrograde moon's n as that (see
|
||||
// `meanElementsAt`): on the row's n Phoebe ran twice its node's rate too fast, 25 degrees from
|
||||
// Horizons by 2025 and 100 by 2075. On the sidereal period it is within 2.6 from 1950 to 2100
|
||||
// (2.58 in 1969). The table's note on misstated retrograde mean motions is about another source,
|
||||
// Jacobson 2000 on Jupiter's outer moons, and says the table carries the corrected values.
|
||||
{ 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, nodePeriodYears: 17.787 },
|
||||
{ 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, but
|
||||
// Kepler's K2 light curve gives 11.594 +/- 0.017 hours, confirming the short periods measured
|
||||
// from the ground (Kiss et al. 2016, MNRAS 457, 2908; arXiv:1601.02395). No pole is known.
|
||||
{ id: 'nereid', name: 'Nereid', kind: 'moon', horizonsCommand: '802', center: '500@899', parentBodyId: 'neptune', spinsFreely: true, rotationPeriodHours: 11.594, trackStepDays: 1 },
|
||||
{ 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));
|
||||
|
||||
/**
|
||||
* Queries JPL Horizons for the osculating orbital elements (and mean radius, where
|
||||
* reported) of the major planets, Pluto, and a curated set of major moons, and writes
|
||||
* `bodies.json`.
|
||||
* 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<BodyRecord[]> {
|
||||
console.log(`Fetching ${BODY_SPECS.length} solar-system bodies from JPL Horizons...`);
|
||||
export async function fetchSolarSystem(): Promise<{ bodies: BodyRecord[]; horizonsOrbits: Map<string, OrbitalElements>; horizonsTracks: Map<string, TrackPoint[]> }> {
|
||||
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 horizonsTracks = new Map<string, TrackPoint[]>();
|
||||
const planetElements = await fetchPlanetMeanElementsText();
|
||||
const satelliteElements = await fetchSatelliteMeanElementsHtml();
|
||||
const pck = await fetchPckText();
|
||||
// The app turns the Sun by elements it carries itself; they must be the kernel's.
|
||||
if (JSON.stringify(parsePckRotationalElements(pck, 10)?.elements) !== JSON.stringify(SUN_ROTATIONAL_ELEMENTS)) {
|
||||
throw new Error(`The Sun's rotational elements in the app, ${JSON.stringify(SUN_ROTATIONAL_ELEMENTS)}, are not the kernel's.`);
|
||||
}
|
||||
const gmById = new Map<string, number | undefined>();
|
||||
|
||||
for (const spec of BODY_SPECS) {
|
||||
const result = await fetchHorizonsBody({
|
||||
@@ -61,18 +224,86 @@ export async function fetchSolarSystem(): Promise<BodyRecord[]> {
|
||||
cacheKey: `horizons-${spec.id}.txt`
|
||||
});
|
||||
|
||||
if (result.radiusKm === undefined) {
|
||||
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 nodeRate = spec.nodePeriodYears
|
||||
? Math.sign(read.rates.longitudeOfAscendingNodeDegPerDay) * (360 / (spec.nodePeriodYears * DAYS_PER_JULIAN_YEAR))
|
||||
: read.rates.longitudeOfAscendingNodeDegPerDay;
|
||||
const corrected: MeanOrbit = {
|
||||
...read,
|
||||
orbit: {
|
||||
...read.orbit,
|
||||
longitudeOfAscendingNodeDeg: read.orbit.longitudeOfAscendingNodeDeg + (spec.nodeOffsetDeg ?? 0),
|
||||
epochJd: spec.epochJd ?? read.orbit.epochJd
|
||||
},
|
||||
rates: {
|
||||
...read.rates,
|
||||
...(spec.periodDays ? { meanMotionDegPerDay: 360 / spec.periodDays } : {}),
|
||||
longitudeOfAscendingNodeDegPerDay: nodeRate,
|
||||
// The periapsis's longitude keeps the row's rate; see `nodePeriodYears`.
|
||||
argumentOfPeriapsisDegPerDay: read.rates.argumentOfPeriapsisDegPerDay + (read.rates.longitudeOfAscendingNodeDegPerDay - nodeRate)
|
||||
}
|
||||
};
|
||||
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;
|
||||
|
||||
// Standish's fit states its own span, 3000 BC to AD 3000. The moons' table and the SBDB state
|
||||
// none, and hold for far less: each card says how far its orbit stays from Horizons over the
|
||||
// span it was measured, where the clock reaches AD 1 to AD 3000. An orbit that took terms from
|
||||
// its IAU W says so first: they move Mimas by up to 44.85 degrees, and are none of JPL's table.
|
||||
let orbitSource = fromW ? `${mean.orbitSource}, with the orbital terms of its IAU W (NAIF pck00011)` : mean.orbitSource;
|
||||
if (parentName || smallBody) {
|
||||
const stepDays = spec.trackStepDays ?? 2;
|
||||
const track = await fetchHorizonsTrack(spec.horizonsCommand, spec.center, stepDays, `horizons-track-${spec.id}-${stepDays}d.txt`);
|
||||
horizonsTracks.set(spec.id, track);
|
||||
const worst = Math.max(...track.map((point) => offsetFromTrackDeg(mean, point)));
|
||||
orbitSource += `, within ${(Math.ceil(worst * 10) / 10).toFixed(1)} degrees of Horizons from ${TRACK_START_YEAR} to ${TRACK_STOP_YEAR}`;
|
||||
}
|
||||
const radiusKm = smallBody?.radiusKm ?? spec.radiusKm ?? result.radiusKm;
|
||||
if (radiusKm === undefined) {
|
||||
console.warn(` no physical radius found for ${spec.name}; defaulting to 0.`);
|
||||
}
|
||||
|
||||
// Every moon listed here is tidally locked, so its day is its orbit — as drawn, from these
|
||||
// elements and the parent's mass by Kepler. Not every page says so: the Moon's gives a rate,
|
||||
// the true sidereal month, 1.4% off the orbit these elements trace, so its face drifted five
|
||||
// degrees an orbit; Titan's gives nothing, so it did not turn. Taking the orbit keeps one face
|
||||
// towards the parent, which is what synchronous means.
|
||||
const rotationPeriodHours = result.tidallyLocked || spec.kind === 'moon'
|
||||
? orbitalPeriodDays(result.orbit.semiMajorAxisAu, gmForParent(spec.parentBodyId)) * HOURS_PER_DAY
|
||||
: result.rotationPeriodHours;
|
||||
// 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, at this same rate (see `lockedToOrbit`), and `build.ts` checks that W and
|
||||
// the orbit keep its face to its planet from AD 1 to 3000; this day is what the renderer would
|
||||
// turn a moon without W by.
|
||||
const locked = spec.kind === 'moon' && !spec.spinsFreely;
|
||||
const rotationPeriodHours = result.tidallyLocked || locked
|
||||
? (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.`);
|
||||
}
|
||||
@@ -82,18 +313,33 @@ export async function fetchSolarSystem(): Promise<BodyRecord[]> {
|
||||
systemStarId: SUN_STAR_ID,
|
||||
name: spec.name,
|
||||
kind: spec.kind,
|
||||
radiusKm: result.radiusKm ?? 0,
|
||||
orbit: result.orbit,
|
||||
radiusKm: radiusKm ?? 0,
|
||||
...(spec.semiAxesKm ? { semiAxesKm: spec.semiAxesKm } : {}),
|
||||
orbit: mean.orbit,
|
||||
rates: mean.rates,
|
||||
...(mean.laplacePole ? { laplacePole: mean.laplacePole } : {}),
|
||||
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 } : {})
|
||||
...((result.obliquityDeg ?? spec.obliquityDeg) !== undefined ? { obliquityDeg: result.obliquityDeg ?? spec.obliquityDeg } : {}),
|
||||
...(rotation ? { rotationalElements: locked ? lockedToOrbit(rotation.elements, mean, spec.name, spec.poleFollowsOrbit) : rotation.elements } : {})
|
||||
});
|
||||
}
|
||||
|
||||
ensureDataDir();
|
||||
writeFileSync(dataPath('bodies.json'), JSON.stringify(bodies, null, 2));
|
||||
console.log(` wrote ${bodies.length} bodies.`);
|
||||
return bodies;
|
||||
return { bodies, horizonsOrbits, horizonsTracks };
|
||||
}
|
||||
|
||||
/** Degrees between where a moon's or dwarf planet's mean elements put it and where Horizons has it. */
|
||||
export function offsetFromTrackDeg(mean: Pick<MeanOrbit, 'orbit' | 'rates' | 'laplacePole'>, point: TrackPoint): number {
|
||||
const own = positionAtEpoch(meanElementsAt(mean.orbit, mean.rates, point.jd));
|
||||
const place = mean.laplacePole ? laplacePlaneToEquatorial(own, mean.laplacePole) : eclipticToEquatorial(own);
|
||||
const cosine = (place.x * point.x + place.y * point.y + place.z * point.z) / (Math.hypot(place.x, place.y, place.z) * Math.hypot(point.x, point.y, point.z));
|
||||
return (Math.acos(Math.min(1, Math.max(-1, cosine))) * 180) / Math.PI;
|
||||
}
|
||||
|
||||
if (require.main === module) {
|
||||
|
||||
+44
-77
@@ -1,4 +1,5 @@
|
||||
import { OrbitalElements } from '../../../src/app/shared/models/body.model';
|
||||
import { extractGmKm3PerS2, extractObliquityDeg, extractRadiusKm, extractRotationPeriodHours, isTidallyLocked } from '../../../src/app/shared/astro/horizons-page';
|
||||
import { fetchTextCached } from './http';
|
||||
|
||||
const HORIZONS_URL = 'https://ssd.jpl.nasa.gov/api/horizons.api';
|
||||
@@ -10,7 +11,7 @@ const REFERENCE_START = '2025-01-01';
|
||||
const REFERENCE_STOP = '2025-01-02';
|
||||
|
||||
export interface HorizonsQuery {
|
||||
/** Horizons body id, e.g. `'499'` for Mars. */
|
||||
/** Horizons body id, e.g. `'499'` for Mars, or a small body's number and a semicolon, `'1;'` for Ceres. */
|
||||
command: string;
|
||||
/** Horizons coordinate center, e.g. `'500@10'` (Sun) or `'500@399'` (Earth). */
|
||||
center: string;
|
||||
@@ -30,70 +31,8 @@ export interface HorizonsResult {
|
||||
obliquityDeg?: number;
|
||||
/** The page says "Synchronous" instead of a period: its day is its orbit. */
|
||||
tidallyLocked: boolean;
|
||||
}
|
||||
|
||||
const RADIUS_PATTERNS = [
|
||||
/Vol\.?\s*mean\s*radius[^=]*=\s*([\d.]+)/i,
|
||||
/Mean\s*radius[^=]*=\s*([\d.]+)/i,
|
||||
/Radius\s*\(IAU\)[^=]*=\s*([\d.]+)/i,
|
||||
/Radius,?\s*\(km\)\s*=\s*([\d.]+)/i,
|
||||
/Radius\s*\(gravity\),?\s*km\s*=\s*([\d.]+)/i
|
||||
];
|
||||
|
||||
/**
|
||||
* How each page states how fast the body turns, in the order they are tried.
|
||||
*
|
||||
* The rate in radians per second is preferred wherever it appears: it is unambiguous and it is
|
||||
* signed: Venus and Uranus carry a negative one. A period in hours or days
|
||||
* comes next, then the sexagesimal form the giant planets use, and finally the word most moons carry
|
||||
* instead of a number, Synchronous. Not all do — the Moon's page gives a rate, Titan's nothing —
|
||||
* so the caller treats every moon it lists as locked whatever its page says.
|
||||
*/
|
||||
const ROTATION_RATE_PATTERN = /Rot(?:ational)?\.?\s*Rate\s*[(,]\s*rad\/s\s*\)?\s*=\s*(-?[\d.]+)/i;
|
||||
const ROTATION_PERIOD_PATTERNS = [
|
||||
/Sid(?:ereal|\.)?\s*rot\.?\s*period[^=]*=\s*(-?[\d.]+)(?:\+-[\d.]+)?\s*(h|hr|hrs|d|day|days)\b/i,
|
||||
/Rotation(?:al)?\s*period[^=]*=\s*(-?[\d.]+)\s*(h|hr|hrs|d|day|days)\b/i
|
||||
];
|
||||
/** `9h 55m 29.711 s`, as Jupiter and Saturn state it. */
|
||||
const SEXAGESIMAL_ROTATION_PATTERN = /Sid(?:ereal|\.)?\s*rot\.?\s*period[^=]*=\s*(\d+)\s*h\s*(\d+)\s*m\s*([\d.]+)\s*s/i;
|
||||
const SYNCHRONOUS_PATTERN = /Rotation(?:al)?\s*period\s*=?\s*:?\s*Synchronous/i;
|
||||
const OBLIQUITY_PATTERN = /Obliquity\s*to\s*orbit[^=]*=\s*(-?[\d.]+)/i;
|
||||
|
||||
const HOURS_PER_DAY = 24;
|
||||
const SECONDS_PER_HOUR = 3600;
|
||||
|
||||
/**
|
||||
* True where the page gives no number because the body keeps one face to its parent, so its day
|
||||
* is its orbit. The period itself is then Kepler's, which the caller
|
||||
* works out from the elements above and the parent's mass.
|
||||
*/
|
||||
export function isTidallyLocked(text: string): boolean {
|
||||
return SYNCHRONOUS_PATTERN.test(text);
|
||||
}
|
||||
|
||||
/** Sidereal rotation period, in hours, from whichever form the page states it in. */
|
||||
export function extractRotationPeriodHours(text: string): number | undefined {
|
||||
const rate = text.match(ROTATION_RATE_PATTERN);
|
||||
if (rate && Number(rate[1]) !== 0) {
|
||||
return (2 * Math.PI) / (Number(rate[1]) * SECONDS_PER_HOUR);
|
||||
}
|
||||
const sexagesimal = text.match(SEXAGESIMAL_ROTATION_PATTERN);
|
||||
if (sexagesimal) {
|
||||
return Number(sexagesimal[1]) + Number(sexagesimal[2]) / 60 + Number(sexagesimal[3]) / SECONDS_PER_HOUR;
|
||||
}
|
||||
for (const pattern of ROTATION_PERIOD_PATTERNS) {
|
||||
const match = text.match(pattern);
|
||||
if (match) {
|
||||
const hours = Number(match[1]) * (match[2].toLowerCase().startsWith('d') ? HOURS_PER_DAY : 1);
|
||||
return Number.isFinite(hours) && hours !== 0 ? hours : undefined;
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export function extractObliquityDeg(text: string): number | undefined {
|
||||
const match = text.match(OBLIQUITY_PATTERN);
|
||||
return match ? Number(match[1]) : undefined;
|
||||
/** The body's own GM, km³/s², where the page states one: what sets where a pair's barycentre lies. */
|
||||
gmKm3PerS2?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -103,7 +42,7 @@ export function extractObliquityDeg(text: string): number | undefined {
|
||||
*/
|
||||
export async function fetchHorizonsBody(query: HorizonsQuery): Promise<HorizonsResult> {
|
||||
const url =
|
||||
`${HORIZONS_URL}?format=text&COMMAND='${query.command}'&OBJ_DATA='YES'` +
|
||||
`${HORIZONS_URL}?format=text&COMMAND='${encodeURIComponent(query.command)}'&OBJ_DATA='YES'` +
|
||||
`&MAKE_EPHEM='YES'&EPHEM_TYPE='ELEMENTS'&CENTER='${query.center}'` +
|
||||
`&START_TIME='${REFERENCE_START}'&STOP_TIME='${REFERENCE_STOP}'&STEP_SIZE='1d'`;
|
||||
|
||||
@@ -113,20 +52,11 @@ export async function fetchHorizonsBody(query: HorizonsQuery): Promise<HorizonsR
|
||||
orbit: extractOrbitalElements(text),
|
||||
rotationPeriodHours: extractRotationPeriodHours(text),
|
||||
obliquityDeg: extractObliquityDeg(text),
|
||||
tidallyLocked: isTidallyLocked(text)
|
||||
tidallyLocked: isTidallyLocked(text),
|
||||
gmKm3PerS2: extractGmKm3PerS2(text)
|
||||
};
|
||||
}
|
||||
|
||||
function extractRadiusKm(text: string): number | undefined {
|
||||
for (const pattern of RADIUS_PATTERNS) {
|
||||
const match = text.match(pattern);
|
||||
if (match) {
|
||||
return Number(match[1]);
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function extractOrbitalElements(text: string): OrbitalElements {
|
||||
const startIndex = text.indexOf('$$SOE');
|
||||
const endIndex = text.indexOf('$$EOE');
|
||||
@@ -163,3 +93,40 @@ function extractNumber(text: string, pattern: RegExp): number {
|
||||
}
|
||||
return Number(match[1]);
|
||||
}
|
||||
|
||||
/** The span a moon's or dwarf planet's mean elements are checked against Horizons over, and the card names. */
|
||||
export const TRACK_START_YEAR = 1950;
|
||||
export const TRACK_STOP_YEAR = 2100;
|
||||
|
||||
/** One Horizons position: TDB Julian date, and ICRF equatorial coordinates in AU from the centre. */
|
||||
export interface TrackPoint {
|
||||
jd: number;
|
||||
x: number;
|
||||
y: number;
|
||||
z: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Where Horizons has a body, from its centre, every `stepDays` from 1950 to 2100: the ephemeris the
|
||||
* mean elements are checked against over the whole span, where one date saw a moon at its best.
|
||||
*/
|
||||
export async function fetchHorizonsTrack(command: string, center: string, stepDays: number, cacheKey: string): Promise<TrackPoint[]> {
|
||||
const url =
|
||||
`${HORIZONS_URL}?format=text&COMMAND='${encodeURIComponent(command)}'&OBJ_DATA='NO'&MAKE_EPHEM='YES'` +
|
||||
`&EPHEM_TYPE='VECTORS'&CENTER='${center}'&START_TIME='${TRACK_START_YEAR}-01-01'&STOP_TIME='${TRACK_STOP_YEAR}-01-01'` +
|
||||
`&STEP_SIZE='${stepDays}%20d'&REF_PLANE='FRAME'&REF_SYSTEM='ICRF'&VEC_TABLE='1'&OUT_UNITS='AU-D'&CSV_FORMAT='YES'&VEC_CORR='NONE'`;
|
||||
const text = await fetchTextCached(url, cacheKey);
|
||||
const startIndex = text.indexOf('$$SOE');
|
||||
const endIndex = text.indexOf('$$EOE');
|
||||
if (startIndex === -1 || endIndex === -1) {
|
||||
throw new Error(`Horizons gave no vectors for ${command} from ${center}: ${text.slice(0, 300)}`);
|
||||
}
|
||||
return text
|
||||
.slice(startIndex + '$$SOE'.length, endIndex)
|
||||
.trim()
|
||||
.split(/\r?\n/)
|
||||
.map((row) => {
|
||||
const [jd, , x, y, z] = row.split(',').map((field) => field.trim());
|
||||
return { jd: Number(jd), x: Number(x), y: Number(y), z: Number(z) };
|
||||
});
|
||||
}
|
||||
|
||||
@@ -0,0 +1,188 @@
|
||||
import { eclipticToEquatorial, laplacePlaneToEquatorial } from '../../../src/app/shared/astro/coordinates';
|
||||
import { meanElementsAt, positionAtEpoch } from '../../../src/app/shared/astro/kepler';
|
||||
import { MeanOrbit } from '../../../src/app/shared/astro/mean-elements';
|
||||
import { orientationAt } from '../../../src/app/shared/astro/rotational-elements';
|
||||
import { BodyRecord, RotationalElements } from '../../../src/app/shared/models/body.model';
|
||||
|
||||
const J2000_JD = 2451545;
|
||||
const DAYS_PER_JULIAN_CENTURY = 36525;
|
||||
const DEG_TO_RAD = Math.PI / 180;
|
||||
|
||||
/** 2025-01-01, the date the ETL asks Horizons about: where a locked moon's W is left as the IAU has it. */
|
||||
export const PRESENT_JD = 2460676.5;
|
||||
|
||||
/**
|
||||
* How far the IAU's W rate for a locked moon may be from the mean motion its orbit is drawn at, as
|
||||
* a fraction of it, before it is taken for the orbit's. Measured: at most 3.4e-6 (Iapetus; Proteus
|
||||
* 6.3e-7). What this catches is a rate read for the wrong body: Oberon's for Titania's is 55 per cent out.
|
||||
*/
|
||||
const MAX_LOCKED_RATE_OFFSET = 1e-5;
|
||||
|
||||
/** Harmonics of the node's angle that carry a pole round its orbit's; see {@link lockedToOrbit}. */
|
||||
const POLE_HARMONICS = 5;
|
||||
|
||||
/**
|
||||
* How far a periodic term's angle may turn from a multiple of the node's rate, as a fraction of it,
|
||||
* and still be taken for the node's angle as the IAU's source had it. Measured: at most 3.1e-2
|
||||
* (Callisto's J6), then Rhea's R4 1.2e-2 and Ganymede's J5 3.3e-3; the nearest that is not a node is
|
||||
* a term of Miranda's W alone, 6.0e-2 from three times it. Multiples go up to the ninth, the most the
|
||||
* report takes (Triton's N7); past that, Umbriel's W has a term 1.0e-2 from ten times its node's.
|
||||
*/
|
||||
const MAX_NODE_RATE_OFFSET = 0.05;
|
||||
const MAX_NODE_HARMONIC = 9;
|
||||
|
||||
/** How far, in degrees, re-rating may move a locked moon's pole or W at {@link PRESENT_JD}; see {@link lockedToOrbit}. */
|
||||
const MAX_PRESENT_OFFSET_DEG = 1e-6;
|
||||
|
||||
/** The planet's east longitude on a moon's IAU body-fixed frame, from the moon's mean place, at a TDB date. */
|
||||
export function subPlanetLongitudeDeg(body: Pick<BodyRecord, 'orbit' | 'rates' | 'laplacePole'>, elements: RotationalElements, jd: number): number {
|
||||
const own = positionAtEpoch(meanElementsAt(body.orbit, body.rates, jd));
|
||||
const place = body.laplacePole ? laplacePlaneToEquatorial(own, body.laplacePole) : eclipticToEquatorial(own);
|
||||
const { poleRaDeg, poleDecDeg, primeMeridianDeg } = orientationAt(elements, jd);
|
||||
const pole = { raDeg: poleRaDeg, decDeg: poleDecDeg };
|
||||
const w = primeMeridianDeg * DEG_TO_RAD;
|
||||
const meridian = laplacePlaneToEquatorial({ x: Math.cos(w), y: Math.sin(w), z: 0 }, pole);
|
||||
const east = laplacePlaneToEquatorial({ x: -Math.sin(w), y: Math.cos(w), z: 0 }, pole);
|
||||
const along = (axis: { x: number; y: number; z: number }) => -(place.x * axis.x + place.y * axis.y + place.z * axis.z);
|
||||
return Math.atan2(along(east), along(meridian)) / DEG_TO_RAD;
|
||||
}
|
||||
|
||||
/**
|
||||
* A locked moon's IAU elements, turned at the rate its orbit is drawn at, so it keeps its face to
|
||||
* its planet over the clock's AD 1 to 3000 and not only near the present its W was fitted to.
|
||||
*
|
||||
* The report gives a locked moon's W the mean motion of whichever orbit its authors had, and JPL's
|
||||
* table has another: Proteus's W turns 6.3e-7 of its rate slower than its row, which turned its far
|
||||
* side to Neptune at AD 1 (146 degrees), Mimas's 1.6e-7 faster (52 at AD 1) and Miranda's (23).
|
||||
* W's rate is set to the orbit's here, its constant moved so W is unchanged at {@link PRESENT_JD}.
|
||||
* Measured over AD 1-3000: Proteus 2.7 degrees, Mimas 8.9, Miranda 2.4, Ariel 1.0. A W with a
|
||||
* quadratic is left: Phobos's orbit already takes the quadratic from W (see
|
||||
* `orbitalTermsOfPrimeMeridian`), and the Moon's, its tidal slowing, is 0.75 degrees at AD 1.
|
||||
*
|
||||
* The node's angle goes the same way. A moon in a Cassini state keeps its axis on its orbit normal,
|
||||
* which goes round the Laplace pole with the node, and the IAU's pole goes round with it on a term
|
||||
* of the node's angle, at the node's rate as its source had it, not quite JPL's current one the
|
||||
* orbit is drawn at (see `nodePeriodYears` in `fetchSolarSystem.ts`): Rhea's R4 turns 1.2 per cent
|
||||
* faster than its node, Callisto's J6 3.1 per cent, and Miranda's U11 0.013 per cent, which on a
|
||||
* 4.4-degree circle over twenty centuries still adds up. On the IAU's rates the axes part from the
|
||||
* drawn orbits by AD 1 or 3000: Rhea's by 0.77 degrees, Miranda's 0.59, Triton's 0.51, Europa's and
|
||||
* Callisto's 0.33. Every term whose angle turns within {@link MAX_NODE_RATE_OFFSET} of a multiple of
|
||||
* the node's rate is set to that multiple, its constant moved so the angle is unchanged at the
|
||||
* present, and the pole with it: over AD 1-3000 the axes of Io, Europa, Ganymede, Callisto, Rhea,
|
||||
* Miranda and Triton stay within 0.23 degrees of their orbit normals, and Mimas's, whose drawn node
|
||||
* takes the IAU's S3 itself, within 0.44. The rest of the pole and its terms are the IAU's, and so
|
||||
* are all of the Moon's and Phobos's, left whole with their W: Ariel's, Umbriel's, Titania's and
|
||||
* Oberon's poles go round on angles of their own at none of their nodes' multiples (Oberon's, the
|
||||
* nearest, 8.7 per cent from three times its node's rate), and their axes stay within 0.50 degrees
|
||||
* of their orbit normals without.
|
||||
*
|
||||
* `poleFollowsOrbit` is for Iapetus, whose IAU pole moves 3.9 degrees a century in right ascension
|
||||
* and 1.1 in declination: a straight line through its orbit normal's 3 439-year circle round the
|
||||
* Laplace pole, 8.3 degrees in radius (16.6 across), which by AD 1 has run past the celestial pole (Dec 97.9) and 11
|
||||
* degrees off the orbit, and turned its face 87 degrees from Saturn. Its axis sits on its orbit normal
|
||||
* (0.04 degrees apart today), as a moon in a Cassini state keeps it, so its pole is given the circle: the
|
||||
* normal's right ascension as sines and declination as cosines of the node's angle and its first
|
||||
* {@link POLE_HARMONICS} harmonics, the IAU's own form for a precessing pole, with its constants
|
||||
* set so the pole is the IAU's at the present. W counts from where the equator crosses the ICRF
|
||||
* equator, which swings as the pole goes round, so W takes sines of the same angles, fitted to hold
|
||||
* the face where it is today. Measured over AD 1-3000: the axis within 0.74 degrees of the orbit
|
||||
* normal (the IAU's line, 11.06), and the face within 16 of Saturn (87), which is what the row's own
|
||||
* 9.4-degree lag and its eccentricity make it from 1950 to 2100 as well (15.9).
|
||||
*/
|
||||
export function lockedToOrbit(elements: RotationalElements, mean: Pick<MeanOrbit, 'orbit' | 'rates' | 'laplacePole'>, name: string, poleFollowsOrbit = false): RotationalElements {
|
||||
const [w0, w1, w2 = 0] = elements.primeMeridianDeg;
|
||||
if (w2 !== 0) {
|
||||
return elements;
|
||||
}
|
||||
const n = Math.sign(w1) * mean.rates.meanMotionDegPerDay;
|
||||
if (Math.abs(w1 / n - 1) > MAX_LOCKED_RATE_OFFSET) {
|
||||
throw new Error(`${name}'s IAU W turns at ${w1} degrees a day, ${Math.abs(w1 / n - 1).toExponential(2)} of its orbit's ${n}: not the rate of the orbit it keeps its face to.`);
|
||||
}
|
||||
const nodeRate = mean.rates.longitudeOfAscendingNodeDegPerDay * DAYS_PER_JULIAN_CENTURY;
|
||||
const present = (PRESENT_JD - J2000_JD) / DAYS_PER_JULIAN_CENTURY;
|
||||
const terms = elements.terms?.map((term) => {
|
||||
const [constant, rate, quadratic = 0] = term.angleDeg;
|
||||
const k = Math.round(rate / nodeRate);
|
||||
if (quadratic !== 0 || k === 0 || Math.abs(k) > MAX_NODE_HARMONIC || Math.abs(rate / (k * nodeRate) - 1) > MAX_NODE_RATE_OFFSET) {
|
||||
return term;
|
||||
}
|
||||
return { ...term, angleDeg: [constant + (rate - k * nodeRate) * present, k * nodeRate] };
|
||||
});
|
||||
const locked: RotationalElements = { ...elements, primeMeridianDeg: [w0 + (w1 - n) * (PRESENT_JD - J2000_JD), n, 0], ...(terms ? { terms } : {}) };
|
||||
const turned = poleFollowsOrbit ? poleRoundOrbit(locked, mean) : locked;
|
||||
// Whatever is re-rated, the pole and W at the present are the kernel's, which is what they were
|
||||
// fitted to. Measured: at most 4.7e-10 degrees (Deimos's W, some 2.6 million degrees round).
|
||||
const [iau, own] = [elements, turned].map((each) => orientationAt(each, PRESENT_JD));
|
||||
const moved = Math.max(...(['poleRaDeg', 'poleDecDeg', 'primeMeridianDeg'] as const).map((key) => Math.abs(own[key] - iau[key])));
|
||||
if (moved > MAX_PRESENT_OFFSET_DEG) {
|
||||
throw new Error(`${name}'s pole or W on ${PRESENT_JD} is ${moved.toExponential(2)} degrees from the IAU's: re-rated, it should be where the kernel has it today.`);
|
||||
}
|
||||
return turned;
|
||||
}
|
||||
|
||||
function poleRoundOrbit(elements: RotationalElements, mean: Pick<MeanOrbit, 'orbit' | 'rates' | 'laplacePole'>): RotationalElements {
|
||||
if (!mean.laplacePole) {
|
||||
throw new Error('A pole that follows its orbit is carried round the orbit\'s Laplace pole, and this orbit has none.');
|
||||
}
|
||||
const laplacePole = mean.laplacePole;
|
||||
const normalAt = (jd: number) => {
|
||||
const { inclinationDeg, longitudeOfAscendingNodeDeg } = meanElementsAt(mean.orbit, mean.rates, jd);
|
||||
const tilt = inclinationDeg * DEG_TO_RAD;
|
||||
const node = longitudeOfAscendingNodeDeg * DEG_TO_RAD;
|
||||
const normal = laplacePlaneToEquatorial({ x: Math.sin(tilt) * Math.sin(node), y: -Math.sin(tilt) * Math.cos(node), z: Math.cos(tilt) }, laplacePole);
|
||||
return { raDeg: Math.atan2(normal.y, normal.x) / DEG_TO_RAD, decDeg: Math.asin(normal.z) / DEG_TO_RAD };
|
||||
};
|
||||
// The node's angle, T in centuries, turned so that 0 is where the normal is furthest north: the
|
||||
// circle is then even in declination and odd in right ascension about it, as the form requires.
|
||||
const nodeRate = mean.rates.longitudeOfAscendingNodeDegPerDay * DAYS_PER_JULIAN_CENTURY;
|
||||
const nodeAtJ2000 = meanElementsAt(mean.orbit, mean.rates, J2000_JD).longitudeOfAscendingNodeDeg;
|
||||
const jdAtAngle = (angleDeg: number, phaseDeg: number) => J2000_JD + ((angleDeg - phaseDeg - nodeAtJ2000) / nodeRate) * DAYS_PER_JULIAN_CENTURY;
|
||||
let phase = 0;
|
||||
let northmost = -Infinity;
|
||||
for (let candidate = 0; candidate < 360; candidate += 0.01) {
|
||||
const dec = normalAt(jdAtAngle(0, candidate)).decDeg;
|
||||
if (dec > northmost) {
|
||||
northmost = dec;
|
||||
phase = candidate;
|
||||
}
|
||||
}
|
||||
const centre = laplacePole;
|
||||
const samples = 3600;
|
||||
const ra = new Array<number>(POLE_HARMONICS + 1).fill(0);
|
||||
const dec = new Array<number>(POLE_HARMONICS + 1).fill(0);
|
||||
for (let sample = 0; sample < samples; sample++) {
|
||||
const angle = (sample / samples) * 360;
|
||||
const normal = normalAt(jdAtAngle(angle, phase));
|
||||
const raOffset = ((((normal.raDeg - centre.raDeg) % 360) + 540) % 360) - 180;
|
||||
for (let k = 0; k <= POLE_HARMONICS; k++) {
|
||||
ra[k] += (2 / samples) * raOffset * Math.sin(k * angle * DEG_TO_RAD);
|
||||
dec[k] += ((k === 0 ? 1 : 2) / samples) * (normal.decDeg - centre.decDeg) * Math.cos(k * angle * DEG_TO_RAD);
|
||||
}
|
||||
}
|
||||
const terms = Array.from({ length: POLE_HARMONICS }, (_, index) => {
|
||||
const k = index + 1;
|
||||
return { angleDeg: [k * (nodeAtJ2000 + phase), k * nodeRate], ra: ra[k], dec: dec[k], pm: 0 };
|
||||
});
|
||||
const round: RotationalElements = { ...elements, poleRaDeg: [centre.raDeg, 0, 0], poleDecDeg: [centre.decDeg + dec[0], 0, 0], terms: [...(elements.terms ?? []), ...terms] };
|
||||
// The IAU's pole at the present, exactly: the fitted circle's constants moved onto it.
|
||||
const iau = orientationAt(elements, PRESENT_JD);
|
||||
const fitted = orientationAt(round, PRESENT_JD);
|
||||
round.poleRaDeg = [round.poleRaDeg[0] + iau.poleRaDeg - fitted.poleRaDeg, 0, 0];
|
||||
round.poleDecDeg = [round.poleDecDeg[0] + iau.poleDecDeg - fitted.poleDecDeg, 0, 0];
|
||||
// W's sines on the same angles, fitted over a turn of the node to what the face drifts by.
|
||||
const pm = new Array<number>(POLE_HARMONICS + 1).fill(0);
|
||||
const wSamples = 36000;
|
||||
for (let sample = 0; sample < wSamples; sample++) {
|
||||
const angle = (sample / wSamples) * 360;
|
||||
const drift = subPlanetLongitudeDeg(mean, round, jdAtAngle(angle, phase));
|
||||
for (let k = 1; k <= POLE_HARMONICS; k++) {
|
||||
pm[k] += (2 / wSamples) * drift * Math.sin(k * angle * DEG_TO_RAD);
|
||||
}
|
||||
}
|
||||
const ownTerms = elements.terms?.length ?? 0;
|
||||
const turned: RotationalElements = { ...round, terms: round.terms!.map((term, index) => (index < ownTerms ? term : { ...term, pm: pm[index - ownTerms + 1] })) };
|
||||
// And W the IAU's at the present.
|
||||
const shift = orientationAt(turned, PRESENT_JD).primeMeridianDeg - orientationAt(elements, PRESENT_JD).primeMeridianDeg;
|
||||
turned.primeMeridianDeg = [turned.primeMeridianDeg[0] - shift, turned.primeMeridianDeg[1], 0];
|
||||
return turned;
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import { SbdbAnswer } from '../../../src/app/shared/astro/mean-elements';
|
||||
import { fetchJsonCached, fetchTextCached } from './http';
|
||||
|
||||
/**
|
||||
* Standish's "Keplerian Elements for Approximate Positions of the Major Planets", Table 2a/2b:
|
||||
* elements against the J2000 ecliptic and their rates per century, fit to the JPL ephemeris for
|
||||
* 3000 BC to AD 3000. Table 1 is closer near the present — Saturn within 0.23 degrees of Horizons
|
||||
* from 1950 to 2100 against this table's 0.32 — but it is only fit for 1800-2050, which the clock
|
||||
* leaves in minutes, and by AD 3000 it has Saturn 4.3 degrees out where this one is within 0.3
|
||||
* of every planet. The page at ssd.jpl.nasa.gov/planets/approx_pos.html carries the same numbers but has
|
||||
* dropped Pluto, so this reads the plain-text file as JPL last published it, from the Internet
|
||||
* Archive's copy — pinned to one capture, so the numbers cannot move under the cache.
|
||||
*/
|
||||
const PLANET_ELEMENTS_URL = 'https://web.archive.org/web/20210420020242id_/https://ssd.jpl.nasa.gov/txt/p_elem_t2.txt';
|
||||
|
||||
/**
|
||||
* JPL SSD's planetary satellite mean elements, as the page stood until 2021: each moon's elements,
|
||||
* its sidereal mean motion to ten figures, and how fast its node and periapsis turn, against its
|
||||
* local Laplace plane (the Moon against the ecliptic). The current page, ssd.jpl.nasa.gov/sats/elem,
|
||||
* has dropped the mean motion and rounds the period to four or five figures — 0.3187 days for
|
||||
* Phobos, which is a revolution out within a decade — so a period from it would not hold. Pinned
|
||||
* to one Internet Archive capture for the same reason as the planets.
|
||||
*/
|
||||
const SATELLITE_ELEMENTS_URL = 'https://web.archive.org/web/20210203000649id_/https://ssd.jpl.nasa.gov/?sat_elem';
|
||||
|
||||
export async function fetchPlanetMeanElementsText(): Promise<string> {
|
||||
return fetchTextCached(PLANET_ELEMENTS_URL, 'jpl-planet-mean-elements-t2.txt');
|
||||
}
|
||||
|
||||
export async function fetchSatelliteMeanElementsHtml(): Promise<string> {
|
||||
return fetchTextCached(SATELLITE_ELEMENTS_URL, 'jpl-satellite-mean-elements.html');
|
||||
}
|
||||
|
||||
/**
|
||||
* A small body's answer from JPL's Small-Body Database: osculating elements to full precision
|
||||
* (without `full-prec` they come rounded to three figures: Ceres's n as 0.214 degrees a day for
|
||||
* 0.2143045, which is 1.1 degrees out within a decade) and its physical parameters.
|
||||
*/
|
||||
export async function fetchSmallBodyAnswer(designation: string, cacheKey: string): Promise<SbdbAnswer> {
|
||||
return fetchJsonCached<SbdbAnswer>(`https://ssd-api.jpl.nasa.gov/sbdb.api?sstr=${encodeURIComponent(designation)}&phys-par=1&full-prec=1`, cacheKey);
|
||||
}
|
||||
@@ -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