Navigation and Geodesy
This example demonstrates geodetic calculations, coordinate conversions, and great-circle navigation.
Overview
Geodesy provides the mathematical foundation for navigation:
Geodetic datums: Earth ellipsoid models (WGS84)
Distance calculations: Vincenty, Haversine methods
Local frames: ECEF and ENU conversions
Great circles: Shortest paths on Earth
Geodetic Calculations
- Vincenty’s Formulae
High accuracy (< 0.5mm)
Works for all distances
Handles antipodal points
- Haversine Formula
Simpler calculation
Good for short distances
Assumes spherical Earth
Earth Ellipsoid: The WGS84 reference ellipsoid with coordinate frames at various locations.
Code Highlights
The example demonstrates:
inverse_geodetic()(Vincenty) for accurate distances and azimuthsdirect_geodetic()for the point reached from a bearing and distancehaversine_distance()for quick spherical-Earth distancesgeodetic_to_ecef()/ecef_to_geodetic()andecef_to_enu()/enu_to_ecef()frame conversionsMulti-waypoint route planning and sensor coverage analysis
Source Code
1"""
2Navigation and Geodesy Example.
3
4This example demonstrates:
51. Geodetic coordinate conversions (WGS84)
62. Local tangent plane transformations (ENU/NED)
73. Geodetic distance calculations
84. Multi-waypoint navigation
95. Sensor placement and coverage analysis
10
11Run with: python examples/navigation_geodesy.py
12"""
13
14import sys
15from pathlib import Path
16
17sys.path.insert(0, str(Path(__file__).parent.parent))
18
19# Output directory for generated plots
20OUTPUT_DIR = Path(__file__).parent.parent / "docs" / "_static" / "images" / "examples"
21OUTPUT_DIR.mkdir(parents=True, exist_ok=True)
22
23import os
24
25import numpy as np # noqa: E402
26import plotly.graph_objects as go # noqa: E402
27
28from pytcl.navigation import (
29 direct_geodetic,
30 ecef_to_enu,
31 ecef_to_geodetic,
32 enu_to_ecef,
33 geodetic_to_ecef,
34 haversine_distance,
35 inverse_geodetic,
36)
37
38SHOW_PLOTS = os.environ.get("PYTCL_SHOW_PLOTS", "1") != "0"
39
40
41def geodetic_basics_demo() -> None:
42 """Demonstrate basic geodetic coordinate conversions."""
43 print("=" * 60)
44 print("1. GEODETIC COORDINATE CONVERSIONS")
45 print("=" * 60)
46
47 # Key locations
48 locations = {
49 "Washington DC": (38.9072, -77.0369, 0.0),
50 "New York City": (40.7128, -74.0060, 0.0),
51 "Los Angeles": (34.0522, -118.2437, 0.0),
52 "GPS Satellite (MEO)": (0.0, -75.0, 20200000.0), # ~20,200 km altitude
53 }
54
55 print("\nGeodetic to ECEF conversions:")
56 print("-" * 60)
57
58 for name, (lat_deg, lon_deg, alt) in locations.items():
59 lat = np.radians(lat_deg)
60 lon = np.radians(lon_deg)
61
62 # Convert to ECEF
63 ecef = geodetic_to_ecef(lat, lon, alt)
64
65 print(f"\n{name}:")
66 print(f" Geodetic: {lat_deg:.4f}N, {lon_deg:.4f}E, {alt:.0f} m")
67 print(
68 f" ECEF: X={ecef[0] / 1000:.1f} km, Y={ecef[1] / 1000:.1f} km, "
69 f"Z={ecef[2] / 1000:.1f} km"
70 )
71
72 # Convert back
73 lat_r, lon_r, alt_r = ecef_to_geodetic(ecef[0], ecef[1], ecef[2])
74 print(
75 f" Roundtrip: {np.degrees(lat_r):.4f}N, {np.degrees(lon_r):.4f}E, "
76 f"{alt_r:.0f} m"
77 )
78
79
80def distance_calculations_demo() -> None:
81 """Demonstrate geodetic distance calculations."""
82 print("\n" + "=" * 60)
83 print("2. GEODETIC DISTANCE CALCULATIONS")
84 print("=" * 60)
85
86 # City pairs for distance calculation
87 city_pairs = [
88 ("Washington DC", (38.9072, -77.0369), "New York City", (40.7128, -74.0060)),
89 ("Washington DC", (38.9072, -77.0369), "Los Angeles", (34.0522, -118.2437)),
90 ("New York City", (40.7128, -74.0060), "London", (51.5074, -0.1278)),
91 ("Los Angeles", (34.0522, -118.2437), "Tokyo", (35.6762, 139.6503)),
92 ]
93
94 print("\nGreat circle distances:")
95 print("-" * 60)
96
97 for city1, (lat1, lon1), city2, (lat2, lon2) in city_pairs:
98 lat1_rad = np.radians(lat1)
99 lon1_rad = np.radians(lon1)
100 lat2_rad = np.radians(lat2)
101 lon2_rad = np.radians(lon2)
102
103 # Haversine (approximate, fast)
104 dist_haversine = haversine_distance(lat1_rad, lon1_rad, lat2_rad, lon2_rad)
105
106 # Inverse geodetic (accurate)
107 dist_geodetic, az_fwd, az_back = inverse_geodetic(
108 lat1_rad, lon1_rad, lat2_rad, lon2_rad
109 )
110
111 print(f"\n{city1} -> {city2}:")
112 print(f" Haversine distance: {dist_haversine / 1000:.1f} km")
113 print(f" Geodetic distance: {dist_geodetic / 1000:.1f} km")
114 print(f" Forward azimuth: {np.degrees(az_fwd):.1f} deg")
115 print(f" Back azimuth: {np.degrees(az_back):.1f} deg")
116
117
118def local_frame_demo() -> None:
119 """Demonstrate local tangent plane (ENU) conversions."""
120 print("\n" + "=" * 60)
121 print("3. LOCAL TANGENT PLANE (ENU) FRAME")
122 print("=" * 60)
123
124 # Reference point: Washington DC
125 ref_lat = np.radians(38.9072)
126 ref_lon = np.radians(-77.0369)
127 ref_alt = 0.0
128
129 print("\nReference point: Washington DC")
130 print(f" Lat: {np.degrees(ref_lat):.4f} deg")
131 print(f" Lon: {np.degrees(ref_lon):.4f} deg")
132
133 # Define points relative to reference in ENU
134 enu_points = {
135 "10 km North": np.array([0.0, 10000.0, 0.0]),
136 "10 km East": np.array([10000.0, 0.0, 0.0]),
137 "10 km NE, 500m Up": np.array([7071.0, 7071.0, 500.0]),
138 "Aircraft overhead (10 km)": np.array([0.0, 0.0, 10000.0]),
139 }
140
141 print("\nENU to Geodetic conversions:")
142 print("-" * 60)
143
144 for name, enu in enu_points.items():
145 # Convert ENU to ECEF (separate components)
146 x, y, z = enu_to_ecef(enu[0], enu[1], enu[2], ref_lat, ref_lon, ref_alt)
147
148 # Convert ECEF to geodetic
149 lat, lon, alt = ecef_to_geodetic(x, y, z)
150
151 print(f"\n{name}:")
152 print(f" ENU: E={enu[0]:.0f} m, N={enu[1]:.0f} m, U={enu[2]:.0f} m")
153 print(
154 f" Geodetic: {np.degrees(lat):.4f}N, {np.degrees(lon):.4f}E, {alt:.0f} m"
155 )
156
157 # Verify roundtrip
158 e_r, n_r, u_r = ecef_to_enu(x, y, z, ref_lat, ref_lon, ref_alt)
159 enu_recovered = np.array([e_r, n_r, u_r])
160 error = np.linalg.norm(enu - enu_recovered)
161 print(f" Roundtrip error: {error:.2e} m")
162
163
164def waypoint_navigation_demo() -> None:
165 """Demonstrate waypoint-to-waypoint navigation."""
166 print("\n" + "=" * 60)
167 print("4. WAYPOINT NAVIGATION")
168 print("=" * 60)
169
170 # Define a flight path with waypoints
171 waypoints = [
172 ("DCA (Reagan Airport)", 38.8521, -77.0377),
173 ("Waypoint 1", 39.5, -76.5),
174 ("Waypoint 2", 40.0, -75.5),
175 ("JFK Airport", 40.6413, -73.7781),
176 ]
177
178 print("\nFlight path waypoints:")
179 print("-" * 60)
180
181 total_distance = 0.0
182 for i, (name, lat, lon) in enumerate(waypoints):
183 print(f"\n{i + 1}. {name}: {lat:.4f}N, {lon:.4f}E")
184
185 if i > 0:
186 # Calculate leg distance and heading
187 prev_name, prev_lat, prev_lon = waypoints[i - 1]
188 lat1_rad = np.radians(prev_lat)
189 lon1_rad = np.radians(prev_lon)
190 lat2_rad = np.radians(lat)
191 lon2_rad = np.radians(lon)
192
193 dist, az_fwd, _ = inverse_geodetic(lat1_rad, lon1_rad, lat2_rad, lon2_rad)
194 total_distance += dist
195
196 print(f" From {prev_name}:")
197 heading = np.degrees(az_fwd)
198 print(f" Distance: {dist / 1000:.1f} km, Heading: {heading:.1f} deg")
199
200 print(f"\nTotal flight distance: {total_distance / 1000:.1f} km")
201
202 # Compute intermediate points along each leg using direct geodetic
203 print("\nIntermediate points along first leg (every 10 km):")
204 print("-" * 60)
205
206 lat1 = np.radians(waypoints[0][1])
207 lon1 = np.radians(waypoints[0][2])
208 lat2 = np.radians(waypoints[1][1])
209 lon2 = np.radians(waypoints[1][2])
210
211 leg_dist, az_fwd, _ = inverse_geodetic(lat1, lon1, lat2, lon2)
212
213 for d in np.arange(0, leg_dist, 10000): # Every 10 km
214 lat_int, lon_int, _ = direct_geodetic(lat1, lon1, az_fwd, d)
215 print(
216 f" {d / 1000:.0f} km: {np.degrees(lat_int):.4f}N, "
217 f"{np.degrees(lon_int):.4f}E"
218 )
219
220
221def sensor_coverage_demo() -> None:
222 """Demonstrate sensor placement and coverage analysis."""
223 print("\n" + "=" * 60)
224 print("5. SENSOR COVERAGE ANALYSIS")
225 print("=" * 60)
226
227 # Radar sensor location (Washington DC)
228 sensor_alt = 50.0 # 50m tower
229
230 # Sensor parameters
231 max_range = 100000.0 # 100 km
232 min_elevation = np.radians(2.0) # 2 degree minimum elevation
233
234 print("\nRadar sensor location: Washington DC")
235 print(f" Height: {sensor_alt} m")
236 print(f" Max range: {max_range / 1000:.0f} km")
237 print(f" Min elevation: {np.degrees(min_elevation):.1f} deg")
238
239 # Calculate coverage at different altitudes
240 print("\nCoverage radius at different target altitudes:")
241 print("-" * 60)
242
243 target_altitudes_m = [alt * 0.3048 for alt in [1000, 5000, 10000, 20000, 40000]]
244
245 for alt_m in target_altitudes_m:
246 # Height difference
247 delta_h = alt_m - sensor_alt
248
249 # Maximum slant range is either max_range or limited by min elevation
250 # At min elevation, slant range r = delta_h / sin(min_el)
251 range_elev_limited = delta_h / np.sin(min_elevation) if delta_h > 0 else 0
252 effective_range = min(max_range, range_elev_limited)
253
254 # Ground range
255 if effective_range > 0:
256 ground_range = np.sqrt(effective_range**2 - delta_h**2)
257 else:
258 ground_range = 0
259
260 print(f" Target at {alt_m:.0f} m ({alt_m / 0.3048:.0f} ft):")
261 print(f" Effective slant range: {effective_range / 1000:.1f} km")
262 print(f" Ground coverage radius: {ground_range / 1000:.1f} km")
263
264 # Check if specific targets are in coverage
265 print("\nTarget detection check:")
266 print("-" * 60)
267
268 targets = [
269 ("Aircraft 50km E, 10km alt", 50000.0, 0.0, 10000.0),
270 ("Aircraft 80km NE, 5km alt", 56569.0, 56569.0, 5000.0),
271 ("Low flyer 30km N, 100m alt", 0.0, 30000.0, 100.0),
272 ("High alt 120km W, 20km alt", -120000.0, 0.0, 20000.0),
273 ]
274
275 for name, e, n, u in targets:
276 enu = np.array([e, n, u - sensor_alt])
277 slant_range = np.linalg.norm(enu)
278 elevation = np.arcsin(enu[2] / slant_range) if slant_range > 0 else 0
279
280 in_range = slant_range <= max_range
281 above_horizon = elevation >= min_elevation
282 detectable = in_range and above_horizon
283
284 status = "DETECTABLE" if detectable else "NOT DETECTABLE"
285 reason = []
286 if not in_range:
287 reason.append(
288 f"range {slant_range / 1000:.1f} km > {max_range / 1000:.0f} km"
289 )
290 if not above_horizon:
291 reason.append(
292 f"elev {np.degrees(elevation):.1f} deg < "
293 f"{np.degrees(min_elevation):.1f} deg"
294 )
295
296 print(f"\n {name}:")
297 print(
298 f" Range: {slant_range / 1000:.1f} km, "
299 f"Elevation: {np.degrees(elevation):.1f} deg"
300 )
301 print(f" Status: {status}")
302 if reason:
303 print(f" Reason: {', '.join(reason)}")
304
305
306def plot_coverage_map() -> None:
307 """Create an interactive coverage map."""
308 print("\n" + "=" * 60)
309 print("6. GENERATING COVERAGE MAP")
310 print("=" * 60)
311
312 # Sensor location
313 sensor_lat = np.radians(38.9072)
314 sensor_lon = np.radians(-77.0369)
315 sensor_alt = 50.0
316 max_range = 100000.0
317
318 # Generate coverage circle points
319 n_points = 72
320 azimuths = np.linspace(0, 2 * np.pi, n_points)
321
322 # Coverage at different altitudes
323 altitudes = [1000, 5000, 10000] # meters
324 colors = ["green", "blue", "red"]
325
326 fig = go.Figure()
327
328 # Add sensor location
329 fig.add_trace(
330 go.Scattergeo(
331 lon=[np.degrees(sensor_lon)],
332 lat=[np.degrees(sensor_lat)],
333 mode="markers",
334 marker=dict(size=15, color="black", symbol="triangle-up"),
335 name="Radar Sensor",
336 )
337 )
338
339 # Add coverage circles for each altitude
340 for alt, color in zip(altitudes, colors):
341 # Calculate ground range for this altitude
342 delta_h = alt - sensor_alt
343 min_elev = np.radians(2.0)
344 range_elev_limited = delta_h / np.sin(min_elev)
345 effective_range = min(max_range, range_elev_limited)
346 ground_range = np.sqrt(max(0, effective_range**2 - delta_h**2))
347
348 # Generate circle points
349 lats = []
350 lons = []
351 for az in azimuths:
352 # direct_geodetic also returns the back azimuth at the destination.
353 lat, lon, _ = direct_geodetic(sensor_lat, sensor_lon, az, ground_range)
354 lats.append(np.degrees(lat))
355 lons.append(np.degrees(lon))
356
357 # Close the circle
358 lats.append(lats[0])
359 lons.append(lons[0])
360
361 fig.add_trace(
362 go.Scattergeo(
363 lon=lons,
364 lat=lats,
365 mode="lines",
366 line=dict(width=2, color=color),
367 name=f"Coverage at {alt}m ({alt * 3.28084:.0f}ft)",
368 )
369 )
370
371 fig.update_layout(
372 title="Radar Coverage Map (Washington DC)",
373 geo=dict(
374 scope="usa",
375 center=dict(lat=np.degrees(sensor_lat), lon=np.degrees(sensor_lon)),
376 projection_scale=5,
377 showland=True,
378 landcolor="rgb(243, 243, 243)",
379 countrycolor="rgb(204, 204, 204)",
380 ),
381 width=900,
382 height=700,
383 )
384
385 fig.write_html(
386 str(OUTPUT_DIR / "navigation_coverage_map.html"),
387 include_plotlyjs="cdn",
388 div_id="navigation_coverage_map",
389 )
390 print("\nInteractive coverage map saved to navigation_coverage_map.html")
391 if SHOW_PLOTS:
392 fig.show()
393
394
395def main() -> None:
396 """Run navigation and geodesy demonstrations."""
397 print("\nNavigation and Geodesy Examples")
398 print("=" * 60)
399 print("Demonstrating pytcl navigation capabilities")
400
401 geodetic_basics_demo()
402 distance_calculations_demo()
403 local_frame_demo()
404 waypoint_navigation_demo()
405 sensor_coverage_demo()
406
407 plot_coverage_map()
408
409 print("\n" + "=" * 60)
410 print("Done!")
411 print("=" * 60)
412
413
414if __name__ == "__main__":
415 main()
Running the Example
python examples/navigation_geodesy.py
See Also
INS/GNSS Navigation - INS/GNSS integration
Coordinate Systems - Coordinate conversions