Coordinate Systems Deep Dive

Overview

The Tracker Component Library provides 20+ coordinate system conversions and transformations essential for multi-sensor tracking. This guide covers all coordinate types, conversions, rotations, and practical usage patterns.

Key Modules:

  • coordinate_systems.conversions - Convert between coordinate types

  • coordinate_systems.rotations - Rotation representations and operations

  • coordinate_systems.jacobians - Partial derivatives for filtering

  • coordinate_systems.projections - Map projections (UTM, Mercator, etc.)

All functions are also re-exported at the pytcl.coordinate_systems package level, so from pytcl.coordinate_systems import cart2sphere works too.

Common Coordinate Systems

Cartesian (XYZ)

Rectangular coordinates commonly used in physics and tracking.

import numpy as np
from pytcl.coordinate_systems.conversions import cart2sphere

x = np.array([100.0, 50.0, 10.0])  # [x, y, z] in meters

# Convert to spherical; returns a (range, azimuth, elevation) tuple
r, az, el = cart2sphere(x, system_type='az-el')

Spherical (Range, Azimuth, Elevation)

Standard radar/sensor coordinate system. With system_type='az-el' the azimuth is measured in the xy-plane from the +x axis and the elevation from the xy-plane (the tracking convention). The default 'standard' uses the physics convention (polar angle from +z).

from pytcl.coordinate_systems.conversions import sphere2cart

# Arguments are (range, azimuth, elevation) in (meters, radians, radians)
cart = sphere2cart(1000.0, np.pi/4, np.pi/6, system_type='az-el')
# Result: array [x, y, z]

Geodetic (Latitude, Longitude, Altitude)

WGS84 ellipsoid coordinates used by GPS. All angles are in radians.

from pytcl.coordinate_systems.conversions import geodetic2ecef

lat = np.radians(40.7128)   # latitude (rad)
lon = np.radians(-74.0060)  # longitude (rad)
alt = 10.0                  # altitude above the ellipsoid (m)

ecef = geodetic2ecef(lat, lon, alt)  # Earth-Centered Earth-Fixed [x, y, z]

ECEF (Earth-Centered Earth-Fixed)

Cartesian coordinates fixed to Earth’s rotation. Origin at Earth’s center.

from pytcl.coordinate_systems.conversions import ecef2geodetic

ecef = np.array([
    6378137.0,     # X (meters, roughly Earth's equatorial radius)
    0.0,           # Y
    0.0            # Z
])

lat, lon, alt = ecef2geodetic(ecef)  # returns a (lat, lon, alt) tuple

ECI (Earth-Centered Inertial)

Cartesian coordinates fixed to distant stars (non-rotating). Used in orbital mechanics.

# [X, Y, Z] in meters, inertial frame
eci = np.array([
    6600000.0,     # X (meters)
    0.0,           # Y
    0.0            # Z
])

Local Coordinates: ENU and NED

Tangent plane at observer location.

  • ENU (East-North-Up): East, North, Up (common in aviation)

  • NED (North-East-Down): North, East, Down (common in marine navigation)

from pytcl.coordinate_systems.conversions import enu2ecef, ned2ecef

# Observer location (radians, radians, meters)
lat_ref = np.radians(40.7128)   # NYC
lon_ref = np.radians(-74.0060)
ecef_ref = geodetic2ecef(lat_ref, lon_ref, 10.0)

# ENU: [east, north, up] in meters, relative to the observer
enu = np.array([100.0, 200.0, 50.0])  # 100m east, 200m north, 50m up
ecef_target = enu2ecef(enu, lat_ref, lon_ref, ecef_ref)

# NED: [north, east, down] in meters
ned = np.array([200.0, 100.0, -50.0])  # 200m N, 100m E, 50m up
ecef_target = ned2ecef(ned, lat_ref, lon_ref, ecef_ref)

Cylindrical (rho, phi, z)

Axially symmetric coordinates.

from pytcl.coordinate_systems.conversions import cyl2cart

# Arguments are (rho, phi, z) = (radius, azimuth, height)
cart = cyl2cart(141.42, np.pi/4, 10.0)  # -> approx [100, 100, 10]

Polar (rho, phi) (2D version of cylindrical)

from pytcl.coordinate_systems.conversions import pol2cart

# Arguments are (radius, azimuth)
cart = pol2cart(141.42, np.pi/4)  # -> approx [100, 100]

