Banner

0.3 Coordinate Reference Systems and Distances¶

Part: 0: Setup and Foundations (Chapter 0.3 of 4)

Dataset: Northeast diabetes lattice; Buffalo city / crime shapefiles (data/diabetes_northeast.shp, data/buffalo_city.shp, data/Crime_location_2018.shp)

Focus: CRS theory and audits, projection distortion, Euclidean vs haversine distances

Library: spatialstats.core (CRS helpers, distance matrices), PyProj

Companion: ch00_setup_and_foundations.ipynb


Table of Contents¶

  1. Overview

  2. Python Setup

  3. Coordinate Reference Systems

  4. How Much Does the CRS Matter?

  5. Distances Suffer Too

  6. Case Study: Valid but Wrong CRS

  7. Distances

  8. Five Ways to Measure the Same Distance

  9. Metric Axioms

  10. Cost of an $n\times n$ Matrix

  11. Normalising Coordinates

  12. Summary and Where Next


1. Overview¶

A wrong or merely plausible CRS silently biases every distance, area and neighbourhood that follows. This notebook quantifies that distortion and then shows how to compute distances correctly.

Step What we do
Theory Scale factors, Mercator $\sec^2\varphi$, grid convergence
Measure Area and distance inflation under Web Mercator vs equal-area
Case study Buffalo files tagged UTM 11N (wrong zone for NY)
Distances Euclidean, haversine, pairwise; metric checks; matrix cost; normalisation trap

Learning objectives (this notebook). Explain what a CRS is and quantify distortion; audit a dataset CRS (including valid but wrong); compute Euclidean and great-circle distances and judge when an $n\times n$ matrix stops being affordable.


2. Python Setup¶

In [ ]:
import ast, importlib, importlib.util, inspect, platform, sys, time, warnings
from pathlib import Path

import numpy as np
import pandas as pd
import geopandas as gpd
import matplotlib.pyplot as plt
from IPython.display import Markdown, display

warnings.filterwarnings("ignore", category=FutureWarning)
warnings.filterwarnings("ignore", category=DeprecationWarning)


def find_repo_root(start: Path = Path.cwd()) -> Path:
    """Walk up from ``start`` until the folder containing ``pyproject.toml`` and ``data/`` is found."""
    for p in [start, *start.parents]:
        if (p / "pyproject.toml").exists() and (p / "data").is_dir():
            return p
    raise FileNotFoundError("Run this notebook from inside the PySpatialStats repository.")


ROOT = find_repo_root()
DATA = ROOT / "data"
try:
    import spatialstats
except ImportError:                       # package not pip-installed: fall back to the source tree
    sys.path.insert(0, str(ROOT))
    import spatialstats

plt.rcParams.update({
    "figure.dpi": 100, "savefig.dpi": 100, "font.size": 10, "axes.titlesize": 11,
    "axes.titleweight": "bold", "axes.spines.top": False, "axes.spines.right": False,
    "axes.grid": True, "grid.alpha": 0.25, "legend.frameon": False,
})
C = {"blue": "#2b6cb0", "red": "#c0392b", "green": "#2f855a", "orange": "#dd6b20", "grey": "#718096", "purple": "#6b46c1"}

print(f"working directory : {Path.cwd().name}/")
print(f"repository data   : {DATA.relative_to(ROOT)}/  ({len(list(DATA.iterdir()))} files)")
print(f"spatialstats      : version {spatialstats.__version__}")
In [ ]:
# Lattice layer used in several later demos
ne = gpd.read_file(DATA / "diabetes_northeast.shp")
print(f"diabetes_northeast: {len(ne)} counties · CRS={ne.crs}")

3. Coordinate Reference Systems¶

Theory: from an ellipsoid to a flat map¶

