From f5596573dc29836b54f2ad7733453e6206a5dba5 Mon Sep 17 00:00:00 2001 From: thodson-usgs Date: Sat, 30 May 2026 15:00:18 -0400 Subject: [PATCH] fix(waterdata): resolve naive datetime filters per-date, not at today's offset _format_api_dates froze `datetime.now().astimezone().tzinfo` (today's UTC offset) and applied it to every naive datetime input. A naive input on a date whose DST status differs from today was shifted by an hour: on an EDT (-0400) machine, time="2020-01-01 12:00:00" produced 2020-01-01T16:00:00Z, but January is EST (-0500) so the correct value is 17:00:00Z. This skewed the query window for get_continuous / get_field_measurements (time/begin/end) across DST boundaries. Use `parsed.astimezone()` to interpret a naive input in the system local zone with the DST rules for its own date, then convert to UTC. Inputs that already carry an explicit offset are unaffected. Co-Authored-By: Claude Opus 4.8 (1M context) --- dataretrieval/waterdata/utils.py | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/dataretrieval/waterdata/utils.py b/dataretrieval/waterdata/utils.py index 0a9f1c71..20a7e1b6 100644 --- a/dataretrieval/waterdata/utils.py +++ b/dataretrieval/waterdata/utils.py @@ -223,7 +223,7 @@ def _parse_datetime(value: str) -> datetime | None: return None -def _format_one(dt, *, date: bool, local_tz) -> str | None: +def _format_one(dt, *, date: bool) -> str | None: """Format a single datetime element for inclusion in the API time arg.""" if pd.isna(dt) or dt == "" or dt is None: return ".." @@ -232,7 +232,11 @@ def _format_one(dt, *, date: bool, local_tz) -> str | None: return None if date: return parsed.strftime("%Y-%m-%d") - aware = parsed if parsed.tzinfo is not None else parsed.replace(tzinfo=local_tz) + # Naive inputs are interpreted in the system local zone (for backwards + # compatibility). Use ``.astimezone()`` rather than a fixed offset so each + # value is resolved against the DST rules for ITS OWN date — a frozen + # ``datetime.now()`` offset shifted off-season inputs by an hour. + aware = parsed if parsed.tzinfo is not None else parsed.astimezone() return aware.astimezone(ZoneInfo("UTC")).strftime("%Y-%m-%dT%H:%M:%SZ") @@ -317,12 +321,8 @@ def _format_api_dates( return single # Half-bounded ranges: NA endpoints render as ".."; any unparseable non-NA - # element invalidates the range. Resolve the local tz only now — after the - # all-NA / duration / interval guards above have had their chance to return. - local_timezone = datetime.now().astimezone().tzinfo - formatted = [ - _format_one(dt, date=date, local_tz=local_timezone) for dt in datetime_input - ] + # element invalidates the range. + formatted = [_format_one(dt, date=date) for dt in datetime_input] if any(f is None for f in formatted): return None return "/".join(formatted)