Planning Safe Flight Corridors over Heritage Sites
A survey grid that is geometrically perfect is still unflyable if it routes an aircraft through the airspace of a standing tower, across a scheduled-monument buffer, or into a restricted zone. Heritage sites concentrate exactly the hazards that flight planning must design around: tall fragile masonry, visitors, protected boundaries, and airspace constraints that carry legal weight. This guide, part of Drone Flight Planning for Photogrammetric Survey, builds the exclusion geometry — structure buffers, minimum-clearance corridors, terrain-following above-ground-level profiles, and heritage-restriction overlays — and exports a geofence the ground station can enforce.
It arises after the grid exists. Once flight grid generation has produced a candidate route, that route must be reconciled against everything the aircraft must not enter before a single waypoint is uploaded. The output here is a corridor mask and a geofence polygon that clip the plan and constrain the airframe.
Context & When to Use
Build corridors whenever the survey overflies or approaches anything the aircraft could strike or must legally avoid — which at a heritage site is nearly always. The trade-offs are safety against coverage. A generous structure buffer guarantees clearance but leaves the base of a wall unmapped, forcing dedicated low oblique passes to fill the gap; a tight buffer maximises coverage but narrows the margin for GPS drift and gusts. Minimum clearance is not one number: it combines the structure height, a horizontal safety margin, and the aircraft’s own position uncertainty. Terrain-following adds the vertical dimension — over a terraced or sloping site a constant barometric altitude either buries the aircraft in the hillside or lets the GSD balloon, so the corridor must be defined in above-ground-level (AGL) terms sampled from a DEM. Pair this with the QA gates in the section overview so that any waypoint intruding on an exclusion zone blocks the plan automatically.
Implementation
The routine buffers each standing structure by a clearance derived from its height, unions those with any imported heritage-restriction polygons, subtracts the result from the site to yield the flyable corridor, clips a candidate grid to it, samples a DEM for the AGL profile along the route, and exports a geofence.
# requirements.txt
# shapely==2.0.4
# geopandas==1.0.1
# rasterio==1.3.9
# pyproj==3.6.1
import geopandas as gpd
import rasterio
from shapely.geometry import mapping
from shapely.ops import unary_union
def build_safe_corridor(site: gpd.GeoDataFrame,
structures: gpd.GeoDataFrame,
restrictions: gpd.GeoDataFrame,
horizontal_margin_m: float = 8.0,
target_epsg: int = 27700) -> gpd.GeoDataFrame:
"""
Subtract buffered structures and heritage restrictions from the site
to leave the flyable corridor. Structure buffer scales with height:
taller masonry needs a wider stand-off for GPS drift and rotor wash.
"""
site = site.to_crs(epsg=target_epsg) # substitute your site's EPSG
structures = structures.to_crs(epsg=target_epsg)
restrictions = restrictions.to_crs(epsg=target_epsg)
# Clearance = fixed horizontal margin + a fraction of structure height.
# 'height_m' is an attribute on each standing-structure feature.
buffered = []
for _, s in structures.iterrows():
h = float(s.get("height_m", 0.0))
clearance = horizontal_margin_m + 0.5 * h
buffered.append(s.geometry.buffer(clearance))
exclusion = unary_union(buffered + list(restrictions.geometry))
corridor = unary_union(site.geometry).difference(exclusion)
if corridor.is_empty:
raise ValueError("Exclusions consume the whole site; revise margins.")
return gpd.GeoDataFrame({"zone": ["corridor"]},
geometry=[corridor], crs=f"EPSG:{target_epsg}")
def clip_grid_to_corridor(grid: gpd.GeoDataFrame,
corridor: gpd.GeoDataFrame) -> gpd.GeoDataFrame:
"""Drop any waypoint that falls outside the safe corridor."""
poly = corridor.geometry.iloc[0]
kept = grid[grid.geometry.within(poly)].copy()
dropped = len(grid) - len(kept)
if dropped:
print(f"Removed {dropped} waypoints intruding on exclusion zones.")
kept["seq"] = range(len(kept)) # re-sequence after clipping
return kept
def agl_profile(grid_wgs: gpd.GeoDataFrame, dem_path: str,
flight_agl_m: float) -> gpd.GeoDataFrame:
"""
Sample terrain under each waypoint and set an absolute altitude that
holds a constant AGL. Keeps GSD stable over sloping heritage ground.
"""
with rasterio.open(dem_path) as dem:
# DEM is sampled in its own CRS; reproject waypoints to match.
pts = grid_wgs.to_crs(dem.crs)
coords = [(p.x, p.y) for p in pts.geometry]
ground = [v[0] for v in dem.sample(coords)]
grid_wgs = grid_wgs.copy()
grid_wgs["ground_z"] = ground
grid_wgs["abs_alt_m"] = [g + flight_agl_m for g in ground]
return grid_wgs
def export_geofence(corridor: gpd.GeoDataFrame, path: str):
"""Write the corridor as a geofence GeoJSON in WGS84 for upload."""
corridor.to_crs(epsg=4326).to_file(path, driver="GeoJSON")
if __name__ == "__main__":
site = gpd.read_file("site_boundary.geojson")
structures = gpd.read_file("standing_structures.geojson") # has height_m
restrictions = gpd.read_file("scheduled_monument_zones.geojson")
grid = gpd.read_file("hillfort_survey_grid.geojson")
corridor = build_safe_corridor(site, structures, restrictions)
safe_grid = clip_grid_to_corridor(grid.to_crs(epsg=27700), corridor)
profiled = agl_profile(safe_grid.to_crs(epsg=4326),
"site_dem.tif", flight_agl_m=22.0)
export_geofence(corridor, "hillfort_geofence.geojson")
profiled.to_file("hillfort_grid_agl.geojson", driver="GeoJSON")
The archaeological logic is in the clearance model and the AGL sampling. Buffer radius grows with structure height because a 12 m tower demands more stand-off than a knee-high wall footing, and the AGL profile is sampled from the DEM in the DEM’s own CRS so a datum mismatch cannot silently corrupt the terrain heights.
Verification
Confirm the corridor excludes every structure buffer and that no surviving waypoint sits inside a restriction zone:
corridor = build_safe_corridor(site, structures, restrictions)
poly = corridor.geometry.iloc[0]
# No structure clearance buffer should intersect the corridor interior.
for _, s in structures.to_crs(epsg=27700).iterrows():
buf = s.geometry.buffer(8.0 + 0.5 * float(s["height_m"]))
assert not poly.contains(buf.centroid), "structure inside corridor"
# Every kept waypoint is clear of all restriction zones.
restr = restrictions.to_crs(epsg=27700).union_all()
assert not safe_grid.geometry.intersects(restr).any(), "waypoint in restriction"
print("corridor clear:", round(poly.area, 1), "m2 flyable")
Expected output reports the flyable area in square metres with no assertion failure. If contains(buf.centroid) trips, a structure was not subtracted — usually because its height_m attribute was missing and the clearance collapsed to the horizontal margin alone.
Common Errors & Fixes
ValueError: Exclusions consume the whole site; revise margins— combined buffers and restrictions cover everything flyable. Lowerhorizontal_margin_m, confirm structureheight_mvalues are metres not centimetres, and check the restriction layer is not accidentally the whole-site boundary.rasterio.errors.RasterioIOError: ... not recognized as a supported file format— the DEM path is wrong or points at a.tfw/.aux.xmlsidecar. Pass the raster itself and confirm it covers the route withrasterio.open(dem_path).bounds.KeyError: 'height_m'— the structures layer lacks the height attribute the clearance model needs. Add it (even a conservative flat value beats none) before buffering, or the stand-off silently defaults to the horizontal margin.
Related
- Drone Flight Planning for Photogrammetric Survey — the section overview and its airspace QA gate.
- Automating flight grid generation with Python — produces the grid this guide clips to the corridor.
- Calculating image overlap and ground sample distance for site survey — the AGL that keeps GSD constant over terrain.
- Mesh generation & optimization for ruins — reconstructing the standing structures these corridors protect.