Source code for pycsamt.gis.utils

# Author: L. Kouadio <etanoyau@gmail.com>
# License: LGPL-3.0

r"""
GIS utilities for *pycsamt* (v2). Provides coordinate
transformations between geographic (lat/lon) and UTM using
GDAL (preferred) or a PROJ fallback (``pyproj``).

Core features
-------------
- Geographic ⇄ UTM conversions (scalar and vectorized).
- Flexible latitude/longitude parsing (decimal and DMS).
- Small helpers for UTM zones and EPSG lookup.

Examples
--------
>>> from pycsamt.gis.utils import project_point_ll2utm
>>> project_point_ll2utm(-118.34, 34.05)  # doctest: +SKIP

.. note::

   Parts of this module are adapted from the **MTPy** project
   (https://github.com/MTgeophysics/mtpy-v2). Original work:
   Krieger, L., & Peacock, J. (2014), *MTpy: A Python toolbox
   for magnetotellurics*, Computers & Geosciences, 72, 167–175,
   https://doi.org/10.1016/j.cageo.2014.07.013.

   The implementation here has been revised, fixed, and
   extended for *pycsamt* v2, with additional validation,
   deprecations, and compatibility layers.
"""

from __future__ import annotations

import re
from collections.abc import Sequence
from typing import (
    Any,
    Literal,
    overload,
)

import numpy as np
import pandas as pd
from numpy.typing import NDArray

from ..decorators import Deprecated
from ..log.logger import get_logger
from .config import (
    EPSG_DICT,
    HAS_GDAL,
    ElevationAPIConfig,
    GDALMissingError,
    GisError,
)
from .constants import (
    _ECC_SQUARED_IDX,
    _EQUATORIAL_RADIUS_IDX,
    DEG2RAD,
    ELLIPSOIDS,
    RAD2DEG,
    utm_letter_designator,
)

if HAS_GDAL:
    from osgeo import osr
    from osgeo.ogr import OGRERR_NONE
else:
    import pyproj  # type: ignore

logger = get_logger(__name__)


Arr1D = NDArray[np.float64]
Obj1D = NDArray[object]

__all__ = [
    "project_point_ll2utm",
    "project_point_utm2ll",
    "ll_to_utm",
    "utm_to_ll",
    "get_utm_string_from_sr",
    "get_utm_zone",
    "utm_zone_to_epsg",
    "get_epsg",
    "assert_xy_coordinate_system",
    "decimal_to_dms",
    "dms_to_decimal",
    "to_utm",
    "to_ll",
    "normalize_lat_lon",
    "GisError",
]

_HEMI_RE = re.compile(r"\s*([NSEW])\s*$", re.IGNORECASE)
_HEMI_SIGN = {"N": 1, "E": 1, "S": -1, "W": -1}


@overload
def normalize_lat_lon(
    a: float | str,
    b: float | str,
    *,
    assume: Literal["lonlat", "latlon", "auto"] = ...,
    error: Literal["ignore", "raise"] = ...,
    clip: bool = ...,
) -> tuple[float, float]: ...


@overload
def normalize_lat_lon(
    a: Sequence[float | str],
    b: Sequence[float | str],
    *,
    assume: Literal["lonlat", "latlon", "auto"] = ...,
    error: Literal["ignore", "raise"] = ...,
    clip: bool = ...,
) -> tuple[np.ndarray, np.ndarray]: ...


