Skip to content

Commit e87feb6

Browse files
thodson-usgsclaude
andcommitted
feat(waterdata): cap chunk_granularity by total sub-requests; document + benchmark
Cap chunk_granularity on the plan's TOTAL sub-request count (2/8/32) rather than per multi-value axis, so several multi-value arguments can't multiply past the ceiling. ChunkPlan._refine now splits the largest splittable chunk across every axis round-robin; behavior is identical for the common single-axis query. Add a 'Speeding up large downloads' usage section to the README (Water Data API) with a measured cold-cache benchmark: parallelizing a large paginated pull's sub-requests gave ~6x (production page size, cold) up to ~12x (latency-bound) on 271 Ohio discharge sites. Temper the 'quota-neutral' claim in the docstring, NEWS, and user guide: a finer split is only ~quota-neutral when each sub-request still spans many pages; otherwise each chunk's partial final page adds some requests. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 0195113 commit e87feb6

6 files changed

Lines changed: 173 additions & 71 deletions

File tree

NEWS.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
**07/01/2026:** Added `waterdata.chunk_granularity(...)` — a context manager to control how finely the OGC `waterdata` getters split multi-value requests. By default the getters chunk only as much as the server's ~8 KB URL limit forces (the fewest sub-requests). But because every sub-request paginates, splitting a large result further is usually quota-neutral, so a caller who *knows* their pull is large can opt into a finer split for smoother progress, more even concurrency, and a smaller unit of retry/resume: `with waterdata.chunk_granularity("high"): df, md = waterdata.get_daily(monitoring_location_id=many_sites)`. The level is one of `"low"`, `"medium"`, or `"high"` — typed as `waterdata.GranularityLevel` (a `typing.Literal`), so a type checker rejects any other value and an invalid string raises `ValueError` at the `with`. Each level caps how many sub-chunks a multi-value argument is split into — `"low"` / `"medium"` / `"high"` cap at 2 / 8 / 32. That ceiling is a granularity constant, deliberately independent of the fan-out concurrency (`API_USGS_CONCURRENT`): how finely a query splits is orthogonal to how many sub-requests run at once. Capping the aggressive end at 32 bounds the blast radius — an accidental `"high"` on a huge list can't explode into thousands of sub-requests. There is no "off" level: not entering the block *is* off. It is deliberately a scoped `with` block rather than an automatic behavior or an environment variable, since the library can't tell in advance whether a query is large — a short-window query might fit in a single page, where extra chunks would only burn quota. Also exported at the top level as `dataretrieval.chunk_granularity`.
1+
**07/01/2026:** Added `waterdata.chunk_granularity(...)` — a context manager to control how finely the OGC `waterdata` getters split multi-value requests. By default the getters chunk only as much as the server's ~8 KB URL limit forces (the fewest sub-requests). But because every sub-request paginates, splitting a large result further costs little or no extra quota when each sub-request still spans many pages, so a caller who *knows* their pull is large can opt into a finer split for smoother progress, more even concurrency, and a smaller unit of retry/resume: `with waterdata.chunk_granularity("high"): df, md = waterdata.get_daily(monitoring_location_id=many_sites)`. The level is one of `"low"`, `"medium"`, or `"high"` — typed as `waterdata.GranularityLevel` (a `typing.Literal`), so a type checker rejects any other value and an invalid string raises `ValueError` at the `with`. Each level caps the *total* number of sub-requests the call is split into — `"low"` / `"medium"` / `"high"` cap at 2 / 8 / 32 overall, across every multi-value argument combined, not per argument. That ceiling is a granularity constant, deliberately independent of the fan-out concurrency (`API_USGS_CONCURRENT`): how finely a query splits is orthogonal to how many sub-requests run at once. Capping the aggressive end at 32 bounds the blast radius — an accidental `"high"` on a huge list, or a call with several multi-value arguments, can't explode into thousands of sub-requests. There is no "off" level: not entering the block *is* off. It is deliberately a scoped `with` block rather than an automatic behavior or an environment variable, since the library can't tell in advance whether a query is large — a short-window query might fit in a single page, where extra chunks would only burn quota. Also exported at the top level as `dataretrieval.chunk_granularity`.
22

