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