coordinate-systems.md
368 lines · skills/geomaster/references/coordinate-systems.md at 5f2bc03
Coordinate Reference Systems (CRS)
Coordinate-system reference, reviewed 2026-10-01 against PyProj/PROJ. Code with external data is illustrative. CRS names were checked against the installed PROJ database; a numerical round trip does not establish datum accuracy or grid availability.
Raw lines 1 to 4Table of Contents
- Fundamentals
- Common CRS Codes
- Projected vs Geographic
- UTM Zones
- Transformations
- Best Practices
Fundamentals
Raw lines 14 to 15What is a CRS?
A Coordinate Reference System defines how coordinates relate to positions on Earth:
- Geographic CRS: Uses angular latitude/longitude; usually degrees, but inspect axis units
- Projected CRS: Uses Cartesian coordinates (meters, feet)
- Vertical CRS: Defines height/depth (e.g., ellipsoidal heights)
Components
- Datum: Mathematical model of Earth's shape
- WGS 84 (EPSG:4326) - Global GPS
- NAD 83 (EPSG:4269) - North America
- ETRS89 (EPSG:4258) - Europe
- Projection: Transformation from curved to flat surface
- Cylindrical (Mercator)
- Conic (Lambert Conformal)
- Azimuthal (Polar Stereographic)
- Units: Degrees, meters, feet, etc.
Common CRS Codes
Raw lines 38 to 39Geographic CRS (Lat/Lon)
| EPSG | Name | Area | Notes |
|---|---|---|---|
| 4326 | WGS 84 | Global | GPS default, use for storage |
| 4269 | NAD83 | North America | USGS data, slightly different from WGS84 |
| 4258 | ETRS89 | Europe | European reference frame |
| 4283 | GDA94 | Australia | Australian datum |
Projected CRS (Meters)
| EPSG | Name | Area | Distortion | Notes |
|---|---|---|---|---|
| 3857 | Web Mercator | Global (85°S-85°N) | High near poles | Web maps (Google, OSM) |
| 32601-32660 | UTM Zone N | 6° longitude zones | Check area of use | Metric calculations |
| 32701-32760 | UTM Zone S | 6° longitude zones | Check area of use | Southern hemisphere |
| 3395 | World Mercator | World | Increases toward poles | Conformal, not equal-area |
| 5070 | CONUS Albers | USA (conterminous) | Low | US national mapping |
| 2154 | Lambert-93 | France | Very low | French national projection |
Regional Projections
United States:
- EPSG:5070 - NAD83 / Conus Albers
- EPSG:3338 - NAD83 / Alaska Albers
- ESRI:102003 - USA Contiguous Albers Equal Area
- EPSG:2227 - California Zone 3 (US Feet)
Europe:
- EPSG:3035 - Europe Equal Area 2001
- EPSG:3857 - Web Mercator (web mapping)
- EPSG:2154 - Lambert 93 (France)
- EPSG:25832-25836 - UTM zones (ETRS89)
Other:
- EPSG:3112 - GDA94 / Geoscience Australia Lambert (Australia)
- EPSG:2056 - CH1903+ / LV95 (Switzerland)
- EPSG:4326 - WGS 84 (global default)
Projected vs Geographic
Raw lines 79 to 80When to Use Geographic (EPSG:4326)
✅ Storing data (databases, files) ✅ Global datasets ✅ Web APIs (GeoJSON, KML) ✅ Latitude/longitude queries ✅ GPS coordinates
# Bad: Distance calculation in geographic CRS
# Assume gdf already has a verified geographic CRS
distance = gdf.geometry.length # WRONG! Returns degrees, not meters
# Good: Calculate distance in projected CRS
gdf_projected = gdf.to_crs("EPSG:32633") # UTM Zone 33N
distance_m = gdf_projected.geometry.length # Correct: metersRaw lines 81 to 98When to Use Projected
✅ Area/distance calculations ✅ Buffer operations ✅ Spatial analysis ✅ High-resolution mapping ✅ Engineering applications
import geopandas as gpd
# Project to appropriate UTM zone
gdf = gdf.to_crs(gdf.estimate_utm_crs())
# Now area and distance are accurate
area_sqm = gdf.geometry.area
buffer_1km = gdf.geometry.buffer(1000) # 1000 metersRaw lines 99 to 117Web Mercator Warning
⚠️ EPSG:3857 (Web Mercator) for visualization only
# DON'T use Web Mercator for area calculations
gdf_web = gdf.to_crs("EPSG:3857")
area = gdf_web.geometry.area # WRONG! Significant distortion
# DO use appropriate projection
gdf_utm = gdf.to_crs("EPSG:32633") # or estimate_utm_crs()
area = gdf_utm.geometry.area # CorrectRaw lines 118 to 131UTM Zones
Raw lines 132 to 133Understanding UTM Zones
Earth is divided into 60 zones (6° longitude each):
- Zones 1-60: West to East
- Each zone divided into North (326xx) and South (327xx)
Finding Your UTM Zone
def get_utm_zone(longitude, latitude):
"""Nominal 6-degree zone only; special Norway/Svalbard zones need registry lookup."""
import math
if not (-180 <= longitude <= 180 and -80 <= latitude <= 84):
raise ValueError('Outside the nominal UTM domain')
zone = min(60, math.floor((longitude + 180) / 6) + 1)
if latitude >= 0:
epsg = 32600 + zone # Northern hemisphere
else:
epsg = 32700 + zone # Southern hemisphere
return f"EPSG:{epsg}"
# Example
get_utm_zone(-122.4, 37.7) # Returns 'EPSG:32610' (Zone 10N)Raw lines 140 to 161Auto-Detect UTM Zone with GeoPandas
import geopandas as gpd
# Load data
gdf = gpd.read_file('data.geojson')
# Estimate best UTM zone
utm_crs = gdf.estimate_utm_crs()
print(f"Best UTM CRS: {utm_crs}")
# Reproject
gdf_projected = gdf.to_crs(utm_crs)Raw lines 162 to 177Special UTM Cases
UPS (Universal Polar Stereographic):
- EPSG:5041 - UPS North (Arctic)
- EPSG:5042 - UPS South (Antarctic)
Other national grids (not UTM):
- EPSG:31466-31469 - German Gauss-Krüger zones
- EPSG:2056 - Swiss LV95 (oblique Mercator)
Transformations
Raw lines 188 to 189Basic Transformation
from pyproj import Transformer
# Create transformer
transformer = Transformer.from_crs(
"EPSG:4326", # WGS 84 (lat/lon)
"EPSG:32610", # UTM Zone 10N for this California point
always_xy=True # Input: x=lon, y=lat (not y=lat, x=lon)
)
# Transform single point
lon, lat = -122.4, 37.7
x, y = transformer.transform(lon, lat)
print(f"Easting: {x:.2f}, Northing: {y:.2f}")Raw lines 190 to 207Batch Transformation
import numpy as np
from pyproj import Transformer
# Arrays of coordinates
lon_array = [-122.4, -122.3]
lat_array = [37.7, 37.8]
transformer = Transformer.from_crs("EPSG:4326", "EPSG:32610", always_xy=True)
xs, ys = transformer.transform(lon_array, lat_array)Raw lines 208 to 221Transformation with PyProj CRS
from pyproj import CRS
# Get CRS information
crs = CRS.from_epsg(32633)
print(f"Name: {crs.name}")
print(f"Type: {crs.type_name}")
print(f"Area of use: {crs.area_of_use.name}")
print(f"Datum: {crs.datum.name}")
print(f"Ellipsoid: {crs.ellipsoid.name}")Raw lines 222 to 236Best Practices
Raw lines 237 to 2381. Always Know Your CRS
import geopandas as gpd
gdf = gpd.read_file('data.geojson')
# Check CRS immediately
print(f"CRS: {gdf.crs}") # Should never be None!
# If absent, obtain the source CRS from metadata; never guess it from bounds.
if gdf.crs is None:
raise ValueError("Source CRS is unknown")Raw lines 239 to 2532. Verify CRS Before Operations
def ensure_same_crs(gdf1, gdf2):
"""Ensure two GeoDataFrames have same CRS."""
if gdf1.crs is None or gdf2.crs is None:
raise ValueError("Source CRS is unknown")
if gdf1.crs != gdf2.crs:
gdf2 = gdf2.to_crs(gdf1.crs)
print(f"Reprojected gdf2 to {gdf1.crs}")
return gdf1, gdf2
# Use before spatial operations
zones, points = ensure_same_crs(zones_gdf, points_gdf)
result = gpd.sjoin(points, zones, predicate='within')Raw lines 254 to 2703. Use Appropriate Projections
# For local analysis (< 500km extent)
gdf_local = gdf.to_crs(gdf.estimate_utm_crs())
# For national/regional analysis
gdf_us = gdf.to_crs("EPSG:5070") # NAD83 / Conus Albers
gdf_eu = gdf.to_crs("EPSG:3035") # Europe Equal Area
# For web visualization
gdf_web = gdf.to_crs("EPSG:3857") # Web MercatorRaw lines 271 to 2844. Preserve Original CRS
# Keep original as backup
gdf_original = gdf.copy()
original_crs = gdf.crs
# Do analysis in projected CRS
gdf_projected = gdf.to_crs(gdf.estimate_utm_crs())
result = gdf_projected.geometry.buffer(1000)
# Convert back if needed
result = result.to_crs(original_crs)Raw lines 285 to 299Common Pitfalls
Raw lines 300 to 301Mistake 1: Area in Degrees
# WRONG: Area in square degrees
gdf = gpd.read_file('data.geojson')
area = gdf.geometry.area # Wrong!
# CORRECT: Use projected CRS
gdf_proj = gdf.to_crs(gdf.estimate_utm_crs())
area_sqm = gdf_proj.geometry.area
area_sqkm = area_sqm / 1_000_000Raw lines 302 to 314Mistake 2: Buffer in Geographic CRS
# WRONG: Buffer of 1000 degrees
gdf['buffer'] = gdf.geometry.buffer(1000)
# CORRECT: Project first
gdf_proj = gdf.to_crs("EPSG:32610")
gdf_proj['buffer_km'] = gdf_proj.geometry.buffer(1000) # 1000 metersRaw lines 315 to 325Mistake 3: Mixing CRS
# WRONG: Spatial join without checking CRS
result = gpd.sjoin(gdf1, gdf2, predicate='intersects')
# CORRECT: Ensure same CRS
if gdf1.crs != gdf2.crs:
gdf2 = gdf2.to_crs(gdf1.crs)
result = gpd.sjoin(gdf1, gdf2, predicate='intersects')Raw lines 326 to 337Quick Reference
# Common operations
# Check CRS
gdf.crs
rasterio.open('file.tif').crs
# Reproject
gdf.to_crs("EPSG:32633")
# Auto-detect UTM
gdf.estimate_utm_crs()
# Transform single point
from pyproj import Transformer
tx = Transformer.from_crs("EPSG:4326", "EPSG:32610", always_xy=True)
x, y = tx.transform(lon, lat)
# Create custom CRS
from pyproj import CRS
custom_crs = CRS.from_proj4(
"+proj=utm +zone=10 +ellps=WGS84 +datum=WGS84 +units=m +no_defs"
)For more information, see:
Raw lines 338 to 368