Choosing a Projected CRS for Basin-Scale Hydrology

Every hydrologic quantity that carries units — contributing area, channel slope, travel time, runoff volume — is measured in whatever projection the data happens to sit in, and the choice is usually inherited from the first file that arrived rather than decided. This guide makes it a decision, as part of the coordinate reference system alignment topic within hydrology data preparation and DEM processing.

Prerequisites

  • The extent of the analysis: one basin, one region, or a continent. This is the single most important input to the decision.
  • pyproj 3.x and geopandas.
  • At least one feature with an independently known area, for verification.

Core Technique: Match the Projection to the Question

No projection preserves everything. The three properties that matter here — area, angle and distance — cannot all be preserved at once, and the hydrologic question decides which to keep.

Which Property the Analysis Needs Preserved Equal-area projections preserve area and suit contributing area, curve number weighting and runoff volume across regional extents. Conformal projections preserve local angle and suit slope, aspect and flow direction over single basins. Equidistant projections preserve distance from a centre and suit travel-time and reach-length work. equal-area Albers, Lambert azimuthal preserves: area use for: contributing area, CN weighting, volumes distorts shape at the edges conformal UTM, state plane preserves: local angle use for: slope, aspect, flow direction, curvature distorts area away from centre equidistant azimuthal equidistant preserves: distance from a point use for: travel time from one outlet, reach lengths only from the chosen centre For one basin inside one UTM zone all three agree to better than 0.1 %, so the choice only starts to matter once the extent exceeds a few hundred kilometres.

Annotated Code Example

python
import logging

import geopandas as gpd
import numpy as np
from pyproj import CRS
from pyproj.aoi import AreaOfInterest
from pyproj.database import query_utm_crs_info

log = logging.getLogger(__name__)
logging.basicConfig(level=logging.INFO, format="%(levelname)s %(message)s")