R-U-V (Range and Direction Cosines)

Range plus the direction cosines u = x/r and v = y/r. Useful for phased-array radars and for Jacobians without the azimuth singularity at the z-axis.

from pytcl.coordinate_systems.conversions import cart2ruv, ruv2cart

# cart2ruv returns a (range, u, v) tuple
r, u, v = cart2ruv(np.array([100.0, 100.0, 10.0]))

# Inverse (assumes the +z half-space)
cart = ruv2cart(r, u, v)  # -> approx [100, 100, 10]

Conversion Matrix Quick Reference

Cartesian (X, Y, Z)
     <-> sphere2cart, cart2sphere
Spherical (Range, Az, El)

Cartesian (X, Y, Z)
     <-> pol2cart, cart2pol
Polar (rho, phi)

Cartesian (X, Y, Z)
     <-> cyl2cart, cart2cyl
Cylindrical (rho, phi, z)

ECEF
     <-> geodetic2ecef, ecef2geodetic
Geodetic (Lat, Lon, Alt)

ECEF
     <-> enu2ecef, ecef2enu
ENU (relative to observer)

ECEF
     <-> ned2ecef, ecef2ned
NED (relative to observer)

ECI <-> ECEF via Earth rotation angle
(pytcl.astronomical.reference_frames: eci_to_ecef(x, gmst))

Practical Examples by Use Case

Use Case 1: Radar Track Display

Problem: Radar gives range, azimuth, elevation. Display on map using lat/lon.

Solution:

import numpy as np
from pytcl.coordinate_systems.conversions import (
    sphere2cart, enu2ecef, ecef2geodetic, geodetic2ecef,
)

# Radar measurement: r=1 km, az=45 deg (from east), el approx 11 deg
radar_range, radar_az, radar_el = 1000.0, np.pi/4, 0.2

# Radar location (radians, radians, meters)
radar_lat = np.radians(40.7128)   # NYC
radar_lon = np.radians(-74.0060)
radar_alt = 100.0

# Step 1: spherical to Cartesian ENU offset from the radar
# ('az-el' azimuth is measured from the +x axis, which is east in ENU)
enu_target = sphere2cart(radar_range, radar_az, radar_el,
                         system_type='az-el')

# Step 2: ENU offset to absolute ECEF
radar_ecef = geodetic2ecef(radar_lat, radar_lon, radar_alt)
ecef_target = enu2ecef(enu_target, radar_lat, radar_lon, radar_ecef)

# Step 3: ECEF to geodetic (lat/lon/alt)
lat, lon, alt = ecef2geodetic(ecef_target)

print(f"Target: Lat={np.degrees(lat):.4f} deg, "
      f"Lon={np.degrees(lon):.4f} deg, Alt={alt:.1f} m")

Output:

Target: Lat=40.7190 deg, Lon=-73.9978 deg, Alt=298.7 m

Use Case 2: Multi-Sensor Fusion (GPS + IMU)

Problem: GPS gives geodetic (lat/lon/alt), IMU gives acceleration in vehicle frame. Filter in common frame.

Solution:

import numpy as np
from pytcl.coordinate_systems.conversions import geodetic2ecef
from pytcl.coordinate_systems.rotations import euler2rotmat

# GPS solution (radians, radians, meters)
gps_lat = np.radians(40.7128)
gps_lon = np.radians(-74.0060)
gps_alt = 100.0
gps_ecef = geodetic2ecef(gps_lat, gps_lon, gps_alt)

# Vehicle attitude: 'ZYX' aerospace sequence takes [yaw, pitch, roll]
yaw, pitch, roll = 0.2, 0.1, 0.05  # radians
C_b2n = euler2rotmat([yaw, pitch, roll], 'ZYX')  # body-to-nav DCM

# Acceleration in body frame [ax, ay, az]
accel_body = np.array([10.0, 0.5, 0.0])

# Transform to the local navigation frame
accel_nav = C_b2n @ accel_body

print(f"Acceleration in the navigation frame: {accel_nav}")

Use Case 3: Orbital Mechanics (ECI to ECEF)

Problem: Satellite in inertial frame (ECI), need ground station view (ECEF).

Solution: the ECI/ECEF conversion is a rotation about the z-axis by the Greenwich Mean Sidereal Time (GMST), so first convert the observation time to a Julian date and compute GMST.

