Skip to content

Coordinate Transforms

This example converts positions between coordinate frames (ECI, ECEF, LLA), propagates a satellite into different state vector representations (J2000, TEME, ITRF), and computes relative motion in the RIC frame. These transforms are the glue between orbit propagation, ground processing, and proximity analysis.

ts
import {
  calcGmst,
  Degrees,
  ecef2eci,
  eci2ecef,
  eci2lla,
  EpochUTC,
  J2000,
  Kilometers,
  KilometersPerSecond,
  lla2ecef,
  lla2eci,
  Radians,
  RIC,
  Satellite,
  TEME,
  TleLine1,
  TleLine2,
  Vector3D,
} from 'ootk';

Run it

bash
npm run build
npx tsx ./examples/coordinate-transforms.ts

ECI and ECEF

eci2ecef and ecef2eci rotate a position about the Earth's spin axis by the Greenwich Mean Sidereal Time angle, which calcGmst computes from a Date. The functions accept plain {x, y, z} objects in kilometers, and the round trip reproduces the input.

ts
console.log('=== Example 1: ECI <-> ECEF Transformations ===\n');

const date = new Date('2024-01-28T12:00:00.000Z');
const gmst = calcGmst(date);

// ECI position vector
const eciPos = {
  x: 6778 as Kilometers,
  y: 0 as Kilometers,
  z: 0 as Kilometers,
};

console.log('ECI Position:');
console.log(`  X: ${eciPos.x.toFixed(2)} km`);
console.log(`  Y: ${eciPos.y.toFixed(2)} km`);
console.log(`  Z: ${eciPos.z.toFixed(2)} km`);

// Convert to ECEF
const ecefPos = eci2ecef(eciPos, gmst.gmst);

console.log('\nECEF Position:');
console.log(`  X: ${ecefPos.x.toFixed(2)} km`);
console.log(`  Y: ${ecefPos.y.toFixed(2)} km`);
console.log(`  Z: ${ecefPos.z.toFixed(2)} km`);

// Convert back to ECI
const eciPos2 = ecef2eci(ecefPos, gmst.gmst);

console.log('\nConverted back to ECI:');
console.log(`  X: ${eciPos2.x.toFixed(2)} km`);
console.log(`  Y: ${eciPos2.y.toFixed(2)} km`);
console.log(`  Z: ${eciPos2.z.toFixed(2)} km`);

ECI and geodetic (LLA)

eci2lla returns latitude and longitude in degrees with altitude in kilometers. Note the asymmetry: lla2eci expects radians, so the angles must be converted before going back. The small position difference on the return trip comes from the geodetic (ellipsoidal) altitude model.

ts
console.log('\n=== Example 2: ECI <-> Geodetic (LLA) Transformations ===\n');

// Convert ECI to Geodetic (returns degrees and kilometers)
const lla = eci2lla(eciPos, gmst.gmst);

console.log('Geodetic Coordinates:');
console.log(`  Latitude:  ${lla.lat.toFixed(4)} deg`);
console.log(`  Longitude: ${lla.lon.toFixed(4)} deg`);
console.log(`  Altitude:  ${lla.alt.toFixed(2)} km`);

// lla2eci expects radians, so convert the angles first
const llaRad = {
  lat: (lla.lat * (Math.PI / 180)) as Radians,
  lon: (lla.lon * (Math.PI / 180)) as Radians,
  alt: lla.alt,
};

const eciFromLla = lla2eci(llaRad, gmst.gmst);

console.log('\nConverted back to ECI:');
console.log(`  X: ${eciFromLla.x.toFixed(2)} km`);
console.log(`  Y: ${eciFromLla.y.toFixed(2)} km`);
console.log(`  Z: ${eciFromLla.z.toFixed(2)} km`);

State vector frames

Satellite.toJ2000 propagates the TLE with SGP4 and rotates the TEME result into the J2000 inertial frame. From a J2000 state, toTEME() and toITRF() reach the other frames, and ITRF.toGeodetic() yields a Geodetic object. Geodetic stores radians internally, so use the latDeg/lonDeg getters for display.

ts
console.log('\n=== Example 3: Different State Vector Frames (J2000, TEME, ITRF) ===\n');

const sat = new Satellite({
  tle1: '1 25544U 98067A   24028.54545847  .00031576  00000-0  57240-3 0  9991' as TleLine1,
  tle2: '2 25544  51.6418 292.2590 0002595 167.5319 252.0460 15.49326324436741' as TleLine2,
});

// Get state in J2000 frame
const j2000State = sat.toJ2000(date);

console.log('J2000 Frame (ECI):');
console.log(`  Position: [${j2000State.position.x.toFixed(2)}, ${j2000State.position.y.toFixed(2)}, ${j2000State.position.z.toFixed(2)}] km`);
console.log(`  Velocity: [${j2000State.velocity.x.toFixed(6)}, ${j2000State.velocity.y.toFixed(6)}, ${j2000State.velocity.z.toFixed(6)}] km/s`);

