# Terrain Referenced Navigation

> Use when you must aid an unaided inertial navigation solution from terrain with no GNSS available: correlate a measured radar-altimeter terrain profile (INS altitude minus radar clearance) along the INS indicated track against a stored digital elevation model strip, form the TERCOM correlation surface over the candidate along-track and cross-track offsets, and take the best-match offset as the coarse position correction. Then run the point-mass SITAN fine stage, per-mass terrain-height likelihood updates and the linearized terrain-slope measurement update that refine the inertial correction epoch by epoch. Produces the correlation surface and best-match offset, the corrected inertial position with per-axis 1-sigma, the recovered vertical bias, and each slope update's innovation, variance and gain. Trigger: terrain referenced navigation, tercom correlation, digital elevation model matching, terrain slope update, radar altimeter profile, point mass sitan, ins position correction.

- Skill: `ashfordeou/terrain-referenced-navigation` (Agent Skill, multi-file: 3 files)
- Install (CLI): `npx skillmds@latest add ashfordeou/terrain-referenced-navigation`
- Raw SKILL.md: https://api.skillmd.com/api/skills/ashfordeou/terrain-referenced-navigation/raw
- Safety review: pending
- Works with: Claude Code, Claude.ai, OpenAI Codex
- Category: AI & ML
- License: Apache-2.0
- Author: ashfordeOU (https://skillmd.com/u/ashfordeou)
- Updated: 2026-09-17
- Page: https://skillmd.com/skills/ashfordeou/terrain-referenced-navigation

---


# Terrain-Referenced Navigation (gnc-autonomy/navigation/terrain-referenced-navigation)

Use when you must aid an unaided inertial navigation solution from
terrain with no GNSS available. This leaf implements the classic
terrain-referenced navigation pair: a TERCOM-style grid correlation of
a measured radar-altimeter terrain profile against a stored digital
elevation model (DEM) strip that estimates the INS horizontal position
error to one grid step, followed by a SITAN point-mass fine stage with a
linearized terrain-slope measurement update that refines the correction
epoch by epoch and recovers the vertical INS altitude bias. The radar
altimeter measures geometric clearance; the INS indicated track is a
GIVEN input (never mechanized, never re-integrated), and no GNSS
observable of any kind appears in the loop. Deterministic offline: pure
stdlib math, no RNG. It pairs with inertial-navigation (the INS error
growth this leaf consumes as an input), tightly-coupled-ins-gnss and
ins-gnss-integrated-filter (the GNSS-aided alternatives this leaf
replaces when no GNSS is available), and kalman-filter-design (the
single-state recursions reused per mass).

## Domain quick reference

- Stored DEM strip: analytic rolling terrain sampled on a 25 m grid,
  origin east 3800 m, north -600 m, grid 293 x 113 nodes (east 3800..
  11100 m, north -600..2200 m). Surface h(x, y) = 420.0 + 62.0 sin(2 pi
  x / 2100.0 + 0.55) + 47.0 sin(2 pi y / 1500.0 + 1.25) + 30.0 sin(2 pi
  (0.75 x + 0.66 y) / 2900.0 + 0.15) + 16.0 sin(2 pi (0.50 x - 0.87 y) /
  760.0 + 0.90), east x and north y in metres. The analytic formula
  exists only to BUILD the strip (terrain_height, build_dem_strip); the
  correlator reads ONLY the stored grid through dem_height (bilinear)
  and dem_gradient (centered differences (f[j + 1] - f[j - 1]) / (2 *
  25) at the cell nodes, bilinearly interpolated).
- Measurement: radar clearance c_i = A_true - h_true_i + eta_i with the
  deterministic mismatch offsets eta_i = 1.7 sin(0.4 + 2 pi i / 11) +
  0.9 sin(1.3 + 2 pi i / 6) + 0.55 sin(2.0 + 2 pi i / 4) (RMS 1.451 m,
  radar-altimeter plus DEM error lumped, no RNG). The measured terrain
  profile is z_i = A_ins - c_i (clearance_profile_to_terrain), with
  A_ins the INS indicated barometric altitude carrying the hidden
  vertical error dA.
- INS error convention: e = p_ind - p_true is constant over the profile
  window. The TERCOM best-match offset (dx_best, dy_best) estimates e;
  the coarse correction is dr0 = (-dx_best, -dy_best).
- Correction convention: dr is ADDED to the INS indicated position,
  p_corrected = p_ind + dr; the true correction is dr_true = -e. A
  hypothesis at dr_k evaluates the DEM at (x_ind_i + dr_k_e, y_ind_i +
  dr_k_n). In the worked example e = (450.0, 180.0) m and dr_true =
  (-450.0, -180.0) m.
- TERCOM stage: for candidate (dx, dy) the DEM profile along the
  corrected track is d_i = h_strip(x_ind_i - dx_c, y_ind_i - dy_c).
  Pearson r = covariance(z, d) / (sigma_z * sigma_d) and MSD =
  mean((z_i - d_i)^2) come from profile_metrics; the best match is the
  argmax r over the candidate grid (mean-centered r is immune to the
  constant vertical bias; MSD is reported at the best but never selects
  it).
- SITAN point-mass stage (filter defaults R_MEAS = 2.25 m^2, Q_BIAS =
  0.02 m^2, P0_BIAS = 100.0 m^2): a bank of (2 * half + 1)^2 candidate
  corrections at spacing around dr0 (13 by 13 at 10 m, dr0 +/- 60 m)
  covers the one-grid-step coarse residual. Each mass k carries a scalar
  vertical-bias Kalman state (dA_k init 0, P_k init P0_BIAS). Per epoch:
  P_k += Q_BIAS, innovation y_k = z_i - h_strip(pos_k) - dA_k with S_k =
  P_k + R_MEAS and gain k_k = P_k / S_k, dA_k += k_k * y_k, P_k = (1 -
  k_k) * P_k, log weight += -0.5 * (y_k^2 / S_k + ln(2 pi S_k)); weights
  normalize from log space (subtract the max before exp).
- Linearized terrain-slope update at the weighted centroid: H = [dh/dx,
  dh/dy, 1.0] (slope row from dem_gradient plus the unit vertical-bias
  column), innovation y_c = z_i - h(pos_c) - dA_c, S = H P_c H^T +
  R_MEAS, gain K = P_c H^T / S on the 3 by 3 centroid covariance of
  (east correction, north correction, vertical bias), then P_c = (I - K
  H) P_c. With the DEM rising toward +x, a centroid east of the truth
  gives a negative innovation y_c ~ H * delta and dr_c += K * y_c moves
  the correction back toward dr_true, contracting the state error epoch
  by epoch.

## Workflow

1. Build the DEM strip on the 25 m grid with build_dem_strip; the strip
   statistics (elevation min 266.69 m, max 568.98 m, mean 426.51 m;
   slope magnitude RMS 0.2220) confirm the stored grid carries the
   texture the correlation needs.
2. Convert the radar-altimeter profile to the measured terrain profile
   z_i = A_ins - c_i with clearance_profile_to_terrain (scalar INS
   barometric altitude broadcast, or a per-sample altitude sequence).
3. Run the TERCOM grid correlation with tercom_match over the INS
   indicated track (x_ind_i, y_ind_i): resample the stored DEM along
   each candidate-corrected track, correlate with the measured profile
   through profile_metrics, and read the correlation surface rows plus
   the best-match offset (dx_best, dy_best) that estimates the INS
   horizontal position error. The coarse correction is dr0 =
   (-dx_best, -dy_best).
4. Run the fine SITAN point-mass stage with sitan_point_mass from dr0
   over the epoch sample indices: the per-mass terrain-height likelihood
   updates concentrate the bank, the weighted centroid feeds the
   linearized terrain-slope measurement update each epoch, and the
   per-epoch states (dr, bias, per-axis 1-sigma, slope row, innovation,
   innovation variance, gain) plus the final correction and recovered
   vertical bias gate the map-aided navigation output.
5. Sanity-check the result against the deterministic checks (the
   corrected position p_ind + dr recovers the true track, the final
   horizontal error, the recovered vertical bias) and run the contract
   test.

## Worked example

Demo aircraft flying due east over the stored strip at north 600.0 m,
true track east 5000.0..9800.0 m, sample spacing 200.0 m, 25 samples.
True altitude 700.0 m; the INS indicates 715.0 m (hidden vertical error
15.0 m). INS horizontal error e = (450.0, 180.0) m, so the indicated
track runs 450 m east and 180 m north of the truth. Radar clearance
carries the deterministic mismatch above (RMS 1.451 m). All values are
REAL outputs of this module, matching the wave-44 spec anchors.

- Measured terrain profile z_0..z_24 (m): 410.435, 369.515, 344.917,
  338.265, 347.204, 366.645, 390.313, 413.563, 433.052, 443.588,
  439.197, 419.466, 392.686, 371.474, 366.708, 384.193, 421.515,
  465.407, 495.856, 497.128, 466.217, 412.889, 355.267, 314.301,
  305.266.
- TERCOM stage over the INS indicated track, candidate grid dx -800..
  800 m step 100 (17 values), dy -400..400 m step 100 (9 values): best
  match r = 0.989515 at offset (dx = 400 m, dy = 200 m) with MSD at the
  best 206.4905 m^2 (the 15 m bias dominates the MSD floor). TERCOM
  estimates the INS error at (400, 200) against the truth (450, 180);
  the peak sits one grid step from the truth because the true error is
  off the 100 m candidate grid. Coarse residual after applying dr0 =
  (-400, -200): 50.0 m east, -20.0 m north, horizontal 53.85 m.
- Identity arm (clean profile, no bias, no mismatch, INS error exactly
  on the grid at (500, 200)): argmax (500, 200), r = 1.000000000, MSD
  0.000000 m^2. Bias-immunity arm (15 m bias stripped from the profile):
  argmax stays (400, 200) (identical = True) and the MSD at the best
  drops to 61.7653 m^2.
- SITAN fine stage from dr0 = (-400.0, -200.0), 169 hypotheses at 10 m
  spacing (coverage dr0 +/- 60 m), replaying samples 6..17 (200 m of
  travel per epoch). Epoch-0 slope-update detail (sample 6): centroid
  position east 6261.3 m, north 574.6 m, DEM height 383.482 m, slope
  row H = [0.1232, -0.0742, 1.00], innovation y = 0.8256 m, innovation
  variance S = 2.6432 m^2, gain K = (0.4334, -1.8335, -0.0406).
- Final SITAN correction: east -447.084 m, north -188.925 m (truth
  -450.000, -180.000 m); final error 2.916 m east, -8.925 m north,
  horizontal 9.389 m. Final vertical-bias estimate 13.487 m (truth 15.0
  m). Fine-stage reduction: coarse horizontal residual 53.85 m to 9.389
  m, factor 5.7.

Read-off: with a 1.45 m-RMS radar-altimeter and DEM mismatch on a
267-569 m rolling strip, a 100 m TERCOM grid resolves a 450/180 m INS
error to one grid step (r = 0.9895, 53.85 m residual), and the SITAN
point-mass fine stage with the linearized terrain-slope update pulls the
correction to about 3-9 m per axis (per-axis 1-sigma about 5 m) while
recovering 13.5 m of the 15 m vertical error, all from radar clearance
against the stored grid, with no GNSS in the loop.

## Verification

- Confirm build_dem_strip statistics (min 266.69, max 568.98, mean
  426.51 m within 0.1 m; slope magnitude RMS 0.2220 within 0.001).
- Confirm tercom_match on the worked set returns the argmax at exactly
  (400, 200) with r = 0.989515 (within 1e-4) and MSD at the best
  206.4905 m^2 (within 1.0 m^2); the clean-profile identity returns
  (500, 200) with r = 1.0 (within 1e-9) and MSD 0.0 (within 1e-6); the
  bias-immunity arm keeps (400, 200) with MSD 61.7653 m^2 (within 1.0).
- Confirm the five correlation-surface rows (dy 0..400 m over dx 100..
  700 m) match the anchors within 5e-4 per cell.
- Confirm sitan_point_mass from dr0 = (-400.0, -200.0) over samples
  6..17 gives the epoch-0 slope row H = [0.1232, -0.0742, 1.00] within
  1e-3, innovation 0.8256 m within 0.01, innovation variance 2.6432 m^2
  within 0.01, gain (0.4334, -1.8335, -0.0406) within 0.005, the final
  correction (-447.084, -188.925) within 0.5 m per axis (final
  horizontal error 9.389 m within 1.0 m) and the final bias 13.487 m
  within 0.5 m, with the per-epoch 1-sigma sequence within 1.0 m of the
  anchor values.
- Confirm the correction convention: p_corrected = p_ind + dr recovers
  the true track to within the final error, and dr_true = -e.
- Confirm ValueError rejection of non-physical inputs across the module
  (off-strip and non-finite sampling, malformed grid, empty profiles
  and candidate grids, length mismatches, empty epoch indices,
  non-positive spacing / r_meas / p0_bias, negative half / q_bias).
- Run the contract test offline: python3
  scripts/test_terrain_referenced_navigation.py (deterministic, exits
  0). The same test passes under the pre-push hook interpreter
  (~/.pyenv/versions/3.13.12/bin/python3).

## Related leaves

- gnc-autonomy/navigation/inertial-navigation: the INS error growth this
  leaf consumes as a given, unaided input.
- gnc-autonomy/navigation/tightly-coupled-ins-gnss: the raw-GNSS
  measurement update alternative when GNSS observables exist.
- gnc-autonomy/navigation/ins-gnss-integrated-filter: the GNSS
  position-update alternative for aided inertial filtering.
- gnc-autonomy/navigation/gnss-pseudorange-positioning: the GNSS
  position solution this leaf replaces when no GNSS is available.
- gnc-autonomy/navigation/kalman-filter-design: the single-state Kalman
  recursion reused per mass in the point-mass bank.
- gnc-autonomy/navigation/navigation-frames: frame conventions for the
  east/north grid coordinates.
- gnc-autonomy/guidance/coverage-path-planning: survey path planning
  over (assumed flat) terrain, the planning-side neighbor.

## Contract test

Run the deterministic contract test (stdlib unittest, offline):

    python3 scripts/test_terrain_referenced_navigation.py

The test covers the DEM strip statistics, node-exact bilinear
reconstruction, the radar-altimeter to terrain conversion, the TERCOM
worked match and its correlation-surface rows, the exact-match and
bias-immunity identities, the SITAN epoch-0 slope-update detail, the
final correction and vertical-bias recovery, the per-epoch 1-sigma
sequence, the correction-convention round trip, determinism, and
ValueError rejection of every non-physical input class listed above.

## Pitfalls

- Feeding the INS indicated altitude error into the DEM correlation: the
  measured profile z_i = A_ins - c_i carries the constant vertical bias
  dA, but mean-centered Pearson r is immune to it, so the TERCOM argmax
  is unbiased; the bias square only lifts the MSD floor (206.4905 m^2
  with the 15 m bias versus 61.7653 m^2 without). Never let MSD select
  the offset, and never strip a bias you cannot prove is constant over
  the window.
- Trusting the coarse TERCOM offset as the final correction: the true
  INS error (450, 180) m is off the 100 m candidate grid, so the argmax
  lands one grid step away at (400, 200) and leaves a 53.85 m residual.
  The fine SITAN stage exists exactly to remove that residual; report
  the coarse offset only as dr0 for the point-mass bank.
- Treating the terrain height as a direct measurement of position: the
  height residual only constrains position through the local slope row
  H = [dh/dx, dh/dy, 1.0], so epochs over flat strip regions (slope near
  zero) barely move the east/north correction. The bank coverage (dr0
  +/- 60 m at 10 m spacing) must always cover the residual from the
  coarse stage or the filter locks onto a false height match.
- Confusing the sign conventions: dr is ADDED to the INS indicated
  position (p_corrected = p_ind + dr, dr_true = -e), while the TERCOM
  offset (dx_best, dy_best) is the estimated INS error e in
  indicated-minus-true form. The coarse correction is dr0 =
  (-dx_best, -dy_best); a sign slip here doubles the error instead of
  removing it.
- Forgetting the vertical bias state: without the per-mass dA_k Kalman
  state and the unit vertical-bias column in H, the constant INS
  altitude error aliases directly into the height innovations and biases
  the position estimate by the slope-to-bias ratio; the recovered bias
  (13.487 m of the 15.0 m truth in the worked example) is a required
  output, not a side effect.
- Sampling the analytic terrain instead of the stored grid: the DEM is a
  given stored strip and the correlator reads only the stored grid
  through bilinear dem_height / centered-difference dem_gradient. The
  analytic TERMS formula exists only inside the strip builder.
- This leaf does NOT do: raw-pseudorange or carrier measurement updates
  with clock states (tightly-coupled-ins-gnss), GNSS position-fix
  updates or the psi-angle error model (ins-gnss-integrated-filter),
  GNSS doppler velocity fixes (gnss-doppler-velocity-positioning),
  code-carrier smoothing (gnss-carrier-smoothing), RAIM fault detection
  (gnss-raim-fde), DOP geometry (dilution-of-precision), INS error
  growth, mechanization, Schuler tuning, alignment or INS/GPS
  integration scoping (inertial-navigation), angle-only bearing
  localization (bearing-only-localization), frame-conversion utilities
  (navigation-frames), single-state Kalman design rules
  (kalman-filter-design), boustrophedon survey swath planning
  (coverage-path-planning), or terrain-following/terrain-avoidance
  guidance. No DEM creation, georeferencing or terrain database
  management: the strip is a given input.

## Behavior contract (gate 3)

Run the deterministic contract test (stdlib unittest, offline):

    python3 scripts/test_terrain_referenced_navigation.py

The test exercises the full workflow of this SKILL.md: building the DEM
strip (step 1), converting the radar-altimeter profile (step 2), the
TERCOM grid correlation surface and best-match offset over the INS
indicated track (step 3), and the SITAN point-mass fine stage with the
linearized terrain-slope measurement update, per-epoch states and
recovered vertical bias (step 4). It must pass under /usr/bin/python3
and under the pre-push hook interpreter; both exits 0, offline, in well
under 20 s.

## Compliance

- Standards referenced, not reproduced: arp4754a (SAE, proprietary-sold)
  frames the airborne-system development assurance context for the
  navigation function; name and paraphrase only, per standards-map.yaml.
- The TERCOM and SITAN relations above are standard published
  navigation methodology (terrain contour matching and point-mass
  terrain-referenced navigation), summary-only.
- compliance: STANDARDS-REF, gated: false.

