Source code for georest.usgs.nldi

"""Client for the USGS Hydro Network-Linked Data Index (NLDI).

The NLDI ties things that sit on rivers (stream gages, water-quality sites,
dams, pour points and more) to the NHDPlus river network, and lets you walk
that network: upstream or downstream from any point, along the main stem or
into every tributary, listing the flowlines or the sites you pass. It also
delineates the basin that drains to a point.

    https://api.water.usgs.gov/nldi/linked-data

A typical question takes two calls: find the river reach at a point, then
navigate from it::

    from georest.usgs import nldi

    reach = nldi.find_flowline(-111.596, 36.866)     # the NHDPlus reach (COMID)
    gages = nldi.sites_along("comid", reach["comid"], "upstream_tributaries",
                             distance_km=100)          # a DataFrame of gages

Everything that starts from a feature takes a ``source`` and an
``identifier``: ``("comid", 18267741)`` for a river reach,
``("nwissite", "USGS-09380000")`` for a USGS gage, and so on;
:func:`list_sources` lists them all. Navigation modes accept the friendly
names (``"upstream"``, ``"upstream_tributaries"``, ``"downstream"``,
``"downstream_diversions"``) or NLDI's codes (``UM``, ``UT``, ``DM``,
``DD``).

Tables come back as pandas DataFrames, with point coordinates as ``lon`` and
``lat``. Flowlines and basins are shapes, so they come back as GeoJSON.

Copyright 2026 Ryan Rock and Ian Housman

Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at

    http://www.apache.org/licenses/LICENSE-2.0
"""

from __future__ import annotations

import urllib.parse
from typing import Any

from ._frames import features_frame, frame
from ._http import fetch_json

BASE_URL = "https://api.water.usgs.gov/nldi/linked-data"

#: Navigation modes, by friendly name, onto NLDI's codes.
MODES = {
    "upstream": "UM",
    "upstream_main": "UM",
    "upstream_tributaries": "UT",
    "downstream": "DM",
    "downstream_main": "DM",
    "downstream_diversions": "DD",
}

#: Columns every sites table carries, even when nothing was found.
_SITE_COLUMNS = ("identifier", "name", "source", "comid", "uri", "lon", "lat")


def _mode(mode: str) -> str:
    code = MODES.get(str(mode).lower(), str(mode).upper())
    if code not in ("UM", "UT", "DM", "DD"):
        raise ValueError(
            f"unknown navigation mode {mode!r}; use one of {sorted(MODES)} "
            f"or UM, UT, DM, DD"
        )
    return code


def _feature_path(source: str, identifier: str | int) -> str:
    if not source or identifier in (None, ""):
        raise ValueError("pass both a source (e.g. 'comid', 'nwissite') and an identifier")
    return f"{BASE_URL}/{urllib.parse.quote(str(source))}/{urllib.parse.quote(str(identifier))}"


def _distance(distance_km: float) -> str:
    if distance_km is None or float(distance_km) <= 0:
        raise ValueError(
            "distance_km must be a positive number of kilometers. NLDI walks "
            "the whole network without one, which can return the entire "
            "Mississippi basin"
        )
    return f"{float(distance_km):g}"


