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.)

Common Coordinate Systems

Cartesian (XYZ)

Rectangular coordinates commonly used in physics and tracking.

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

from pytcl.coordinate_systems.conversions import cart2sphere
sphere = cart2sphere(x)  # Convert to spherical
# Result: [range, azimuth, elevation]

Spherical (Range, Azimuth, Elevation)

Standard radar/sensor coordinate system.

# [range, azimuth, elevation] in [meters, radians, radians]
sphere = np.array([
    1000.0,           # range (meters)
    np.pi/4,          # azimuth (radians, 0=East, π/2=North)
    np.pi/6           # elevation (radians, 0=horizon, ±π/2=zenith)
])

from pytcl.coordinate_systems.conversions import sphere2cart
cart = sphere2cart(sphere)  # Result: [x, y, z]

Geodetic (Latitude, Longitude, Altitude)

WGS84 ellipsoid coordinates used by GPS.

# [latitude, longitude, altitude] in [degrees or radians, degrees or radians, meters]
geodetic = np.array([
    40.7128,      # latitude (degrees N)
    -74.0060,     # longitude (degrees E)
    10.0          # altitude above ellipsoid (meters)
])

from pytcl.coordinate_systems.conversions import geodetic2ecef
ecef = geodetic2ecef(geodetic)  # Earth-Centered Earth-Fixed

ECEF (Earth-Centered Earth-Fixed)

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

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

from pytcl.coordinate_systems.conversions import ecef2geodetic
geodetic = ecef2geodetic(ecef)

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)

# ENU: [east, north, up] in meters
# Relative to observer location
enu = np.array([100.0, 200.0, 50.0])  # 100m east, 200m north, 50m up

# Convert to ECEF if you know observer position
from pytcl.coordinate_systems.conversions import enu2ecef
observer_geodetic = np.array([40.7128, -74.0060, 10.0])  # NYC
ecef_target = enu2ecef(enu, observer_geodetic)

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

from pytcl.coordinate_systems.conversions import ned2ecef
ecef_target = ned2ecef(ned, observer_geodetic)

Cylindrical (ρ, φ, z)

Axially symmetric coordinates.

# [rho, phi, z] = [radius, azimuth, height]
cyl = np.array([
    141.42,        # ρ = sqrt(x² + y²)
    np.pi/4,       # φ = atan2(y, x)
    10.0           # z
])

from pytcl.coordinate_systems.conversions import cyl2cart
cart = cyl2cart(cyl)  # → [100, 100, 10]

Polar (ρ, φ) (2D version of cylindrical)

# [rho, phi] = [radius, azimuth]
polar = np.array([141.42, np.pi/4])  # 2D plane

from pytcl.coordinate_systems.conversions import pol2cart
cart = pol2cart(polar)  # → [100, 100]

RUV (Range, Unit Vector)

Range and direction as unit vector. Useful for Jacobians without dealing with singularities at z=0.

# [range, ux, uy, uz] where (ux, uy, uz) is unit direction vector
ruv = np.array([
    1000.0,                    # range (meters)
    0.7071,  0.7071, 0.0      # unit direction vector
])

from pytcl.coordinate_systems.conversions import ruv2cart
cart = ruv2cart(ruv)

Conversion Matrix Quick Reference

Cartesian (X, Y, Z)
     ↕ sphere2cart, cart2sphere
Spherical (Range, Az, El)

Cartesian (X, Y, Z)
     ↕ pol2cart, cart2pol
Polar (ρ, φ)

Cartesian (X, Y, Z)
     ↕ cyl2cart, cart2cyl
Cylindrical (ρ, φ, 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 rotation (see reference_frames)

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, ecef2enu, enu2ecef, ecef2geodetic
)

# Radar measurement
radar_meas = np.array([1000.0, np.pi/4, 0.2])  # r=1km, az=45°, el≈11°

# Radar location
radar_lat_lon_alt = np.array([40.7128, -74.0060, 100.0])  # NYC, 100m elevation

# Step 1: Sphere to Cartesian (relative to radar)
cart_relative = sphere2cart(radar_meas)  # [x, y, z] offset from radar

# Step 2: Cartesian to ENU (still relative)
# Note: sphere2cart output is [x_east, y_north, z_up] in ENU convention
enu_target = cart_relative  # Already ENU relative to radar

# Step 3: ENU to ECEF (absolute position)
ecef_target = enu2ecef(enu_target, radar_lat_lon_alt)

# Step 4: ECEF to Geodetic (lat/lon/alt)
target_position = ecef2geodetic(ecef_target)