// Convert to TEME frame
const temeState = j2000State.toTEME();

console.log('\nTEME Frame:');
console.log(`  Position: [${temeState.position.x.toFixed(2)}, ${temeState.position.y.toFixed(2)}, ${temeState.position.z.toFixed(2)}] km`);
console.log(`  Velocity: [${temeState.velocity.x.toFixed(6)}, ${temeState.velocity.y.toFixed(6)}, ${temeState.velocity.z.toFixed(6)}] km/s`);

// Convert to ITRF frame (Earth-fixed)
const itrfState = j2000State.toITRF();

console.log('\nITRF Frame (Earth-fixed):');
console.log(`  Position: [${itrfState.position.x.toFixed(2)}, ${itrfState.position.y.toFixed(2)}, ${itrfState.position.z.toFixed(2)}] km`);
console.log(`  Velocity: [${itrfState.velocity.x.toFixed(6)}, ${itrfState.velocity.y.toFixed(6)}, ${itrfState.velocity.z.toFixed(6)}] km/s`);

// Convert ITRF to Geodetic (Geodetic stores radians; use the Deg getters)
const geodeticFromItrf = itrfState.toGeodetic();

console.log('\nGeodetic from ITRF:');
console.log(`  Latitude:  ${geodeticFromItrf.latDeg.toFixed(4)} deg`);
console.log(`  Longitude: ${geodeticFromItrf.lonDeg.toFixed(4)} deg`);
console.log(`  Altitude:  ${geodeticFromItrf.alt.toFixed(2)} km`);

RIC relative frame

The RIC (Radial, In-track, Cross-track) frame expresses one object's state relative to another, which is the standard view for conjunction and formation analysis. Use the static RIC.fromJ2000(deputy, chief) factory; the deputy here is built by perturbing the chief's true anomaly through toClassicalElements() and toJ2000().

ts
console.log('\n=== Example 4: Relative Coordinates (RIC Frame) ===\n');

// Use the ISS as the chief satellite
const chiefState = sat.toJ2000(date);

// Create a deputy slightly ahead in the same orbit by perturbing the
// true anomaly of the chief's classical elements
const deputyElements = chiefState.toClassicalElements();

deputyElements.trueAnomaly = (deputyElements.trueAnomaly + 0.1) as Radians;

const deputyState = deputyElements.toJ2000();

console.log('Satellite 1 (Chief) Position:');
console.log(`  [${chiefState.position.x.toFixed(2)}, ${chiefState.position.y.toFixed(2)}, ${chiefState.position.z.toFixed(2)}] km`);

console.log('\nSatellite 2 (Deputy) Position:');
console.log(`  [${deputyState.position.x.toFixed(2)}, ${deputyState.position.y.toFixed(2)}, ${deputyState.position.z.toFixed(2)}] km`);

// Convert to RIC coordinates (deputy state relative to chief origin)
const ricState = RIC.fromJ2000(deputyState, chiefState);

console.log('\nRelative Position in RIC Frame:');
console.log(`  Radial:      ${ricState.position.x.toFixed(4)} km`);
console.log(`  In-track:    ${ricState.position.y.toFixed(4)} km`);
console.log(`  Cross-track: ${ricState.position.z.toFixed(4)} km`);

console.log('\nRelative Velocity in RIC Frame:');
console.log(`  Radial:      ${ricState.velocity.x.toFixed(6)} km/s`);
console.log(`  In-track:    ${ricState.velocity.y.toFixed(6)} km/s`);
console.log(`  Cross-track: ${ricState.velocity.z.toFixed(6)} km/s`);

Creating state vectors directly

J2000 and TEME states can be constructed from an EpochUTC plus position and velocity Vector3Ds. Identical numbers in the two frames are different physical states: converting the TEME state to J2000 shifts the vector because the frames differ by precession and nutation.

ts
console.log('\n=== Example 5: Creating State Vectors Directly ===\n');

const epoch = EpochUTC.fromDateTime(date);

// Create a J2000 state vector
const customJ2000 = new J2000(
  epoch,
  new Vector3D(
    6778 as Kilometers,
    0 as Kilometers,
    0 as Kilometers,
  ),
  new Vector3D(
    0 as KilometersPerSecond,
    7.7 as KilometersPerSecond,
    0 as KilometersPerSecond,
  ),
);

console.log('Custom J2000 State:');
console.log(`  Position: [${customJ2000.position.x.toFixed(2)}, ${customJ2000.position.y.toFixed(2)}, ${customJ2000.position.z.toFixed(2)}] km`);
console.log(`  Velocity: [${customJ2000.velocity.x.toFixed(6)}, ${customJ2000.velocity.y.toFixed(6)}, ${customJ2000.velocity.z.toFixed(6)}] km/s`);

