usdata 0.16.0__tar.gz → 0.17.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.16.0 → usdata-0.17.0}/PKG-INFO +3 -2
- {usdata-0.16.0 → usdata-0.17.0}/README.md +2 -1
- {usdata-0.16.0 → usdata-0.17.0}/pyproject.toml +1 -1
- {usdata-0.16.0 → usdata-0.17.0}/pyproject.toml.orig +1 -1
- usdata-0.17.0/src/usdata/__init__.py +98 -0
- {usdata-0.16.0 → usdata-0.17.0}/src/usdata/_fetch.py +27 -5
- {usdata-0.16.0 → usdata-0.17.0}/src/usdata/_grib.py +136 -41
- usdata-0.17.0/src/usdata/_netcdf.py +69 -0
- {usdata-0.16.0 → usdata-0.17.0}/src/usdata/cache.py +21 -4
- usdata-0.17.0/src/usdata/cache_ops.py +249 -0
- usdata-0.17.0/src/usdata/cite.py +197 -0
- {usdata-0.16.0 → usdata-0.17.0}/src/usdata/cli/app.py +201 -21
- usdata-0.17.0/src/usdata/cli/cache.py +132 -0
- usdata-0.17.0/src/usdata/cli/cite.py +76 -0
- usdata-0.17.0/src/usdata/cli/doctor.py +33 -0
- usdata-0.17.0/src/usdata/cli/inspect.py +147 -0
- {usdata-0.16.0 → usdata-0.17.0}/src/usdata/data/registry.yaml +635 -255
- usdata-0.17.0/src/usdata/doctor.py +229 -0
- usdata-0.17.0/src/usdata/inspect.py +246 -0
- {usdata-0.16.0 → usdata-0.17.0}/src/usdata/manifest.py +26 -0
- {usdata-0.16.0 → usdata-0.17.0}/src/usdata/models.py +183 -1
- usdata-0.17.0/src/usdata/providers/__init__.py +37 -0
- usdata-0.17.0/src/usdata/providers/http.py +44 -0
- {usdata-0.16.0 → usdata-0.17.0}/src/usdata/providers/noaa/coastwatch.py +2 -2
- {usdata-0.16.0 → usdata-0.17.0}/src/usdata/providers/noaa/coops.py +2 -2
- {usdata-0.16.0 → usdata-0.17.0}/src/usdata/providers/noaa/ghcnd.py +2 -2
- {usdata-0.16.0 → usdata-0.17.0}/src/usdata/providers/noaa/glm.py +2 -2
- {usdata-0.16.0 → usdata-0.17.0}/src/usdata/providers/noaa/goes.py +2 -2
- {usdata-0.16.0 → usdata-0.17.0}/src/usdata/providers/noaa/hrrr.py +2 -2
- {usdata-0.16.0 → usdata-0.17.0}/src/usdata/providers/noaa/hurdat2.py +2 -2
- {usdata-0.16.0 → usdata-0.17.0}/src/usdata/providers/noaa/mrms.py +2 -2
- {usdata-0.16.0 → usdata-0.17.0}/src/usdata/providers/noaa/nexrad.py +5 -18
- {usdata-0.16.0 → usdata-0.17.0}/src/usdata/providers/noaa/spc.py +2 -2
- {usdata-0.16.0 → usdata-0.17.0}/src/usdata/providers/noaa/storm_events.py +2 -2
- {usdata-0.16.0 → usdata-0.17.0}/src/usdata/providers/params.py +18 -0
- {usdata-0.16.0 → usdata-0.17.0}/src/usdata/providers/usgs/daily.py +2 -2
- {usdata-0.16.0 → usdata-0.17.0}/src/usdata/pull.py +47 -7
- {usdata-0.16.0 → usdata-0.17.0}/src/usdata/readers.py +113 -3
- {usdata-0.16.0 → usdata-0.17.0}/src/usdata/registry.py +126 -8
- usdata-0.17.0/src/usdata/testing.py +460 -0
- usdata-0.16.0/src/usdata/__init__.py +0 -55
- usdata-0.16.0/src/usdata/_netcdf.py +0 -36
- usdata-0.16.0/src/usdata/providers/__init__.py +0 -5
- usdata-0.16.0/src/usdata/providers/_http.py +0 -27
- {usdata-0.16.0 → usdata-0.17.0}/src/usdata/_files.py +0 -0
- {usdata-0.16.0 → usdata-0.17.0}/src/usdata/_hurdat2.py +0 -0
- {usdata-0.16.0 → usdata-0.17.0}/src/usdata/_progress.py +0 -0
- {usdata-0.16.0 → usdata-0.17.0}/src/usdata/_radar.py +0 -0
- {usdata-0.16.0 → usdata-0.17.0}/src/usdata/cli/__init__.py +0 -0
- {usdata-0.16.0 → usdata-0.17.0}/src/usdata/cli/progress.py +0 -0
- {usdata-0.16.0 → usdata-0.17.0}/src/usdata/data/nexrad_sites.csv +0 -0
- {usdata-0.16.0 → usdata-0.17.0}/src/usdata/data/places.csv +0 -0
- {usdata-0.16.0 → usdata-0.17.0}/src/usdata/data/places.sources.json +0 -0
- {usdata-0.16.0 → usdata-0.17.0}/src/usdata/protocols/__init__.py +0 -0
- {usdata-0.16.0 → usdata-0.17.0}/src/usdata/protocols/erddap.py +0 -0
- {usdata-0.16.0 → usdata-0.17.0}/src/usdata/protocols/http.py +0 -0
- {usdata-0.16.0 → usdata-0.17.0}/src/usdata/protocols/listing.py +0 -0
- {usdata-0.16.0 → usdata-0.17.0}/src/usdata/protocols/s3.py +0 -0
- {usdata-0.16.0 → usdata-0.17.0}/src/usdata/provenance.py +0 -0
- {usdata-0.16.0 → usdata-0.17.0}/src/usdata/providers/base.py +0 -0
- {usdata-0.16.0 → usdata-0.17.0}/src/usdata/providers/noaa/__init__.py +0 -0
- {usdata-0.16.0 → usdata-0.17.0}/src/usdata/providers/noaa/gfs.py +0 -0
- {usdata-0.16.0 → usdata-0.17.0}/src/usdata/providers/noaa/gsom.py +0 -0
- {usdata-0.16.0 → usdata-0.17.0}/src/usdata/providers/noaa/gsoy.py +0 -0
- {usdata-0.16.0 → usdata-0.17.0}/src/usdata/providers/noaa/lcd.py +0 -0
- {usdata-0.16.0 → usdata-0.17.0}/src/usdata/providers/noaa/nexrad_level3.py +0 -0
- {usdata-0.16.0 → usdata-0.17.0}/src/usdata/providers/noaa/normals.py +0 -0
- {usdata-0.16.0 → usdata-0.17.0}/src/usdata/providers/noaa/sites.py +0 -0
- {usdata-0.16.0 → usdata-0.17.0}/src/usdata/providers/usgs/__init__.py +0 -0
- {usdata-0.16.0 → usdata-0.17.0}/src/usdata/py.typed +0 -0
- {usdata-0.16.0 → usdata-0.17.0}/src/usdata/query.py +0 -0
- {usdata-0.16.0 → usdata-0.17.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.17.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
|
|
@@ -108,7 +108,8 @@ the `check-*` commands install their respective extras.
|
|
|
108
108
|
|
|
109
109
|
Releases: `just release minor` prepares a release branch. Update release
|
|
110
110
|
notices, then `just release-pr` validates and opens a draft for review. Merging
|
|
111
|
-
the reviewed PR publishes to PyPI and creates the tag and GitHub release
|
|
111
|
+
the reviewed PR publishes to PyPI and creates the tag and GitHub release, then
|
|
112
|
+
walks the published package through the getting-started guide. See
|
|
112
113
|
[versioning](docs/versioning.md).
|
|
113
114
|
|
|
114
115
|
The main way the project grows is a new dataset: one registry entry and one
|
|
@@ -74,7 +74,8 @@ the `check-*` commands install their respective extras.
|
|
|
74
74
|
|
|
75
75
|
Releases: `just release minor` prepares a release branch. Update release
|
|
76
76
|
notices, then `just release-pr` validates and opens a draft for review. Merging
|
|
77
|
-
the reviewed PR publishes to PyPI and creates the tag and GitHub release
|
|
77
|
+
the reviewed PR publishes to PyPI and creates the tag and GitHub release, then
|
|
78
|
+
walks the published package through the getting-started guide. See
|
|
78
79
|
[versioning](docs/versioning.md).
|
|
79
80
|
|
|
80
81
|
The main way the project grows is a new dataset: one registry entry and one
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
"""usdata: unified access and provenance for U.S. public scientific data."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from importlib.metadata import PackageNotFoundError, version
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
try:
|
|
9
|
+
__version__ = version("usdata")
|
|
10
|
+
except PackageNotFoundError: # running from a source tree without an install
|
|
11
|
+
__version__ = "0.0.0"
|
|
12
|
+
|
|
13
|
+
from usdata._fetch import ChecksumMismatch, FetchedAsset, fetch, fetch_asset
|
|
14
|
+
from usdata.cite import cite_dataset, cite_lockfile
|
|
15
|
+
from usdata.inspect import inspect_asset, inspect_path
|
|
16
|
+
from usdata.models import Asset, BBox, Dataset, Provenance, Query, TemporalSelection, TimeRange
|
|
17
|
+
from usdata.pull import pull, verify
|
|
18
|
+
from usdata.query import build_query
|
|
19
|
+
from usdata.registry import DatasetNotFound, Registry, SearchResult, default_registry
|
|
20
|
+
from usdata.selection import select_by_time
|
|
21
|
+
|
|
22
|
+
__all__ = [
|
|
23
|
+
"Asset",
|
|
24
|
+
"BBox",
|
|
25
|
+
"ChecksumMismatch",
|
|
26
|
+
"Dataset",
|
|
27
|
+
"DatasetNotFound",
|
|
28
|
+
"FetchedAsset",
|
|
29
|
+
"Provenance",
|
|
30
|
+
"Query",
|
|
31
|
+
"Registry",
|
|
32
|
+
"SearchResult",
|
|
33
|
+
"TemporalSelection",
|
|
34
|
+
"TimeRange",
|
|
35
|
+
"__version__",
|
|
36
|
+
"build_query",
|
|
37
|
+
"cite_dataset",
|
|
38
|
+
"cite_lockfile",
|
|
39
|
+
"datasets",
|
|
40
|
+
"default_registry",
|
|
41
|
+
"fetch",
|
|
42
|
+
"fetch_asset",
|
|
43
|
+
"get",
|
|
44
|
+
"inspect_asset",
|
|
45
|
+
"inspect_path",
|
|
46
|
+
"pull",
|
|
47
|
+
"search",
|
|
48
|
+
"select_by_time",
|
|
49
|
+
"verify",
|
|
50
|
+
]
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def datasets(
|
|
54
|
+
*,
|
|
55
|
+
provider: str | None = None,
|
|
56
|
+
domain: str | None = None,
|
|
57
|
+
format: str | None = None,
|
|
58
|
+
reader: str | None = None,
|
|
59
|
+
capability: str | None = None,
|
|
60
|
+
status: str = "available",
|
|
61
|
+
) -> list[Dataset]:
|
|
62
|
+
"""List curated registry datasets. Keyword arguments match ``Registry.list``."""
|
|
63
|
+
return default_registry().list(
|
|
64
|
+
provider=provider,
|
|
65
|
+
domain=domain,
|
|
66
|
+
format=format,
|
|
67
|
+
reader=reader,
|
|
68
|
+
capability=capability,
|
|
69
|
+
status=status,
|
|
70
|
+
)
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def search(
|
|
74
|
+
text: str | None = None,
|
|
75
|
+
*,
|
|
76
|
+
include_planned: bool = False,
|
|
77
|
+
domain: str | None = None,
|
|
78
|
+
format: str | None = None,
|
|
79
|
+
reader: str | None = None,
|
|
80
|
+
capability: str | None = None,
|
|
81
|
+
status: str | None = None,
|
|
82
|
+
**kwargs: Any,
|
|
83
|
+
) -> list[SearchResult]:
|
|
84
|
+
"""Search the curated registry. Remaining keyword arguments match ``build_query``."""
|
|
85
|
+
return default_registry().search(
|
|
86
|
+
build_query(text, **kwargs),
|
|
87
|
+
include_planned=include_planned,
|
|
88
|
+
domain=domain,
|
|
89
|
+
format=format,
|
|
90
|
+
reader=reader,
|
|
91
|
+
capability=capability,
|
|
92
|
+
status=status,
|
|
93
|
+
)
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def get(dataset_id: str) -> Dataset:
|
|
97
|
+
"""Look up a dataset by ``provider:name`` id."""
|
|
98
|
+
return default_registry().get(dataset_id)
|
|
@@ -4,7 +4,7 @@ from __future__ import annotations
|
|
|
4
4
|
|
|
5
5
|
from collections.abc import Mapping
|
|
6
6
|
from pathlib import Path
|
|
7
|
-
from typing import Any
|
|
7
|
+
from typing import TYPE_CHECKING, Any
|
|
8
8
|
|
|
9
9
|
from pydantic import BaseModel
|
|
10
10
|
|
|
@@ -14,6 +14,9 @@ from usdata.cache import asset_path, sha256_file
|
|
|
14
14
|
from usdata.models import Asset, Dataset, Provenance, Query
|
|
15
15
|
from usdata.providers import Provider, load_adapter
|
|
16
16
|
|
|
17
|
+
if TYPE_CHECKING:
|
|
18
|
+
from usdata.inspect import Summary
|
|
19
|
+
|
|
17
20
|
|
|
18
21
|
class ChecksumMismatch(RuntimeError):
|
|
19
22
|
"""A fetched file's sha256 did not match the checksum the adapter declared."""
|
|
@@ -37,16 +40,21 @@ class FetchedAsset(BaseModel):
|
|
|
37
40
|
nrows: int | None = None,
|
|
38
41
|
sweep: int | list[int] | None = None,
|
|
39
42
|
select: Mapping[str, Any] | None = None,
|
|
43
|
+
strict: bool = False,
|
|
40
44
|
) -> Any:
|
|
41
45
|
"""Open local data with an optional ``pandas``, ``radar``, ``netcdf``, or ``grib`` reader.
|
|
42
46
|
|
|
43
47
|
ERDDAP units are kept in ``frame.attrs["units"]`` and source provenance
|
|
44
48
|
in ``frame.attrs["usdata"]``. NEXRAD returns a xarray DataTree with provenance
|
|
45
49
|
in ``radar.attrs["usdata"]``. NetCDF4 and GRIB2 return a loaded xarray Dataset
|
|
46
|
-
with matching provenance in its attributes
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
to
|
|
50
|
+
with matching provenance in its attributes, and with units and long names
|
|
51
|
+
the file leaves unstated filled from the registry entry's variables. See
|
|
52
|
+
``usdata.readers.open_asset`` for options. Use ``sweep=0`` or ``sweep=[0, 2]``
|
|
53
|
+
to load selected zero-based radar sweeps, and
|
|
54
|
+
``select={"shortName": "cape", "typeOfLevel": "surface"}``
|
|
55
|
+
to choose GRIB2 messages; ``strict=True`` raises instead of warning when a
|
|
56
|
+
GRIB2 select value matches none of the selected messages. Cached files and
|
|
57
|
+
provenance sidecars are never changed.
|
|
50
58
|
"""
|
|
51
59
|
from usdata.readers import open_asset
|
|
52
60
|
|
|
@@ -59,8 +67,22 @@ class FetchedAsset(BaseModel):
|
|
|
59
67
|
nrows=nrows,
|
|
60
68
|
sweep=sweep,
|
|
61
69
|
select=select,
|
|
70
|
+
strict=strict,
|
|
62
71
|
)
|
|
63
72
|
|
|
73
|
+
def inspect(self) -> Summary:
|
|
74
|
+
"""Summarize this file: provenance, format, and what that format holds.
|
|
75
|
+
|
|
76
|
+
CSV columns and a capped row count need no extra; NetCDF4 variables and
|
|
77
|
+
GRIB2 messages need the same extras ``open`` does, and a missing one
|
|
78
|
+
yields a summary with no detail and a note naming it. Like ``open``,
|
|
79
|
+
this reads the cached file and changes nothing. See
|
|
80
|
+
``usdata.inspect.inspect_asset``.
|
|
81
|
+
"""
|
|
82
|
+
from usdata.inspect import inspect_asset
|
|
83
|
+
|
|
84
|
+
return inspect_asset(self)
|
|
85
|
+
|
|
64
86
|
|
|
65
87
|
def _sidecar_not_older(path: Path) -> bool:
|
|
66
88
|
"""Whether ``path`` still carries the mtime it had when its sidecar was written.
|
|
@@ -14,14 +14,17 @@ from __future__ import annotations
|
|
|
14
14
|
|
|
15
15
|
import gzip
|
|
16
16
|
import re
|
|
17
|
+
import warnings
|
|
17
18
|
from collections.abc import Iterator, Mapping
|
|
18
19
|
from contextlib import contextmanager
|
|
19
20
|
from datetime import UTC, datetime
|
|
20
21
|
from importlib import import_module
|
|
22
|
+
from inspect import currentframe
|
|
21
23
|
from pathlib import Path
|
|
22
24
|
from typing import TYPE_CHECKING, Any
|
|
23
25
|
|
|
24
|
-
from usdata.
|
|
26
|
+
from usdata.inspect import GribMessage
|
|
27
|
+
from usdata.readers import MissingReaderDependency, fill_registry_attrs
|
|
25
28
|
|
|
26
29
|
if TYPE_CHECKING:
|
|
27
30
|
from usdata._fetch import FetchedAsset
|
|
@@ -33,7 +36,7 @@ LIBRARY_HINT = (
|
|
|
33
36
|
"`conda install -c conda-forge eccodes` or `brew install eccodes`"
|
|
34
37
|
)
|
|
35
38
|
MRMS_NAME = re.compile(r"^MRMS_(?P<product>.+?)_\d{2}\.\d{2}_\d{8}-\d{6}\.grib2(?:\.gz)?$")
|
|
36
|
-
|
|
39
|
+
INVENTORY_KEYS = ("shortName", "name", "typeOfLevel", "level", "step", "units")
|
|
37
40
|
VARIABLE_KEYS = (
|
|
38
41
|
"name",
|
|
39
42
|
"units",
|
|
@@ -129,28 +132,98 @@ def _get(eccodes: Any, h: int, key: str, ktype: type | None = None) -> Any:
|
|
|
129
132
|
return None
|
|
130
133
|
|
|
131
134
|
|
|
132
|
-
def
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
if isinstance(option, bool) or not isinstance(option, int | float | str):
|
|
142
|
-
raise ValueError(f"select[{key!r}] values must be strings or numbers")
|
|
143
|
-
if isinstance(option, str):
|
|
144
|
-
matched |= text == option
|
|
145
|
-
elif number is not None:
|
|
146
|
-
matched |= number == option
|
|
147
|
-
if not matched:
|
|
148
|
-
return False
|
|
149
|
-
return True
|
|
135
|
+
def _options(key: str, wanted: Any) -> list[Any]:
|
|
136
|
+
"""One key's wanted values as a validated list, whether one value or several were given."""
|
|
137
|
+
options = list(wanted) if isinstance(wanted, list | tuple | set) else [wanted]
|
|
138
|
+
if not options:
|
|
139
|
+
raise ValueError(f"select[{key!r}] must not be empty")
|
|
140
|
+
for option in options:
|
|
141
|
+
if isinstance(option, bool) or not isinstance(option, int | float | str):
|
|
142
|
+
raise ValueError(f"select[{key!r}] values must be strings or numbers")
|
|
143
|
+
return options
|
|
150
144
|
|
|
151
145
|
|
|
152
|
-
def
|
|
153
|
-
|
|
146
|
+
def _matched(eccodes: Any, h: int, key: str, options: list[Any]) -> set[int]:
|
|
147
|
+
"""Positions in ``options`` that this message's value for ``key`` matches."""
|
|
148
|
+
text = _get(eccodes, h, key, str)
|
|
149
|
+
number = _get(eccodes, h, key, float) if text is not None else None
|
|
150
|
+
hits = set()
|
|
151
|
+
for index, option in enumerate(options):
|
|
152
|
+
if isinstance(option, str):
|
|
153
|
+
if text == option:
|
|
154
|
+
hits.add(index)
|
|
155
|
+
elif number is not None and number == option:
|
|
156
|
+
hits.add(index)
|
|
157
|
+
return hits
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def _unmatched_report(
|
|
161
|
+
options: Mapping[str, list[Any]],
|
|
162
|
+
matched: Mapping[str, set[int]],
|
|
163
|
+
present: Mapping[str, list[str]],
|
|
164
|
+
) -> str:
|
|
165
|
+
"""Text naming every select value that matched no message, with what was there instead."""
|
|
166
|
+
parts = []
|
|
167
|
+
for key, wanted in options.items():
|
|
168
|
+
missing = [option for index, option in enumerate(wanted) if index not in matched[key]]
|
|
169
|
+
if not missing:
|
|
170
|
+
continue
|
|
171
|
+
available = ", ".join(repr(value) for value in present[key]) or "none"
|
|
172
|
+
parts.append(
|
|
173
|
+
f"select[{key!r}] matched no message for "
|
|
174
|
+
f"{', '.join(repr(option) for option in missing)}; "
|
|
175
|
+
f"{key} values available with the other select keys: {available}."
|
|
176
|
+
)
|
|
177
|
+
return " ".join(parts)
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def _caller_stacklevel() -> int:
|
|
181
|
+
"""Stack level of the first frame outside usdata, so a warning points at the caller."""
|
|
182
|
+
package = Path(__file__).resolve().parent
|
|
183
|
+
frame = currentframe()
|
|
184
|
+
frame = frame.f_back if frame is not None else None
|
|
185
|
+
level = 1
|
|
186
|
+
while frame is not None and Path(frame.f_code.co_filename).resolve().is_relative_to(package):
|
|
187
|
+
level += 1
|
|
188
|
+
frame = frame.f_back
|
|
189
|
+
return level
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
def _shape(eccodes: Any, h: int) -> tuple[int, int] | None:
|
|
193
|
+
"""A message's grid rows and columns under either key pair, or None when neither is set."""
|
|
194
|
+
rows, cols = _get(eccodes, h, "Nj", int), _get(eccodes, h, "Ni", int)
|
|
195
|
+
if rows is None or cols is None:
|
|
196
|
+
rows, cols = _get(eccodes, h, "Ny", int), _get(eccodes, h, "Nx", int)
|
|
197
|
+
return None if rows is None or cols is None else (rows, cols)
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def inventory(path: Path) -> list[GribMessage]:
|
|
201
|
+
"""Every message in a local GRIB2 file, in file order, without decoding its values."""
|
|
202
|
+
eccodes = _modules()[0]
|
|
203
|
+
messages = []
|
|
204
|
+
for index, h in enumerate(_messages(eccodes, path)):
|
|
205
|
+
keys = {key: _get(eccodes, h, key, str) for key in INVENTORY_KEYS}
|
|
206
|
+
messages.append(
|
|
207
|
+
GribMessage(
|
|
208
|
+
index=index,
|
|
209
|
+
short_name=keys["shortName"],
|
|
210
|
+
name=keys["name"],
|
|
211
|
+
type_of_level=keys["typeOfLevel"],
|
|
212
|
+
level=keys["level"],
|
|
213
|
+
step=keys["step"],
|
|
214
|
+
units=keys["units"],
|
|
215
|
+
shape=_shape(eccodes, h),
|
|
216
|
+
)
|
|
217
|
+
)
|
|
218
|
+
return messages
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
def _available(path: Path) -> str:
|
|
222
|
+
"""Every message as a (shortName, typeOfLevel, level) triple, for a reader error to list."""
|
|
223
|
+
return ", ".join(
|
|
224
|
+
f"({message.short_name!r}, {message.type_of_level!r}, {message.level!r})"
|
|
225
|
+
for message in inventory(path)
|
|
226
|
+
)
|
|
154
227
|
|
|
155
228
|
|
|
156
229
|
def _product_name(asset_id: str) -> str | None:
|
|
@@ -176,13 +249,11 @@ class _Grid:
|
|
|
176
249
|
|
|
177
250
|
def __init__(self, eccodes: Any, numpy: Any, h: int) -> None:
|
|
178
251
|
self.grid_type = _get(eccodes, h, "gridType", str) or "unknown"
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
if rows is None or cols is None:
|
|
182
|
-
rows, cols = _get(eccodes, h, "Ny", int), _get(eccodes, h, "Nx", int)
|
|
183
|
-
if rows is None or cols is None:
|
|
252
|
+
shape = _shape(eccodes, h)
|
|
253
|
+
if shape is None:
|
|
184
254
|
raise ValueError(f"cannot determine the grid shape of a {self.grid_type} message")
|
|
185
|
-
self.shape =
|
|
255
|
+
self.shape = shape
|
|
256
|
+
rows, cols = shape
|
|
186
257
|
self.flip_rows = bool(_get(eccodes, h, "jScansPositively", int))
|
|
187
258
|
self.flip_cols = bool(_get(eccodes, h, "iScansNegatively", int))
|
|
188
259
|
if _get(eccodes, h, "jPointsAreConsecutive", int):
|
|
@@ -227,21 +298,41 @@ class _Grid:
|
|
|
227
298
|
return numpy.ascontiguousarray(grid)
|
|
228
299
|
|
|
229
300
|
|
|
230
|
-
def open_grib2(
|
|
231
|
-
|
|
301
|
+
def open_grib2(
|
|
302
|
+
fetched: FetchedAsset, select: Mapping[str, Any] | None = None, *, strict: bool = False
|
|
303
|
+
) -> Any:
|
|
304
|
+
"""Decode selected GRIB2 messages into one loaded xarray Dataset.
|
|
305
|
+
|
|
306
|
+
A select value that matches none of the selected messages warns, or raises
|
|
307
|
+
``ValueError`` when ``strict``; a select that matches nothing always raises.
|
|
308
|
+
"""
|
|
232
309
|
eccodes, xarray, numpy = _modules()
|
|
233
310
|
if select is not None and not isinstance(select, Mapping):
|
|
234
311
|
raise ValueError("select must be a mapping of ecCodes key names to values")
|
|
312
|
+
options = {key: _options(key, wanted) for key, wanted in (select or {}).items()}
|
|
313
|
+
matched: dict[str, set[int]] = {key: set() for key in options}
|
|
314
|
+
present: dict[str, list[str]] = {key: [] for key in options}
|
|
235
315
|
fields: dict[str, tuple[Any, dict[str, Any]]] = {}
|
|
236
316
|
grid: _Grid | None = None
|
|
237
|
-
|
|
317
|
+
count = 0
|
|
238
318
|
names: list[str] = []
|
|
239
319
|
for h in _messages(eccodes, fetched.path):
|
|
240
|
-
|
|
241
|
-
if
|
|
320
|
+
count += 1
|
|
321
|
+
if count > 1 and select is None:
|
|
242
322
|
continue
|
|
243
|
-
|
|
323
|
+
hits = {key: _matched(eccodes, h, key, wanted) for key, wanted in options.items()}
|
|
324
|
+
missed = [key for key, indexes in hits.items() if not indexes]
|
|
325
|
+
for key in options:
|
|
326
|
+
# What this key could have been with the rest of the select held fixed.
|
|
327
|
+
if missed not in ([], [key]):
|
|
328
|
+
continue
|
|
329
|
+
value = _get(eccodes, h, key, str)
|
|
330
|
+
if value is not None and value not in present[key]:
|
|
331
|
+
present[key].append(value)
|
|
332
|
+
if missed:
|
|
244
333
|
continue
|
|
334
|
+
for key, indexes in hits.items():
|
|
335
|
+
matched[key] |= indexes
|
|
245
336
|
message_grid = _Grid(eccodes, numpy, h)
|
|
246
337
|
if grid is None:
|
|
247
338
|
grid = message_grid
|
|
@@ -274,20 +365,23 @@ def open_grib2(fetched: FetchedAsset, select: Mapping[str, Any] | None = None) -
|
|
|
274
365
|
)
|
|
275
366
|
names.append(short)
|
|
276
367
|
fields[f"{short}#{len(names)}"] = (data, attrs)
|
|
277
|
-
if
|
|
278
|
-
listing = ", ".join(f"({s!r}, {t!r}, {lv!r})" for s, t, lv in available)
|
|
368
|
+
if count > 1 and select is None:
|
|
279
369
|
raise ValueError(
|
|
280
|
-
f"{
|
|
370
|
+
f"{count} messages; pass select={{...}} with ecCodes keys to choose, "
|
|
281
371
|
f"for example select={{'shortName': ..., 'typeOfLevel': ...}}. "
|
|
282
|
-
f"Available (shortName, typeOfLevel, level): {
|
|
372
|
+
f"Available (shortName, typeOfLevel, level): {_available(fetched.path)}"
|
|
283
373
|
)
|
|
284
374
|
if grid is None or not fields:
|
|
285
|
-
if not
|
|
375
|
+
if not count:
|
|
286
376
|
raise ValueError("no GRIB2 messages found")
|
|
287
|
-
listing = ", ".join(f"({s!r}, {t!r}, {lv!r})" for s, t, lv in available)
|
|
288
377
|
raise ValueError(
|
|
289
|
-
|
|
378
|
+
"select matched no messages. Available (shortName, typeOfLevel, level): "
|
|
379
|
+
f"{_available(fetched.path)}"
|
|
290
380
|
)
|
|
381
|
+
if report := _unmatched_report(options, matched, present):
|
|
382
|
+
if strict:
|
|
383
|
+
raise ValueError(report)
|
|
384
|
+
warnings.warn(report, UserWarning, stacklevel=_caller_stacklevel())
|
|
291
385
|
variables: dict[str, Any] = {}
|
|
292
386
|
duplicates = {name for name in names if names.count(name) > 1}
|
|
293
387
|
for (data, attrs), name in zip(fields.values(), names, strict=True):
|
|
@@ -305,4 +399,5 @@ def open_grib2(fetched: FetchedAsset, select: Mapping[str, Any] | None = None) -
|
|
|
305
399
|
"asset_id": fetched.asset.id,
|
|
306
400
|
"provenance": fetched.provenance.model_dump(mode="json"),
|
|
307
401
|
}
|
|
402
|
+
fill_registry_attrs(fetched, dataset)
|
|
308
403
|
return dataset
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
"""Local, eagerly loaded NetCDF4 reading behind the netcdf extra."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from importlib import import_module
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import TYPE_CHECKING, Any
|
|
8
|
+
|
|
9
|
+
from usdata.inspect import NetcdfVariable
|
|
10
|
+
from usdata.readers import MissingReaderDependency, fill_registry_attrs
|
|
11
|
+
|
|
12
|
+
if TYPE_CHECKING:
|
|
13
|
+
from usdata._fetch import FetchedAsset
|
|
14
|
+
|
|
15
|
+
PIP_HINT = 'NetCDF4 reading requires xarray and h5netcdf; install: pip install "usdata[netcdf]"'
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _xarray() -> Any:
|
|
19
|
+
"""The xarray module once h5netcdf can back it, or a hint naming the extra."""
|
|
20
|
+
try:
|
|
21
|
+
xarray = import_module("xarray")
|
|
22
|
+
import_module("h5netcdf")
|
|
23
|
+
import_module("h5py")
|
|
24
|
+
except ModuleNotFoundError as error:
|
|
25
|
+
if error.name not in {"xarray", "h5netcdf", "h5py"}:
|
|
26
|
+
raise
|
|
27
|
+
raise MissingReaderDependency(PIP_HINT) from error
|
|
28
|
+
return xarray
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _text(value: Any) -> str | None:
|
|
32
|
+
"""One attribute as a single string, or None where the file states nothing."""
|
|
33
|
+
return None if value is None else str(value)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def open_netcdf(fetched: FetchedAsset) -> Any:
|
|
37
|
+
"""Load a NetCDF4 root Dataset, then close every source file handle."""
|
|
38
|
+
xarray = _xarray()
|
|
39
|
+
# A local file object and fixed engine prevent interpretation as an OPeNDAP URL.
|
|
40
|
+
with (
|
|
41
|
+
fetched.path.open("rb") as stream,
|
|
42
|
+
xarray.open_dataset(stream, engine="h5netcdf", chunks=None) as dataset,
|
|
43
|
+
):
|
|
44
|
+
dataset.load()
|
|
45
|
+
dataset.attrs["usdata"] = {
|
|
46
|
+
"asset_id": fetched.asset.id,
|
|
47
|
+
"provenance": fetched.provenance.model_dump(mode="json"),
|
|
48
|
+
}
|
|
49
|
+
fill_registry_attrs(fetched, dataset)
|
|
50
|
+
return dataset
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def variables(path: Path) -> list[NetcdfVariable]:
|
|
54
|
+
"""Describe a local NetCDF4 file's data variables without loading their values."""
|
|
55
|
+
xarray = _xarray()
|
|
56
|
+
with (
|
|
57
|
+
path.open("rb") as stream,
|
|
58
|
+
xarray.open_dataset(stream, engine="h5netcdf", chunks=None) as dataset,
|
|
59
|
+
):
|
|
60
|
+
return [
|
|
61
|
+
NetcdfVariable(
|
|
62
|
+
name=str(name),
|
|
63
|
+
dims=[str(dim) for dim in array.dims],
|
|
64
|
+
shape=[int(size) for size in array.shape],
|
|
65
|
+
units=_text(array.attrs.get("units")),
|
|
66
|
+
long_name=_text(array.attrs.get("long_name")),
|
|
67
|
+
)
|
|
68
|
+
for name, array in dataset.data_vars.items()
|
|
69
|
+
]
|
|
@@ -23,14 +23,31 @@ def cache_dir() -> Path:
|
|
|
23
23
|
|
|
24
24
|
def asset_path(asset: Asset, root: Path | None = None) -> Path:
|
|
25
25
|
"""Where an asset lives in the cache: <root>/<provider>/<dataset>/<asset id>."""
|
|
26
|
-
|
|
26
|
+
return cached_path(asset.dataset_id, asset.id, root)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def cached_path(dataset_id: str, asset_id: str, root: Path | None = None) -> Path:
|
|
30
|
+
"""Where the cache keeps one asset, addressed by its ids rather than the asset itself.
|
|
31
|
+
|
|
32
|
+
Args:
|
|
33
|
+
dataset_id: The owning dataset's ``provider:name`` id.
|
|
34
|
+
asset_id: The asset's id; slashes become underscores, as they do on fetch.
|
|
35
|
+
root: Cache root to resolve against, or the configured cache directory.
|
|
36
|
+
|
|
37
|
+
Returns:
|
|
38
|
+
The path the cache uses for that asset, whether or not it exists.
|
|
39
|
+
|
|
40
|
+
Raises:
|
|
41
|
+
ValueError: Either id is unsafe, or the path would escape the cache root.
|
|
42
|
+
"""
|
|
43
|
+
provider, sep, name = dataset_id.partition(":")
|
|
27
44
|
if not sep or any(
|
|
28
45
|
not re.fullmatch(r"[A-Za-z0-9][A-Za-z0-9_.-]*", part) for part in (provider, name)
|
|
29
46
|
):
|
|
30
|
-
raise ValueError(f"unsafe dataset id: {
|
|
31
|
-
safe_id =
|
|
47
|
+
raise ValueError(f"unsafe dataset id: {dataset_id!r}")
|
|
48
|
+
safe_id = asset_id.replace("/", "_")
|
|
32
49
|
if not safe_id or safe_id in {".", ".."} or "\\" in safe_id or "\x00" in safe_id:
|
|
33
|
-
raise ValueError(f"unsafe asset id: {
|
|
50
|
+
raise ValueError(f"unsafe asset id: {asset_id!r}")
|
|
34
51
|
base = (root or cache_dir()).expanduser().resolve()
|
|
35
52
|
path = base / provider / name / safe_id
|
|
36
53
|
for candidate in (path, path.with_name(path.name + ".provenance.json")):
|