Terrain AGL Hold — terrain following for fixed wings (builds on #11438) - #11785
Terrain AGL Hold — terrain following for fixed wings (builds on #11438)#11785MartinovEm wants to merge 31 commits into
Conversation
4b9e4ef to
3b656e4
Compare
Adds a third sdcardVTable_t implementation for the SITL target that reads/writes 512-byte blocks in a disk-image file on the host (--sdcard=<image>), following the SPI/SDIO drivers contract (deferred completion callbacks from poll()). Enables USE_SDCARD and USE_TERRAIN for SITL, so asyncfatfs and the terrain tile reader run unchanged in the simulator. Without --sdcard the virtual FC behaves like one with an empty card slot.
…es and climb lookahead
Adds terrain_nav.{c,h}: a strict wrapper around the terrain module for
future navigation consumers. Success is always an explicit boolean and
values are written only on success - no sentinel, stale or substituted
value ever reaches a caller. Provides current AGL, terrain height at an
arbitrary location (scheduling the block load on cache miss), terrain
height at the GPS origin, and a bounded climb-lookahead that walks the
grid along a bearing, accumulates achievable climb and reports the worst
height deficit together with an explicit count of unavailable samples.
Built entirely on the terrain module's public utilities; no existing
files changed apart from the build list. Nothing in flight code calls
this API yet - flight behavior is unchanged.
FW 3D Cruise only, opt-in RC box, health-gated, all commands through the single altitude-target funnel. Engage captures current AGL (below minimum: slew-limited climb to it); data loss freezes the target with an OSD warning, resume after 3 s healthy; pitch stick pauses the hold and re-captures on release; directional lookahead climbs early for rising terrain within the global per-cycle block budget (cache - 2); nav_max_altitude always wins with TERRAIN VS MAX ALT / TERRAIN PULL UP warnings. New settings: terrain_nav_min_agl (60 m default), terrain_nav_lookahead (1000 m, 0=off). SITL tile cache set to 5 to mirror the small-cache board tier.
…ning The climb lookahead scans along the course over ground; when the heading estimate is invalid the cog can be stale, so the lookahead now switches itself off (same skip as the config and low-speed gates) and the pilot gets a new lowest-priority OSD warning - the ceiling and data-loss warnings always override it. The reactive hold keeps tracking unchanged (the same degraded mode as terrain_nav_lookahead = 0). No new settings.
…LIMB info + TERR flymode indicator
…mand step, no deadband-edge writer flap)
…capped lookahead horizon
…d threat only, PULL UP survives below-min captures, pass-at-minimum clear rule, TAWS callout texts Below the minimum the escape-test shortfall now counts only the real terrain-relative deficit (the altitude term is clamped out there; above the minimum it keeps acting as the honest cushion), so the automatic climb shows TERRAIN AUTO CLIMB TO MIN instead of a false TERRAIN AHEAD. A capture below the minimum no longer extinguishes an active PULL UP - the alarm clears only at/above the minimum, as always. TERRAIN AHEAD clears when the escape test passes with the aircraft at/above the minimum, both sustained 2 s; a returning threat re-fires the series (the spare-margin clear could never be met riding exactly at the floor). OSD texts become "TERRAIN AHEAD!" and "TERRAIN! PULL UP!".
…-min capture hands over to the auto-climb info A capture below the minimum (low engagement or a deep stick release) starts a clean alarm phase again: the automatic climb shows TERRAIN AUTO CLIMB TO MIN, and TERRAIN! PULL UP! returns only when the climb is failing (losing height against the best achieved), when the pilot pushes below the margin, or when the floor is breached after having been reached.
… cap-uncover fast clear
…annel + auto-climb info alternation
At a 30 m floor the alarm point (floor - 5 m) minus the worst-case map error budget (30 m on steep slopes) minus the tracking band leaves no real clearance. 50 m is the lowest floor where the worst-case stack still clears with margin. Default unchanged at 6000.
…format The terrain data layer now stores heights as a 10-bit packed heightOffset[] plus an int16 heightBase instead of a plain int16 height[x][y] grid. Read the four interpolation corners through getHeightOffsetByIndex() + heightBase, scaled by TERRAIN_HEIGHT_OFFSET_RESOLUTION_M (2 m), the same way terrain.c decodes a sample; the interpolation math is unchanged.
The blackbox log-number stat compiles on SITL only once USE_SDCARD is enabled (added for SITL SD-card support in this branch). On 64-bit hosts int32_t is not long, and clang on macOS errors on the format mismatch with warnings-as-errors. Cast the argument to match the format.
4e7ec2e to
bfa9a96
Compare
|
RAM / Flash usage vs. base branch — commit
See RAM/flash optimization guide for techniques to reduce usage. |
|
Test firmware build ready — commit Download firmware for PR #11785 247 targets built. Find your board's
|
|
ⓘ Qodo reviews are paused because the subscription is no longer active. Ask your workspace admin to reactivate the subscription to resume reviews. Manage billing |
PR Summary by QodoAdd Terrain AGL Hold (terrain-following Cruise) backed by SD terrain tiles
AI Description
Diagram
High-Level Assessment
Files changed (44)
|
Code Review by Qodo
1. Async seek mis-handled
|
…users Field-tester feedback: spell out the safety floor and the -5 m warning band with numbers, add a 'how to fly quiet' tip (capture higher = calm margin), explain TERRAIN AHEAD! as ending up below the floor (not necessarily impact), split the ladder into plain later-vs-now terms, and de-jargon the AUTO CLIMB / DATA LOST / LOOKAHEAD OFF rows.
… not distance-only A distance alone means different things at different speeds: 1000 m is nearly a minute of flight at 20 m/s but only ~18 s at 55 m/s. Spell out the 35 s ground-speed cap and its reasoning in the settings table, so the horizon is understood as time expressed through speed.
… bare minimum Recovering exactly to the floor delivered the aircraft onto the edge of the warning band (the alarm sits 5 m under the floor), so normal terrain breathing right after a recovery kept firing warnings. A below-floor capture now targets (floor + 10 m); captures at or above the floor are unchanged. Requested by field-testing feedback. Validated: unit hold-core 120/120; SITL .TER suite green incl. the 70 m capture checks (below-floor target exactly 70 m on 322/322 samples).
…ting description + pilot doc)
|
Update: below-floor recovery now levels off ~10 m above the floor (commits d08a95a + 37f5562). Field-testing feedback: recovering exactly to the floor parked the aircraft on the edge of the warning band (the band starts 5 m under the floor), so normal terrain breathing right after a recovery kept re-firing warnings. Now, when the mode has to recover you from below the floor — on engage below the floor, or when you release the stick while below the safety floor — it climbs to 10 m above the floor and holds there: with the default 60 m floor it levels off at 70 m. Captures at or above the floor are unchanged, and there are no new settings. The OSD message Verified: unit 120/120 · SITL suite green (below-floor target exactly 70.0 m on 322/322 samples) · X-Plane HITL — chart below. All holds below the floor in the chart are deliberate (stick held forward, nose down), to show the alarm ladder in that band is unchanged. Note: the field-campaign numbers in the description (every clear at 60.0–60.6 m) were flown with the previous exact-floor recovery; with this change a below-floor recovery settles at ~70 m instead.
|
…to terrain-nav-main
…gate (F7/H7 only, AT32 out)
…merged) into terrain-nav-main
… into terrain-nav-main # Conflicts: # src/main/io/osd.c
…review Three new generalizable rules from the terrain_nav layer review: - budget speculative cache reads against the shared cache's size (lookahead capped at (CACHE_SIZE - 3) * 540 m plus a time horizon) - consume an existing cache in place, never a private copy - keep a feature's whole static state in one caller-owned struct (the entire hold state machine is ~100 B of static RAM)
|
Hi MartinovEm, thanks for the PR — the Terrain AGL Hold looks like a really nice piece of work, and I really like how the whole state machine lives in one caller-owned struct. Nice work keep it to about 108 bytes RAM! While tracing through the code I had a few questions — I'd appreciate your take: Handover blend between RX frames. stickWishValid is set only when rocToAltMode == ROC_TO_ALT_CONSTANT (terrain_nav_hold.c), but the stock stick writer only runs on RX-new cycles, and the blend's own funnel writes set ROC_TO_ALT_TARGET. Between RX frames, does runBlend then chase 0 instead of the pilot's wish? It looks like the commanded climb rate could be attenuated (and sawtooth at the RX frame rate) for the blend's full ~1 s lifetime, and stickFullPull — and therefore the PULL UP vs TURN AWAY decision — would only be accurate on RX-new cycles. Am I reading that right, or does something refresh the wish between frames that I missed? GPS fix loss = hard disengage rather than freeze. A routine 1–2 s GPS drop resets the floor-alarm state (pullUpActive/pullUpRatchet/minAglReached), and re-engagement below the floor starts a fresh AUTO CLIMB phase. Since the freeze grace was designed exactly for short data gaps, I wondered whether short GPS-fix losses could ride the same freeze path — or is a fresh floor-alarm phase deliberately safer? Minor nits I noticed (no urgency): the bounds guard in terrainNavGetHeightAtLocation can't fire with the current spacing values; lookahead silently turns off below 3 m/s ground speed with no OSD notice (unlike the NO_HEADING case); terrainNavGetHomeTerrainHeight and the complete field are currently unused; and the escape-alarm persist timers run through the <500 ms usable-pause window. Anyway, great work overall — the freeze-on-data-loss design and the single altitude-target funnel are really clean. |
|
Hi @sensei-hacker — really glad you like it :) and thanks for the kind words! I'm especially happy the RAM part landed well with you — keeping the footprint minimal and not touching INAV's proven layers was the idea from day one. Handover blend — yep, you read it right. The stock stick writer only runs when a new RX frame arrives, while my blend runs every loop and overwrites the climb mode, so between RX frames it loses the pilot's wish and chases zero. In practice the blend just fades early and the stock path takes over — that's why the HITL flights felt fine — but the first second of a stick input is weaker htan it should be, and the full-pull flag is only right on RX cycles. Thanks for pointing that out, obviously I had missed it. I'll fix it and test it again before committing the fix. GPS loss: that one is deliberate. The freeze is meant for momentary gaps in the map data while the position is still trusted — a block not in the cache yet, a bad read — and it holds the last target without touchin the map. When the fix itself is gone I don't trust the AGL anymore, so the hold steps aside, cruise carries on as stock, and when the fix is back the hold re-engages by itself through the normal engage path (a fresh capture, with the auto-climb if that happens below the floor). That's why I preferred a clean known state over a resumed one — but if you'd rather have short fix losses (a second or two) ride through the freeze, I'll change it the way you recommend. The nits — all correct. the bounds guard can't fire with the current grid geometry, the home-height helper and the complete field are unused, and the lookahead goes quiet below 3 m/s without saying so. I'll take those out and show TERRAIN LOOKAHEAD OFF in the slow case too. The persist timers are wall-clock on purpose (a short pause counts toward the window), but I'm happy to freeze them during a pause if you prefer. |
… LOOKAHEAD OFF below 3 m/s, drop a dead bounds guard and the unused 'complete' field (review)
…S gating of our own The hold no longer reads the terrain task's products (terrainGetLastAMSL / terrainGetLastDistanceCm) or checks GPS itself. It takes the terrain height for its own position and for home straight from the data layer's cache lookup (getHeightAmslMeters), computes AGL = altitude above home minus the terrain rise above home (the same relative datum the data layer uses), and takes 'position' from the estimator, so it follows whatever INAV trusts for position. Health = terrain enabled, SD reader alive, usable position estimate, home height known; a lost position becomes 'no data' and rides the existing freeze path. Our own copy of the tile lookup is gone. The only touch to the data layer is a 2-line export of getHeightAmslMeters (static removed in terrain.c, prototype in terrain.h) - approved by error414 (Discord, 2026-08-27). Verified: unit hold-core 120/120; SITL .TER suite (engage decode 519/519, edge, stick, handover, hill, failsafe clean).
Whichever way you think, as long as it's purpose. |
…TH743v2) into terrain-nav-main
|
Hi @sensei-hacker, I did the changes we discussed. The handover blend now keeps the pilot's stick wish between RX frames instead of chasing zero, and I flew it HITL on an H7 last night — the stick takes over cleanly now. "TERRAIN LOOKAHEAD OFF" also shows below 3 m/s, and the dead bounds guard and the unused field are gone. The home-height helper stayed, because it turned out to be needed for the next part. On the GPS question I talked it through with error414 just to make some things clear to me. His view was that the hold shouldn't watch GPS at all — it should just ask his data layer for the terrain height under its own position and trust the data layer's health. And it makes sense, following the inav logic. So that is what it does now — if the position is lost, the hold simply sees "no usable terrain data" and goes through the existing freeze path (altitude frozen, "TERRAIN LOST - ALT FROZEN") instead of dropping out. His layer needed only one small export for that, which he agreed to. The persist timers I left wall-clock, on purpose, as we discussed. |
… the debug values set debug_mode = TERRAIN_NAV publishes the hold's inputs and outputs on the eight debug values (blackbox and the Configurator): terrain/AGL/engaged flags plus the estimator statuses, AGL, estimated altitude, commanded altitude target, held AGL, hold status, hold warning, navigation state. Reads cached module state only - no grid query - and stays dormant unless selected. Documented in the pilot doc.
The size-diff workflow stored one baseline per branch, clobbered on every push, so PRs were compared against the base branch's LATEST tip. When the base advances after a PR forks, the delta includes unrelated changes (PR iNavFlight#11785 showed a stale +9,788 B RAM figure that actually belonged to iNavFlight#11438; the PR's real marginal RAM was ~120 B). Publish per-commit baselines alongside the branch-tip pointer: - publish-baseline now stores size-baseline-<COMMIT_SHA> (primary) in addition to size-baseline-<BRANCH> (backward-compat latest-tip pointer), and prunes per-commit baselines to the newest 50 per branch (plus a global cap) so iNavFlight/pr-test-builds can't grow unbounded. Per-commit releases carry a machine-readable branch marker in their notes so pruning can group them. Resolve the PR's true base commit on the comparison side: - pr-comment computes the merge-base of PR head and base ref via the compare API (verified to resolve fork-PR head SHAs), fetches the exact per-commit baseline, and falls back to the nearest ancestor commit that has one by walking the base branch's first-parent chain. The branch-tip baseline is deliberately NOT a fallback — comparing against it is the stale-delta bug this fixes. All new SHA/ref inputs are regex-validated before use in shell commands or release tags. - The comment header now names the baseline commit used ("vs. base commit `abcd123`"), with a note when a nearest-available baseline was used; the graceful "no size baseline available" path is unchanged. Also fixes two pre-existing size-diff-comment tests whose notable-delta expectations predated NOISE_THRESHOLD_BYTES being raised to 256.