[docs] def list_sources() -> Any: """Every kind of feature the NLDI links to the river network. Returns: ``pandas.DataFrame`` with ``source`` (the name to pass as ``source=`` or ``data_source=``), ``name`` and ``url``. Example:: from georest.usgs import nldi nldi.list_sources() # nwissite (USGS gages), WQP, ref_dams, huc12pp, ... """ rows = fetch_json(BASE_URL, {"f": "json"}) return frame(({"source": r.get("source"), "name": r.get("sourceName"), "url": r.get("features")} for r in rows or []), ("source", "name", "url"))
[docs] def find_flowline(lon: float, lat: float) -> dict: """The NHDPlus river reach nearest a point. Args: lon, lat: The point, in decimal degrees. Returns: The reach as a GeoJSON Feature: its ``comid`` (pass it on as ``("comid", reach["comid"])``) and the other properties at the top level of the dict, and the reach's line under ``geometry``. Example:: from georest.usgs import nldi reach = nldi.find_flowline(-111.596, 36.866) reach["comid"] # an NHDPlus COMID, e.g. 18267741 """ fc = fetch_json(f"{BASE_URL}/comid/position", {"coords": f"POINT({float(lon)} {float(lat)})"}) features = (fc or {}).get("features") or [] if not features: raise ValueError(f"no NHDPlus flowline found near ({lon}, {lat})") feature = features[0] props = dict(feature.get("properties") or {}) props["comid"] = int(props.get("comid") or props.get("identifier")) return {**props, "geometry": feature.get("geometry")}
[docs] def get_feature(source: str, identifier: str | int) -> dict: """One linked feature, such as a USGS gage, with where it joins the network. Returns: The feature's properties (``name``, ``comid`` of the reach it sits on, ``uri``, ...) plus ``lon`` and ``lat``. Example:: from georest.usgs import nldi gage = nldi.get_feature("nwissite", "USGS-09380000") gage["name"], gage["comid"] # 'COLORADO RIVER AT LEES FERRY, AZ', 20733845 """ fc = fetch_json(_feature_path(source, identifier)) features = (fc or {}).get("features") or [] if not features: raise ValueError(f"NLDI has no {source} feature {identifier!r}") row = features_frame({"features": features[:1]}).iloc[0].to_dict() return row
[docs] def flowlines(source: str, identifier: str | int, mode: str = "upstream", *, distance_km: float = 50) -> dict: """The river reaches you pass navigating from a feature. Args: source, identifier: Where to start, e.g. ``("comid", 18267741)`` or ``("nwissite", "USGS-09380000")``. mode: ``"upstream"`` (main stem), ``"upstream_tributaries"``, ``"downstream"`` or ``"downstream_diversions"``. distance_km: How far to go. Required: without it NLDI walks the whole network. Returns: A GeoJSON FeatureCollection of LineStrings, one per NHDPlus reach, each with its ``nhdplus_comid``. Example:: from georest.usgs import nldi river = nldi.flowlines("nwissite", "USGS-09380000", "upstream", distance_km=25) len(river["features"]) # reaches within 25 km upstream of Lees Ferry """ return fetch_json(f"{_feature_path(source, identifier)}/navigation/{_mode(mode)}/flowlines", {"distance": _distance(distance_km)})
[docs] def sites_along(source: str, identifier: str | int, mode: str = "upstream", *, data_source: str = "nwissite", distance_km: float = 50) -> Any: """The linked sites you pass navigating from a feature. Args: source, identifier: Where to start. mode: ``"upstream"``, ``"upstream_tributaries"``, ``"downstream"`` or ``"downstream_diversions"``. data_source: Which sites to list: ``"nwissite"`` (USGS stream gages, the default), ``"WQP"`` (Water Quality Portal sites), ``"ref_dams"``, ``"huc12pp"``, ...; see :func:`list_sources`. distance_km: How far to go. Required: without it NLDI walks the whole network. Returns: ``pandas.DataFrame``, one row per site: ``identifier``, ``name``, ``comid``, ``uri`` and the rest of NLDI's properties, plus ``lon`` and ``lat``. Example:: from georest.usgs import nldi # Every USGS gage on the river and its tributaries within 100 km # upstream of Lees Ferry gages = nldi.sites_along("nwissite", "USGS-09380000", "upstream_tributaries", distance_km=100) gages[["identifier", "name"]] """ fc = fetch_json( f"{_feature_path(source, identifier)}/navigation/{_mode(mode)}/{urllib.parse.quote(data_source)}", {"distance": _distance(distance_km)}) return features_frame(fc, _SITE_COLUMNS)
[docs] def basin(source: str, identifier: str | int, *, simplified: bool = True, split_catchment: bool = False) -> dict: """The drainage basin upstream of a feature. Args: source, identifier: The outlet, e.g. ``("nwissite", "USGS-09380000")``. simplified: Return a lighter, simplified outline (the default). Full detail can run to megabytes for a large basin. split_catchment: Split the outlet's own catchment at the exact point instead of including all of it. Slower; matters for small basins. Returns: A GeoJSON FeatureCollection holding one (Multi)Polygon. Example:: from georest.usgs import nldi drains_to_lees_ferry = nldi.basin("nwissite", "USGS-09380000") drains_to_lees_ferry["features"][0]["geometry"]["type"] # 'MultiPolygon' """ return fetch_json(f"{_feature_path(source, identifier)}/basin", {"simplified": str(bool(simplified)).lower(), "splitCatchment": str(bool(split_catchment)).lower()})