def recommend_crs(catchment_path: str, purpose: str = "area") -> dict:
    """
    Recommend a projected CRS for a catchment, and say why.

    Parameters
    ----------
    catchment_path : Catchment polygon in any CRS.
    purpose        : "area" for contributing area and volumes, "shape" for
                     slope and flow direction, "distance" for travel time.
    """
    gdf = gpd.read_file(catchment_path).to_crs(4326)
    minx, miny, maxx, maxy = gdf.total_bounds
    lon_c, lat_c = (minx + maxx) / 2.0, (miny + maxy) / 2.0
    span_deg = max(maxx - minx, maxy - miny)
    span_km = span_deg * 111.0 * np.cos(np.radians(lat_c))

    utm_zone = int((lon_c + 180) // 6) + 1
    zone_of_min = int((minx + 180) // 6) + 1
    zone_of_max = int((maxx + 180) // 6) + 1
    straddles = zone_of_min != zone_of_max

    log.info("Extent %.1f × %.1f degrees (~%.0f km), centre (%.3f, %.3f)",
             maxx - minx, maxy - miny, span_km, lat_c, lon_c)
    if straddles:
        log.warning("Basin straddles UTM zones %d–%d — a single zone will "
                    "distort one side", zone_of_min, zone_of_max)

    # --- Under about 400 km and inside one zone, UTM is the pragmatic choice:
    # it is conformal, widely understood, and its area error is negligible. ---
    if not straddles and span_km < 400:
        candidates = query_utm_crs_info(
            datum_name="WGS 84",
            area_of_interest=AreaOfInterest(minx, miny, maxx, maxy),
        )
        crs = CRS.from_epsg(candidates[0].code) if candidates else None
        rationale = (f"single UTM zone {utm_zone}, extent {span_km:.0f} km — "
                     "conformal, negligible area error at this size")
    elif purpose == "area":
        # A custom Albers with standard parallels at one sixth and five sixths
        # of the latitude range minimises distortion over the extent.
        sp1 = miny + (maxy - miny) / 6.0
        sp2 = maxy - (maxy - miny) / 6.0
        crs = CRS.from_proj4(
            f"+proj=aea +lat_1={sp1:.4f} +lat_2={sp2:.4f} "
            f"+lat_0={lat_c:.4f} +lon_0={lon_c:.4f} "
            "+x_0=0 +y_0=0 +datum=WGS84 +units=m +no_defs"
        )
        rationale = ("custom Albers equal-area fitted to the extent — areas "
                     "preserved, which is what the analysis measures")
    else:
        crs = CRS.from_proj4(
            f"+proj=tmerc +lat_0={lat_c:.4f} +lon_0={lon_c:.4f} +k=0.9996 "
            "+x_0=500000 +y_0=0 +datum=WGS84 +units=m +no_defs"
        )
        rationale = ("custom transverse Mercator centred on the basin — "
                     "conformal across a zone-straddling extent")

    log.info("Recommended: %s", rationale)
    return {"crs": crs, "rationale": rationale, "span_km": span_km,
            "straddles_utm_zone": straddles}


def verify_area(catchment_path: str, crs, known_area_km2: float,
                tolerance_pct: float = 1.0) -> dict:
    """
    Check a projection against an independently known area.

    A systematic offset here is a projection problem. Scattered differences
    across several features are delineation differences, which is a different
    investigation entirely.
    """
    gdf = gpd.read_file(catchment_path).to_crs(crs)
    computed = float(gdf.geometry.area.sum()) / 1e6
    diff_pct = 100.0 * (computed - known_area_km2) / known_area_km2

    level = log.info if abs(diff_pct) <= tolerance_pct else log.error
    level("Area in this CRS: %.3f km² vs known %.3f km² (%+.2f %%)",
          computed, known_area_km2, diff_pct)
    if abs(diff_pct) > tolerance_pct:
        log.error("Beyond the %.1f %% tolerance — check the projection before "
                  "assuming the delineation is wrong", tolerance_pct)
    return {"computed_km2": computed, "known_km2": known_area_km2,
            "diff_pct": diff_pct, "within_tolerance": abs(diff_pct) <= tolerance_pct}


def distortion_across_extent(crs, bounds_4326, samples: int = 5) -> dict:
    """
    Sample the area scale factor across the extent, so the distortion is a
    measured number rather than an assumption.
    """
    from shapely.geometry import box

    minx, miny, maxx, maxy = bounds_4326
    factors = []
    for lon in np.linspace(minx, maxx, samples):
        for lat in np.linspace(miny, maxy, samples):
            d = 0.01  # a small test cell, about 1 km
            cell = gpd.GeoSeries([box(lon, lat, lon + d, lat + d)], crs=4326)
            true_m2 = float(cell.to_crs(
                f"+proj=aeqd +lat_0={lat} +lon_0={lon} +datum=WGS84 +units=m"
            ).area.iloc[0])
            proj_m2 = float(cell.to_crs(crs).area.iloc[0])
            factors.append(proj_m2 / true_m2)

    arr = np.asarray(factors)
    log.info("Area scale factor across the extent: min %.5f, max %.5f, "
             "spread %.3f %%", arr.min(), arr.max(),
             100.0 * (arr.max() - arr.min()))
    return {"min": float(arr.min()), "max": float(arr.max()),
            "spread_pct": float(100.0 * (arr.max() - arr.min()))}


# --- Example usage ---
# rec = recommend_crs("basin.gpkg", purpose="area")
# verify_area("basin.gpkg", rec["crs"], known_area_km2=1482.0)

Parameter Reference

Extent Purpose Recommendation
< 400 km, one UTM zone Any The local UTM zone
< 400 km, straddling zones Any Custom transverse Mercator centred on the basin
400–2 000 km Area Custom Albers fitted to the extent
400–2 000 km Shape Lambert conformal conic fitted to the extent
Continental Area A published continental equal-area, e.g. an Albers standard for the region
Continental Shape Not achievable; work per region

Worked Example: What the Wrong Choice Costs

The same 1 482 km² basin, measured in four projections:

CRS Area (km²) Difference
Custom Albers, fitted 1 482.0 reference
Local UTM zone 1 481.4 −0.04 %
Adjacent UTM zone 1 476.9 −0.34 %
Web Mercator (EPSG:3857) 2 963.5 +100 %

The last row is the one worth internalising. Web Mercator’s area distortion at mid-latitudes is close to a factor of two, and because the number that comes back is plausible-looking — square metres, correct order of magnitude for something — it passes every check except a comparison against a known area.

Why Web Mercator Is Never a Hydrologic CRS The area scale factor of Web Mercator rises from 1.0 at the equator to 1.35 at 30 degrees, 2.0 at 45 degrees, 3.4 at 60 degrees and 13 at 75 degrees. Any area computed in it at mid latitudes is roughly double the truth. 30° 45° 60° 75° latitude 16× area scale, log 2× at 45° — most temperate basins The result is in square metres and the order of magnitude looks right, which is exactly why this error survives review.

Repeated reprojection is its own cost. Each pass resamples the elevation surface, and the smoothing accumulates in exactly the terrain derivatives the analysis depends on.

Every Reprojection Costs Some Terrain Standard deviation of slope, as a proxy for terrain detail, falls from 100 percent of the original after no reprojection to 96 percent after one, 92 after two and 85 after four, because each bilinear resampling smooths the surface. 100 % 96 % 92 % 85 % as acquired 1 reprojection 2 reprojections 4 reprojections Choose the analysis CRS once and reproject each source into it exactly once. slope detail

Gotchas and Edge Cases

  • Web Mercator for anything measured. It is a display projection. Areas are roughly double at 45° latitude.
  • UTM chosen by the file rather than by the basin. A basin near a zone boundary can inherit the adjacent zone from one input and the correct one from another.
  • Datum ignored while the projection is chosen carefully. A NAD27-to-NAD83 shift is tens to hundreds of metres, which is larger than most of the distortions discussed here.
  • Reprojecting the DEM repeatedly. Every reprojection resamples and smooths. Choose the analysis CRS once and reproject each source into it exactly once.
  • Custom PROJ strings without a datum. Omitting +datum leaves the transformation underdetermined and PROJ will pick something.
  • Checking areas against a delineation rather than an independent source. Two delineations agreeing proves they used the same projection, not that it is right.