Skip to content

UTM Zone & EPSG Helpers#

UTM helpers in pyramids.utm for resolving the UTM zone / EPSG code of a WGS84 point or vector layer — used for per-tile reprojection, local-metre areas of interest, and STAC point cubes.

These return the EPSG-correct zone: the plain 6°-wide longitude bands, with the hemisphere selecting the 326xx (north) or 327xx (south) band. The Norway/Svalbard zone shifts are the MGRS grid-zone lettering convention, not the UTM CRS definitions, so they are deliberately not applied — EPSG:32631 (0°E–6°E) is the zone whose area of use covers Bergen at 5°E, while EPSG:32632 (6°E–12°E) does not. The values here agree with pyproj.database.query_utm_crs_info.

Functions#

pyramids.utm.utm_zone(lon) #

Return the UTM zone number (1-60) for a longitude.

The zone is a plain 6°-wide band: zone 1 starts at 180°W, and each zone spans 6° of longitude. The value depends only on lon; latitude selects the hemisphere band in utm_epsg, not the zone number.

Parameters:

Name Type Description Default
lon float

Longitude in degrees. A longitude outside [-180, 180] — e.g. the 0..360 convention common in climate/ocean grids — is wrapped into [-180, 180] first, so 200 is treated as -160.

required

Returns:

Name Type Description
int int

The UTM zone number, 1..60.

Examples:

  • Greenwich sits at the zone 30/31 boundary and lands in zone 31:
    >>> from pyramids.utm import utm_zone
    >>> utm_zone(0.0)
    31
    
  • Bergen (5°E) is zone 31 — the plain band, not the MGRS zone-32 shift:
    >>> utm_zone(5.0)
    31
    
  • A 0..360 longitude is wrapped, so 200°E resolves like 160°W:
    >>> utm_zone(200.0)
    4
    
Source code in src/pyramids/utm.py
def utm_zone(lon: float) -> int:
    """Return the UTM zone number (1-60) for a longitude.

    The zone is a plain 6°-wide band: zone 1 starts at 180°W, and each zone spans
    6° of longitude. The value depends only on `lon`; latitude selects the
    hemisphere band in `utm_epsg`, not the zone number.

    Args:
        lon: Longitude in degrees. A longitude outside `[-180, 180]` — e.g. the
            `0..360` convention common in climate/ocean grids — is wrapped into
            `[-180, 180]` first, so `200` is treated as `-160`.

    Returns:
        int: The UTM zone number, `1..60`.

    Examples:
        - Greenwich sits at the zone 30/31 boundary and lands in zone 31:
            ```python
            >>> from pyramids.utm import utm_zone
            >>> utm_zone(0.0)
            31

            ```
        - Bergen (5°E) is zone 31 — the plain band, not the MGRS zone-32 shift:
            ```python
            >>> utm_zone(5.0)
            31

            ```
        - A `0..360` longitude is wrapped, so 200°E resolves like 160°W:
            ```python
            >>> utm_zone(200.0)
            4

            ```
    """
    if lon < -180.0 or lon > 180.0:
        lon = ((lon + 180.0) % 360.0) - 180.0
    zone = math.floor((lon + 180.0) / 6.0) + 1
    zone = min(max(zone, 1), 60)
    return zone

pyramids.utm.utm_epsg(lon, lat) #

Return the EPSG code of the UTM zone containing (lon, lat).

Parameters:

Name Type Description Default
lon float

Longitude in degrees (wrapped into [-180, 180]; see utm_zone).

required
lat float

Latitude in degrees. Only its sign is used (the equator is treated as northern) to pick the northern (326xx) or southern (327xx) band. UTM is defined for roughly 80°S..84°N; a latitude outside that band still returns a code, but the poles are properly the domain of UPS, not UTM.

required

Returns:

Name Type Description
int int

326NN (northern hemisphere) or 327NN (southern) for UTM zone NN.

Examples:

  • Bergen, Norway resolves to UTM 31N — matching pyproj and the EPSG area of use, not the MGRS zone-32 convention:
    >>> from pyramids.utm import utm_epsg
    >>> utm_epsg(5.0, 60.0)
    32631
    
  • A southern-hemisphere point uses the 327xx band:
    >>> utm_epsg(31.25, -25.0)
    32736
    
Source code in src/pyramids/utm.py
def utm_epsg(lon: float, lat: float) -> int:
    """Return the EPSG code of the UTM zone containing `(lon, lat)`.

    Args:
        lon: Longitude in degrees (wrapped into `[-180, 180]`; see `utm_zone`).
        lat: Latitude in degrees. Only its sign is used (the equator is treated as
            northern) to pick the northern (`326xx`) or southern (`327xx`) band. UTM
            is defined for roughly `80°S..84°N`; a latitude outside that band still
            returns a code, but the poles are properly the domain of UPS, not UTM.

    Returns:
        int: `326NN` (northern hemisphere) or `327NN` (southern) for UTM zone `NN`.

    Examples:
        - Bergen, Norway resolves to UTM 31N — matching `pyproj` and the EPSG area
          of use, not the MGRS zone-32 convention:
            ```python
            >>> from pyramids.utm import utm_epsg
            >>> utm_epsg(5.0, 60.0)
            32631

            ```
        - A southern-hemisphere point uses the `327xx` band:
            ```python
            >>> utm_epsg(31.25, -25.0)
            32736

            ```
    """
    base = _UTM_NORTH_BASE if lat >= 0 else _UTM_SOUTH_BASE
    return base + utm_zone(lon)