import numpy as np
from pytcl.astronomical import cal_to_jd, gmst
from pytcl.astronomical.reference_frames import eci_to_ecef
from pytcl.coordinate_systems.conversions import ecef2geodetic

# Satellite position in ECI (the rotation is unit-agnostic; meters here)
sat_eci = np.array([6600e3, 0.0, 0.0])

# Time of observation -> Julian date -> GMST angle (radians)
jd = cal_to_jd(2026, 2, 26, 12, 0, 0.0)
theta = gmst(jd)

# Transform to ECEF (fixed to Earth)
sat_ecef = eci_to_ecef(sat_eci, theta)

# Get geodetic position (lat/lon/alt)
lat, lon, alt = ecef2geodetic(sat_ecef)

print(f"Satellite lat={np.degrees(lat):.2f} deg, "
      f"lon={np.degrees(lon):.2f} deg")

Rotations: Euler Angles, Quaternions, DCM

Three Ways to Represent Rotation:

  1. Euler Angles (3 angles, gimbal lock issues)

  2. Quaternions (4 parameters, smooth interpolation)

  3. Direction Cosine Matrix (DCM) (3x3 matrix, 9 parameters)

Euler Angles (Yaw, Pitch, Roll)

from pytcl.coordinate_systems.rotations import euler2rotmat, rotmat2euler

# The default 'ZYX' aerospace sequence takes angles [yaw, pitch, roll]
# and builds R = Rz(yaw) @ Ry(pitch) @ Rx(roll)
yaw, pitch, roll = 0.2, 0.05, 0.1  # radians

DCM = euler2rotmat([yaw, pitch, roll], 'ZYX')

# Rotate a vector: v_rotated = DCM @ v
v = np.array([1.0, 0.0, 0.0])
v_rotated = DCM @ v

# Back to Euler angles
angles_back = rotmat2euler(DCM, 'ZYX')  # [yaw, pitch, roll]

Quaternions (better for interpolation, no gimbal lock)

from pytcl.coordinate_systems.rotations import (
    euler2quat, quat2euler, quat_multiply, quat_rotate
)

# Euler to quaternion; [w, x, y, z] scalar-first
quat = euler2quat([0.2, 0.05, 0.1], 'ZYX')

# Re-normalize after repeated operations (plain NumPy)
quat = quat / np.linalg.norm(quat)

# Compose rotations (quaternion multiplication)
quat1 = euler2quat([0.1, 0.0, 0.0], 'ZYX')
quat2 = euler2quat([0.0, 0.05, 0.0], 'ZYX')
quat_combined = quat_multiply(quat1, quat2)

# Rotate a vector directly
v_rotated = quat_rotate(quat, np.array([1.0, 0.0, 0.0]))

# Back to Euler
euler_back = quat2euler(quat, 'ZYX')

Related helpers: quat_conjugate, quat_inverse, quat2rotmat, rotmat2quat, and slerp(q1, q2, t) for interpolation.

Direction Cosine Matrix (DCM)

from pytcl.coordinate_systems.rotations import euler2rotmat

# DCM: 3x3 orthogonal matrix (R R^T = I, det = +1)
DCM = euler2rotmat([0.2, 0.05, 0.1], 'ZYX')

# Verify orthogonality
assert np.allclose(DCM @ DCM.T, np.eye(3))
assert np.isclose(np.linalg.det(DCM), 1.0)

# Rotate vector
v = np.array([1.0, 0.0, 0.0])
v_rotated = DCM @ v

# Rotate back
v_original = DCM.T @ v_rotated

is_rotation_matrix(R) checks both properties for you.

Jacobians for Nonlinear Filtering

Why Jacobians Matter

In Extended Kalman Filters, you need partial derivatives (Jacobians) for linearization.

Common Jacobians Available:

from pytcl.coordinate_systems.jacobians import (
    spherical_jacobian,      # d(r, az, el) / d(x, y, z)
    spherical_jacobian_inv,  # d(x, y, z) / d(r, az, el)
    geodetic_jacobian,       # d(ECEF) / d(lat, lon, alt)
    enu_jacobian,            # ECEF -> ENU rotation at (lat, lon)
    ned_jacobian,            # ECEF -> NED rotation at (lat, lon)
    polar_jacobian,          # 2D polar
    ruv_jacobian,            # d(r, u, v) / d(x, y, z)
)

