usdata 0.16.0__tar.gz → 0.18.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.18.0}/PKG-INFO +3 -2
- {usdata-0.16.0 → usdata-0.18.0}/README.md +2 -1
- {usdata-0.16.0 → usdata-0.18.0}/pyproject.toml +1 -1
- {usdata-0.16.0 → usdata-0.18.0}/pyproject.toml.orig +1 -1
- usdata-0.18.0/src/usdata/__init__.py +99 -0
- usdata-0.18.0/src/usdata/__main__.py +8 -0
- {usdata-0.16.0 → usdata-0.18.0}/src/usdata/_fetch.py +53 -10
- {usdata-0.16.0 → usdata-0.18.0}/src/usdata/_grib.py +222 -58
- usdata-0.18.0/src/usdata/_netcdf.py +69 -0
- {usdata-0.16.0 → usdata-0.18.0}/src/usdata/cache.py +26 -4
- usdata-0.18.0/src/usdata/cache_ops.py +249 -0
- usdata-0.18.0/src/usdata/cite.py +259 -0
- {usdata-0.16.0 → usdata-0.18.0}/src/usdata/cli/app.py +262 -22
- usdata-0.18.0/src/usdata/cli/cache.py +132 -0
- usdata-0.18.0/src/usdata/cli/cite.py +76 -0
- usdata-0.18.0/src/usdata/cli/doctor.py +33 -0
- usdata-0.18.0/src/usdata/cli/inspect.py +164 -0
- {usdata-0.16.0 → usdata-0.18.0}/src/usdata/data/registry.yaml +639 -257
- usdata-0.18.0/src/usdata/doctor.py +229 -0
- usdata-0.18.0/src/usdata/inspect.py +322 -0
- {usdata-0.16.0 → usdata-0.18.0}/src/usdata/manifest.py +35 -7
- usdata-0.18.0/src/usdata/models.py +561 -0
- usdata-0.18.0/src/usdata/protocols/http.py +261 -0
- {usdata-0.16.0 → usdata-0.18.0}/src/usdata/protocols/s3.py +30 -2
- {usdata-0.16.0 → usdata-0.18.0}/src/usdata/provenance.py +25 -3
- usdata-0.18.0/src/usdata/providers/__init__.py +37 -0
- {usdata-0.16.0 → usdata-0.18.0}/src/usdata/providers/base.py +22 -1
- usdata-0.18.0/src/usdata/providers/http.py +44 -0
- {usdata-0.16.0 → usdata-0.18.0}/src/usdata/providers/noaa/coastwatch.py +2 -2
- {usdata-0.16.0 → usdata-0.18.0}/src/usdata/providers/noaa/coops.py +2 -2
- {usdata-0.16.0 → usdata-0.18.0}/src/usdata/providers/noaa/gfs.py +21 -9
- {usdata-0.16.0 → usdata-0.18.0}/src/usdata/providers/noaa/ghcnd.py +2 -2
- {usdata-0.16.0 → usdata-0.18.0}/src/usdata/providers/noaa/glm.py +2 -2
- {usdata-0.16.0 → usdata-0.18.0}/src/usdata/providers/noaa/goes.py +2 -2
- usdata-0.18.0/src/usdata/providers/noaa/grib_index.py +210 -0
- usdata-0.18.0/src/usdata/providers/noaa/hrrr.py +438 -0
- {usdata-0.16.0 → usdata-0.18.0}/src/usdata/providers/noaa/hurdat2.py +2 -2
- {usdata-0.16.0 → usdata-0.18.0}/src/usdata/providers/noaa/mrms.py +2 -2
- {usdata-0.16.0 → usdata-0.18.0}/src/usdata/providers/noaa/nexrad.py +5 -18
- {usdata-0.16.0 → usdata-0.18.0}/src/usdata/providers/noaa/spc.py +2 -2
- {usdata-0.16.0 → usdata-0.18.0}/src/usdata/providers/noaa/storm_events.py +2 -2
- {usdata-0.16.0 → usdata-0.18.0}/src/usdata/providers/params.py +18 -0
- {usdata-0.16.0 → usdata-0.18.0}/src/usdata/providers/usgs/daily.py +2 -2
- {usdata-0.16.0 → usdata-0.18.0}/src/usdata/pull.py +172 -18
- {usdata-0.16.0 → usdata-0.18.0}/src/usdata/readers.py +180 -3
- {usdata-0.16.0 → usdata-0.18.0}/src/usdata/registry.py +129 -9
- usdata-0.18.0/src/usdata/testing.py +475 -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/models.py +0 -256
- usdata-0.16.0/src/usdata/protocols/http.py +0 -113
- 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/src/usdata/providers/noaa/hrrr.py +0 -224
- {usdata-0.16.0 → usdata-0.18.0}/src/usdata/_files.py +0 -0
- {usdata-0.16.0 → usdata-0.18.0}/src/usdata/_hurdat2.py +0 -0
- {usdata-0.16.0 → usdata-0.18.0}/src/usdata/_progress.py +0 -0
- {usdata-0.16.0 → usdata-0.18.0}/src/usdata/_radar.py +0 -0
- {usdata-0.16.0 → usdata-0.18.0}/src/usdata/cli/__init__.py +0 -0
- {usdata-0.16.0 → usdata-0.18.0}/src/usdata/cli/progress.py +0 -0
- {usdata-0.16.0 → usdata-0.18.0}/src/usdata/data/nexrad_sites.csv +0 -0
- {usdata-0.16.0 → usdata-0.18.0}/src/usdata/data/places.csv +0 -0
- {usdata-0.16.0 → usdata-0.18.0}/src/usdata/data/places.sources.json +0 -0
- {usdata-0.16.0 → usdata-0.18.0}/src/usdata/protocols/__init__.py +0 -0
- {usdata-0.16.0 → usdata-0.18.0}/src/usdata/protocols/erddap.py +0 -0
- {usdata-0.16.0 → usdata-0.18.0}/src/usdata/protocols/listing.py +0 -0
- {usdata-0.16.0 → usdata-0.18.0}/src/usdata/providers/noaa/__init__.py +0 -0
- {usdata-0.16.0 → usdata-0.18.0}/src/usdata/providers/noaa/gsom.py +0 -0
- {usdata-0.16.0 → usdata-0.18.0}/src/usdata/providers/noaa/gsoy.py +0 -0
- {usdata-0.16.0 → usdata-0.18.0}/src/usdata/providers/noaa/lcd.py +0 -0
- {usdata-0.16.0 → usdata-0.18.0}/src/usdata/providers/noaa/nexrad_level3.py +0 -0
- {usdata-0.16.0 → usdata-0.18.0}/src/usdata/providers/noaa/normals.py +0 -0
- {usdata-0.16.0 → usdata-0.18.0}/src/usdata/providers/noaa/sites.py +0 -0
- {usdata-0.16.0 → usdata-0.18.0}/src/usdata/providers/usgs/__init__.py +0 -0
- {usdata-0.16.0 → usdata-0.18.0}/src/usdata/py.typed +0 -0
- {usdata-0.16.0 → usdata-0.18.0}/src/usdata/query.py +0 -0
- {usdata-0.16.0 → usdata-0.18.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.18.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,99 @@
|
|
|
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 Citation, 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
|
+
"Citation",
|
|
27
|
+
"Dataset",
|
|
28
|
+
"DatasetNotFound",
|
|
29
|
+
"FetchedAsset",
|
|
30
|
+
"Provenance",
|
|
31
|
+
"Query",
|
|
32
|
+
"Registry",
|
|
33
|
+
"SearchResult",
|
|
34
|
+
"TemporalSelection",
|
|
35
|
+
"TimeRange",
|
|
36
|
+
"__version__",
|
|
37
|
+
"build_query",
|
|
38
|
+
"cite_dataset",
|
|
39
|
+
"cite_lockfile",
|
|
40
|
+
"datasets",
|
|
41
|
+
"default_registry",
|
|
42
|
+
"fetch",
|
|
43
|
+
"fetch_asset",
|
|
44
|
+
"get",
|
|
45
|
+
"inspect_asset",
|
|
46
|
+
"inspect_path",
|
|
47
|
+
"pull",
|
|
48
|
+
"search",
|
|
49
|
+
"select_by_time",
|
|
50
|
+
"verify",
|
|
51
|
+
]
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def datasets(
|
|
55
|
+
*,
|
|
56
|
+
provider: str | None = None,
|
|
57
|
+
domain: str | None = None,
|
|
58
|
+
format: str | None = None,
|
|
59
|
+
reader: str | None = None,
|
|
60
|
+
capability: str | None = None,
|
|
61
|
+
status: str = "available",
|
|
62
|
+
) -> list[Dataset]:
|
|
63
|
+
"""List curated registry datasets. Keyword arguments match ``Registry.list``."""
|
|
64
|
+
return default_registry().list(
|
|
65
|
+
provider=provider,
|
|
66
|
+
domain=domain,
|
|
67
|
+
format=format,
|
|
68
|
+
reader=reader,
|
|
69
|
+
capability=capability,
|
|
70
|
+
status=status,
|
|
71
|
+
)
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def search(
|
|
75
|
+
text: str | None = None,
|
|
76
|
+
*,
|
|
77
|
+
include_planned: bool = False,
|
|
78
|
+
domain: str | None = None,
|
|
79
|
+
format: str | None = None,
|
|
80
|
+
reader: str | None = None,
|
|
81
|
+
capability: str | None = None,
|
|
82
|
+
status: str | None = None,
|
|
83
|
+
**kwargs: Any,
|
|
84
|
+
) -> list[SearchResult]:
|
|
85
|
+
"""Search the curated registry. Remaining keyword arguments match ``build_query``."""
|
|
86
|
+
return default_registry().search(
|
|
87
|
+
build_query(text, **kwargs),
|
|
88
|
+
include_planned=include_planned,
|
|
89
|
+
domain=domain,
|
|
90
|
+
format=format,
|
|
91
|
+
reader=reader,
|
|
92
|
+
capability=capability,
|
|
93
|
+
status=status,
|
|
94
|
+
)
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def get(dataset_id: str) -> Dataset:
|
|
98
|
+
"""Look up a dataset by ``provider:name`` id."""
|
|
99
|
+
return default_registry().get(dataset_id)
|
|
@@ -2,9 +2,10 @@
|
|
|
2
2
|
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
5
|
+
import os
|
|
5
6
|
from collections.abc import Mapping
|
|
6
7
|
from pathlib import Path
|
|
7
|
-
from typing import Any
|
|
8
|
+
from typing import TYPE_CHECKING, Any
|
|
8
9
|
|
|
9
10
|
from pydantic import BaseModel
|
|
10
11
|
|
|
@@ -14,6 +15,9 @@ from usdata.cache import asset_path, sha256_file
|
|
|
14
15
|
from usdata.models import Asset, Dataset, Provenance, Query
|
|
15
16
|
from usdata.providers import Provider, load_adapter
|
|
16
17
|
|
|
18
|
+
if TYPE_CHECKING:
|
|
19
|
+
from usdata.inspect import Summary
|
|
20
|
+
|
|
17
21
|
|
|
18
22
|
class ChecksumMismatch(RuntimeError):
|
|
19
23
|
"""A fetched file's sha256 did not match the checksum the adapter declared."""
|
|
@@ -37,16 +41,21 @@ class FetchedAsset(BaseModel):
|
|
|
37
41
|
nrows: int | None = None,
|
|
38
42
|
sweep: int | list[int] | None = None,
|
|
39
43
|
select: Mapping[str, Any] | None = None,
|
|
44
|
+
strict: bool = False,
|
|
40
45
|
) -> Any:
|
|
41
46
|
"""Open local data with an optional ``pandas``, ``radar``, ``netcdf``, or ``grib`` reader.
|
|
42
47
|
|
|
43
48
|
ERDDAP units are kept in ``frame.attrs["units"]`` and source provenance
|
|
44
49
|
in ``frame.attrs["usdata"]``. NEXRAD returns a xarray DataTree with provenance
|
|
45
50
|
in ``radar.attrs["usdata"]``. NetCDF4 and GRIB2 return a loaded xarray Dataset
|
|
46
|
-
with matching provenance in its attributes
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
to
|
|
51
|
+
with matching provenance in its attributes, and with units and long names
|
|
52
|
+
the file leaves unstated filled from the registry entry's variables. See
|
|
53
|
+
``usdata.readers.open_asset`` for options. Use ``sweep=0`` or ``sweep=[0, 2]``
|
|
54
|
+
to load selected zero-based radar sweeps, and
|
|
55
|
+
``select={"shortName": "cape", "typeOfLevel": "surface"}``
|
|
56
|
+
to choose GRIB2 messages; ``strict=True`` raises instead of warning when a
|
|
57
|
+
GRIB2 select value matches none of the selected messages. Cached files and
|
|
58
|
+
provenance sidecars are never changed.
|
|
50
59
|
"""
|
|
51
60
|
from usdata.readers import open_asset
|
|
52
61
|
|
|
@@ -59,8 +68,22 @@ class FetchedAsset(BaseModel):
|
|
|
59
68
|
nrows=nrows,
|
|
60
69
|
sweep=sweep,
|
|
61
70
|
select=select,
|
|
71
|
+
strict=strict,
|
|
62
72
|
)
|
|
63
73
|
|
|
74
|
+
def inspect(self) -> Summary:
|
|
75
|
+
"""Summarize this file: provenance, format, and what that format holds.
|
|
76
|
+
|
|
77
|
+
CSV columns and a capped row count need no extra; NetCDF4 variables and
|
|
78
|
+
GRIB2 messages need the same extras ``open`` does, and a missing one
|
|
79
|
+
yields a summary with no detail and a note naming it. Like ``open``,
|
|
80
|
+
this reads the cached file and changes nothing. See
|
|
81
|
+
``usdata.inspect.inspect_asset``.
|
|
82
|
+
"""
|
|
83
|
+
from usdata.inspect import inspect_asset
|
|
84
|
+
|
|
85
|
+
return inspect_asset(self)
|
|
86
|
+
|
|
64
87
|
|
|
65
88
|
def _sidecar_not_older(path: Path) -> bool:
|
|
66
89
|
"""Whether ``path`` still carries the mtime it had when its sidecar was written.
|
|
@@ -83,7 +106,14 @@ def _fetch_asset(
|
|
|
83
106
|
*,
|
|
84
107
|
root: Path | None = None,
|
|
85
108
|
force: bool = False,
|
|
109
|
+
pinned: Provenance | None = None,
|
|
86
110
|
) -> FetchedAsset:
|
|
111
|
+
"""Fetch one asset through the cache, passing any pinned record to the adapter.
|
|
112
|
+
|
|
113
|
+
``pinned`` is the provenance a lockfile holds for this asset. The adapter sees
|
|
114
|
+
it through ``prepare_fetch``, so an asset that pins byte ranges is reproduced
|
|
115
|
+
from the record rather than by resolving its query again.
|
|
116
|
+
"""
|
|
87
117
|
if asset.dataset_id != dataset.id:
|
|
88
118
|
raise ValueError(f"asset dataset {asset.dataset_id!r} does not match {dataset.id!r}")
|
|
89
119
|
_progress.emit(_progress.AssetProgress(asset.id, "start", asset.size))
|
|
@@ -106,8 +136,9 @@ def _fetch_asset(
|
|
|
106
136
|
_progress.emit(_progress.AssetProgress(asset.id, "cached", prov.size))
|
|
107
137
|
return FetchedAsset(asset=asset, path=path, provenance=prov, from_cache=True)
|
|
108
138
|
with staged_path(path) as tmp:
|
|
139
|
+
partial = adapter.prepare_fetch(asset, pinned)
|
|
109
140
|
adapter.fetch(asset, tmp)
|
|
110
|
-
prov = provenance.record(dataset, asset, tmp)
|
|
141
|
+
prov = provenance.record(dataset, asset, tmp, partial)
|
|
111
142
|
if asset.checksum and prov.checksum != asset.checksum:
|
|
112
143
|
raise ChecksumMismatch(f"{asset.id}: expected {asset.checksum}, got {prov.checksum}")
|
|
113
144
|
# A crash between replacements leaves a detectable mismatch, never a trusted partial file.
|
|
@@ -117,11 +148,17 @@ def _fetch_asset(
|
|
|
117
148
|
|
|
118
149
|
|
|
119
150
|
def fetch_asset(
|
|
120
|
-
dataset: Dataset,
|
|
151
|
+
dataset: Dataset,
|
|
152
|
+
asset: Asset,
|
|
153
|
+
*,
|
|
154
|
+
root: str | os.PathLike[str] | None = None,
|
|
155
|
+
force: bool = False,
|
|
121
156
|
) -> FetchedAsset:
|
|
122
157
|
"""Fetch one asset, reusing the cache only when its provenance still describes it."""
|
|
123
158
|
with load_adapter(dataset) as adapter:
|
|
124
|
-
return _fetch_asset(
|
|
159
|
+
return _fetch_asset(
|
|
160
|
+
dataset, asset, adapter, root=None if root is None else Path(root), force=force
|
|
161
|
+
)
|
|
125
162
|
|
|
126
163
|
|
|
127
164
|
def _fetch_with(
|
|
@@ -139,8 +176,14 @@ def _fetch_with(
|
|
|
139
176
|
|
|
140
177
|
|
|
141
178
|
def fetch(
|
|
142
|
-
dataset: Dataset,
|
|
179
|
+
dataset: Dataset,
|
|
180
|
+
query: Query,
|
|
181
|
+
*,
|
|
182
|
+
root: str | os.PathLike[str] | None = None,
|
|
183
|
+
force: bool = False,
|
|
143
184
|
) -> list[FetchedAsset]:
|
|
144
185
|
"""Resolve and fetch a query, sharing one adapter and closing its owned resources."""
|
|
145
186
|
with load_adapter(dataset) as adapter:
|
|
146
|
-
return _fetch_with(
|
|
187
|
+
return _fetch_with(
|
|
188
|
+
adapter, dataset, query, root=None if root is None else Path(root), force=force
|
|
189
|
+
)
|
|
@@ -3,7 +3,9 @@
|
|
|
3
3
|
Messages are decoded one at a time with the ecCodes Python bindings, never
|
|
4
4
|
cfgrib. The reader builds the xarray Dataset itself: one data variable per
|
|
5
5
|
selected message on one shared grid, coordinates computed from the grid
|
|
6
|
-
definition, and provenance under ``attrs["usdata"]``.
|
|
6
|
+
definition, and provenance under ``attrs["usdata"]``. Variable names follow
|
|
7
|
+
from the set of selected messages alone, never from which short names happen to
|
|
8
|
+
repeat, so the same select always yields the same names. Values arrive from
|
|
7
9
|
ecCodes as float64, are masked to NaN where the message's bitmap marks them
|
|
8
10
|
missing, and are stored as float32; the float64 array is released before the
|
|
9
11
|
Dataset is returned. Rows are ordered north to south and columns west to east
|
|
@@ -14,14 +16,17 @@ from __future__ import annotations
|
|
|
14
16
|
|
|
15
17
|
import gzip
|
|
16
18
|
import re
|
|
17
|
-
|
|
19
|
+
import warnings
|
|
20
|
+
from collections.abc import Iterator, Mapping, Sequence
|
|
18
21
|
from contextlib import contextmanager
|
|
19
22
|
from datetime import UTC, datetime
|
|
20
23
|
from importlib import import_module
|
|
24
|
+
from inspect import currentframe
|
|
21
25
|
from pathlib import Path
|
|
22
|
-
from typing import TYPE_CHECKING, Any
|
|
26
|
+
from typing import TYPE_CHECKING, Any, NamedTuple
|
|
23
27
|
|
|
24
|
-
from usdata.
|
|
28
|
+
from usdata.inspect import GribMessage
|
|
29
|
+
from usdata.readers import MissingReaderDependency, fill_registry_attrs
|
|
25
30
|
|
|
26
31
|
if TYPE_CHECKING:
|
|
27
32
|
from usdata._fetch import FetchedAsset
|
|
@@ -33,7 +38,7 @@ LIBRARY_HINT = (
|
|
|
33
38
|
"`conda install -c conda-forge eccodes` or `brew install eccodes`"
|
|
34
39
|
)
|
|
35
40
|
MRMS_NAME = re.compile(r"^MRMS_(?P<product>.+?)_\d{2}\.\d{2}_\d{8}-\d{6}\.grib2(?:\.gz)?$")
|
|
36
|
-
|
|
41
|
+
INVENTORY_KEYS = ("shortName", "name", "typeOfLevel", "level", "step", "units")
|
|
37
42
|
VARIABLE_KEYS = (
|
|
38
43
|
"name",
|
|
39
44
|
"units",
|
|
@@ -129,28 +134,98 @@ def _get(eccodes: Any, h: int, key: str, ktype: type | None = None) -> Any:
|
|
|
129
134
|
return None
|
|
130
135
|
|
|
131
136
|
|
|
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
|
|
137
|
+
def _options(key: str, wanted: Any) -> list[Any]:
|
|
138
|
+
"""One key's wanted values as a validated list, whether one value or several were given."""
|
|
139
|
+
options = list(wanted) if isinstance(wanted, list | tuple | set) else [wanted]
|
|
140
|
+
if not options:
|
|
141
|
+
raise ValueError(f"select[{key!r}] must not be empty")
|
|
142
|
+
for option in options:
|
|
143
|
+
if isinstance(option, bool) or not isinstance(option, int | float | str):
|
|
144
|
+
raise ValueError(f"select[{key!r}] values must be strings or numbers")
|
|
145
|
+
return options
|
|
150
146
|
|
|
151
147
|
|
|
152
|
-
def
|
|
153
|
-
|
|
148
|
+
def _matched(eccodes: Any, h: int, key: str, options: list[Any]) -> set[int]:
|
|
149
|
+
"""Positions in ``options`` that this message's value for ``key`` matches."""
|
|
150
|
+
text = _get(eccodes, h, key, str)
|
|
151
|
+
number = _get(eccodes, h, key, float) if text is not None else None
|
|
152
|
+
hits = set()
|
|
153
|
+
for index, option in enumerate(options):
|
|
154
|
+
if isinstance(option, str):
|
|
155
|
+
if text == option:
|
|
156
|
+
hits.add(index)
|
|
157
|
+
elif number is not None and number == option:
|
|
158
|
+
hits.add(index)
|
|
159
|
+
return hits
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def _unmatched_report(
|
|
163
|
+
options: Mapping[str, list[Any]],
|
|
164
|
+
matched: Mapping[str, set[int]],
|
|
165
|
+
present: Mapping[str, list[str]],
|
|
166
|
+
) -> str:
|
|
167
|
+
"""Text naming every select value that matched no message, with what was there instead."""
|
|
168
|
+
parts = []
|
|
169
|
+
for key, wanted in options.items():
|
|
170
|
+
missing = [option for index, option in enumerate(wanted) if index not in matched[key]]
|
|
171
|
+
if not missing:
|
|
172
|
+
continue
|
|
173
|
+
available = ", ".join(repr(value) for value in present[key]) or "none"
|
|
174
|
+
parts.append(
|
|
175
|
+
f"select[{key!r}] matched no message for "
|
|
176
|
+
f"{', '.join(repr(option) for option in missing)}; "
|
|
177
|
+
f"{key} values available with the other select keys: {available}."
|
|
178
|
+
)
|
|
179
|
+
return " ".join(parts)
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def _caller_stacklevel() -> int:
|
|
183
|
+
"""Stack level of the first frame outside usdata, so a warning points at the caller."""
|
|
184
|
+
package = Path(__file__).resolve().parent
|
|
185
|
+
frame = currentframe()
|
|
186
|
+
frame = frame.f_back if frame is not None else None
|
|
187
|
+
level = 1
|
|
188
|
+
while frame is not None and Path(frame.f_code.co_filename).resolve().is_relative_to(package):
|
|
189
|
+
level += 1
|
|
190
|
+
frame = frame.f_back
|
|
191
|
+
return level
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def _shape(eccodes: Any, h: int) -> tuple[int, int] | None:
|
|
195
|
+
"""A message's grid rows and columns under either key pair, or None when neither is set."""
|
|
196
|
+
rows, cols = _get(eccodes, h, "Nj", int), _get(eccodes, h, "Ni", int)
|
|
197
|
+
if rows is None or cols is None:
|
|
198
|
+
rows, cols = _get(eccodes, h, "Ny", int), _get(eccodes, h, "Nx", int)
|
|
199
|
+
return None if rows is None or cols is None else (rows, cols)
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
def inventory(path: Path) -> list[GribMessage]:
|
|
203
|
+
"""Every message in a local GRIB2 file, in file order, without decoding its values."""
|
|
204
|
+
eccodes = _modules()[0]
|
|
205
|
+
messages = []
|
|
206
|
+
for index, h in enumerate(_messages(eccodes, path)):
|
|
207
|
+
keys = {key: _get(eccodes, h, key, str) for key in INVENTORY_KEYS}
|
|
208
|
+
messages.append(
|
|
209
|
+
GribMessage(
|
|
210
|
+
file_index=index,
|
|
211
|
+
short_name=keys["shortName"],
|
|
212
|
+
name=keys["name"],
|
|
213
|
+
type_of_level=keys["typeOfLevel"],
|
|
214
|
+
level=keys["level"],
|
|
215
|
+
step=keys["step"],
|
|
216
|
+
units=keys["units"],
|
|
217
|
+
shape=_shape(eccodes, h),
|
|
218
|
+
)
|
|
219
|
+
)
|
|
220
|
+
return messages
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
def _available(path: Path) -> str:
|
|
224
|
+
"""Every message as a (shortName, typeOfLevel, level) triple, for a reader error to list."""
|
|
225
|
+
return ", ".join(
|
|
226
|
+
f"({message.short_name!r}, {message.type_of_level!r}, {message.level!r})"
|
|
227
|
+
for message in inventory(path)
|
|
228
|
+
)
|
|
154
229
|
|
|
155
230
|
|
|
156
231
|
def _product_name(asset_id: str) -> str | None:
|
|
@@ -176,13 +251,11 @@ class _Grid:
|
|
|
176
251
|
|
|
177
252
|
def __init__(self, eccodes: Any, numpy: Any, h: int) -> None:
|
|
178
253
|
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:
|
|
254
|
+
shape = _shape(eccodes, h)
|
|
255
|
+
if shape is None:
|
|
184
256
|
raise ValueError(f"cannot determine the grid shape of a {self.grid_type} message")
|
|
185
|
-
self.shape =
|
|
257
|
+
self.shape = shape
|
|
258
|
+
rows, cols = shape
|
|
186
259
|
self.flip_rows = bool(_get(eccodes, h, "jScansPositively", int))
|
|
187
260
|
self.flip_cols = bool(_get(eccodes, h, "iScansNegatively", int))
|
|
188
261
|
if _get(eccodes, h, "jPointsAreConsecutive", int):
|
|
@@ -227,21 +300,96 @@ class _Grid:
|
|
|
227
300
|
return numpy.ascontiguousarray(grid)
|
|
228
301
|
|
|
229
302
|
|
|
230
|
-
|
|
231
|
-
"""
|
|
303
|
+
class _Field(NamedTuple):
|
|
304
|
+
"""One decoded message: its short name, values, variable attributes, and identity."""
|
|
305
|
+
|
|
306
|
+
short: str
|
|
307
|
+
data: Any
|
|
308
|
+
attrs: dict[str, Any]
|
|
309
|
+
message: dict[str, Any]
|
|
310
|
+
|
|
311
|
+
|
|
312
|
+
def variable_names(fields: Sequence[tuple[str, Any, Any]]) -> list[str]:
|
|
313
|
+
"""The names one set of selected messages takes, under the one GRIB2 naming rule.
|
|
314
|
+
|
|
315
|
+
Every name is the bare short name while the messages share one type of level
|
|
316
|
+
and level, and every name is ``short_typeOfLevel_level`` as soon as they span
|
|
317
|
+
more than one, so the names follow from the selection rather than from which
|
|
318
|
+
short names happened to repeat. A name that would still repeat gains its
|
|
319
|
+
position.
|
|
320
|
+
|
|
321
|
+
Args:
|
|
322
|
+
fields: One ``(short name, type of level, level)`` triple per message, in
|
|
323
|
+
the order the messages appear.
|
|
324
|
+
|
|
325
|
+
Returns:
|
|
326
|
+
One variable name per field, in the same order.
|
|
327
|
+
"""
|
|
328
|
+
spans = len({(level_type, level) for _, level_type, level in fields}) > 1
|
|
329
|
+
names: list[str] = []
|
|
330
|
+
for short, level_type, level in fields:
|
|
331
|
+
label = short
|
|
332
|
+
if spans:
|
|
333
|
+
kind = "level" if level_type is None else level_type
|
|
334
|
+
label = f"{short}_{kind}_{'' if level is None else level}"
|
|
335
|
+
if label in names:
|
|
336
|
+
label = f"{label}_{len(names)}"
|
|
337
|
+
names.append(label)
|
|
338
|
+
return names
|
|
339
|
+
|
|
340
|
+
|
|
341
|
+
def open_grib2(
|
|
342
|
+
fetched: FetchedAsset, select: Mapping[str, Any] | None = None, *, strict: bool = False
|
|
343
|
+
) -> Any:
|
|
344
|
+
"""Decode selected GRIB2 messages into one loaded xarray Dataset.
|
|
345
|
+
|
|
346
|
+
A file of several messages needs ``select``, unless its provenance says the
|
|
347
|
+
fetch already selected them: a partial fetch concatenated the messages that
|
|
348
|
+
were asked for, so all of them are wanted and ``select`` stays optional.
|
|
349
|
+
|
|
350
|
+
Variables take their ``shortName`` when every selected message shares one
|
|
351
|
+
type of level and level, and ``shortName_typeOfLevel_level`` for all of
|
|
352
|
+
them as soon as the selection spans more than one, so the names follow from
|
|
353
|
+
the select rather than from which short names happened to repeat.
|
|
354
|
+
``attrs["usdata"]["messages"]`` maps each variable name to the message it
|
|
355
|
+
came from: ``file_index`` numbers it in this file from zero, and
|
|
356
|
+
``object_index`` numbers it in the source object as that object's index
|
|
357
|
+
sidecar does, one-based, for a partial fetch and ``None`` for a whole file.
|
|
358
|
+
A partial fetch's entries also carry ``selector``, the index selector the
|
|
359
|
+
message was fetched for.
|
|
360
|
+
|
|
361
|
+
A select value that matches none of the selected messages warns, or raises
|
|
362
|
+
``ValueError`` when ``strict``; a select that matches nothing always raises.
|
|
363
|
+
"""
|
|
232
364
|
eccodes, xarray, numpy = _modules()
|
|
233
365
|
if select is not None and not isinstance(select, Mapping):
|
|
234
366
|
raise ValueError("select must be a mapping of ecCodes key names to values")
|
|
235
|
-
|
|
367
|
+
options = {key: _options(key, wanted) for key, wanted in (select or {}).items()}
|
|
368
|
+
matched: dict[str, set[int]] = {key: set() for key in options}
|
|
369
|
+
present: dict[str, list[str]] = {key: [] for key in options}
|
|
370
|
+
selected: list[_Field] = []
|
|
236
371
|
grid: _Grid | None = None
|
|
237
|
-
|
|
238
|
-
|
|
372
|
+
count = 0
|
|
373
|
+
needs_select = select is None and not fetched.provenance.is_partial
|
|
374
|
+
object_messages = fetched.provenance.object_messages
|
|
375
|
+
selectors = fetched.provenance.selectors if object_messages else []
|
|
239
376
|
for h in _messages(eccodes, fetched.path):
|
|
240
|
-
|
|
241
|
-
if
|
|
377
|
+
count += 1
|
|
378
|
+
if count > 1 and needs_select:
|
|
242
379
|
continue
|
|
243
|
-
|
|
380
|
+
hits = {key: _matched(eccodes, h, key, wanted) for key, wanted in options.items()}
|
|
381
|
+
missed = [key for key, indexes in hits.items() if not indexes]
|
|
382
|
+
for key in options:
|
|
383
|
+
# What this key could have been with the rest of the select held fixed.
|
|
384
|
+
if missed not in ([], [key]):
|
|
385
|
+
continue
|
|
386
|
+
value = _get(eccodes, h, key, str)
|
|
387
|
+
if value is not None and value not in present[key]:
|
|
388
|
+
present[key].append(value)
|
|
389
|
+
if missed:
|
|
244
390
|
continue
|
|
391
|
+
for key, indexes in hits.items():
|
|
392
|
+
matched[key] |= indexes
|
|
245
393
|
message_grid = _Grid(eccodes, numpy, h)
|
|
246
394
|
if grid is None:
|
|
247
395
|
grid = message_grid
|
|
@@ -266,37 +414,51 @@ def open_grib2(fetched: FetchedAsset, select: Mapping[str, Any] | None = None) -
|
|
|
266
414
|
stamp = _time(attrs.get(date_key), attrs.get(time_key))
|
|
267
415
|
if stamp:
|
|
268
416
|
attrs[label] = stamp
|
|
269
|
-
|
|
417
|
+
message: dict[str, Any] = {
|
|
418
|
+
"file_index": count - 1,
|
|
419
|
+
"object_index": object_messages[count - 1] if object_messages else None,
|
|
420
|
+
"shortName": _get(eccodes, h, "shortName", str),
|
|
421
|
+
"typeOfLevel": attrs.get("typeOfLevel"),
|
|
422
|
+
"level": attrs.get("level"),
|
|
423
|
+
"step": attrs.get("step"),
|
|
424
|
+
}
|
|
425
|
+
if selectors:
|
|
426
|
+
message["selector"] = selectors[count - 1]
|
|
427
|
+
short = message["shortName"]
|
|
270
428
|
if short in (None, "", "unknown", "~"):
|
|
271
429
|
short = _product_name(fetched.asset.id) or (
|
|
272
430
|
f"parameter_{attrs.get('discipline')}_{attrs.get('parameterCategory')}"
|
|
273
431
|
f"_{attrs.get('parameterNumber')}"
|
|
274
432
|
)
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
if len(available) > 1 and select is None:
|
|
278
|
-
listing = ", ".join(f"({s!r}, {t!r}, {lv!r})" for s, t, lv in available)
|
|
433
|
+
selected.append(_Field(short=short, data=data, attrs=attrs, message=message))
|
|
434
|
+
if count > 1 and needs_select:
|
|
279
435
|
raise ValueError(
|
|
280
|
-
f"{
|
|
436
|
+
f"{count} messages; pass select={{...}} with ecCodes keys to choose, "
|
|
281
437
|
f"for example select={{'shortName': ..., 'typeOfLevel': ...}}. "
|
|
282
|
-
f"Available (shortName, typeOfLevel, level): {
|
|
438
|
+
f"Available (shortName, typeOfLevel, level): {_available(fetched.path)}"
|
|
283
439
|
)
|
|
284
|
-
if grid is None or not
|
|
285
|
-
if not
|
|
440
|
+
if grid is None or not selected:
|
|
441
|
+
if not count:
|
|
286
442
|
raise ValueError("no GRIB2 messages found")
|
|
287
|
-
listing = ", ".join(f"({s!r}, {t!r}, {lv!r})" for s, t, lv in available)
|
|
288
443
|
raise ValueError(
|
|
289
|
-
|
|
444
|
+
"select matched no messages. Available (shortName, typeOfLevel, level): "
|
|
445
|
+
f"{_available(fetched.path)}"
|
|
290
446
|
)
|
|
447
|
+
if report := _unmatched_report(options, matched, present):
|
|
448
|
+
if strict:
|
|
449
|
+
raise ValueError(report)
|
|
450
|
+
warnings.warn(report, UserWarning, stacklevel=_caller_stacklevel())
|
|
291
451
|
variables: dict[str, Any] = {}
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
452
|
+
messages: dict[str, dict[str, Any]] = {}
|
|
453
|
+
names = variable_names(
|
|
454
|
+
[
|
|
455
|
+
(field.short, field.attrs.get("typeOfLevel"), field.attrs.get("level"))
|
|
456
|
+
for field in selected
|
|
457
|
+
]
|
|
458
|
+
)
|
|
459
|
+
for label, field in zip(names, selected, strict=True):
|
|
460
|
+
variables[label] = (grid.dims, field.data, field.attrs)
|
|
461
|
+
messages[label] = field.message
|
|
300
462
|
dataset = xarray.Dataset(variables, coords=grid.coords)
|
|
301
463
|
dataset.latitude.attrs["units"] = "degrees_north"
|
|
302
464
|
dataset.longitude.attrs["units"] = "degrees_east"
|
|
@@ -304,5 +466,7 @@ def open_grib2(fetched: FetchedAsset, select: Mapping[str, Any] | None = None) -
|
|
|
304
466
|
dataset.attrs["usdata"] = {
|
|
305
467
|
"asset_id": fetched.asset.id,
|
|
306
468
|
"provenance": fetched.provenance.model_dump(mode="json"),
|
|
469
|
+
"messages": messages,
|
|
307
470
|
}
|
|
471
|
+
fill_registry_attrs(fetched, dataset)
|
|
308
472
|
return dataset
|