The Earth is not flat, not a sphere, but close to an oblate ellipsoid of revolution. WGS 84 has semi-major axis $a=6\,378\,137$ m and flattening $f=1/298.257223563$, giving eccentricity $e^2=f(2-f)\approx0.00669$. Positions on it are geodetic coordinates: latitude $\varphi$ (north–south), longitude $\lambda$ (east–west), in degrees. A coordinate reference system (CRS) specifies (i) a datum (which ellipsoid, anchored where) and, for projected systems, (ii) a map projection $$ (x,y)=P(\varphi,\lambda), $$ plus units. A projected CRS lets us use plane geometry — Euclidean distance, area by the shoelace formula — but a curved surface cannot be flattened without distortion (Gauss's Theorema Egregium). Locally the projection turns an infinitesimal circle into an ellipse; its axes are the scale factors $h$ (along meridians) and $k$ (along parallels). Then

$$ \text{distance scale}\approx k,\qquad \text{area scale}\approx h\,k . $$

Projections are classified by what they preserve: conformal ($h=k$: angles and local shapes), equal-area ($hk=1$: areas), equidistant (distances from a point or line). No projection preserves all of them.

Web Mercator (EPSG:3857, the CRS of web maps) is conformal. On a sphere of radius $R$: $$ x=R\lambda,\qquad y=R\ln\tan\!\Big(\frac{\pi}{4}+\frac{\varphi}{2}\Big),\qquad h=k=\sec\varphi, $$ so distances are inflated by $\sec\varphi$ and areas by $\sec^2\varphi$ — a factor of $1.81$ at $42^\circ$N. Albers equal-area conic (e.g. EPSG:5070 for the contiguous US) has $hk=1$ everywhere by construction.

Geographic CRSs (EPSG:4326) are not projections: coordinates are degrees, so "Euclidean distance" is in degrees, and one degree of longitude is $\tfrac{\pi}{180}R\cos\varphi$ long — 111 km at the equator, 0 at the pole.

Different Types of Map Projections

Map projections are methods used to represent the curved surface of the Earth on a flat map. The choice of projection affects how shapes, areas, distances, and directions appear.

  • Conformal projections (e.g., Mercator, Web Mercator EPSG:3857) preserve local angles and shapes but distort areas, especially near the poles.
  • Equal-area (equivalent) projections (e.g., Albers Equal-Area, EPSG:5070) preserve area across the map, making them suitable for statistical mapping, but can distort shapes and angles.
  • Equidistant projections preserve distances from a specific point or along certain lines, but not everywhere on the map.
  • Azimuthal projections project the surface from a particular point, preserving directions from that point (good for air route maps).
  • Compromise projections (e.g., Robinson, Winkel Tripel) try to minimize overall distortion but do not perfectly preserve area, shape, or scale.

UN Recommended Map Projection for Global Mapping

Recently, the United Nations has recommended the use of the "Equal Earth" projection for global maps. The Equal Earth projection is an equal-area, pseudo-cylindrical projection designed to represent land masses proportionally and with visually pleasing shapes, addressing the shortcomings of the popular but highly distorting Mercator projection. This projection is suitable for global thematic and statistical maps used in international contexts.

In [ ]:
from pyproj import Geod
from spatialstats.core import validate_crs, is_geographic, is_projected, to_crs, ensure_projected

inventory = []
for epsg in [4326, 3857, 5070, 3310, 26917, 3718]:
    crs = validate_crs(f"EPSG:{epsg}")
    inventory.append({
        "EPSG": epsg, "name": crs.name,
        "kind": "geographic" if is_geographic(crs) else "projected" if is_projected(crs) else "other",
        "unit": crs.axis_info[0].unit_name,
        "designed for": crs.area_of_use.name[:58] + ("…" if len(crs.area_of_use.name) > 58 else ""),
    })
pd.DataFrame(inventory).set_index("EPSG")

4. How Much Does the CRS Matter?¶

Take the 217 northeastern counties. We can compute each county's true (geodesic) area on the ellipsoid with PyProj, and compare it with the planar area obtained after projecting to several CRSs. If Mercator theory is right, the ratio $A_{3857}/A_{\text{geodesic}}$ should equal $\sec^2\varphi$ county by county.

In [ ]:
geod = Geod(ellps="WGS84")
ne_ll = ne.to_crs(4326)
geodesic_km2 = np.array([abs(geod.geometry_area_perimeter(g)[0]) for g in ne_ll.geometry]) / 1e6

candidates = {"Web Mercator (3857)": 3857, "CONUS Albers, equal-area (5070)": 5070,
              "UTM 18N (32618)": 32618, "Global equal-area cylindrical (6933)": 6933}
planar_km2 = {name: ne.to_crs(code).area.to_numpy() / 1e6 for name, code in candidates.items()}

ratio_table = pd.DataFrame({
    "total area (km²)": {name: a.sum() for name, a in planar_km2.items()},
    "ratio to geodesic": {name: a.sum() / geodesic_km2.sum() for name, a in planar_km2.items()},
    "worst county (ratio)": {name: (a / geodesic_km2)[np.argmax(np.abs(a / geodesic_km2 - 1))]
                             for name, a in planar_km2.items()},
})
ratio_table.loc["geodesic (truth)"] = [geodesic_km2.sum(), 1.0, 1.0]
display(ratio_table.round(4))

lat = ne_ll.geometry.representative_point().y.to_numpy()
sec2 = 1 / np.cos(np.radians(lat)) ** 2
merc_ratio = planar_km2["Web Mercator (3857)"] / geodesic_km2

fig, ax = plt.subplots(1, 2, figsize=(11.5, 4.4))
ax[0].scatter(lat, merc_ratio, s=16, color=C["blue"], label="counties (measured)")
grid_lat = np.linspace(lat.min(), lat.max(), 100)
ax[0].plot(grid_lat, 1 / np.cos(np.radians(grid_lat)) ** 2, color=C["red"], lw=2, label=r"theory: $\sec^2\varphi$")
ax[0].axhline(1, color="k", lw=0.8)
ax[0].set(xlabel="latitude (°N)", ylabel="planar area / geodesic area", title="Web Mercator inflates area by sec²φ")
ax[0].legend()
for name, a in planar_km2.items():
    ax[1].scatter(lat, a / geodesic_km2, s=10, alpha=0.7, label=name)
ax[1].axhline(1, color="k", lw=0.8)
ax[1].set(xlabel="latitude (°N)", ylabel="planar area / geodesic area", title="Area distortion by CRS", ylim=(0.98, 2.1))
ax[1].legend(fontsize=8, loc="upper left")
plt.tight_layout(); plt.show()

r = np.corrcoef(merc_ratio, sec2)[0, 1]
display(Markdown(
    f"**Take-away.** Correlation between the measured Mercator area ratio and $\\sec^2\\varphi$ is **{r:.4f}**; "
    f"summed over the region Web Mercator reports **{ratio_table.loc['Web Mercator (3857)', 'ratio to geodesic']:.2f}×** the true area. "
    f"An equal-area CRS is within {abs(ratio_table.loc['CONUS Albers, equal-area (5070)', 'ratio to geodesic'] - 1) * 100:.4f}% of the truth in total, "
    f"and within {abs(ratio_table.loc['CONUS Albers, equal-area (5070)', 'worst county (ratio)'] - 1) * 100:.3f}% in the worst county."))

5. Distances Suffer Too¶

Areas are distorted by $hk$, distances by $k$. Let us measure the distance between two cities about 220 km apart, five ways.

In [ ]:
from pyproj import Transformer

albany, boston = (-73.7562, 42.6526), (-71.0589, 42.3601)              # (lon, lat)
d_true = geod.inv(*albany, *boston)[2] / 1000                          # geodesic on the ellipsoid, km

def euclid_in(epsg):
    tr = Transformer.from_crs(4326, epsg, always_xy=True)
    (x1, y1), (x2, y2) = tr.transform(*albany), tr.transform(*boston)
    return np.hypot(x2 - x1, y2 - y1) / 1000

rows = {
    "geodesic on WGS84 ellipsoid (truth)": d_true,
    "Euclidean on raw degrees × 111.32 km": np.hypot((boston[0] - albany[0]), (boston[1] - albany[1])) * 111.32,
    "Euclidean in Web Mercator (3857)": euclid_in(3857),
    "Euclidean in CONUS Albers (5070)": euclid_in(5070),
    "Euclidean in UTM 18N (32618)": euclid_in(32618),
}
dist_table = pd.DataFrame({"distance (km)": rows})
dist_table["error vs truth (%)"] = 100 * (dist_table["distance (km)"] / d_true - 1)
dist_table.round(2)

The raw-degree shortcut is off by about a third because a degree of longitude at $42^\circ$N is only $111.32\cos 42^\circ\approx 82.7$ km. Web Mercator inflates by $\sec 42^\circ\approx 1.35$. A projection suited to the region — the local UTM zone here, or the equal-area Albers used for the whole US — is within a fraction of a percent.

Rule of thumb. Any method that uses distance — distance-band or kernel weights, kriging, K-functions, GWR bandwidths, DBSCAN — needs coordinates in a projected CRS with metric units, chosen for the study area: a regional equal-area or conformal projection (CONUS Albers 5070, California Albers 3310) or the local UTM zone. ensure_projected() defaults to Web Mercator (3857) only because it works everywhere; pass target_crs= explicitly for real work.

In [ ]:
# ensure_projected: three behaviours worth knowing
geo = ne.to_crs(4326)
print("lon/lat -> default target        :", ensure_projected(geo).crs.to_epsg())
print("lon/lat -> explicit target_crs   :", ensure_projected(geo, target_crs=5070).crs.to_epsg())
print("already projected -> returned as-is :", ensure_projected(ne).crs.to_epsg())

nocrs = gpd.GeoDataFrame(ne.drop(columns="geometry"), geometry=list(ne.geometry))   # same data, CRS forgotten
print("CRS of the copy:", nocrs.crs)
try:
    ensure_projected(nocrs)
except ValueError as err:
    print("no CRS -> ValueError                :", err)

6. Case Study: Valid but Wrong CRS¶

Software can tell you whether a CRS exists; only you can check that it is right for the place. The Buffalo, NY crime and neighbourhood files declare a projected CRS. Let us audit it. The audit has four steps: read the declared CRS and its area of use; convert the data back to longitude/latitude; test whether the location falls inside the area of use; and, if not, measure how badly distances and areas are distorted.

In [ ]:
city = gpd.read_file(DATA / "buffalo_city.shp")
crimes = gpd.read_file(DATA / "Crime_location_2018.shp")
crime_tbl = pd.read_csv(DATA / "crime_data_2018.csv")            # the same events with lon/lat columns

declared = city.crs
west, south, east, north = declared.area_of_use.bounds
ll = city.to_crs(4326)
union = ll.geometry.union_all() if hasattr(ll.geometry, "union_all") else ll.geometry.unary_union
lon0, lat0 = union.centroid.x, union.centroid.y

print(f"declared CRS      : {declared.name}")
deg = lambda v, pos, neg: f"{abs(v):.1f}°{pos if v >= 0 else neg}"
print(f"designed for      : longitudes {deg(west, 'E', 'W')} to {deg(east, 'E', 'W')}, latitudes {deg(south, 'N', 'S')} to {deg(north, 'N', 'S')}")
print(f"data are located  : longitude {deg(lon0, 'E', 'W')}, latitude {deg(lat0, 'N', 'S')}  (Buffalo, New York)")
print(f"inside the CRS's area of use?  {west <= lon0 <= east and south <= lat0 <= north}")

Buffalo is at $78.9^\circ$W; UTM zone 11N is centred on $117^\circ$W and designed for $114^\circ$–$120^\circ$W. The data are $38^\circ$ from the central meridian. Transverse Mercator can be computed that far out, so no error is raised — but two things grow away from the central meridian $\lambda_0$: the scale factor and the grid convergence $\gamma$ (the angle between grid north and true north). On a sphere, $$ k=\frac{k_0}{\sqrt{1-B^{2}}},\quad B=\cos\varphi\,\sin(\lambda-\lambda_0),\quad k_0=0.9996;\qquad \gamma=\arctan\!\big(\tan(\lambda-\lambda_0)\,\sin\varphi\big). $$ Distances are inflated by $k$, areas by $k^2$, and the whole map is rotated by $\gamma$. We can check both predictions against measurements: geodesic distances between crime locations (from their longitude/latitude) against distances in the file, the polygon areas against the sqmiles attribute that ships with the neighbourhood layer, and the direction of the vectors between crime pairs in the file against the same vectors in the correct zone.

In [ ]:
from scipy.spatial import cKDTree

# 1. Distances: pairs of crimes 100 m - 3 km apart in the file; geodesic distance from their lon/lat
xy = np.column_stack([crimes.geometry.x, crimes.geometry.y])
lonlat = np.column_stack(Transformer.from_crs(declared, 4326, always_xy=True).transform(xy[:, 0], xy[:, 1]))
rng = np.random.default_rng(0)
sample = rng.choice(len(xy), 4000, replace=False)
pairs = cKDTree(xy[sample]).query_pairs(3000, output_type="ndarray")
pairs = sample[pairs[rng.choice(len(pairs), 3000, replace=False)]]
d_file = np.linalg.norm(xy[pairs[:, 0]] - xy[pairs[:, 1]], axis=1)
d_geo = geod.inv(lonlat[pairs[:, 0], 0], lonlat[pairs[:, 0], 1], lonlat[pairs[:, 1], 0], lonlat[pairs[:, 1], 1])[2]
keep = d_geo > 100
k_measured = (d_file[keep] / d_geo[keep]).mean()

# 2. Theory
B = np.cos(np.radians(lat0)) * np.sin(np.radians(lon0 + 117))
k_theory = 0.9996 / np.sqrt(1 - B ** 2)

# 3. Areas: planar area in the file vs geodesic area vs the layer's own 'sqmiles' attribute
area_file = city.area.sum() / 1e6
area_geo = sum(abs(geod.geometry_area_perimeter(g)[0]) for g in ll.geometry) / 1e6
area_attr = city["sqmiles"].sum() * 2.589988

# 3b. Rotation: direction of the same pair-vectors in the file (UTM 11N) and in the correct zone (UTM 17N)
xy17 = np.column_stack(Transformer.from_crs(4326, 26917, always_xy=True).transform(lonlat[:, 0], lonlat[:, 1]))
pi_, pj_ = pairs[keep, 0], pairs[keep, 1]
ang = lambda P: np.arctan2(P[pj_, 1] - P[pi_, 1], P[pj_, 0] - P[pi_, 0])
dtheta = np.degrees((ang(xy) - ang(xy17) + np.pi) % (2 * np.pi) - np.pi)
rot_measured = np.median(dtheta)
gamma = lambda lam0: np.degrees(np.arctan(np.tan(np.radians(lon0 - lam0)) * np.sin(np.radians(lat0))))
rot_theory = gamma(-117) - gamma(-81)

# 4. Do the file's lon/lat (via the declared CRS) agree with the lon/lat columns of the CSV?
agree = np.abs(lonlat - crime_tbl[["long", "lat"]].to_numpy()).max()

audit = pd.DataFrame({
    "quantity": ["linear scale factor k", "area (km²)", "rotation vs. correct zone"],
    "as stored in the file": [f"{k_measured:.4f} (measured)", f"{area_file:.1f}", f"{abs(rot_measured):.1f}° (measured)"],
    "theory / truth": [f"{k_theory:.4f} (formula)", f"{area_geo:.1f} geodesic; {area_attr:.1f} from 'sqmiles' attribute",
                       f"{abs(rot_theory):.1f}° (γ formula, difference of the two zones)"],
    "distortion": [f"distances × {k_measured:.3f}", f"areas × {area_file / area_geo:.3f}", "map turned by the grid convergence"],
}).set_index("quantity")
display(audit)
print(f"lon/lat recovered through the declared CRS agree with the CSV's lon/lat to within {agree:.4f} degrees (about {agree * 111_000:.0f} m):")
print("-> the coordinates locate Buffalo correctly; what is wrong is the zone the numbers are expressed in.")

The measured scale factor agrees with the formula to three decimals, the measured rotation with the grid-convergence formula, and the geodesic area reproduces the layer's own sqmiles attribute, so the diagnosis is solid: the coordinates are Buffalo expressed in UTM 11N, applied 38° outside its zone. (The residual of about 0.001° between the file's implied longitude/latitude and the CSV's is typical of two software packages evaluating the transverse-Mercator series differently far from the central meridian — one more symptom of using a projection out of range.) Every distance is about 12 % too long, every area about 26 % too large, and every direction is turned by about 27°. For later chapters that means an intensity in events per km² would be about 20 % too low, a 500 m distance threshold would really cover only about 446 m on the ground, and so on.

Fix. Go through longitude/latitude to the correct zone, UTM 17N (NAD83, EPSG:26917), designed for $84^\circ$–$78^\circ$W:

In [ ]:
city_fixed = city.to_crs(4326).to_crs(26917)
crimes_fixed = crimes.to_crs(4326).to_crs(26917)

xy_f = np.column_stack([crimes_fixed.geometry.x, crimes_fixed.geometry.y])
k_fixed = (np.linalg.norm(xy_f[pairs[:, 0]] - xy_f[pairs[:, 1]], axis=1)[keep] / d_geo[keep]).mean()

fix = pd.DataFrame({
    "declared UTM 11N": [f"{k_measured:.4f}", f"{area_file:.1f}"],
    "re-projected to UTM 17N (EPSG:26917)": [f"{k_fixed:.4f}", f"{city_fixed.area.sum() / 1e6:.1f}"],
    "truth": ["1.0000", f"{area_attr:.1f}"],
}, index=["distance scale factor", "area (km²)"])
display(fix)

fig, ax = plt.subplots(1, 2, figsize=(11, 4))
km = lambda v, _: f"{v / 1000:,.0f}"
for a, (g, ttl) in zip(ax, [(city, f"declared CRS (UTM 11N): {area_file:.0f} km²"), (city_fixed, f"UTM 17N: {city_fixed.area.sum() / 1e6:.0f} km²")]):
    g.boundary.plot(ax=a, color="k", lw=0.6); a.set_title(ttl); a.set_aspect("equal")
    a.set_xlabel("easting (km)"); a.set_ylabel("northing (km)")
    a.xaxis.set_major_formatter(km); a.yaxis.set_major_formatter(km); a.tick_params(labelsize=8)
    a.xaxis.set_major_locator(plt.MaxNLocator(4))
plt.tight_layout(); plt.show()

Transverse Mercator is conformal, so every neighbourhood keeps its local shape; the whole map is merely turned and stretched. Seen on its own the left-hand map looks like a perfectly reasonable Buffalo — it takes a side-by-side comparison, or a check of the numbers, to notice. A wrong CRS can look right on a map; it shows up in the numbers. In section 0.9 the audit is packaged as a reusable function.

Two more CRS failure modes¶

Mismatched CRSs. Coordinates in different systems are just different numbers; joining them "works" and returns nothing. Missing CRS. A file without a CRS cannot be reprojected, and nothing tells you whether it is metres or degrees.

In [ ]:
counties = ne                                            # CRS: EPSG:5070 (Albers, metres)
with warnings.catch_warnings(record=True) as caught:
    warnings.simplefilter("always")
    joined = gpd.sjoin(crimes.head(200), counties)       # crimes: 'UTM 11N'  vs  counties: 'Albers'
print(f"spatial join of 200 crimes with counties in a different CRS -> {len(joined)} matches")
print("GeoPandas warned:", str(caught[0].message).splitlines()[0])

incidents = gpd.read_file(DATA / "Crime_Incidents_2018.shp")
print("\nCrime_Incidents_2018.shp has CRS:", incidents.crs)
print("its coordinates look like:", incidents.geometry.x.iloc[:2].round(3).tolist(), incidents.geometry.y.iloc[:2].round(3).tolist(),
      "-> plausibly degrees; the file also has 'longitude' / 'latitude' columns")
try:
    to_crs(incidents, 26917)
except ValueError as err:
    print("core.to_crs on a file with no CRS -> ValueError:", err)

same = np.allclose(incidents.geometry.x.astype(float), pd.to_numeric(incidents["longitude"], errors="coerce"), atol=1e-6, equal_nan=True)
if same:
    incidents = incidents.set_crs(4326)                   # only after checking, never blindly
    print("after verifying the coordinates equal the lon/lat columns, set_crs(4326) ->", incidents.crs.to_epsg())

7. Distances¶

Theory: what is a distance?¶

A function $d$ is a metric on locations if, for all $\mathbf{a},\mathbf{b},\mathbf{c}$:

  1. $d(\mathbf{a},\mathbf{b})\ge 0$, and $d(\mathbf{a},\mathbf{b})=0 \iff \mathbf{a}=\mathbf{b}$;
  2. $d(\mathbf{a},\mathbf{b})=d(\mathbf{b},\mathbf{a})$ (symmetry);
  3. $d(\mathbf{a},\mathbf{c})\le d(\mathbf{a},\mathbf{b})+d(\mathbf{b},\mathbf{c})$ (triangle inequality).

For coordinates $\mathbf{a}=(a_1,a_2)$, $\mathbf{b}=(b_1,b_2)$ the Minkowski family is $d_p=\big(\sum_j |a_j-b_j|^p\big)^{1/p}$: $p=2$ is Euclidean (straight line), $p=1$ is Manhattan (city blocks), $p\to\infty$ is Chebyshev. On the sphere the shortest path is a great circle. Its length, from the haversine formula (Sinnott, 1984), with latitudes $\varphi_1,\varphi_2$ and longitudes $\lambda_1,\lambda_2$ (in radians), is $$ a=\sin^2\!\frac{\varphi_2-\varphi_1}{2}+\cos\varphi_1\cos\varphi_2\,\sin^2\!\frac{\lambda_2-\lambda_1}{2},\qquad d=2R\arcsin\sqrt{a}, $$ with the mean Earth radius $R=6371$ km. Treating the Earth as a sphere introduces an error of up to about $0.5\%$ against the exact ellipsoidal geodesic (Karney, 2013).

For $n$ locations the distance matrix $\mathbf{D}=(d_{ij})\in\mathbb{R}^{n\times n}$ is symmetric with a zero diagonal and needs $8n^2$ bytes in double precision. Nearest-neighbour queries with a $k$-d tree need only $O(n\log n)$ time and $O(n)$ memory, so for large $n$ never build $\mathbf{D}$ unless you must.

The functions¶

Function Purpose Input order
coords_from_geometry(geom) $(x,y)$ per feature (representative point for polygons) —
euclidean_distance_matrix(c) $n\times n$ Euclidean distances $(x,y)$
euclidean_distances(a, b) rectangular $m\times n$ distances between two sets $(x,y)$
haversine_distance_matrix(ll) great-circle distances in km (latitude, longitude)
pairwise_distances(c, metric) dispatcher: euclidean, haversine, network, or any SciPy metric depends on metric
normalize_coords(c) rescale each axis to $[0,1]$ $(x,y)$

Note the input order of the haversine function: latitude first. Most spatial software, and GeoPandas, uses (longitude, latitude) = (x, y). Mixing them up is a classic bug — quantified below.

In [ ]:
from spatialstats.core import (coords_from_geometry, euclidean_distance_matrix, euclidean_distances,
                               haversine_distance_matrix, pairwise_distances, normalize_coords)

# Length of one degree of longitude at various latitudes: haversine vs the closed form (pi/180) R cos(phi)
R = 6371.0
lats = np.array([0, 20, 40, 45, 60, 80])
via_haversine = np.array([haversine_distance_matrix(np.array([[la, 0.0], [la, 1.0]]))[0, 1] for la in lats])
closed_form = np.pi / 180 * R * np.cos(np.radians(lats))
pd.DataFrame({"haversine (km)": via_haversine, "(π/180)·R·cos φ (km)": closed_form}, index=pd.Index(lats, name="latitude (°)")).round(3)

8. Five Ways to Measure the Same Distance¶

Four US city pairs — short to long — comparing the haversine function with the exact ellipsoidal geodesic and with Euclidean distance in a suitable projected CRS (CONUS Albers).

In [ ]:
cities = {"New York": (40.7128, -74.0060), "Boston": (42.3601, -71.0589), "Atlanta": (33.7490, -84.3880),
          "Miami": (25.7617, -80.1918), "Seattle": (47.6062, -122.3321), "Denver": (39.7392, -104.9903)}
pairs_ = [("New York", "Boston"), ("New York", "Atlanta"), ("Atlanta", "Denver"), ("Miami", "Seattle")]
to_albers = Transformer.from_crs(4326, 5070, always_xy=True)

rows = []
for a, b in pairs_:
    (la1, lo1), (la2, lo2) = cities[a], cities[b]
    truth = geod.inv(lo1, la1, lo2, la2)[2] / 1000
    hav = haversine_distance_matrix(np.array([[la1, lo1], [la2, lo2]]))[0, 1]
    x1, y1 = to_albers.transform(lo1, la1); x2, y2 = to_albers.transform(lo2, la2)
    alb = np.hypot(x2 - x1, y2 - y1) / 1000
    swapped = haversine_distance_matrix(np.array([[lo1, la1], [lo2, la2]]))[0, 1]     # (lon, lat) passed by mistake
    rows.append({"pair": f"{a} – {b}", "geodesic (km)": truth, "haversine (km)": hav,
                 "haversine error (%)": 100 * (hav / truth - 1), "Albers Euclid. error (%)": 100 * (alb / truth - 1),
                 "lon/lat swapped error (%)": 100 * (swapped / truth - 1)})
pd.DataFrame(rows).set_index("pair").round(2)

The haversine formula stays within about a fifth of a percent here (its theoretical worst case is about half a percent). Euclidean distance in Albers stays within 1 %, but its error is not a simple function of distance — it depends on where the pair sits relative to the projection's standard parallels (an equal-area projection makes no promise about distance). Swapping latitude and longitude produces distances that are wrong by 3 % to 17 % here, in either direction — and no error message.

Distance matrices for real data¶

We compute all $217\times 217$ distances between northeastern county coordinates two ways: Euclidean in the projected CRS (metres) and haversine on longitude/latitude, and compare them pair by pair.

In [ ]:
xy_proj = coords_from_geometry(ne)                                                    # (x, y) in metres: one point per county
# transform *those same points* to lon/lat (a representative point is not invariant under reprojection,
# so calling coords_from_geometry() again on the lon/lat layer would pick different points)
lon, lat = Transformer.from_crs(ne.crs, 4326, always_xy=True).transform(xy_proj[:, 0], xy_proj[:, 1])
lat_lon = np.column_stack([lat, lon])                                                 # haversine wants (lat, lon)

D_eu = euclidean_distance_matrix(xy_proj) / 1000                                      # km
D_hv = pairwise_distances(lat_lon, metric="haversine")                                # km
iu = np.triu_indices(len(ne), k=1)
rel = 100 * (D_eu[iu] / D_hv[iu] - 1)

fig, ax = plt.subplots(1, 2, figsize=(11.5, 4.3))
ax[0].scatter(D_hv[iu], D_eu[iu], s=2, alpha=0.15, color=C["blue"])
mx = D_hv[iu].max()
ax[0].plot([0, mx], [0, mx], color=C["red"], lw=1.2, label="y = x")
ax[0].set(xlabel="haversine distance (km)", ylabel="Euclidean in Albers (km)", title=f"{len(iu[0]):,} county pairs"); ax[0].legend()
ax[1].scatter(D_hv[iu], rel, s=2, alpha=0.15, color=C["purple"])
ax[1].axhline(0, color="k", lw=0.8)
ax[1].set(xlabel="haversine distance (km)", ylabel="Albers vs haversine (%)", title="Relative difference")
plt.tight_layout(); plt.show()

display(Markdown(f"The two methods agree on average within **{np.abs(rel).mean():.2f}%** (worst pair {np.abs(rel).max():.2f}%). "
                 f"Median and maximum distance between two counties: {np.median(D_hv[iu]):.0f} km, {D_hv[iu].max():.0f} km."))

9. Metric Axioms¶

Distance matrices feed everything from spatial weights to kernel bandwidths, and a defective one produces silent nonsense. Two cheap sanity checks: symmetry with a zero diagonal, and the triangle inequality on random triples.

In [ ]:
rng = np.random.default_rng(0)
for name, D in [("Euclidean (Albers, km)", D_eu), ("haversine (km)", D_hv)]:
    i, j, k = rng.integers(0, len(D), size=(3, 20000))
    violation = D[i, k] - (D[i, j] + D[j, k])
    print(f"{name:24s} symmetric: {np.allclose(D, D.T)} | zero diagonal: {np.allclose(np.diag(D), 0)} | "
          f"min off-diagonal: {D[np.triu_indices(len(D), 1)].min():.3f} | worst triangle-inequality violation: {violation.max():.2e}")

10. Cost of an $n\times n$ Matrix¶

Memory grows with $n^2$. The cell below measures the time and memory of a dense matrix, and the time of the alternative — asking a $k$-d tree for each point's 8 nearest neighbours.

In [ ]:
from scipy.spatial import cKDTree

rng = np.random.default_rng(1)
rows = []
for n in [500, 1000, 2000, 3107, 4000]:
    pts = rng.random((n, 2)) * 1e5
    t0 = time.perf_counter(); D = euclidean_distance_matrix(pts); t_dense = time.perf_counter() - t0
    t0 = time.perf_counter(); cKDTree(pts).query(pts, k=9); t_tree = time.perf_counter() - t0
    rows.append({"n": n, "dense matrix (MB)": D.nbytes / 1e6, "dense time (s)": t_dense, "8-NN by k-d tree (s)": t_tree})
    del D
scale = pd.DataFrame(rows).set_index("n")
display(scale.round(3))
big = {"US counties": 3107, "Buffalo crimes": 14001, "CA PM2.5 station-days": 59566}
display(Markdown("**Dense-matrix footprint at real sizes:** " + "; ".join(f"{k} (n = {n:,}) → {8 * n * n / 1e9:.2f} GB" for k, n in big.items()) + "."))

11. Normalising Coordinates¶

normalize_coords rescales each axis separately to $[0,1]$: $x'=(x-x_{\min})/(x_{\max}-x_{\min})$, and likewise for $y$. If the study area is not square this changes the geometry: distances after normalisation weight the two axes by $1/R_x$ and $1/R_y$ — for the US, whose extent is about 1.6 times wider than tall, one metre north counts 1.6 times as much as one metre east — $$ d'(\mathbf{a},\mathbf{b})=\sqrt{\Big(\tfrac{\Delta x}{R_x}\Big)^2+\Big(\tfrac{\Delta y}{R_y}\Big)^2}\;\ne\;\frac{d(\mathbf{a},\mathbf{b})}{\text{const}}\quad\text{unless }R_x=R_y . $$ Neighbours found on normalised coordinates are then not the nearest neighbours on the ground. Rescaling with one common range keeps distances proportional. The Northeast happens to be almost square, so the damage there is small; the elongated contiguous US shows what can go wrong.

In [ ]:
def scaling_diagnostics(xy, region):
    rx, ry = np.ptp(xy[:, 0]), np.ptp(xy[:, 1])
    D0 = euclidean_distance_matrix(xy)
    iu_ = np.triu_indices(len(xy), k=1)
    A = D0.copy(); np.fill_diagonal(A, np.inf)
    rows = []
    for label, Z in [("normalize_coords (per axis)", normalize_coords(xy)),
                     ("single common range", (xy - xy.min(axis=0)) / max(rx, ry))]:
        Dn = euclidean_distance_matrix(Z)
        ratio = Dn[iu_] / D0[iu_]
        B = Dn.copy(); np.fill_diagonal(B, np.inf)
        rows.append({"region": region, "E-W / N-S extent": rx / ry, "scaling": label,
                     "corr with true distance": np.corrcoef(Dn[iu_], D0[iu_])[0, 1],
                     "distance ratio, max / min": ratio.max() / ratio.min(),
                     "same nearest neighbour (%)": 100 * np.mean(A.argmin(axis=1) == B.argmin(axis=1))})
    return rows

us_xy_ = pd.read_csv(DATA / "diabetes_usa.csv")[["X", "Y"]].to_numpy()
tab = pd.DataFrame(scaling_diagnostics(xy_proj, "Northeast (217 counties)") + scaling_diagnostics(us_xy_, "Contiguous US (3,107 counties)"))
tab.set_index(["region", "scaling"]).round(3)

12. Summary and Where Next¶

  • CRS. Every projection distorts: Web Mercator by $\sec\varphi$ in distance and $\sec^2\varphi$ in area. Use a projected, metric CRS suited to the region — and verify that the declared CRS fits the location (Buffalo is not in UTM zone 11N).
  • Distances. Euclidean (projected) or haversine (latitude first!); matrices cost $8n^2$ bytes, so prefer k-d trees for large $n$; rescale coordinates with a single common range.

Previous: 0.2 Spatial Data Types and Containers

Next: 0.4 Datasets, Maps and Workflow — data catalogue, choropleths, loading recipe, pitfalls and exercises.

Full single-file chapter: ch00_setup_and_foundations.ipynb.