# Jacobian of the Cartesian -> spherical measurement function
cart = np.array([1000.0, 500.0, 100.0])
H = spherical_jacobian(cart, system_type='az-el')  # 3x3 matrix

# Use in an EKF measurement update
from pytcl.dynamic_estimation.kalman import ekf_predict, ekf_update
from pytcl.coordinate_systems.conversions import cart2sphere

def h_measure(state):
    """Predict the spherical measurement from a Cartesian state."""
    r, az, el = cart2sphere(state[:3], system_type='az-el')
    return np.array([r, az, el])

def H_jacobian(state):
    """Jacobian of the measurement model (position block only)."""
    H = np.zeros((3, state.shape[0]))
    H[:, :3] = spherical_jacobian(state[:3], system_type='az-el')
    return H

# ekf_predict / ekf_update take these as the h and H arguments

Checking a Jacobian Numerically

The library ships a finite-difference helper, so there is no need to write your own:

from pytcl.coordinate_systems.jacobians import (
    numerical_jacobian, spherical_jacobian
)
from pytcl.coordinate_systems.conversions import cart2sphere

cart = np.array([1000.0, 500.0, 100.0])
J_analytic = spherical_jacobian(cart, system_type='az-el')
J_numeric = numerical_jacobian(
    lambda p: np.array(cart2sphere(p, system_type='az-el')), cart
)

print("Max difference:", np.abs(J_analytic - J_numeric).max())
# Max difference: ~1e-6 (finite-difference accuracy)

Map Projections

When to Use Map Projections

Project 3D Earth to 2D maps for display or processing.

UTM (Universal Transverse Mercator)

Good for local areas with minimal distortion.

from pytcl.coordinate_systems.projections import (
    geodetic2utm, utm2geodetic
)

# Geodetic to UTM (angles in radians)
lat, lon = np.radians(40.7128), np.radians(-74.0060)  # NYC
result = geodetic2utm(lat, lon)

print(f"UTM zone: {result.zone}{result.hemisphere}")
print(f"Easting: {result.easting:.1f} m, Northing: {result.northing:.1f} m")

# UTM back to geodetic; returns (lat, lon) in radians
lat_back, lon_back = utm2geodetic(
    result.easting, result.northing, result.zone, result.hemisphere
)

Output:

UTM zone: 18N
Easting: 583959.4 m, Northing: 4507351.0 m

Mercator Projection

Conformal projection (preserves angles). Used by web maps.

from pytcl.coordinate_systems.projections import mercator, mercator_inverse

res = mercator(np.radians(40.7128), np.radians(-74.0060))
print(f"x={res.x:.1f} m, y={res.y:.1f} m")
# x=-8238310.2 m, y=4942194.8 m

lat_back, lon_back = mercator_inverse(res.x, res.y)

Lambert Conformal Conic

Good for mid-latitude regions (weather maps, regional charts). Takes the projection origin and two standard parallels:

from pytcl.coordinate_systems.projections import lambert_conformal_conic

res = lambert_conformal_conic(
    np.radians(40.7128), np.radians(-74.0060),  # point
    np.radians(39.0), np.radians(-96.0),        # origin lat0, lon0
    np.radians(33.0), np.radians(45.0),         # standard parallels
)
print(f"x={res.x:.1f} m, y={res.y:.1f} m")

Also available: transverse_mercator, stereographic, polar_stereographic, azimuthal_equidistant (each with an inverse).

Comparison:

Projection

Best Area

Distortion

Complexity

UTM

Local

Low

Simple

Mercator

Global

High eq.

Medium

Lambert Conformal

Regional

Low lat.

Medium

Coordinate Transformation Workflow

Five-Step Process for Complex Transformations

import numpy as np
from pytcl.coordinate_systems.conversions import (
    sphere2cart, enu2ecef, ecef2geodetic, geodetic2ecef,
)

