GPS-Denied Navigation Techniques
When GPS is unavailable â indoors, underground, in urban canyons, or during jamming â drones must rely on alternative navigation methods. This is critical for military, search-and-rescue, and industrial applications.
GPS-Denied Navigation Challenge
GPS denial can be intentional (jamming/spoofing) or environmental (tunnels, dense forests, indoor spaces). Drones need backup navigation systems that work without satellite signals.
GPS-Denied Techniques Overview
Terrain-Referenced Navigation
Terrain navigation compares measured terrain profile against a stored digital elevation map (DEM) to determine position.
from typing import Tuple, List
from scipy.interpolate import griddata
class TerrainReferencedNavigation:
def __init__(self, dem: np.ndarray, dem_resolution: float = 10.0):
self.dem = dem
self.resolution = dem_resolution
self.dem_shape = dem.shape
# Create coordinate grids
self.x_grid = np.arange(dem.shape[1]) * dem_resolution
self.y_grid = np.arange(dem.shape[0]) * dem_resolution
def get_terrain_height(self, x: float, y: float) -> float:
"""Get terrain height at position (x, y)."""
# Convert to grid indices
ix = int(x / self.resolution)
iy = int(y / self.resolution)
if 0 <= ix < self.dem_shape[1] and 0 <= iy < self.dem_shape[0]:
return self.dem[iy, ix]
return 0.0
def correlate_profile(self, measured_profile: np.ndarray,
drone_position: np.ndarray,
search_radius: float = 100.0) -> Tuple[np.ndarray, float]:
"""Find best matching position using profile correlation."""
best_match = drone_position.copy()
best_score = -float('inf')
# Search around current position
for dx in np.arange(-search_radius, search_radius, self.resolution):
for dy in np.arange(-search_radius, search_radius, self.resolution):
test_pos = drone_position + np.array([dx, dy, 0])
# Extract DEM profile along same path
dem_profile = self._extract_profile(test_pos, len(measured_profile))
# Compute correlation
if len(dem_profile) == len(measured_profile):
score = np.corrcoef(measured_profile, dem_profile)[0, 1]
if score > best_score:
best_score = score
best_match = test_pos[:2]
return best_match, best_score
def _extract_profile(self, position: np.ndarray, n_points: int) -> np.ndarray:
"""Extract terrain profile along flight direction."""
profile = np.zeros(n_points)
for i in range(n_points):
x = position[0] + i * self.resolution
y = position[1]
profile[i] = self.get_terrain_height(x, y)
return profile
def TERCOM_algorithm(self, altimeter_readings: np.ndarray,
flight_path: np.ndarray) -> np.ndarray:
"""Terrain Contour Matching (TERCOM) algorithm."""
n_windows = len(altimeter_readings) - 10 # Window size
best_position = flight_path[-1].copy()
best_distance = float('inf')
for i in range(0, len(flight_path) - n_windows):
# Extract DEM section
dem_section = []
for j in range(n_windows):
pos = flight_path[i + j]
dem_section.append(self.get_terrain_height(pos[0], pos[1]))
dem_section = np.array(dem_section)
# Compute mean absolute difference
if len(dem_section) == len(altimeter_readings):
distance = np.mean(np.abs(altimeter_readings - dem_section))
if distance < best_distance:
best_distance = distance
best_position = flight_path[i + n_windows // 2].copy()
return best_position
class SandiaTerrainAlgorithm:
"""SANDIA terrain matching algorithm with DEM smoothing."""
def __init__(self, dem: np.ndarray, smoothing_sigma: float = 2.0):
from scipy.ndimage import gaussian_filter
self.original_dem = dem
self.smoothed_dem = gaussian_filter(dem, sigma=smoothing_sigma)
self.smoothed_dem = dem # Use original for simplicity
def match(self, altitude_profile: np.ndarray,
initial_estimate: np.ndarray) -> np.ndarray:
"""Perform terrain matching with confidence estimation."""
# Normalize altitude profile
profile_mean = np.mean(altitude_profile)
profile_std = np.std(altitude_profile)
if profile_std > 0:
normalized_profile = (altitude_profile - profile_mean) / profile_std
else:
return initial_estimate
# Simple correlation-based matching
# In practice, use more sophisticated methods
best_position = initial_estimate[:2].copy()
return best_position
Magnetic Navigation
Earth's magnetic field provides a natural positioning reference. Magnetic navigation compares measured field values against a magnetic anomaly map.
from typing import Tuple, List
class MagneticNavigation:
def __init__(self, magnetic_map: np.ndarray, map_resolution: float = 100.0):
self.magnetic_map = magnetic_map
self.resolution = map_resolution
# IGRF reference model parameters
self.igrf_coefficients = self._load_igrf_coefficients()
def _load_igrf_coefficients(self):
"""Load International Geomagnetic Reference Field coefficients."""
# Simplified - in practice use full IGRF model
return {
'g10': -29404.8, 'g11': -1450.9, 'h11': 4653.2,
'g20': -2500.0, 'g21': 3010.0, 'h21': -2991.6
}
def compute_reference_field(self, position: np.ndarray) -> np.ndarray:
"""Compute reference magnetic field at position."""
lat, lon, alt = position
# Simplified dipole model
# In practice, use full IGRF spherical harmonic expansion
declination = 0.0 # Would come from IGRF
inclination = 60.0 # Would come from IGRF
intensity = 50000.0 # nT, would come from IGRF
return np.array([
intensity * np.cos(np.radians(inclination)) * np.cos(np.radians(declination)),
intensity * np.cos(np.radians(inclination)) * np.sin(np.radians(declination)),
intensity * np.sin(np.radians(inclination))
])
def match_position(self, measured_field: np.ndarray,
search_area: Tuple[np.ndarray, np.ndarray]) -> np.ndarray:
"""Find position that best matches magnetic measurements."""
min_pos, max_pos = search_area
best_position = (min_pos + max_pos) / 2
best_error = float('inf')
step = 100.0 # meters
x = min_pos[0]
while x <= max_pos[0]:
y = min_pos[1]
while y <= max_pos[1]:
# Get map value
map_value = self._get_map_value(x, y)
if map_value is not None:
error = np.linalg.norm(measured_field - map_value)
if error < best_error:
best_error = error
best_position = np.array([x, y])
y += step
x += step
return best_position
def _get_map_value(self, x: float, y: float) -> np.ndarray:
"""Get magnetic value from map at position."""
ix = int(x / self.resolution)
iy = int(y / self.resolution)
if 0 <= ix < self.magnetic_map.shape[1] and \
0 <= iy < self.magnetic_map.shape[0]:
return self.magnetic_map[iy, ix]
return None
def detect_anomaly(self, measured_field: np.ndarray,
reference_field: np.ndarray) -> float:
"""Detect magnetic anomaly (difference from expected)."""
anomaly = measured_field - reference_field
return np.linalg.norm(anomaly)
def kalman_filter_magnetic(self, measurements: List[np.ndarray],
dt: float = 1.0) -> List[np.ndarray]:
"""Kalman filter for magnetic field estimation."""
# State: [x, y, Bx, By, Bz]
state = np.zeros(6)
P = np.eye(6) * 100
positions = []
for z in measurements:
# Predict (simple constant velocity)
F = np.eye(6)
F[0, 2] = dt
F[1, 3] = dt
state = F @ state
P = F @ P @ F.T + np.eye(6) * 0.1
# Update with magnetic measurement
H = np.zeros((3, 6))
H[0:3, 2:5] = np.eye(3)
R = np.eye(3) * 100 # Measurement noise
y = z - state[2:5]
S = H @ P @ H.T + R
K = P @ H.T @ np.linalg.inv(S)
state = state + K @ y
P = (np.eye(6) - K @ H) @ P
positions.append(state[:2].copy())
return positions
Celestial Navigation
Celestial navigation uses sun, moon, and star positions to determine orientation and approximate position.
from datetime import datetime, timedelta
from typing import Tuple
class CelestialNavigation:
def __init__(self):
# Solar position constants
self_speed = 360 / 365.25 # degrees per day
def compute_sun_position(self, datetime_utc: datetime,
latitude: float, longitude: float) -> Tuple[float, float]:
"""Compute sun azimuth and elevation."""
# Day of year
day_of_year = datetime_utc.timetuple().tm_yday
# Solar declination
declination = 23.45 * np.sin(np.radians(360/365 * (day_of_year - 81)))
# Hour angle
solar_noon = 12.0 # Approximate
hours_since_noon = datetime_utc.hour + datetime_utc.minute/60 - solar_noon
hour_angle = hours_since_noon * 15 # 15 degrees per hour
# Solar elevation
lat_rad = np.radians(latitude)
dec_rad = np.radians(declination)
ha_rad = np.radians(hour_angle)
elevation = np.arcsin(
np.sin(lat_rad) * np.sin(dec_rad) +
np.cos(lat_rad) * np.cos(dec_rad) * np.cos(ha_rad)
)
# Solar azimuth
azimuth = np.arctan2(
-np.sin(ha_rad),
np.tan(dec_rad) * np.cos(lat_rad) - np.sin(lat_rad) * np.cos(ha_rad)
)
return np.degrees(azimuth), np.degrees(elevation)
def determine_heading_from_sun(self, datetime_utc: datetime,
latitude: float, longitude: float,
measured_elevation: float) -> float:
"""Determine heading from sun elevation measurement."""
# Get expected sun position
_, expected_elevation = self.compute_sun_position(
datetime_utc, latitude, longitude
)
# Heading correction based on elevation difference
# This is simplified - real implementation uses full spherical astronomy
heading_correction = measured_elevation - expected_elevation
return heading_correction
def star_tracker_heading(self, star_positions: list,
catalog_stars: dict) -> float:
"""Determine heading from star observations."""
if len(star_positions) < 2:
return None
# Match observed stars to catalog
matched_stars = []
for obs_pos in star_positions:
for star_id, catalog_pos in catalog_stars.items():
angular_distance = self._angular_distance(obs_pos, catalog_pos)
if angular_distance < np.radians(5): # 5 degree tolerance
matched_stars.append((obs_pos, catalog_pos))
break
if len(matched_stars) < 2:
return None
# Compute rotation between observed and catalog frames
# Using the two best matches
obs1, cat1 = matched_stars[0]
obs2, cat2 = matched_stars[1]
# Compute angle differences
obs_angle = np.arctan2(obs1[1] - obs2[1], obs1[0] - obs2[0])
cat_angle = np.arctan2(cat1[1] - cat2[1], cat1[0] - cat2[0])
heading = cat_angle - obs_angle
return np.degrees(heading) % 360
def _angular_distance(self, pos1, pos2):
"""Compute angular distance between two sky positions."""
return np.arccos(np.clip(
np.sin(pos1[1]) * np.sin(pos2[1]) +
np.cos(pos1[1]) * np.cos(pos2[1]) * np.cos(pos1[0] - pos2[0]),
-1, 1
))
class StarCatalog:
"""Manage star catalog for celestial navigation."""
def __init__(self):
self.bright_stars = {
'Polaris': (np.radians(37.95), np.radians(89.26)),
'Sirius': (np.radians(101.29), np.radians(-16.72)),
'Canopus': (np.radians(95.99), np.radians(-52.70)),
'Arcturus': (np.radians(213.92), np.radians(19.18)),
'Vega': (np.radians(279.23), np.radians(38.78)),
'Capella': (np.radians(79.17), np.radians(46.00)),
'Rigel': (np.radians(78.63), np.radians(-8.20)),
'Procyon': (np.radians(114.83), np.radians(5.23)),
}
def get_reference_stars(self, min_elevation: float = 30.0) -> dict:
"""Get stars suitable for navigation."""
return self.bright_stars
Multi-Sensor Fusion for GPS-Denied Navigation
from typing import Dict, List, Optional
from dataclasses import dataclass
@dataclass
class NavigationState:
position: np.ndarray
velocity: np.ndarray
orientation: np.ndarray
covariance: np.ndarray
confidence: float
class GPSDeniedNavigationSystem:
def __init__(self):
self.vio = None # Visual-Inertial Odometry
self.terrain_nav = None # Terrain navigation
self.magnetic_nav = None # Magnetic navigation
self.celestial_nav = CelestialNavigation()
self.state = NavigationState(
position=np.zeros(3),
velocity=np.zeros(3),
orientation=np.zeros(3),
covariance=np.eye(6) * 1.0,
confidence=1.0
)
self.available_sensors = set()
self.sensor_weights = {
'vio': 0.4,
'terrain': 0.3,
'magnetic': 0.2,
'celestial': 0.1
}
def update_sensor_availability(self, sensors: Dict[str, bool]):
"""Update which sensors are currently available."""
for sensor, available in sensors.items():
if available:
self.available_sensors.add(sensor)
else:
self.available_sensors.discard(sensor)
def fuse_measurements(self, measurements: Dict[str, np.ndarray]) -> NavigationState:
"""Fuse measurements from available sensors."""
positions = []
weights = []
# VIO
if 'vio' in measurements and 'vio' in self.available_sensors:
vio_pos = measurements['vio']
positions.append(vio_pos)
weights.append(self.sensor_weights['vio'])
# Terrain
if 'terrain' in measurements and 'terrain' in self.available_sensors:
terrain_pos = measurements['terrain']
positions.append(terrain_pos)
weights.append(self.sensor_weights['terrain'])
# Magnetic
if 'magnetic' in measurements and 'magnetic' in self.available_sensors:
magnetic_pos = measurements['magnetic']
positions.append(magnetic_pos)
weights.append(self.sensor_weights['magnetic'])
# Celestial
if 'celestial' in measurements and 'celestial' in self.available_sensors:
celestial_pos = measurements['celestial']
positions.append(celestial_pos)
weights.append(self.sensor_weights['celestial'])
if not positions:
return self.state
# Normalize weights
weights = np.array(weights)
weights = weights / weights.sum()
# Weighted fusion
positions = np.array(positions)
fused_position = np.average(positions, axis=0, weights=weights)
# Update covariance
if len(positions) > 1:
position_variance = np.var(positions, axis=0)
self.state.covariance[:3, :3] = np.diag(position_variance)
# Update state
self.state.position = fused_position
self.state.confidence = 1.0 - np.sqrt(np.mean(self.state.covariance[:3, :3]))
return self.state
def predict_position(self, dt: float, control_input: np.ndarray) -> np.ndarray:
"""Dead reckoning prediction when no measurements available."""
# Simple constant velocity model
self.state.position += self.state.velocity * dt
self.state.velocity += control_input * dt
# Increase uncertainty
self.state.covariance += np.eye(6) * 0.1 * dt
self.state.confidence *= 0.99 # Decay confidence
return self.state.position.copy()
def get_navigation_solution(self) -> Dict:
"""Get current navigation solution with quality metrics."""
return {
'position': self.state.position.copy(),
'velocity': self.state.velocity.copy(),
'orientation': self.state.orientation.copy(),
'position_uncertainty': np.sqrt(np.diag(self.state.covariance[:3, :3])),
'confidence': self.state.confidence,
'available_sensors': list(self.available_sensors),
'recommended_action': self._get_recommendation()
}
def _get_recommendation(self) -> str:
"""Recommend action based on navigation quality."""
if self.state.confidence > 0.8:
return 'NORMAL_OPERATION'
elif self.state.confidence > 0.5:
return 'REDUCE_SPEED'
elif self.state.confidence > 0.3:
return 'INCREASE_ALTITUDE'
else:
return 'CONSIDER_RTL'
Hands-On Project: Complete GPS-Denied Navigation
from typing import Dict, List
class GPSDeniedDrone:
def __init__(self, environment: str = 'indoor'):
self.environment = environment
# Navigation systems based on environment
self.nav_system = GPSDeniedNavigationSystem()
# Environment-specific configurations
self.config = self._get_config(environment)
# State
self.position = np.zeros(3)
self.velocity = np.zeros(3)
self.heading = 0.0
# Logs
self.navigation_log = []
def _get_config(self, environment: str) -> Dict:
configs = {
'indoor': {
'sensors': ['vio', 'uwb', 'wifi'],
'update_rate': 30,
'max_speed': 2.0
},
'urban': {
'sensors': ['vio', 'magnetic', 'visual_landmarks'],
'update_rate': 10,
'max_speed': 5.0
},
'cave': {
'sensors': ['vio', 'magnetic'],
'update_rate': 10,
'max_speed': 1.0
},
'forest': {
'sensors': ['vio', 'terrain', 'magnetic'],
'update_rate': 5,
'max_speed': 3.0
}
}
return configs.get(environment, configs['indoor'])
def process_sensor_data(self, sensor_data: Dict) -> Dict:
"""Process sensor data and update navigation."""
measurements = {}
# Process each available sensor
for sensor in self.config['sensors']:
if sensor in sensor_data:
if sensor == 'vio':
measurements['vio'] = self._process_vio(sensor_data[sensor])
elif sensor == 'uwb':
measurements['uwb'] = self._process_uwb(sensor_data[sensor])
elif sensor == 'magnetic':
measurements['magnetic'] = self._process_magnetic(sensor_data[sensor])
elif sensor == 'terrain':
measurements['terrain'] = self._process_terrain(sensor_data[sensor])
# Fuse measurements
if measurements:
state = self.nav_system.fuse_measurements(measurements)
self.position = state.position
self.velocity = state.velocity
# Log
self.navigation_log.append({
'position': self.position.copy(),
'sensors_used': list(measurements.keys()),
'confidence': self.nav_system.state.confidence
})
return {
'position': self.position,
'velocity': self.velocity,
'heading': self.heading,
'confidence': self.nav_system.state.confidence
}
def _process_vio(self, data: Dict) -> np.ndarray:
"""Process VIO data."""
return data.get('position', self.position)
def _process_uwb(self, data: Dict) -> np.ndarray:
"""Process UWB ranging data."""
return data.get('position', self.position)
def _process_magnetic(self, data: Dict) -> np.ndarray:
"""Process magnetic field data."""
return data.get('position', self.position)
def _process_terrain(self, data: Dict) -> np.ndarray:
"""Process terrain profile data."""
return data.get('position', self.position)
def navigate_to(self, target: np.ndarray) -> Dict:
"""Navigate to target in GPS-denied environment."""
error = target - self.position
distance = np.linalg.norm(error)
if distance < 0.5:
return {'status': 'arrived', 'position': self.position}
# Velocity command
speed = min(self.config['max_speed'], distance * 0.5)
direction = error / distance
velocity_cmd = direction * speed
# Update velocity
self.velocity = velocity_cmd
return {
'status': 'navigating',
'velocity_command': velocity_cmd,
'distance_to_target': distance,
'position': self.position,
'confidence': self.nav_system.state.confidence
}
def emergency_procedure(self) -> Dict:
"""Execute emergency procedure when navigation fails."""
print("EMERGENCY: Navigation confidence critical!")
# Try to find any available positioning
available = self.nav_system.available_sensors
if len(available) == 0:
return {
'action': 'HOVER_AND_AWAIT',
'message': 'No positioning available - hovering'
}
# Land if possible
return {
'action': 'CONTROLLED_LANDING',
'message': 'Attempting controlled landing'
}
def get_navigation_report(self) -> Dict:
"""Generate comprehensive navigation report."""
report = {
'current_position': self.position.tolist(),
'heading': np.degrees(self.heading),
'speed': np.linalg.norm(self.velocity),
'confidence': self.nav_system.state.confidence,
'sensors_active': list(self.nav_system.available_sensors),
'position_uncertainty': np.sqrt(
np.diag(self.nav_system.state.covariance[:3, :3])
).tolist(),
'environment': self.environment,
'navigation_log_length': len(self.navigation_log)
}
return report
def main():
# Test GPS-denied navigation in different environments
environments = ['indoor', 'urban', 'cave', 'forest']
for env in environments:
print(f"\n{'='*50}")
print(f"Testing GPS-denied navigation: {env.upper()}")
print('='*50)
drone = GPSDeniedDrone(environment=env)
# Simulate navigation
target = np.array([10.0, 10.0, 2.0])
for step in range(20):
result = drone.navigate_to(target)
# Simulate sensor data
sensor_data = {
'vio': {'position': drone.position + np.random.randn(3) * 0.1},
}
nav_state = drone.process_sensor_data(sensor_data)
print(f"Step {step}: Position={nav_state['position'][:2]}, "
f"Confidence={nav_state['confidence']:.2f}")
if result['status'] == 'arrived':
print("Target reached!")
break
# Print report
report = drone.get_navigation_report()
print(f"\nNavigation Report:")
print(f" Final position: {report['current_position'][:2]}")
print(f" Sensors used: {report['sensors_active']}")
if __name__ == "__main__":
main()
Key Takeaways
- VIO provides short-term accurate odometry but drifts over time
- Terrain matching gives bounded-error position fixes outdoors
- Magnetic navigation works in any environment with a magnetic map
- Celestial navigation serves as a backup when other sensors fail
- Multi-sensor fusion provides robust navigation across environments
- Always monitor navigation confidence and have fallback procedures