Skip to content

fix(nwis): keep censored peaks instead of dropping or dating them - #395

Merged
thodson-usgs merged 8 commits into
DOI-USGS:mainfrom
thodson-usgs:fix/peaks-partial-dates
Aug 28, 2026
Merged

fix(nwis): keep censored peaks instead of dropping or dating them#395
thodson-usgs merged 8 commits into
DOI-USGS:mainfrom
thodson-usgs:fix/peaks-partial-dates

Conversation

@thodson-usgs

@thodson-usgs thodson-usgs commented Aug 26, 2026

Copy link
Copy Markdown
Collaborator

Stacked on #344 — that branch is merged in, so the diff to review here is preformat_peaks_response and its tests. Rebase or merge once #344 lands.

The bug

nwis.get_discharge_peaks and get_record(service="peaks") silently discard every peak whose date is only partly known.

NWIS zero-fills the unknown part of a historical peak's date. From the RDB's own legend:

#   A ... Year of occurrence is unknown or not exact
#   Bd ... Day of occurrence is unknown or not exact
#   Bm ... Month of occurrence is unknown or not exact

So 1858-00-00 (month unknown) and 1844-06-00 (day unknown) are real, qualified records. Neither parses as a date, so preformat_peaks_response coerced both to NaT and the dropna below deleted the row — discharge value and all.

The loss is biased toward the records that matter most, because the imprecisely dated peaks are the old ones. Across six sites, 24 of 672 peaks were being dropped:

site peaks dropped before after
14105700 167 20 (incl. 1859 @ 847,000 ft³/s) 0
02035000 129 2 0
09380000 104 1 0
06934500 99 1 (1844 @ 700,000 ft³/s) 0
01646500 / 01491000 95 / 78 0 0

Why the date is left as NaT rather than completed

An earlier revision of this branch pinned each censored date to the start of the known period (1858-00-001858-01-01), mirroring waterdata.get_peaks(). That was wrong twice over: it put a day in the frame that the record does not claim, and since the function popped peak_dt, it destroyed the only evidence the date was ever approximate.

There is no representation that avoids this within the datetime column. A datetime64 value is a full instant:

pd.Timestamp('1858')     # -> 1858-01-01 00:00:00
pd.Timestamp('1858-06')  # -> 1858-06-01 00:00:00

Mixed-frequency Period values fall back to object dtype and raise IncompatibleFrequency on PeriodIndex/astype, so they cannot form an index either. Any value in that column asserts a precision NWIS did not supply.

So the peak is kept and datetime is left NaT:

   peak_dt datetime  peak_va peak_cd
1858-00-00      NaT   563000      Bm
1859-00-00      NaT   847000      Bm
1844-06-00      NaT   700000      Bd

peak_dt is no longer popped. The peaks response has no water_yr, so it is the only column carrying a censored peak's year — and the only dependable way to tell an unknown day from a known one, since peak_cd holds the qualifier for just 22 of those 24 rows.

Rows whose peak_dt is blank are kept on the same reasoning: a missing date is not a reason to discard a discharge value.

Callers who want a resolved timestamp alongside explicit year/month/day and a qualifier field should use waterdata.get_peaks(), which the modern API already provides.

Notes

  • Behavior change: peaks queries return more rows; datetime may be NaT, so a caller selecting on the datetime index will not see those peaks and should filter on peak_dt; and peak_dt is now present in the returned frame.
  • ADR 0005 permits compatibility, security and correctness fixes on the deprecated nwis facade. This adds no public symbol, getter, or parameter.
  • The waterservices_peaks fixture has no censored dates, so its row count is unchanged — its assertion moves 240 → 260 purely because peak_dt is retained. Restated as a shape so rows and columns are no longer conflated.

Verified: live across six sites, 672 rows in and 672 out, zero dropped, zero invented dates. 1136 tests pass; mypy --strict, ruff, Xenon, complexipy and import-linter all clean.

🤖 Generated with Claude Code

arpitjain099 and others added 8 commits July 17, 2026 06:03
nwis.get_discharge_peaks / get_record(service="peaks") against a site
with no annual-peak data returns a peaks RDB body of comment lines only,
which read_rdb parses to a column-less empty DataFrame. format_response
runs preformat_peaks_response before its own empty-frame check, and that
function's first statement pops "peak_dt", so the empty case raised
KeyError('peak_dt') instead of returning an empty frame.

