Coordinate Systems

This example demonstrates coordinate conversions, rotations, and projections.

Overview

Coordinate transformations are fundamental for tracking:

  • Cartesian-Spherical: Range, azimuth, elevation conversions

  • Geodetic-ECEF: Earth-fixed coordinates

  • Local frames: ENU, NED transformations

  • Map projections: UTM, Mercator, Lambert

Coordinate Systems

Cartesian (x, y, z)
  • Standard 3D coordinates

  • Used for state estimation

Spherical (range, azimuth, elevation)
  • Sensor-centric measurements

  • Radar and lidar output

Geodetic (latitude, longitude, altitude)
  • Geographic coordinates

  • Navigation reference

ECEF (Earth-Centered, Earth-Fixed)
  • Rotates with Earth

  • GPS coordinates

ENU/NED (Local Tangent Plane)
  • East-North-Up or North-East-Down

  • Local navigation frame

Rotation Representations

  • Rotation matrices: 3x3 orthogonal matrices

  • Quaternions: 4D unit vectors, singularity-free

  • Euler angles: Roll, pitch, yaw

  • Axis-angle: Rotation axis and angle

Code Highlights

The example demonstrates:

  • cart2sphere() and sphere2cart()

  • geodetic_to_ecef() and ecef_to_geodetic()

  • ecef2enu() and enu2ecef()

  • euler2quat() and quat2euler()

  • slerp() for quaternion interpolation

Source Code

  1"""
  2Coordinate Systems Example.
  3
  4This example demonstrates:
  51. Cartesian to spherical coordinate conversions
  62. Geodetic (WGS84) to ECEF transformations
  73. Local tangent plane (ENU/NED) coordinates
  84. Rotation matrices and quaternions
  95. Jacobian-based covariance transformations
 10
 11Run with: python examples/coordinate_systems.py
 12"""
 13
 14import sys
 15from pathlib import Path
 16
 17sys.path.insert(0, str(Path(__file__).parent.parent))
 18
 19import numpy as np  # noqa: E402
 20import plotly.graph_objects as go  # noqa: E402
 21from plotly.subplots import make_subplots  # noqa: E402
 22
 23from pytcl.coordinate_systems import (  # noqa: E402
 24    cart2sphere,
 25    cross_covariance_transform,
 26    ecef2enu,
 27    ecef2geodetic,
 28    ecef2ned,
 29    enu2ecef,
 30    euler2rotmat,
 31    geodetic2ecef,
 32    quat2rotmat,
 33    quat_multiply,
 34    rotmat2euler,
 35    rotmat2quat,
 36    rotz,
 37    slerp,
 38    sphere2cart,
 39    spherical_jacobian_inv,
 40)
 41
 42
 43def spherical_conversions_demo() -> None:
 44    """Demonstrate spherical coordinate conversions."""
 45    print("=" * 60)
 46    print("1. SPHERICAL COORDINATE CONVERSIONS")
 47    print("=" * 60)
 48
 49    # Define a point in Cartesian coordinates
 50    cart_point = np.array([1000.0, 2000.0, 500.0])  # meters
 51    print(
 52        f"\nCartesian point: x={cart_point[0]:.1f}, y={cart_point[1]:.1f}, "
 53        f"z={cart_point[2]:.1f} m"
 54    )
 55
 56    # Convert to spherical (range, azimuth, elevation)
 57    r, az, el = cart2sphere(cart_point)
 58    print("\nSpherical coordinates:")
 59    print(f"  Range:     {r:.2f} m")
 60    print(f"  Azimuth:   {np.degrees(az):.2f} deg")
 61    print(f"  Elevation: {np.degrees(el):.2f} deg")
 62
 63    # Convert back to Cartesian
 64    cart_recovered = sphere2cart(r, az, el)
 65    print(
 66        f"\nRecovered Cartesian: x={cart_recovered[0]:.1f}, "
 67        f"y={cart_recovered[1]:.1f}, z={cart_recovered[2]:.1f} m"
 68    )
 69
 70    # Verify roundtrip
 71    error = np.linalg.norm(cart_point - cart_recovered)
 72    print(f"Roundtrip error: {error:.2e} m")
 73
 74
 75def geodetic_conversions_demo() -> None:
 76    """Demonstrate geodetic coordinate conversions."""
 77    print("\n" + "=" * 60)
 78    print("2. GEODETIC (WGS84) COORDINATE CONVERSIONS")
 79    print("=" * 60)
 80
 81    # Define a geodetic point (Washington DC area)
 82    lat = np.radians(38.9072)  # Latitude in radians
 83    lon = np.radians(-77.0369)  # Longitude in radians
 84    alt = 100.0  # Altitude in meters
 85
 86    print("\nGeodetic coordinates:")
 87    print(f"  Latitude:  {np.degrees(lat):.4f} deg")
 88    print(f"  Longitude: {np.degrees(lon):.4f} deg")
 89    print(f"  Altitude:  {alt:.1f} m")
 90
 91    # Convert to ECEF
 92    ecef = geodetic2ecef(lat, lon, alt)
 93    print("\nECEF coordinates:")
 94    print(f"  X: {ecef[0]/1000:.3f} km")
 95    print(f"  Y: {ecef[1]/1000:.3f} km")
 96    print(f"  Z: {ecef[2]/1000:.3f} km")
 97
 98    # Convert back to geodetic
 99    lat_r, lon_r, alt_r = ecef2geodetic(ecef)
