| name | nws-flood-thresholds |
| version | 1.1.1 |
| description | Downloads and parses NWS flood-stage thresholds (action/minor/moderate/major) for USGS gauges, then matches station IDs for labeling historical observations or a bulk snapshot. Use when asking at what stage flooding begins at a gauge, not how high the water is now. Not for live USGS levels, NWS forecasts, coastal or urban flooding without a river gauge, or locations outside the United States. |
| risk | safe |
| source | openrouter-deepsearch |
| date_added | 2026-06-16T00:00:00.000Z |
NWS Flood Thresholds Guide
The National Weather Service (NWS) publishes the river stage (in feet) at which
each monitored gauge crosses from "watch it" into progressively more dangerous
flooding. This skill explains how to pull those static thresholds in bulk,
clean up the quirks in the published file, and match them back to USGS station
IDs. The emphasis throughout is on why each step exists, because the failure
modes here are quiet ones: a mislabeled column or a string-vs-number mismatch
does not crash, it just gives you wrong answers about whether a river flooded.
When to Use
Reach for this skill when your question is "at what level does flooding begin
here?" rather than "how high is the water right now?". Concretely:
- You need the action / minor / moderate / major stage for one or many
gauges, e.g. to label historical water-level observations as flood / no-flood.
- You are joining flood thresholds onto USGS station data and need the IDs to
line up exactly (the matching is the part that usually breaks).
- You want a single bulk snapshot of every gauge in the country to load into
a database, a notebook, or a downstream analytics job.
The thresholds are reference constants, so they are cheap to cache and reuse —
that is what makes a bulk download the right tool instead of per-station API
calls.
When NOT to use
Each of these is a capability boundary, not an arbitrary rule — knowing the
reason lets you judge the edge cases:
- You need real-time water levels. This file is a static threshold table. It
tells you the flood stage is 18 ft; it does not tell you the river is at 17.4
ft today. Pair it with
usgs-water-data for live readings.
- You need a forecast. Thresholds are historical/definitional. For "will it
flood tomorrow?" use the NWS forecast products (
nws-forecast-api).
- You need non-riverine flooding (coastal surge, urban flash flooding) that
has no river gauge. Those phenomena are not represented in a gauge-stage table,
so the data simply will not exist for them.
- You need locations outside the United States. NWS only covers US gauges.
- You are in a locked-down environment that blocks outbound HTTP. The skill
fetches directly from NOAA servers; without a vetted proxy the download will
fail. Know this up front rather than discovering it mid-pipeline.
- You are on Python 3.8 or earlier. The examples use pandas 2.x APIs (the
nullable
string dtype, Series.mask) and modern type-hint syntax, which need
Python 3.9+ to import cleanly.
Prerequisites
| Requirement | Detail |
|---|
| Python | 3.9+ (3.10+ recommended for | union syntax in type hints) |
| pandas | >=2.2,<3 — pin in your venv to avoid parsing-behavior changes |
| requests | >=2.32,<3 |
| OpenSSL | 3.0+ (NOAA updated TLS in 2025; older OpenSSL will fail the handshake) |
| Network | Outbound HTTPS to water.noaa.gov required |
| OS | Windows host is primary (PowerShell). All path examples use Windows-style paths. |
Install dependencies (PowerShell)
python -m venv .venv
.\.venv\Scripts\Activate.ps1
pip install "pandas>=2.2,<3" "requests>=2.32,<3"
Procedure
Step 1 — Understand the data sources
There are two ways to get the data; the trade-off is breadth vs. immediacy.
Option 1 — Bulk CSV (use this for more than a handful of stations). One
request returns every gauge in the country, so you pay a single round trip and
can cache the result. This is almost always the right choice for analysis.
https://water.noaa.gov/resources/downloads/reports/nwps_all_gauges_report.csv
Option 2 — Individual station pages (use this for spot checks). Good for
verifying one value against the official source, not for batch work — hitting it
per station is slow and rude to NOAA's servers.
https://water.noaa.gov/gauges/<station_id>
Example: https://water.noaa.gov/gauges/04118105
Step 2 — Understand the flood stage categories
NWS defines four escalating thresholds. The reason the table calls out flood
stage as primary is that it is the dividing line between "no flooding" and
"flooding" — the other three refine how bad it is.
| Category | CSV column | What it means | Why you'd use it |
|---|
| Action stage | action stage | Water is high enough to start monitoring / preparing. | Early-warning logic. |
| Flood stage (minor) | flood stage | Flooding begins: minor property impact, some public threat. | The yes/no flood test — use this as the default threshold. |
| Moderate flood stage | moderate flood stage | Structures inundated; evacuations possible. | Severity tiering. |
| Major flood stage | major flood stage | Extensive damage; significant evacuations. | Worst-case classification. |
For a simple "did it flood?" question, compare the observed level against
flood stage. Reach for the other three only when you need to grade severity.
Step 3 — Save the reference implementation
Save the following as nws_flood_thresholds.py in your project. It is fully
typed, validates its inputs at the boundary, and turns NOAA's quirks (the
trailing-delimiter column, the -9999 sentinel, IDs that look like numbers)
into explicit, documented handling instead of silent bugs.
from __future__ import annotations
import io
import logging
import math
from collections.abc import Iterable, Sequence
from pathlib import Path
from typing import Final, TypedDict
import pandas as pd
import requests
logger = logging.getLogger("nws_flood_thresholds")
NWS_REPORT_URL: Final[str] = (
"https://water.noaa.gov/resources/downloads/reports/nwps_all_gauges_report.csv"
)
DEFAULT_CACHE_PATH: Final[Path] = Path("cache") / "nwps_all_gauges_report.csv"
SENTINEL_NO_VALUE: Final[float] = -9999.0
USGS_ID_WIDTH: Final[int] = 8
STAGE_COLUMNS: Final[tuple[str, ...]] = (
"action stage",
"flood stage",
"moderate flood stage",
"major flood stage",
)
DEFAULT_TIMEOUT_SECONDS: Final[float] = 30.0
():
():
():
():
name:
action:
flood:
moderate:
major:
() -> :
text = (raw).strip()
text.endswith():
text = text[:-]
text.zfill(USGS_ID_WIDTH)
() -> :
raw :
math.nan
text = (raw).strip()
text == text.lower() {, , , }:
math.nan
:
value = (text)
(TypeError, ValueError):
math.nan
value == SENTINEL_NO_VALUE:
math.nan
value
() -> :
required = {, , , *STAGE_COLUMNS}
missing = required.difference(df.columns)
missing:
NwsSchemaError(
)
() -> pd.DataFrame:
url.lower().startswith():
ValueError()
timeout <= :
ValueError()
cache_path.parent.mkdir(parents=, exist_ok=)
refresh cache_path.is_file():
logger.info(, url)
http = session session requests
:
response = http.get(url, timeout=timeout)
response.raise_for_status()
requests.RequestException exc:
NwsDownloadError() exc
response.content:
NwsDownloadError()
cache_path.write_bytes(response.content)
:
logger.debug(, cache_path)
raw_text = cache_path.read_text(encoding=)
raw_text.strip():
NwsDownloadError(
)
:
frame = pd.read_csv(
io.StringIO(raw_text),
dtype=,
header=,
index_col=,
low_memory=,
)
(pd.errors.ParserError, pd.errors.EmptyDataError) exc:
NwsDownloadError() exc
frame.columns = [(col).strip().lower() col frame.columns]
validate_schema(frame)
frame
() -> pd.DataFrame:
result = df.copy()
col columns:
col result.columns:
NwsSchemaError()
numeric = pd.to_numeric(result[col], errors=)
result[col] = numeric.mask(numeric.eq(SENTINEL_NO_VALUE)).astype()
result
() -> pd.DataFrame:
(state_code, ) (state_code.strip()) != :
ValueError()
required = {, , }
missing = required.difference(df.columns)
missing:
NwsSchemaError()
code = state_code.strip().upper()
state = df[].astype()..strip()..upper()
usgs = df[].astype()..strip()
flood = pd.to_numeric(df[], errors=)
mask = (
state.eq(code)
& usgs.notna()
& usgs.ne()
& flood.notna()
& flood.ne(SENTINEL_NO_VALUE)
)
df.loc[mask].copy()
() -> [, StationThresholds]:
required = {, , *STAGE_COLUMNS}
missing = required.difference(df.columns)
missing:
NwsSchemaError()
wanted = {normalize_usgs_id(sid) sid station_ids}
wanted:
{}
canonical_ids = df[].(normalize_usgs_id)
subset = df.loc[canonical_ids.isin(wanted)]
thresholds: [, StationThresholds] = {}
_, row subset.iterrows():
usgs_id = normalize_usgs_id(row[])
thresholds[usgs_id] = StationThresholds(
name=(row[]),
action=parse_stage(row[]),
flood=parse_stage(row[]),
moderate=parse_stage(row[]),
major=parse_stage(row[]),
)
thresholds
() -> StationThresholds | :
(usgs_id, ) usgs_id.strip():
ValueError()
build_threshold_dict(df, [usgs_id]).get(normalize_usgs_id(usgs_id))
Step 4 — Key columns reference
These are the columns the code above depends on. validate_schema checks for
exactly this set, so if NOAA renames one you get a clear error instead of a
mysterious empty result.
| Column name | Description |
|---|
usgs id | USGS station id (8-char string, zero-padded on the left). |
location name | Human-readable station / location name. |
state | Two-letter state code. |
action stage | Action threshold (feet). |
flood stage | Minor flood threshold (feet) — the primary yes/no line. |
moderate flood stage | Moderate flood threshold (feet). |
major flood stage | Major flood threshold (feet). |
Step 5 — Run the end-to-end example
This reuses the module above to download, clean, filter by state, and look up
specific stations — including the branch for a category NOAA does not publish.
import math
report = download_report(refresh=False)
report = coerce_stage_columns(report)
california = filter_by_state(report, "CA")
print(f"California gauges with a flood stage: {len(california)}")
print(
california[["usgs id", "location name", "flood stage"]]
.head(10)
.to_string(index=False)
)
target_ids = ["04118105", "02334500"]
thresholds = build_threshold_dict(report, target_ids)
for station_id in target_ids:
record = thresholds.get(normalize_usgs_id(station_id))
if record is None:
print(f"{station_id}: not present in the NWS report")
continue
flood = record["flood"]
flood_text = "not published" if math.isnan(flood) else f"{flood:.1f} ft"
print(f"{station_id} ({record['name']}): flood stage = {flood_text}")
single = get_station_threshold(report, "04118105")
single :
()
Step 6 — Spot-check a single station against the official page
For verification of one value, compare against the NWS gauge page directly:
https://water.noaa.gov/gauges/04118105
Open the URL in a browser or fetch it with requests.get and confirm the flood
stage shown matches what get_station_threshold returned.
Pitfalls
The 43-vs-44 column quirk (MOST COMMON BUG)
The NWS report has historically shipped data rows with one more field than the
header row because every row ends with a trailing comma. This is the single
most common source of "my columns are all shifted" bugs with this file.
The naive read — calling pd.read_csv with its default arguments — does not
error. Instead pandas quietly decides the extra field means the first column is a
row index, promotes it, and slides every remaining value one position left. Your
usgs id data then sits under the location name label, and nothing complains.
Fix: Always read with index_col=False (see download_report). This tells
pandas "do not treat any column as the index," so the trailing empty field is
dropped and the remaining columns line up with the header exactly. After parsing
we lower-case the headers and run validate_schema, so any future layout
change surfaces immediately rather than corrupting results downstream.
Common issues & mitigations
| Issue | Cause | Why it bites | Mitigation |
|---|
| Columns shifted by one | Trailing comma → 44 data fields vs 43 headers | pandas silently promotes a column to the index | Read with index_col=False (see download_report) |
Stage value of -9999 | NOAA's sentinel for "no threshold defined" | Treated as a real number it skews every aggregate | Map to NaN at parse time (parse_stage, coerce_stage_columns) |
Empty usgs id | Gauge is NWS-only, no USGS counterpart | Produces null join keys against USGS data | Exclude when matching (filter_by_state); keep for NWS-only work |
ID 04118105 won't match | Read as a number, leading zero lost | 4118105 != '04118105' and the match fails | Always canonicalize via normalize_usgs_id |
| Missing thresholds for a known gauge | Station absent from the report | Lookups return None unexpectedly | Fall back to USGS stage-discharge tables or flag as unavailable |
| SSL/TLS handshake failure | Outdated OpenSSL after NOAA's 2025 TLS change | Download fails before any parsing | Run on OpenSSL 3.0+; keep verify=True (the default) |
Best practices (HARD RULES)
- Cache the CSV and refresh at most once a day. The thresholds barely
change, so frequent downloads only waste NOAA bandwidth and your time —
refresh=True exists for the occasional forced refetch.
- Validate the schema on every load. A renamed column should fail loudly at
the source (
validate_schema), not produce a quietly empty result three steps
later.
- Convert thresholds to numeric immediately, mapping
-9999 and blanks to
NaN, so no comparison ever runs against a string or a sentinel.
- Treat USGS ids as zero-padded strings end to end. The leading zero is real
data; lose it once and every join silently misses rows.
- Pin compatible dependency ranges (
pandas>=2.2,<3 and
requests>=2.32,<3) in a virtual environment, so a future major release
can't change parsing behavior under you without a deliberate upgrade.
- Never delete the cache file unless intentionally refreshing. If the cache
is empty or corrupt, delete it and re-run with
refresh=True — do not
silently work around a bad cache.
Verification
Run each check below to confirm the pipeline is producing correct results.
1. Schema and column alignment
from nws_flood_thresholds import download_report, validate_schema
report = download_report(refresh=False)
validate_schema(report)
print(report.columns.tolist())
assert "usgs id" in report.columns
assert report.index.name is None
2. Stage columns are numeric with sentinels removed
from nws_flood_thresholds import download_report, coerce_stage_columns, STAGE_COLUMNS, SENTINEL_NO_VALUE
report = download_report()
report = coerce_stage_columns(report)
for col in STAGE_COLUMNS:
assert report[col].dtype == "float64", f"{col} is {report[col].dtype}, expected float64"
assert not report[col].eq(SENTINEL_NO_VALUE).any(), f"{col} still contains -9999 sentinel"
print("All stage columns are float64 with no -9999 sentinels.")
3. State filter returns expected row count
from nws_flood_thresholds import download_report, coerce_stage_columns, filter_by_state
report = coerce_stage_columns(download_report())
ca = filter_by_state(report, "CA")
print(f"California gauges with a flood stage: {len(ca)}")
assert len(ca) > 0, "Expected non-zero California gauges"
4. Single-station lookup matches official NWS page
from nws_flood_thresholds import download_report, coerce_stage_columns, get_station_threshold
report = coerce_stage_columns(download_report())
result = get_station_threshold(report, "04118105")
assert result is not None, "Station 04118105 should be in the report"
print(f"04118105 ({result['name']}): flood stage = {result['flood']} ft")
5. Forced refresh still parses cleanly
from nws_flood_thresholds import download_report
report = download_report(refresh=True)
print(f"Refreshed report: {len(report)} rows, {len(report.columns)} columns")
6. No sentinels or empty IDs in filtered dataset
from nws_flood_thresholds import download_report, coerce_stage_columns, filter_by_state, SENTINEL_NO_VALUE
report = coerce_stage_columns(download_report())
ca = filter_by_state(report, "CA")
assert ca["usgs id"].notna().all() and (ca["usgs id"].str.strip() != "").all()
assert not ca["flood stage"].eq(SENTINEL_NO_VALUE).any()
assert ca["flood stage"].notna().all()
print("Filtered dataset is clean: no empty IDs, no -9999, no NaN flood stages.")
Related skills
usgs-water-data — retrieve real-time or historical water levels to compare
against these thresholds.
nws-forecast-api — pull NWS weather and flood forecasts (the predictive
counterpart to these static thresholds).
geospatial-analysis — join thresholds to GIS layers for mapping and spatial
queries.