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:

  1. Accelerometers: Specific force measurements

  2. Gyroscopes: Angular rate measurements

  3. Attitude update: Quaternion integration

  4. Velocity update: Transform and integrate acceleration

  5. 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 INSState

  • Strapdown 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