100    print("\nRecovered geodetic:")
101    print(f"  Latitude:  {np.degrees(lat_r):.4f} deg")
102    print(f"  Longitude: {np.degrees(lon_r):.4f} deg")
103    print(f"  Altitude:  {alt_r:.1f} m")
104
105
106def local_tangent_plane_demo() -> None:
107    """Demonstrate local tangent plane (ENU/NED) conversions."""
108    print("\n" + "=" * 60)
109    print("3. LOCAL TANGENT PLANE (ENU/NED) CONVERSIONS")
110    print("=" * 60)
111
112    # Reference point (origin of local frame)
113    ref_lat = np.radians(38.9072)
114    ref_lon = np.radians(-77.0369)
115
116    # Compute ECEF reference point for altitude = 0
117    ref_ecef = geodetic2ecef(ref_lat, ref_lon, 0.0)
118
119    # Target point 1 km East, 2 km North, 100 m Up from reference
120    enu_offset = np.array([1000.0, 2000.0, 100.0])  # East, North, Up
121    print("\nENU offset from reference:")
122    print(f"  East:  {enu_offset[0]:.1f} m")
123    print(f"  North: {enu_offset[1]:.1f} m")
124    print(f"  Up:    {enu_offset[2]:.1f} m")
125
126    # Convert ENU to ECEF
127    target_ecef = enu2ecef(enu_offset, ref_lat, ref_lon, ref_ecef)
128    print("\nTarget ECEF coordinates:")
129    print(f"  X: {target_ecef[0]/1000:.3f} km")
130    print(f"  Y: {target_ecef[1]/1000:.3f} km")
131    print(f"  Z: {target_ecef[2]/1000:.3f} km")
132
133    # Convert ECEF back to ENU
134    enu_recovered = ecef2enu(target_ecef, ref_lat, ref_lon, ref_ecef)
135    print("\nRecovered ENU:")
136    print(f"  East:  {enu_recovered[0]:.1f} m")
137    print(f"  North: {enu_recovered[1]:.1f} m")
138    print(f"  Up:    {enu_recovered[2]:.1f} m")
139
140    # Also show NED (North, East, Down) - common in aviation
141    ned = ecef2ned(target_ecef, ref_lat, ref_lon, ref_ecef)
142    print("\nNED coordinates (aviation convention):")
143    print(f"  North: {ned[0]:.1f} m")
144    print(f"  East:  {ned[1]:.1f} m")
145    print(f"  Down:  {ned[2]:.1f} m")
146
147
148def rotation_demo() -> None:
149    """Demonstrate rotation matrices and Euler angles."""
150    print("\n" + "=" * 60)
151    print("4. ROTATION MATRICES AND EULER ANGLES")
152    print("=" * 60)
153
154    # Create individual rotation matrices
155    roll = np.radians(10.0)  # Roll about X
156    pitch = np.radians(20.0)  # Pitch about Y
157    yaw = np.radians(30.0)  # Yaw about Z
158
159    print("\nEuler angles (ZYX convention):")
160    print(f"  Roll (X):  {np.degrees(roll):.1f} deg")
161    print(f"  Pitch (Y): {np.degrees(pitch):.1f} deg")
162    print(f"  Yaw (Z):   {np.degrees(yaw):.1f} deg")
163
164    # Combined rotation (ZYX order: yaw, then pitch, then roll)
165    # euler2rotmat expects [angle1, angle2, angle3] for the sequence
166    R = euler2rotmat([yaw, pitch, roll], sequence="ZYX")
167    print("\nRotation matrix (3x3):")
168    print(R)
169
170    # Verify it's a proper rotation (det = 1, orthogonal)
171    print(f"\nDeterminant: {np.linalg.det(R):.6f} (should be 1)")
172    print(f"R @ R.T = I check: {np.allclose(R @ R.T, np.eye(3))}")
173
174    # Extract Euler angles back
175    angles_recovered = rotmat2euler(R, sequence="ZYX")
176    yaw_r, pitch_r, roll_r = angles_recovered
177    print("\nRecovered Euler angles:")
178    print(f"  Roll:  {np.degrees(roll_r):.1f} deg")
179    print(f"  Pitch: {np.degrees(pitch_r):.1f} deg")
180    print(f"  Yaw:   {np.degrees(yaw_r):.1f} deg")
181
182
183def quaternion_demo() -> None:
184    """Demonstrate quaternion operations and interpolation."""
185    print("\n" + "=" * 60)
186    print("5. QUATERNIONS AND SLERP INTERPOLATION")
187    print("=" * 60)
188
189    # Create a rotation matrix (ZYX = yaw, pitch, roll order)
190    roll, pitch, yaw = np.radians(15.0), np.radians(25.0), np.radians(45.0)
191    R = euler2rotmat([yaw, pitch, roll], sequence="ZYX")
192
193    # Convert to quaternion [w, x, y, z]
194    q = rotmat2quat(R)
195    print("\nQuaternion [w, x, y, z]:")
196    print(f"  q = [{q[0]:.4f}, {q[1]:.4f}, {q[2]:.4f}, {q[3]:.4f}]")
197    print(f"  Norm: {np.linalg.norm(q):.6f} (should be 1)")
198
199    # Convert back to rotation matrix
200    R_recovered = quat2rotmat(q)
201    print(f"\nRotation matrix roundtrip check: {np.allclose(R, R_recovered)}")
202
203    # Quaternion multiplication (composing rotations)
204    q2 = rotmat2quat(rotz(np.radians(90.0)))  # 90 deg yaw rotation
205    q_composed = quat_multiply(q, q2)
206    print("\nComposed quaternion (original + 90 deg yaw):")
207    print(
208        f"  q = [{q_composed[0]:.4f}, {q_composed[1]:.4f}, "
209        f"{q_composed[2]:.4f}, {q_composed[3]:.4f}]"
210    )
211
212    # SLERP interpolation between two orientations
213    print("\nSLERP interpolation (identity to 90 deg yaw):")
214    q_start = np.array([1.0, 0.0, 0.0, 0.0])  # Identity
215    q_end = rotmat2quat(rotz(np.radians(90.0)))
216
217    for t in [0.0, 0.25, 0.5, 0.75, 1.0]:
218        q_interp = slerp(q_start, q_end, t)
219        R_interp = quat2rotmat(q_interp)
220        # rotmat2euler returns [angle1, angle2, angle3] for ZYX = [yaw, pitch, roll]
221        angles = rotmat2euler(R_interp, sequence="ZYX")
222        yaw_interp = angles[0]
223        print(f"  t={t:.2f}: yaw = {np.degrees(yaw_interp):.1f} deg")
224
225
226def jacobian_covariance_demo() -> None:
227    """Demonstrate Jacobian-based covariance transformation."""
228    print("\n" + "=" * 60)
229    print("6. JACOBIAN-BASED COVARIANCE TRANSFORMATION")
230    print("=" * 60)
231
232    # Sensor measures in spherical coordinates with uncertainty
233    r = 5000.0  # Range in meters
234    az = np.radians(45.0)  # Azimuth
235    el = np.radians(10.0)  # Elevation
236
237    # Measurement covariance in spherical coordinates
238    sigma_r = 10.0  # Range std (meters)
239    sigma_az = np.radians(0.5)  # Azimuth std (radians)
240    sigma_el = np.radians(0.5)  # Elevation std (radians)
241
242    P_spherical = np.diag([sigma_r**2, sigma_az**2, sigma_el**2])
243
244    print("\nSpherical measurement:")
245    print(f"  Range:     {r:.1f} +/- {sigma_r:.1f} m")
246    print(f"  Azimuth:   {np.degrees(az):.1f} +/- {np.degrees(sigma_az):.2f} deg")
247    print(f"  Elevation: {np.degrees(el):.1f} +/- {np.degrees(sigma_el):.2f} deg")
248
249    # Get Jacobian of Cartesian w.r.t. spherical at this point
250    # spherical_jacobian_inv: d[x,y,z] = J @ d[r,az,el]
251    J = spherical_jacobian_inv(r, az, el)
252
253    print("\nJacobian (dCartesian/dSpherical):")
254    print(J)
255
256    # Transform covariance to Cartesian
257    P_cartesian = cross_covariance_transform(P_spherical, J)
258
259    print("\nCartesian covariance matrix:")
260    print(P_cartesian)
261
262    # Extract position uncertainties
263    sigma_x = np.sqrt(P_cartesian[0, 0])
264    sigma_y = np.sqrt(P_cartesian[1, 1])
265    sigma_z = np.sqrt(P_cartesian[2, 2])
266
267    # Convert mean to Cartesian
268    cart = sphere2cart(r, az, el)
269    print("\nCartesian position with uncertainties:")
270    print(f"  x = {cart[0]:.1f} +/- {sigma_x:.1f} m")
271    print(f"  y = {cart[1]:.1f} +/- {sigma_y:.1f} m")
272    print(f"  z = {cart[2]:.1f} +/- {sigma_z:.1f} m")
273
274
275def main() -> None:
276    """Run all coordinate system demonstrations."""
277    print("\nCoordinate Systems Examples")
278    print("=" * 60)
279    print("Demonstrating pytcl coordinate transformation capabilities")
280
281    spherical_conversions_demo()
282    geodetic_conversions_demo()
283    local_tangent_plane_demo()
284    rotation_demo()
285    quaternion_demo()
286    jacobian_covariance_demo()
287
288    # Visualization
289    visualize_coordinate_transforms()
290
291    print("\n" + "=" * 60)
292    print("Done!")
293    print("=" * 60)
294
295
296def visualize_coordinate_transforms() -> None:
297    """Visualize coordinate system transformations."""
298    print("\nGenerating coordinate transform visualization...")
299
300    # Create a grid of points in spherical coordinates
301    r = 1000.0
302    azimuths = np.linspace(0, 360, 9)
303    elevations = np.linspace(-90, 90, 5)
304
305    points_cart = []
306    for az in azimuths:
307        for el in elevations:
308            cart = sphere2cart(r, np.radians(az), np.radians(el))
309            points_cart.append(cart)
310
311    points_cart = np.array(points_cart)
312
313    # Create 3D scatter plot
314    fig = go.Figure()
315
316    fig.add_trace(
317        go.Scatter3d(
318            x=points_cart[:, 0],
319            y=points_cart[:, 1],
320            z=points_cart[:, 2],
321            mode="markers",
322            marker=dict(size=5, color="blue", opacity=0.8),
323            name="Spherical Coords (Cartesian)",
324        )
325    )
326
327    fig.update_layout(
328        title="Spherical to Cartesian Coordinate Transformation",
329        scene=dict(
330            xaxis_title="X (m)",
331            yaxis_title="Y (m)",
332            zaxis_title="Z (m)",
333            camera=dict(eye=dict(x=1.5, y=1.5, z=1.5)),
334        ),
335        height=600,
336        width=800,
337    )
338
339    fig.show()
340
341
342if __name__ == "__main__":
343    main()

Running the Example

python examples/coordinate_systems.py

See Also