Measure exoplanet inclination from the plane of the sky

The Exoplanet Archive measures orbital inclination from the plane of the sky —
the plane perpendicular to our line of sight to the host star. Ninety degrees
means edge-on as seen from Earth, which is why transiting planets pile up there:
1643 of the 2061 published inclinations are within five degrees of 90. The
renderer fed that straight into a propagator that reads inclination as an angle
from the reference plane, so every transiting system was tilted against a plane
its inclination was never measured against.

Each body's elements are now rotated out of their own reference plane into the
scene by a per-body quaternion. Solar-system elements keep the ecliptic rotation
from the previous commit. Exoplanets get a rotation carrying the elements' +Z
onto the line of sight to their host, which is exactly the star's own position —
so an inclination of i means the orbit's normal sits i from our line of sight,
which is the definition.

The rotation about that axis is the node's position angle on the sky. The
archive does not publish it and the ETL does not request it, so the shortest arc
is used: deterministic, and no less arbitrary than anything else given no data.
Planets with no published inclination default to face-on, which is the honest
reading of an unconstrained orbit rather than a guess at a tilt.

Unifying this replaced the direct eclipticToEquatorial call in the renderer, so
solar-system bodies and moons come out exactly where they did before — verified
against Sol side by side.

Tests: 253 passing, up from 247. The strongest one is the definition itself: a
90-degree planet must pass through our line of sight to the star, which is what
a transit is. One test of mine had to be corrected rather than the code — it
asserted that two systems at the same inclination must occupy different planes,
which is not guaranteed once the node angle is arbitrary, while each still sits
at the correct angle to its own host.