print(f"Target: Lat={np.degrees(target_position[0]):.4f}°, "
      f"Lon={np.degrees(target_position[1]):.4f}°, "
      f"Alt={target_position[2]:.1f}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, ecef2enu
from pytcl.coordinate_systems.rotations import euler2dcm

# GPS solution
gps_geodetic = np.array([40.7128, -74.0060, 100.0])
gps_ecef = geodetic2ecef(gps_geodetic)

# Reference point (IMU navigation origin)
ref_geodetic = np.array([40.7128, -74.0060, 100.0])
ref_ecef = geodetic2ecef(ref_geodetic)

# Target relative to reference (ENU frame)
target_enu = np.array([100.0, 50.0, 10.0])  # 100m E, 50m N, 10m up

# IMU acceleration (in vehicle body frame)
# Vehicle attitude (heading, pitch, roll)
euler = np.array([0.2, 0.1, 0.05])  # radians
DCM = euler2dcm(euler)  # Direction Cosine Matrix

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

# Transform to ENU (local tangent plane)
accel_enu = DCM.T @ accel_body

print(f"Acceleration in ENU: {accel_enu}")

Use Case 3: Orbital Mechanics (ECI ↔ ECEF)

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

Solution:

import numpy as np
from pytcl.astronomical.reference_frames import ecef2eci, eci2ecef
from pytcl.coordinate_systems.conversions import ecef2geodetic

# Satellite position in ECI (inertial frame)
sat_eci = np.array([6600000.0, 0.0, 0.0])  # X=6600km, Y=0, Z=0

# Time of observation
import datetime
t = datetime.datetime(2026, 2, 26, 12, 0, 0)  # UTC

# Transform to ECEF (fixed to Earth)
sat_ecef = eci2ecef(sat_eci, t)

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

print(f"Satellite lat={np.degrees(sat_geodetic[0]):.2f}°, "
      f"lon={np.degrees(sat_geodetic[1]):.2f}°")

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) (3×3 matrix, computationally intensive)

Euler Angles (Roll, Pitch, Yaw)

from pytcl.coordinate_systems.rotations import (
    euler2dcm, euler2quat, dcm2euler, quat2euler
)

# Euler angles [roll, pitch, yaw] in radians
# Convention: RzRyRx (yaw about Z, pitch about Y, roll about X)
euler = np.array([
    0.1,       # roll (rotation about X)
    0.05,      # pitch (rotation about Y)
    0.2        # yaw (rotation about Z)
])

# Convert to DCM
DCM = euler2dcm(euler)

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

Quaternions (better for interpolation, no gimbal lock)

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

# Euler to Quaternion
euler = np.array([0.1, 0.05, 0.2])
quat = euler2quat(euler)  # [w, x, y, z] scalar-first

# Normalize quaternion (stays unit length)
quat = quat_normalize(quat)

# Multiple rotations (quaternion multiplication)
quat1 = euler2quat(np.array([0.1, 0, 0]))
quat2 = euler2quat(np.array([0, 0.05, 0]))
quat_combined = quat_multiply(quat1, quat2)

# Back to Euler
euler_back = quat2euler(quat)

Direction Cosine Matrix (DCM)

from pytcl.coordinate_systems.rotations import euler2dcm, dcm2euler

# DCM: 3×3 orthogonal matrix (RT = I, det=1)
euler = np.array([0.1, 0.05, 0.2])
DCM = euler2dcm(euler)

# Verify orthogonality
assert np.allclose(DCM @ DCM.T, np.eye(3))  # R^T R = I
assert np.isclose(np.linalg.det(DCM), 1.0)  # det = +1

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

# Rotate back
v_original = DCM.T @ v_rotated

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 (
    jacobian_cart2sphere,
    jacobian_sphere2cart,
    jacobian_cart2pol,
    jacobian_geodetic2ecef,
    jacobian_ecef2enu
)

# Jacobian of spherical to Cartesian conversion
# ∂(x,y,z)/∂(r,az,el)
sphere = np.array([1000.0, 0.5, 0.1])
H_sphere = jacobian_sphere2cart(sphere)  # 3×3 matrix

# Use in EKF measurement update
from pytcl.dynamic_estimation.kalman import extended_kalman_filter

def h_measure(state_cart):
    """Measure in spherical, state in Cartesian"""
    return sphere2cart(state_cart)

def H_jacobian(state_cart):
    """Jacobian of measurement model"""
    return jacobian_sphere2cart(state_cart)

# EKF prediction/update uses these

Computing Jacobians Manually (if needed):

import numpy as np

