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.

Exclusion buffers and safe corridor A site polygon with buffered standing structures and a heritage-restriction zone subtracted, leaving a safe flight corridor. Deriving the safe corridor site area tower clearance buffer restriction zone Corridor = site minus (structure buffers ∪ restriction zones)

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.

Field note. Scheduled-monument and restriction boundaries are frequently supplied in a national grid while the drone's own geofence expects WGS84, and the two can differ by several metres if the transformation is skipped. Reproject restriction overlays into the planning CRS before any buffering or subtraction, and export the geofence back to EPSG:4326 only at the very end — a corridor computed in mixed CRSs will look correct on screen yet leave the aircraft legally inside a protected zone.

Common Errors & Fixes

  • ValueError: Exclusions consume the whole site; revise margins — combined buffers and restrictions cover everything flyable. Lower horizontal_margin_m, confirm structure height_m values 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.xml sidecar. Pass the raster itself and confirm it covers the route with rasterio.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.

Part of Photogrammetry & 3D Site Mapping Pipelines.