Note the e2e camera-flight test flaked once under parallel load during this
work, then passed in isolation and on two further full runs. Its click-until-
entered poll has a fixed 15s budget that a loaded machine can exceed; that is
pre-existing and unrelated to this change.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WaySiNst4HhDXBHnMy8p5G
This commit is contained in:
Claude
2026-08-04 18:05:07 +00:00
parent 2293585940
commit 2f45fa7fef
4 changed files with 178 additions and 25 deletions
+7 -5
View File
@@ -52,11 +52,13 @@ same place an in-scene click would.
star field is instanced quads on a `SpriteNodeMaterial` instead, which behaves the same on
both backends. Their size is angular rather than world-space — real stars are unresolvable
point sources, so apparent size should follow brightness, not distance.
- **One reference frame.** The two data sources disagree: HYG gives star positions in
equatorial J2000, while JPL Horizons reports orbital elements against the ecliptic, tilted
23.4° away. The scene is equatorial throughout and orbits are rotated into it, so a direction
means the same thing in both views. Systems are still presented face-on — by placing the
camera relative to the orbital plane rather than by rotating the world into a convenient pose.
- **One reference frame, from three sources.** HYG gives star positions in equatorial J2000.
JPL Horizons reports orbital elements against the ecliptic, tilted 23.4° away. The Exoplanet
Archive measures inclination from the *plane of the sky* — perpendicular to our line of sight
to each host star, which is why transiting planets cluster at 90°. Each set of elements is
rotated from its own reference plane into the scene's equatorial frame, so a direction means
the same thing everywhere. Systems are still presented face-on — by placing the camera
relative to the orbital plane rather than by rotating the world into a convenient pose.
- **Two coordinate scales.** The galaxy view works in parsecs and the system view in AU —
about eight orders of magnitude apart, which wrecks float precision if rendered in one unit
space. The camera rig recentres the active star to the origin ("floating origin") and swaps
@@ -392,7 +392,10 @@ export class GalaxySystemSceneComponent implements AfterViewInit, OnDestroy {
const systemBodies = this.bodies.filter((body) => body.systemStarId === star.id);
const systemExoplanets = this.exoplanets.filter((exoplanet) => exoplanet.hostStarId === star.id);
this.systemRenderer = new SystemOrbitsRenderer(systemBodies, systemExoplanets);
// The star's own position is the line of sight to it, which is the plane the archive
// measures exoplanet inclinations against. The Sun sits at the origin and has no
// exoplanets, so it has no meaningful direction and the renderer falls back.
this.systemRenderer = new SystemOrbitsRenderer(systemBodies, systemExoplanets, { x: star.x, y: star.y, z: star.z });
this.systemGroup.add(this.systemRenderer.object);
// Sized against this system's innermost orbit, so the star never swallows its own planets.
@@ -210,4 +210,90 @@ describe('SystemOrbitsRenderer exoplanet propagation', () => {
renderer.dispose();
});
});
describe('exoplanet inclination is measured from the plane of the sky', () => {
// A host somewhere off all three axes, so nothing can pass by coincidence.
const LINE_OF_SIGHT = new THREE.Vector3(0.37, -0.62, 0.69).normalize();
function circular(inclinationDeg: number): ExoplanetRecord {
return exoplanet({ orbit: { semiMajorAxisAu: 0.5, eccentricity: 0, inclinationDeg } });
}
/** Normal of the plane the rendered orbit actually lies in. */
function orbitNormal(renderer: SystemOrbitsRenderer): THREE.Vector3 {
const a = positionAt(renderer, DEFAULT_EPOCH_JD);
const b = positionAt(renderer, DEFAULT_EPOCH_JD + 20);
return new THREE.Vector3().crossVectors(a, b).normalize();
}
it('tilts the orbit by the published inclination away from the line of sight', () => {
// The definition: inclination is the angle between the orbital axis and our line of
// sight to the star. Reading it as an ecliptic inclination instead tips the orbit against
// a plane it was never measured against.
for (const inclinationDeg of [0, 30, 60, 88.9, 90]) {
const renderer = new SystemOrbitsRenderer([], [circular(inclinationDeg)], LINE_OF_SIGHT);
const angleDeg = (Math.acos(Math.abs(orbitNormal(renderer).dot(LINE_OF_SIGHT))) * 180) / Math.PI;
expect(angleDeg).toBeCloseTo(inclinationDeg <= 90 ? inclinationDeg : 180 - inclinationDeg, 4);
renderer.dispose();
}
});
it('makes an edge-on planet actually transit its star as seen from Earth', () => {
// 90 degrees means edge-on to us, which is why transiting planets cluster there. So some
// point on the orbit must lie along the line of sight — in front of or behind the star.
const renderer = new SystemOrbitsRenderer([], [circular(90)], LINE_OF_SIGHT);
let closestToLineOfSight = 0;
for (let day = 0; day < 120; day++) {
const p = positionAt(renderer, DEFAULT_EPOCH_JD + day).normalize();
closestToLineOfSight = Math.max(closestToLineOfSight, Math.abs(p.dot(LINE_OF_SIGHT)));
}
expect(closestToLineOfSight).toBeGreaterThan(0.99);
renderer.dispose();
});
it('keeps a face-on planet in the plane of the sky, never transiting', () => {
const renderer = new SystemOrbitsRenderer([], [circular(0)], LINE_OF_SIGHT);
for (let day = 0; day < 120; day += 7) {
const p = positionAt(renderer, DEFAULT_EPOCH_JD + day).normalize();
expect(Math.abs(p.dot(LINE_OF_SIGHT))).toBeLessThan(1e-9);
}
renderer.dispose();
});
it('places identical elements differently for hosts in different directions', () => {
// Each system is oriented against its own line of sight, so the same elements around two
// stars in different parts of the sky do not land in the same place.
//
// Note this checks position, not the plane's normal. With no published node angle the
// rotation about the line of sight is arbitrary, so two planes can come out near-parallel
// by coincidence while each still sits at its correct inclination to its own host — which
// is the property the test above pins.
const here = new SystemOrbitsRenderer([], [circular(88.9)], new THREE.Vector3(1, 0, 0));
const there = new SystemOrbitsRenderer([], [circular(88.9)], new THREE.Vector3(0, 0, 1));
expect(positionAt(here, DEFAULT_EPOCH_JD).distanceTo(positionAt(there, DEFAULT_EPOCH_JD))).toBeGreaterThan(0.1);
here.dispose();
there.dispose();
});
it('falls back to the ecliptic frame when the host direction is unknown', () => {
const withoutHost = new SystemOrbitsRenderer([], [circular(0)]);
const eclipticPole = eclipticToEquatorial({ x: 0, y: 0, z: 1 });
expect(Math.abs(orbitNormal(withoutHost).dot(new THREE.Vector3(eclipticPole.x, eclipticPole.y, eclipticPole.z)))).toBeCloseTo(1, 9);
withoutHost.dispose();
});
it('ignores a zero-length host direction rather than producing NaN', () => {
const renderer = new SystemOrbitsRenderer([], [circular(45)], new THREE.Vector3(0, 0, 0));
const p = positionAt(renderer, DEFAULT_EPOCH_JD);
expect([p.x, p.y, p.z].every(Number.isFinite)).toBe(true);
renderer.dispose();
});
});
});
@@ -2,7 +2,7 @@ import * as THREE from 'three/webgpu';
import { gmForParent } from '../../shared/astro/constants';
import { isPropagatableOrbit, orbitEllipsePoints, propagateOrbit, resolveGravitationalParameter, resolveOrbitalElements } from '../../shared/astro/kepler';
import { eclipticToEquatorial } from '../../shared/astro/coordinates';
import { CartesianCoordinates, OBLIQUITY_J2000_DEG } from '../../shared/astro/coordinates';
import { BodyRecord, OrbitalElements } from '../../shared/models/body.model';
import { bodyMarkerRadiusAu } from './system-framing';
import { ExoplanetRecord } from '../../shared/models/exoplanet.model';
@@ -29,6 +29,42 @@ const ORBIT_LINE_OPACITY_BY_KIND: Record<SystemMemberKind, number> = {
};
const EARTH_RADIUS_KM = 6371;
const DEG_TO_RAD = Math.PI / 180;
/**
* Rotation carrying the **ecliptic** frame into the scene's equatorial one — a turn of the
* obliquity about the shared vernal-equinox axis. Solar-system elements come from Horizons
* against the ecliptic, so this is their frame.
*/
const ECLIPTIC_FRAME = new THREE.Quaternion().setFromAxisAngle(new THREE.Vector3(1, 0, 0), OBLIQUITY_J2000_DEG * DEG_TO_RAD);
/**
* Rotation carrying the frame an **exoplanet's** elements are measured in into the scene.
*
* The Exoplanet Archive measures inclination from the *plane of the sky* — the plane
* perpendicular to our line of sight to the host star — not from the ecliptic. 90 degrees means
* edge-on as seen from Earth, which is why transiting planets cluster there: 1643 of the 2061
* published inclinations are within 5 degrees of 90. Treating that as an ecliptic inclination
* tips every transiting system on its side against a plane it was never measured against.
*
* Carrying the elements' +Z onto the line of sight fixes it: an inclination of `i` then means
* the orbit's normal sits `i` from our line of sight, which is exactly the definition. The
* rotation about that axis is the node's position angle on the sky, which the archive does not
* publish, so the shortest arc from +Z is used — deterministic, and no less arbitrary than any
* other choice given no data.
*
* Falls back to the ecliptic frame when there is no direction to work with.
*/
function skyPlaneFrame(lineOfSight: CartesianCoordinates | undefined): THREE.Quaternion {
if (!lineOfSight) {
return ECLIPTIC_FRAME.clone();
}
const direction = new THREE.Vector3(lineOfSight.x, lineOfSight.y, lineOfSight.z);
if (direction.lengthSq() === 0) {
return ECLIPTIC_FRAME.clone();
}
return new THREE.Quaternion().setFromUnitVectors(new THREE.Vector3(0, 0, 1), direction.normalize());
}
function colorForKind(kind: SystemMemberKind): THREE.Color {
switch (kind) {
@@ -43,13 +79,14 @@ function colorForKind(kind: SystemMemberKind): THREE.Color {
}
}
function buildOrbitLine(elements: OrbitalElements, kind: SystemMemberKind): THREE.Line {
function buildOrbitLine(elements: OrbitalElements, kind: SystemMemberKind, frame: THREE.Quaternion): THREE.Line {
const points = orbitEllipsePoints(elements);
const positions = new Float32Array(points.length * 3);
const scratch = new THREE.Vector3();
points.forEach((point, index) => {
// Elements are ecliptic (Horizons' default reference plane); the scene is equatorial, to
// match the star catalogue. Without this the orbits sit 23.4 degrees off the sky.
const { x, y, z } = eclipticToEquatorial(point);
// Elements are measured against their source's own reference plane; `frame` rotates that
// plane into the scene's equatorial one.
const { x, y, z } = scratch.set(point.x, point.y, point.z).applyQuaternion(frame);
positions[index * 3] = x;
positions[index * 3 + 1] = y;
positions[index * 3 + 2] = z;
@@ -79,6 +116,8 @@ interface TrackedTopLevelBody {
elements: OrbitalElements;
gmAu3PerDay2: number;
marker: THREE.Mesh;
/** Rotation from this body's own element frame into the scene's equatorial one. */
frame: THREE.Quaternion;
/** AU position last computed for this body; moons read their parent's here. */
position: THREE.Vector3;
}
@@ -88,6 +127,7 @@ interface TrackedMoon {
elements: OrbitalElements;
gmAu3PerDay2: number;
marker: THREE.Mesh;
frame: THREE.Quaternion;
pivot: THREE.Group;
parentId: string;
}
@@ -110,7 +150,12 @@ export class SystemOrbitsRenderer {
private readonly moons: TrackedMoon[] = [];
private readonly disposables: Array<{ geometry: THREE.BufferGeometry; material: THREE.Material }> = [];
constructor(bodies: readonly BodyRecord[], exoplanets: readonly ExoplanetRecord[]) {
constructor(
bodies: readonly BodyRecord[],
exoplanets: readonly ExoplanetRecord[],
/** Direction from the Sun to this system's host star, equatorial — the exoplanet line of sight. */
hostStarDirection?: CartesianCoordinates
) {
const members: SystemMember[] = [];
const topLevelBodiesById = new Map<string, BodyRecord>();
@@ -135,7 +180,7 @@ export class SystemOrbitsRenderer {
}
// A body reaches here only when it has no parentBodyId, so `kind` is 'planet' or 'dwarf'.
const kind: SystemMemberKind = body.kind;
const tracked = this.addTopLevelBody(body.id, kind, body.orbit, gmForParent(undefined), body.radiusKm);
const tracked = this.addTopLevelBody(body.id, kind, body.orbit, gmForParent(undefined), body.radiusKm, ECLIPTIC_FRAME);
members.push({ id: body.id, kind, marker: tracked.marker });
}
@@ -148,10 +193,13 @@ export class SystemOrbitsRenderer {
if (!parentTracked) {
continue; // orphaned moon reference; skip rather than crash.
}
const moon = this.addMoon(body.id, body.orbit, gmForParent(body.parentBodyId), body.radiusKm, parentTracked);
const moon = this.addMoon(body.id, body.orbit, gmForParent(body.parentBodyId), body.radiusKm, parentTracked, ECLIPTIC_FRAME);
members.push({ id: body.id, kind: 'moon', marker: moon.marker });
}
// Every exoplanet in a system shares the same line of sight, so the frame is built once.
const exoplanetFrame = skyPlaneFrame(hostStarDirection);
for (const exoplanet of exoplanets) {
// Only a semi-major axis is genuinely required; resolveOrbitalElements defaults the rest,
// eccentricity included. Demanding a published eccentricity as well used to drop 1509
@@ -169,7 +217,7 @@ export class SystemOrbitsRenderer {
periodDays: exoplanet.periodDays,
hostStarMassSolar: exoplanet.hostStarMassSolar
});
const tracked = this.addTopLevelBody(exoplanet.id, 'exoplanet', elements, gm, radiusKm);
const tracked = this.addTopLevelBody(exoplanet.id, 'exoplanet', elements, gm, radiusKm, exoplanetFrame);
members.push({ id: exoplanet.id, kind: 'exoplanet', marker: tracked.marker });
}
@@ -179,8 +227,8 @@ export class SystemOrbitsRenderer {
/** Recomputes every marker's position for the given Julian date. Call once per tick. */
update(epochJd: number): void {
for (const body of this.topLevelBodies) {
const { x, y, z } = eclipticToEquatorial(propagateOrbit(body.elements, body.gmAu3PerDay2, epochJd));
body.position.set(x, y, z); // Equatorial, matching buildOrbitLine and the star field.
const orbital = propagateOrbit(body.elements, body.gmAu3PerDay2, epochJd);
body.position.set(orbital.x, orbital.y, orbital.z).applyQuaternion(body.frame);
body.marker.position.copy(body.position);
}
@@ -190,8 +238,8 @@ export class SystemOrbitsRenderer {
continue;
}
moon.pivot.position.copy(parent.position);
const { x, y, z } = eclipticToEquatorial(propagateOrbit(moon.elements, moon.gmAu3PerDay2, epochJd));
moon.marker.position.set(x, y, z);
const orbital = propagateOrbit(moon.elements, moon.gmAu3PerDay2, epochJd);
moon.marker.position.set(orbital.x, orbital.y, orbital.z).applyQuaternion(moon.frame);
}
}
@@ -218,28 +266,42 @@ export class SystemOrbitsRenderer {
this.object.clear();
}
private addTopLevelBody(id: string, kind: SystemMemberKind, elements: OrbitalElements, gmAu3PerDay2: number, radiusKm: number | undefined): TrackedTopLevelBody {
const orbitLine = buildOrbitLine(elements, kind);
private addTopLevelBody(
id: string,
kind: SystemMemberKind,
elements: OrbitalElements,
gmAu3PerDay2: number,
radiusKm: number | undefined,
frame: THREE.Quaternion
): TrackedTopLevelBody {
const orbitLine = buildOrbitLine(elements, kind, frame);
const marker = buildMarker(kind, radiusKm, this.maxTopLevelSemiMajorAxisAu);
this.object.add(orbitLine, marker);
this.trackDisposable(orbitLine.geometry, orbitLine.material as THREE.Material);
this.trackDisposable(marker.geometry, marker.material as THREE.Material);
const tracked: TrackedTopLevelBody = { id, kind, elements, gmAu3PerDay2, marker, position: new THREE.Vector3() };
const tracked: TrackedTopLevelBody = { id, kind, elements, gmAu3PerDay2, marker, frame, position: new THREE.Vector3() };
this.topLevelBodies.push(tracked);
return tracked;
}
private addMoon(id: string, elements: OrbitalElements, gmAu3PerDay2: number, radiusKm: number | undefined, parent: TrackedTopLevelBody): TrackedMoon {
private addMoon(
id: string,
elements: OrbitalElements,
gmAu3PerDay2: number,
radiusKm: number | undefined,
parent: TrackedTopLevelBody,
frame: THREE.Quaternion
): TrackedMoon {
const pivot = new THREE.Group();
const orbitLine = buildOrbitLine(elements, 'moon');
const orbitLine = buildOrbitLine(elements, 'moon', frame);
const marker = buildMarker('moon', radiusKm, this.maxTopLevelSemiMajorAxisAu);
pivot.add(orbitLine, marker);
this.object.add(pivot);
this.trackDisposable(orbitLine.geometry, orbitLine.material as THREE.Material);
this.trackDisposable(marker.geometry, marker.material as THREE.Material);
const moon: TrackedMoon = { id, elements, gmAu3PerDay2, marker, pivot, parentId: parent.id };
const moon: TrackedMoon = { id, elements, gmAu3PerDay2, marker, frame, pivot, parentId: parent.id };
this.moons.push(moon);
return moon;
}