Measuring Vegetation Clearance to Power Lines

TL;DR: Densify each span’s fitted catenary to a point every 0.2 m, build a scipy.spatial.cKDTree on those conductor samples, query every vegetation point (classes 3–5) for its nearest conductor distance, and flag points inside your utility’s clearance distance — then cluster flagged points into encroachment sites with a span ID, minimum distance and location for the field crew.

# Context and Motivation

This guide is part of Power Line Detection in LiDAR Point Clouds. Vegetation clearance is the product that justifies most utility LiDAR flights: regulators and insurers require utilities to keep trees out of a clearance zone around conductors, and walking or driving every kilometre of line is slow and misses what cannot be seen from the ground. LiDAR measures the three-dimensional distance from every branch to every wire across the whole network in one pass.

Two details separate a trustworthy clearance product from a misleading one. First, distance must be measured to the wire, not to the sparse wire returns — a branch can sit between two returns 1.5 m apart and appear farther from the conductor than it is. Densifying the fitted catenary fixes that. Second, the output must be sites, not points: a field crew needs one record per tree with a location and the worst distance, not ten thousand flagged returns.

The clearance zone in cross-section A cross-section looking along a power line. The conductor is a dot at the centre of a circular clearance zone of fixed radius. Tree crowns rise from below on both sides. Vegetation points inside the circle are highlighted as encroachments; points outside are ordinary vegetation. The shortest distance from the nearest encroaching point to the conductor is drawn and labelled. conductor 3.1 m < 4.0 m limit dashed circle: clearance distance distance is 3D — a branch beside the wire counts as much as one below it

# Prerequisites and Assumptions

  • A classified tile with conductors in class 14 and vegetation in classes 3, 4 and 5.
  • Catenary fits per span from fitting catenary curves to conductor points, or at least span groups of conductor points.
  • Python with NumPy, SciPy, pandas and GeoPandas; PDAL bindings to read the tile.
  • The clearance distance your utility or regulator specifies. It varies with voltage, jurisdiction and conductor temperature; this guide uses 4.0 m as a placeholder, not a recommendation.

# Step-by-Step Implementation

# Step 1 — Densify conductors

Evaluate each fitted catenary every 0.2 m along its span and map (s, z) back to (x, y, z) using the span’s axis. If you have no fits, use the conductor points directly but accept that distances between returns are overestimated.

# Step 2 — Load vegetation near the corridor

Read classes 3–5, cropped to the corridor buffer if one is known. Points far from any conductor cannot violate clearance and only cost query time.

# Step 3 — Query nearest conductor distances

Build a cKDTree on the densified conductor samples and query each vegetation point with distance_upper_bound set a little above the clearance distance, which makes the query much faster because distant points return early.

# Step 4 — Group violations into sites

Cluster flagged points in plan view with DBSCAN at a 2 m radius. Each cluster is one encroachment site — typically one tree or one hedge section.

# Step 5 — Export for the field

Write one row per site with the span ID, the minimum distance, the number of points, the site centroid and a small polygon, to a GeoPackage the crew can load on a tablet.

# Complete Working Example

python
"""Vegetation clearance to fitted conductors, grouped into encroachment sites."""
from __future__ import annotations

import json
from pathlib import Path

import geopandas as gpd
import numpy as np
import pandas as pd
import pdal
from scipy.spatial import cKDTree
from shapely.geometry import MultiPoint
from sklearn.cluster import DBSCAN

CLEARANCE_M = 4.0          # replace with your utility's specified distance


def densify(fits: pd.DataFrame, step: float = 0.2) -> tuple[np.ndarray, np.ndarray]:
    """fits: one row per span with a, s0, z0, cx, cy, ux, uy, s_min, s_max."""
    xyz, span_of = [], []
    for f in fits.itertuples():
        s = np.arange(f.s_min, f.s_max + step, step)
        z = f.z0 + f.a * (np.cosh((s - f.s0) / f.a) - 1.0)
        x = f.cx + s * f.ux
        y = f.cy + s * f.uy
        xyz.append(np.column_stack([x, y, z]))
        span_of.append(np.full(len(s), f.span))
    return np.vstack(xyz), np.concatenate(span_of)


def vegetation(src: Path) -> np.ndarray:
    p = pdal.Pipeline(json.dumps({"pipeline": [
        {"type": "readers.las", "filename": str(src)},
        {"type": "filters.range", "limits": "Classification[3:5]"},
    ]}))
    p.execute()
    return p.arrays[0]