This is the same empty-result contract fixed for the other services in
issue DOI-USGS#171; peaks was missed because it is preformatted first. Return the
frame unchanged when peak_dt is absent so the empty-frame path in
format_response handles it and callers can check df.empty.

Adds a regression test alongside the existing DOI-USGS#171 coverage.

Signed-off-by: Arpit Jain <arpitjain099@gmail.com>
The empty-peaks test parsed with _read_rdb, which already runs
format_response(service=None); the real get_discharge_peaks path uses
the raw read_rdb parser followed by format_response(service="peaks").
Switch to read_rdb so the test exercises the actual call path without
the redundant format pass.

Signed-off-by: thodson-usgs <thodson@usgs.gov>

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Conflict was in tests/nwis_test.py, where main added TestGetRecordDispatch
directly after the class this branch appends to; both sides kept.

read_rdb has since moved to the dataretrieval.rdb leaf, so the test imports
it from there rather than through the nwis adapter, and passes
_NWIS_RDB_DTYPES to mirror what get_discharge_peaks now does.

Retarget the test docstring at the defect that is actually reachable.
get_discharge_peaks does not raise KeyError: every empty peaks response the
live service returns starts "No sites/data", which _querying turns into
NoSitesError before format_response is reached. The real gap is that
format_response and preformat_peaks_response are public API and crash on a
column-less frame, where every other service yields an empty one (issue DOI-USGS#171).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
NWIS zero-fills the unknown part of a historical peak's date -- YYYY-MM-00
when the day is not known, YYYY-00-00 when the month is not either, the Bd
and Bm peak_cd qualifiers. Neither parses as a date, so to_datetime coerced
both to NaT and the dropna below deleted the row along with its discharge
value.

The loss is silent and biased toward the records that matter most, because
the imprecisely dated peaks are the old ones: site 14105700 lost 20 of its
167 peaks, among them an 1859 flood of 847,000 ft3/s, and 06934500 lost its
1844 peak of 700,000 ft3/s. Measured across six sites, 24 of 672 peaks were
being dropped; none are now.

Pin each to the start of the period that is known. waterdata.get_peaks(),
the replacement this facade points at, resolves the same records the same
way -- it dates a year-only peak to 1 January and flags it [MONTHUNKNOWN] --
so the two agree during the migration window. Filtered to discharge, legacy
and modern now return the same 167 rows for 14105700.

A peak with no peak_dt at all is still dropped: there is no period to pin it
to, and no way to place it on the datetime index format_response builds.
That is the case the existing test pins, and it stays green.

Permitted by ADR 0005, which allows correctness fixes on the deprecated nwis
facade.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Review follow-ups on the empty-peaks guard.

The guard fired on any frame missing peak_dt, including a non-empty one from
a truncated or altered RDB header. Such a frame is malformed rather than
empty, and was being returned silently without its datetime index where it
used to raise. Require df.empty too, and pin it with a test.

Correct the regression test's docstring, which said the empty responses
get_discharge_peaks sees are caught earlier as NoSitesError. That check fires
only on a body starting "No sites/data" -- what the live service happens to
send today -- so a comment-only RDB does reach the guarded line. As written
the docstring read as "this guard is unreachable", inviting its deletion.

Widen the test class docstring to cover both arms it now holds, document the
pass-through in preformat_peaks_response's public docstring, and add the
NEWS entry this behavior change warrants.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…oftware/dataretrieval-python/.claude/worktrees/pr344-nwis-peaks into fix/peaks-partial-dates

# Conflicts:
#	NEWS.md
#	dataretrieval/nwis.py
Cleanup pass, no behavior change.

The guard's contract was stated three times -- Notes paragraph, inline
comment, NEWS entry -- so nothing failed when they drifted. Keep the rendered
docstring, since preformat_peaks_response is public API, and cut the inline
comment back to the constraint it exists to explain. Drop the [MONTHUNKNOWN]
detail from _peak_datetimes: naming the Water Data collection's flag format
in a private legacy helper rots the first time that collection changes it.

test_preformat_peaks_response duplicated the new dateless test on the same
happy-path row, and its assertion -- isna().sum() == 0 after a dropna on that
column -- was trivially true and would have passed even if every row were
dropped. Fold its one distinct input into the stronger test.

Move the malformed-frame test out of TestReadRdb, which parses no RDB in it,
so the class docstring can describe its contents again, and hoist the
comment-only RDB body both its tests use into one constant. Drop the dtype
hints from the peaks call: read_rdb returns early for an all-comment body and
never applies them, so they only widened the test's coupling to nwis privates.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Replaces the imputing fix on this branch. NWIS zero-fills the unknown part of
a historical peak's date -- YYYY-MM-00 when the day is not known, YYYY-00-00
when the month is not either. Those rows were being dropped with their
discharge values: 24 of 672 peaks across six sites, and disproportionately a
site's largest, since the imprecisely dated peaks are the old ones.

The earlier fix pinned each to the start of the known period. That put a day
in the frame that the record does not claim, and because the function popped
peak_dt, it also destroyed the only evidence the date was ever approximate --
the response carries no water_yr, and peak_cd holds the Bd/Bm qualifier for
only 22 of those 24 rows.

There is no representation that avoids this in the datetime column itself. A
datetime64 value is a full instant, so pd.Timestamp("1858") IS 1858-01-01;
mixed-frequency Periods fall back to object dtype and cannot form an index.
The honest option is to leave datetime as NaT and keep the peak.

So: parse with coerce and stop there. No normalization, no dropna, and
peak_dt stays in the frame so a caller can recover the year and tell a
censored date from a known one. Rows whose peak_dt is blank are kept on the
same reasoning -- a missing date is not a reason to discard a discharge value.

The waterservices peaks fixture has no censored dates, so its row count is
unchanged; its assertion moves 240 -> 260 purely because peak_dt is retained.
Restated as a shape so the two are no longer conflated.

BREAKING CHANGE: peaks queries return more rows, datetime may be NaT, and
peak_dt is now present in the returned frame.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@thodson-usgs thodson-usgs changed the title fix(nwis): keep peaks whose date is only partly known fix(nwis): keep censored peaks instead of dropping or dating them Aug 27, 2026
@thodson-usgs

Copy link
Copy Markdown
Collaborator Author

Reworked the approach after maintainer review — the PR body and title are updated to match, so this note is for anyone who read the earlier version.

What changed: the previous revision pinned each censored date to the start of the known period (1858-00-001858-01-01), mirroring waterdata.get_peaks(). That put a day in the frame the record does not claim. Worse, because the function popped peak_dt, it destroyed the only evidence the date was ever approximate — so a caller could not distinguish an imputed date from a real one.

Why there's no middle option. I checked whether the datetime column could carry mixed precision. It can't — a datetime64 value is a full instant, and pandas imputes for you the moment you construct one:

pd.Timestamp('1858')     # -> 1858-01-01 00:00:00
pd.Timestamp('1858-06')  # -> 1858-06-01 00:00:00

Mixed-frequency Period values degrade to object dtype and raise IncompatibleFrequency on PeriodIndex/astype, so they can't form an index either.

Two facts that decided the shape of the fix:

  • The peaks RDB has no water_yr column, so peak_dt is the only carrier of a censored peak's year. It is no longer popped.
  • peak_cd is not a dependable censoring flag — 22 of 24 censored dates across six sites carry a Bd/Bm qualifier; two have peak_cd = NaN. Censoring has to be derived from peak_dt itself.

So the fix is now smaller than the one it replaces: parse with errors="coerce" and stop. No normalization helper, no dropna, peak_dt retained.

Verified live across six sites: 672 rows in, 672 out, zero dropped, zero invented dates, 24 NaT. 1136 tests pass; mypy strict, ruff, Xenon, complexipy and import-linter clean.

One knock-on worth calling out: the waterservices_peaks fixture asserted df.size == 240. size is rows × columns, so retaining peak_dt moved it to 260 with the row count unchanged at 20. Restated as df.shape == (20, 13) so the two aren't conflated again.

@thodson-usgs
thodson-usgs requested a review from ehinman August 27, 2026 14:09

@ehinman ehinman left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looks good to me.

@thodson-usgs

Copy link
Copy Markdown
Collaborator Author

Thanks @ehinman. You might not have read the relevant ADRs but the gist is that: NWIS is frozen, but bug fixes are still allowed. You may overrule that when needed, but should ask your approval before doing so.

@thodson-usgs
thodson-usgs merged commit 013d3fc into DOI-USGS:main Aug 28, 2026
11 checks passed
@thodson-usgs
thodson-usgs deleted the fix/peaks-partial-dates branch August 28, 2026 19:39
@ehinman

ehinman commented Aug 28, 2026

Copy link
Copy Markdown
Collaborator

Thanks @ehinman. You might not have read the relevant ADRs but the gist is that: NWIS is frozen, but bug fixes are still allowed. You may overrule that when needed, but should ask your approval before doing so.

I appreciate making bug fixes until the services are no longer available. Unfortunately, only people who are updating drpy and still using the legacy water services will reap the benefits.

thodson-usgs added a commit to thodson-usgs/dataretrieval-python that referenced this pull request Sep 1, 2026
…l dates

A glossary pass over the package found three canonical terms drifting, plus
the gap that let one of them drift.

**"censored" was a coinage that collides.** DOI-USGS#395 introduced "censored peak"
and "censored dates" for a peak whose date NWIS only partly knows. In water
data a *censored* value is one outside a detection limit -- a meaning this
package already ships, in `wqp.what_detection_limits` and
`ResultDetectionQuantitationLimit`. A reader meeting "a censored peak's year"
in a public docstring would reasonably expect a `peak_va` below a reporting
limit. Nothing about the value is censored; the date is partly unknown. The
same commit already had the right words -- "whose date is only partly known",
"cannot hold a partial date" -- so this uses them.

**A collection is not a service** (CONTEXT.md). `peaks`, `dv`, `iv` and `stat`
are NWIS collections, and Organization, Activity and Station are WQP's; prose
called all of them services, including "every other service already treated an
empty result as a legitimate empty frame", which reads as a claim about other
USGS systems when it is a claim about other NWIS collections.

**Monitoring location is canonical**, and the legacy exemption is narrower
than it was being read: the glossary grants it to "the deprecated NWIS getters
and the WQP profiles ... and their parameters", not to prose, and not at all to
the modern adapters. Fixed in `waterdata/`, `ogc/planning.py`, NEWS, and tests.
`nwis.py` also said "USGS station", which is neither the canonical term nor the
recorded legacy one.

**The gap.** `service=` on the legacy NWIS getters names a collection, and
`CONTEXT.md` recorded the identical OGC resolution while leaving this one
unlisted. Unlisted, the parameter's spelling read as licence for the prose
spelling. It is now a Known legacy name, with the scope stated: the parameter
keeps its name, prose says collection.

Prose only. No executable code changed -- verified by comparing the AST of
every touched module against its parent with docstrings stripped.

Raised, not fixed, for their own change:

- `wqp.services_wqx3` and `wqp.services_legacy` hold collection names. Not in
  `__all__`, but `tests/wqp_test.py` reaches them through the module, so
  renaming is a code change rather than a prose one.
- `pd.to_datetime(df["peak_dt"], errors="coerce")` in `preformat_peaks_response`
  emits a pandas `UserWarning` ("Could not infer format...") whenever a frame
  mixes zero-filled and parseable dates. Pre-existing, but DOI-USGS#395 promoted that
  mix from an edge case to the documented normal case for historical peaks, so
  a caller now gets library noise on stderr. Passing `format="%Y-%m-%d"`
  silences it and coerces identically.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JAEQqs7XzQHGQQi2KakuXD
thodson-usgs added a commit to arpitjain099/dataretrieval-python that referenced this pull request Sep 2, 2026
Main absorbed this PR's empty-peaks guard via DOI-USGS#395 (censored peaks), so the
code and NEWS conflicts resolve to upstream wholesale — keeping this branch's
version of preformat_peaks_response would have reverted DOI-USGS#395's keep-censored-
peaks behavior (it popped peak_dt and dropped NaT rows). The branch's
remaining net contribution is the malformed-frame regression test; its
duplicate empty-peaks test (now redundant with main's) and the
_NWIS_RDB_DTYPES import it needed are dropped.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JAEQqs7XzQHGQQi2KakuXD
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants