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