usdata 0.12.0__tar.gz → 0.13.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {usdata-0.12.0 → usdata-0.13.0}/PKG-INFO +1 -1
- {usdata-0.12.0 → usdata-0.13.0}/pyproject.toml +1 -1
- {usdata-0.12.0 → usdata-0.13.0}/pyproject.toml.orig +1 -1
- {usdata-0.12.0 → usdata-0.13.0}/src/usdata/data/registry.yaml +3 -0
- usdata-0.13.0/src/usdata/protocols/listing.py +61 -0
- {usdata-0.12.0 → usdata-0.13.0}/src/usdata/protocols/s3.py +0 -1
- {usdata-0.12.0 → usdata-0.13.0}/src/usdata/providers/base.py +55 -2
- {usdata-0.12.0 → usdata-0.13.0}/src/usdata/providers/noaa/coastwatch.py +7 -13
- {usdata-0.12.0 → usdata-0.13.0}/src/usdata/providers/noaa/coops.py +3 -10
- {usdata-0.12.0 → usdata-0.13.0}/src/usdata/providers/noaa/ghcnd.py +26 -16
- {usdata-0.12.0 → usdata-0.13.0}/src/usdata/providers/noaa/goes.py +15 -16
- {usdata-0.12.0 → usdata-0.13.0}/src/usdata/providers/noaa/gsom.py +1 -8
- {usdata-0.12.0 → usdata-0.13.0}/src/usdata/providers/noaa/gsoy.py +1 -11
- {usdata-0.12.0 → usdata-0.13.0}/src/usdata/providers/noaa/hurdat2.py +21 -33
- {usdata-0.12.0 → usdata-0.13.0}/src/usdata/providers/noaa/nexrad.py +10 -8
- {usdata-0.12.0 → usdata-0.13.0}/src/usdata/providers/noaa/normals.py +7 -19
- {usdata-0.12.0 → usdata-0.13.0}/src/usdata/providers/noaa/storm_events.py +13 -51
- {usdata-0.12.0 → usdata-0.13.0}/src/usdata/providers/usgs/daily.py +16 -18
- {usdata-0.12.0 → usdata-0.13.0}/README.md +0 -0
- {usdata-0.12.0 → usdata-0.13.0}/src/usdata/__init__.py +0 -0
- {usdata-0.12.0 → usdata-0.13.0}/src/usdata/_files.py +0 -0
- {usdata-0.12.0 → usdata-0.13.0}/src/usdata/_hurdat2.py +0 -0
- {usdata-0.12.0 → usdata-0.13.0}/src/usdata/_netcdf.py +0 -0
- {usdata-0.12.0 → usdata-0.13.0}/src/usdata/_progress.py +0 -0
- {usdata-0.12.0 → usdata-0.13.0}/src/usdata/_radar.py +0 -0
- {usdata-0.12.0 → usdata-0.13.0}/src/usdata/cache.py +0 -0
- {usdata-0.12.0 → usdata-0.13.0}/src/usdata/cli/__init__.py +0 -0
- {usdata-0.12.0 → usdata-0.13.0}/src/usdata/cli/app.py +0 -0
- {usdata-0.12.0 → usdata-0.13.0}/src/usdata/cli/progress.py +0 -0
- {usdata-0.12.0 → usdata-0.13.0}/src/usdata/data/nexrad_sites.csv +0 -0
- {usdata-0.12.0 → usdata-0.13.0}/src/usdata/data/places.csv +0 -0
- {usdata-0.12.0 → usdata-0.13.0}/src/usdata/data/places.sources.json +0 -0
- {usdata-0.12.0 → usdata-0.13.0}/src/usdata/fetch.py +0 -0
- {usdata-0.12.0 → usdata-0.13.0}/src/usdata/manifest.py +0 -0
- {usdata-0.12.0 → usdata-0.13.0}/src/usdata/models.py +0 -0
- {usdata-0.12.0 → usdata-0.13.0}/src/usdata/protocols/__init__.py +0 -0
- {usdata-0.12.0 → usdata-0.13.0}/src/usdata/protocols/erddap.py +0 -0
- {usdata-0.12.0 → usdata-0.13.0}/src/usdata/protocols/http.py +0 -0
- {usdata-0.12.0 → usdata-0.13.0}/src/usdata/provenance.py +0 -0
- {usdata-0.12.0 → usdata-0.13.0}/src/usdata/providers/__init__.py +0 -0
- {usdata-0.12.0 → usdata-0.13.0}/src/usdata/providers/_http.py +0 -0
- {usdata-0.12.0 → usdata-0.13.0}/src/usdata/providers/noaa/__init__.py +0 -0
- {usdata-0.12.0 → usdata-0.13.0}/src/usdata/providers/noaa/sites.py +0 -0
- {usdata-0.12.0 → usdata-0.13.0}/src/usdata/providers/usgs/__init__.py +0 -0
- {usdata-0.12.0 → usdata-0.13.0}/src/usdata/pull.py +0 -0
- {usdata-0.12.0 → usdata-0.13.0}/src/usdata/py.typed +0 -0
- {usdata-0.12.0 → usdata-0.13.0}/src/usdata/query.py +0 -0
- {usdata-0.12.0 → usdata-0.13.0}/src/usdata/readers.py +0 -0
- {usdata-0.12.0 → usdata-0.13.0}/src/usdata/registry.py +0 -0
- {usdata-0.12.0 → usdata-0.13.0}/src/usdata/selection.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: usdata
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.13.0
|
|
4
4
|
Summary: Unified Python SDK and CLI for discovering, fetching, and tracking provenance of U.S. public scientific data
|
|
5
5
|
Keywords: noaa,usgs,nasa,open-data,scientific-data,provenance
|
|
6
6
|
Author: Jake Van Slyke
|
|
@@ -743,6 +743,7 @@ catalog:
|
|
|
743
743
|
reader_extra: radar
|
|
744
744
|
examples:
|
|
745
745
|
- examples/radar-reflectivity/example.ipynb
|
|
746
|
+
- examples/event-context/example.ipynb
|
|
746
747
|
noaa:goes-abi:
|
|
747
748
|
guide: docs/providers/noaa-goes.md
|
|
748
749
|
summary: GOES CONUS imagery
|
|
@@ -753,6 +754,7 @@ catalog:
|
|
|
753
754
|
reader_extra: netcdf
|
|
754
755
|
examples:
|
|
755
756
|
- examples/goes-imagery/example.ipynb
|
|
757
|
+
- examples/event-context/example.ipynb
|
|
756
758
|
noaa:storm-events:
|
|
757
759
|
guide: docs/providers/noaa-storm-events.md
|
|
758
760
|
summary: Storm Events details
|
|
@@ -763,6 +765,7 @@ catalog:
|
|
|
763
765
|
reader_extra: pandas
|
|
764
766
|
examples:
|
|
765
767
|
- examples/storm-events/example.ipynb
|
|
768
|
+
- examples/event-context/example.ipynb
|
|
766
769
|
noaa:hurdat2:
|
|
767
770
|
guide: docs/providers/noaa-hurdat2.md
|
|
768
771
|
summary: Tropical cyclone best tracks
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
"""Read file names and byte sizes from Apache-style HTML directory indexes."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
from html.parser import HTMLParser
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class _Index(HTMLParser):
|
|
10
|
+
"""Collect (name, size) for anchors whose href is a literal local file name.
|
|
11
|
+
|
|
12
|
+
Only hrefs matching ``pattern`` in full are kept, so a listing can never
|
|
13
|
+
steer a download to an arbitrary link. The size is the first all-digit
|
|
14
|
+
table cell after the anchor's cell in the same row, or None.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
def __init__(self, pattern: re.Pattern[str]) -> None:
|
|
18
|
+
super().__init__()
|
|
19
|
+
self.pattern = pattern
|
|
20
|
+
self.entries: list[tuple[str, int | None]] = []
|
|
21
|
+
self._in_row = False
|
|
22
|
+
self._in_cell = False
|
|
23
|
+
self._cells: list[str] = []
|
|
24
|
+
self._name: str | None = None
|
|
25
|
+
self._name_cell: int | None = None
|
|
26
|
+
|
|
27
|
+
def handle_starttag(self, tag: str, attrs: list[tuple[str, str | None]]) -> None:
|
|
28
|
+
if tag == "tr":
|
|
29
|
+
self._in_row, self._cells, self._name, self._name_cell = True, [], None, None
|
|
30
|
+
elif tag == "td":
|
|
31
|
+
self._cells.append("")
|
|
32
|
+
self._in_cell = True
|
|
33
|
+
elif tag == "a":
|
|
34
|
+
href = dict(attrs).get("href", "") or ""
|
|
35
|
+
if not self.pattern.fullmatch(href):
|
|
36
|
+
return
|
|
37
|
+
if self._in_row:
|
|
38
|
+
self._name, self._name_cell = href, len(self._cells) - 1
|
|
39
|
+
else:
|
|
40
|
+
self.entries.append((href, None))
|
|
41
|
+
|
|
42
|
+
def handle_data(self, data: str) -> None:
|
|
43
|
+
if self._in_cell:
|
|
44
|
+
self._cells[-1] += data
|
|
45
|
+
|
|
46
|
+
def handle_endtag(self, tag: str) -> None:
|
|
47
|
+
if tag == "td":
|
|
48
|
+
self._in_cell = False
|
|
49
|
+
elif tag == "tr":
|
|
50
|
+
if self._name is not None:
|
|
51
|
+
later = self._cells[(self._name_cell or 0) + 1 :]
|
|
52
|
+
sizes = [c.strip() for c in later if c.strip().isascii() and c.strip().isdigit()]
|
|
53
|
+
self.entries.append((self._name, int(sizes[0]) if sizes else None))
|
|
54
|
+
self._in_row = False
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def directory_entries(html: str, pattern: re.Pattern[str]) -> list[tuple[str, int | None]]:
|
|
58
|
+
"""File names matching ``pattern`` in a directory index, with byte sizes when listed."""
|
|
59
|
+
index = _Index(pattern)
|
|
60
|
+
index.feed(html)
|
|
61
|
+
return index.entries
|
|
@@ -61,7 +61,6 @@ def list_objects(
|
|
|
61
61
|
try:
|
|
62
62
|
while True:
|
|
63
63
|
resp = http.get(https_url(bucket), client, params=params)
|
|
64
|
-
resp.raise_for_status()
|
|
65
64
|
root = ET.fromstring(resp.text)
|
|
66
65
|
truncated = _text(root, "IsTruncated") == "true"
|
|
67
66
|
token = _text(root, "NextContinuationToken")
|
|
@@ -5,12 +5,21 @@ from __future__ import annotations
|
|
|
5
5
|
import importlib
|
|
6
6
|
from abc import ABC, abstractmethod
|
|
7
7
|
from collections.abc import Mapping
|
|
8
|
+
from datetime import UTC, datetime
|
|
8
9
|
from pathlib import Path
|
|
9
10
|
from types import MappingProxyType, TracebackType
|
|
10
|
-
from typing import ClassVar, Self
|
|
11
|
+
from typing import ClassVar, Literal, Self
|
|
11
12
|
|
|
12
13
|
from usdata.models import Asset, Dataset, Query
|
|
13
14
|
|
|
15
|
+
QueryField = Literal["text", "bbox", "variables", "time"]
|
|
16
|
+
_LABELS: dict[QueryField, str] = {
|
|
17
|
+
"text": "text",
|
|
18
|
+
"bbox": "location/bbox",
|
|
19
|
+
"variables": "variables",
|
|
20
|
+
"time": "start/end",
|
|
21
|
+
}
|
|
22
|
+
|
|
14
23
|
|
|
15
24
|
class NotImplementedProvider(NotImplementedError):
|
|
16
25
|
"""Raised by adapters that are registered but not yet built."""
|
|
@@ -20,8 +29,30 @@ class QueryError(ValueError):
|
|
|
20
29
|
"""The query cannot be satisfied by this dataset (missing or unsupported constraints)."""
|
|
21
30
|
|
|
22
31
|
|
|
32
|
+
def to_utc(value: datetime) -> datetime:
|
|
33
|
+
"""Apply the shared time policy: naive datetimes mean UTC, aware ones convert to it."""
|
|
34
|
+
return value.replace(tzinfo=value.tzinfo or UTC).astimezone(UTC)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _is_set(query: Query, field: QueryField) -> bool:
|
|
38
|
+
if field == "text":
|
|
39
|
+
return bool(query.text)
|
|
40
|
+
if field == "bbox":
|
|
41
|
+
return query.bbox is not None
|
|
42
|
+
if field == "variables":
|
|
43
|
+
return bool(query.variables)
|
|
44
|
+
return query.time is not None
|
|
45
|
+
|
|
46
|
+
|
|
23
47
|
class Provider(ABC):
|
|
24
|
-
"""One adapter per dataset. Translates a normalized query into concrete assets.
|
|
48
|
+
"""One adapter per dataset. Translates a normalized query into concrete assets.
|
|
49
|
+
|
|
50
|
+
``list_assets`` implementations validate with the shared helpers before any
|
|
51
|
+
transport: ``check_params`` for provider-specific keys, ``reject`` for query
|
|
52
|
+
fields the source cannot honour, and ``utc_window`` for the time bounds.
|
|
53
|
+
Every adapter then reports the same errors for the same mistakes, and no
|
|
54
|
+
query field is silently ignored.
|
|
55
|
+
"""
|
|
25
56
|
|
|
26
57
|
accepted_params: ClassVar[Mapping[str, str]] = MappingProxyType({})
|
|
27
58
|
"""Accepted ``query.params`` keys, each mapped to a one-line description.
|
|
@@ -49,6 +80,28 @@ class Provider(ABC):
|
|
|
49
80
|
"""Release owned resources. Providers with resources override this method."""
|
|
50
81
|
return None
|
|
51
82
|
|
|
83
|
+
def check_params(self, query: Query) -> None:
|
|
84
|
+
"""Reject every ``query.params`` key outside ``accepted_params``, naming it."""
|
|
85
|
+
if unknown := set(query.params) - set(self.accepted_params):
|
|
86
|
+
raise QueryError(f"unsupported {self.dataset.id} params: {', '.join(sorted(unknown))}")
|
|
87
|
+
|
|
88
|
+
def reject(self, query: Query, *fields: QueryField, hint: str = "") -> None:
|
|
89
|
+
"""Raise ``QueryError`` when the query sets a field this dataset cannot honour.
|
|
90
|
+
|
|
91
|
+
Sources that ignore a filter must say so rather than return unfiltered
|
|
92
|
+
data; ``hint`` tells the caller what to do instead.
|
|
93
|
+
"""
|
|
94
|
+
present = [_LABELS[field] for field in fields if _is_set(query, field)]
|
|
95
|
+
if present:
|
|
96
|
+
message = f"{self.dataset.id} does not support {', '.join(present)}"
|
|
97
|
+
raise QueryError(f"{message}; {hint}" if hint else message)
|
|
98
|
+
|
|
99
|
+
def utc_window(self, query: Query) -> tuple[datetime, datetime]:
|
|
100
|
+
"""Both time bounds, required, in UTC. Naive bounds are read as UTC."""
|
|
101
|
+
if query.time is None or query.time.start is None or query.time.end is None:
|
|
102
|
+
raise QueryError(f"{self.dataset.id} requires both start and end")
|
|
103
|
+
return to_utc(query.time.start), to_utc(query.time.end)
|
|
104
|
+
|
|
52
105
|
@abstractmethod
|
|
53
106
|
def list_assets(self, query: Query) -> list[Asset]:
|
|
54
107
|
"""Resolve a query to the concrete objects that satisfy it, without downloading."""
|
|
@@ -53,17 +53,11 @@ class CoastwatchSst(_HttpProvider):
|
|
|
53
53
|
|
|
54
54
|
def list_assets(self, query: Query) -> list[Asset]:
|
|
55
55
|
"""Resolve a valid grid intersection into one stable, bounded CSV request."""
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
):
|
|
62
|
-
raise QueryError(
|
|
63
|
-
f"{self.dataset.id} requires a bbox/location and both start and end times"
|
|
64
|
-
)
|
|
65
|
-
if unknown := set(query.params) - set(self.accepted_params):
|
|
66
|
-
raise QueryError(f"unsupported CoastWatch params: {', '.join(sorted(unknown))}")
|
|
56
|
+
self.check_params(query)
|
|
57
|
+
self.reject(query, "text", hint="select a bbox/location, time window, and variables")
|
|
58
|
+
if query.bbox is None:
|
|
59
|
+
raise QueryError(f"{self.dataset.id} requires a bbox/location and both start and end")
|
|
60
|
+
start, end = self.utc_window(query)
|
|
67
61
|
stride = query.params.get("stride", 1)
|
|
68
62
|
if isinstance(stride, str) and stride.isascii() and stride.isdigit():
|
|
69
63
|
stride = int(stride)
|
|
@@ -102,8 +96,8 @@ class CoastwatchSst(_HttpProvider):
|
|
|
102
96
|
raise ValueError("unexpected time axis")
|
|
103
97
|
except (KeyError, TypeError, ValueError) as error:
|
|
104
98
|
raise httpx.DecodingError("CoastWatch grid metadata changed or is invalid") from error
|
|
105
|
-
first = bisect_left(times,
|
|
106
|
-
last = bisect_right(times,
|
|
99
|
+
first = bisect_left(times, start)
|
|
100
|
+
last = bisect_right(times, end) - 1
|
|
107
101
|
if first > last:
|
|
108
102
|
return []
|
|
109
103
|
if (last - first + 1) * lat[1] * lon[1] > MAX_ROWS:
|
|
@@ -89,12 +89,8 @@ class CoopsWaterLevels(_HttpProvider):
|
|
|
89
89
|
|
|
90
90
|
def list_assets(self, query: Query) -> list[Asset]:
|
|
91
91
|
"""Describe one bounded CSV request; availability is checked during fetching."""
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
if query.bbox is not None or query.text is not None or query.variables:
|
|
95
|
-
raise QueryError(
|
|
96
|
-
"CO-OPS requires an explicit station; location, text and variables are unsupported"
|
|
97
|
-
)
|
|
92
|
+
self.check_params(query)
|
|
93
|
+
self.reject(query, "bbox", "text", "variables", hint="CO-OPS requires an explicit station")
|
|
98
94
|
station = query.params.get("station")
|
|
99
95
|
if (
|
|
100
96
|
not isinstance(station, str)
|
|
@@ -109,10 +105,7 @@ class CoopsWaterLevels(_HttpProvider):
|
|
|
109
105
|
units = query.params.get("units", "metric")
|
|
110
106
|
if units not in ("metric", "english"):
|
|
111
107
|
raise QueryError("units must be metric or english")
|
|
112
|
-
|
|
113
|
-
raise QueryError(f"{self.dataset.id} requires both start and end timestamps")
|
|
114
|
-
start = query.time.start.replace(tzinfo=query.time.start.tzinfo or UTC).astimezone(UTC)
|
|
115
|
-
end = query.time.end.replace(tzinfo=query.time.end.tzinfo or UTC).astimezone(UTC)
|
|
108
|
+
start, end = self.utc_window(query)
|
|
116
109
|
if any(value.second or value.microsecond for value in (start, end)):
|
|
117
110
|
raise QueryError("CO-OPS timestamps must have minute precision (zero seconds)")
|
|
118
111
|
if end - start > MAX_INTERVAL:
|
|
@@ -79,7 +79,6 @@ class GhcnDaily(_HttpProvider):
|
|
|
79
79
|
seen: set[str] = set()
|
|
80
80
|
while True:
|
|
81
81
|
resp = http.get(SEARCH_URL, self._http(), params=params)
|
|
82
|
-
resp.raise_for_status()
|
|
83
82
|
body = resp.json()
|
|
84
83
|
results = body.get("results", [])
|
|
85
84
|
before = len(found)
|
|
@@ -111,24 +110,35 @@ class GhcnDaily(_HttpProvider):
|
|
|
111
110
|
break
|
|
112
111
|
return found
|
|
113
112
|
|
|
114
|
-
def
|
|
115
|
-
|
|
116
|
-
if
|
|
117
|
-
raise QueryError(f"{self.dataset.id} requires both start and end dates")
|
|
118
|
-
if unknown := set(query.params) - set(self.accepted_params):
|
|
119
|
-
raise QueryError(f"unsupported {self.dataset.id} params: {', '.join(sorted(unknown))}")
|
|
120
|
-
if query.params.get("units", "metric") not in ("metric", "standard"):
|
|
113
|
+
def _units(self, query: Query) -> str:
|
|
114
|
+
units = query.params.get("units", "metric")
|
|
115
|
+
if units not in ("metric", "standard"):
|
|
121
116
|
raise QueryError("units must be metric or standard")
|
|
117
|
+
return units
|
|
118
|
+
|
|
119
|
+
def _stations(self, query: Query) -> list[str]:
|
|
120
|
+
"""Explicit ``stations`` or the ones found inside the bbox, never a mix of both."""
|
|
121
|
+
if "stations" in query.params and query.bbox is not None:
|
|
122
|
+
raise QueryError("pass stations or a location/bbox, not both")
|
|
122
123
|
if "stations" in query.params:
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
124
|
+
return _stations_param(query.params["stations"])
|
|
125
|
+
if query.bbox is not None:
|
|
126
|
+
return self.find_stations(query)
|
|
127
|
+
raise QueryError(f"{self.dataset.id} needs a location, bbox, or stations=...")
|
|
128
|
+
|
|
129
|
+
def list_assets(self, query: Query) -> list[Asset]:
|
|
130
|
+
"""One CSV asset per chunk of up to STATIONS_PER_ASSET stations for the query window."""
|
|
131
|
+
self.check_params(query)
|
|
132
|
+
self.reject(query, "text", hint="search the registry instead")
|
|
133
|
+
start_at, end_at = self.utc_window(query)
|
|
134
|
+
window = TimeRange(start=start_at, end=end_at)
|
|
135
|
+
query = query.model_copy(update={"time": window})
|
|
136
|
+
units = self._units(query)
|
|
137
|
+
stations = self._stations(query)
|
|
128
138
|
if not stations:
|
|
129
139
|
return []
|
|
130
140
|
|
|
131
|
-
start, end = _date(
|
|
141
|
+
start, end = _date(window.start), _date(window.end)
|
|
132
142
|
assets: list[Asset] = []
|
|
133
143
|
for i in range(0, len(stations), STATIONS_PER_ASSET):
|
|
134
144
|
chunk = stations[i : i + STATIONS_PER_ASSET]
|
|
@@ -138,7 +148,7 @@ class GhcnDaily(_HttpProvider):
|
|
|
138
148
|
"startDate": start,
|
|
139
149
|
"endDate": end,
|
|
140
150
|
"format": "csv",
|
|
141
|
-
"units":
|
|
151
|
+
"units": units,
|
|
142
152
|
"includeStationLocation": "1",
|
|
143
153
|
}
|
|
144
154
|
if query.variables:
|
|
@@ -152,7 +162,7 @@ class GhcnDaily(_HttpProvider):
|
|
|
152
162
|
href=url,
|
|
153
163
|
protocol=Protocol.HTTP,
|
|
154
164
|
media_type="text/csv",
|
|
155
|
-
time=
|
|
165
|
+
time=window,
|
|
156
166
|
bbox=query.bbox,
|
|
157
167
|
)
|
|
158
168
|
)
|
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
"""GOES ABI CONUS Cloud and Moisture Imagery from anonymous NOAA S3 buckets.
|
|
2
2
|
|
|
3
3
|
Require ``satellite`` (16, 17, 18, or 19), ``channel`` (1--16 or C01--C16), and
|
|
4
|
-
both timestamps. The optional ``product`` must be
|
|
5
|
-
single-channel NetCDF files by inclusive
|
|
4
|
+
both timestamps at most seven days apart. The optional ``product`` must be
|
|
5
|
+
``ABI-L2-CMIPC``. Select whole single-channel NetCDF files by inclusive
|
|
6
|
+
scan-start time, never by scan overlap.
|
|
6
7
|
Geographic and variable subsetting are not available for these archived files.
|
|
7
8
|
"""
|
|
8
9
|
|
|
@@ -21,6 +22,7 @@ from usdata.providers.base import QueryError
|
|
|
21
22
|
|
|
22
23
|
PRODUCT = "ABI-L2-CMIPC"
|
|
23
24
|
PUBLIC_START = datetime(2017, 2, 28, tzinfo=UTC)
|
|
25
|
+
MAX_WINDOW = timedelta(days=7)
|
|
24
26
|
KEY_RE = re.compile(
|
|
25
27
|
r"OR_ABI-L2-CMIPC-M[346]C(?P<channel>0[1-9]|1[0-6])_G(?P<satellite>1[6-9])"
|
|
26
28
|
r"_s(?P<start>\d{14})_e(?P<end>\d{14})_c\d{14}\.nc"
|
|
@@ -57,24 +59,21 @@ class GoesAbi(_HttpProvider):
|
|
|
57
59
|
|
|
58
60
|
def list_assets(self, query: Query) -> list[Asset]:
|
|
59
61
|
"""List complete scenes whose scan starts fall inside the inclusive UTC interval."""
|
|
60
|
-
|
|
61
|
-
raise QueryError(f"unsupported GOES params: {', '.join(sorted(unknown))}")
|
|
62
|
+
self.check_params(query)
|
|
62
63
|
if query.params.get("product", PRODUCT) != PRODUCT:
|
|
63
64
|
raise QueryError(f"only product={PRODUCT} is supported")
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
if
|
|
73
|
-
raise QueryError(
|
|
65
|
+
self.reject(
|
|
66
|
+
query,
|
|
67
|
+
"bbox",
|
|
68
|
+
"text",
|
|
69
|
+
"variables",
|
|
70
|
+
hint="archived GOES scenes are whole files; select satellite and channel",
|
|
71
|
+
)
|
|
72
|
+
start, end = self.utc_window(query)
|
|
73
|
+
if end - start > MAX_WINDOW:
|
|
74
|
+
raise QueryError("GOES requests must span at most 7 days; split longer intervals")
|
|
74
75
|
satellite = _number(query.params.get("satellite"), "satellite", 16, 19)
|
|
75
76
|
channel = _number(query.params.get("channel"), "channel", 1, 16)
|
|
76
|
-
start = query.time.start.replace(tzinfo=query.time.start.tzinfo or UTC).astimezone(UTC)
|
|
77
|
-
end = query.time.end.replace(tzinfo=query.time.end.tzinfo or UTC).astimezone(UTC)
|
|
78
77
|
if end < PUBLIC_START:
|
|
79
78
|
raise QueryError("GOES CMIPC public observations begin on 2017-02-28")
|
|
80
79
|
start = max(start, PUBLIC_START)
|
|
@@ -6,10 +6,8 @@ selected in full. Geographic discovery uses the same normalized month bounds.
|
|
|
6
6
|
"""
|
|
7
7
|
|
|
8
8
|
from calendar import monthrange
|
|
9
|
-
from datetime import UTC
|
|
10
9
|
|
|
11
10
|
from usdata.models import Asset, Query, TimeRange
|
|
12
|
-
from usdata.providers.base import QueryError
|
|
13
11
|
from usdata.providers.noaa.ghcnd import GhcnDaily
|
|
14
12
|
|
|
15
13
|
|
|
@@ -19,10 +17,7 @@ class GlobalSummaryMonthly(GhcnDaily):
|
|
|
19
17
|
ncei_dataset = "global-summary-of-the-month"
|
|
20
18
|
|
|
21
19
|
def _monthly_query(self, query: Query) -> Query:
|
|
22
|
-
|
|
23
|
-
raise QueryError(f"{self.dataset.id} requires both start and end dates")
|
|
24
|
-
start = query.time.start.replace(tzinfo=query.time.start.tzinfo or UTC).astimezone(UTC)
|
|
25
|
-
end = query.time.end.replace(tzinfo=query.time.end.tzinfo or UTC).astimezone(UTC)
|
|
20
|
+
start, end = self.utc_window(query)
|
|
26
21
|
return query.model_copy(
|
|
27
22
|
update={
|
|
28
23
|
"time": TimeRange(
|
|
@@ -44,6 +39,4 @@ class GlobalSummaryMonthly(GhcnDaily):
|
|
|
44
39
|
|
|
45
40
|
def list_assets(self, query: Query) -> list[Asset]:
|
|
46
41
|
"""Resolve monthly CSVs with stable URLs and complete-month asset bounds."""
|
|
47
|
-
if "stations" in query.params and query.bbox is not None:
|
|
48
|
-
raise QueryError("pass stations or a location/bbox, not both")
|
|
49
42
|
return super().list_assets(self._monthly_query(query))
|
|
@@ -5,10 +5,7 @@ Both dates are required; every UTC calendar year touched by the interval is
|
|
|
5
5
|
selected in full. Geographic discovery uses the same normalized year bounds.
|
|
6
6
|
"""
|
|
7
7
|
|
|
8
|
-
from datetime import UTC
|
|
9
|
-
|
|
10
8
|
from usdata.models import Asset, Query, TimeRange
|
|
11
|
-
from usdata.providers.base import QueryError
|
|
12
9
|
from usdata.providers.noaa.ghcnd import GhcnDaily
|
|
13
10
|
|
|
14
11
|
|
|
@@ -18,14 +15,7 @@ class GlobalSummaryYearly(GhcnDaily):
|
|
|
18
15
|
ncei_dataset = "global-summary-of-the-year"
|
|
19
16
|
|
|
20
17
|
def _yearly_query(self, query: Query) -> Query:
|
|
21
|
-
|
|
22
|
-
raise QueryError(f"{self.dataset.id} does not support text queries")
|
|
23
|
-
if "stations" in query.params and query.bbox is not None:
|
|
24
|
-
raise QueryError("pass stations or a location/bbox, not both")
|
|
25
|
-
if query.time is None or query.time.start is None or query.time.end is None:
|
|
26
|
-
raise QueryError(f"{self.dataset.id} requires both start and end dates")
|
|
27
|
-
start = query.time.start.replace(tzinfo=query.time.start.tzinfo or UTC).astimezone(UTC)
|
|
28
|
-
end = query.time.end.replace(tzinfo=query.time.end.tzinfo or UTC).astimezone(UTC)
|
|
18
|
+
start, end = self.utc_window(query)
|
|
29
19
|
return query.model_copy(
|
|
30
20
|
update={
|
|
31
21
|
"time": TimeRange(
|
|
@@ -17,12 +17,12 @@ from __future__ import annotations
|
|
|
17
17
|
import re
|
|
18
18
|
from collections.abc import Mapping
|
|
19
19
|
from datetime import UTC, date, datetime
|
|
20
|
-
from html.parser import HTMLParser
|
|
21
20
|
from pathlib import Path
|
|
22
21
|
from typing import ClassVar
|
|
23
22
|
|
|
24
23
|
from usdata.models import Asset, Protocol, Query, TimeRange
|
|
25
24
|
from usdata.protocols import http
|
|
25
|
+
from usdata.protocols.listing import directory_entries
|
|
26
26
|
from usdata.providers._http import _HttpProvider
|
|
27
27
|
from usdata.providers.base import QueryError
|
|
28
28
|
|
|
@@ -33,20 +33,6 @@ FILE_NAME = re.compile(r"hurdat2-(?:(atl|nepac)-)?(\d{4})-(\d{4})-(\d{6}|\d{8})(
|
|
|
33
33
|
BASIN_TOKENS = {"atlantic": frozenset({"", "atl"}), "pacific": frozenset({"nepac"})}
|
|
34
34
|
|
|
35
35
|
|
|
36
|
-
class _Directory(HTMLParser):
|
|
37
|
-
"""Collect HURDAT2 filenames from the NHC Apache directory listing."""
|
|
38
|
-
|
|
39
|
-
def __init__(self) -> None:
|
|
40
|
-
super().__init__()
|
|
41
|
-
self.names: list[str] = []
|
|
42
|
-
|
|
43
|
-
def handle_starttag(self, tag: str, attrs: list[tuple[str, str | None]]) -> None:
|
|
44
|
-
href = dict(attrs).get("href", "") or "" if tag == "a" else ""
|
|
45
|
-
# Only literal local filenames; never follow arbitrary links from a listing.
|
|
46
|
-
if FILE_NAME.fullmatch(href):
|
|
47
|
-
self.names.append(href)
|
|
48
|
-
|
|
49
|
-
|
|
50
36
|
def _revision(value: str) -> date | None:
|
|
51
37
|
"""The revision date encoded in a filename, or None if it is not a real date."""
|
|
52
38
|
try:
|
|
@@ -64,39 +50,40 @@ class Hurdat2(_HttpProvider):
|
|
|
64
50
|
|
|
65
51
|
def list_assets(self, query: Query) -> list[Asset]:
|
|
66
52
|
"""Select the newest revision of the requested basin's complete database."""
|
|
67
|
-
|
|
68
|
-
raise QueryError(f"unsupported HURDAT2 params: {', '.join(sorted(unknown))}")
|
|
53
|
+
self.check_params(query)
|
|
69
54
|
requested = query.params.get("basin", "atlantic")
|
|
70
55
|
basin = requested.strip().casefold() if isinstance(requested, str) else ""
|
|
71
56
|
if basin not in BASIN_TOKENS:
|
|
72
57
|
raise QueryError(
|
|
73
58
|
f"unsupported HURDAT2 basin {requested!r}; use 'atlantic' (default) or 'pacific'"
|
|
74
59
|
)
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
60
|
+
self.reject(
|
|
61
|
+
query,
|
|
62
|
+
"bbox",
|
|
63
|
+
"variables",
|
|
64
|
+
"text",
|
|
65
|
+
hint="HURDAT2 publishes one whole text file per basin; filter the parsed track "
|
|
66
|
+
"table locally",
|
|
67
|
+
)
|
|
68
|
+
self.reject(
|
|
69
|
+
query,
|
|
70
|
+
"time",
|
|
71
|
+
hint="every revision holds the complete basin record, so a date range would not "
|
|
72
|
+
"change the download; remove start/end and filter the parsed 'time' column locally",
|
|
73
|
+
)
|
|
74
|
+
page = http.get(DIRECTORY_URL, self._http()).text
|
|
88
75
|
candidates = []
|
|
89
|
-
for name in
|
|
76
|
+
for name, size in directory_entries(page, FILE_NAME):
|
|
90
77
|
match = FILE_NAME.fullmatch(name)
|
|
91
78
|
assert match is not None
|
|
92
79
|
first, last, revised = int(match[2]), int(match[3]), _revision(match[4])
|
|
93
80
|
if (match[1] or "") not in BASIN_TOKENS[basin] or revised is None or first > last:
|
|
94
81
|
continue
|
|
95
82
|
# Prefer the latest season covered, then the newest revision of that span.
|
|
96
|
-
candidates.append(((last, revised, name), first, last))
|
|
83
|
+
candidates.append(((last, revised, name), first, last, size))
|
|
97
84
|
if not candidates:
|
|
98
85
|
raise QueryError(f"no HURDAT2 {basin} best-track file in the NHC directory listing")
|
|
99
|
-
(_, _, name), first, last = max(candidates)
|
|
86
|
+
(_, _, name), first, last, size = max(candidates)
|
|
100
87
|
return [
|
|
101
88
|
Asset(
|
|
102
89
|
id=name,
|
|
@@ -104,6 +91,7 @@ class Hurdat2(_HttpProvider):
|
|
|
104
91
|
href=DIRECTORY_URL + name,
|
|
105
92
|
protocol=Protocol.HTTP,
|
|
106
93
|
media_type="text/plain",
|
|
94
|
+
size=size,
|
|
107
95
|
time=TimeRange(
|
|
108
96
|
start=datetime(first, 1, 1, tzinfo=UTC),
|
|
109
97
|
end=datetime(last, 12, 31, 23, 59, 59, 999999, tzinfo=UTC),
|
|
@@ -2,8 +2,8 @@
|
|
|
2
2
|
|
|
3
3
|
Key layout: ``YYYY/MM/DD/SITE/SITEYYYYMMDD_HHMMSS[_V06][.gz]``. One object per
|
|
4
4
|
volume scan. ``_MDM`` objects are metadata sidecars and are skipped. There is
|
|
5
|
-
no server-side subsetting; a query selects sites and a time window
|
|
6
|
-
whole scan in that window is an asset.
|
|
5
|
+
no server-side subsetting; a query selects sites and a time window of at most
|
|
6
|
+
31 days, and every whole scan in that window is an asset.
|
|
7
7
|
|
|
8
8
|
Site selection, in order: ``site=``/``sites=`` params, then radars located
|
|
9
9
|
inside the query bbox, then the single radar nearest the bbox centre.
|
|
@@ -25,6 +25,7 @@ from usdata.providers.base import QueryError
|
|
|
25
25
|
from usdata.providers.noaa import sites
|
|
26
26
|
|
|
27
27
|
BUCKET = "unidata-nexrad-level2"
|
|
28
|
+
MAX_WINDOW = timedelta(days=31)
|
|
28
29
|
KEY_RE = re.compile(r"^(?P<site>[A-Z]{4})(?P<stamp>\d{8}_\d{6})(?:_V0[36])?(?:\.gz)?$")
|
|
29
30
|
|
|
30
31
|
|
|
@@ -58,8 +59,7 @@ class NexradLevel2(_HttpProvider):
|
|
|
58
59
|
|
|
59
60
|
def select_sites(self, query: Query) -> list[str]:
|
|
60
61
|
"""Radar site ids the query refers to; see the module docstring for the rules."""
|
|
61
|
-
|
|
62
|
-
raise QueryError(f"unsupported NEXRAD params: {', '.join(sorted(unknown))}")
|
|
62
|
+
self.check_params(query)
|
|
63
63
|
if "site" in query.params and "sites" in query.params:
|
|
64
64
|
raise QueryError("pass only one of site or sites")
|
|
65
65
|
if "nearest" in query.params and {"site", "sites"}.intersection(query.params):
|
|
@@ -95,10 +95,12 @@ class NexradLevel2(_HttpProvider):
|
|
|
95
95
|
|
|
96
96
|
def list_assets(self, query: Query) -> list[Asset]:
|
|
97
97
|
"""Every volume scan for the selected sites inside the query's UTC time window."""
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
end =
|
|
98
|
+
self.reject(
|
|
99
|
+
query, "text", "variables", hint="whole volume scans are selected by site and time"
|
|
100
|
+
)
|
|
101
|
+
start, end = self.utc_window(query)
|
|
102
|
+
if end - start > MAX_WINDOW:
|
|
103
|
+
raise QueryError("NEXRAD requests must span at most 31 days; split longer intervals")
|
|
102
104
|
assets: list[Asset] = []
|
|
103
105
|
for site in self.select_sites(query):
|
|
104
106
|
day = start.date()
|
|
@@ -21,8 +21,8 @@ from typing import Any, ClassVar
|
|
|
21
21
|
import httpx
|
|
22
22
|
|
|
23
23
|
from usdata.models import Asset, Protocol, Query, TimeRange
|
|
24
|
-
from usdata.providers.base import QueryError
|
|
25
|
-
from usdata.providers.noaa.ghcnd import DATA_URL, STATIONS_PER_ASSET, GhcnDaily
|
|
24
|
+
from usdata.providers.base import QueryError, to_utc
|
|
25
|
+
from usdata.providers.noaa.ghcnd import DATA_URL, STATIONS_PER_ASSET, GhcnDaily
|
|
26
26
|
|
|
27
27
|
PERIODS = {
|
|
28
28
|
"monthly": "normals-monthly-1991-2020",
|
|
@@ -55,8 +55,7 @@ def _window(query: Query, period: str) -> tuple[str, str] | None:
|
|
|
55
55
|
return (f"{PLACEHOLDER_YEAR}-01-01", f"{PLACEHOLDER_YEAR}-12-31")
|
|
56
56
|
if start is None or end is None:
|
|
57
57
|
raise QueryError("pass both start and end dates, or neither for the whole year")
|
|
58
|
-
first =
|
|
59
|
-
last = end.replace(tzinfo=end.tzinfo or UTC).astimezone(UTC)
|
|
58
|
+
first, last = to_utc(start), to_utc(end)
|
|
60
59
|
if (first.month, first.day) > (last.month, last.day):
|
|
61
60
|
raise QueryError("normals windows cannot cross the new year; split the query")
|
|
62
61
|
return (
|
|
@@ -80,23 +79,12 @@ class ClimateNormals(GhcnDaily):
|
|
|
80
79
|
|
|
81
80
|
def list_assets(self, query: Query) -> list[Asset]:
|
|
82
81
|
"""One CSV asset per chunk of stations for the selected period and window."""
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
if unknown := set(query.params) - set(self.accepted_params):
|
|
86
|
-
raise QueryError(f"unsupported {self.dataset.id} params: {', '.join(sorted(unknown))}")
|
|
82
|
+
self.check_params(query)
|
|
83
|
+
self.reject(query, "text", hint="search the registry instead")
|
|
87
84
|
period = _period(query)
|
|
88
|
-
units =
|
|
89
|
-
if units not in ("metric", "standard"):
|
|
90
|
-
raise QueryError("units must be metric or standard")
|
|
91
|
-
if "stations" in query.params and query.bbox is not None:
|
|
92
|
-
raise QueryError("pass stations or a location/bbox, not both")
|
|
85
|
+
units = self._units(query)
|
|
93
86
|
window = _window(query, period)
|
|
94
|
-
|
|
95
|
-
stations = _stations_param(query.params["stations"])
|
|
96
|
-
elif query.bbox is not None:
|
|
97
|
-
stations = self.find_stations(query)
|
|
98
|
-
else:
|
|
99
|
-
raise QueryError(f"{self.dataset.id} needs a location, bbox, or stations=...")
|
|
87
|
+
stations = self._stations(query)
|
|
100
88
|
if not stations:
|
|
101
89
|
return []
|
|
102
90
|
|
|
@@ -9,11 +9,11 @@ from __future__ import annotations
|
|
|
9
9
|
|
|
10
10
|
import re
|
|
11
11
|
from datetime import UTC, datetime
|
|
12
|
-
from html.parser import HTMLParser
|
|
13
12
|
from pathlib import Path
|
|
14
13
|
|
|
15
14
|
from usdata.models import Asset, Protocol, Query, TimeRange
|
|
16
15
|
from usdata.protocols import http
|
|
16
|
+
from usdata.protocols.listing import directory_entries
|
|
17
17
|
from usdata.providers._http import _HttpProvider
|
|
18
18
|
from usdata.providers.base import QueryError
|
|
19
19
|
|
|
@@ -21,65 +21,27 @@ DIRECTORY_URL = "https://www.ncei.noaa.gov/pub/data/swdi/stormevents/csvfiles/"
|
|
|
21
21
|
DETAILS_NAME = re.compile(r"StormEvents_details-ftp_v1\.0_d(\d{4})_c(\d{8})\.csv\.gz", re.ASCII)
|
|
22
22
|
|
|
23
23
|
|
|
24
|
-
class _Directory(HTMLParser):
|
|
25
|
-
"""Read filenames and exact byte sizes from NCEI's HTML directory table."""
|
|
26
|
-
|
|
27
|
-
def __init__(self) -> None:
|
|
28
|
-
super().__init__()
|
|
29
|
-
self.files: list[tuple[str, int | None]] = []
|
|
30
|
-
self._cells: list[str] = []
|
|
31
|
-
self._name: str | None = None
|
|
32
|
-
self._in_cell = False
|
|
33
|
-
|
|
34
|
-
def handle_starttag(self, tag: str, attrs: list[tuple[str, str | None]]) -> None:
|
|
35
|
-
if tag == "tr":
|
|
36
|
-
self._cells, self._name = [], None
|
|
37
|
-
elif tag == "td":
|
|
38
|
-
self._cells.append("")
|
|
39
|
-
self._in_cell = True
|
|
40
|
-
elif tag == "a":
|
|
41
|
-
href = dict(attrs).get("href", "") or ""
|
|
42
|
-
# Only literal local filenames; never follow arbitrary links from a listing.
|
|
43
|
-
if DETAILS_NAME.fullmatch(href):
|
|
44
|
-
self._name = href
|
|
45
|
-
|
|
46
|
-
def handle_data(self, data: str) -> None:
|
|
47
|
-
if self._in_cell:
|
|
48
|
-
self._cells[-1] += data
|
|
49
|
-
|
|
50
|
-
def handle_endtag(self, tag: str) -> None:
|
|
51
|
-
if tag == "td":
|
|
52
|
-
self._in_cell = False
|
|
53
|
-
elif tag == "tr" and self._name is not None:
|
|
54
|
-
size = self._cells[2].strip() if len(self._cells) > 2 else ""
|
|
55
|
-
self.files.append(
|
|
56
|
-
(self._name, int(size) if size.isascii() and size.isdigit() else None)
|
|
57
|
-
)
|
|
58
|
-
|
|
59
|
-
|
|
60
24
|
class StormEvents(_HttpProvider):
|
|
61
25
|
"""Resolve whole-year details archives; preserve the original gzip bytes."""
|
|
62
26
|
|
|
63
27
|
def list_assets(self, query: Query) -> list[Asset]:
|
|
64
28
|
"""Select the latest supported details revision for every requested year."""
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
start =
|
|
75
|
-
end = query.time.end.replace(tzinfo=query.time.end.tzinfo or UTC).astimezone(UTC)
|
|
29
|
+
self.check_params(query)
|
|
30
|
+
self.reject(
|
|
31
|
+
query,
|
|
32
|
+
"bbox",
|
|
33
|
+
"variables",
|
|
34
|
+
"text",
|
|
35
|
+
hint="Storm Events downloads whole annual details files; filter locally after "
|
|
36
|
+
"opening the CSV",
|
|
37
|
+
)
|
|
38
|
+
start, end = self.utc_window(query)
|
|
76
39
|
if start.year < 1950:
|
|
77
40
|
raise QueryError("Storm Events annual details files start in 1950")
|
|
78
41
|
years = range(start.year, end.year + 1)
|
|
79
|
-
|
|
80
|
-
listing.feed(http.get(DIRECTORY_URL, self._http()).text)
|
|
42
|
+
page = http.get(DIRECTORY_URL, self._http()).text
|
|
81
43
|
selected: dict[int, tuple[str, int | None]] = {}
|
|
82
|
-
for name, size in
|
|
44
|
+
for name, size in directory_entries(page, DETAILS_NAME):
|
|
83
45
|
match = DETAILS_NAME.fullmatch(name)
|
|
84
46
|
assert match is not None
|
|
85
47
|
year = int(match[1])
|
|
@@ -5,8 +5,10 @@ prefix), and ``statistic_id`` (default 00003: daily mean). Variables are
|
|
|
5
5
|
five-digit parameter codes, such as 00060 for streamflow. Dates select inclusive
|
|
6
6
|
local calendar days; time-of-day information is discarded for daily values.
|
|
7
7
|
|
|
8
|
-
|
|
9
|
-
|
|
8
|
+
Listing probes one row at each page offset to learn how many pages exist without
|
|
9
|
+
downloading them. Each page is then fetched as the service's CSV representation,
|
|
10
|
+
preserving units, qualifiers, and approval status without the volatile GeoJSON
|
|
11
|
+
timeStamp.
|
|
10
12
|
"""
|
|
11
13
|
|
|
12
14
|
from __future__ import annotations
|
|
@@ -20,7 +22,7 @@ from typing import ClassVar
|
|
|
20
22
|
|
|
21
23
|
import httpx
|
|
22
24
|
|
|
23
|
-
from usdata.models import Asset, Protocol, Query
|
|
25
|
+
from usdata.models import Asset, Protocol, Query, TimeRange
|
|
24
26
|
from usdata.protocols import http
|
|
25
27
|
from usdata.providers._http import _HttpProvider
|
|
26
28
|
from usdata.providers.base import QueryError
|
|
@@ -53,10 +55,10 @@ class WaterDaily(_HttpProvider):
|
|
|
53
55
|
|
|
54
56
|
def list_assets(self, query: Query) -> list[Asset]:
|
|
55
57
|
"""Resolve site or bbox queries to paginated CSV requests."""
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
58
|
+
self.check_params(query)
|
|
59
|
+
self.reject(query, "text", hint="select sites, a location, or parameter codes")
|
|
60
|
+
start, end = self.utc_window(query)
|
|
61
|
+
query = query.model_copy(update={"time": TimeRange(start=start, end=end)})
|
|
60
62
|
if "site" in query.params and "sites" in query.params:
|
|
61
63
|
raise QueryError("pass only one of site or sites")
|
|
62
64
|
raw_sites = query.params.get("sites", query.params.get("site"))
|
|
@@ -77,7 +79,7 @@ class WaterDaily(_HttpProvider):
|
|
|
77
79
|
raise QueryError("variables must be five-digit USGS parameter codes, for example 00060")
|
|
78
80
|
params = {
|
|
79
81
|
"f": "json",
|
|
80
|
-
"time": f"{
|
|
82
|
+
"time": f"{start.date().isoformat()}/{end.date().isoformat()}",
|
|
81
83
|
"statistic_id": statistic,
|
|
82
84
|
"limit": str(PAGE_SIZE),
|
|
83
85
|
}
|
|
@@ -97,20 +99,18 @@ class WaterDaily(_HttpProvider):
|
|
|
97
99
|
assets: list[Asset] = []
|
|
98
100
|
offset = 0
|
|
99
101
|
while True:
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
response.
|
|
102
|
+
# A one-row probe says whether a page starts here; the CSV asset is the page.
|
|
103
|
+
probe = httpx.URL(ITEMS_URL, params={**params, "limit": "1", "offset": str(offset)})
|
|
104
|
+
response = http.get(probe, self._http())
|
|
103
105
|
try:
|
|
104
|
-
|
|
105
|
-
features = body["features"]
|
|
106
|
+
features = response.json()["features"]
|
|
106
107
|
if not isinstance(features, list):
|
|
107
108
|
raise ValueError("features must be a list")
|
|
108
|
-
links = body.get("links", [])
|
|
109
|
-
next_url = next((link["href"] for link in links if link.get("rel") == "next"), None)
|
|
110
109
|
except (ValueError, KeyError, TypeError, AttributeError) as e:
|
|
111
110
|
raise httpx.DecodingError("invalid USGS page", request=response.request) from e
|
|
112
111
|
if not features:
|
|
113
112
|
break
|
|
113
|
+
url = httpx.URL(ITEMS_URL, params={**params, "offset": str(offset)})
|
|
114
114
|
href = str(url.copy_set_param("f", "csv"))
|
|
115
115
|
digest = hashlib.sha256(href.encode()).hexdigest()[:20]
|
|
116
116
|
assets.append(
|
|
@@ -124,12 +124,10 @@ class WaterDaily(_HttpProvider):
|
|
|
124
124
|
bbox=query.bbox,
|
|
125
125
|
)
|
|
126
126
|
)
|
|
127
|
-
if next_url is None:
|
|
128
|
-
break
|
|
129
127
|
# The live service can fall back from a cursor link to offset=1 at the
|
|
130
128
|
# end of a page, repeating records. Absolute offsets preserve the original
|
|
131
129
|
# filters and avoid mixing pagination modes; sortby is not supported here.
|
|
132
|
-
offset +=
|
|
130
|
+
offset += PAGE_SIZE
|
|
133
131
|
return assets
|
|
134
132
|
|
|
135
133
|
def fetch(self, asset: Asset, dest: Path) -> Path:
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|