usdata 0.19.0__tar.gz → 0.20.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.
Files changed (75) hide show
  1. {usdata-0.19.0 → usdata-0.20.0}/PKG-INFO +2 -2
  2. {usdata-0.19.0 → usdata-0.20.0}/README.md +1 -1
  3. {usdata-0.19.0 → usdata-0.20.0}/pyproject.toml +1 -1
  4. {usdata-0.19.0 → usdata-0.20.0}/pyproject.toml.orig +1 -1
  5. {usdata-0.19.0 → usdata-0.20.0}/src/usdata/_fetch.py +6 -1
  6. {usdata-0.19.0 → usdata-0.20.0}/src/usdata/_grib.py +32 -11
  7. {usdata-0.19.0 → usdata-0.20.0}/src/usdata/cli/inspect.py +2 -1
  8. {usdata-0.19.0 → usdata-0.20.0}/src/usdata/data/registry.yaml +141 -35
  9. {usdata-0.19.0 → usdata-0.20.0}/src/usdata/inspect.py +69 -20
  10. {usdata-0.19.0 → usdata-0.20.0}/src/usdata/protocols/http.py +4 -2
  11. {usdata-0.19.0 → usdata-0.20.0}/src/usdata/protocols/listing.py +36 -10
  12. {usdata-0.19.0 → usdata-0.20.0}/src/usdata/providers/base.py +29 -6
  13. {usdata-0.19.0 → usdata-0.20.0}/src/usdata/providers/noaa/grib_index.py +75 -26
  14. {usdata-0.19.0 → usdata-0.20.0}/src/usdata/providers/noaa/hrrr.py +31 -10
  15. usdata-0.20.0/src/usdata/providers/noaa/ibtracs.py +200 -0
  16. usdata-0.20.0/src/usdata/providers/noaa/nbm.py +106 -0
  17. usdata-0.20.0/src/usdata/providers/noaa/rap.py +110 -0
  18. {usdata-0.19.0 → usdata-0.20.0}/src/usdata/pull.py +155 -83
  19. {usdata-0.19.0 → usdata-0.20.0}/src/usdata/readers.py +100 -15
  20. {usdata-0.19.0 → usdata-0.20.0}/src/usdata/testing.py +6 -1
  21. {usdata-0.19.0 → usdata-0.20.0}/src/usdata/__init__.py +0 -0
  22. {usdata-0.19.0 → usdata-0.20.0}/src/usdata/__main__.py +0 -0
  23. {usdata-0.19.0 → usdata-0.20.0}/src/usdata/_files.py +0 -0
  24. {usdata-0.19.0 → usdata-0.20.0}/src/usdata/_hurdat2.py +0 -0
  25. {usdata-0.19.0 → usdata-0.20.0}/src/usdata/_netcdf.py +0 -0
  26. {usdata-0.19.0 → usdata-0.20.0}/src/usdata/_progress.py +0 -0
  27. {usdata-0.19.0 → usdata-0.20.0}/src/usdata/_radar.py +0 -0
  28. {usdata-0.19.0 → usdata-0.20.0}/src/usdata/cache.py +0 -0
  29. {usdata-0.19.0 → usdata-0.20.0}/src/usdata/cache_ops.py +0 -0
  30. {usdata-0.19.0 → usdata-0.20.0}/src/usdata/cite.py +0 -0
  31. {usdata-0.19.0 → usdata-0.20.0}/src/usdata/cli/__init__.py +0 -0
  32. {usdata-0.19.0 → usdata-0.20.0}/src/usdata/cli/app.py +0 -0
  33. {usdata-0.19.0 → usdata-0.20.0}/src/usdata/cli/cache.py +0 -0
  34. {usdata-0.19.0 → usdata-0.20.0}/src/usdata/cli/cite.py +0 -0
  35. {usdata-0.19.0 → usdata-0.20.0}/src/usdata/cli/doctor.py +0 -0
  36. {usdata-0.19.0 → usdata-0.20.0}/src/usdata/cli/progress.py +0 -0
  37. {usdata-0.19.0 → usdata-0.20.0}/src/usdata/data/nexrad_sites.csv +0 -0
  38. {usdata-0.19.0 → usdata-0.20.0}/src/usdata/data/places.csv +0 -0
  39. {usdata-0.19.0 → usdata-0.20.0}/src/usdata/data/places.sources.json +0 -0
  40. {usdata-0.19.0 → usdata-0.20.0}/src/usdata/doctor.py +0 -0
  41. {usdata-0.19.0 → usdata-0.20.0}/src/usdata/manifest.py +0 -0
  42. {usdata-0.19.0 → usdata-0.20.0}/src/usdata/mirror.py +0 -0
  43. {usdata-0.19.0 → usdata-0.20.0}/src/usdata/models.py +0 -0
  44. {usdata-0.19.0 → usdata-0.20.0}/src/usdata/protocols/__init__.py +0 -0
  45. {usdata-0.19.0 → usdata-0.20.0}/src/usdata/protocols/erddap.py +0 -0
  46. {usdata-0.19.0 → usdata-0.20.0}/src/usdata/protocols/s3.py +0 -0
  47. {usdata-0.19.0 → usdata-0.20.0}/src/usdata/provenance.py +0 -0
  48. {usdata-0.19.0 → usdata-0.20.0}/src/usdata/providers/__init__.py +0 -0
  49. {usdata-0.19.0 → usdata-0.20.0}/src/usdata/providers/http.py +0 -0
  50. {usdata-0.19.0 → usdata-0.20.0}/src/usdata/providers/noaa/__init__.py +0 -0
  51. {usdata-0.19.0 → usdata-0.20.0}/src/usdata/providers/noaa/coastwatch.py +0 -0
  52. {usdata-0.19.0 → usdata-0.20.0}/src/usdata/providers/noaa/coops.py +0 -0
  53. {usdata-0.19.0 → usdata-0.20.0}/src/usdata/providers/noaa/gfs.py +0 -0
  54. {usdata-0.19.0 → usdata-0.20.0}/src/usdata/providers/noaa/ghcnd.py +0 -0
  55. {usdata-0.19.0 → usdata-0.20.0}/src/usdata/providers/noaa/glm.py +0 -0
  56. {usdata-0.19.0 → usdata-0.20.0}/src/usdata/providers/noaa/goes.py +0 -0
  57. {usdata-0.19.0 → usdata-0.20.0}/src/usdata/providers/noaa/gsom.py +0 -0
  58. {usdata-0.19.0 → usdata-0.20.0}/src/usdata/providers/noaa/gsoy.py +0 -0
  59. {usdata-0.19.0 → usdata-0.20.0}/src/usdata/providers/noaa/hurdat2.py +0 -0
  60. {usdata-0.19.0 → usdata-0.20.0}/src/usdata/providers/noaa/lcd.py +0 -0
  61. {usdata-0.19.0 → usdata-0.20.0}/src/usdata/providers/noaa/mrms.py +0 -0
  62. {usdata-0.19.0 → usdata-0.20.0}/src/usdata/providers/noaa/nexrad.py +0 -0
  63. {usdata-0.19.0 → usdata-0.20.0}/src/usdata/providers/noaa/nexrad_level3.py +0 -0
  64. {usdata-0.19.0 → usdata-0.20.0}/src/usdata/providers/noaa/normals.py +0 -0
  65. {usdata-0.19.0 → usdata-0.20.0}/src/usdata/providers/noaa/sites.py +0 -0
  66. {usdata-0.19.0 → usdata-0.20.0}/src/usdata/providers/noaa/spc.py +0 -0
  67. {usdata-0.19.0 → usdata-0.20.0}/src/usdata/providers/noaa/storm_events.py +0 -0
  68. {usdata-0.19.0 → usdata-0.20.0}/src/usdata/providers/params.py +0 -0
  69. {usdata-0.19.0 → usdata-0.20.0}/src/usdata/providers/usgs/__init__.py +0 -0
  70. {usdata-0.19.0 → usdata-0.20.0}/src/usdata/providers/usgs/daily.py +0 -0
  71. {usdata-0.19.0 → usdata-0.20.0}/src/usdata/providers/usgs/earthquakes.py +0 -0
  72. {usdata-0.19.0 → usdata-0.20.0}/src/usdata/py.typed +0 -0
  73. {usdata-0.19.0 → usdata-0.20.0}/src/usdata/query.py +0 -0
  74. {usdata-0.19.0 → usdata-0.20.0}/src/usdata/registry.py +0 -0
  75. {usdata-0.19.0 → usdata-0.20.0}/src/usdata/selection.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: usdata