[docs] def normalize_lat_lon( a: Any, b: Any, *, assume: Literal["lonlat", "latlon", "auto"] = "lonlat", error: Literal["ignore", "raise"] = "ignore", clip: bool = False, ): r""" Resolve input pair to ``(lat, lon)`` regardless of order. Accepts values in either legacy order (lat, lon) or the new expected order (lon, lat). Returns a tuple in the canonical order ``(lat, lon)`` and avoids common ``|Latitude| > 90`` errors by swapping when clear. Parameters ---------- a, b : float or str, or 1-D sequences Coordinate components. Strings may be decimal or DMS (``"DD:MM:SS"``). Sequences must be same length. assume : {"lonlat","latlon","auto"}, default "lonlat" Tie-break for ambiguous pairs (both ``|val| <= 90``). - ``"lonlat"``: treat input as (lon, lat). - ``"latlon"``: treat input as (lat, lon). - ``"auto"``: prefer (lon, lat) when ambiguous. error : {"ignore","raise"}, default "ignore" On impossible pairs (both ``|val| > 90`` or both ``|val| > 180``), either raise or return ``nan``s. clip : bool, default False If ``True``, clip lat to [-90, 90] and lon to [-180, 180] when slightly out-of-range. Returns ------- lat, lon : float or ndarray Coordinates in canonical order. Notes ----- Heuristics: - If one value is in (90, 180] and the other in [-90, 90], the former is lon and the latter is lat. - If both are ``|val| <= 90``, use ``assume``. - Values with ``|val| > 180`` are invalid unless ``clip``. """ def _to_float(v): if v is None or v == "None": return np.nan if isinstance(v, str): try: return float(v) except Exception: try: return convert_position_str2float(v) # type: ignore # noqa: E501 except Exception: return np.nan try: return float(v) except Exception: return np.nan def _coerce_pair(x, y): xv = _to_float(x) yv = _to_float(y) ax = abs(xv) ay = abs(yv) # invalid domain checks if ax > 1e9 or ay > 1e9: xv = np.nan yv = np.nan # quick invalid > 180 if ax > 180 or ay > 180: if clip: xv = np.clip(xv, -180.0, 180.0) yv = np.clip(yv, -180.0, 180.0) ax = abs(xv) ay = abs(yv) else: if error == "raise": raise ValueError("Values exceed 180.") return (np.nan, np.nan) # decisive cases if (90 < ax <= 180) and (ay <= 90): lon = xv lat = yv return (lat, lon) if (90 < ay <= 180) and (ax <= 90): lon = yv lat = xv return (lat, lon) # both within 90 → ambiguous if ax <= 90 and ay <= 90: if assume == "latlon": lat, lon = xv, yv else: # "lonlat" and "auto" prefer lon,lat input lat, lon = yv, xv return (lat, lon) # one slightly out of 90 but not decisive if clip: xv = np.clip(xv, -180.0, 180.0) yv = np.clip(yv, -180.0, 180.0) ax = abs(xv) ay = abs(yv) # fallback: try assume if assume == "latlon": lat, lon = xv, yv else: lat, lon = yv, xv # final range checks if abs(lat) > 90 or abs(lon) > 180: if error == "raise": raise ValueError("Unresolvable pair.") return (np.nan, np.nan) return (lat, lon) # scalar vs vector handling if np.isscalar(a) and np.isscalar(b): return _coerce_pair(a, b) a_arr = np.asarray(a, dtype=object) b_arr = np.asarray(b, dtype=object) if a_arr.shape != b_arr.shape: raise ValueError("Shapes of a and b must match.") lat_out = np.empty_like(a_arr, dtype=float) lon_out = np.empty_like(b_arr, dtype=float) it = np.nditer( [a_arr, b_arr, lat_out, lon_out], flags=["multi_index", "refs_ok"], op_flags=[["readonly"], ["readonly"], ["writeonly"], ["writeonly"]], ) for xa, xb, yl, yo in it: lat, lon = _coerce_pair(xa.item(), xb.item()) yl[...] = lat yo[...] = lon return lat_out, lon_out
[docs] def assert_xy_coordinate_system(x, y) -> str: r""" Infer the coordinate system of paired ``x`` and ``y`` arrays. Heuristics detect one of three systems: * ``'ll'`` — longitude/latitude in decimal degrees. * ``'dms'`` — degree-minute-second strings ``DD:MM:SS``. * ``'utm'`` — any other numeric system (fallback). Parameters ---------- x, y : array-like (1D) Arrays of horizontal (``x``) and vertical (``y``) positions. Elements may be numeric or DMS strings. Returns ------- cs : {'utm', 'dms', 'll'} Inferred coordinate system token. Notes ----- - DMS is detected if any array consists entirely of strings containing the ``':'`` separator. - Decimal degrees are detected when either orientation matches bounds: * ``|x| <= 180`` and ``|y| <= 90`` (lon, lat), or * ``|x| <= 90`` and ``|y| <= 180`` (lat, lon). - If neither DMS nor decimal-degree bounds match, the function returns ``'utm'`` by default. Examples -------- >>> import numpy as np >>> np.random.seed(42) >>> x, y = np.random.rand(7) * 1.0, np.arange(7) * 0.1 >>> assert_xy_coordinate_system(x, y) 'll' >>> assert_xy_coordinate_system(x * 1000, y * 1000) 'utm' >>> x = ['28:24:43.08', '28:24:42.69', '28:24:42.31'] >>> y = ['109:19:58.34', '109:19:58.93', '109:19:59.51'] >>> assert_xy_coordinate_system(x, y) 'dms' """ def _is_dms(a) -> bool: a = np.asarray(a, dtype=object).ravel() if a.size == 0: return False return all(":" in str(v) for v in a) # Normalize to numpy arrays xa = np.asarray(x, dtype=object).ravel() ya = np.asarray(y, dtype=object).ravel() # DMS detection if _is_dms(xa) or _is_dms(ya): return "dms" # Try numeric interpretation try: xf = np.asarray(xa, dtype=float) yf = np.asarray(ya, dtype=float) except (ValueError, TypeError): return "utm" # Decimal-degree bounds (either orientation) x180_y90 = np.all(np.abs(xf) <= 180.0) and np.all(np.abs(yf) <= 90.0) x90_y180 = np.all(np.abs(xf) <= 90.0) and np.all(np.abs(yf) <= 180.0) if x180_y90 or x90_y180: return "ll" return "utm"
def _assert_minutes(minutes: float) -> float: r""" Validate a minutes component in DMS notation. Ensures that ``minutes`` lies in the half-open interval ``[0, 60)``. Returns the input when valid, otherwise raises ``ValueError``. Parameters ---------- minutes : float Minutes component to validate. Returns ------- float The validated minutes value. Raises ------ ValueError If ``minutes`` is not in ``[0, 60)``. Examples -------- >>> _assert_minutes(59.9) 59.9 """ if not (0 <= minutes < 60): msg = f"Minutes must be in [0, 60): {minutes}" raise ValueError(msg) return minutes def _assert_seconds(seconds: float) -> float: r""" Validate a seconds component in DMS notation. Ensures that ``seconds`` lies in the half-open interval ``[0, 60)``. Returns the input when valid, otherwise raises ``ValueError``. Parameters ---------- seconds : float Seconds component to validate. Returns ------- float The validated seconds value. Raises ------ ValueError If ``seconds`` is not in ``[0, 60)``. Examples -------- >>> _assert_seconds(12.345) 12.345 """ if not (0 <= seconds < 60): msg = f"Seconds must be in [0, 60): {seconds}" raise ValueError(msg) return seconds def _rollover_dms( unit: float, value: float, ) -> tuple[float, float]: r""" Carry over excess seconds or minutes into the higher unit. If ``value >= 60``, this function computes the carry and remainder, returning ``(unit + carry, remainder)``. If no carry is needed, returns ``(unit, value)``. Parameters ---------- unit : float The higher unit (minutes for seconds rollover, or degrees for minutes rollover). value : float The lower unit to check and normalize. Returns ------- tuple of float A pair ``(new_unit, remainder)`` after rollover. Examples -------- >>> _rollover_dms(10, 120) # 120 sec -> 2 min (12, 0) """ carry = int(value // 60) remainder = value % 60 if carry: return (unit + carry, remainder) return (unit, remainder)
[docs] def dms_to_decimal(position_str): r""" Convert a DMS string or decimal degrees to a float. This is a convenience wrapper around :func:`convert_position_str2float`. It accepts either a decimal degrees string/number or a sexagesimal string in ``'DD:MM:SS.sss'`` format. Parameters ---------- position_str : str or float Coordinate in decimal degrees or DMS string. Returns ------- float or None Decimal degrees value, or ``None`` for invalid input. Examples -------- >>> dms_to_decimal("34:03:00") 34.05 >>> dms_to_decimal("-118.34") -118.34 """ return convert_position_str2float(position_str)
def convert_position_str2float( position_str: str, ) -> float | None: r""" Convert DMS string ``'DD:MM:SS.sss'`` or decimal string to a float in decimal degrees. Accepts optional trailing hemisphere letter: 'N','S','E','W' (e.g., '26:00:00N', '10:00:00 W'). The function first attempts a direct ``float()`` cast. If that fails, it parses the DMS components, validates them, and applies rollover so that values like ``'10:60:00'`` become ``'11:00:00'``. Parameters ---------- position_str : str Input coordinate as decimal degrees or DMS string. Returns ------- float or None Decimal degrees value. Returns ``None`` for inputs that are ``None`` or the string ``'None'``. Raises ------ ValueError If the string is not a valid decimal or DMS in the form ``DD:MM:SS`` (with optional decimals). Notes ----- - Sign is taken from the degrees component only. Minutes and seconds are treated as non-negative magnitudes. - Rollover is applied to keep minutes and seconds within ``[0, 60)``. Examples -------- >>> convert_position_str2float("34:03:00") 34.05 >>> convert_position_str2float("-118:20:24") -118.34 >>> convert_position_str2float("-118.34") -118.34 """ if position_str in (None, "None"): return None # Fast path: plain decimal string/number try: return float(position_str) except (TypeError, ValueError): pass # Normalize and peel optional hemisphere at end s = str(position_str).strip().strip('"').strip("'") hemi_sign: int | None = None m = _HEMI_RE.search(s) if m: hemi = m.group(1).upper() hemi_sign = _HEMI_SIGN[hemi] s = s[: m.start()].rstrip() parts = s.split(":") if len(parts) != 3: msg = f"Invalid DMS, expected 'DD:MM:SS': {position_str}" raise ValueError(msg) # Degrees may carry sign; minutes/seconds must be non-negative deg = float(parts[0]) try: minutes = abs(float(parts[1])) except ValueError as exc: raise ValueError(f"Invalid minutes: {parts[1]!r}") from exc try: seconds = abs(float(parts[2])) except ValueError as exc: # noqa # Handle cases like '00N' already stripped above, but be safe # by removing any trailing non-numeric tokens. t = re.sub(r"[^\d.+\-eE]", "", parts[2]) try: seconds = abs(float(t)) except Exception as exc2: raise ValueError(f"Invalid seconds: {parts[2]!r}") from exc2 # Sign: hemisphere (if any) overrides degree sign. Capture it # before rollover so carries apply to the magnitude, not the # signed value ('-118:60:00' must become -119, not -117). sign = -1 if deg < 0 else 1 if hemi_sign is not None: sign = hemi_sign deg = abs(deg) # Rollover SS->MM then MM->DD, then validate the remainders # (validating first would reject '10:60:00', which the rollover # exists to normalize to 11:00:00). minutes, seconds = _rollover_dms(minutes, seconds) deg, minutes = _rollover_dms(deg, minutes) minutes = _assert_minutes(minutes) seconds = _assert_seconds(seconds) return sign * (deg + minutes / 60.0 + seconds / 3600.0) def assert_lat_value(latitude) -> float | None: r""" Validate and coerce a latitude to decimal degrees. Accepts a float-like value or a sexagesimal string and returns the value in decimal degrees. Returns ``None`` for ``None``-like inputs. Raises ``ValueError`` if the coerced value is out of valid latitude bounds. Parameters ---------- latitude : float or str or None Latitude in decimal degrees or as a string in ``"DD:MM:SS.sss"`` format. ``None`` and ``"None"`` are treated as missing. Returns ------- float or None Decimal degrees in ``[-90, 90)`` or ``None`` when the input is missing. Notes ----- Sexagesimal parsing is delegated to :func:`convert_position_str2float`. Examples -------- >>> assert_lat_value("34:03:00") 34.05 """ if latitude in [None, "None"]: return None try: lat_value = float(latitude) except TypeError: return None except ValueError: lat_value = convert_position_str2float(latitude) if abs(lat_value) >= 90: raise ValueError(f"|Latitude| > 90, unacceptable!: {lat_value!r}") return lat_value def assert_lon_value(longitude) -> float | None: r""" Validate and coerce a longitude to decimal degrees. Accepts a float-like value or a sexagesimal string and returns the value in decimal degrees. Returns ``None`` for ``None``-like inputs. Raises ``ValueError`` if the coerced value is out of valid longitude bounds. Parameters ---------- longitude : float or str or None Longitude in decimal degrees or as a string in ``"DD:MM:SS.sss"`` format. ``None`` and ``"None"`` are treated as missing. Returns ------- float or None Decimal degrees in ``[-180, 180)`` or ``None`` when the input is missing. Notes ----- Sexagesimal parsing is delegated to :func:`convert_position_str2float`. Examples -------- >>> assert_lon_value("-118:20:24") -118.34 """ if longitude in [None, "None"]: return None try: lon_value = float(longitude) except TypeError: return None except ValueError: lon_value = convert_position_str2float(longitude) if abs(lon_value) >= 180: raise ValueError(f"|Longitude| > 180, unacceptable!: {lon_value!r}") return lon_value def assert_elevation_value(elevation) -> float: r""" Validate and coerce an elevation to a floating number. Attempts to cast ``elevation`` to ``float``. If coercion fails, returns ``0.0`` and logs a warning. Parameters ---------- elevation : Any Elevation value expected to be numeric. Returns ------- float Elevation as a floating number. ``0.0`` is returned on invalid input. Notes ----- A warning is logged via the package logger when the input is not numeric. Examples -------- >>> assert_elevation_value("12.5") 12.5 >>> assert_elevation_value("oops") 0.0 """ try: elev_value = float(elevation) except (ValueError, TypeError): elev_value = 0.0 logger.warning(f"{elevation} is not a number, setting elevation to 0") return elev_value
[docs] def decimal_to_dms(position: float) -> str: r""" Convert decimal degrees to a ``DD:MM:SS.ss`` string. Parameters ---------- position : float Decimal degrees for latitude or longitude. Returns ------- str Sexagesimal string in ``"DD:MM:SS.ss"`` format. See Also -------- convert_position_float2str Underlying converter used by this wrapper. Examples -------- >>> decimal_to_dms(-118.34563) '-118:20:44.27' """ return convert_position_float2str(position)
def convert_position_float2str(position: float) -> str: r""" Convert a decimal-degree value to ``DD:MM:SS.ss`` string. Parameters ---------- position : float Decimal degrees of latitude or longitude. Returns ------- str Sexagesimal string in ``"DD:MM:SS.ss"`` format. Notes ----- The seconds field is rounded to 4 decimals to avoid carry-over artifacts from floating point precision. If rounding pushes seconds to ``60``, minutes are incremented and seconds reset to ``0``. Examples -------- >>> convert_position_float2str(-118.34563) '-118:20:44.27' """ assert type(position) is float, "Given value is not a float" deg = int(position) sign = -1 if deg < 0 else 1 deg = abs(deg) minutes = (abs(position) - deg) * 60.0 sec = np.round((minutes - int(minutes)) * 60.0, 4) if sec >= 60.0: minutes += 1.0 sec = 0.0 if int(minutes) == 60: deg += 1 minutes = 0.0 position_str = f"{sign * int(deg)}:{int(minutes):02.0f}:{sec:05.2f}" return position_str
[docs] @Deprecated( "GDAL SpatialReference → UTM string is deprecated; " "use 'get_utm_zone' for standard UTM formatting." ) def get_utm_string_from_sr(spatial_ref: osr.SpatialReference) -> str: r""" Return a UTM zone string (e.g., ``'11N'``) from a GDAL ``SpatialReference``. Parameters ---------- spatial_ref : osr.SpatialReference GDAL spatial reference object with a UTM projection. Returns ------- str UTM zone string such as ``'11N'`` or ``'55S'``. If a valid UTM zone is not encoded, returns ``'0'``. Notes ----- This helper is deprecated. Prefer :func:`get_utm_zone`, which derives a zone from latitude/longitude using the standard UTM rules. Examples -------- >>> # sr is an osr.SpatialReference set to a UTM CRS >>> # get_utm_string_from_sr(sr) # doctest: +SKIP """ zone = spatial_ref.GetUTMZone() if zone > 0: return f"{zone}N" if zone < 0: return f"{abs(zone)}S" return str(zone)
[docs] def get_utm_zone( latitude: float, longitude: float, ) -> tuple[int, bool, str]: r""" Compute the UTM zone, hemisphere flag, and zone string for a geographic coordinate. Parameters ---------- latitude : float Latitude in decimal degrees. longitude : float Longitude in decimal degrees. Returns ------- zone_number : int UTM zone number in ``[1, 60]``. is_northern : bool ``True`` for the northern hemisphere, else ``False``. zone_string : str Concatenation of zone and latitude band letter, e.g., ``'11N'``. Notes ----- - Zone width is 6 degrees, starting at ``-180°``. Values are wrapped into ``[1, 60]``. - The latitude band letter is obtained via :func:`utm_letter_designator`. Examples -------- >>> get_utm_zone(34.05, -118.34)[2] '11S' """ zone_number = int((longitude + 180.0) / 6.0) + 1 zone_number = ((zone_number - 1) % 60) + 1 is_northern = latitude >= 0.0 letter = utm_letter_designator(latitude) zone_string = f"{zone_number}{letter}" return zone_number, is_northern, zone_string
[docs] def utm_zone_to_epsg( zone_number: int, is_northern: bool, ) -> int | None: r""" Resolve the WGS84 UTM EPSG code for a given UTM zone. Parameters ---------- zone_number : int UTM zone number in ``[1, 60]``. is_northern : bool ``True`` for northern hemisphere, ``False`` for southern hemisphere. Returns ------- int or None Matching EPSG code (e.g., ``32611`` or ``32755``) when found, else ``None``. Notes ----- The lookup scans :data:`EPSG_DICT` for a PROJ string that matches ``+zone``, ``+datum=WGS84``, and an optional ``+south`` flag for the southern hemisphere. Examples -------- >>> utm_zone_to_epsg(11, True) in (32611, None) True """ datum_flag = "" if is_northern else "+south" for epsg_code, proj4 in EPSG_DICT.items(): if ( f"+zone={zone_number}" in proj4 and "+datum=WGS84" in proj4 and datum_flag in proj4 ): return epsg_code return None
[docs] def get_epsg( latitude: float, longitude: float, ) -> int | None: r""" Get the WGS84 UTM EPSG code for a geographic coordinate. Parameters ---------- latitude : float Latitude in decimal degrees. longitude : float Longitude in decimal degrees. Returns ------- int or None EPSG code for the inferred UTM CRS, or ``None`` if no match is found. Notes ----- This is a convenience wrapper that calls :func:`get_utm_zone` and :func:`utm_zone_to_epsg`. Examples -------- >>> epsg = get_epsg(34.05, -118.34) >>> epsg in (32611, 32711, None) True """ zone_number, is_northern, _ = get_utm_zone( latitude, longitude, ) return utm_zone_to_epsg(zone_number, is_northern)
@overload def to_ll( easting: Any, northing: Any, zone: Any | None = ..., data: Any | None = ..., *, datum: str = ..., epsg: int | None = ..., as_frame: Literal[True], ) -> pd.DataFrame: ... @overload def to_ll( easting: float, northing: float, zone: Any | None = ..., data: Any | None = ..., *, datum: str = ..., epsg: int | None = ..., as_frame: Literal[False] = ..., ) -> tuple[float, float]: ... @overload def to_ll( easting: Sequence[float] | Arr1D, northing: Sequence[float] | Arr1D, zone: Sequence[Any] | Obj1D | None = ..., data: Any | None = ..., *, datum: str = ..., epsg: int | None = ..., as_frame: Literal[False] = ..., ) -> tuple[Arr1D, Arr1D]: ...
[docs] def to_ll( easting, northing, zone: Any | None = None, data: Any | None = None, *, datum: str = "WGS84", epsg: int | None = None, as_frame: bool = False, ) -> Any: r""" Convert UTM coordinates to geographic (lat, lon) using :func:`project_point_utm2ll`. Accepts flexible inputs (scalars, arrays, Series, or column names referencing a provided DataFrame). Returns a tuple or a DataFrame with original and converted fields. Parameters ---------- easting, northing : float, array-like, or pd.Series UTM coordinates in meters. When strings are given, they are treated as column names in ``data``. zone : str, int, array-like, or pd.Series, optional UTM zone designator (e.g., ``"11S"``) or signed UTM code (negative for south). If strings are given, they are treated as column names in ``data``. May be omitted when ``epsg`` is provided. data : pd.DataFrame, optional Source of columns when any of the inputs are strings. datum : str, default "WGS84" Datum name for GDAL-backed paths. epsg : int, optional EPSG code that defines the projected CRS. When set, it takes precedence and ``zone`` is not required. as_frame : bool, default False If ``True``, return a DataFrame with columns ``['easting','northing','zone','lat','lon']``. Otherwise, return ``(lat, lon)`` as scalars or arrays. Returns ------- DataFrame or tuple A DataFrame when ``as_frame=True``; otherwise a tuple ``(lat, lon)`` matching the input shape. Notes ----- - Index is preserved when inputs originate from a Series or from ``data[<column>]`` with ``as_frame=True``. - When arrays are given and ``epsg`` is used, the same EPSG applies to all rows. - ``zone`` may be a scalar or an array/Series matching the shape of ``easting`` and ``northing``. Examples -------- Scalars:: >>> to_ll(377274.0, 3762150.0, "11S") # doctest: +SKIP (34.05, -118.34) From a DataFrame:: >>> # df has 'E', 'N', 'zone' columns >>> # to_ll('E','N','zone', data=df, as_frame=True) ... # doctest: +SKIP """ # -------------------------------------------------------------- # Resolve inputs to 1-D numpy arrays; keep optional index # -------------------------------------------------------------- idx = None def _extract(v, name: str): nonlocal idx if isinstance(v, str): if data is None: raise ValueError( f"'{name}' is a column name but 'data' is not provided." ) col = data[v] idx = getattr(col, "index", None) return np.asarray(col.to_numpy()) if hasattr(v, "to_numpy"): # pandas Series idx = getattr(v, "index", None) return np.asarray(v.to_numpy()) if np.isscalar(v): return np.asarray([v]) return np.asarray(v) e_arr = _extract(easting, "easting") n_arr = _extract(northing, "northing") if e_arr.shape != n_arr.shape: raise ValueError( "easting and northing shapes differ: " f"{e_arr.shape} != {n_arr.shape}" ) if zone is not None: if isinstance(zone, str): z_arr = np.asarray([zone], dtype=object) else: z_arr = _extract(zone, "zone") if z_arr.size == 1 and e_arr.size > 1: z_arr = np.full(e_arr.shape, z_arr.item(), dtype=object) if z_arr.shape != e_arr.shape: raise ValueError( "zone shape must match easting/northing or be scalar." ) else: # allow None when epsg is provided z_arr = np.full(e_arr.shape, None, dtype=object) # -------------------------------------------------------------- # Compute lat/lon element-wise (project_point_utm2ll works # on scalars). Keep dtype as float64 for arrays. # -------------------------------------------------------------- lat_list = [] lon_list = [] for i in range(e_arr.size): zval = z_arr.ravel()[i] lat_i, lon_i = project_point_utm2ll( # type: ignore[name-defined] # noqa: E501 float(e_arr.ravel()[i]), float(n_arr.ravel()[i]), zval, datum=datum, epsg=epsg, ) lat_list.append(lat_i) lon_list.append(lon_i) lat_arr = np.asarray(lat_list, dtype=float).reshape(e_arr.shape) lon_arr = np.asarray(lon_list, dtype=float).reshape(n_arr.shape) # -------------------------------------------------------------- # Return per user request # -------------------------------------------------------------- if as_frame: def _orig(v, arr): if np.isscalar(v): return np.asarray([v]) if isinstance(v, str) and data is not None: return np.asarray(data[v].to_numpy()) if hasattr(v, "to_numpy"): return np.asarray(v.to_numpy()) return np.asarray(arr) e_col = _orig(easting, e_arr) n_col = _orig(northing, n_arr) z_col = ( _orig(zone, z_arr) if zone is not None else np.asarray([None] * e_arr.size, dtype=object) ) df = pd.DataFrame( { "easting": e_col, "northing": n_col, "zone": z_col, "lat": lat_arr, "lon": lon_arr, } ) if idx is not None and len(df) == len(idx): df.index = idx return df # scalar squeeze if the inputs were scalars if ( np.isscalar(easting) and np.isscalar(northing) and (np.isscalar(zone) or zone is None) ): return (lat_arr.item(), lon_arr.item()) return (lat_arr, lon_arr)
# ---------- overloads for to_utm ---------- @overload def to_utm( lat: Any, lon: Any, data: Any | None = ..., *, datum: str = ..., utm_zone: str | None = ..., epsg: int | None = ..., as_frame: Literal[True], ) -> pd.DataFrame: ... @overload def to_utm( lat: float, lon: float, data: Any | None = ..., *, datum: str = ..., utm_zone: str | None = ..., epsg: int | None = ..., as_frame: Literal[False] = ..., ) -> tuple[float, float, str | None]: ... @overload def to_utm( lat: Sequence[Any] | Arr1D, lon: Sequence[Any] | Arr1D, data: Any | None = ..., *, datum: str = ..., utm_zone: str | None = ..., epsg: int | None = ..., as_frame: Literal[False] = ..., ) -> tuple[Arr1D, Arr1D, Obj1D]: ...
[docs] def to_utm( lat: str | float | Sequence, lon: str | float | Sequence, data: Any | None = None, *, datum: str = "WGS84", utm_zone: str | None = None, epsg: int | None = None, as_frame: bool = False, ) -> Any: r""" Convert geographic coordinates to UTM using :func:`project_point_ll2utm`. Accepts flexible latitude/longitude inputs (scalars, arrays, pandas Series, or column names referencing a passed ``data`` frame). Returns either arrays/tuples or a ``DataFrame`` with original and UTM fields. Parameters ---------- lat, lon : float, str, array-like, or pd.Series Geographic coordinates in decimal degrees or DMS strings. If a string is given, it is treated as a column name and ``data`` must be provided. data : pd.DataFrame, optional Source of columns when ``lat`` or ``lon`` are strings. datum : str, default "WGS84" Datum name or EPSG (int) accepted by the underlying transformer. utm_zone : str, optional UTM zone (e.g., ``"11S"``). If absent, it is inferred from the input centroid (or per-point for scalar). epsg : int, optional EPSG code for the projected CRS. Overrides ``utm_zone`` when provided. as_frame : bool, default False If ``True``, return a pandas ``DataFrame`` with columns ``['lat','lon','easting','northing','zone']``. If ``False``, return ``(easting, northing, zone)`` as arrays (or scalars for scalar inputs). Returns ------- DataFrame or tuple When ``as_frame=True``, a frame with original and UTM columns. Otherwise a tuple ``(e, n, zone)`` where each element matches the input shape (scalar or 1-D array). Notes ----- - Parsing of DMS strings and range checks are handled by the underlying projection helper. - For array inputs, this function preserves order and length; indices are preserved when built from ``data[<col>]`` and ``as_frame=True``. Examples -------- Scalar input:: >>> to_utm(34.05, -118.34) # doctest: +SKIP (..., ..., '11S') From a DataFrame:: >>> # df has columns 'lat_deg' and 'lon_deg' >>> # to_utm('lat_deg','lon_deg', data=df, as_frame=True) ... # doctest: +SKIP """ # ------------------------------------------------------------------ # Resolve input into 1-D arrays and an optional index for frames # ------------------------------------------------------------------ idx = None def _extract(v, name: str): nonlocal idx if isinstance(v, str): if data is None: raise ValueError( f"'{name}' is a column name but 'data' is not provided." ) col = data[v] idx = getattr(col, "index", None) return np.asarray(col.to_numpy()) if hasattr(v, "to_numpy"): # pandas Series arr = v.to_numpy() # type: ignore[attr-defined] idx = getattr(v, "index", None) return np.asarray(arr) if np.isscalar(v): return np.asarray([v]) return np.asarray(v) lat_arr = _extract(lat, "lat") lon_arr = _extract(lon, "lon") if lat_arr.shape != lon_arr.shape: raise ValueError( f"lat and lon shapes differ: {lat_arr.shape} != {lon_arr.shape}" ) # ------------------------------------------------------------------ # Delegate to projection helper # - project_point_ll2utm supports scalar or array inputs and # will return either a tuple (scalar) or a record array. # ------------------------------------------------------------------ res = project_point_ll2utm( # type: ignore[name-defined] lat_arr, lon_arr, datum=datum, utm_zone=utm_zone, epsg=epsg, ) # Normalize output to arrays if isinstance(res, tuple): e, n, z = res e_arr = np.asarray([e]) n_arr = np.asarray([n]) z_arr = np.asarray([z], dtype=object) else: # expected a recarray with named fields e_arr = np.asarray(res["easting"]) n_arr = np.asarray(res["northing"]) z_arr = np.asarray(res["utm_zone"], dtype=object) # Return per user request if as_frame: # Build lat/lon columns reflecting original inputs if np.isscalar(lat): lat_col = np.asarray([lat]) elif isinstance(lat, str) and data is not None: lat_col = np.asarray(data[lat].to_numpy()) elif hasattr(lat, "to_numpy"): lat_col = np.asarray(lat.to_numpy()) else: lat_col = np.asarray(lat_arr) if np.isscalar(lon): lon_col = np.asarray([lon]) elif isinstance(lon, str) and data is not None: lon_col = np.asarray(data[lon].to_numpy()) elif hasattr(lon, "to_numpy"): lon_col = np.asarray(lon.to_numpy()) else: lon_col = np.asarray(lon_arr) frame = pd.DataFrame( { "lat": lat_col, "lon": lon_col, "easting": e_arr, "northing": n_arr, "zone": z_arr, } ) if idx is not None and len(frame) == len(idx): frame.index = idx return frame # arrays/scalars: squeeze to scalar if input was scalar if np.isscalar(lat) and np.isscalar(lon): return (e_arr[0], n_arr[0], z_arr[0]) return (e_arr, n_arr, z_arr)
[docs] def project_point_ll2utm( lat, lon, datum: str = "WGS84", utm_zone: str | None = None, epsg: int | None = None, ): r""" Transform one geographic point to UTM coordinates. Accepts a single point or 1-D arrays/Series of points in decimal degrees (or DMS strings) and returns UTM easting, northing, and zone. Uses GDAL/OSR when available; falls back to PROJ via ``pyproj`` with explicit XY order. Parameters ---------- lat : float or str, array-like, or pd.Series Latitude in decimal degrees or sexagesimal string (``"DD:MM:SS.sss"``). lon : float, str, array-like, or pd.Series Longitude in decimal degrees or sexagesimal string (``"DD:MM:SS.sss"``). datum : str, default "WGS84" Geodetic datum name (e.g., ``"WGS84"``, ``"NAD83"``). May also be an EPSG integer if GDAL is used. utm_zone : str, optional UTM zone designator like ``"55S"``. If omitted or set to a non-string (or ``"none"``), the zone is inferred from the input point. epsg : int, optional EPSG code for the projected CRS. When provided, it takes precedence over ``utm_zone`` and ``datum``. Returns ------- tuple or np.recarray For scalar input: ``(easting, northing, zone)``. For array input: record array with fields ``('easting','northing','elev','utm_zone')``. - easting : float or None UTM easting in meters, or ``None`` when inputs are ``None``. - northing : float or None UTM northing in meters, or ``None`` when inputs are ``None``. - zone : str or None UTM zone string (e.g., ``"55S"``). ``None`` when not determinable or inputs are ``None``. Notes ----- - This variant accepts a single point. For vectorized inputs, use :func:`project_points_ll2utm`. - Inputs are validated with ``assert_lat_value`` and ``assert_lon_value`` before projection. - With GDAL, transforms use ``osr.CoordinateTransformation``; with PROJ they use ``pyproj.Proj``. Notes ----- - Enforces traditional GIS order (lon, lat) in the GDAL path to avoid invalid-latitude errors with GDAL ≥ 3. - The ``pyproj`` path uses a Transformer with ``always_xy=True`` for the same reason. Examples -------- >>> e, n, z = project_point_ll2utm(34.05, -118.34) >>> z '11S' """ if lat is None or lon is None: return (None, None, None) # normalize to 1-D arrays; accept scalars/iterables if np.iterable(lat) and np.iterable(lon): lat_arr = np.asarray([assert_lat_value(v) for v in lat]) lon_arr = np.asarray([assert_lon_value(v) for v in lon]) if lat_arr.size != lon_arr.size: raise ValueError("lat and lon sizes differ.") else: lat_arr = np.asarray([assert_lat_value(lat)]) lon_arr = np.asarray([assert_lon_value(lon)]) n = lat_arr.size out = np.zeros( n, dtype=[ ("easting", np.float64), ("northing", np.float64), ("elev", np.float64), ("utm_zone", "U4"), ], ) if HAS_GDAL: # build source geographic SRS ll_cs = osr.SpatialReference() if isinstance(datum, int): ogrerr = ll_cs.ImportFromEPSG(datum) else: ogrerr = ll_cs.SetWellKnownGeogCS(str(datum)) if ogrerr != OGRERR_NONE: raise GisError(f"GDAL/OSR error code: {ogrerr}") for i in range(n): la = float(lat_arr[i]) lo = float(lon_arr[i]) utm_cs = osr.SpatialReference() if isinstance(epsg, int): ogrerr = utm_cs.ImportFromEPSG(epsg) if ogrerr != OGRERR_NONE: raise GisError(f"GDAL/OSR error code: {ogrerr}") # zone string for info (derive if missing) _, _, zone_str = get_utm_zone(la, lo) else: # tie projected CRS to same geographic part ogrerr = utm_cs.CopyGeogCSFrom(ll_cs) if ogrerr != OGRERR_NONE: raise GisError(f"GDAL/OSR error code: {ogrerr}") if ( utm_zone is None or not isinstance(utm_zone, str) or utm_zone.lower() == "none" ): znum, znorth, zone_str = get_utm_zone(la, lo) else: znum = int(utm_zone[:-1]) znorth = utm_zone[-1].lower() > "n" zone_str = utm_zone utm_cs.SetUTM(znum, znorth) # enforce (lon, lat) order in GDAL ≥ 3 if hasattr(osr, "OAMS_TRADITIONAL_GIS_ORDER"): ll_cs.SetAxisMappingStrategy(osr.OAMS_TRADITIONAL_GIS_ORDER) utm_cs.SetAxisMappingStrategy(osr.OAMS_TRADITIONAL_GIS_ORDER) xform = osr.CoordinateTransformation(ll_cs, utm_cs) x, y, z = xform.TransformPoint(lo, la, 0.0) out["easting"][i] = x out["northing"][i] = y out["elev"][i] = z out["utm_zone"][i] = zone_str else: # pyproj fallback with explicit (lon, lat) import pyproj # type: ignore # source CRS from datum (string or epsg-int both ok) src = pyproj.CRS.from_user_input(datum) for i in range(n): la = float(lat_arr[i]) lo = float(lon_arr[i]) if isinstance(epsg, int): dst = pyproj.CRS.from_epsg(epsg) _, _, zone_str = get_utm_zone(la, lo) else: if ( utm_zone is None or not isinstance(utm_zone, str) or utm_zone.lower() == "none" ): znum, znorth, zone_str = get_utm_zone(la, lo) else: znum = int(utm_zone[:-1]) znorth = utm_zone[-1].lower() > "n" zone_str = utm_zone proj4 = "+proj=utm +zone={} +{} +datum={}".format( znum, "north" if znorth else "south", datum, ) dst = pyproj.CRS.from_string(proj4) tr = pyproj.Transformer.from_crs( src, dst, always_xy=True, ) x, y = tr.transform(lo, la) out["easting"][i] = x out["northing"][i] = y out["elev"][i] = 0.0 out["utm_zone"][i] = zone_str if n == 1: return ( out["easting"][0], out["northing"][0], out["utm_zone"][0], ) return np.rec.array(out)
[docs] def project_point_utm2ll( easting: float, northing: float, utm_zone, datum: str = "WGS84", epsg: int | None = 3149, ) -> tuple[float, float]: r""" Transform a UTM point to latitude/longitude. Converts UTM easting/northing to geographic latitude and longitude (decimal degrees). Uses GDAL/OSR when available, else falls back to PROJ via ``pyproj``. Parameters ---------- easting : float Easting in meters. northing : float Northing in meters. utm_zone : str or int UTM zone designator. Either a string like ``"10S"`` or an integer UTM code (negative for south). Ignored when ``epsg`` is provided. datum : str, default "WGS84" Geodetic datum name (e.g., ``"WGS84"``, ``"NAD27"``). epsg : int, optional EPSG code defining the projected CRS. When provided, it takes precedence over ``utm_zone`` and ``datum``. The default is ``3149`` for historical compatibility. Returns ------- lat : float Latitude in decimal degrees, rounded to 6 decimals. lon : float Longitude in decimal degrees, rounded to 6 decimals. Notes ----- - With GDAL, the conversion uses ``osr.CoordinateTransformation`` to a geographic CRS cloned from the projected CRS. - With PROJ, the conversion uses ``pyproj.Proj`` with ``inverse=True``. - When ``epsg`` refers to a non-UTM CRS, ``utm_zone`` is not required. - The function validates ``easting`` and ``northing`` can be cast to ``float`` and raises ``GisError`` otherwise. Examples -------- Using an explicit zone:: >>> project_point_utm2ll(377274.0, 3762150.0, "11S") Using an EPSG code (preferred when known):: >>> project_point_utm2ll( ... 377274.0, 3762150.0, utm_zone="11S", epsg=32611 ... ) See Also -------- project_points_ll2utm Forward transform from geographic to UTM. References ---------- .. [1] PROJ documentation, https://proj.org/ .. [2] GDAL/OSR, https://gdal.org/ """ try: easting = float(easting) except ValueError as exc: raise GisError("easting is not a float") from exc try: northing = float(northing) except ValueError as exc: raise GisError("northing is not a float") from exc if HAS_GDAL: utm_cs = osr.SpatialReference() utm_cs.SetWellKnownGeogCS(datum) else: pp = None # type: ignore[assignment] if epsg is not None: if HAS_GDAL: ogrerr = utm_cs.ImportFromEPSG(epsg) if ogrerr != OGRERR_NONE: raise RuntimeError(f"GDAL/OSR error code: {ogrerr}") else: pp = pyproj.Proj(f"+init=EPSG:{epsg}") elif isinstance(utm_zone, (str, np.bytes_)): if isinstance(utm_zone, np.bytes_): utm_zone = utm_zone.decode("UTF-8") try: zone_number = int(utm_zone[:-1]) zone_letter = utm_zone[-1] except ValueError as exc: raise ValueError( f"Zone number '{utm_zone[:-1]}' is not a number" ) from exc is_northern = zone_letter.lower() >= "n" elif isinstance(utm_zone, int): is_northern = utm_zone >= 0 zone_number = abs(utm_zone) else: raise NotImplementedError( f"utm_zone type '{type(utm_zone).__name__}' not supported" ) if epsg is None: if HAS_GDAL: utm_cs.SetUTM(zone_number, is_northern) else: projstring = "+proj=utm +zone={} +{} +datum={}".format( zone_number, "north" if is_northern else "south", datum, ) pp = pyproj.Proj(projstring) if HAS_GDAL: ll_cs = utm_cs.CloneGeogCS() utm2ll = osr.CoordinateTransformation(utm_cs, ll_cs).TransformPoint ll_point = list(utm2ll(easting, northing, 0.0)) lon, lat = ll_point[0], ll_point[1] else: assert pp is not None lon, lat = pp(easting, northing, inverse=True) # normalize_lat_lon resolve the classic axis order issue often # seen when interacting with different GIS libraries. lat, lon = normalize_lat_lon(lon, lat, assume="latlon") return (round(lat, 6), round(lon, 6))
def project_points_ll2utm( lat, lon, datum: str = "WGS84", utm_zone: str | None = None, epsg: int | None = None, ): r""" Transform latitude/longitude to UTM coordinates. Converts geographic coordinates to UTM eastings and northings. Uses GDAL/OSR when available, else falls back to PROJ via ``pyproj``. Accepts scalars or arrays; array inputs must share the same shape. Parameters ---------- lat : float, str, or array-like Latitude(s) in decimal degrees or sexagesimal string. Sexagesimal uses ``"DD:MM:SS.sss"`` format. When an array is given, its shape must match ``lon``. lon : float, str, or array-like Longitude(s) in decimal degrees or sexagesimal string. Same formatting rules as for ``lat``. datum : str, default "WGS84" Geodetic datum name. Examples: ``"WGS84"``, ``"NAD83"``, ``"NAD27"``. Passed to the backend CRS. utm_zone : str, optional UTM zone designator (e.g., ``"55S"``). If omitted, a zone is inferred from the input centroid. Ignored when ``epsg`` is provided. epsg : int, optional EPSG code that fully defines the projected CRS. When set, it takes precedence over ``utm_zone``. Returns ------- easting : float or ndarray UTM easting(s) in meters. Matches the input shape for array inputs. northing : float or ndarray UTM northing(s) in meters. Matches the input shape for array inputs. zone : str or None UTM zone string (e.g., ``"55S"``) when determinable. May be ``None`` for CRS that are not UTM. Notes ----- - If either ``lat`` or ``lon`` is ``None``, the function returns ``(None, None, None)``. - Array inputs are flattened for computation and then reshaped back to the original layout. - With GDAL, the transform uses ``osr.CoordinateTransformation``; with PROJ it uses ``pyproj.Proj``. - When ``epsg`` refers to a non-UTM projected CRS, a UTM zone may not be derivable; ``zone`` can be ``None``. Examples -------- Decimal degrees:: >>> e, n, z = project_points_ll2utm(34.05, -118.34) >>> round(e, 1), round(n, 1), z (..., ..., ...) Sexagesimal strings:: >>> project_points_ll2utm("34:03:00", "-118:20:24") Vectorized input:: >>> lats = [34.00, 34.05] >>> lons = [-118.40, -118.34] >>> e, n, z = project_points_ll2utm(lats, lons) See Also -------- project_point_utm2ll Inverse transform from UTM to geographic coords. References ---------- .. [1] EPSG Registry, https://epsg.org/ .. [2] PROJ documentation, https://proj.org/ .. [3] GDAL/OSR, https://gdal.org/ """ lat = np.array(lat) lon = np.array(lon) if np.shape(lat) != np.shape(lon): raise ValueError( "latitude and longitude arrays are of different lengths" ) flattened = False llshape = np.shape(lat) if llshape and llshape[0] > 1: flattened = True lat = lat.flatten() lon = lon.flatten() if lat is None or lon is None: return (None, None, None) if HAS_GDAL: utm_cs = osr.SpatialReference() utm_cs.SetWellKnownGeogCS(datum) ll_cs = utm_cs.CloneGeogCS() ll_cs.ExportToPrettyWkt() else: # pyproj will be used below via `pp` pp = None # type: ignore[assignment] if epsg is not None: if HAS_GDAL: ogrerr = utm_cs.ImportFromEPSG(epsg) if ogrerr != OGRERR_NONE: raise RuntimeError(f"GDAL/osgeo ogr error code: {ogrerr}") zone_id = utm_cs.GetUTMZone() if zone_id and zone_id > 0: utm_cs.SetUTM(abs(zone_id), zone_id > 0) else: pp = pyproj.Proj(f"+init=EPSG:{epsg}") else: if utm_zone is not None: zone_number = int(utm_zone[:-1]) hemi_letter = utm_zone[-1].upper() is_northern = hemi_letter >= "N" else: latc = (np.nanmax(lat) + np.nanmin(lat)) / 2.0 lonc = (np.nanmax(lon) + np.nanmin(lon)) / 2.0 zone_number = int(np.floor((lonc + 180.0) / 6.0) + 1) letter = utm_letter_designator(latc) is_northern = letter >= "N" utm_zone = f"{zone_number}{letter}" if HAS_GDAL: utm_cs.SetUTM(zone_number, is_northern) else: projstring = "+proj=utm +zone={:d} +{} +datum={}".format( zone_number, "north" if is_northern else "south", datum, ) pp = pyproj.Proj(projstring) if HAS_GDAL: ll2utm = osr.CoordinateTransformation(ll_cs, utm_cs).TransformPoints pts = np.array(ll2utm(np.array([lon, lat]).T)) easting = pts[:, 0] northing = pts[:, 1] else: assert pp is not None easting, northing = pp(lon, lat) if flattened: easting = np.reshape(easting, llshape) northing = np.reshape(northing, llshape) return (easting, northing, utm_zone)
[docs] def ll_to_utm( reference_ellipsoid: int, lat: float, lon: float ) -> tuple[str, float, float]: """ Convert latitude/longitude to UTM coordinates. Equations from USGS Bulletin 1532. Parameters ---------- reference_ellipsoid : int ID of ellipsoid from ELLIPSOIDS (e.g. 23 for WGS-84). lat, lon : float Geographic coordinates in decimal degrees. Returns ------- utm_zone : str UTM zone string (e.g. '11N'). easting : float northing : float """ # Get ellipsoid parameters try: ell = next(e for e in ELLIPSOIDS if e[0] == reference_ellipsoid) except StopIteration: raise GisError(f"Unknown ellipsoid ID: {reference_ellipsoid}") a = ell[_EQUATORIAL_RADIUS_IDX] ecc_sq = ell[_ECC_SQUARED_IDX] k0 = 0.9996 # normalize longitude into [-180, 180) long_temp = ((lon + 180) % 360) - 180 lat_rad = lat * DEG2RAD long_rad = long_temp * DEG2RAD # determine UTM zone number zone_number = int((long_temp + 180) / 6) + 1 # Norway exception if 56.0 <= lat < 64.0 and 3.0 <= long_temp < 12.0: zone_number = 32 # Svalbard exceptions if 72.0 <= lat < 84.0: if 0.0 <= long_temp < 9.0: zone_number = 31 elif 9.0 <= long_temp < 21.0: zone_number = 33 elif 21.0 <= long_temp < 33.0: zone_number = 35 elif 33.0 <= long_temp < 42.0: zone_number = 37 long_origin = (zone_number - 1) * 6 - 180 + 3 long_origin_rad = long_origin * DEG2RAD utm_zone = f"{zone_number}{utm_letter_designator(lat)}" ecc_prime_sq = ecc_sq / (1 - ecc_sq) N = a / np.sqrt(1 - ecc_sq * np.sin(lat_rad) ** 2) T = np.tan(lat_rad) ** 2 C = ecc_prime_sq * np.cos(lat_rad) ** 2 A = np.cos(lat_rad) * (long_rad - long_origin_rad) # meridional arc M = a * ( (1 - ecc_sq / 4 - 3 * ecc_sq**2 / 64 - 5 * ecc_sq**3 / 256) * lat_rad - (3 * ecc_sq / 8 + 3 * ecc_sq**2 / 32 + 45 * ecc_sq**3 / 1024) * np.sin(2 * lat_rad) + (15 * ecc_sq**2 / 256 + 45 * ecc_sq**3 / 1024) * np.sin(4 * lat_rad) - (35 * ecc_sq**3 / 3072) * np.sin(6 * lat_rad) ) # easting easting = ( k0 * N * ( A + (1 - T + C) * A**3 / 6 + (5 - 18 * T + T**2 + 72 * C - 58 * ecc_prime_sq) * A**5 / 120 ) + 500_000.0 ) # northing northing = k0 * ( M + N * np.tan(lat_rad) * ( A**2 / 2 + (5 - T + 9 * C + 4 * C**2) * A**4 / 24 + (61 - 58 * T + T**2 + 600 * C - 330 * ecc_prime_sq) * A**6 / 720 ) ) # southern hemisphere offset if lat < 0: northing += 10_000_000.0 return utm_zone, easting, northing
[docs] def utm_to_ll( reference_ellipsoid: int, northing: float, easting: float, zone: str ) -> tuple[float, float]: """ Convert UTM coordinates to latitude/longitude. Inverse of ll_to_utm, equations from USGS Bulletin 1532. Parameters ---------- reference_ellipsoid : int ID of ellipsoid from ELLIPSOIDS. northing, easting : float UTM coordinates in meters. zone : str UTM zone string (e.g. '11N'). Returns ------- lat, lon : float Geographic coordinates in decimal degrees. """ # Get ellipsoid parameters try: ell = next(e for e in ELLIPSOIDS if e[0] == reference_ellipsoid) except StopIteration: raise GisError(f"Unknown ellipsoid ID: {reference_ellipsoid}") a = ell[_EQUATORIAL_RADIUS_IDX] ecc_sq = ell[_ECC_SQUARED_IDX] k0 = 0.9996 # Compute e1 parameter e1 = (1 - np.sqrt(1 - ecc_sq)) / (1 + np.sqrt(1 - ecc_sq)) # Remove offsets x = easting - 500_000.0 y = northing # Determine hemisphere zone_letter = zone[-1] if zone_letter.upper() < "N": # southern hemisphere y -= 10_000_000.0 # Central meridian of zone zone_number = int(zone[:-1]) long_origin = (zone_number - 1) * 6 - 180 + 3 # Calculate footpoint latitude M = y / k0 mu = M / (a * (1 - ecc_sq / 4 - 3 * ecc_sq**2 / 64 - 5 * ecc_sq**3 / 256)) phi1_rad = ( mu + (3 * e1 / 2 - 27 * e1**3 / 32) * np.sin(2 * mu) + (21 * e1**2 / 16 - 55 * e1**4 / 32) * np.sin(4 * mu) + (151 * e1**3 / 96) * np.sin(6 * mu) ) # Precompute ecc_prime_sq = ecc_sq / (1 - ecc_sq) n1 = a / np.sqrt(1 - ecc_sq * np.sin(phi1_rad) ** 2) t1 = np.tan(phi1_rad) ** 2 c1 = ecc_prime_sq * np.cos(phi1_rad) ** 2 r1 = a * (1 - ecc_sq) / (1 - ecc_sq * np.sin(phi1_rad) ** 2) ** 1.5 d = x / (n1 * k0) # Latitude lat_rad = phi1_rad - (n1 * np.tan(phi1_rad) / r1) * ( d**2 / 2 - (5 + 3 * t1 + 10 * c1 - 4 * c1**2 - 9 * ecc_prime_sq) * d**4 / 24 + ( 61 + 90 * t1 + 298 * c1 + 45 * t1**2 - 252 * ecc_prime_sq - 3 * c1**2 ) * d**6 / 720 ) lat = lat_rad * RAD2DEG # Longitude lon_rad = ( d - (1 + 2 * t1 + c1) * d**3 / 6 + (5 - 2 * c1 + 28 * t1 - 3 * c1**2 + 8 * ecc_prime_sq + 24 * t1**2) * d**5 / 120 ) / np.cos(phi1_rad) lon = long_origin + lon_rad * RAD2DEG return lat, lon
def epsg_project( x, y, epsg_from, epsg_to, ): r""" Project coordinates between two EPSG-defined CRSs. Leverages :mod:`pyproj` to transform ``(x, y)`` from the CRS identified by ``epsg_from`` to the CRS identified by ``epsg_to``. Inputs may be scalars or array-like; outputs match the broadcasted input shape. Parameters ---------- x, y : float or array-like Coordinates to transform. When arrays are provided, they must be broadcastable to a common shape. epsg_from : int EPSG code of the source CRS. epsg_to : int EPSG code of the destination CRS. Returns ------- x2, y2 : float or ndarray Transformed coordinates in the destination CRS. Returns ``None`` when :mod:`pyproj` is unavailable or EPSG codes are missing from :data:`EPSG_DICT`. Notes ----- - Uses ``pyproj.Transformer`` under the hood. Older ``pyproj.transform`` calls are avoided. - This helper consults :data:`EPSG_DICT` for PROJ strings. If an EPSG code is absent from that dictionary, the function logs a warning and returns ``None``. Examples -------- >>> # WGS84 lon/lat (EPSG:4326) to Web Mercator (EPSG:3857) >>> epsg_project(-118.34, 34.05, 4326, 3857) # doctest: +SKIP References ---------- .. [1] PROJ / pyproj documentation, https://pyproj.org/ """ try: import pyproj # type: ignore except Exception: # pragma: no cover logger.warning("Please install 'pyproj' to use epsg_project.") return None if epsg_from is None or epsg_to is None: logger.warning("Both 'epsg_from' and 'epsg_to' must be provided.") return None try: src = pyproj.CRS.from_user_input(EPSG_DICT[epsg_from]) dst = pyproj.CRS.from_user_input(EPSG_DICT[epsg_to]) except KeyError: logger.warning( "EPSG code not in EPSG_DICT: from=%r to=%r", epsg_from, epsg_to ) return None transformer = pyproj.Transformer.from_crs( src, dst, always_xy=True, ) x2, y2 = transformer.transform(x, y) return x2, y2 def utm_wgs84_conv( lat: float, lon: float, ) -> tuple[float, float, int, str]: r""" Convert WGS84 (lat, lon) to UTM and verify round-trip. Uses the :mod:`utm` package to convert a WGS84 geographic coordinate to UTM ``(easting, northing, zone, letter)``. Then converts back to lat/lon and checks numerical consistency. Parameters ---------- lat : float Latitude in decimal degrees (WGS84). lon : float Longitude in decimal degrees (WGS84). Returns ------- e : float UTM easting (meters). n : float UTM northing (meters). zone : int UTM zone number in ``[1, 60]``. letter : str UTM latitude band letter. Notes ----- - Round-trip consistency is checked with a tolerance of ``1e-10`` degrees. If exceeded, a warning is logged. - Requires the external package ``utm`` (``pip install utm``). Examples -------- >>> e, n, z, L = utm_wgs84_conv(34.05, -118.34) # doctest: +SKIP >>> z, L # doctest: +SKIP (11, 'S') References ---------- .. [1] Turbo87/utm, https://github.com/Turbo87/utm """ try: import utm # type: ignore except Exception: # pragma: no cover logger.warning("Please install 'utm' (pip install utm).") raise e, n, z, L = utm.from_latlon(lat, lon) new_lat, new_lon = utm.to_latlon(e, n, z, L) tol = 1e-10 if abs(lat - new_lat) > tol: logger.warning("lat round-trip mismatch: in=%r out=%r", lat, new_lat) if abs(lon - new_lon) > tol: logger.warning("lon round-trip mismatch: in=%r out=%r", lon, new_lon) return e, n, z, L @Deprecated( "Deprecated; will be removed in a future release. " "Use 'project_point_utm2ll' instead (GDAL/pyproj-backed)." ) def transform_utm_to_ll( easting: float, northing: float, zone, reference_ellipsoid: str = "WGS84", ) -> tuple[float, float, float]: r""" Transform a UTM point to (lon, lat, alt) using GDAL/OSR. Parameters ---------- easting : float UTM easting in meters. northing : float UTM northing in meters. zone : str or int Zone string like ``'11N'`` / ``'55S'`` or a signed integer UTM code (negative for south). reference_ellipsoid : str, default "WGS84" Well-known geographic CRS name for GDAL. Returns ------- lon : float Longitude in decimal degrees. lat : float Latitude in decimal degrees. alt : float Altitude (meters), usually ``0.0`` for 2D inputs. Raises ------ GDALMissingError If GDAL/OSR is not available. ValueError If ``zone`` cannot be parsed. Notes ----- - This function is **deprecated**. Prefer :func:`project_point_utm2ll`. """ if not HAS_GDAL: raise GDALMissingError( "GDAL/OSR is required for 'transform_utm_to_ll'. " "Use 'project_point_utm2ll' for a GDAL/pyproj path." ) utm_sr = osr.SpatialReference() utm_sr.SetWellKnownGeogCS(reference_ellipsoid) # parse zone if isinstance(zone, int): zone_num = abs(zone) is_north = zone >= 0 elif isinstance(zone, str): try: zone_num = int(zone[:-1]) zone_letter = zone[-1] except Exception as exc: # noqa: BLE001 raise ValueError(f"Invalid UTM zone string: {zone!r}") from exc is_north = zone_letter.lower() >= "n" else: raise ValueError(f"Unsupported zone type: {type(zone).__name__}") utm_sr.SetUTM(zone_num, is_north) ll_sr = utm_sr.CloneGeogCS() to_ll = osr.CoordinateTransformation(utm_sr, ll_sr) lon, lat, alt = to_ll.TransformPoint(easting, northing, 0.0) return (lon, lat, alt) @Deprecated( "Deprecated; will be removed in a future release. " "Use 'project_point_ll2utm' instead (GDAL/pyproj-backed)." ) def transform_ll_to_utm( lon: float, lat: float, reference_ellipsoid: str = "WGS84", ): r""" Transform a geographic point (lon, lat) to UTM using GDAL. Parameters ---------- lon : float Longitude in decimal degrees. lat : float Latitude in decimal degrees. reference_ellipsoid : str, default "WGS84" Well-known geographic CRS name for GDAL. Returns ------- utm_sr : osr.SpatialReference UTM spatial reference describing the destination CRS. utm_point : tuple[float, float, float] ``(easting, northing, altitude)`` in meters. Raises ------ GDALMissingError If GDAL/OSR is not available. Notes ----- - This function is **deprecated**. Prefer :func:`project_point_ll2utm`, which can use PROJ when GDAL is unavailable. """ if not HAS_GDAL: raise GDALMissingError( "GDAL/OSR is required for 'transform_ll_to_utm'. " "Use 'project_point_ll2utm' for a GDAL/pyproj path." ) def _utm_zone_number(lon_deg: float) -> int: return int(1 + (lon_deg + 180.0) / 6.0) def _is_northern(lat_deg: float) -> bool: return lat_deg >= 0.0 utm_sr = osr.SpatialReference() utm_sr.SetWellKnownGeogCS(reference_ellipsoid) utm_sr.SetUTM(_utm_zone_number(lon), _is_northern(lat)) ll_sr = utm_sr.CloneGeogCS() to_utm = osr.CoordinateTransformation(ll_sr, utm_sr) easting, northing, alt = to_utm.TransformPoint(lon, lat, 0.0) return utm_sr, (easting, northing, alt) def _resolve_api_name(name: str | None) -> str: """ Resolves the API name from a full name or a shorthand symbol. """ # If no name is given, use the default from the config. if name is None: return ElevationAPIConfig.DEFAULT_API # Map shorthand symbols to their corresponding API names. symbol_map = { ",": "open_meteo", # Comma for comma_separated "|": "open_topo_data", # Pipe for pipe_separated "indiv": "usgs_ned", } # First, check if the provided name is a direct valid key. if name in ElevationAPIConfig.APIS: return name # Next, check if it's a recognized shorthand symbol. if name in symbol_map: return symbol_map[name] # If the name is unrecognized, log a warning and fallback. logger.warning( f"Unknown API name or symbol: '{name}'. " f"Falling back to default API: " f"'{ElevationAPIConfig.DEFAULT_API}'." ) return ElevationAPIConfig.DEFAULT_API def _extract_value_from_nested_dict(data: dict, key_path: str) -> Any: r"""Extract a value from a nested dict using a dot-path. This helper traverses a dictionary structure using a dot-separated string to locate and return a nested value. Parameters ---------- data : dict The dictionary to search within. key_path : str A dot-separated path representing the keys to follow. For example, 'results.elevation' will access ``data['results']['elevation']``. Returns ------- Any The value found at the specified path, or ``None`` if any key along the path does not exist. """ # Split the path into individual keys. keys = key_path.split(".") value = data # Sequentially access each key in the path. for key in keys: if isinstance(value, list) and key.isdigit(): try: # Handle list indexing if a key is a digit. value = value[int(key)] except IndexError: # Index is out of bounds. return None elif isinstance(value, dict): # Access the next level of the dictionary. value = value.get(key) else: # The path is invalid for the current data structure. return None if value is None: # A key was not found. return None return value @overload def get_elevation_from_api( latitude: float, longitude: float, api_name: str | None = None ) -> float: ... @overload def get_elevation_from_api( latitude: Sequence[float] | np.ndarray, longitude: Sequence[float] | np.ndarray, api_name: str | None = None, ) -> np.ndarray: ... def get_elevation_from_api( latitude: float | Sequence[float] | np.ndarray, longitude: float | Sequence[float] | np.ndarray, api_name: str | None = None, ) -> float | np.ndarray: r"""Fetches elevation from sea level for geographic coords. This function queries an online service to retrieve elevation data (in meters) for one or more points on Earth's surface. It supports multiple APIs and includes a fallback mechanism. Parameters ---------- latitude : float or array-like The latitude(s) of the point(s) in decimal degrees. longitude : float or array-like The longitude(s) of the point(s) in decimal degrees. api_name : str, optional The name of the elevation API to use (e.g., 'open_meteo', 'open_topo_data'). Can also be a shorthand symbol like ',' or '|'. If ``None``, the default API is used. Returns ------- float or numpy.ndarray - If inputs are scalars, returns a single float for the elevation in meters. - If inputs are arrays, returns a NumPy array of elevations. Raises ------ GisError If the API request fails after all fallbacks or if the response is invalid. ImportError If the `requests` library is not installed. Notes ----- This utility requires an active internet connection and the `requests` library. If a specified API fails, the function will automatically attempt to use the default API as a fallback. The underlying data quality depends on the chosen API provider [1]_. See Also -------- get_elevation_from_utm : A similar function for UTM coords. pycsamt.gis.config.ElevationAPIConfig : API configurations. References ---------- .. [1] Open-Meteo Elevation API Documentation. https://open-meteo.com/en/docs/elevation-api Examples -------- >>> # Fetch elevation for a single point (Mt. Everest) >>> lat, lon = 27.9881, 86.9250 >>> elev = get_elevation_from_api(lat, lon) >>> print(f"Elevation of Mount Everest: {elev:.2f} m") Elevation of Mount Everest: 8815.00 m >>> # Fetch for multiple points using an array >>> lats = [34.05, 40.71, 35.68] >>> lons = [-118.24, -74.00, 139.69] >>> elevs = get_elevation_from_api(lats, lons) >>> print(elevs) [ 86. 3968. 3939.] """ from ..utils._dependency import import_optional_dependency # Lazily import requests to avoid hard dependency. requests = import_optional_dependency( "requests", extra="'requests' is required to fetch elevation data." ) # Resolve the API name from full name or symbol. resolved_api_name = _resolve_api_name(api_name) api_config = ElevationAPIConfig.get_api_config(resolved_api_name) api_url = api_config["url"] params_format = api_config["params_format"] response_key = api_config["response_key"] is_scalar = np.isscalar(latitude) # Elevation APIs cap how many locations one request can carry # (Open-Meteo rejects overly long point lists with a 400). Batch # array input into chunks and concatenate, so a normal-sized # survey (dozens to a couple hundred stations) doesn't fail # outright just because it exceeds one request's limit. _MAX_BATCH = 90 if not is_scalar and len(latitude) > _MAX_BATCH: lat_arr = np.asarray(latitude, dtype=float) lon_arr = np.asarray(longitude, dtype=float) chunks = [ get_elevation_from_api( lat_arr[i : i + _MAX_BATCH], lon_arr[i : i + _MAX_BATCH], api_name, ) for i in range(0, len(lat_arr), _MAX_BATCH) ] return np.concatenate(chunks) params = {} # Prepare parameters based on the API's required format. if params_format == "comma_separated": params = { "latitude": ( latitude if is_scalar else ",".join(map(str, latitude)) ), "longitude": ( longitude if is_scalar else ",".join(map(str, longitude)) ), } elif params_format == "pipe_separated": if is_scalar: locations = f"{latitude},{longitude}" else: locations = "|".join( [f"{lat},{lon}" for lat, lon in zip(latitude, longitude)] ) params = {"locations": locations} # For other formats like 'individual', handle them separately # if necessary, or add them to the ElevationAPIConfig. try: # Make the API call. response = requests.get(api_url, params=params) response.raise_for_status() # Check for HTTP errors. data = response.json() # Extract elevation using the nested key path. elevations = _extract_value_from_nested_dict(data, response_key) if elevations is None: raise GisError("Elevation data not found in API response.") if is_scalar: # Return a single float for scalar input. return float( elevations[0] if isinstance(elevations, list) else elevations ) else: # Return a NumPy array for array input. return np.array(elevations, dtype=float) except requests.exceptions.RequestException as e: logger.error(f"API request to {resolved_api_name} failed: {e}") # If the failed API was not the default, try fallback. if resolved_api_name != ElevationAPIConfig.DEFAULT_API: logger.info( f"Trying fallback API: {ElevationAPIConfig.DEFAULT_API}" ) return get_elevation_from_api( latitude, longitude, ElevationAPIConfig.DEFAULT_API ) # If fallback also fails or was the default, raise error. raise GisError(f"Failed to fetch elevation data from API: {e}") from e @overload def get_elevation_from_utm( easting: float, northing: float, zone: str, datum: str = "WGS84", **kws ) -> float: ... @overload def get_elevation_from_utm( easting: Sequence[float] | np.ndarray, northing: Sequence[float] | np.ndarray, zone: str, datum: str = "WGS84", **kws, ) -> np.ndarray: ... def get_elevation_from_utm( easting: float | Sequence[float] | np.ndarray, northing: float | Sequence[float] | np.ndarray, zone: str, datum: str = "WGS84", **kws, ) -> float | np.ndarray: r"""Fetches elevation from sea level for UTM coordinates. This utility first converts UTM coordinates (easting, northing) to geographic latitude and longitude, then queries an online API to retrieve the elevation in meters. Parameters ---------- easting : float or array-like The UTM easting coordinate(s) in meters. northing : float or array-like The UTM northing coordinate(s) in meters. zone : str The UTM zone designator for the coordinates, including the hemisphere letter (e.g., "11S", "32N"). datum : str, default="WGS84" The geodetic datum of the UTM coordinates. Returns ------- float or numpy.ndarray - If the input is a single point, returns a float representing the elevation in meters. - If the input is an array of points, returns a NumPy array of elevations. Raises ------ GisError If the coordinate conversion or the subsequent API call fails. See Also -------- get_elevation_from_api : The underlying function that fetches elevation data. to_ll : The coordinate conversion function used internally. Examples -------- >>> from pycsamt.gis.utils import get_elevation_from_utm >>> # Fetch elevation for a single UTM point in Los Angeles >>> easting, northing, zone = 377274.0, 3762150.0, "11S" >>> elevation = get_elevation_from_utm( ... easting, northing, zone ... ) >>> print(f"Elevation: {elevation:.2f} m") Elevation: 86.00 m """ try: # Step 1: Convert UTM coordinates to Latitude/Longitude. # The `to_ll` function handles both scalar and array # inputs automatically. lat, lon = to_ll( easting=easting, northing=northing, zone=zone, datum=datum, as_frame=False, ) # Step 2: Fetch elevation using the geographic coords. # This function also handles scalar and array inputs. return get_elevation_from_api(lat, lon, **kws) except Exception as e: # Catch any error from conversion or the API call. logger.error(f"Failed to get elevation from UTM coordinates: {e}") # Raise a specific error for better error handling. raise GisError( "Could not process UTM coordinates to get elevation" ) from e def calculate_azimuth( easting: Sequence[float] | NDArray, northing: Sequence[float] | NDArray ) -> NDArray: r"""Calculates the forward azimuth between consecutive points. This function takes a sequence of UTM coordinates and computes the azimuth (compass direction) for the line segment starting at each point and ending at the next. Parameters ---------- easting : array-like A sequence (list, NumPy array, etc.) of the easting coordinates in meters. northing : array-like A sequence of the northing coordinates in meters. Must be the same length as ``easting``. Returns ------- numpy.ndarray An array of azimuths in degrees (0-360), measured clockwise from North. The last value is always ``NaN`` as there is no subsequent point to define a direction. Notes ----- The azimuth at index ``i`` represents the direction of the vector from point ``i`` to point ``i+1``. The calculation is performed on the horizontal (2D) projection of the points; elevation is not used. Examples -------- >>> import numpy as np >>> # A simple square path moving East, North, West >>> easting = [0, 100, 100, 0] >>> northing = [0, 0, 100, 100] >>> calculate_azimuth(easting, northing) array([ 90., 0., 270., nan]) """ # Ensure inputs are NumPy arrays for vectorization easting = np.asarray(easting, dtype=float) northing = np.asarray(northing, dtype=float) # Azimuth requires at least two points to define a direction if easting.size < 2: return np.full_like(easting, np.nan) # Calculate the difference in coordinates between points delta_easting = np.diff(easting) delta_northing = np.diff(northing) # Calculate the angle in radians using arctan2 for quadrant # correctness. The arguments are (dy, dx) in traditional # math, so we use (delta_easting, delta_northing). angle_rad = np.arctan2(delta_easting, delta_northing) # Convert radians to degrees angle_deg = np.rad2deg(angle_rad) # Normalize the angle to a 0-360 degree azimuth azimuths = (angle_deg + 360) % 360 # The result should have the same shape as the input. The # azimuth is assigned to the start point of each segment. # The last point has no forward segment, so it is NaN. result = np.full_like(easting, np.nan) result[:-1] = azimuths return result