def jacobian_numerical(func, x, delta=1e-8):
    """Numerical Jacobian using finite differences"""
    n = len(x)
    m = len(func(x))
    J = np.zeros((m, n))

    for i in range(n):
        x_plus = x.copy()
        x_plus[i] += delta
        x_minus = x.copy()
        x_minus[i] -= delta

        J[:, i] = (func(x_plus) - func(x_minus)) / (2 * delta)

    return J

# Test: Jacobian of sphere2cart
from pytcl.coordinate_systems.conversions import sphere2cart

sphere = np.array([1000.0, 0.5, 0.1])
J_analytic = jacobian_sphere2cart(sphere)
J_numeric = jacobian_numerical(sphere2cart, sphere)

print("Analytic Jacobian:\n", J_analytic)
print("Numeric Jacobian:\n", J_numeric)
print("Difference:", np.linalg.norm(J_analytic - J_numeric))

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 (
    geographic2utm, utm2geographic
)

# Geodetic to UTM
geodetic = np.array([40.7128, -74.0060, 0.0])  # NYC
easting, northing, zone = geographic2utm(geodetic)

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

# UTM back to Geodetic
geodetic_back = utm2geographic(easting, northing, zone)

Mercator Projection

Conformal projection (preserves angles).

# Similar interface
# Web maps (Google Maps, OpenStreetMap) use Web Mercator
# Use for global visualizations that need correct angles

Lambert Conformal Conic

Good for mid-latitude regions.

# For weather maps, regional forecasts
# Preserves angles and shapes better than Mercator

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
)

def transform_measurement(radar_meas, radar_position, reference_position):
    """
    Transform radar measurement to global geodetic coordinates

    Args:
        radar_meas: [range, azimuth, elevation] from radar
        radar_position: [lat, lon, alt] of radar
        reference_position: [lat, lon, alt] reference frame

    Returns:
        target_geodetic: [lat, lon, alt] of target
    """

    # Step 1: Identify source and destination
    # Source: Spherical (radar coords)
    # Destination: Geodetic (lat/lon/alt)

    # Step 2: Find intermediate frames
    # Path: Spherical → Cartesian (ENU) → ECEF → Geodetic

    # Step 3: Spherical to Cartesian (relative to radar)
    cart_relative = sphere2cart(radar_meas)  # [x, y, z] in ENU

    # Step 4: ENU to ECEF (absolute position)
    ecef_target = enu2ecef(cart_relative, radar_position)

    # Step 5: ECEF to Geodetic (final destination)
    target_geodetic = ecef2geodetic(ecef_target)

    return target_geodetic

Performance Considerations

Vectorization: Convert Multiple Points at Once

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

# ❌ Slow: Loop
measurements = np.array([
    [100, 0.1, 0.05],
    [105, 0.12, 0.06],
    [102, 0.11, 0.04],
])

cart_slow = []
for meas in measurements:
    cart_slow.append(sphere2cart(meas))

# ✅ Fast: Vectorized if function supports it
cart_fast = np.array([sphere2cart(m) for m in measurements])

# Or reshape for batch processing
measurements_batch = measurements.reshape(-1, 3)
# Apply conversion to all rows

Caching Jacobians

from pytcl.coordinate_systems.jacobians import jacobian_cart2sphere
from functools import lru_cache

@lru_cache(maxsize=256)
def cached_jacobian(x_tuple):
    """Cache Jacobian with quantization"""
    x = np.array(x_tuple)
    return jacobian_cart2sphere(x)

# Quantize to reduce cache misses
x_quantized = np.round(x / 10) * 10  # 10m resolution
x_tuple = tuple(x_quantized)

J = cached_jacobian(x_tuple)

Common Pitfalls and Solutions

Pitfall 1: Angle Units (Degrees vs Radians)

Problem: Function expects radians, you pass degrees.

# ❌ Wrong
geodetic = np.array([40, -74, 0])  # Degrees
ecef = geodetic2ecef(geodetic)  # Interprets as radians!

# ✅ Correct
geodetic_rad = np.radians(np.array([40, -74]))
geodetic = np.array([*geodetic_rad, 0])  # [lat_rad, lon_rad, alt]
ecef = geodetic2ecef(geodetic)

Pitfall 2: Azimuth Direction Convention

Problem: Different conventions for azimuth reference: - 0° = North (navigation) - 0° = East (math) - 0° = North, ±180° = South (compass)

# TCL convention: East=0, North=π/2
from pytcl.coordinate_systems.conversions import cart2sphere

# Cartesian [100, 0, 0] → Spherical
sphere = cart2sphere(np.array([100, 0, 0]))  # [100, 0, 0]
# range=100, azimuth=0 (East), elevation=0

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