3
- Version: 0.19.0
3
+ Version: 0.20.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,open-data,scientific-data,provenance,reproducible-research,weather,climate,meteorology
6
6
  Author: Jake Van Slyke
@@ -93,7 +93,7 @@ breaking changes.
93
93
  | [docs.usdata.dev](https://docs.usdata.dev/) | How to use it: [install](https://docs.usdata.dev/install/), [getting started](https://docs.usdata.dev/getting-started/), guides, dataset notes, and reference |
94
94
  | [Severe-weather case study](https://usdata.dev/examples/severe-weather-case-study/) | One tornado, six sources, one manifest and lockfile, ending in a citation |
95
95
 
96
- Twenty datasets are available today and twenty-five more are planned, grouped
96
+ Twenty-three datasets are available today and twenty-one more are planned, grouped
97
97
  by agency and product family in the [catalog](docs/providers/README.md).
98
98
 
99
99
  ## How this compares
@@ -48,7 +48,7 @@ breaking changes.
48
48
  | [docs.usdata.dev](https://docs.usdata.dev/) | How to use it: [install](https://docs.usdata.dev/install/), [getting started](https://docs.usdata.dev/getting-started/), guides, dataset notes, and reference |
49
49
  | [Severe-weather case study](https://usdata.dev/examples/severe-weather-case-study/) | One tornado, six sources, one manifest and lockfile, ending in a citation |
50
50
 
51
- Twenty datasets are available today and twenty-five more are planned, grouped
51
+ Twenty-three datasets are available today and twenty-one more are planned, grouped
52
52
  by agency and product family in the [catalog](docs/providers/README.md).
53
53
 
54
54
  ## How this compares
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "usdata"
3
- version = "0.19.0"
3
+ version = "0.20.0"
4
4
  description = "Unified Python SDK and CLI for discovering, fetching, and tracking provenance of U.S. public scientific data"
5
5
  readme = "README.md"
6
6
  license = "Apache-2.0"
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "usdata"
3
- version = "0.19.0"
3
+ version = "0.20.0"
4
4
  description = "Unified Python SDK and CLI for discovering, fetching, and tracking provenance of U.S. public scientific data"
5
5
  readme = "README.md"
6
6
  license = "Apache-2.0"
@@ -130,6 +130,8 @@ def _fetch_asset(
130
130
  and prov.provider == dataset.provider
131
131
  and prov.source_url == asset.href
132
132
  and prov.size == path.stat().st_size
133
+ # A listing that now reports another size describes a rebuilt file.
134
+ and (asset.size is None or prov.size == asset.size)
133
135
  and (asset.checksum is None or prov.checksum == asset.checksum)
134
136
  # Trust an untouched cached file; hash whenever anything is unclear.
135
137
  and (_sidecar_not_older(path) or sha256_file(path) == prov.checksum)
@@ -138,7 +140,10 @@ def _fetch_asset(
138
140
  return FetchedAsset(asset=asset, path=path, provenance=prov, from_cache=True)
139
141
  with staged_path(path) as tmp:
140
142
  partial = adapter.prepare_fetch(asset, pinned)
141
- adapter.fetch(asset, tmp)
143
+ if partial is None:
144
+ adapter.fetch(asset, tmp)
145
+ else:
146
+ adapter.fetch_partial(asset, tmp, partial)
142
147
  prov = provenance.record(dataset, asset, tmp, partial)
143
148
  if asset.checksum and prov.checksum != asset.checksum:
144
149
  raise ChecksumMismatch(f"{asset.id}: expected {asset.checksum}, got {prov.checksum}")
@@ -81,6 +81,9 @@ def _modules() -> tuple[Any, Any, Any]:
81
81
  except (RuntimeError, OSError) as error:
82
82
  # eccodes imports, then findlibs fails to locate the shared library.
83
83
  raise MissingReaderDependency(f"{LIBRARY_HINT} ({error})") from error
84
+ # A GRIB2 message may hold several fields (RAP packs wind components that way);
85
+ # without this ecCodes yields only the first and silently drops the rest.
86
+ eccodes.codes_grib_multi_support_on()
84
87
  return eccodes, xarray, numpy
85
88
 
86
89
 
@@ -92,9 +95,15 @@ def _handle(eccodes: Any, handle: int) -> Iterator[int]:
92
95
  eccodes.codes_release(handle)
93
96
 
94
97
 
95
- def _messages_from_bytes(eccodes: Any, data: bytes) -> Iterator[int]:
96
- """Handles for each message in an in-memory GRIB byte string, using section 0 lengths."""
98
+ def _messages_from_bytes(eccodes: Any, data: bytes) -> Iterator[tuple[int, int]]:
99
+ """Message index and handle for each field in an in-memory GRIB byte string.
100
+
101
+ Messages are walked by their section 0 lengths; an in-memory message yields
102
+ its first field only, which is every field of the single-field products
103
+ that arrive compressed.
104
+ """
97
105
  offset = 0
106
+ index = 0
98
107
  while offset < len(data):
99
108
  if data[offset : offset + 4] != b"GRIB":
100
109
  raise ValueError(f"not a GRIB message at byte {offset}")
@@ -104,12 +113,18 @@ def _messages_from_bytes(eccodes: Any, data: bytes) -> Iterator[int]:
104
113
  if length < 16 or offset + length > len(data):
105
114
  raise ValueError(f"truncated GRIB message at byte {offset}")
106
115
  with _handle(eccodes, eccodes.codes_new_from_message(data[offset : offset + length])) as h:
107
- yield h
116
+ yield index, h
108
117
  offset += length
118
+ index += 1
109
119
 
110
120
 
111
- def _messages(eccodes: Any, path: Path) -> Iterator[int]:
112
- """Handles for each message, decompressing gzip in memory without changing cached bytes."""
121
+ def _messages(eccodes: Any, path: Path) -> Iterator[tuple[int, int]]:
122
+ """Message index and handle for each field, decompressing gzip in memory.
123
+
124
+ The index counts GRIB2 messages from zero, so the fields of one message
125
+ that holds several share it; that is the number a partial fetch's record
126
+ pairs with, and the ``file_index`` the reader and ``inspect`` report.
127
+ """
113
128
  with path.open("rb") as probe:
114
129
  compressed = probe.read(2) == b"\x1f\x8b"
115
130
  if compressed:
@@ -117,12 +132,18 @@ def _messages(eccodes: Any, path: Path) -> Iterator[int]:
117
132
  yield from _messages_from_bytes(eccodes, data)
118
133
  return
119
134
  # ecCodes reads through the descriptor, so it gets a file object it alone positions.
135
+ index = -1
136
+ last_offset: int | None = None
120
137
  with path.open("rb") as raw:
121
138
  while (handle := eccodes.codes_grib_new_from_file(raw)) is not None:
122
139
  with _handle(eccodes, handle) as h:
123
140
  if eccodes.codes_get(h, "edition") != 2:
124
141
  raise ValueError("only GRIB edition 2 is supported")
125
- yield h
142
+ offset = _get(eccodes, h, "offset", int)
143
+ if offset is None or offset != last_offset:
144
+ index += 1
145
+ last_offset = offset
146
+ yield index, h
126
147
 
127
148
 
128
149
  def _get(eccodes: Any, h: int, key: str, ktype: type | None = None) -> Any:
@@ -203,7 +224,7 @@ def inventory(path: Path) -> list[GribMessage]:
203
224
  """Every message in a local GRIB2 file, in file order, without decoding its values."""
204
225
  eccodes = _modules()[0]
205
226
  messages = []
206
- for index, h in enumerate(_messages(eccodes, path)):
227
+ for index, h in _messages(eccodes, path):
207
228
  keys = {key: _get(eccodes, h, key, str) for key in INVENTORY_KEYS}
208
229
  messages.append(
209
230
  GribMessage(
@@ -373,7 +394,7 @@ def open_grib2(
373
394
  needs_select = select is None and not fetched.provenance.is_partial
374
395
  object_messages = fetched.provenance.object_messages
375
396
  selectors = fetched.provenance.selectors if object_messages else []
376
- for h in _messages(eccodes, fetched.path):
397
+ for index, h in _messages(eccodes, fetched.path):
377
398
  count += 1
378
399
  if count > 1 and needs_select:
379
400
  continue
@@ -415,15 +436,15 @@ def open_grib2(
415
436
  if stamp:
416
437
  attrs[label] = stamp
417
438
  message: dict[str, Any] = {
418
- "file_index": count - 1,
419
- "object_index": object_messages[count - 1] if object_messages else None,
439
+ "file_index": index,
440
+ "object_index": object_messages[index] if object_messages else None,
420
441
  "shortName": _get(eccodes, h, "shortName", str),
421
442
  "typeOfLevel": attrs.get("typeOfLevel"),
422
443
  "level": attrs.get("level"),
423
444
  "step": attrs.get("step"),
424
445
  }
425
446
  if selectors:
426
- message["selector"] = selectors[count - 1]
447
+ message["selector"] = selectors[index]
427
448
  short = message["shortName"]
428
449
  if short in (None, "", "unknown", "~"):
429
450
  short = _product_name(fetched.asset.id) or (
@@ -94,7 +94,8 @@ def _echo_detail(summary: Summary, width: int) -> None:
94
94
  else:
95
95
  typer.echo(" columns:")
96
96
  for name in frame.columns:
97
- typer.echo(f" {name}")
97
+ unit = frame.units.get(name)
98
+ typer.echo(f" {name} ({unit})" if unit else f" {name}")
98
99
  if (scene := summary.netcdf) is not None:
99
100
  _echo_table(
100
101
  "variables",
@@ -473,21 +473,77 @@ datasets:
473
473
 
474
474
  - id: noaa:ibtracs
475
475
  provider: noaa
476
- status: planned
476
+ status: available
477
477
  domain: tropical-cyclones
478
- target: later
478
+ since: "0.20"
479
479
  title: IBTrACS Global Tropical Cyclone Tracks
480
480
  description: >-
481
- International Best Track Archive for Climate Stewardship: merged best
482
- tracks from all agencies worldwide since 1842. CSV and NetCDF files by
483
- basin or period from an NCEI HTTPS directory (v04r01).
481
+ International Best Track Archive for Climate Stewardship: every agency's
482
+ tropical cyclone best tracks merged into one global record since 1842,
483
+ published by NCEI as whole files under an anonymous HTTPS directory. A
484
+ subset parameter names one of the eleven files NCEI builds (the complete
485
+ record, active storms, the last three years, everything since 1980, or
486
+ one of seven basins), a format parameter picks the CSV list or the
487
+ NetCDF file, and the newest product version wins unless one is pinned.
488
+ There is no query interface, so dates and geographic filters are
489
+ rejected; the files are rebuilt in place under unchanging names, so a
490
+ lockfile's checksum is what pins the bytes. The CSV opens with the units-row reader
491
+ and the NetCDF with xarray; filter the table locally.
484
492
  keywords: [tropical cyclone, hurricane, typhoon, best track, ibtracs, global]
485
493
  protocol: http
486
494
  homepage: https://www.ncei.noaa.gov/products/international-best-track-archive
487
495
  license: US Government Work (public domain)
488
- temporal_extent: { start: "1842-01-01T00:00:00Z" }
496
+ temporal_extent: { start: "1842-10-25T03:00:00Z" }
489
497
  capabilities: { spatial_subset: false, temporal_subset: false, variable_subset: false }
490
-
498
+ summary: Global tropical cyclone best tracks
499
+ formats: [CSV with a units row, NetCDF4]
500
+ selection: One whole subset file per query, from the newest or a pinned product version
501
+ inputs: Required subset; optional format and version; no dates or geographic filters
502
+ reader: pandas
503
+ guide: docs/providers/noaa-ibtracs.md
504
+ examples:
505
+ - examples/ibtracs/README.md
506
+ resolution:
507
+ spatial: "Positions to a tenth of a degree"
508
+ temporal: "Three-hourly points, interpolated between the agencies' six-hourly best tracks"
509
+ update_frequency: >-
510
+ Files are rebuilt in place as agencies deliver best tracks, stamped with the build
511
+ date; the v04r01 change log records twenty-three additions and corrections between
512
+ 2024-06-21 and 2026-07-31
513
+ citation: >-
514
+ Gahtan, J., K. R. Knapp, C. J. Schreck, H. J. Diamond, J. P. Kossin, and M. C. Kruk, 2024:
515
+ International Best Track Archive for Climate Stewardship (IBTrACS) Project, Version 4r01.
516
+ NOAA National Centers for Environmental Information, doi:10.25921/82ty-9e16. Knapp, K. R.,
517
+ M. C. Kruk, D. H. Levinson, H. J. Diamond, and C. J. Neumann, 2010: The International Best
518
+ Track Archive for Climate Stewardship (IBTrACS). Bull. Amer. Meteor. Soc., 91, 363-376
519
+ terms: https://www.ncei.noaa.gov/products/international-best-track-archive
520
+ variables:
521
+ - { name: "sid", description: "IBTrACS serial id, such as 2004086S29318; SID in the CSV" }
522
+ - { name: "season", units: "year", description: "Season the storm is assigned to" }
523
+ - { name: "number", description: "Storm number within the season" }
524
+ - { name: "basin", description: "Basin at this point: NA, EP, WP, NI, SI, SP, or SA" }
525
+ - { name: "subbasin", description: "Sub-basin at this point, or MM when none applies" }
526
+ - { name: "name", description: "Storm name, or UNNAMED" }
527
+ - { name: "time", units: "UTC", description: "Track-point time; ISO_TIME in the CSV" }
528
+ - { name: "nature", description: "Nature of the cyclone: TS, ET, SS, DS, NR, or MX" }
529
+ - { name: "lat", units: "degrees_north", description: "Track-point latitude" }
530
+ - { name: "lon", units: "degrees_east", description: "Track-point longitude in [-180, 180]" }
531
+ - { name: "wmo_wind", units: "knots", description: "Maximum sustained wind from the responsible WMO agency, in that agency's averaging period" }
532
+ - { name: "wmo_pres", units: "millibars", description: "Minimum central pressure from the responsible WMO agency" }
533
+ - { name: "wmo_agency", description: "The WMO agency responsible for the storm at this point" }
534
+ - { name: "track_type", description: "main, PROVISIONAL, or a spur track merged, split, or otherwise set aside" }
535
+ - { name: "dist2land", units: "km", description: "Distance to land at this point" }
536
+ - { name: "landfall", units: "km", description: "Minimum distance to land before the next point; 0 marks a landfall" }
537
+ - { name: "iflag", description: "One interpolation flag per agency: original, interpolated, or filled" }
538
+ - { name: "usa_wind", units: "knots", description: "Maximum sustained 1-minute wind from the U.S. agency" }
539
+ - { name: "usa_pres", units: "millibars", description: "Minimum central pressure from the U.S. agency" }
540
+ - { name: "usa_status", description: "U.S. agency status, such as TD, TS, HU, or EX" }
541
+ - { name: "usa_sshs", description: "Saffir-Simpson category from the U.S. wind; -5 to 5, with the negatives for non-hurricane statuses" }
542
+ - { name: "usa_rmw", units: "nautical miles", description: "Radius of maximum wind from the U.S. agency" }
543
+ - { name: "usa_r34", units: "nautical miles", description: "34 kt wind extent by quadrant; USA_R34_NE through USA_R34_NW in the CSV" }
544
+ - { name: "storm_speed", units: "knots", description: "Storm translation speed" }
545
+ - { name: "storm_dir", units: "degrees", description: "Storm translation direction" }
546
+ adapter: usdata.providers.noaa.ibtracs:Ibtracs
491
547
  - id: noaa:coops-water-levels
492
548
  provider: noaa
493
549
  status: available
@@ -946,19 +1002,50 @@ datasets:
946
1002
 
947
1003
  - id: noaa:nbm
948
1004
  provider: noaa
949
- status: planned
1005
+ status: available
1006
+ system: noaa:ncep-models
950
1007
  domain: weather-models
951
- target: later
1008
+ since: "0.20"
952
1009
  title: National Blend of Models
953
1010
  description: >-
954
- Statistically blended forecast guidance in GRIB2 from the public
955
- noaa-nbm-grib2-pds S3 bucket, laid out as blend.YYYYMMDD/HH/core/ with
956
- files per region and forecast hour.
957
- keywords: [forecast, blend, nbm, grib2, guidance]
1011
+ NOAA's statistically blended, bias-corrected forecast guidance in GRIB2 from the anonymous
1012
+ noaa-nbm-grib2-pds S3 bucket, one whole core file per hourly run, forecast hour, and
1013
+ region. Select a run by initialization window and cycle hour, the forecast hours from 1 to
1014
+ 264, and the CONUS, Alaska, Hawaii, Puerto Rico, or Guam grid. The CONUS file is about
1015
+ 170 MB per hour; the server subsets nothing, so fields are chosen either by naming GRIB2
1016
+ messages, which fetches their byte ranges through the object's index sidecar, or after
1017
+ download with the GRIB2 reader. Each file holds hundreds of fields; the catalog lists the
1018
+ handful the guide and example select.
1019
+ keywords: [forecast, blend, nbm, grib2, guidance, temperature, precipitation, nwp]
958
1020
  protocol: s3
959
1021
  homepage: https://registry.opendata.aws/noaa-nbm/
960
1022
  license: US Government Work (public domain)
961
- capabilities: { spatial_subset: false, temporal_subset: true, variable_subset: false }
1023
+ temporal_extent: { start: "2020-09-29T12:00:00Z" }
1024
+ capabilities: { spatial_subset: false, temporal_subset: true, variable_subset: false, partial_fetch: true }
1025
+ summary: NBM forecast guidance
1026
+ formats: [GRIB2]
1027
+ selection: Regional core files, whole or by named GRIB2 message, chosen by run initialization window, cycle hour, forecast hours, and region
1028
+ inputs: Both timestamps, cycle, and forecast_hour
1029
+ reader: grib
1030
+ guide: docs/providers/noaa-nbm.md
1031
+ examples:
1032
+ - examples/nbm-forecast/README.md
1033
+ resolution:
1034
+ spatial: "2.5 km Lambert conformal CONUS grid, 2345 x 1597 points; separate Alaska, Hawaii, Puerto Rico, and Guam grids"
1035
+ temporal: >-
1036
+ Hourly runs; forecast hours hourly to 36, then every 3 hours to about 190, then every
1037
+ 6 hours to 264, on a schedule that varies by cycle
1038
+ update_frequency: "Hourly"
1039
+ citation: >-
1040
+ NOAA National Blend of Models (NBM) was accessed on [date] from
1041
+ https://registry.opendata.aws/noaa-nbm
1042
+ terms: https://www.noaa.gov/information-technology/open-data-dissemination
1043
+ variables:
1044
+ - { name: "2t", units: "K", description: "2 m temperature" }
1045
+ - { name: "2d", units: "K", description: "2 m dewpoint temperature" }
1046
+ - { name: "tp", units: "kg/m2", description: "Total precipitation accumulated over the step" }
1047
+ limits: { max_window: P1D }
1048
+ adapter: usdata.providers.noaa.nbm:Nbm
962
1049
 
963
1050
  - id: noaa:oisst
964
1051
  provider: noaa
@@ -1378,33 +1465,52 @@ datasets:
1378
1465
  # Documentation composition keyed by stable dataset ID; validated by just docs.
1379
1466
  - id: noaa:rap
1380
1467
  provider: noaa
1381
- status: planned
1468
+ status: available
1469
+ system: noaa:ncep-models
1382
1470
  domain: weather-models
1383
- target: later
1471
+ since: "0.20"
1384
1472
  title: RAP Model Output
1385
1473
  description: >-
1386
- Rapid Refresh 13 km hourly analyses and forecasts in GRIB2 from the public
1387
- noaa-rap-pds S3 bucket, laid out as rap.YYYYMMDD/ with one file per cycle,
1388
- forecast hour, and grid family (awp130 is the 13 km CONUS grid, about 18 MB
1389
- per analysis). The bucket begins in December 2020; it is the environmental
1390
- source for years HRRR's archive does not reach only where NCEI holds older
1391
- runs. Same cycle and forecast-hour selection as HRRR.
1392
- keywords:
1393
- - forecast
1394
- - model
1395
- - rap
1396
- - grib2
1397
- - weather
1398
- - nwp
1474
+ Rapid Refresh 13 km hourly analyses and forecasts in GRIB2 from the anonymous noaa-rap-pds
1475
+ S3 bucket, one whole file per run, forecast hour, and grid family. Select a run by
1476
+ initialization window and cycle hour, the forecast hours, and the file family: the 13 km
1477
+ CONUS pressure-level grid (about 18 MB per file), its secondary fields, or the native-grid
1478
+ pressure and hybrid-level files. Runs reach 21 hours, or 51 from the 03, 09, 15, and 21
1479
+ UTC runs. The server subsets nothing, so fields are chosen either by naming GRIB2 messages,
1480
+ which fetches their byte ranges through the object's index sidecar, or after download with
1481
+ the GRIB2 reader. Each file holds hundreds of fields; the catalog lists the handful the
1482
+ guide and example select.
1483
+ keywords: [forecast, model, rap, grib2, weather, nwp, cape, helicity, convective]
1399
1484
  protocol: s3
1400
1485
  homepage: https://registry.opendata.aws/noaa-rap/
1401
1486
  license: US Government Work (public domain)
1402
- temporal_extent:
1403
- start: "2020-12-01T00:00:00Z"
1404
- capabilities:
1405
- spatial_subset: false
1406
- temporal_subset: true
1407
- variable_subset: false
1487
+ temporal_extent: { start: "2021-02-22T00:00:00Z" }
1488
+ capabilities: { spatial_subset: false, temporal_subset: true, variable_subset: false, partial_fetch: true }
1489
+ summary: RAP model output
1490
+ formats: [GRIB2]
1491
+ selection: Files, whole or by named GRIB2 message, chosen by run initialization window, cycle hour, forecast hours, and file family
1492
+ inputs: Both timestamps, cycle, and forecast_hour
1493
+ reader: grib
1494
+ guide: docs/providers/noaa-rap.md
1495
+ examples:
1496
+ - examples/rap-environment/README.md
1497
+ resolution:
1498
+ spatial: "13 km Lambert conformal CONUS grid, 451 x 337 points, for the awp130 family"
1499
+ temporal: >-
1500
+ Hourly runs; forecast hours to 21, or to 51 from the 03, 09, 15, and 21 UTC runs
1501
+ update_frequency: "Hourly"
1502
+ citation: >-
1503
+ NOAA Rapid Refresh (RAP) was accessed on [date] from https://registry.opendata.aws/noaa-rap
1504
+ terms: https://www.noaa.gov/information-technology/open-data-dissemination
1505
+ variables:
1506
+ - { name: "cape", units: "J/kg", description: "Convective available potential energy, surface or a ground layer" }
1507
+ - { name: "cin", units: "J/kg", description: "Convective inhibition, surface or a ground layer" }
1508
+ - { name: "hlcy", units: "m2/s2", description: "Storm-relative helicity over the 0-1 km and 0-3 km layers" }
1509
+ - { name: "refc", units: "dBZ", description: "Composite reflectivity" }
1510
+ - { name: "2t", units: "K", description: "2 m temperature" }
1511
+ - { name: "2d", units: "K", description: "2 m dewpoint temperature" }
1512
+ limits: { max_window: P1D }
1513
+ adapter: usdata.providers.noaa.rap:Rap
1408
1514
 
1409
1515
  - id: noaa:igra
1410
1516
  provider: noaa
@@ -21,7 +21,7 @@ from typing import TYPE_CHECKING
21
21
  from pydantic import BaseModel, Field
22
22
 
23
23
  from usdata import provenance, readers
24
- from usdata.models import Provenance
24
+ from usdata.models import Protocol, Provenance
25
25
 
26
26
  if TYPE_CHECKING:
27
27
  from usdata._fetch import FetchedAsset
@@ -47,7 +47,16 @@ class CsvSummary(BaseModel):
47
47
  """A delimited text file's header and how many data rows the scan counted."""
48
48
 
49
49
  columns: list[str] = Field(description="Header fields, in file order")
50
- row_count: int = Field(ge=0, description="Data rows counted, excluding the header")
50
+ units: dict[str, str] = Field(
51
+ default_factory=dict,
52
+ description=(
53
+ "Units per column, from the units row an ERDDAP or IBTrACS CSV lays under its "
54
+ "header; empty for a CSV with no such row"
55
+ ),
56
+ )
57
+ row_count: int = Field(
58
+ ge=0, description="Data rows counted, excluding the header and any units row"
59
+ )
51
60
  row_limit: int = Field(gt=0, description="Rows the scan reads before it stops")
52
61
  truncated: bool = Field(description="The scan stopped early, so row_count is a lower bound")
53
62
 
@@ -77,7 +86,10 @@ class GribMessage(BaseModel):
77
86
  ``selector``, the index selector the message was fetched for.
78
87
  """
79
88
 
80
- file_index: int = Field(ge=0, description="Zero-based position in the local file")
89
+ file_index: int = Field(
90
+ ge=0,
91
+ description="Zero-based message position in the local file; fields of one message share it",
92
+ )
81
93
  object_index: int | None = Field(
82
94
  default=None,
83
95
  ge=1,
@@ -186,6 +198,7 @@ def inspect_asset(fetched: FetchedAsset) -> Summary:
186
198
  fetched.asset.id,
187
199
  fetched.provenance,
188
200
  _detect(fetched.asset.id, fetched.asset.media_type),
201
+ fetched.asset.protocol,
189
202
  )
190
203
 
191
204
 
@@ -198,23 +211,39 @@ def inspect_path(path: str | os.PathLike[str]) -> Summary:
198
211
 
199
212
  Returns:
200
213
  The summary ``inspect_asset`` builds, with the format taken from the file
201
- name because a sidecar records no media type.
214
+ name because a sidecar records no media type, and the protocol from the
215
+ registry entry of the dataset the sidecar names, for the same reason.
202
216
 
203
217
  Raises:
204
218
  OSError: The file or its provenance sidecar is missing or unreadable.
205
219
  ValueError: The sidecar is not a provenance record.
206
220
  """
207
221
  local = Path(path)
208
- return _summarize(local, local.name, provenance.read(local), _detect(local.name, None))
222
+ record = provenance.read(local)
223
+ return _summarize(
224
+ local, local.name, record, _detect(local.name, None), _registered_protocol(record)
225
+ )
226
+
227
+
228
+ def _registered_protocol(record: Provenance) -> Protocol | None:
229
+ """The protocol the registry gives the dataset a sidecar names, or None when it has none."""
230
+ from usdata.registry import DatasetNotFound, default_registry
231
+
232
+ try:
233
+ return default_registry().get(record.dataset_id).protocol
234
+ except DatasetNotFound:
235
+ return None
209
236
 
210
237
 
211
- def _summarize(path: Path, asset_id: str, record: Provenance, fmt: AssetFormat) -> Summary:
238
+ def _summarize(
239
+ path: Path, asset_id: str, record: Provenance, fmt: AssetFormat, protocol: Protocol | None
240
+ ) -> Summary:
212
241
  """One summary, with the detail its format allows and a note where it allows none."""
213
242
  detail: CsvSummary | NetcdfSummary | Grib2Summary | None = None
214
243
  note: str | None = None
215
244
  if fmt is not AssetFormat.BYTES:
216
245
  try:
217
- detail = _detail(path, fmt, record)
246
+ detail = _detail(path, fmt, record, protocol)
218
247
  except readers.MissingReaderDependency as error:
219
248
  note = str(error)
220
249
  # A cached file that no longer decodes is reported, not raised: a summary
@@ -259,11 +288,11 @@ def _detect(name: str, media_type: str | None) -> AssetFormat:
259
288
 
260
289
 
261
290
  def _detail(
262
- path: Path, fmt: AssetFormat, record: Provenance
291
+ path: Path, fmt: AssetFormat, record: Provenance, protocol: Protocol | None
263
292
  ) -> CsvSummary | NetcdfSummary | Grib2Summary:
264
293
  """The detail for one recognized format, reading only what that format needs."""
265
294
  if fmt is AssetFormat.CSV:
266
- return _csv_summary(path)
295
+ return _csv_summary(path, units_row=readers.has_units_row(record.dataset_id, protocol))
267
296
  if fmt is AssetFormat.NETCDF:
268
297
  from usdata._netcdf import variables
269
298
 
@@ -275,23 +304,35 @@ def _paired(messages: list[GribMessage], record: Provenance) -> list[GribMessage
275
304
  """Each message as the fetch that took it described it, where provenance says.
276
305
 
277
306
  The file itself carries neither number nor selector: a partial fetch recorded
278
- one message number and one selector per range, so the lists pair up in order.
279
- A whole file, or a record that does not pair, leaves both unset.
307
+ one message number and one selector per range, and ``file_index`` counts the
308
+ file's messages the same way, so the fields of a message that holds several
309
+ all pair with its number. A whole file, or a record that does not pair,
310
+ leaves both unset.
280
311
  """
281
312
  numbers = record.object_messages
282
- if len(numbers) != len(messages):
313
+ if not numbers or any(message.file_index >= len(numbers) for message in messages):
283
314
  return messages
284
- selectors = (
285
- record.selectors if len(record.selectors) == len(messages) else [None] * len(messages)
286
- )
315
+ selectors = record.selectors if len(record.selectors) == len(numbers) else []
287
316
  return [
288
- message.model_copy(update={"object_index": number, "selector": selector})
289
- for message, number, selector in zip(messages, numbers, selectors, strict=True)
317
+ message.model_copy(
318
+ update={
319
+ "object_index": numbers[message.file_index],
320
+ "selector": selectors[message.file_index] if selectors else None,
321
+ }
322
+ )
323
+ for message in messages
290
324
  ]
291
325
 
292
326
 
293
- def _csv_summary(path: Path) -> CsvSummary:
294
- """Header and data-row count through the standard library, stopping at ``ROW_LIMIT`` rows."""
327
+ def _csv_summary(path: Path, *, units_row: bool = False) -> CsvSummary:
328
+ """Header and data-row count through the standard library, stopping at ``ROW_LIMIT`` rows.
329
+
330
+ With ``units_row`` the row under the header is read as units, as ``open``
331
+ reads it, rather than counted as data.
332
+
333
+ Raises:
334
+ ValueError: A units row was expected and does not match the header.
335
+ """
295
336
  with path.open("rb") as raw:
296
337
  compressed = raw.read(2) == b"\x1f\x8b"
297
338
  raw.seek(0)
@@ -299,13 +340,21 @@ def _csv_summary(path: Path) -> CsvSummary:
299
340
  with io.TextIOWrapper(binary, encoding="utf-8-sig", newline="") as stream:
300
341
  records = csv.reader(stream)
301
342
  columns = next(records, [])
343
+ units: dict[str, str] = {}
344
+ if units_row and columns:
345
+ values = next(records, [])
346
+ if len(values) != len(columns):
347
+ raise ValueError("CSV must have a units row matching the header")
348
+ units = dict(zip(columns, (value.strip() for value in values), strict=True))
302
349
  rows = 0
303
350
  for _ in records:
304
351
  rows += 1
305
352
  if rows == ROW_LIMIT:
306
353
  break
307
354
  truncated = next(records, None) is not None
308
- return CsvSummary(columns=columns, row_count=rows, row_limit=ROW_LIMIT, truncated=truncated)
355
+ return CsvSummary(
356
+ columns=columns, units=units, row_count=rows, row_limit=ROW_LIMIT, truncated=truncated
357
+ )
309
358
 
310
359
 
311
360
  __all__ = [
@@ -174,7 +174,8 @@ def download_ranges(
174
174
  url: The object's URL; ``s3://`` callers map it with ``s3.object_url`` first.
175
175
  dest: Path the concatenated bytes are moved to once every run has arrived.
176
176
  ranges: Ascending, non-overlapping inclusive byte ranges to fetch.
177
- etag: The object's ETag, sent as ``If-Match`` so a republished object fails.
177
+ etag: The object's ETag, bare or quoted, sent as a quoted ``If-Match`` so a
178
+ republished object fails.
178
179
  total: The object's size, checked against every ``Content-Range``.
179
180
  http: Client to use; one is created and closed here when it is omitted.
180
181
 
@@ -191,7 +192,8 @@ def download_ranges(
191
192
  own = http is None
192
193
  active = http or client()
193
194
  attempt = 0
194
- headers = {"If-Match": etag.strip('"')}
195
+ # An entity-tag is a quoted string (RFC 9110); records keep the bare value.
196
+ headers = {"If-Match": '"' + etag.strip('"') + '"'}
195
197
 
196
198
  def request() -> Path:
197
199
  nonlocal attempt