"""Client for the Water Quality Portal (WQP), run by the USGS and EPA.
The largest public store of US water-quality data: samples and field
measurements from the USGS, EPA, states, tribes and hundreds of other
organizations, at more than a million sites:
https://www.waterqualitydata.us
::
from georest.usgs import wqp
sites = wqp.find_sites(state="CO", county="069", site_type="Stream")
temps = wqp.get_results(sites=["USGS-09380000"], characteristic="Temperature, water",
start="2025-01-01", end="2025-12-31")
temps[["date", "value", "unit", "Result_MeasureStatusIdentifier"]]
This client reads the portal's **WQX 3.0** format. The portal's original
format stopped receiving USGS data in 2024: a 2025 search for USGS results
that returns 130 rows in WQX 3.0 returns none in the original (checked
2026-10-28). WQX 3.0 queries are slower, often most of a minute.
Results are pandas DataFrames with every column the portal sends, plus
``lon`` and ``lat`` (standardized to NAD83) and, for results, ``date``,
``value`` (the measurement as a number) and ``unit``.
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 io
import re
import urllib.parse
from collections.abc import Iterable
from typing import Any
from ._http import fetch_text
BASE_URL = "https://www.waterqualitydata.us/wqx3"
#: WQX 3.0 queries routinely take most of a minute; allow five.
TIMEOUT = 300
#: State postal codes onto FIPS codes, which the portal's state and county
#: filters use (``US:08`` for Colorado).
STATE_FIPS = {
"AL": "01", "AK": "02", "AZ": "04", "AR": "05", "CA": "06", "CO": "08", "CT": "09",
"DE": "10", "DC": "11", "FL": "12", "GA": "13", "HI": "15", "ID": "16", "IL": "17",
"IN": "18", "IA": "19", "KS": "20", "KY": "21", "LA": "22", "ME": "23", "MD": "24",
"MA": "25", "MI": "26", "MN": "27", "MS": "28", "MO": "29", "MT": "30", "NE": "31",
"NV": "32", "NH": "33", "NJ": "34", "NM": "35", "NY": "36", "NC": "37", "ND": "38",
"OH": "39", "OK": "40", "OR": "41", "PA": "42", "RI": "44", "SC": "45", "SD": "46",
"TN": "47", "TX": "48", "UT": "49", "VT": "50", "VA": "51", "WA": "53", "WV": "54",
"WI": "55", "WY": "56", "AS": "60", "GU": "66", "MP": "69", "PR": "72", "VI": "78",
}
def _state_code(state: str) -> str:
s = str(state).strip().upper()
if s.startswith("US:"):
return s
if s in STATE_FIPS:
return f"US:{STATE_FIPS[s]}"
if s.isdigit() and s.zfill(2) in STATE_FIPS.values():
return f"US:{s.zfill(2)}"
raise ValueError(f"state {state!r} is not a state postal code like 'CO' or a FIPS code like '08'")
def _many(value: str | Iterable[str]) -> list[str]:
"""One value or several, always as a list.
Several values go to the portal as a repeated parameter
(``siteid=a&siteid=b``). Joining them with semicolons, which the portal
also accepts, quietly gives wrong answers: two site ids came back as no
sites at all, and two characteristics as 692 sites instead of the 1,051
the repeated form returns (checked 2026-10-28).
"""
return [value] if isinstance(value, str) else [str(v) for v in value]
def _get(path: str, params: dict) -> str:
"""GET from the portal with list values sent as repeated parameters."""
query = urllib.parse.urlencode(params, doseq=True)
return fetch_text(f"{BASE_URL}/{path}?{query}", timeout=TIMEOUT)
def _date(value: str, name: str) -> str:
"""ISO ``YYYY-MM-DD`` (or the portal's ``MM-DD-YYYY``) to ``MM-DD-YYYY``."""
text = str(value).strip()
m = re.fullmatch(r"(\d{4})-(\d{2})-(\d{2})", text)
if m:
return f"{m.group(2)}-{m.group(3)}-{m.group(1)}"
if re.fullmatch(r"\d{2}-\d{2}-\d{4}", text):
return text
raise ValueError(f"{name} {value!r} is not a date like 2025-06-30")
def _params(*, sites=None, state=None, county=None, bbox=None, huc=None,
site_type=None, characteristic=None, providers=None,
start=None, end=None) -> dict:
if not any((sites, state, county, bbox, huc)):
raise ValueError(
"name where to look: sites=, state=, county= (with state=), bbox= or huc=. "
"Without one the portal would search the whole country"
)
params: dict[str, Any] = {"mimeType": "csv", "zip": "no"}
if sites:
params["siteid"] = _many(sites)
if county:
if not state:
raise ValueError("county= needs state= too (county codes repeat across states)")
county_text = str(county).strip()
if not county_text.isdigit():
raise ValueError(f"county {county!r} is a 3-digit FIPS code, e.g. '069'")
params["countycode"] = f"{_state_code(state)}:{county_text.zfill(3)}"
elif state:
params["statecode"] = _state_code(state)
if bbox:
west, south, east, north = (float(v) for v in bbox)
if west >= east or south >= north:
raise ValueError("bbox is (west, south, east, north), west < east and south < north")
params["bBox"] = f"{west},{south},{east},{north}"
if huc:
params["huc"] = str(huc)
for name, value in (("siteType", site_type), ("characteristicName", characteristic),
("providers", providers)):
if value:
params[name] = _many(value)
if start:
params["startDateLo"] = _date(start, "start")
if end:
params["startDateHi"] = _date(end, "end")
return params
def _read(text: str) -> Any:
import pandas as pd
if not text.strip():
return pd.DataFrame()
# Read everything as text first: identifiers such as HUC codes and
# county FIPS codes carry leading zeros that a number would drop.
return pd.read_csv(io.StringIO(text), dtype=str, keep_default_na=False)
def _with_coordinates(df: Any) -> Any:
import pandas as pd
for out, column in (("lon", "Location_LongitudeStandardized"),
("lat", "Location_LatitudeStandardized")):
df[out] = pd.to_numeric(df[column], errors="coerce") if column in df else pd.Series(dtype=float)
return df
[docs]
def find_sites(*, sites: str | Iterable[str] | None = None, state: str | None = None,
county: str | None = None, bbox: tuple | None = None,
huc: str | None = None, site_type: str | Iterable[str] | None = None,
characteristic: str | Iterable[str] | None = None,
providers: str | Iterable[str] | None = None) -> Any:
"""Monitoring locations in the Water Quality Portal.
Args:
sites: Site ids, e.g. ``"USGS-09380000"``.
state: A state postal code (``"CO"``) or FIPS code (``"08"``).
county: A county's 3-digit FIPS code, with ``state``.
bbox: ``(west, south, east, north)`` in decimal degrees.
huc: A hydrologic unit code (2 to 12 digits).
site_type: e.g. ``"Stream"``, ``"Lake, Reservoir, Impoundment"``,
``"Well"``, ``"Spring"``.
characteristic: Keep only sites with samples of this, e.g.
``"Nitrate"`` or ``"Temperature, water"``.
providers: ``"NWIS"`` (USGS), ``"STORET"`` (EPA and partners), or both.
Returns:
``pandas.DataFrame``, one row per site, with every WQX 3.0 location
column (``Location_Identifier``, ``Location_Name``,
``Location_Type``, HUC codes, drainage area, ...) plus ``lon`` and
``lat``.
Example::
from georest.usgs import wqp
# Stream sites in Larimer County, Colorado, that measured nitrate
sites = wqp.find_sites(state="CO", county="069", site_type="Stream",
characteristic="Nitrate")
sites[["Location_Identifier", "Location_Name", "Org_FormalName", "lon", "lat"]]
"""
params = _params(sites=sites, state=state, county=county, bbox=bbox, huc=huc,
site_type=site_type, characteristic=characteristic, providers=providers)
return _with_coordinates(_read(_get("Station/search", params)))
[docs]
def get_results(*, sites: str | Iterable[str] | None = None, state: str | None = None,
county: str | None = None, bbox: tuple | None = None,
huc: str | None = None, site_type: str | Iterable[str] | None = None,
characteristic: str | Iterable[str] | None = None,
providers: str | Iterable[str] | None = None,
start: str | None = None, end: str | None = None,
profile: str = "basicPhysChem") -> Any:
"""Water-quality results: samples and field measurements.
Args:
sites, state, county, bbox, huc, site_type, providers: Where to look,
as for :func:`find_sites`. At least one place is required.
characteristic: What was measured, e.g. ``"Temperature, water"``,
``"Nitrate"``, ``"pH"``, ``"Escherichia coli"``.
start, end: Sample dates, ``"YYYY-MM-DD"``.
profile: The portal's column set: ``"basicPhysChem"`` (the default)
or ``"fullPhysChem"`` for every lab and method detail.
Returns:
``pandas.DataFrame``, one row per result, with every WQX 3.0 column
(site, activity, characteristic, measure and unit, status, detection
limits, method, ...) plus ``lon``, ``lat``, ``date``, ``value`` (the
measurement as a number, empty where the portal reports none, such as
a non-detect; see ``Result_ResultDetectionCondition``) and ``unit``.
Example::
from georest.usgs import wqp
temps = wqp.get_results(sites="USGS-09380000", characteristic="Temperature, water",
start="2025-01-01", end="2025-12-31")
temps[["date", "value", "unit", "Result_MeasureStatusIdentifier"]]
"""
import pandas as pd
params = _params(sites=sites, state=state, county=county, bbox=bbox, huc=huc,
site_type=site_type, characteristic=characteristic, providers=providers,
start=start, end=end)
params["dataProfile"] = profile
df = _with_coordinates(_read(_get("Result/search", params)))
df["date"] = df["Activity_StartDate"] if "Activity_StartDate" in df else pd.Series(dtype=str)
df["value"] = (pd.to_numeric(df["Result_Measure"], errors="coerce")
if "Result_Measure" in df else pd.Series(dtype=float))
df["unit"] = df["Result_MeasureUnit"] if "Result_MeasureUnit" in df else pd.Series(dtype=str)
return df