def transform_measurement(radar_meas, radar_lat, radar_lon, radar_alt):
    """
    Transform a radar measurement to global geodetic coordinates.

    Args:
        radar_meas: (range, azimuth, elevation) from the radar
        radar_lat, radar_lon: radar position (radians)
        radar_alt: radar altitude (meters)

    Returns:
        (lat, lon, alt) of the target in radians/meters
    """
    # Step 1: Identify source and destination
    #   Source: Spherical (radar coords); Destination: Geodetic
    # Step 2: Find intermediate frames
    #   Path: Spherical -> Cartesian (ENU) -> ECEF -> Geodetic
    r, az, el = radar_meas

    # Step 3: Spherical to Cartesian (ENU offset from the radar)
    enu = sphere2cart(r, az, el, system_type='az-el')

    # Step 4: ENU to ECEF (absolute position)
    radar_ecef = geodetic2ecef(radar_lat, radar_lon, radar_alt)
    ecef = enu2ecef(enu, radar_lat, radar_lon, radar_ecef)

    # Step 5: ECEF to Geodetic (final destination)
    return ecef2geodetic(ecef)

Performance Considerations

Vectorization: Convert Multiple Points at Once

The conversion functions accept arrays, so batches never need a Python loop:

import numpy as np
from pytcl.coordinate_systems.conversions import sphere2cart

# (n, 3) array of [range, az, el] measurements
measurements = np.array([
    [100.0, 0.10, 0.05],
    [105.0, 0.12, 0.06],
    [102.0, 0.11, 0.04],
])

# One vectorized call; returns a (3, n) array of [x, y, z] columns
cart_all = sphere2cart(
    measurements[:, 0], measurements[:, 1], measurements[:, 2],
    system_type='az-el',
)

# cart2sphere likewise accepts (3, n) or (n, 3) point arrays

Caching Jacobians

import numpy as np
from functools import lru_cache
from pytcl.coordinate_systems.jacobians import spherical_jacobian

@lru_cache(maxsize=256)
def cached_jacobian(point_tuple):
    """Cache Jacobians for quantized points (tuples are hashable)."""
    return spherical_jacobian(np.array(point_tuple), system_type='az-el')

# Quantize to 10 m resolution to raise the cache hit rate
point = np.array([1003.7, 498.2, 101.4])
point_quantized = tuple(np.round(point / 10.0) * 10.0)

J = cached_jacobian(point_quantized)

Common Pitfalls and Solutions

Pitfall 1: Angle Units (Degrees vs Radians)

Problem: Every pytcl function expects radians; passing degrees produces silently wrong answers.

from pytcl.coordinate_systems.conversions import geodetic2ecef

# Wrong: degrees are interpreted as radians
ecef = geodetic2ecef(40.0, -74.0, 0.0)

# Correct
ecef = geodetic2ecef(np.radians(40.0), np.radians(-74.0), 0.0)

Pitfall 2: Azimuth Direction Convention

Problem: Different conventions for azimuth reference:

  • 0 deg = North (navigation)

  • 0 deg = East (math)

  • 0 deg = North, +/-180 deg = South (compass)

from pytcl.coordinate_systems.conversions import cart2sphere

# TCL 'az-el' convention: azimuth from the +x axis
r, az, el = cart2sphere(np.array([100.0, 0.0, 0.0]),
                        system_type='az-el')
# r=100, az=0 (+x direction), el=0

# Convert compass bearing to math convention
def compass_to_math_bearing(compass_deg):
    """90 deg compass = 0 deg math, etc."""
    return np.radians(90.0 - compass_deg)

Pitfall 3: Coordinate Frame Confusion (ECEF vs ECI)

Problem: Mixing inertial and rotating frames.

from pytcl.astronomical import cal_to_jd, gmst
from pytcl.astronomical.reference_frames import eci_to_ecef

# ECI: fixed to distant stars (inertial)
sat_eci = np.array([6600e3, 0.0, 0.0])

# ECEF rotates with Earth: you MUST supply the rotation angle
# for the observation time
theta = gmst(cal_to_jd(2026, 2, 26, 12, 0, 0.0))
sat_ecef = eci_to_ecef(sat_eci, theta)

# Wrong: using ECI coordinates directly as if they were ECEF
# Correct: rotate between frames using GMST at the observation time

Pitfall 4: Singularities (Azimuth Undefined at Z-Axis)

Problem: Spherical azimuth is undefined when x=y=0 (looking straight up/down).

# Problematic: looking straight up, azimuth is arbitrary
r, az, el = cart2sphere(np.array([0.0, 0.0, 100.0]),
                        system_type='az-el')

# Use R-U-V instead (range + direction cosines): well defined
from pytcl.coordinate_systems.conversions import cart2ruv
r, u, v = cart2ruv(np.array([0.0, 0.0, 100.0]))
# Result: r=100, u=0, v=0 (boresight)

Pitfall 5: Altitude Reference Ambiguity

