GPS-denied navigation for fixed-wing UAVs using TERCOM (Terrain Contour Matching) fused with an Error-State Kalman Filter (ESKF). The package operates entirely in the tercom ROS 2 namespace and interfaces with PX4 SITL via MAVROS.
The system estimates UAV position without GPS by correlating a real-time terrain elevation profile (barometer + rangefinder) against a pre-loaded Digital Elevation Model (DEM). GPS is used once at startup to initialize the filter; after that, the ESKF runs on IMU prediction with periodic TERCOM, barometric, and velocity updates.
MAVROS topics
│
├── imu/data ──────────────────────────────────────► eskf_node (prediction)
├── altitude ──────────────────────────► tercom_node ──► eskf_node (baro update)
├── distance_sensor/rangefinder_pub ───► tercom_node
├── local_position/odom ───────────────► tercom_node
├── global_position/global ────────────────────────► eskf_node (init only)
└── local_position/velocity_local ─────────────────► eskf_node (velocity update)
tercom_node ──► /tercom/tercom_node/position_fix ──────► eskf_node (TERCOM update)
tercom_node ◄── /tercom/eskf_node/pose (covariance feedback for dynamic search radius)
eskf_node ──► /tercom/eskf_node/odom ──────────────────► diagnostics_node
Node responsibilities:
| Node | Responsibility |
|---|---|
dem_server_node |
Loads GeoTIFF DEM at startup; exposes metadata via latched topic and ROS services |
tercom_node |
Collects synchronized baro + rangefinder profiles; runs vectorized TERCOM correlation matching |
eskf_node |
15-state ESKF (position / velocity / attitude / accel-bias / gyro-bias); fuses IMU + TERCOM + baro + velocity |
diagnostics_node |
Computes ground-truth error metrics, publishes RViz visualizations, and logs CSV |
- ROS 2 Humble or later (tested on Humble)
- Build type:
ament_python
rclpy
std_msgs
geometry_msgs
nav_msgs
sensor_msgs
visualization_msgs
mavros_msgs
tf2_ros
diagnostic_msgs
std_srvs
message_filters
| Package | Purpose |
|---|---|
numpy |
Vectorized TERCOM matching, ESKF matrices |
scipy |
Quaternion operations in ESKF |
rasterio |
GeoTIFF DEM loading and CRS auto-detection |
pyproj |
Coordinate reference system transformations (UTM ↔ WGS84) |
Pillow |
Satellite PNG loading for DEM colorization in RViz (optional) |
pandas |
CSV parsing for analyze_tercom_log.py |
matplotlib |
Figure generation for analyze_tercom_log.py |
Install Python dependencies:
pip install numpy scipy rasterio pyproj Pillow pandas matplotlib- A GeoTIFF DEM covering the flight area (geographic or projected CRS; auto-reprojected to UTM if needed)
- A rangefinder (AGL distance) + barometer (AMSL altitude) — or their MAVROS equivalents in simulation
- Tested against PX4 SITL + Gazebo using the
taif_test4terrain model
# Clone into your ROS 2 workspace
cd ~/ros2_ws/src
git clone <repo-url> tercom_nav
# Install ROS dependencies
cd ~/ros2_ws
rosdep install --from-paths src --ignore-src -r -y
# Build
colcon build --packages-select tercom_nav
# Source
source install/setup.bashros2 launch tercom_nav tercom_nav.launch.py \
dem_file:=/path/to/terrain.tif \
world_origin_lat:=<lat> \
world_origin_lon:=<lon> \
world_origin_alt:=<alt_msl>ros2 launch tercom_nav tercom_standalone.launch.py \
dem_file:=/path/to/terrain.tif \
world_origin_lat:=<lat> \
world_origin_lon:=<lon> \
world_origin_alt:=<alt_msl>ros2 launch tercom_nav tercom_nav.launch.py \
dem_file:=/home/user/shared_volume/PX4-Autopilot/Tools/simulation/gz/models/taif_test4/textures/taif_test4_tercom_dem.tif \
world_origin_lat:=21.2651 \
world_origin_lon:=40.3542 \
world_origin_alt:=1859.7Or use the pre-configured override file:
ros2 launch tercom_nav tercom_nav.launch.py \
params_file:=$(ros2 pkg prefix tercom_nav)/share/tercom_nav/config/taif_test4_params.yaml| Argument | Default | Description |
|---|---|---|
dem_file |
"" |
Required. Absolute path to GeoTIFF DEM |
dem_metadata_file |
"" |
Optional JSON sidecar with DEM metadata |
mavros_ns |
target/mavros |
MAVROS namespace prefix |
world_origin_lat |
0.0 |
Gazebo world origin latitude (degrees) |
world_origin_lon |
0.0 |
Gazebo world origin longitude (degrees) |
world_origin_alt |
0.0 |
Gazebo world origin altitude MSL (m) |
use_sim_time |
true |
Use /clock from simulation |
params_file |
<pkg>/config/tercom_params.yaml |
Parameter override YAML |
Same as above except dem_metadata_file and params_file are not exposed (always uses package defaults). Use for lightweight deployments where diagnostics are not needed.
All parameters are set via the params YAML. The defaults are in config/tercom_params.yaml.
| Parameter | Default | Description |
|---|---|---|
dem_file |
"" |
Absolute path to GeoTIFF DEM |
dem_metadata_file |
"" |
Optional JSON sidecar |
nodata_value |
-9999.0 |
Sentinel value for missing cells |
interpolation_method |
"bilinear" |
"nearest" or "bilinear" |
| Parameter | Default | Description |
|---|---|---|
dem_file |
"" |
Absolute path to GeoTIFF DEM |
profile_min_spacing_m |
-1.0 |
Min distance between profile samples (m); -1 = auto = 1.0 × pixel_size |
profile_max_samples |
20 |
Max samples per profile before triggering a match |
profile_min_distance_m |
-1.0 |
Min total profile length (m); -1 = auto = 15 × pixel_size |
search_radius_pixels |
50 |
Static search half-size in pixels |
search_radius_dynamic |
true |
Adapt search radius from ESKF covariance |
search_radius_sigma_mult |
3.0 |
Dynamic radius = mult × sigma_pos / pixel_size |
search_radius_min_pixels |
10 |
Lower bound for dynamic search radius |
search_radius_max_pixels |
100 |
Upper bound for dynamic search radius |
mad_reject_threshold |
30.0 |
Reject match if MAD > this (m) |
discrimination_min |
1.5 |
Reject match if 2nd_best / best < this |
discrimination_exclusion_radius |
3 |
Pixel exclusion zone around best for 2nd-best search |
roughness_min |
3.0 |
Reject match if terrain std < this (m) — flat terrain |
enable_adaptive_sampling |
true |
Enable velocity-based adaptive sampling |
adaptive_min_interval_s |
0.5 |
Min time between adaptive samples (s) |
adaptive_max_interval_s |
5.0 |
Max time between adaptive samples (s) |
adaptive_pixels_per_sample |
1.5 |
Target spacing in DEM pixels per sample |
sync_slop_s |
0.05 |
Timestamp tolerance for baro/rangefinder sync (s) |
world_origin_lat |
0.0 |
Gazebo world origin latitude |
world_origin_lon |
0.0 |
Gazebo world origin longitude |
world_origin_alt |
0.0 |
Gazebo world origin altitude MSL (m) |
| Parameter | Default | Description |
|---|---|---|
imu_rate_hz |
50.0 |
Decimated IMU processing rate (raw input ~250 Hz) |
accel_noise |
0.1 |
Accelerometer noise density (m/s²/√Hz) |
gyro_noise |
0.01 |
Gyroscope noise density (rad/s/√Hz) |
accel_bias_noise |
0.001 |
Accelerometer bias random walk (m/s³) |
gyro_bias_noise |
0.0001 |
Gyroscope bias random walk (rad/s²) |
bias_time_constant |
300.0 |
Gauss-Markov bias time constant (s) |
tercom_noise_base |
-1.0 |
Base TERCOM position noise (m); -1 = auto from DEM pixel size |
baro_noise |
3.0 |
Barometric altitude noise std (m) |
baro_update_rate_hz |
1.0 |
Rate limit for barometric updates (Hz) |
velocity_noise |
0.5 |
Velocity measurement noise std (m/s) |
enable_velocity_updates |
true |
Enable MAVROS velocity aiding |
velocity_update_rate_hz |
5.0 |
Rate limit for velocity updates (Hz) |
init_pos_std |
5.0 |
Initial position uncertainty (m) |
init_vel_std |
1.0 |
Initial velocity uncertainty (m/s) |
init_att_std |
0.05 |
Initial attitude uncertainty (rad) |
init_abias_std |
0.5 |
Initial accelerometer bias uncertainty (m/s²) |
init_wbias_std |
0.01 |
Initial gyroscope bias uncertainty (rad/s) |
gps_init_samples |
20 |
GPS fixes averaged for initialization |
gps_init_timeout_s |
30.0 |
Max wait time for GPS initialization (s) |
nis_threshold |
15.0 |
NIS window average above this → divergence (χ² 2DOF 95% = 5.99) |
nis_window_size |
10 |
Window size for NIS averaging |
max_position_std |
500.0 |
Position std above this → divergence (m) |
max_innovation_m |
200.0 |
Innovation gate: reject TERCOM updates beyond this (m) |
consecutive_reject_limit |
5 |
Consecutive rejections before divergence |
divergence_action |
"reset" |
Response to divergence: "warn", "reset", or "reset_with_gps" |
world_origin_lat/lon/alt |
0.0 |
Must match tercom_node and diagnostics_node |
| Parameter | Default | Description |
|---|---|---|
log_to_csv |
true |
Enable CSV logging |
csv_path |
"/tmp/tercom_logs/" |
Output directory for CSV files |
publish_dem_pointcloud |
true |
Publish DEM as PointCloud2 at startup |
dem_pointcloud_decimation |
4 |
Publish every Nth DEM pixel (lower = denser cloud) |
dem_file |
"" |
DEM file for point cloud generation |
dem_satellite_image |
"" |
Optional path to aerial PNG; colors DEM cloud with satellite imagery instead of elevation colormap |
dem_satellite_bounds |
[0,0,0,0] |
WGS84 bounds of the PNG: [west_lon, south_lat, east_lon, north_lat]; required when dem_satellite_image is set |
error_publish_rate_hz |
10.0 |
Rate for error metric topics (Hz) |
path_publish_rate_hz |
2.0 |
Rate for path visualization topics (Hz) |
world_origin_lat/lon/alt |
0.0 |
Must match other nodes |
Subscribes:
| Topic (remapped from) | Type | Description |
|---|---|---|
altitude |
mavros_msgs/Altitude |
Barometric AMSL altitude (synchronized with rangefinder) |
distance_sensor |
sensor_msgs/Range |
Rangefinder AGL distance |
imu_data |
sensor_msgs/Imu |
Roll/pitch correction for rangefinder tilt |
local_odom |
nav_msgs/Odometry |
Local ENU position for profile collection |
eskf_covariance |
geometry_msgs/PoseWithCovarianceStamped |
ESKF covariance for dynamic search radius |
Publishes:
| Topic | Type | Description |
|---|---|---|
/tercom/tercom_node/position_fix |
geometry_msgs/PointStamped |
UTM position fix (x=easting, y=northing, z=terrain elevation); frame_id=utm |
/tercom/tercom_node/match_quality |
std_msgs/Float32MultiArray |
[MAD (m), discrimination ratio, roughness (m), adaptive_noise_var] |
/tercom/tercom_node/profile_path |
nav_msgs/Path |
Profile sample positions for RViz |
/tercom/tercom_node/search_region |
visualization_msgs/Marker |
Search window wireframe |
/tercom/tercom_node/status |
std_msgs/String |
COLLECTING, MATCHING, or WAITING_SENSORS at 1 Hz |
/tercom/tercom_node/rejected_fix |
geometry_msgs/PointStamped |
UTM position of a rejected TERCOM fix |
/tercom/tercom_node/rejection_reason |
std_msgs/String |
Human-readable reason the fix was rejected |
Services:
| Service | Type | Description |
|---|---|---|
/tercom/tercom_node/trigger_match |
std_srvs/Trigger |
Force a match with current samples (≥ 5 required) |
/tercom/tercom_node/reset_profile |
std_srvs/Trigger |
Clear all collected samples |
Subscribes:
| Topic (remapped from) | Type | Description |
|---|---|---|
imu_data |
sensor_msgs/Imu |
High-rate IMU (decimated to imu_rate_hz) |
gps_global |
sensor_msgs/NavSatFix |
GPS fix — used only during initialization |
altitude |
mavros_msgs/Altitude |
Barometric AMSL altitude |
velocity_local |
geometry_msgs/TwistStamped |
ENU velocity aiding |
local_odom |
nav_msgs/Odometry |
Stored for initialization velocity |
tercom_fix |
geometry_msgs/PointStamped |
TERCOM UTM fix |
tercom_quality |
std_msgs/Float32MultiArray |
Match quality for adaptive measurement noise |
Publishes:
| Topic | Type | Rate | Description |
|---|---|---|---|
/tercom/eskf_node/odom |
nav_msgs/Odometry |
imu_rate_hz |
Full odometry with 6×6 covariance |
/tercom/eskf_node/pose |
geometry_msgs/PoseWithCovarianceStamped |
imu_rate_hz |
Pose + covariance (fed back to tercom_node) |
/tercom/eskf_node/global |
sensor_msgs/NavSatFix |
1 Hz | Estimated lat/lon/alt |
/tercom/eskf_node/bias_accel |
geometry_msgs/Vector3Stamped |
1 Hz | Estimated accelerometer bias |
/tercom/eskf_node/bias_gyro |
geometry_msgs/Vector3Stamped |
1 Hz | Estimated gyroscope bias |
/tercom/eskf_node/state |
std_msgs/String |
On change | Filter state machine state |
/tercom/eskf_node/health |
std_msgs/Float32MultiArray |
1 Hz | [avg_NIS, max_pos_std (m), innov_norm, is_healthy] |
Services:
| Service | Type | Description |
|---|---|---|
/tercom/eskf_node/reset_filter |
std_srvs/Trigger |
Hard reset: re-enters WAITING_GPS state |
Publishes:
| Topic | Type | Description |
|---|---|---|
/tercom/dem_server_node/dem_info |
std_msgs/String |
Latched JSON: CRS, pixel size, bounds, elevation range |
Services:
| Service | Type | Description |
|---|---|---|
/tercom/dem_server_node/get_dem_info |
std_srvs/Trigger |
Returns DEM metadata as JSON string |
/tercom/dem_server_node/get_elevation |
std_srvs/Trigger |
Returns DEM bounding box info |
Publishes:
| Topic | Type | Description |
|---|---|---|
/tercom/diagnostics_node/position_error |
geometry_msgs/Vector3Stamped |
Per-axis error vs ground truth (m) |
/tercom/diagnostics_node/error_norm |
std_msgs/Float64 |
Horizontal error norm (m) |
/tercom/diagnostics_node/error_stats |
std_msgs/Float32MultiArray |
[rms_h, max_h, mean_h, v_err, latest_h, v_err] (m) |
/tercom/diagnostics_node/estimated_path |
nav_msgs/Path |
Accumulated ESKF trajectory |
/tercom/diagnostics_node/ground_truth_path |
nav_msgs/Path |
Accumulated ground truth trajectory |
/tercom/diagnostics_node/tercom_fixes_viz |
visualization_msgs/MarkerArray |
Sphere markers colored by match quality |
/tercom/diagnostics_node/covariance_ellipse |
visualization_msgs/Marker |
2-sigma covariance ellipse |
/tercom/diagnostics_node/dem_surface |
sensor_msgs/PointCloud2 |
DEM point cloud colored by satellite aerial image when dem_satellite_image is set, otherwise jet elevation colormap (latched, published once at startup) |
/tercom/diagnostics_node/error_arrow |
visualization_msgs/MarkerArray |
Arrow marker showing current position error vector |
/tercom/diagnostics_node/error_colored_path |
visualization_msgs/MarkerArray |
Estimated path line colored by instantaneous error magnitude |
/tercom/diagnostics_node/rejected_fixes_viz |
visualization_msgs/MarkerArray |
Markers for rejected TERCOM fixes |
/tercom/diagnostics_node/nis_history |
std_msgs/Float32MultiArray |
Sliding-window NIS history array |
/tercom/diagnostics_node/error_history_chart |
std_msgs/Float32MultiArray |
Horizontal error history array |
The filter transitions through the following states:
WAITING_GPS ──► INITIALIZING ──► RUNNING ◄──► DIVERGED ──► RESETTING
│
└──► RUNNING (after soft reset)
| State | Description |
|---|---|
WAITING_GPS |
Waiting for first valid GPS fix |
INITIALIZING |
Averaging gps_init_samples GPS fixes to compute initial position |
RUNNING |
Normal operation: IMU prediction + TERCOM/baro/velocity updates |
DIVERGED |
Filter health checks failed; action depends on divergence_action |
RESETTING |
Hard reset in progress; re-acquires GPS |
Divergence actions:
divergence_action |
Behavior |
|---|---|
"warn" |
Log warning only; stay in RUNNING |
"reset" |
Inflate covariance, zero biases; stay in RUNNING |
"reset_with_gps" |
Full hard reset; re-enter WAITING_GPS |
Terrain elevation at each sample point is computed as:
h_terrain = h_baro_AMSL − h_AGL × cos(roll) × cos(pitch)
A match is triggered when:
num_samples ≥ profile_max_samples, andtotal_distance ≥ profile_min_distance_m
The matcher evaluates all candidate positions in the search window simultaneously using vectorized NumPy operations. A candidate match is rejected if any of these fail:
| Check | Threshold |
|---|---|
| MAD (mean absolute deviation) | > mad_reject_threshold |
| Discrimination ratio (2nd-best / best) | < discrimination_min |
| Terrain roughness (std of DEM window) | < roughness_min |
After a successful match, a sliding window retains the last max_samples / 2 samples for profile continuity into the next match.
Fix marker colors in RViz (diagnostics_node):
- Green — roughness > 6 m AND discrimination > 2.25 AND MAD < 15 m
- Yellow — all thresholds pass but not by the above margin
- Red — any rejection threshold failed
When log_to_csv: true, diagnostics_node creates a timestamped CSV at csv_path/tercom_log_YYYYMMDD_HHMMSS.csv.
Columns: ros_timestamp_ns, est_x, est_y, est_z, est_vx, est_vy, est_vz, true_x, true_y, true_z, true_vx, true_vy, true_vz, err_x, err_y, err_z, err_h_norm, err_3d_norm, cov_xx, cov_yy, cov_zz, tercom_mad, tercom_disc, tercom_roughness, filter_state, nis
scripts/analyze_tercom_log.py reads a CSV produced by diagnostics_node and generates publication-quality figures for post-flight performance analysis. It requires pandas and matplotlib.
python3 $(ros2 pkg prefix tercom_nav)/share/tercom_nav/scripts/analyze_tercom_log.py \
/tmp/tercom_logs/tercom_log_YYYYMMDD_HHMMSS.csv \
[--outdir /tmp/tercom_figures]Figures generated (saved as PNG and PDF):
| Figure | Description |
|---|---|
01_trajectory_xy |
XY ground track: estimated vs ground truth |
02_position_error_time |
Per-axis and horizontal/3D error vs time |
03_error_statistics |
Sliding-window RMSE, mean, max horizontal error |
04_covariance_consistency |
3-sigma position bounds vs actual error |
05_nis_time |
NIS vs time with chi-squared consistency bounds |
06_tercom_quality |
MAD, discrimination, roughness, adaptive noise vs time |
07_speed_profile |
Estimated and true speed vs time |
08_filter_state_timeline |
Color-coded filter state over time |
09_error_histogram |
Distribution of horizontal and vertical errors |
10_cov_vs_error |
Covariance sigma vs actual error (consistency scatter) |
11_tercom_mad_vs_error |
TERCOM MAD vs position error scatter |
12_trajectory_3d |
3D trajectory comparison |
13_accepted_fixes_rate |
Cumulative accepted TERCOM fixes vs time |
14_health_metrics |
Filter health: max_pos_std, innov_norm, is_healthy |
15_summary_dashboard |
Single-page multi-panel summary figure |
A pre-configured RViz layout is provided:
rviz2 -d $(ros2 pkg prefix tercom_nav)/share/tercom_nav/config/rviz_tercom.rvizKey displays:
- DEM surface point cloud — colored by satellite aerial imagery when
dem_satellite_imageis configured, otherwise jet colormap by elevation; rendered as filled squares sized to cover inter-point spacing - Estimated path and ground truth path
- TERCOM fix markers (color-coded by quality)
- Profile collection path
- 2-sigma covariance ellipse
| Module | Description |
|---|---|
core/dem_manager.py |
Loads any GeoTIFF DEM; auto-reprojects geographic → UTM if needed; bilinear or nearest-neighbor elevation lookups (single-point and vectorized batch) |
core/tercom_matcher.py |
ProfileCollector sliding-window buffer with min-spacing enforcement; match_profile() fully vectorized TERCOM correlation returning best UTM position, MAD, discrimination ratio, and roughness |
core/eskf.py |
15-state ESKF (position / velocity / attitude / accel-bias / gyro-bias) with IMU-driven linearized prediction and separate Joseph-form updates for 2D TERCOM position, barometric altitude, and 3D velocity |
core/adaptive_sampler.py |
Distance-based trigger: fires when the drone travels pixels_per_sample × pixel_size meters since the last sample, with min/max time interval clamps |
core/coordinate_utils.py |
WGS84 ↔ UTM ↔ local ENU ↔ DEM pixel coordinate conversions; caches pyproj.Transformer objects for efficiency |
core/health_monitor.py |
Three independent ESKF health checks: NIS windowed average, position covariance bound, and consecutive innovation rejection count |
core/terrain_quality.py |
Terrain roughness computation; good / marginal / poor classification; adaptive TERCOM measurement noise variance scaling with MAD, roughness, and discrimination |
Run all tests:
cd ~/ros2_ws
colcon test --packages-select tercom_nav
colcon test-result --verboseOr run directly with pytest (no ROS runtime required):
cd src/tercom_nav
pytest test/ -v46 tests across 5 files, all pure unit tests against core library code:
| File | Tests | What is covered |
|---|---|---|
test_adaptive_sampler.py |
9 | Adaptive sampler triggers, health monitor divergence and reset |
test_coordinate_utils.py |
7 | UTM conversion, ENU round-trips, pixel coordinate transforms |
test_dem_manager.py |
7 | DEM load, elevation lookup, bounds check, batch vs single |
test_eskf.py |
15 | ESKF init, prediction, all update types, covariance symmetry, divergence |
test_tercom_matcher.py |
8 | Profile collection, spacing enforcement, match recovery, flat terrain, error handling |
tercom_nav/
├── package.xml
├── setup.py
├── setup.cfg
├── README.md
├── resource/
│ └── tercom_nav
├── config/
│ ├── tercom_params.yaml # Default parameters for all nodes
│ ├── taif_test4_params.yaml # Override config for taif_test4 DEM (PX4 SITL)
│ └── rviz_tercom.rviz # RViz layout
├── launch/
│ ├── tercom_nav.launch.py # Full system: all 4 nodes
│ └── tercom_standalone.launch.py # Minimal: tercom_node + eskf_node only
├── scripts/
│ └── analyze_tercom_log.py # Post-flight log analysis and figure generation
├── tercom_nav/
│ ├── nodes/
│ │ ├── dem_server_node.py
│ │ ├── tercom_node.py
│ │ ├── eskf_node.py
│ │ └── diagnostics_node.py
│ └── core/
│ ├── dem_manager.py
│ ├── tercom_matcher.py
│ ├── eskf.py
│ ├── adaptive_sampler.py
│ ├── coordinate_utils.py
│ ├── health_monitor.py
│ └── terrain_quality.py
└── test/
├── test_adaptive_sampler.py
├── test_coordinate_utils.py
├── test_dem_manager.py
├── test_eskf.py
└── test_tercom_matcher.py