Pitfall 3: Coordinate Frame Confusion (ECEF vs ECI)

Problem: Mixing inertial and rotating frames.

import datetime
from pytcl.astronomical.reference_frames import eci2ecef, ecef2eci

# ECI: Fixed to distant stars (inertial)
sat_eci = np.array([6600000, 0, 0])

# ECEF: Rotates with Earth
# MUST specify time of observation
t = datetime.datetime(2026, 2, 26, 12, 0, 0)
sat_ecef = eci2ecef(sat_eci, t)

# ❌ Wrong: Use ECI coordinates directly without transformation
# ✅ Correct: Transform between frames with time

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

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

# ❌ Problematic: Origin
sphere = cart2sphere(np.array([0, 0, 100]))  # Looking straight up
# Azimuth is undefined/arbitrary

# ✅ Use RUV instead (range + unit vector)
from pytcl.coordinate_systems.conversions import cart2ruv
ruv = cart2ruv(np.array([0, 0, 100]))  # Well-defined
# Result: [100, 0, 0, 1] (range=100, direction=[0,0,1])

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
# Elevation = WGS84 height - geoid height (EGM96/2008)

lat_lon = np.array([40.7128, -74.0060])
altitude_wgs84 = 10.0  # Above WGS84 ellipsoid
altitude_msl = 0.0  # Above sea level (approximately)

# Use WGS84 altitude for geodetic2ecef
geodetic = np.array([lat_lon[0], lat_lon[1], altitude_wgs84])
ecef = geodetic2ecef(geodetic)

Pitfall 6: ENU/NED Reference Point Matters

Problem: ENU/NED are relative to observer; forgetting reference point.

from pytcl.coordinate_systems.conversions import enu2ecef

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

# ❌ Wrong: No reference point
# Where is this ENU relative to?

# ✅ Correct: Specify observer position
observer = np.array([40.7128, -74.0060, 10.0])  # NYC
ecef_target = enu2ecef(enu, observer)

Advanced Topics

Batch Conversion with Multiple Frames

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

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

    Returns:
        targets_geodetic: (N, 3) array of [lat, lon, alt]
    """
    from pytcl.coordinate_systems.conversions import (
        sphere2cart, enu2ecef, ecef2geodetic
    )

    targets = []
    for i in range(len(measurements)):
        # Convert radar meas to ENU (relative)
        enu = sphere2cart(measurements[i])

        # Convert to ECEF (absolute)
        ecef = enu2ecef(enu, radar_positions[i])

        # Convert to geodetic
        geodetic = ecef2geodetic(ecef)
        targets.append(geodetic)

    return np.array(targets)

Time-Varying Reference Frames

import numpy as np
import datetime

def satellite_to_ground_track(sat_eci, times):
    """
    Project satellite position to ground track (geodetic)

    As satellite orbits, ECEF relationship changes with time
    """
    from pytcl.astronomical.reference_frames import eci2ecef
    from pytcl.coordinate_systems.conversions import ecef2geodetic

    ground_track = []
    for t in times:
        sat_ecef = eci2ecef(sat_eci, t)
        ground_point = ecef2geodetic(sat_ecef)
        ground_track.append(ground_point)

    return np.array(ground_track)

Conversion Decision Tree

How to choose the right conversion:

Start: I have coordinates in ______

├─ Spherical (range, az, el)?
│  └─ Want Cartesian? → sphere2cart
│
├─ Cartesian (x, y, z)?
│  ├─ Want Spherical? → cart2sphere
│  ├─ Want Polar (2D)? → cart2pol
│  ├─ Want Geodetic? → Need intermediate ECEF
│  │   └─ Is this local (around observer)? → ECEF unknown
│  │   └─ Is this absolute? → Need context
│  └─ Want ENU/NED?
│       └─ Use ecef2enu or ecef2ned
│
├─ Geodetic (lat, lon, alt)?
│  └─ Want Cartesian (ECEF)? → geodetic2ecef
│
├─ ECEF?
│  ├─ Want Geodetic? → ecef2geodetic
│  ├─ Want ENU/NED? → ecef2enu or ecef2ned
│  ├─ Want ECI? → Need time → ecef2eci(x, time)
│  └─ Want Local? → Depends on observer
│
├─ ENU/NED (local)?
│  ├─ Want ECEF? → enu2ecef(enu, observer) or ned2ecef(ned, observer)
│  └─ Want local Cartesian? → Already Cartesian (ENU/NED ARE Cartesian)
│
└─ ECI (inertial)?
    └─ Want ECEF? → Need time → eci2ecef(x, time)

See Also