pyramids.utm.utm_epsg_for_polygon(gdf) #

Return the UTM EPSG for a vector layer, from the centre of its bounds.

The layer is reprojected to WGS84 (a no-op when it is already EPSG:4326), the centre of its total bounds is taken, and that lon/lat is passed to utm_epsg. Any geometry type is accepted (points, lines, polygons); the zone is chosen from the bounds centre, not a true geometric centroid.

The single zone returned is the one at the bounds centre; it is exact only for a layer that fits within one 6°-wide zone, and is a best-effort choice for a wider layer (which spans several zones). Only the clearly-nonsensical case is rejected: bounds spanning more than 180° of longitude — a dateline-crossing layer (whose total bounds spuriously spans -180…180), a near-polar extent (whose longitudes fan out around the pole), or a genuinely half-globe span — where the mid-span centre would name a zone covering none of the data.

Parameters:

Name Type Description Default
gdf GeoDataFrame

A GeoDataFrame with a defined CRS and at least one finite-bounds geometry.

required

Returns:

Name Type Description
int int

The EPSG code of the UTM zone at the layer's bounds centre.

Raises:

Type Description
CRSError

gdf has no CRS (so its coordinates cannot be placed on Earth).

ValueError

gdf is empty / has no finite bounds, or its bounds span more than 180° of longitude (no single UTM zone applies).

Source code in src/pyramids/utm.py
def utm_epsg_for_polygon(gdf: GeoDataFrame) -> int:
    """Return the UTM EPSG for a vector layer, from the centre of its bounds.

    The layer is reprojected to WGS84 (a no-op when it is already `EPSG:4326`), the
    centre of its total bounds is taken, and that lon/lat is passed to `utm_epsg`.
    Any geometry type is accepted (points, lines, polygons); the zone is chosen from
    the bounds centre, not a true geometric centroid.

    The single zone returned is the one at the bounds centre; it is exact only for a
    layer that fits within one 6°-wide zone, and is a best-effort choice for a wider
    layer (which spans several zones). Only the clearly-nonsensical case is rejected:
    bounds spanning more than 180° of longitude — a dateline-crossing layer (whose
    total bounds spuriously spans `-180…180`), a near-polar extent (whose longitudes
    fan out around the pole), or a genuinely half-globe span — where the mid-span
    centre would name a zone covering none of the data.

    Args:
        gdf: A `GeoDataFrame` with a defined CRS and at least one finite-bounds
            geometry.

    Returns:
        int: The EPSG code of the UTM zone at the layer's bounds centre.

    Raises:
        CRSError: `gdf` has no CRS (so its coordinates cannot be placed on Earth).
        ValueError: `gdf` is empty / has no finite bounds, or its bounds span more
            than 180° of longitude (no single UTM zone applies).
    """
    if gdf.crs is None:
        raise CRSError(
            "gdf has no CRS; set one (gdf.set_crs / gdf.crs = ...) before computing "
            "a UTM zone."
        )
    wgs84 = gdf.to_crs(_WGS84_EPSG)
    minx, miny, maxx, maxy = wgs84.total_bounds
    if not all(math.isfinite(v) for v in (minx, miny, maxx, maxy)):
        raise ValueError(
            "gdf has no finite bounds (it is empty or all its geometries are null); "
            "cannot compute a UTM zone."
        )
    if maxx - minx > 180.0:
        raise ValueError(
            f"gdf bounds span {maxx - minx:.1f}° of longitude (a dateline crossing, "
            "a near-polar extent, or a half-globe span); no single UTM zone applies."
        )
    return utm_epsg((minx + maxx) / 2.0, (miny + maxy) / 2.0)

pyramids.utm.project_to_utm(gdf) #

Reproject a vector layer to its local UTM zone.

Parameters:

Name Type Description Default
gdf GeoDataFrame

A GeoDataFrame with a defined CRS.

required

Returns:

Type Description
GeoDataFrame

tuple[GeoDataFrame, int]: The layer reprojected to its UTM zone (a fresh

int

GeoDataFrame; the input is not modified), and that zone's EPSG code.

Raises:

Type Description
CRSError

gdf has no CRS.

ValueError

gdf is empty / has no finite bounds, or its bounds span more than 180° of longitude (see :func:utm_epsg_for_polygon).

Source code in src/pyramids/utm.py
def project_to_utm(gdf: GeoDataFrame) -> tuple[GeoDataFrame, int]:
    """Reproject a vector layer to its local UTM zone.

    Args:
        gdf: A `GeoDataFrame` with a defined CRS.

    Returns:
        tuple[GeoDataFrame, int]: The layer reprojected to its UTM zone (a fresh
        `GeoDataFrame`; the input is not modified), and that zone's EPSG code.

    Raises:
        CRSError: `gdf` has no CRS.
        ValueError: `gdf` is empty / has no finite bounds, or its bounds span more
            than 180° of longitude (see :func:`utm_epsg_for_polygon`).
    """
    epsg = utm_epsg_for_polygon(gdf)
    return gdf.to_crs(epsg), epsg