// Create a TEME state vector with the same numbers
const customTEME = new TEME(
  epoch,
  new Vector3D(
    6778 as Kilometers,
    0 as Kilometers,
    0 as Kilometers,
  ),
  new Vector3D(
    0 as KilometersPerSecond,
    7.7 as KilometersPerSecond,
    0 as KilometersPerSecond,
  ),
);

console.log('\nCustom TEME State:');
console.log(`  Position: [${customTEME.position.x.toFixed(2)}, ${customTEME.position.y.toFixed(2)}, ${customTEME.position.z.toFixed(2)}] km`);
console.log(`  Velocity: [${customTEME.velocity.x.toFixed(6)}, ${customTEME.velocity.y.toFixed(6)}, ${customTEME.velocity.z.toFixed(6)}] km/s`);

// Convert TEME to J2000
const temeToJ2000 = customTEME.toJ2000();

console.log('\nTEME converted to J2000:');
console.log(`  Position: [${temeToJ2000.position.x.toFixed(2)}, ${temeToJ2000.position.y.toFixed(2)}, ${temeToJ2000.position.z.toFixed(2)}] km`);
console.log(`  Velocity: [${temeToJ2000.velocity.x.toFixed(6)}, ${temeToJ2000.velocity.y.toFixed(6)}, ${temeToJ2000.velocity.z.toFixed(6)}] km/s`);

Geodetic to ECEF

lla2ecef converts a degrees-based LLA object to Earth-fixed Cartesian coordinates using the WGS-84 ellipsoid, which is why the geocentric distance at 41.75 deg latitude is about 9 km less than the equatorial radius.

ts
console.log('\n=== Example 6: Geodetic to ECEF ===\n');

const observerLla = {
  lat: 41.754785 as Degrees,
  lon: -70.539151 as Degrees,
  alt: 0.060966 as Kilometers,
};

console.log('Observer Location:');
console.log(`  Latitude:  ${observerLla.lat} deg`);
console.log(`  Longitude: ${observerLla.lon} deg`);
console.log(`  Altitude:  ${observerLla.alt} km`);

const observerEcef = lla2ecef(observerLla);

console.log('\nECEF Position:');
console.log(`  X: ${observerEcef.x.toFixed(4)} km`);
console.log(`  Y: ${observerEcef.y.toFixed(4)} km`);
console.log(`  Z: ${observerEcef.z.toFixed(4)} km`);

const distance = Math.hypot(observerEcef.x, observerEcef.y, observerEcef.z);

console.log(`\nDistance from Earth center: ${distance.toFixed(4)} km`);
console.log('Earth equatorial radius: 6378.137 km');

Output

txt
=== Example 1: ECI <-> ECEF Transformations ===

ECI Position:
  X: 6778.00 km
  Y: 0.00 km
  Z: 0.00 km

ECEF Position:
  X: 4103.43 km
  Y: 5394.73 km
  Z: 0.00 km

Converted back to ECI:
  X: 6778.00 km
  Y: 0.00 km
  Z: 0.00 km

=== Example 2: ECI <-> Geodetic (LLA) Transformations ===

Geodetic Coordinates:
  Latitude:  0.0000 deg
  Longitude: 52.7421 deg
  Altitude:  399.86 km

Converted back to ECI:
  X: 6770.87 km
  Y: -0.00 km
  Z: 0.00 km

=== Example 3: Different State Vector Frames (J2000, TEME, ITRF) ===

J2000 Frame (ECI):
  Position: [-1536.88, 6490.06, 1295.88] km
  Velocity: [-4.988100, -0.011607, -5.818747] km/s

TEME Frame:
  Position: [-1574.82, 6481.63, 1292.52] km
  Velocity: [-4.974387, -0.038184, -5.830361] km/s

ITRF Frame (Earth-fixed):
  Position: [-6112.25, 2670.58, 1292.52] km
  Velocity: [-2.786382, -3.536609, -5.830361] km/s

Geodetic from ITRF:
  Latitude:  11.0343 deg
  Longitude: 156.3984 deg
  Altitude:  416.92 km

=== Example 4: Relative Coordinates (RIC Frame) ===

Satellite 1 (Chief) Position:
  [-1536.88, 6490.06, 1295.88] km

Satellite 2 (Deputy) Position:
  [-1970.72, 6456.65, 774.39] km

Relative Position in RIC Frame:
  Radial:      -33.2487 km
  In-track:    678.3660 km
  Cross-track: -0.0000 km

... (truncated)

=== Example 6: Geodetic to ECEF ===

Observer Location:
  Latitude:  41.754785 deg
  Longitude: -70.539151 deg
  Altitude:  0.060966 km

ECEF Position:
  X: 1587.5953 km
  Y: -4492.9853 km
  Z: 4225.3650 km

Distance from Earth center: 6368.7585 km
Earth equatorial radius: 6378.137 km

Released under the AGPL-3.0 License.