33
**06/23/2026:** **Breaking change (1.2.0):** the minimum supported Python is now **3.10** (`requires-python = ">=3.10"`). 3.9 support was already effectively broken — the `waterdata` module's dependencies (`anyio`, the test stack) require 3.10+, and the `waterdata` test modules already skipped on <3.10. `anyio` is now declared as a direct dependency (it is imported directly by `waterdata`), and the CI/ruff/mypy targets move to 3.10. Also fully removed the deprecated `variable_info` metadata property: the `NWIS_Metadata` override only warned and returned `None` (it relied on the defunct `get_pmcodes`), and the `BaseMetadata` abstract is gone too since nothing implemented it — accessing `.variable_info` now raises `AttributeError`. `site_info` is unaffected.
44

README.md

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -105,6 +105,54 @@ df, metadata = waterdata.get_continuous(
105105
print(f"Retrieved {len(df)} continuous gage height measurements")
106106
```
107107

108+
#### Speeding up large downloads with `chunk_granularity`
109+
110+
By default the getters split a multi-value request only as far as the server's
111+
~8 KB URL limit forces — the fewest sub-requests. For a **large, paginated**
112+
pull that is needlessly conservative: every sub-request pages through its own
113+
results, so dividing the query into more, smaller sub-requests lets those pages
114+
be fetched **in parallel**. `chunk_granularity` opts a single call into that
115+
finer split. It pays off only when the result is large enough to span many
116+
pages *and* the query has a multi-value argument to divide (such as a list of
117+
monitoring locations); on a small query — or one with nothing to split — it just
118+
adds requests, so it is a deliberate, scoped `with` block, never the default.
119+
120+
```python
121+
from dataretrieval import waterdata
122+
123+
# All stream gages in Ohio, then 20 years of their daily discharge — large
124+
# enough to span many pages, so it profits from a finer split.
125+
sites, _ = waterdata.get_monitoring_locations(state="Ohio", site_type_code="ST")
126+
127+
with waterdata.chunk_granularity("high"): # "low" | "medium" | "high"
128+
df, md = waterdata.get_daily(
129+
monitoring_location_id=sites["monitoring_location_id"].tolist(),
130+
parameter_code="00060", # discharge
131+
time="2004-01-01/2023-12-31",
132+
)
133+
```
134+
135+
`"high"` fans a call out into up to 32 sub-requests, `"medium"` up to 8, and
136+
`"low"` up to 2 — a fixed ceiling, so an accidental setting on a huge list can't
137+
explode into thousands of requests.
138+
139+
Benchmark — 271 Ohio discharge sites (`get_daily`, `parameter_code="00060"`),
140+
cold cache, each level run against its own time window so results are not
141+
cache-served, with the page size fixed so every level fetches roughly the same
142+
number of pages (isolating the effect of parallelism):
143+
144+
| level | parallelism | pages | wall-clock | speedup |
145+
| -------------- | ----------- | ----- | ------------------------- | ------- |
146+
| `default` | 1 | 32 | 23.9 s / 27.4 s (2 runs) ||
147+
| `"medium"` (8) | 8 | 37 | 4.1 s / 4.3 s | ~|
148+
| `"high"` (32) | 32 | 44 | 2.0 s | ~12× |
149+
150+
The gain comes from overlapping each sub-request's per-page latency and
151+
server-side work — a genuinely large pull at the default page size shows a
152+
similar multi-fold speedup. The extra sub-requests each cost one request against
153+
your hourly [rate limit](https://api.waterdata.usgs.gov/signup/), so reserve the
154+
aggressive levels for pulls you know are large.
155+
108156
Visit the
109157
[API Reference](https://doi-usgs.github.io/dataretrieval-python/reference/waterdata.html)
110158
for more information and examples on available services and input parameters.

dataretrieval/ogc/chunking.py

Lines changed: 36 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -182,8 +182,9 @@ def get_active_client() -> httpx.AsyncClient | None:
182182
# Chunk-granularity dial: opt-in to fan a query out *more finely* than the byte
183183
# limit alone requires. Scoped to a ``with chunk_granularity(...):`` block (a
184184
# ContextVar), deliberately NOT an env var (see :func:`chunk_granularity` for
185-
# why). The ambient holds the resolved cap on sub-chunks per axis; ``0`` (the
186-
# default, outside any block) means "chunk only as much as the byte limit needs".
185+
# why). The ambient holds the resolved cap on the plan's total sub-request
186+
# count; ``0`` (the default, outside any block) means "chunk only as much as
187+
# the byte limit needs".
187188
_granularity: Ambient[int] = Ambient("ogc_chunk_granularity", 0)
188189

189190
#: The three accepted granularity levels, as a typing ``Literal`` so a type
@@ -196,12 +197,14 @@ def get_active_client() -> httpx.AsyncClient | None:
196197
#: modules).
197198
_VALID_LEVELS: tuple[GranularityLevel, ...] = get_args(GranularityLevel)
198199

199-
# Granularity's own ceiling on sub-chunks per axis — deliberately NOT tied to the
200-
# concurrency default: fan-out *volume* (how many sub-requests a query becomes)
201-
# is orthogonal to how many run at once (``API_USGS_CONCURRENT``). 32 is a sane
202-
# single-axis ceiling that also bounds the blast radius of an accidental
203-
# ``"high"`` on a very long list; the milder levels are a quarter and a
204-
# sixteenth of it (the three are spaced 4x apart).
200+
# Granularity's own ceiling on the plan's total sub-request count —
201+
# deliberately NOT tied to the concurrency default: fan-out *volume* (how many
202+
# sub-requests a query becomes) is orthogonal to how many run at once
203+
# (``API_USGS_CONCURRENT``). 32 is a sane ceiling on the whole call (across
204+
# every axis, not per axis — see ``ChunkPlan._refine``) that bounds the blast
205+
# radius of an accidental ``"high"`` on a very long list or several multi-value
206+
# arguments at once; the milder levels are a quarter and a sixteenth of it (the
207+
# three are spaced 4x apart).
205208
_GRANULARITY_MAX_CHUNKS = 32
206209
_GRANULARITY_LEVELS: dict[str, int] = {
207210
"low": _GRANULARITY_MAX_CHUNKS // 16, # 2
@@ -212,7 +215,7 @@ def get_active_client() -> httpx.AsyncClient | None:
212215

213216
def _resolve_granularity(level: GranularityLevel) -> int:
214217
"""
215-
Map a granularity level name to its per-axis sub-chunk cap.
218+
Map a granularity level name to its total sub-request cap.
216219
217220
Parameters
218221
----------
@@ -222,7 +225,7 @@ def _resolve_granularity(level: GranularityLevel) -> int:
222225
Returns
223226
-------
224227
int
225-
The maximum sub-chunks per multi-value axis for that level
228+
The maximum total sub-requests for the whole call at that level
226229
(``2`` / ``8`` / ``32``).
227230
228231
Raises
@@ -247,12 +250,16 @@ def chunk_granularity(level: GranularityLevel) -> Iterator[None]:
247250
By default the Water Data / NGWMN getters chunk a request only as much as
248251
the server's ~8 KB URL-byte limit forces — the fewest sub-requests that
249252
fit. That is the safe default, but it can be *needlessly* conservative:
250-
because every sub-request paginates, splitting a large result further is
251-
usually quota-neutral (ten states pulled as one under-limit request page
252-
just as many times as ten per-state requests would). This context manager
253-
lets a caller who *knows* their pull is large ask for that finer split —
254-
trading the same pages for more, smaller sub-requests, which gives smoother
255-
progress, more even concurrency, and a smaller unit of retry/resume.
253+
because every sub-request paginates, splitting a large result further costs
254+
little or no extra quota *as long as each sub-request still spans many
255+
pages* — rows-per-chunk far exceeding the page size (ten states pulled as
256+
one request then page nearly as many times as ten per-state requests
257+
would). When a split leaves each sub-request only a page or two, its partial
258+
final page is extra, so finer chunks do add some requests. This context
259+
manager lets a caller who *knows* their pull is large ask for that finer
260+
split — trading roughly the same pages for more, smaller sub-requests, which
261+
gives smoother progress, more even concurrency, and a smaller unit of
262+
retry/resume.
256263
257264
Because the library can't tell in advance whether a query is large (ten
258265
states over a short window might fit in a single page, where extra chunks
@@ -267,15 +274,19 @@ def chunk_granularity(level: GranularityLevel) -> Iterator[None]:
267274
Parameters
268275
----------
269276
level : {"low", "medium", "high"}
270-
How aggressively to chunk within the block. Each level caps how many
271-
sub-chunks a single multi-value argument is split into — ``2`` / ``8`` /
272-
``32`` for ``"low"`` / ``"medium"`` / ``"high"`` — or one value per
273-
sub-request once the argument has fewer values than the cap. The ceiling
274-
is fixed (it is *not* tied to ``API_USGS_CONCURRENT``: how finely a query
275-
splits is orthogonal to how many sub-requests run at once); capping
276-
``"high"`` at ``32`` keeps an accidental aggressive level on a very long
277-
list from exploding into thousands of sub-requests. (With several
278-
multi-value arguments the per-argument counts still multiply.)
277+
How aggressively to chunk within the block. Each level caps the
278+
*total* number of sub-requests the call is split into — ``2`` / ``8``
279+
/ ``32`` for ``"low"`` / ``"medium"`` / ``"high"`` — or one
280+
sub-request per remaining atom once that's fewer than the cap. The
281+
cap applies to the whole call, not per multi-value argument: with
282+
several multi-value arguments the axes are refined together against
283+
the same shared ceiling (their cartesian product does not multiply
284+
past it). The ceiling is fixed (it is *not* tied to
285+
``API_USGS_CONCURRENT``: how finely a query splits is orthogonal to
286+
how many sub-requests run at once); capping ``"high"`` at ``32`` keeps
287+
an accidental aggressive level on a very long list — or several
288+
multi-value arguments at once — from exploding into thousands of
289+
sub-requests.
279290
280291
Yields
281292
------

dataretrieval/ogc/planning.py

Lines changed: 39 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -330,12 +330,13 @@ class ChunkPlan:
330330
Byte budget for the request (URL + body) — a hard ceiling every
331331
sub-request must fit.
332332
max_chunks_per_axis : int, optional
333-
Soft cap on sub-chunks per multi-value axis (default ``0`` = off).
333+
Soft cap on the plan's total sub-request count (default ``0`` = off).
334334
``0`` chunks only as much as ``url_limit`` requires — the most
335-
conservative plan, fewest sub-requests. A positive cap fans each axis
336-
out into up to ``min(len(atoms), max_chunks_per_axis)`` pieces (never
337-
fewer than the byte budget already forces), so a large multi-page pull
338-
is issued as more, smaller sub-requests. Set from the resolved
335+
conservative plan, fewest sub-requests. A positive cap fans the plan
336+
out to up to ``max_chunks_per_axis`` sub-requests overall (the
337+
cartesian product across axes, never fewer than the byte budget
338+
already forces) — capped as a whole, not per axis, so several
339+
multi-value axes can't multiply past the cap. Set from the resolved
339340
:func:`~dataretrieval.ogc.chunking.chunk_granularity` level; see
340341
:meth:`_refine`.
341342
@@ -492,38 +493,50 @@ def _plan(
492493

493494
def _refine(self, max_chunks_per_axis: int) -> None:
494495
"""
495-
Fan each axis out more finely than the byte budget alone requires —
496+
Fan the plan out more finely than the byte budget alone requires —
496497
the granularity dial (see
497498
:func:`~dataretrieval.ogc.chunking.chunk_granularity` for why a caller
498499
would want this).
499500
500-
Each axis is split until it holds at least
501-
``min(len(atoms), max_chunks_per_axis)`` chunks — up to the cap, or one
502-
atom per chunk for a shorter axis. Purely additive — only ever *splits*
503-
existing chunks, so the byte pass's work and the ``url_limit`` invariant
504-
are both preserved (an axis the byte pass already split past the cap is
505-
left alone), and it never raises. A no-op at ``max_chunks_per_axis <= 0``.
501+
Caps the plan's *total* sub-request count (:attr:`total`, the
502+
cartesian product across all axes) at ``max_chunks_per_axis``, not
503+
each axis independently — with several multi-value axes, a cap of 32
504+
still means at most 32 sub-requests overall, not ``32 ** n_axes``.
505+
Each split picks the single largest splittable chunk across *every*
506+
axis (ties broken by axis-extraction order, then lowest index), so
507+
growth is distributed round-robin rather than one axis saturating
508+
before another is touched. Purely additive — only ever *splits*
509+
existing chunks, so the byte pass's work and the ``url_limit``
510+
invariant are both preserved, and it never raises. A no-op at
511+
``max_chunks_per_axis <= 0``.
506512
507513
Parameters
508514
----------
509515
max_chunks_per_axis : int
510-
Soft cap on sub-chunks per axis (``0`` = off), the resolved
511-
granularity level. A shorter axis simply saturates at one atom per
512-
chunk.
516+
Soft cap on the plan's total sub-request count (``0`` = off),
517+
the resolved granularity level. Despite the name — kept for the
518+
public dial's "sub-chunks per multi-value argument" framing,
519+
which matches this cap exactly in the common single-axis case —
520+
multi-axis plans are capped on the product, not per axis.
513521
"""
514522
if max_chunks_per_axis <= 0:
515523
return
516-
for axis in self.axes:
517-
chunks = self.chunks[axis.arg_key]
518-
target = min(len(axis.atoms), max_chunks_per_axis)
519-
# ``target <= len(atoms)`` guarantees a splittable chunk each pass, so
520-
# this reaches exactly ``target`` and terminates. Split the chunk with
521-
# the most *atoms* (``_plan`` splits by *bytes*): here we even out
522-
# cardinality for smooth fan-out, not URL size. Ties take the lowest
523-
# index, keeping the split deterministic.
524-
while len(chunks) < target:
525-
idx, _ = max(enumerate(chunks), key=lambda kv: len(kv[1]))
526-
_split_at(chunks, idx)
524+
while self.total < max_chunks_per_axis:
525+
# Largest splittable chunk across every axis; a chunk of size 1
526+
# can't be split further. ``max`` with a stable input order
527+
# breaks ties by axis order, then lowest index within an axis.
528+
candidate: tuple[_Axis, int] | None = None
529+
candidate_size = -1
530+
for axis in self.axes:
531+
for idx, chunk in enumerate(self.chunks[axis.arg_key]):
532+
if len(chunk) <= 1:
533+
continue
534+
if len(chunk) > candidate_size:
535+
candidate, candidate_size = (axis, idx), len(chunk)
536+
if candidate is None:
537+
return # every axis saturated at one atom per chunk
538+
axis, idx = candidate
539+
_split_at(self.chunks[axis.arg_key], idx)
527540

528541
def _worst_case_args(self) -> dict[str, Any]:
529542
"""

0 commit comments

Comments
 (0)