fix(nwis): keep censored peaks instead of dropping or dating them - #395
Conversation
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>
|
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 ( Why there's no middle option. I checked whether the datetime column could carry mixed precision. It can't — a pd.Timestamp('1858') # -> 1858-01-01 00:00:00
pd.Timestamp('1858-06') # -> 1858-06-01 00:00:00Mixed-frequency Two facts that decided the shape of the fix:
So the fix is now smaller than the one it replaces: parse with Verified live across six sites: 672 rows in, 672 out, zero dropped, zero invented dates, 24 One knock-on worth calling out: the |
|
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. |
…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
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
Stacked on #344 — that branch is merged in, so the diff to review here is
preformat_peaks_responseand its tests. Rebase or merge once #344 lands.The bug
nwis.get_discharge_peaksandget_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:
So
1858-00-00(month unknown) and1844-06-00(day unknown) are real, qualified records. Neither parses as a date, sopreformat_peaks_responsecoerced both toNaTand thedropnabelow 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:
Why the date is left as
NaTrather than completedAn earlier revision of this branch pinned each censored date to the start of the known period (
1858-00-00→1858-01-01), mirroringwaterdata.get_peaks(). That was wrong twice over: it put a day in the frame that the record does not claim, and since the function poppedpeak_dt, it destroyed the only evidence the date was ever approximate.There is no representation that avoids this within the datetime column. A
datetime64value is a full instant:Mixed-frequency
Periodvalues fall back toobjectdtype and raiseIncompatibleFrequencyonPeriodIndex/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
datetimeis leftNaT:peak_dtis no longer popped. The peaks response has nowater_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, sincepeak_cdholds the qualifier for just 22 of those 24 rows.Rows whose
peak_dtis 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/dayand aqualifierfield should usewaterdata.get_peaks(), which the modern API already provides.Notes
datetimemay beNaT, so a caller selecting on the datetime index will not see those peaks and should filter onpeak_dt; andpeak_dtis now present in the returned frame.nwisfacade. This adds no public symbol, getter, or parameter.waterservices_peaksfixture has no censored dates, so its row count is unchanged — its assertion moves 240 → 260 purely becausepeak_dtis 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