Problem: MSL vs ellipsoid (WGS84) altitude.

from pytcl.coordinate_systems.conversions import geodetic2ecef

# TCL uses WGS84 ellipsoid altitude (above ellipsoid)
# GPS altitude is ellipsoid altitude
# MSL elevation = WGS84 height - geoid height (see pytcl.gravity.geoid_height)

lat, lon = np.radians(40.7128), np.radians(-74.0060)
altitude_wgs84 = 10.0  # above the WGS84 ellipsoid

ecef = geodetic2ecef(lat, lon, altitude_wgs84)

Pitfall 6: ENU/NED Reference Point Matters

Problem: ENU/NED are relative to an observer; the reference position is a required part of the conversion.

from pytcl.coordinate_systems.conversions import enu2ecef, geodetic2ecef

enu = np.array([100.0, 200.0, 50.0])  # 100m east, 200m north, 50m up

# The observer's latitude/longitude orient the tangent plane, and the
# observer's ECEF position anchors it:
lat_ref = np.radians(40.7128)   # NYC
lon_ref = np.radians(-74.0060)
ecef_ref = geodetic2ecef(lat_ref, lon_ref, 10.0)

ecef_target = enu2ecef(enu, lat_ref, lon_ref, ecef_ref)

Advanced Topics

Batch Conversion with Multiple Frames

from pytcl.coordinate_systems.conversions import (
    sphere2cart, enu2ecef, ecef2geodetic, geodetic2ecef,
)

def batch_convert_radar_to_geodetic(measurements, radar_positions):
    """
    Convert radar measurements from different radar locations.

    Args:
        measurements: (N, 3) array of [range, az, el]
        radar_positions: (N, 3) array of [lat (rad), lon (rad), alt (m)]

    Returns:
        (N, 3) array of [lat, lon, alt]
    """
    targets = []
    for meas, radar in zip(measurements, radar_positions):
        r, az, el = meas
        radar_lat, radar_lon, radar_alt = radar

        enu = sphere2cart(r, az, el, system_type='az-el')
        radar_ecef = geodetic2ecef(radar_lat, radar_lon, radar_alt)
        ecef = enu2ecef(enu, radar_lat, radar_lon, radar_ecef)
        targets.append(ecef2geodetic(ecef))

    return np.array(targets)

Time-Varying Reference Frames

import numpy as np
from pytcl.astronomical import gmst
from pytcl.astronomical.reference_frames import eci_to_ecef
from pytcl.coordinate_systems.conversions import ecef2geodetic

def satellite_ground_track(sat_eci, jd_times):
    """
    Project a satellite position to its ground track (geodetic).

    The ECI/ECEF relationship changes with time, so GMST is
    recomputed for every sample.
    """
    track = []
    for jd in jd_times:
        sat_ecef = eci_to_ecef(sat_eci, gmst(jd))
        track.append(ecef2geodetic(sat_ecef))
    return np.array(track)

Conversion Decision Tree

How to choose the right conversion:

Start: I have coordinates in ______

+- Spherical (range, az, el)?
|  +- Want Cartesian? -> sphere2cart(r, az, el)
|
+- Cartesian (x, y, z)?
|  +- Want Spherical? -> cart2sphere
|  +- Want Polar (2D)? -> cart2pol
|  +- Want R-U-V? -> cart2ruv
|  +- Want Geodetic? -> Need intermediate ECEF
|      +- Is this local (around observer)? -> enu2ecef first
|      +- Is this absolute? -> already ECEF, use ecef2geodetic
|
+- Geodetic (lat, lon, alt)?
|  +- Want Cartesian (ECEF)? -> geodetic2ecef(lat, lon, alt)
|  +- Want local ENU? -> geodetic2enu
|
+- ECEF?
|  +- Want Geodetic? -> ecef2geodetic
|  +- Want ENU/NED? -> ecef2enu or ecef2ned
|  +- Want ECI? -> Need GMST -> ecef_to_eci(x, gmst)
|
+- ENU/NED (local)?
|  +- Want ECEF? -> enu2ecef(enu, lat, lon, ecef_ref) or ned2ecef(...)
|  +- Want local Cartesian? -> Already Cartesian (ENU/NED ARE Cartesian)
|
+- ECI (inertial)?
    +- Want ECEF? -> Need GMST -> eci_to_ecef(x, gmst)

See Also