Where the line meets the hill
The checker said the original line enters the terrain 842 m into its second leg, 2.15 km along the path. In SITL the vehicle made contact at 2.14 km.
A checker and planner for drone missions over hills. For every leg it proves a lower bound on the clearance above the terrain, counting the error in the elevation data, in the vehicle's altitude and in how far off the line it may fly. A leg it can't prove is refused, and plan raises the mission until every leg is proven.
Ground stations give mission altitudes relative to home by default, so "60 m" means 60 m above the take-off point. Where the ground rises along the way, that is a line drawn into the hillside, and a multicopter without a rangefinder flies it there. Checking the terrain under the waypoints, or every so many metres along the line, can miss the high point between samples, the ridge beside the line, and the few metres the elevation data itself is wrong by.
Plays the seven stages below in order, about 60 seconds. Scroll or press Esc to stop.
pytest, on Python 3.11, 3.12 and 3.13.plan files, or CSVReal runs of terrain-follow 0.1.0, output verbatim. delivery.plan is the repo's example: a 3.7 km delivery from PX4's default simulator home in Zurich, at 60 m above home. The elevation data is the repo's clip of the Copernicus GLO-30 DEM. terrain-frame.plan in the last tab is Illustrative input: the same mission with one waypoint switched to QGroundControl's terrain frame.
A mission file is a list of items, and the checker needs the path the vehicle actually flies through them. terrain-follow reads QGroundControl .plan files and a small CSV, and models what each item means to a PX4 multicopter.
--home-alt, or the DEM at home. Items that don't move the vehicle, like a speed change, pass through.A leg it can't model is a leg it can't prove. It names the item and the reason instead of guessing.
Six missions, read as legs or refused
delivery.plan is the repo's example. The other five are Illustrative input, written for this page as small edits of it. The item list is a summary of each file; the output is what terrain-follow check printed, with its three header lines left out where there is a table.Between elevation posts, the terrain is the bilinear interpolation of the four posts around it. A bilinear patch is linear along each axis, so over a rectangle inside one cell its maximum is at one of the rectangle's corners. Clip the corridor's box to every cell it touches, evaluate the corners, and the largest is the exact maximum of the surface over the box.
--horizontal-error (10 m) of the piece. Metres become degrees with the WGS84 radii at that latitude, rounded up by 0.1% so a corridor never shrinks.--dem-error above the surface: 4 m is the Copernicus GLO-30 specification at LE90.One 5 m piece of the refused leg
data/zurich-cop30.asc, 21 m apart east to west and 31 m north to south, around piece 461 of 487 on leg 3 to 4, 2.3 km into the leg. The box, the corners and their values come from the repo's own DEM class, run in a scratch script. The box holds no post at all, so the nearest-post mutant falls back to the closest one.Each leg is cut into pieces no longer than 5 m. On a piece the planned altitude is at least the lower of its two ends, the vehicle may be 5 m below that, and the ground may be 4 m above the surface's maximum over the corridor. The leg's proven clearance is the smallest of these over its pieces.
clearance >= min(altitude at the ends) - max(DEM over the box) - dem_error - alt_error
The delivery as planned, 60 m above home
check(keep_profile=True) on delivery.plan, the profile the CLI draws with terrain-follow profile, taken every 10 m. Proven and centre-line figures are the CLI's.A sound bound is never above the true minimum clearance. terrain-follow mutants makes random cases, a small rough DEM with hills and noise, a leg over it and a random budget, and compares the bound with a brute-force search that samples the corridor every metre along and on a grid across. Sampling can only miss low points, so a correct checker is never above it.
pytest runs the property on 150 fixed seeds, and under Hypothesis with any piece length from 0.5 to 20 m. CI also runs 1,000 seeds on every push.Cases where each variant claims more clearance than there is
terrain-follow mutants --seeds 1000, the command CI runs, output read straight off the table: overstated cases out of 1,000, and the first seed that caught each one.This is the line. A mission goes on only if every leg is proven to clear the terrain by the required margin. Anything short of that exits 1 and names the leg. The budget is a set of flags, so whoever flies the mission states it.
The bound holds for the DEM plus the errors you state. A mast, a crane or trees that grew since 2015 aren't in the DEM, and no margin fixes that.
The planned mission under four budgets
terrain-follow check examples/delivery-planned.plan --dem data/zurich-cop30.asc with each flag, header lines left out; the exit code is the shell's. The first is the default budget.ClearedAbove this line terrain-follow reads the mission and proves each leg against the terrain. Below it, only a mission whose every leg is proven goes on, and it is flown.
terrain-follow plan keeps the horizontal path exactly as planned. Only altitudes change, and only upwards. The result is written with AMSL altitudes and checked again, leg by leg, by the same checker.
--slack of extra altitude. A landing gets a waypoint straight above it.The extra piece length is what makes the final check pass by construction: any piece the checker cuts lies inside the widened box of every planner piece it touches. The check still runs.
Planned, at three settings of --slack
.plan files terrain-follow plan wrote with each setting; 15 m gives the repo's examples/delivery-planned.plan byte for byte. The dashed line is the corridor maximum plus 39 m, as the CLI's profile draws it.Both missions are flown in PX4 v1.15.4 software-in-the-loop with Gazebo Classic, over a world built from the same DEM. Every post is a vertex of the ground mesh, placed with the projection Gazebo's GPS simulator uses, so the vehicle's GPS reports a post's own coordinates over it. PX4 flies each .plan as uploaded.
sitl/analyze.py.Two flights, measured from telemetry
What the checker predicted, set against both missions flown in PX4 SITL on this machine, and what the checks cost. The README's own run, from the same commit, gave the same contact point and the same lowest clearance.
The checker said the original line enters the terrain 842 m into its second leg, 2.15 km along the path. In SITL the vehicle made contact at 2.14 km.
The planned mission was proven to keep at least 30.1 m. Flown, its lowest clearance over the DEM was higher, because SITL spends little of the 9 m error budget.
The simulated ground is the DEM, so the DEM error is zero, and PX4 stayed within 2.21 m of the line sideways and 0.36 m below it.
On this machine with Python 3.12, while other jobs kept it busy:
The README says about 10 s and 11.8 s. Each seed checks the bound and six mutants against a brute-force search of the corridor.
Counted with pytest -q: 237 passed on Python 3.11, 3.12 and 3.13. 190 of them are two properties run over many inputs: soundness on 150 seeds, and the exact box maximum on 40 random grids.
.plan round trips.CI also runs ruff, mypy, 1,000 mutant seeds, a check that the planned example and the README profile match what the code makes, and the SITL flights. Run 38665143703 on cd490b4 passed every job; the SITL job took 13 minutes.
--alt-error.git clone https://github.com/ahmed-hashim-pro/terrain-follow && cd terrain-follow
python3 -m venv .venv && . .venv/bin/activate
pip install -e '.[dev]'
terrain-follow check examples/delivery.plan --dem data/zurich-cop30.asc
terrain-follow plan examples/delivery.plan --dem data/zurich-cop30.asc -o safe.plan
terrain-follow check safe.plan --dem data/zurich-cop30.asc
terrain-follow mutants --seeds 300
# The suite, then the SITL flights (Docker, about 5 GB).
pytest -q
make sitl
Python 3.11 or newer and nothing else at run time. Any DEM in geographic coordinates that GDAL can read works, clipped to an ESRI ASCII grid.