Fitting Catenary Curves to Conductor Points
TL;DR: For each span, find the horizontal axis with an SVD of the plan coordinates, project points to a distance s along it, fit z = z0 + a·(cosh((s − s0)/a) − 1) with scipy.optimize.least_squares using a soft-L1 loss, then report the low point (s0, z0), the sag relative to the chord between the ends, and the residual RMS — which should be a few centimetres for a clean span.
# Context and Motivation
This guide is part of Power Line Detection in LiDAR Point Clouds. Detected conductor points are a noisy, gappy sample of a curve that physics tells us is a catenary: a flexible cable of uniform weight hanging between two supports. Fitting that curve does three jobs at once. It fills the gaps between returns so clearance can be measured anywhere along the span, not just where the laser happened to hit. It gives engineering numbers — the lowest point and the sag — that utilities track against design values and thermal ratings. And its residuals are a quality check: a span that does not fit a catenary is two spans, or one span with canopy points mixed in.
The catenary parameter a is the ratio of horizontal tension to weight per unit length. Large a means a tight, flat wire; small a means a deep sag. On spans of 50 to 400 metres, a typically ranges from several hundred to a few thousand metres.
# Prerequisites and Assumptions
- Conductor points grouped into spans, each with a span ID — the output of the detection workflow.
- Python with NumPy, SciPy 1.8+ and pandas.
- One conductor per span group. If a group holds several phases, split them first (Step 2).
- A projected CRS in metres, with Z in metres. Mixing feet and metres produces a plausible-looking but wrong
a.
# Step-by-Step Implementation
# Step 1 — Find the span axis
Centre the plan coordinates and take the first right singular vector; it points along the span. Project each point onto it to get s, the horizontal distance along the span.
# Step 2 — Separate parallel conductors
Project onto the perpendicular vector as well. Several phases show up as distinct bands in that offset; split them with a 1D clustering (a histogram with a 0.3 m bin and gaps between peaks is enough) and fit each separately.
# Step 3 — Seed the fit
Good starting values make the fit fast and stable: s0 at the s of the lowest point, z0 at the minimum z, and a from a parabola fit, since for shallow sag z ≈ z0 + (s − s0)² / (2a).
# Step 4 — Fit robustly
Use least_squares with loss="soft_l1" and f_scale=0.1, which down-weights the occasional canopy or insulator point that slipped through detection.
# Step 5 — Derive engineering quantities
Evaluate the curve at the span ends, compute sag as the largest vertical distance between the chord and the curve, and record the residual RMS.
# Complete Working Example
"""Fit a catenary to each conductor span and report sag, low point and residuals."""
from __future__ import annotations
from dataclasses import dataclass
import numpy as np
import pandas as pd
from scipy.optimize import least_squares
@dataclass
class SpanFit:
span: int
a: float
s0: float
z0: float
sag_m: float
rms_m: float
length_m: float
n: int
def catenary(params: np.ndarray, s: np.ndarray) -> np.ndarray:
a, s0, z0 = params
return z0 + a * (np.cosh((s - s0) / a) - 1.0)
def fit_span(span_id: int, x: np.ndarray, y: np.ndarray, z: np.ndarray) -> SpanFit:
xy = np.column_stack([x, y])
centre = xy.mean(axis=0)
_, _, vt = np.linalg.svd(xy - centre, full_matrices=False)
s = (xy - centre) @ vt[0]
# Seed from a parabola: z = c2 s^2 + c1 s + c0 -> a = 1 / (2 c2)
c2, c1, c0 = np.polyfit(s, z, 2)
c2 = max(c2, 1e-5)
s0_seed = -c1 / (2 * c2)
seed = np.array([1.0 / (2 * c2), s0_seed, np.polyval([c2, c1, c0], s0_seed)])
res = least_squares(lambda p: catenary(p, s) - z, seed, loss="soft_l1", f_scale=0.1,
bounds=([10.0, s.min() - 500, z.min() - 50],
[1e5, s.max() + 500, z.max() + 50]))
a, s0, z0 = res.x
resid = catenary(res.x, s) - z
s_lo, s_hi = s.min(), s.max()
grid = np.linspace(s_lo, s_hi, 500)
curve = catenary(res.x, grid)
chord = np.interp(grid, [s_lo, s_hi], [curve[0], curve[-1]])
return SpanFit(span=span_id, a=float(a), s0=float(s0), z0=float(z0),
sag_m=float((chord - curve).max()),
rms_m=float(np.sqrt(np.mean(resid ** 2))),
length_m=float(s_hi - s_lo), n=len(z))
def fit_all(points: pd.DataFrame) -> pd.DataFrame:
fits = [fit_span(int(sid), g.X.to_numpy(), g.Y.to_numpy(), g.Z.to_numpy())
for sid, g in points.groupby("SpanId") if len(g) >= 12]
df = pd.DataFrame([f.__dict__ for f in fits])
df["suspect"] = df.rms_m > 0.25
return df
if __name__ == "__main__":
pts = pd.read_parquet("corridor_0082_spans.parquet") # X, Y, Z, SpanId
report = fit_all(pts)
print(report.round(3).to_string(index=False))A clean result looks like this — low residuals, a in the hundreds to thousands, and sag that scales with span length:
span a s0 z0 sag_m rms_m length_m n suspect
3 1184.2 -12.61 212.874 4.912 0.038 214.66 318 False
4 942.7 8.35 209.117 6.230 0.041 216.02 297 False
7 611.3 -3.02 198.440 0.844 0.412 64.18 121 TrueSpan 7 is flagged: a short span with a large residual usually means a crown or an insulator string is still in the group.
# Key Parameter Table
| Parameter | Type | Default | Guidance |
|---|---|---|---|
| minimum points per span | int | 12 | Fewer gives unstable a; report such spans as unfitted |
loss |
string | soft_l1 |
Robust to a few outliers; linear for already-clean spans |
f_scale |
float, m | 0.1 | Residual size beyond which points are down-weighted |
a bounds |
m | 10–100,000 | Guards against runaway fits on nearly straight spans |
| suspect RMS | float, m | 0.25 | Above this, inspect the span for merged phases or clutter |
# Verification
- Residual RMS under 0.1 m on most spans. Clean helicopter corridor data often achieves 0.03 to 0.05 m.
- Low point between the attachments, or just beyond the lower one on steeply inclined spans. A low point far outside the span means the fit converged on a nonsensical
a. - Sag plausibility. Sag scales roughly with the square of span length for a given tension; compare adjacent spans of similar length on the same circuit, which should have similar sag.
- Re-fit after cleaning. Drop points with residuals beyond three times the RMS and refit. If
achanges by more than a few percent, the first fit was being pulled by outliers.
# Gotchas and Edge Cases
Inclined spans. When attachments differ in height, the low point can fall outside the span, and the lowest measured point is simply the lower attachment. That is physically correct, and the fit handles it — but report clearance along the whole span rather than at s0.
Near-straight spans. Very tight or very short spans barely sag, so a is poorly constrained and can run to the upper bound. Sag and residuals remain valid; a itself should not be reported.
Temperature and load. A fit describes the wire at the time of the flight. Engineering assessments rescale sag to maximum operating temperature using the conductor’s properties, which is outside what the point cloud can tell you; record the flight time and weather with each fit.
# Frequently Asked Questions
Why fit a catenary instead of a parabola?
A parabola is a good approximation for shallow sag and is used to seed the fit, but it diverges from the true shape on long or slack spans. The catenary is the physically correct shape for a uniform cable under its own weight and costs almost nothing extra to fit.
How many points does a span need for a reliable fit?
A dozen well-distributed points will fit, but thirty or more spread along the whole span give stable sag and low-point estimates. Points clustered at one end constrain the curve poorly, however many there are.
What does a large residual mean?
Usually that the span group contains something else: a second conductor, an insulator string, or canopy points below the wire. Inspect the residual pattern along the span; clustered negative residuals point to vegetation.
Can the fit tell me the conductor tension?
Only in combination with the conductor’s weight per metre, which the point cloud does not know. With that value, horizontal tension equals a times the weight per unit length.
# Related
- Power Line Detection in LiDAR Point Clouds — producing the span groups fitted here
- Detecting Power Line Conductors with Linearity — the candidate test upstream
- Measuring Vegetation Clearance to Power Lines — using the fitted curve for clearance
- DBSCAN Segmentation with filters.dbscan — how spans are grouped
- Point Cloud Segmentation — grouping methods in general