Gooding IOD
This example runs angles-only initial orbit determination with the Gooding method: three optical right ascension/declination observations plus initial slant-range guesses are enough to recover a full state vector. This is the workflow for telescopes and other passive sensors that measure direction but not range.
import {
Degrees,
EpochUTC,
GoodingIOD,
GroundObject,
Kilometers,
ObservationOptical,
RadecTopocentric,
} from 'ootk';Run it
npm run build
npx tsx ./examples/gooding-iod.tsSetup Site
Angles-only observations carry no range information, so the solver needs the observer's inertial position at every observation epoch. A GroundObject holds the geodetic site and its toJ2000(date) method returns the site's J2000 state at any time.
// Optical observations are angles-only, so the observer's inertial position
// at each epoch is required. A GroundObject provides toJ2000(date) for that.
const site = new GroundObject({
name: 'Cape Cod Observatory',
lat: 41.958076 as Degrees,
lon: -70.662182 as Degrees,
alt: 0 as Kilometers,
});Setup Observations
Each ObservationOptical pairs the site's J2000 state at the observation time with a RadecTopocentric built via fromDegrees(epoch, ra, dec). The three observations span two hours, long enough for the target's motion to show up in the RA/Dec values.
/**
* Realistic observations of a GEO satellite pass.
* Observations span 2 hours with satellite motion reflected in RA/Dec changes.
*/
const observations = [
{
date: new Date(2025, 10, 22, 2, 0, 0),
ra: 333.38 as Degrees,
dec: -6.24 as Degrees,
},
{
date: new Date(2025, 10, 22, 3, 0, 0),
ra: 334.12 as Degrees,
dec: -5.87 as Degrees,
},
{
date: new Date(2025, 10, 22, 4, 0, 0),
ra: 334.89 as Degrees,
dec: -5.51 as Degrees,
},
];
// Pair each RA/Dec observation with the site's inertial position at that epoch
const [obs1, obs2, obs3] = observations.map((o) => new ObservationOptical(
site.toJ2000(o.date),
RadecTopocentric.fromDegrees(EpochUTC.fromDateTime(o.date), o.ra, o.dec),
));
for (const [i, obs] of [obs1, obs2, obs3].entries()) {
console.log(`Observation ${i + 1}: ${obs.epoch.toDateTime().toISOString()}` +
` RA ${obs.observation.rightAscensionDegrees.toFixed(2)} deg,` +
` Dec ${obs.observation.declinationDegrees.toFixed(2)} deg`);
}Solve Gooding
GoodingIOD is constructed with an optional gravitational parameter (defaults to Earth's mu). solve(o1, o2, o3, r1Init, r3Init) takes the three observations plus initial slant-range guesses for the first and third; it iterates the ranges until the implied positions are Keplerian-consistent and returns a J2000 state at the middle observation epoch. If you have no range estimate at all, estimate(o1, o2, o3) bootstraps the ranges with Gauss IOD internally.
/**
* Gooding IOD needs initial SLANT RANGE guesses from observer to satellite
* for the first and third observations.
* GEO altitude is ~35,786 km above Earth's surface, so for a ground observer
* the slant range to a GEO satellite is roughly 35,800 to 40,000 km
* depending on elevation angle.
*/
const rangeEstimate1 = 36800 as Kilometers;
const rangeEstimate3 = 36800 as Kilometers;
const iod = new GoodingIOD();
const solved = iod.solve(obs1, obs2, obs3, rangeEstimate1, rangeEstimate3);Results
The solved J2000 state converts to TEME with toTEME() and to Keplerian elements with toClassicalElements() (element angles are stored in radians). With only three synthetic observations and coarse range guesses, the recovered orbit is a loose fit; real applications refine it with more observations and a batch least squares pass.
const sv = solved.toTEME();
const elements = solved.toClassicalElements();
console.log('\nSolved State Vector (TEME) at middle observation epoch:');
console.log(` Epoch: ${sv.epoch.toDateTime().toISOString()}`);
console.log(` Position: [${sv.position.x.toFixed(2)}, ${sv.position.y.toFixed(2)}, ${sv.position.z.toFixed(2)}] km`);
console.log(` Velocity: [${sv.velocity.x.toFixed(6)}, ${sv.velocity.y.toFixed(6)}, ${sv.velocity.z.toFixed(6)}] km/s`);
console.log('\nClassical Orbital Elements:');
console.log(` Semi-major axis: ${elements.semimajorAxis.toFixed(2)} km`);
console.log(` Eccentricity: ${elements.eccentricity.toFixed(6)}`);
console.log(` Inclination: ${(elements.inclination * (180 / Math.PI)).toFixed(4)} deg`);
console.log(` Right Ascension: ${(elements.rightAscension * (180 / Math.PI)).toFixed(4)} deg`);
console.log(` Arg of Perigee: ${(elements.argPerigee * (180 / Math.PI)).toFixed(4)} deg`);
console.log(` True Anomaly: ${(elements.trueAnomaly * (180 / Math.PI)).toFixed(4)} deg`);
console.log(` Period: ${elements.period.toFixed(2)} minutes`);Output
Observation dates are constructed in local time, so exact values differ per machine timezone.
Observation 1: 2025-11-22T07:00:00.000Z RA 333.38 deg, Dec -6.24 deg
Observation 2: 2025-11-22T08:00:00.000Z RA 334.12 deg, Dec -5.87 deg
Observation 3: 2025-11-22T09:00:00.000Z RA 334.89 deg, Dec -5.51 deg
Solved State Vector (TEME) at middle observation epoch:
Epoch: 2025-11-22T08:00:00.000Z
Position: [261029.45, -121146.91, -25021.18] km
Velocity: [0.798135, 0.522975, 0.447411] km/s
Classical Orbital Elements:
Semi-major axis: 241702.57 km
Eccentricity: 0.473120
Inclination: 31.4378 deg
Right Ascension: 343.1596 deg
Arg of Perigee: 212.3778 deg
True Anomaly: 137.8107 deg
Period: 19709.77 minutes