INS/GNSS Navigation
This example demonstrates Inertial Navigation System (INS) and GNSS integration.
Overview
INS/GNSS integration combines:
INS: High-rate, smooth navigation with drift
GNSS: Accurate but noisy absolute position
Integration: Best of both systems
Key Concepts
Strapdown mechanization: Integrating IMU measurements
Error state: Modeling INS drift
Loosely coupled: GNSS position updates
Tightly coupled: GNSS pseudorange updates
INS Mechanization
Strapdown INS integrates:
Accelerometers: Specific force measurements
Gyroscopes: Angular rate measurements
Attitude update: Quaternion integration
Velocity update: Transform and integrate acceleration
Position update: Integrate velocity
Error Sources
Gyro bias: Causes heading drift
Accelerometer bias: Causes position drift
Scale factor errors: Proportional errors
Coning/sculling: Integration errors
Code Highlights
The example demonstrates:
INS state initialization with
INSStateStrapdown mechanization with
mechanize_ins_ned()GNSS update with Kalman filter
Error state estimation and correction
Trajectory visualization
Source Code
1"""
2INS/GNSS Navigation Example.
3
4This example demonstrates:
51. INS mechanization (strapdown navigation)
62. IMU data processing with coning/sculling corrections
73. Loosely-coupled INS/GNSS integration
84. Tightly-coupled INS/GNSS integration
95. DOP computation and GNSS outage detection
10
11Run with: python examples/ins_gnss_navigation.py
12"""
13
14import sys
15from pathlib import Path
16
17sys.path.insert(0, str(Path(__file__).parent.parent))
18
19import os
20
21import numpy as np # noqa: E402
22import plotly.graph_objects as go # noqa: E402
23from plotly.subplots import make_subplots # noqa: E402
24
25from pytcl.navigation import ( # noqa: E402
26 WGS84,
27 GNSSMeasurement,
28 IMUData,
29 coarse_alignment,
30 compute_dop,
31 coning_correction,
32 earth_rate_ned,
33 geodetic_to_ecef,
34 gnss_outage_detection,
35 gravity_ned,
36 initialize_ins_gnss,
37 initialize_ins_state,
38 loose_coupled_predict,
39 loose_coupled_update,
40 mechanize_ins_ned,
41 normal_gravity,
42 radii_of_curvature,
43 satellite_elevation_azimuth,
44 sculling_correction,
45 transport_rate_ned,
46)
47
48SHOW_PLOTS = os.environ.get("PYTCL_SHOW_PLOTS", "1") != "0"
49OUTPUT_DIR = Path(__file__).resolve().parent / "output"
50
51
52def ins_basics_demo() -> None:
53 """Demonstrate basic INS concepts."""
54 print("=" * 60)
55 print("1. INS FUNDAMENTALS")
56 print("=" * 60)
57
58 # Define a location (San Francisco)
59 lat = np.radians(37.7749)
60 lon = np.radians(-122.4194)
61 alt = 100.0 # meters
62
63 print("\nLocation: San Francisco")
64 print(f" Latitude: {np.degrees(lat):.4f} deg")
65 print(f" Longitude: {np.degrees(lon):.4f} deg")
66 print(f" Altitude: {alt:.1f} m")
67
68 # Normal gravity
69 g = normal_gravity(lat, alt)
70 print(f"\nNormal gravity: {g:.6f} m/s^2")
71
72 # Gravity in NED frame
73 g_ned = gravity_ned(lat, alt)
74 print(
75 f"Gravity vector (NED): [{g_ned[0]:.6f}, {g_ned[1]:.6f}, {g_ned[2]:.6f}] m/s^2"
76 )
77
78 # Earth rate in NED
79 omega_ie = earth_rate_ned(lat)
80 print("\nEarth rotation (NED):")
81 print(f" North: {omega_ie[0] * 1e6:.3f} micro-rad/s")
82 print(f" East: {omega_ie[1] * 1e6:.3f} micro-rad/s")
83 print(f" Down: {omega_ie[2] * 1e6:.3f} micro-rad/s")
84 print(f" Total: {np.linalg.norm(omega_ie) * 180 / np.pi * 3600:.4f} deg/hr")
85
86 # Radii of curvature
87 R_n, R_e = radii_of_curvature(lat)
88 print("\nRadii of curvature:")
89 print(f" Meridian (R_n): {R_n / 1e6:.3f} Mm")
90 print(f" Prime vertical (R_e): {R_e / 1e6:.3f} Mm")
91
92
93def imu_processing_demo() -> None:
94 """Demonstrate IMU data processing."""
95 print("\n" + "=" * 60)
96 print("2. IMU DATA PROCESSING")
97 print("=" * 60)
98
99 np.random.seed(42)
100
101 # Simulated IMU data for a stationary sensor
102 dt = 0.01 # 100 Hz IMU
103
104 # Small biases and noise (typical MEMS IMU)
105 gyro_bias = np.array([0.001, -0.0005, 0.002]) # rad/s
106 accel_bias = np.array([0.01, -0.02, 0.015]) # m/s^2
107
108 # Generate IMU samples
109 n_samples = 100
110 print(f"\nSimulating {n_samples} IMU samples at {1 / dt:.0f} Hz")
111 print(
112 f" Gyro bias: [{gyro_bias[0] * 1e3:.2f}, {gyro_bias[1] * 1e3:.2f}, {gyro_bias[2] * 1e3:.2f}] mrad/s"
113 )
114 print(
115 f" Accel bias: [{accel_bias[0] * 1e3:.1f}, {accel_bias[1] * 1e3:.1f}, {accel_bias[2] * 1e3:.1f}] mm/s^2"
116 )
117
118 # Stationary sensor: gyro measures Earth rate, accel measures gravity
119 lat = np.radians(37.7749)
120 omega_ie = earth_rate_ned(lat)
121 g_ned = gravity_ned(lat, 0)
122
123 # Transform to body frame (assume level, north-pointing)
124 omega_body = omega_ie + gyro_bias + 1e-4 * np.random.randn(3)
125 accel_body = -g_ned + accel_bias + 0.01 * np.random.randn(3)
126
127 print("\nRaw IMU readings (stationary):")
128 print(
129 f" Gyro: [{omega_body[0] * 1e3:.3f}, {omega_body[1] * 1e3:.3f}, {omega_body[2] * 1e3:.3f}] mrad/s"
130 )
131 print(
132 f" Accel: [{accel_body[0]:.4f}, {accel_body[1]:.4f}, {accel_body[2]:.4f}] m/s^2"
133 )
134
135 # Coning correction (for high-frequency angular motion)
136 # Simulate angular increments
137 alpha_prev = omega_body * dt + 1e-6 * np.random.randn(3)
138 alpha_curr = omega_body * dt + 1e-6 * np.random.randn(3)
139
140 coning = coning_correction(alpha_prev, alpha_curr)
141 print(f"\nConing correction magnitude: {np.linalg.norm(coning) * 1e9:.3f} nano-rad")
142
143 # Sculling correction
144 dv_prev = accel_body * dt
145 dv_curr = accel_body * dt
146
147 sculling = sculling_correction(alpha_prev, alpha_curr, dv_prev, dv_curr)
148 print(
149 f"Sculling correction magnitude: {np.linalg.norm(sculling) * 1e9:.3f} nano-m/s"
150 )
151
152
153def coarse_alignment_demo() -> None:
154 """Demonstrate INS coarse alignment (leveling)."""
155 print("\n" + "=" * 60)
156 print("3. INS COARSE ALIGNMENT (LEVELING)")
157 print("=" * 60)
158
159 # Location
160 lat = np.radians(37.7749)
161 alt = 100.0
162
163 # Simulated accelerometer measurements (stationary)
164 g_ned = gravity_ned(lat, alt)
165
166 # True attitude: small roll and pitch
167 true_roll = np.radians(2.0)
168 true_pitch = np.radians(-1.5)
169
170 print("\nTrue attitude:")
171 print(f" Roll: {np.degrees(true_roll):.2f} deg")
172 print(f" Pitch: {np.degrees(true_pitch):.2f} deg")
173 print(" (Heading cannot be determined from accelerometers alone)")
174
175 # Construct rotation matrix (NED to body)
176 cr, sr = np.cos(true_roll), np.sin(true_roll)
177 cp, sp = np.cos(true_pitch), np.sin(true_pitch)
178
179 # Rotation matrices (heading assumed 0 for simplicity)
180 R_roll = np.array([[1, 0, 0], [0, cr, sr], [0, -sr, cr]])
181 R_pitch = np.array([[cp, 0, -sp], [0, 1, 0], [sp, 0, cp]])
182
183 C_bn = R_roll @ R_pitch # Body from NED (no heading rotation)
184
185 # Body-frame measurements
186 # Accelerometer measures reaction to gravity (specific force)
187 accel_body = C_bn @ (-g_ned)
188
189 # Add small noise
190 np.random.seed(42)
191 accel_body += 0.005 * np.random.randn(3)
192
193 print("\nBody-frame accelerometer reading:")
194 print(
195 f" Accel: [{accel_body[0]:.4f}, {accel_body[1]:.4f}, {accel_body[2]:.4f}] m/s^2"
196 )
197 print(f" (For level vehicle: [0, 0, {-normal_gravity(lat, alt):.4f}] m/s^2)")
198
199 # Coarse alignment (leveling only - uses accelerometer to find roll/pitch)
200 roll_est, pitch_est = coarse_alignment(accel_body, lat)
201
202 print("\nEstimated attitude (coarse leveling):")
203 print(
204 f" Roll: {np.degrees(roll_est):.2f} deg (error: {np.degrees(roll_est - true_roll):.3f} deg)"
205 )
206 print(
207 f" Pitch: {np.degrees(pitch_est):.2f} deg (error: {np.degrees(pitch_est - true_pitch):.3f} deg)"
208 )
209
210 # Note about gyrocompassing
211 print("\nNote: Heading estimation requires gyrocompassing (sensing Earth")
212 print("rotation with gyroscopes), which is separate from coarse leveling.")
213
214
215def ins_mechanization_demo() -> None:
216 """Demonstrate INS mechanization."""
217 print("\n" + "=" * 60)
218 print("4. INS MECHANIZATION")
219 print("=" * 60)
220
221 # Initialize INS state
222 lat = np.radians(37.7749)
223 lon = np.radians(-122.4194)
224 alt = 100.0
225 vN, vE, vD = 10.0, 5.0, -1.0 # Moving NE, slight descent
226
227 state = initialize_ins_state(lat, lon, alt, vN=vN, vE=vE, vD=vD)
228
229 print("\nInitial state:")
230 print(
231 f" Position: {np.degrees(state.latitude):.6f}N, {np.degrees(state.longitude):.6f}E, {state.altitude:.1f}m"
232 )
233 print(
234 f" Velocity (NED): [{state.velocity[0]:.2f}, {state.velocity[1]:.2f}, {state.velocity[2]:.2f}] m/s"
235 )
236
237 # Simulate forward motion with IMU
238 dt = 0.01 # 100 Hz
239 n_steps = 100
240
241 # IMU measurements for constant velocity (no acceleration)
242 g_ned = gravity_ned(lat, alt)
243 omega_ie = earth_rate_ned(lat)
244 omega_en = transport_rate_ned(
245 state.velocity[0], state.velocity[1], state.latitude, state.altitude
246 )
247
248 # Body-frame measurements (assuming level flight, north heading)
249 accel_body = -g_ned # Only gravity
250 gyro_body = omega_ie + omega_en # Earth rate + transport rate
251
252 print(f"\nSimulating {n_steps} navigation steps at {1 / dt:.0f} Hz")
253
254 # Integrate
255 for _ in range(n_steps):
256 imu = IMUData(
257 gyro=gyro_body,
258 accel=accel_body,
259 dt=dt,
260 )
261 state = mechanize_ins_ned(state, imu)
262
263 print(f"\nFinal state after {n_steps * dt:.1f} seconds:")
264 print(
265 f" Position: {np.degrees(state.latitude):.6f}N, {np.degrees(state.longitude):.6f}E, {state.altitude:.1f}m"
266 )
267 print(
268 f" Velocity (NED): [{state.velocity[0]:.2f}, {state.velocity[1]:.2f}, {state.velocity[2]:.2f}] m/s"
269 )
270
271 # Expected position change
272 R_n, R_e = radii_of_curvature(lat)
273 _expected_dlat = vN * n_steps * dt / (R_n + alt) # noqa: F841
274 _expected_dlon = vE * n_steps * dt / ((R_e + alt) * np.cos(lat)) # noqa: F841
275
276 print("\nPosition change:")
277 print(
278 f" North: {np.degrees(state.latitude - lat) * 111000:.1f} m (expected: {vN * n_steps * dt:.1f} m)"
279 )
280 print(
281 f" East: {np.degrees(state.longitude - lon) * 111000 * np.cos(lat):.1f} m "
282 f"(expected: {vE * n_steps * dt:.1f} m)"
283 )
284
285
286def gnss_geometry_demo() -> None:
287 """Demonstrate GNSS geometry and DOP."""
288 print("\n" + "=" * 60)
289 print("5. GNSS GEOMETRY AND DOP")
290 print("=" * 60)
291
292 # User position
293 user_lat = np.radians(37.7749)
294 user_lon = np.radians(-122.4194)
295 user_alt = 100.0
296 user_lla = np.array([user_lat, user_lon, user_alt])
297
298 user_ecef = np.array(geodetic_to_ecef(user_lat, user_lon, user_alt, WGS84))
299 print(f"\nUser position: {np.degrees(user_lat):.4f}N, {np.degrees(user_lon):.4f}E")
300
301 # Simulated satellite positions (GPS constellation subset)
302 sat_positions = [
303 (30, 45, 20200e3), # Lat, lon, alt (deg, deg, m)
304 (45, -90, 20200e3),
305 (15, -150, 20200e3),
306 (60, 0, 20200e3),
307 (-30, 90, 20200e3),
308 (0, 180, 20200e3),
309 ]
310
311 print("\nSatellite visibility:")
312 print("-" * 50)
313
314 visible_sats = []
315 for i, (lat_deg, lon_deg, alt) in enumerate(sat_positions):
316 lat = np.radians(lat_deg)
317 lon = np.radians(lon_deg)
318 pos_ecef = np.array(geodetic_to_ecef(lat, lon, alt, WGS84))
319
320 # Compute elevation and azimuth
321 el, az = satellite_elevation_azimuth(user_lla, pos_ecef)
322
323 if el > 0: # Above horizon
324 visible_sats.append(pos_ecef)
325 print(
326 f" PRN {i + 1}: El={np.degrees(el):.1f} deg, Az={np.degrees(az):.1f} deg"
327 )
328 else:
329 print(f" PRN {i + 1}: Below horizon (El={np.degrees(el):.1f} deg)")
330
331 # Compute DOP from geometry matrix
332 if len(visible_sats) >= 4:
333 # Build geometry matrix (line-of-sight unit vectors + clock column)
334 H = np.zeros((len(visible_sats), 4))
335 for i, sat_ecef in enumerate(visible_sats):
336 los = sat_ecef - user_ecef
337 range_val = np.linalg.norm(los)
338 H[i, :3] = -los / range_val # Unit vector toward satellite
339 H[i, 3] = 1.0 # Clock column
340
341 GDOP, PDOP, HDOP, VDOP = compute_dop(H)
342 print(f"\nDilution of Precision (DOP) with {len(visible_sats)} satellites:")
343 print(f" GDOP: {GDOP:.2f}")
344 print(f" PDOP: {PDOP:.2f}")
345 print(f" HDOP: {HDOP:.2f}")
346 print(f" VDOP: {VDOP:.2f}")
347
348 # Interpret DOP
349 if PDOP < 2:
350 quality = "Excellent"
351 elif PDOP < 5:
352 quality = "Good"
353 elif PDOP < 10:
354 quality = "Moderate"
355 else:
356 quality = "Poor"
357 print(f" Position accuracy: {quality}")
358 else:
359 print(f"\nInsufficient satellites ({len(visible_sats)}) for DOP computation")
360
361
362def loose_coupling_demo() -> None:
363 """Demonstrate loosely-coupled INS/GNSS integration."""
364 print("\n" + "=" * 60)
365 print("6. LOOSELY-COUPLED INS/GNSS INTEGRATION")
366 print("=" * 60)
367
368 np.random.seed(42)
369
370 # Initialize INS state
371 lat = np.radians(37.7749)
372 lon = np.radians(-122.4194)
373 alt = 100.0
374 vN, vE, vD = 10.0, 5.0, 0.0
375
376 ins_state = initialize_ins_state(lat, lon, alt, vN=vN, vE=vE, vD=vD)
377
378 # Initialize INS/GNSS integrated state
379 pos_std = 5.0 # meters
380 vel_std = 0.1 # m/s
381 att_std = np.radians(0.5) # rad
382
383 state = initialize_ins_gnss(
384 ins_state, position_std=pos_std, velocity_std=vel_std, attitude_std=att_std
385 )
386
387 print("\nInitial state:")
388 print(
389 f" Position: {np.degrees(state.ins_state.latitude):.6f}N, "
390 f"{np.degrees(state.ins_state.longitude):.6f}E"
391 )
392 print(f" Position std: {np.sqrt(state.error_cov[0, 0]):.2f} m")
393 print(f" Velocity std: {np.sqrt(state.error_cov[3, 3]):.3f} m/s")
394
395 # Simulate navigation with GNSS updates
396 dt_ins = 0.01 # INS rate
397 dt_gnss = 1.0 # GNSS rate
398 n_gnss_epochs = 5
399
400 print(f"\nSimulating {n_gnss_epochs} GNSS epochs ({n_gnss_epochs} seconds)")
401 print("-" * 60)
402
403 # IMU measurements (constant velocity)
404 g_ned = gravity_ned(lat, alt)
405 accel_body = -g_ned
406 gyro_body = earth_rate_ned(lat)
407
408 for epoch in range(n_gnss_epochs):
409 # INS propagation between GNSS updates
410 for _ in range(int(dt_gnss / dt_ins)):
411 imu = IMUData(
412 gyro=gyro_body + 1e-5 * np.random.randn(3),
413 accel=accel_body + 0.01 * np.random.randn(3),
414 dt=dt_ins,
415 )
416 state = loose_coupled_predict(state, imu)
417
418 # GNSS measurement (with noise)
419 gnss_pos = np.array(
420 [
421 state.ins_state.latitude + np.random.randn() * 2e-6,
422 state.ins_state.longitude + np.random.randn() * 2e-6,
423 state.ins_state.altitude + np.random.randn() * 5.0,
424 ]
425 )
426 gnss_vel = np.array(
427 [
428 state.ins_state.velocity[0] + np.random.randn() * 0.05,
429 state.ins_state.velocity[1] + np.random.randn() * 0.05,
430 state.ins_state.velocity[2] + np.random.randn() * 0.1,
431 ]
432 )
433
434 gnss_meas = GNSSMeasurement(
435 position=gnss_pos,
436 velocity=gnss_vel,
437 position_cov=np.diag([3.0**2, 3.0**2, 6.0**2]),
438 velocity_cov=np.diag([0.05**2, 0.05**2, 0.1**2]),
439 time=epoch * dt_gnss,
440 )
441
442 # GNSS update
443 result = loose_coupled_update(state, gnss_meas)
444 state = result.state
445
446 print(
447 f" Epoch {epoch + 1}: Position std = {np.sqrt(state.error_cov[0, 0]):.2f} m, "
448 f"Velocity std = {np.sqrt(state.error_cov[3, 3]):.4f} m/s"
449 )
450
451 print("\nFinal uncertainties:")
452 print(f" Position (N): {np.sqrt(state.error_cov[0, 0]):.2f} m")
453 print(f" Position (E): {np.sqrt(state.error_cov[1, 1]):.2f} m")
454 print(f" Position (D): {np.sqrt(state.error_cov[2, 2]):.2f} m")
455 print(f" Velocity (N): {np.sqrt(state.error_cov[3, 3]):.4f} m/s")
456
457
458def gnss_outage_demo() -> None:
459 """Demonstrate GNSS outage detection."""
460 print("\n" + "=" * 60)
461 print("7. GNSS OUTAGE DETECTION")
462 print("=" * 60)
463
464 np.random.seed(42)
465
466 # GNSS outage detection uses chi-squared test on innovations
467 # To detect measurement faults (spoofing, multipath, etc.)
468 innovation_cov = np.diag([3.0**2, 3.0**2, 6.0**2])
469
470 print("\nGNSS measurement fault detection using chi-squared test")
471 print(" Innovation covariance: diag([9, 9, 36]) m^2")
472
473 # Chi-squared threshold for 3 DOF (position), 95% confidence
474 threshold_95 = 7.815 # chi2.ppf(0.95, 3)
475
476 # Test with normal innovations
477 print("\nNormal innovations (should pass):")
478 for i in range(3):
479 innovation = np.random.multivariate_normal(np.zeros(3), innovation_cov)
480 fault = gnss_outage_detection(
481 innovation, innovation_cov, threshold=threshold_95
482 )
483 nis = innovation @ np.linalg.solve(innovation_cov, innovation)
484 print(f" Sample {i + 1}: NIS={nis:.2f}, Fault={fault}")
485
486 # Test with biased innovations (simulating fault)
487 print("\nBiased innovations (should detect fault):")
488 fault_bias = np.array([15.0, -10.0, 20.0]) # Large bias
489 for i in range(3):
490 innovation = fault_bias + np.random.multivariate_normal(
491 np.zeros(3), innovation_cov
492 )
493 fault = gnss_outage_detection(
494 innovation, innovation_cov, threshold=threshold_95
495 )
496 nis = innovation @ np.linalg.solve(innovation_cov, innovation)
497 print(f" Sample {i + 1}: NIS={nis:.2f}, Fault={fault}")
498
499 print(
500 "\nNote: NIS (Normalized Innovation Squared) should follow chi-squared distribution"
501 )
502 print(
503 f" with {len(innovation)} DOF. Threshold={threshold_95:.2f} (95% confidence)"
504 )
505
506
507def main() -> None:
508 """Run INS/GNSS navigation demonstrations."""
509 print("\nINS/GNSS Navigation Examples")
510 print("=" * 60)
511 print("Demonstrating pytcl navigation capabilities")
512
513 ins_basics_demo()
514 imu_processing_demo()
515 coarse_alignment_demo()
516 ins_mechanization_demo()
517 gnss_geometry_demo()
518 loose_coupling_demo()
519 gnss_outage_demo()
520
521 # Visualization
522 visualize_navigation_trajectory()
523
524 print("\n" + "=" * 60)
525 print("Done!")
526 print("=" * 60)
527
528
529def visualize_navigation_trajectory() -> None:
530 """Visualize INS navigation trajectory."""
531 print("\nGenerating navigation trajectory visualization...")
532
533 # Simulate a trajectory
534 np.random.seed(42)
535 n_steps = 200
536
537 # Create a circular trajectory
538 t = np.linspace(0, 2 * np.pi, n_steps)
539 x = 1000 * np.cos(t)
540 y = 1000 * np.sin(t)
541 z = 50 * np.sin(2 * t)
542
543 # Add noise
544 x_noisy = x + 5 * np.random.randn(n_steps)
545 y_noisy = y + 5 * np.random.randn(n_steps)
546 z_noisy = z + 2 * np.random.randn(n_steps)
547
548 # Create 3D trajectory plot
549 fig = go.Figure()
550
551 fig.add_trace(
552 go.Scatter3d(
553 x=x,
554 y=y,
555 z=z,
556 mode="lines",
557 line=dict(color="blue", width=3),
558 name="True Trajectory",
559 )
560 )
561
562 fig.add_trace(
563 go.Scatter3d(
564 x=x_noisy,
565 y=y_noisy,
566 z=z_noisy,
567 mode="markers",
568 marker=dict(size=4, color="red", opacity=0.5),
569 name="Measured Position",
570 )
571 )
572
573 fig.update_layout(
574 title="INS Navigation Trajectory: True vs Measured",
575 scene=dict(
576 xaxis_title="X (m)",
577 yaxis_title="Y (m)",
578 zaxis_title="Z (m)",
579 ),
580 height=600,
581 width=800,
582 )
583
584 if SHOW_PLOTS:
585 fig.show()
586 else:
587 OUTPUT_DIR.mkdir(parents=True, exist_ok=True)
588 fig.write_html(
589 str(OUTPUT_DIR / "ins_gnss_navigation.html"),
590 include_plotlyjs="cdn",
591 div_id="ins_gnss_navigation",
592 )
593
594
595if __name__ == "__main__":
596 main()
Running the Example
python examples/ins_gnss_navigation.py
See Also
Navigation and Geodesy - Geodetic calculations
Coordinate Systems - Coordinate transformations
Kalman Filter Comparison - Filter for integration