Geodesic vs Projected Distance for Interconnection Screening
The scenario: a national screen ranks candidate sites by distance to the nearest suitable substation, and two sites 900 kilometres apart are compared using a single projected frame chosen for the portfolio centroid. One is measured 4 percent long and the other 2 percent short, which is enough to reorder them. The fix is not “always use geodesic” — that is 40 times slower and unnecessary for most of the work — but knowing where the boundary is. This page locates it, and it extends proximity and distance calculations.
Root-cause analysis
Projected distance is exact in the plane and approximate on the ellipsoid, and the error has three drivers that compound.
- Distance from the frame’s central meridian or standard parallels. A transverse Mercator zone is near-exact on its central meridian and departs quadratically with easting; at the zone edge the scale factor is about 1.0007, so a 400-metre spacing measures 28 centimetres long.
- Baseline length. The scale error is a ratio, so it grows linearly with the distance being measured. A 0.07 percent error is 28 centimetres over 400 metres and 700 metres over 1,000 kilometres.
- Using one frame for an extent it was not designed for. This is the dominant term in practice. A portfolio spanning several UTM zones measured in one of them accumulates error that is systematic per site, which is exactly the shape that reorders a ranking.
Pre-flight validation
The decision is quantitative, so measure it. For a given frame and extent, compare a sample of baselines against the geodesic answer and read off the worst case.
import geopandas as gpd
import numpy as np
from pyproj import Geod
GEOD = Geod(ellps="WGS84")
def projected_vs_geodesic(
origins: gpd.GeoDataFrame,
targets: gpd.GeoDataFrame,
*,
projected_epsg: int,
sample: int = 500,
seed: int = 7,
) -> dict:
"""Worst-case and typical disagreement between a projected frame and the ellipsoid."""
rng = np.random.default_rng(seed)
o = origins.sample(min(sample, len(origins)), random_state=seed)
t = targets.sample(min(sample, len(targets)), random_state=seed + 1)
pairs = min(len(o), len(t))
og, tg = o.to_crs(4326).geometry.iloc[:pairs], t.to_crs(4326).geometry.iloc[:pairs]
_, _, geodesic_m = GEOD.inv(og.x.values, og.y.values, tg.x.values, tg.y.values)
op, tp = o.to_crs(projected_epsg).geometry.iloc[:pairs], t.to_crs(projected_epsg).geometry.iloc[:pairs]
projected_m = np.hypot(op.x.values - tp.x.values, op.y.values - tp.y.values)
rel = (projected_m - np.abs(geodesic_m)) / np.abs(geodesic_m)
return {
"pairs": pairs,
"median_abs_error_pct": float(np.median(np.abs(rel)) * 100),
"p95_abs_error_pct": float(np.percentile(np.abs(rel), 95) * 100),
"max_abs_error_m": float(np.max(np.abs(projected_m - np.abs(geodesic_m)))),
"bias_pct": float(np.mean(rel) * 100), # systematic, not random
}
The bias_pct field is the one that matters for a ranking. Random error averages out across a
portfolio; a systematic bias that depends on where a site sits relative to the frame does not.
Fix implementation
The practical rule is a two-tier measurement: projected inside a zone, geodesic across zones, with the tier chosen from the geometry rather than from a global setting.
import geopandas as gpd
import numpy as np
from pyproj import Geod
GEOD = Geod(ellps="WGS84")
def screen_distances(
sites: gpd.GeoDataFrame,
substations: gpd.GeoDataFrame,
*,
zone_field: str = "utm_zone",
cross_zone_method: str = "geodesic",
) -> gpd.GeoDataFrame:
"""Projected distance within a zone, geodesic across zones — decided per pair."""
out = []
for zone, sites_in_zone in sites.groupby(zone_field):
epsg = 32600 + int(zone) # northern hemisphere UTM
local_subs = substations[substations[zone_field] == zone]
if not local_subs.empty:
sp = sites_in_zone.to_crs(epsg)
up = local_subs.to_crs(epsg)
joined = gpd.sjoin_nearest(sp, up, how="left", distance_col="distance_m")
joined["method"] = "projected"
joined["frame"] = f"EPSG:{epsg}"
out.append(joined)
# Anything whose nearest candidate lies outside the zone is measured on the ellipsoid.
far = substations[substations[zone_field] != zone]
if not far.empty and cross_zone_method == "geodesic":
sg = sites_in_zone.to_crs(4326)
fg = far.to_crs(4326)
for idx, site in sg.iterrows():
_, _, dists = GEOD.inv(
np.full(len(fg), site.geometry.x), np.full(len(fg), site.geometry.y),
fg.geometry.x.values, fg.geometry.y.values,
)
best = int(np.argmin(np.abs(dists)))
out.append(
gpd.GeoDataFrame(
[{**site.to_dict(), "distance_m": float(abs(dists[best])),
"method": "geodesic", "frame": "WGS84 ellipsoid"}],
geometry="geometry", crs=4326,
)
)
return gpd.pd.concat(out, ignore_index=True)
Recording the method and frame per row is what makes a mixed-tier result honest: two distances in
the same column measured different ways are comparable to within the error the pre-flight already
quantified, and a reviewer can see which is which.
Fallback routing and performance tuning
- Vectorise the geodesic call.
Geod.invaccepts arrays, so a loop over pairs is the usual reason geodesic distance is reported as slow; the array form is within a small factor of the projected calculation. - Filter before measuring. A geodesic distance to every substation in the country is wasted work; an H3 or bounding-box pre-filter cuts the candidate set to a handful before either method runs.
- Reuse the zone assignment. Deriving a UTM zone per feature once and storing it turns the tier decision into a column comparison instead of a geometry operation.
- Prefer
sjoin_nearestwithin a zone. It builds an STRtree internally and handles polygon substations correctly, which a KD-tree over centroids does not. - Do not mix tiers within one ranking without recording it. The tiers agree to well within the screening tolerance, but only a stated method survives a challenge.
Downstream validation
def assert_distance_method_consistency(df, *, max_cross_method_gap_pct: float = 0.5) -> None:
"""Where both methods were computed, they must agree within the screening tolerance."""
both = df.dropna(subset=["distance_m", "distance_geodesic_m"])
if both.empty:
return
gap = (both["distance_m"] - both["distance_geodesic_m"]).abs() / both["distance_geodesic_m"]
assert gap.max() * 100 <= max_cross_method_gap_pct, (
f"projected and geodesic disagree by {gap.max()*100:.2f}% — the projected frame is being "
"used outside its zone of validity"
)
assert df["method"].isin({"projected", "geodesic"}).all(), "unrecorded distance method"
assert df["frame"].notna().all(), "a distance with no frame recorded"
Where the boundary actually falls
Three thresholds cover almost every decision in this domain.
Inside one UTM zone, under about 100 kilometres. Projected distance is correct to a few parts per ten thousand — centimetres on a turbine spacing, tens of metres on a long gen-tie. Use it, and use the zone containing the extent rather than the one containing the portfolio centroid.
Inside one zone, over about 300 kilometres. The scale factor is still small but the baseline makes it material: 0.07 percent of 300 kilometres is 210 metres. That is irrelevant for a screening rank and relevant for a cost estimate, so the rule is projected for the rank and geodesic for the figure that goes into a pro forma.
Across zones, any length. Use geodesic. There is no single projected frame that is simultaneously correct for two distant zones, and the error is systematic per site rather than random, which is precisely the shape that reorders a ranking.
The pattern that avoids the whole question for most work is to screen within zones and only compare across zones on the shortlist — which is also what keeps the run fast, because the expensive method is applied to tens of pairs rather than millions.
Frequently asked questions
Is geopandas.distance geodesic?
No — it is planar, computed in whatever CRS the GeoSeries is in. On a geographic frame it returns
degrees, which is the failure this whole page exists to prevent. pyproj.Geod.inv and
Geod.geometry_length are the geodesic entry points, and GeoSeries.to_crs before distance is the
projected one.
How much slower is geodesic distance really?
Vectorised, roughly two to four times a planar calculation — not the order of magnitude its reputation suggests. The slowness people encounter comes from calling it per pair in a Python loop, which is a hundred times slower and has nothing to do with the ellipsoid.
Does the ellipsoid choice matter?
Between WGS84 and GRS80, no — they differ in flattening by about one part in ten billion, which is nanometres over a continental baseline. Between a modern ellipsoid and Clarke 1866, yes, and that is a datum problem rather than a distance one.
What about distances that cross the antimeridian?
Geodesic handles them correctly and projected distance does not, because the planar coordinates jump by the width of the world. Any portfolio spanning the Pacific should use geodesic for cross-basin pairs regardless of the length thresholds above.
Should the reported distance be geodesic even when the screen used projected?
Report what was measured, with its method. Re-measuring the shortlist geodesically and publishing that figure is good practice, but silently substituting one method’s number into another method’s ranking makes the ranking unreproducible — the rank came from one set of distances and the report shows another.
How does this interact with routed distance?
It bounds it. Both geodesic and projected straight-line distances are lower bounds on the routed length, and the circuity factor is defined against whichever was used. Because circuity is a ratio, mixing methods between the numerator and the denominator quietly changes it, which is one more reason the method belongs in the output.
Does the choice of method change the circuity factor?
Yes, and it is an easy place to introduce an inconsistency. Circuity is routed length divided by straight-line length, so a routed distance measured on a projected surface against a geodesic straight line mixes two conventions in one ratio. The effect is small — tenths of a percent — and systematic, which means it moves every circuity figure in the same direction and makes a portfolio comparison against published benchmarks quietly wrong. Compute both terms the same way and record which way that was.
What about elevation — should distances be slope-corrected?
For screening, no. Over a 10-kilometre gen-tie with 200 metres of relief, the slope correction adds about 2 metres, which is far below the routing uncertainty. It matters for conductor length and sag calculations, which are engineering questions downstream of siting, and those use the routed profile rather than a point-to-point distance.
Related
- Proximity & Distance Calculations — the parent workflow
- Choosing UTM vs State Plane for Wind Farm Siting — the scale-factor behaviour inside a zone
- Benchmarking STRtree vs cKDTree vs H3 for Substation Lookups — the pre-filter that makes either method cheap
- Grid Routing & Least-Cost Path Analysis — the routed distance these figures bound