Coordinate Systems & Geodesy

Table of Contents

Overview

Accurate position calculations are fundamental to balloon trajectory prediction. The simulator implements sophisticated geodetic calculations to handle the non-spherical shape of Earth, variations in gravity, and transformations between different coordinate systems. This section covers the mathematical foundations and practical implementations of these systems.

Why Geodesy Matters: Earth is not a perfect sphere but an oblate spheroid, flattened at the poles by about 21 km. This affects gravity, distances, and trajectories. For a balloon flight from equator to pole, ignoring Earth's true shape would cause position errors exceeding 20 km.

WGS84 Reference Ellipsoid

The World Geodetic System 1984 (WGS84) defines Earth's shape and gravitational field. It serves as the reference for GPS and most modern mapping systems.

Fundamental Parameters

Parameter Symbol Value Description
Semi-major axis $a$ 6,378,137.0 m Equatorial radius
Semi-minor axis $b$ 6,356,752.314245 m Polar radius
Flattening $f$ 1/298.257223563 $(a-b)/a$
First eccentricity² $e^2$ 6.69437999014 × 10⁻³ $(a²-b²)/a²$
Second eccentricity² $e'^2$ 6.73949674228 × 10⁻³ $(a²-b²)/b²$

Derived Relationships

$$f = \frac{a-b}{a} = 1 - \frac{b}{a}$$ $$e^2 = 2f - f^2 = \frac{a^2 - b^2}{a^2}$$ $$e'^2 = \frac{e^2}{1-e^2} = \frac{a^2 - b^2}{b^2}$$ $$b = a(1-f) = a\sqrt{1-e^2}$$
The flattening $f$ quantifies how much Earth deviates from a perfect sphere. The eccentricity $e$ measures the ellipse's deviation from a circle. These small values (~0.003) have significant effects on long-distance calculations.

Coordinate System Types

1. Geodetic Coordinates (LLA)

Geodetic coordinates specify position using latitude, longitude, and altitude above the ellipsoid:

  • Latitude ($\phi$): Angle between the equatorial plane and the ellipsoid normal, -90° to +90°
  • Longitude ($\lambda$): Angle from the prime meridian, -180° to +180°
  • Height ($h$): Distance above the ellipsoid along the normal
Important: Geodetic latitude differs from geocentric latitude. The ellipsoid normal does not pass through Earth's center except at the equator and poles.

2. Earth-Centered Earth-Fixed (ECEF)