def clearance_sites(src: Path, fits: pd.DataFrame, crs: str) -> gpd.GeoDataFrame:
    wire_xyz, wire_span = densify(fits)
    tree = cKDTree(wire_xyz)
    veg = vegetation(src)
    pts = np.column_stack([veg["X"], veg["Y"], veg["Z"]])
    dist, idx = tree.query(pts, k=1, distance_upper_bound=CLEARANCE_M + 1.0)
    bad = dist < CLEARANCE_M
    if not bad.any():
        return gpd.GeoDataFrame(columns=["span", "min_dist_m", "points", "geometry"], crs=crs)

    bad_pts, bad_dist, bad_span = pts[bad], dist[bad], wire_span[idx[bad]]
    labels = DBSCAN(eps=2.0, min_samples=3).fit_predict(bad_pts[:, :2])
    rows = []
    for lab in np.unique(labels):
        sel = labels == lab
        hull = MultiPoint(bad_pts[sel, :2]).convex_hull.buffer(0.5)
        rows.append({
            "site": int(lab),
            "span": int(np.bincount(bad_span[sel]).argmax()),
            "min_dist_m": round(float(bad_dist[sel].min()), 2),
            "points": int(sel.sum()),
            "max_height_m": round(float(bad_pts[sel, 2].max()), 2),
            "geometry": hull,
        })
    sites = gpd.GeoDataFrame(rows, crs=crs).sort_values("min_dist_m")
    return sites


if __name__ == "__main__":
    fits = pd.read_parquet("corridor_0082_fits.parquet")
    sites = clearance_sites(Path("corridor_0082_wire.laz"), fits, "EPSG:6340")
    sites.to_file("corridor_0082_clearance.gpkg", layer="encroachments", driver="GPKG")
    print(sites.head(10).drop(columns="geometry").to_string(index=False))

DBSCAN labels isolated flagged points as -1; they are kept as their own site here because a single branch tip inside the zone is still a violation. If your specification allows ignoring isolated returns, drop label -1 instead.

# Key Parameter Table

Parameter Type Default Guidance
CLEARANCE_M float, m 4.0 (placeholder) Set from your utility’s specification for the voltage class
densify step float, m 0.2 Smaller than typical branch size; 0.5 m is too coarse for close work
distance_upper_bound float, m clearance + 1 Speeds queries; larger lets you also report near misses
site DBSCAN eps float, m 2.0 Plan-view grouping radius; roughly one crown radius
site min_samples int 3 Lower keeps single-branch sites together
Why the wire must be densified A wire drawn as a continuous curve with sparse returns every 1.5 metres. A branch point sits below the wire between two returns. Measured to the nearest return, the distance is 4.3 metres and passes. Measured to the densified curve directly above it, the distance is 3.7 metres and fails a 4 metre limit. to nearest return: 4.3 m, passes to densified wire: 3.7 m, fails branch dots: LiDAR returns on the conductor · line: fitted catenary sampled every 0.2 m

# Verification

  • Known encroachments. If the utility has recent patrol records, check that every reported tree appears as a site. Missed sites usually mean the span was not detected or the vegetation was classified as something other than 3–5.
  • Distance distribution. Plot a histogram of distances for all vegetation within 10 m of a conductor. It should be smooth; a hard cut or spike at one value means distance_upper_bound or a crop is clipping the data.
  • Spot measurement. Pick two sites and measure the distance manually in a 3D viewer from the branch to the fitted curve.
python
assert (sites.min_dist_m >= 0).all() and (sites.min_dist_m < CLEARANCE_M).all()
assert sites.span.isin(fits.span).all(), "site attached to an unknown span"

# Gotchas and Edge Cases

Flight-time sag is not worst-case sag. Conductors sag more when hot or iced. A site that clears at flight conditions can violate at maximum operating temperature. Many utilities rescale sag to a design condition before measuring; the point cloud provides the geometry, the engineering model provides the rescaling.

The same span, hot and cold Two catenaries between the same attachment points. The upper solid curve is the conductor as flown on a cool morning, clearing a tree crown by 4.6 metres. The lower dashed curve is the same conductor rescaled to maximum operating temperature, sagging further and clearing the crown by only 2.9 metres, inside a 4 metre limit. flight: 4.6 m max temperature: 2.9 m solid: as flown dashed: design condition

Blowout. Wind swings conductors sideways. Some specifications define a horizontal clearance larger than the vertical one to allow for it. If so, compute horizontal and vertical components of the distance separately rather than a single 3D distance.

Misclassified vegetation. Points in class 1 near the wire are invisible to this workflow. Check the classification near conductors; a tree left unclassified is a missed violation.

Growth since the flight. A site at 4.2 m in spring may be at 3.5 m by late summer. Report near misses — within a metre of the limit — as a watch list, which is why the query bound extends beyond the limit.

# Frequently Asked Questions

Should clearance be measured in 3D or separately horizontally and vertically?

It depends on the specification. A single 3D distance is simplest and conservative for many cases, but some rules define different horizontal and vertical clearances. Compute both components from the nearest-point vector if your specification needs them.

Why densify the fitted curve instead of using conductor points?

Conductor returns are often more than a metre apart. A branch between two returns is closer to the wire than to either return, so distances to returns overestimate clearance. The densified curve represents the wire everywhere along the span.

How do I handle spans with no catenary fit?

Use the conductor points directly and flag those sites as lower confidence. Also report the span as unfitted so it can be re-flown or checked manually.

What point density is needed for clearance work?

Dense enough to capture both the wire and the outer branches of crowns near it — typically corridor surveys at 30 points per square metre or more. Sparse data underestimates how far crowns reach toward the wire.