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.

The quantities a fit gives you A span between two attachment points at different heights. Scattered conductor returns follow a fitted catenary curve. A dashed chord joins the two attachment points. The lowest point of the curve is marked, offset toward the lower attachment. The sag is shown as the vertical distance from the chord to the curve at mid-span. low point (s0, z0) sag at mid-span attachment A attachment B dashed: chord between attachments · solid: fitted catenary · dots: LiDAR returns

# 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

python
"""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:

text
 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     True

Span 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
Residuals tell you what went wrong Two residual plots against distance along the span. The upper plot for a clean span shows residuals scattered evenly within plus or minus five centimetres of zero. The lower plot for a contaminated span shows a cluster of residuals around minus 0.8 metres over a short stretch, the signature of canopy points below the wire pulling the fit. clean span RMS 0.04 m contaminated canopy points ≈ −0.8 m RMS 0.41 m — flagged

# 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 a changes 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.

Low point outside the span A steeply inclined span on a hillside. The upper attachment is high on the left and the lower attachment is low on the right. The fitted catenary continues past the lower attachment as a dashed extension, reaching its mathematical minimum outside the span. Within the span, the lowest point of the wire is the lower attachment itself. upper attachment lower attachment = lowest wire point s0 outside

# 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.