ECEF is a Cartesian coordinate system that rotates with Earth:

  • Origin: Earth's center of mass
  • X-axis: Points to the intersection of prime meridian and equator
  • Y-axis: Points to 90°E longitude on the equator
  • Z-axis: Points to the North Pole (Earth's rotation axis)

3. Local Tangent Plane (ENU)

East-North-Up coordinates are useful for local calculations:

  • East (E): Points toward local east
  • North (N): Points toward local north
  • Up (U): Points along the local vertical (ellipsoid normal)

Coordinate Transformations

Geodetic to ECEF Transformation

Converting from latitude/longitude/height to ECEF coordinates requires the prime vertical radius of curvature:

$$N = \frac{a}{\sqrt{1 - e^2\sin^2\phi}}$$
The prime vertical radius $N$ represents the radius of curvature in the east-west direction. It varies from $a$ at the equator to $a/\sqrt{1-e^2}$ at the poles.

The transformation equations are:

$$X = (N + h)\cos\phi\cos\lambda$$ $$Y = (N + h)\cos\phi\sin\lambda$$ $$Z = (N(1-e^2) + h)\sin\phi$$

Implementation

class WGS84:
    def __init__(self):
        self.a = 6378137.0  # Semi-major axis [m]
        self.f = 1/298.257223563  # Flattening
        self.b = self.a * (1 - self.f)  # Semi-minor axis
        self.e2 = 2*self.f - self.f**2  # First eccentricity squared
        self.ep2 = self.e2 / (1 - self.e2)  # Second eccentricity squared

    def geodetic_to_ecef(self, lat_deg, lon_deg, height_m):
        """Convert geodetic coordinates to ECEF"""
        # Convert to radians
        lat = np.radians(lat_deg)
        lon = np.radians(lon_deg)

        # Prime vertical radius of curvature
        N = self.a / np.sqrt(1 - self.e2 * np.sin(lat)**2)

        # ECEF coordinates
        X = (N + height_m) * np.cos(lat) * np.cos(lon)
        Y = (N + height_m) * np.cos(lat) * np.sin(lon)
        Z = (N * (1 - self.e2) + height_m) * np.sin(lat)

        return X, Y, Z

ECEF to Geodetic Transformation

The inverse transformation is more complex and requires iteration. We use Bowring's method:

def ecef_to_geodetic(self, X, Y, Z):
    """Convert ECEF to geodetic coordinates using Bowring's method"""
    # Distance from Z-axis
    p = np.sqrt(X**2 + Y**2)

    # Longitude is straightforward
    lon = np.arctan2(Y, X)

    # Initial values for iteration
    lat = np.arctan2(Z, p * (1 - self.e2))

    # Iterate to refine latitude
    for _ in range(5):  # Usually converges in 2-3 iterations
        N = self.a / np.sqrt(1 - self.e2 * np.sin(lat)**2)
        h = p / np.cos(lat) - N
        lat = np.arctan2(Z, p * (1 - self.e2 * N / (N + h)))

    # Final height calculation
    N = self.a / np.sqrt(1 - self.e2 * np.sin(lat)**2)
    h = p / np.cos(lat) - N

    return np.degrees(lat), np.degrees(lon), h

Local Tangent Plane Transformation

To convert ECEF to local East-North-Up coordinates:

$$\begin{bmatrix} E \\ N \\ U \end{bmatrix} = R \begin{bmatrix} X - X_0 \\ Y - Y_0 \\ Z - Z_0 \end{bmatrix}$$

where the rotation matrix $R$ is:

$$R = \begin{bmatrix} -\sin\lambda_0 & \cos\lambda_0 & 0 \\ -\sin\phi_0\cos\lambda_0 & -\sin\phi_0\sin\lambda_0 & \cos\phi_0 \\ \cos\phi_0\cos\lambda_0 & \cos\phi_0\sin\lambda_0 & \sin\phi_0 \end{bmatrix}$$
def ecef_to_enu(self, X, Y, Z, lat0_deg, lon0_deg, h0_m):
    """Convert ECEF to local ENU coordinates"""
    # Reference point in ECEF
    X0, Y0, Z0 = self.geodetic_to_ecef(lat0_deg, lon0_deg, h0_m)

    # Difference vector
    dX = X - X0
    dY = Y - Y0
    dZ = Z - Z0

    # Convert reference point to radians
    lat0 = np.radians(lat0_deg)
    lon0 = np.radians(lon0_deg)

    # Rotation matrix elements
    sin_lat = np.sin(lat0)
    cos_lat = np.cos(lat0)
    sin_lon = np.sin(lon0)
    cos_lon = np.cos(lon0)

    # Apply rotation
    E = -sin_lon * dX + cos_lon * dY
    N = -sin_lat * cos_lon * dX - sin_lat * sin_lon * dY + cos_lat * dZ
    U = cos_lat * cos_lon * dX + cos_lat * sin_lon * dY + sin_lat * dZ

    return E, N, U

Gravitational Model

Earth's gravity varies with latitude and altitude due to rotation and the non-spherical shape. The WGS84 gravity model includes these effects.

Normal Gravity Formula

The Somigliana formula gives theoretical gravity on the ellipsoid:

$$\gamma_0(\phi) = \gamma_e \frac{1 + k\sin^2\phi}{\sqrt{1 - e^2\sin^2\phi}}$$
where:
  • $\gamma_e = 9.7803253359$ m/s² (equatorial gravity)
  • $\gamma_p = 9.8321849378$ m/s² (polar gravity)
  • $k = \frac{b\gamma_p - a\gamma_e}{a\gamma_e} = 0.00193185265241$

Gravity at Altitude

For points above the ellipsoid, we apply altitude and free-air corrections:

$$g(\phi, h) = \gamma_0(\phi) - \left(\frac{2\gamma_0}{a}\right)\left(1 + f + m - 2f\sin^2\phi\right)h + \frac{3\gamma_0}{a^2}h^2$$
where $m = \frac{\omega^2a^2b}{GM} = 0.00344978650684$ accounts for Earth's rotation

J₂ Gravitational Correction

For high-precision calculations, we include the J₂ term (Earth's oblateness):

$$g(\phi, h) = g_0(1 + f_1\sin^2\phi + f_2\sin^2(2\phi)) \times \left(1 - \frac{2h}{a}\right)$$
where:
  • $f_1 = 5.3024 \times 10^{-3}$
  • $f_2 = -5.9 \times 10^{-6}$
  • $J_2 = 1.08263 \times 10^{-3}$ (second degree zonal harmonic)

Implementation

def calculate_gravity(self, lat_deg, height_m):
    """Calculate gravitational acceleration using WGS84 model"""
    lat = np.radians(lat_deg)

    # Constants
    gamma_e = 9.7803253359  # Equatorial gravity [m/s²]
    k = 0.00193185265241

    # Normal gravity on ellipsoid (Somigliana formula)
    gamma_0 = gamma_e * (1 + k * np.sin(lat)**2) / np.sqrt(1 - self.e2 * np.sin(lat)**2)

    # Altitude correction (to second order)
    f1 = 5.3024e-3
    f2 = -5.9e-6

    # Combined formula
    g = gamma_0 * (1 + f1 * np.sin(lat)**2 + f2 * np.sin(2*lat)**2) * (1 - 2*height_m/self.a)

    return g
Gravity Variations: Gravity varies by about 0.5% from equator to pole (9.78 to 9.83 m/s²). At balloon altitudes (30 km), gravity decreases by about 1%. These variations significantly affect trajectory calculations over long flights.

Local Tangent Plane Coordinates

Azimuth and Elevation

For tracking and communication, we often need azimuth and elevation angles from a ground station to the balloon:

$$\text{Azimuth} = \arctan2(E, N)$$ $$\text{Elevation} = \arctan2\left(U, \sqrt{E^2 + N^2}\right)$$ $$\text{Range} = \sqrt{E^2 + N^2 + U^2}$$

Great Circle Distance

The shortest distance between two points on the ellipsoid uses Vincenty's formulae:

def vincenty_distance(self, lat1_deg, lon1_deg, lat2_deg, lon2_deg):
    """Calculate geodesic distance using Vincenty's formula"""
    lat1, lon1 = np.radians([lat1_deg, lon1_deg])
    lat2, lon2 = np.radians([lat2_deg, lon2_deg])

    # Reduced latitudes
    U1 = np.arctan((1 - self.f) * np.tan(lat1))
    U2 = np.arctan((1 - self.f) * np.tan(lat2))

    L = lon2 - lon1
    lambda_prev = L

    # Iterate to convergence
    for _ in range(100):
        sin_sigma = np.sqrt(
            (np.cos(U2) * np.sin(lambda_prev))**2 +
            (np.cos(U1) * np.sin(U2) - np.sin(U1) * np.cos(U2) * np.cos(lambda_prev))**2
        )
        cos_sigma = np.sin(U1) * np.sin(U2) + np.cos(U1) * np.cos(U2) * np.cos(lambda_prev)
        sigma = np.arctan2(sin_sigma, cos_sigma)

        sin_alpha = np.cos(U1) * np.cos(U2) * np.sin(lambda_prev) / sin_sigma
        cos2_alpha = 1 - sin_alpha**2
        cos_2sigma_m = cos_sigma - 2 * np.sin(U1) * np.sin(U2) / cos2_alpha

        C = self.f/16 * cos2_alpha * (4 + self.f * (4 - 3 * cos2_alpha))
        lambda_new = L + (1 - C) * self.f * sin_alpha * (
            sigma + C * sin_sigma * (
                cos_2sigma_m + C * cos_sigma * (-1 + 2 * cos_2sigma_m**2)
            )
        )

        if abs(lambda_new - lambda_prev) < 1e-12:
            break
        lambda_prev = lambda_new

    # Calculate distance
    u2 = cos2_alpha * self.ep2
    A = 1 + u2/16384 * (4096 + u2 * (-768 + u2 * (320 - 175 * u2)))
    B = u2/1024 * (256 + u2 * (-128 + u2 * (74 - 47 * u2)))
    delta_sigma = B * sin_sigma * (
        cos_2sigma_m + B/4 * (
            cos_sigma * (-1 + 2 * cos_2sigma_m**2) -
            B/6 * cos_2sigma_m * (-3 + 4 * sin_sigma**2) * (-3 + 4 * cos_2sigma_m**2)
        )
    )

    distance = self.b * A * (sigma - delta_sigma)
    return distance

Implementation Examples

Complete Coordinate System Class

class CoordinateSystem:
    """Complete implementation of WGS84 coordinate transformations"""

    def __init__(self):
        # WGS84 parameters
        self.a = 6378137.0
        self.f = 1/298.257223563
        self.b = self.a * (1 - self.f)
        self.e2 = 2*self.f - self.f**2
        self.ep2 = self.e2 / (1 - self.e2)

        # Derived parameters
        self.e = np.sqrt(self.e2)
        self.ep = np.sqrt(self.ep2)

        # Earth rotation rate
        self.omega = 7.292115e-5  # rad/s

    def get_earth_radius(self, lat_deg):
        """Get Earth radius at given latitude"""
        lat = np.radians(lat_deg)

        # Radius of curvature in meridian
        M = self.a * (1 - self.e2) / (1 - self.e2 * np.sin(lat)**2)**1.5

        # Radius of curvature in prime vertical
        N = self.a / np.sqrt(1 - self.e2 * np.sin(lat)**2)

        # Mean radius
        R = np.sqrt(M * N)

        return R

    def get_local_vertical(self, lat_deg, lon_deg):
        """Get local vertical unit vector in ECEF"""
        lat = np.radians(lat_deg)
        lon = np.radians(lon_deg)

        # Unit vector along ellipsoid normal
        nx = np.cos(lat) * np.cos(lon)
        ny = np.cos(lat) * np.sin(lon)
        nz = np.sin(lat)

        return np.array([nx, ny, nz])

    def get_rotation_matrix_ecef_to_enu(self, lat_deg, lon_deg):
        """Get rotation matrix from ECEF to ENU"""
        lat = np.radians(lat_deg)
        lon = np.radians(lon_deg)

        sin_lat = np.sin(lat)
        cos_lat = np.cos(lat)
        sin_lon = np.sin(lon)
        cos_lon = np.cos(lon)

        R = np.array([
            [-sin_lon, cos_lon, 0],
            [-sin_lat*cos_lon, -sin_lat*sin_lon, cos_lat],
            [cos_lat*cos_lon, cos_lat*sin_lon, sin_lat]
        ])

        return R

Trajectory Calculations

class TrajectoryCoordinates:
    """Handle coordinate transformations for balloon trajectories"""

    def __init__(self):
        self.cs = CoordinateSystem()

    def calculate_downrange_distance(self, launch_lat, launch_lon, current_lat, current_lon):
        """Calculate great circle distance from launch"""
        return self.cs.vincenty_distance(launch_lat, launch_lon, current_lat, current_lon)

    def calculate_bearing(self, lat1, lon1, lat2, lon2):
        """Calculate initial bearing from point 1 to point 2"""
        lat1, lon1, lat2, lon2 = np.radians([lat1, lon1, lat2, lon2])

        dlon = lon2 - lon1

        y = np.sin(dlon) * np.cos(lat2)
        x = np.cos(lat1) * np.sin(lat2) - np.sin(lat1) * np.cos(lat2) * np.cos(dlon)

        bearing_rad = np.arctan2(y, x)
        bearing_deg = np.degrees(bearing_rad)

        # Normalize to 0-360
        return (bearing_deg + 360) % 360

    def project_position(self, lat, lon, bearing_deg, distance_m):
        """Project a position given bearing and distance"""
        lat1 = np.radians(lat)
        lon1 = np.radians(lon)
        bearing = np.radians(bearing_deg)

        # Angular distance
        delta = distance_m / self.cs.get_earth_radius(lat)

        # Calculate new position
        lat2 = np.arcsin(
            np.sin(lat1) * np.cos(delta) +
            np.cos(lat1) * np.sin(delta) * np.cos(bearing)
        )

        lon2 = lon1 + np.arctan2(
            np.sin(bearing) * np.sin(delta) * np.cos(lat1),
            np.cos(delta) - np.sin(lat1) * np.sin(lat2)
        )

        return np.degrees(lat2), np.degrees(lon2)

Accuracy Considerations

Sources of Error

1. Ellipsoid Approximation

  • WGS84 approximates the geoid to ~100m globally
  • Local variations can exceed 50m
  • For precise work, use EGM2008 geoid model

2. Numerical Precision

  • Double precision: ~15-17 significant digits
  • At Earth's surface: ~0.1 mm precision
  • Use radians internally to avoid degree conversion errors

3. Datum Transformations

  • GPS uses WGS84, maps may use other datums
  • NAD83 differs from WGS84 by ~2m
  • Older datums can differ by hundreds of meters

Best Practices

# Good practice: Keep calculations in consistent units
class SafeCoordinateCalculator:
    def __init__(self):
        self.cs = CoordinateSystem()

    def safe_distance_calculation(self, lat1_deg, lon1_deg, alt1_m,
                                 lat2_deg, lon2_deg, alt2_m):
        """Calculate 3D distance with proper altitude handling"""

        # Convert to ECEF for accurate 3D distance
        x1, y1, z1 = self.cs.geodetic_to_ecef(lat1_deg, lon1_deg, alt1_m)
        x2, y2, z2 = self.cs.geodetic_to_ecef(lat2_deg, lon2_deg, alt2_m)

        # Euclidean distance in ECEF
        distance_3d = np.sqrt((x2-x1)**2 + (y2-y1)**2 + (z2-z1)**2)

        # Surface distance for comparison
        surface_distance = self.cs.vincenty_distance(lat1_deg, lon1_deg,
                                                    lat2_deg, lon2_deg)

        return {
            'distance_3d': distance_3d,
            'surface_distance': surface_distance,
            'altitude_difference': alt2_m - alt1_m
        }
Common Pitfalls:
  • Mixing geodetic and geocentric latitudes
  • Using spherical Earth formulas for precise work
  • Ignoring altitude in distance calculations
  • Not accounting for datum differences