As an FPV pilot flying a field with hills around it, I kept coming back to the same worry: plain Cruise holds a fixed altitude — fine over flat ground, but around those hills it can quietly become a problem, since the aircraft holds its height above home, not above the ground rising ahead of it. The terrain data is already on the SD card — so I decided to work on a Cruise that follows it.
Terrain AGL Hold is basically CRUZ with a moving target — in 3D Cruise, one new mode box makes the altitude target follow the terrain data under the aircraft: you hold height above the ground, not above home. Downhill the plane descends with the valley, uphill it climbs with the ridge. Box off — instant stock CRUZ.
▶ Video — Flights 1–2: floor work and turns over the flat field (AtomRC Beluga, SpeedyBee F405 Wing, real SD terrain tiles).
▶ Video — Flight 3: crossing a ridge that rises ~150 m above the takeoff field — three passes, hands off the pitch stick, no warnings needed.
What it does
One navigation cycle — inputs, health gate, the moving target, the alarm ladder, and the single gate into the altitude target path (the stock controller, untouched).
TERRAIN AGL HOLD— fixed wing only, active only inside 3D Cruise (NAV COURSE HOLD + NAV ALTHOLD). It is a modifier: everything else stays stock Cruise.TERRAIN AUTO CLIMB TO MIN). Why 50 m is the lowest the firmware will accept: the floor has to absorb everything going wrong at once — worst-case map error on steep slopes (~±30 m; typical source agreement is ~6 m, measured against ICESat-2 and radar-altimeter data), the aircraft's natural ±5–8 m tracking breathing, and the alarm margin (the floor alarm fires 5 m below the floor). At a 50 m floor that worst-case stack still clears the ground with a few meters in hand.TERRAIN AHEAD!tens of seconds before any reactive alarm could fire.TERRAIN AUTO CLIMB TO MIN):TERRAIN NOT READYTERRAIN AUTO CLIMB TO MINTERRAIN AHEAD!TERRAIN! PULL UP!(blinks)TERRAIN! TURN AWAY!(blinks)TERRAIN VS MAX ALTnav_max_altitudeceiling allowsTERRAIN LOST - ALT FROZENTERRAIN LOOKAHEAD OFFSafety design
getHeightAmslMeters, agreed with error414 — this layer only reads its API. Every height comes from the data layer's EXISTING tile cache; it adds no tile buffers or storage of its own — the whole layer costs ~100 bytes of RAM, and staying that small was a design constraint from day one, not an accident.nav_max_altitudeclamp downstream. No PID or controller code is modified anywhere.nav_fw_auto_climb_rate— no separate terrain climb setting. Set it to what your aircraft can genuinely sustain: the terrain escape warning trusts that number.NB: set
nav_fw_manual_climb_rateequal tonav_fw_auto_climb_rate(they default to 300/500). Matched, an auto-climb stays smooth if you touch the pitch stick — and pulling won’t climb any faster, since the auto-climb is already at that rate. Left unmatched, you may see a small, harmless nose-ease.TERRAIN LOST - ALT FROZEN(it will not descend blindly), 3 s of healthy data resumes. Proven closed-loop on a deliberately corrupted card.Why a separate mode, and not SURFACE
A fair question: INAV already has "terrain following" — SURFACE mode. Why not extend it? Because under the same family name, the two do different jobs with different physics:
A pilot carrying SURFACE expectations into a map-based mode (or vice versa) would be carrying the wrong safety assumptions — that is exactly what a distinct name prevents. The two never stack: this mode refuses to engage while SURFACE is active. Converging the pilot-facing "terrain following" concept over both sources someday — happy to discuss (see the open question below); the safety rules stay per-source either way.
New settings (only two)
terrain_nav_min_aglterrain_nav_lookaheadHow to test (bench + first flight)
maintenance-10.x(still versioned 9.1.0 there) with the terrain data layer merged (10-bit.TERtiles; terrain is enabled by default only on targets with more than 256 KB RAM — F7 (F745/F746/F765) and H7). Enable the data layer:set terrain_enabled = ON(it defaults OFF), with.TERterrain tiles on the SD card — you can generate them for your area with this map generator (.TER (INAV Terrain)is its default output); the Configurator's built-in Map Generator tab switches to.TERin feat(map-generator): switch terrain output to .TER version 50 (10-bit packed) inav-configurator#2708.The two
terrain_nav_*settings are CLI-only (simple enough that a Configurator page isn't planned). Defaults are sane; for a first test you can skip bothsetlines entirely.OSD: the stock Altitude element shows height above your home point, not above the ground. To see the terrain height above ground (AGL) in flight, add the Rangefinder OSD element — with no rangefinder fitted, it shows the terrain-derived AGL. The flight-mode field shows TERR while the hold is engaged.
3. Important: set
nav_fw_manual_climb_rateequal tonav_fw_auto_climb_rate(defaults are 300/500 — unequal). If manual < auto, grabbing pitch during an auto-climb commands the lower rate and the nose visibly eases.4. Ground check outdoors: GPS fix ·
sd_info= Ready · the OSD height-above-ground element reads ≈ 0 on the ground.5. First flight: fly 3D Cruise as usual, get comfortably HIGH over FLAT ground, then flip TERRAIN AGL HOLD on — watch it hold height above the ground; flip it off and on; grab the pitch stick (it yields, release re-captures). Only when that is boring, go lower or toward terrain — and never dive at a hill on purpose; the message table above says what each warning wants from you. Panic rule: box off = stock cruise instantly; the pitch stick always wins.
Verification — honest levels
Flight 2: floor work, the push-dive ladders, the bank-vs-sag curve.
The SD timing proof: nav never blocks, the data layer’s lock does its job.
The campaign-wide alarm discipline: every fire below the floor, every clear at 60.
Tested on: SPI-SD F405 Wing + SITL — the flight evidence below was flown on the earlier int16/9.1 base; on the current rebased base the layer is verified by clean build, unit tests 120/120 and SITL (incl. the firmware AGL matching an independent int16 reference 519/519 on
.TERtiles). Not yet measured on SDIO-SD boards (H7 class) — the design is driver-independent (cache-only reads, async IO, health gating), but "at least as good on SDIO" is reasoning, not measurement. SDIO testers very welcome.Integration notes
maintenance-10.xon 2026-08-26; this PR now carries only theterrain_navlayer.ec46b22f9(direct-read cache, 10-bit.TERtiles, 2 m steps, terrain gate by MCU RAM size) and withmaintenance-10.xafter the merge. Re-verified there: clean build, unit 120/120, SITL scenarios incl. the engage decode check (519/519 vs an independent int16 reference). Hardware/field evidence on this base is still pending — F4 no longer builds terrain by default, so it will come from an H7.maintenance-10.xholds boxId 60 / permanentId 69 for AUTO SPEED; this PR's box is boxId 61 / permanentId 70.Roadmap (after this proves itself)
Terrain-relative waypoints · terrain-aware RTH · geozone awareness — deliberately later, on top of this foundation.
Credits: error414's terrain data layer made all of this possible — this PR only reads what #11438 provides. He saw the feature before anyone else and has been testing it since.