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.
# 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
"""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 |
# 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_boundor 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.
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.
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.
# Related
- Fitting Catenary Curves to Conductor Points — the curves densified here
- Power Line Detection in LiDAR Point Clouds — the full detection workflow
- Detecting Power Line Conductors with Linearity — finding the conductor points
- Individual Tree Segmentation — attributing sites to individual trees
- Canopy Height Models — a raster view of vegetation under the corridor