usdata 0.32.0__tar.gz → 0.33.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 (89) hide show
  1. {usdata-0.32.0 → usdata-0.33.0}/PKG-INFO +2 -2
  2. {usdata-0.32.0 → usdata-0.33.0}/README.md +1 -1
  3. {usdata-0.32.0 → usdata-0.33.0}/pyproject.toml +1 -1
  4. {usdata-0.32.0 → usdata-0.33.0}/pyproject.toml.orig +1 -1
  5. {usdata-0.32.0 → usdata-0.33.0}/src/usdata/__init__.py +3 -0
  6. {usdata-0.32.0 → usdata-0.33.0}/src/usdata/_grib.py +7 -2
  7. {usdata-0.32.0 → usdata-0.33.0}/src/usdata/cli/app.py +92 -25
  8. {usdata-0.32.0 → usdata-0.33.0}/src/usdata/data/registry.yaml +127 -7
  9. {usdata-0.32.0 → usdata-0.33.0}/src/usdata/inspect.py +18 -15
  10. usdata-0.33.0/src/usdata/messages.py +60 -0
  11. {usdata-0.32.0 → usdata-0.33.0}/src/usdata/models.py +67 -0
  12. {usdata-0.32.0 → usdata-0.33.0}/src/usdata/provenance.py +1 -0
  13. {usdata-0.32.0 → usdata-0.33.0}/src/usdata/providers/base.py +31 -1
  14. usdata-0.33.0/src/usdata/providers/noaa/enso.py +76 -0
  15. usdata-0.33.0/src/usdata/providers/noaa/ersst.py +95 -0
  16. {usdata-0.32.0 → usdata-0.33.0}/src/usdata/providers/noaa/grib_index.py +24 -37
  17. usdata-0.33.0/src/usdata/providers/noaa/gsom_files.py +83 -0
  18. {usdata-0.32.0 → usdata-0.33.0}/src/usdata/providers/noaa/hrrr.py +18 -1
  19. {usdata-0.32.0 → usdata-0.33.0}/src/usdata/testing.py +8 -4
  20. {usdata-0.32.0 → usdata-0.33.0}/src/usdata/__main__.py +0 -0
  21. {usdata-0.32.0 → usdata-0.33.0}/src/usdata/_aqs.py +0 -0
  22. {usdata-0.32.0 → usdata-0.33.0}/src/usdata/_census.py +0 -0
  23. {usdata-0.32.0 → usdata-0.33.0}/src/usdata/_fetch.py +0 -0
  24. {usdata-0.32.0 → usdata-0.33.0}/src/usdata/_files.py +0 -0
  25. {usdata-0.32.0 → usdata-0.33.0}/src/usdata/_hurdat2.py +0 -0
  26. {usdata-0.32.0 → usdata-0.33.0}/src/usdata/_netcdf.py +0 -0
  27. {usdata-0.32.0 → usdata-0.33.0}/src/usdata/_progress.py +0 -0
  28. {usdata-0.32.0 → usdata-0.33.0}/src/usdata/_radar.py +0 -0
  29. {usdata-0.32.0 → usdata-0.33.0}/src/usdata/cache.py +0 -0
  30. {usdata-0.32.0 → usdata-0.33.0}/src/usdata/cache_ops.py +0 -0
  31. {usdata-0.32.0 → usdata-0.33.0}/src/usdata/cite.py +0 -0
  32. {usdata-0.32.0 → usdata-0.33.0}/src/usdata/cli/__init__.py +0 -0
  33. {usdata-0.32.0 → usdata-0.33.0}/src/usdata/cli/cache.py +0 -0
  34. {usdata-0.32.0 → usdata-0.33.0}/src/usdata/cli/cite.py +0 -0
  35. {usdata-0.32.0 → usdata-0.33.0}/src/usdata/cli/doctor.py +0 -0
  36. {usdata-0.32.0 → usdata-0.33.0}/src/usdata/cli/inspect.py +0 -0
  37. {usdata-0.32.0 → usdata-0.33.0}/src/usdata/cli/progress.py +0 -0
  38. {usdata-0.32.0 → usdata-0.33.0}/src/usdata/data/nexrad_sites.csv +0 -0
  39. {usdata-0.32.0 → usdata-0.33.0}/src/usdata/data/places.csv +0 -0
  40. {usdata-0.32.0 → usdata-0.33.0}/src/usdata/data/places.sources.json +0 -0
  41. {usdata-0.32.0 → usdata-0.33.0}/src/usdata/doctor.py +0 -0
  42. {usdata-0.32.0 → usdata-0.33.0}/src/usdata/manifest.py +0 -0
  43. {usdata-0.32.0 → usdata-0.33.0}/src/usdata/mirror.py +0 -0
  44. {usdata-0.32.0 → usdata-0.33.0}/src/usdata/protocols/__init__.py +0 -0
  45. {usdata-0.32.0 → usdata-0.33.0}/src/usdata/protocols/erddap.py +0 -0
  46. {usdata-0.32.0 → usdata-0.33.0}/src/usdata/protocols/http.py +0 -0
  47. {usdata-0.32.0 → usdata-0.33.0}/src/usdata/protocols/listing.py +0 -0
  48. {usdata-0.32.0 → usdata-0.33.0}/src/usdata/protocols/s3.py +0 -0
  49. {usdata-0.32.0 → usdata-0.33.0}/src/usdata/providers/__init__.py +0 -0
  50. {usdata-0.32.0 → usdata-0.33.0}/src/usdata/providers/census/__init__.py +0 -0
  51. {usdata-0.32.0 → usdata-0.33.0}/src/usdata/providers/census/acs.py +0 -0
  52. {usdata-0.32.0 → usdata-0.33.0}/src/usdata/providers/credentials.py +0 -0
  53. {usdata-0.32.0 → usdata-0.33.0}/src/usdata/providers/epa/__init__.py +0 -0
  54. {usdata-0.32.0 → usdata-0.33.0}/src/usdata/providers/epa/aqs.py +0 -0
  55. {usdata-0.32.0 → usdata-0.33.0}/src/usdata/providers/fema/__init__.py +0 -0
  56. {usdata-0.32.0 → usdata-0.33.0}/src/usdata/providers/fema/declarations.py +0 -0
  57. {usdata-0.32.0 → usdata-0.33.0}/src/usdata/providers/http.py +0 -0
  58. {usdata-0.32.0 → usdata-0.33.0}/src/usdata/providers/noaa/__init__.py +0 -0
  59. {usdata-0.32.0 → usdata-0.33.0}/src/usdata/providers/noaa/coastwatch.py +0 -0
  60. {usdata-0.32.0 → usdata-0.33.0}/src/usdata/providers/noaa/coops.py +0 -0
  61. {usdata-0.32.0 → usdata-0.33.0}/src/usdata/providers/noaa/gfs.py +0 -0
  62. {usdata-0.32.0 → usdata-0.33.0}/src/usdata/providers/noaa/ghcnd.py +0 -0
  63. {usdata-0.32.0 → usdata-0.33.0}/src/usdata/providers/noaa/glm.py +0 -0
  64. {usdata-0.32.0 → usdata-0.33.0}/src/usdata/providers/noaa/goes.py +0 -0
  65. {usdata-0.32.0 → usdata-0.33.0}/src/usdata/providers/noaa/gsom.py +0 -0
  66. {usdata-0.32.0 → usdata-0.33.0}/src/usdata/providers/noaa/gsoy.py +0 -0
  67. {usdata-0.32.0 → usdata-0.33.0}/src/usdata/providers/noaa/hurdat2.py +0 -0
  68. {usdata-0.32.0 → usdata-0.33.0}/src/usdata/providers/noaa/ibtracs.py +0 -0
  69. {usdata-0.32.0 → usdata-0.33.0}/src/usdata/providers/noaa/lcd.py +0 -0
  70. {usdata-0.32.0 → usdata-0.33.0}/src/usdata/providers/noaa/mrms.py +0 -0
  71. {usdata-0.32.0 → usdata-0.33.0}/src/usdata/providers/noaa/nbm.py +0 -0
  72. {usdata-0.32.0 → usdata-0.33.0}/src/usdata/providers/noaa/nexrad.py +0 -0
  73. {usdata-0.32.0 → usdata-0.33.0}/src/usdata/providers/noaa/nexrad_level3.py +0 -0
  74. {usdata-0.32.0 → usdata-0.33.0}/src/usdata/providers/noaa/normals.py +0 -0
  75. {usdata-0.32.0 → usdata-0.33.0}/src/usdata/providers/noaa/nws_vtec.py +0 -0
  76. {usdata-0.32.0 → usdata-0.33.0}/src/usdata/providers/noaa/rap.py +0 -0
  77. {usdata-0.32.0 → usdata-0.33.0}/src/usdata/providers/noaa/sites.py +0 -0
  78. {usdata-0.32.0 → usdata-0.33.0}/src/usdata/providers/noaa/spc.py +0 -0
  79. {usdata-0.32.0 → usdata-0.33.0}/src/usdata/providers/noaa/storm_events.py +0 -0
  80. {usdata-0.32.0 → usdata-0.33.0}/src/usdata/providers/params.py +0 -0
  81. {usdata-0.32.0 → usdata-0.33.0}/src/usdata/providers/usgs/__init__.py +0 -0
  82. {usdata-0.32.0 → usdata-0.33.0}/src/usdata/providers/usgs/daily.py +0 -0
  83. {usdata-0.32.0 → usdata-0.33.0}/src/usdata/providers/usgs/earthquakes.py +0 -0
  84. {usdata-0.32.0 → usdata-0.33.0}/src/usdata/pull.py +0 -0
  85. {usdata-0.32.0 → usdata-0.33.0}/src/usdata/py.typed +0 -0
  86. {usdata-0.32.0 → usdata-0.33.0}/src/usdata/query.py +0 -0
  87. {usdata-0.32.0 → usdata-0.33.0}/src/usdata/readers.py +0 -0
  88. {usdata-0.32.0 → usdata-0.33.0}/src/usdata/registry.py +0 -0
  89. {usdata-0.32.0 → usdata-0.33.0}/src/usdata/selection.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: usdata
3
- Version: 0.32.0
3
+ Version: 0.33.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
@@ -113,7 +113,7 @@ breaking changes.
113
113
  | [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 |
114
114
  | [Severe-weather case study](https://usdata.dev/studies/severe-weather-case-study/) | One tornado, six sources, one manifest and lockfile, ending in a citation |
115
115
 
116
- Twenty-eight datasets are available today and eighteen more are planned, grouped
116
+ Thirty-one datasets are available today and seventeen more are planned, grouped
117
117
  by agency and product family in the [catalog](docs/providers/README.md).
118
118
 
119
119
  ## How this compares
@@ -68,7 +68,7 @@ breaking changes.
68
68
  | [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 |
69
69
  | [Severe-weather case study](https://usdata.dev/studies/severe-weather-case-study/) | One tornado, six sources, one manifest and lockfile, ending in a citation |
70
70
 
71
- Twenty-eight datasets are available today and eighteen more are planned, grouped
71
+ Thirty-one datasets are available today and seventeen more are planned, grouped
72
72
  by agency and product family in the [catalog](docs/providers/README.md).
73
73
 
74
74
  ## How this compares
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "usdata"
3
- version = "0.32.0"
3
+ version = "0.33.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.32.0"
3
+ version = "0.33.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"
@@ -13,6 +13,7 @@ except PackageNotFoundError: # running from a source tree without an install
13
13
  from usdata._fetch import ChecksumMismatch, FetchedAsset, fetch, fetch_asset
14
14
  from usdata.cite import Citation, cite_dataset, cite_lockfile
15
15
  from usdata.inspect import inspect_asset, inspect_path
16
+ from usdata.messages import MessageListing, list_messages
16
17
  from usdata.models import (
17
18
  Asset,
18
19
  BBox,
@@ -36,6 +37,7 @@ __all__ = [
36
37
  "Dataset",
37
38
  "DatasetNotFound",
38
39
  "FetchedAsset",
40
+ "MessageListing",
39
41
  "Place",
40
42
  "Provenance",
41
43
  "Query",
@@ -55,6 +57,7 @@ __all__ = [
55
57
  "get",
56
58
  "inspect_asset",
57
59
  "inspect_path",
60
+ "list_messages",
58
61
  "pull",
59
62
  "search",
60
63
  "select_by_time",
@@ -96,7 +96,9 @@ def import_eccodes() -> Any:
96
96
  imported later in the same process, both copies free the same memory as the
97
97
  interpreter exits, which aborts it with "double free or corruption" and exit
98
98
  status 134 after the work is done. Loading pyproj's copy first avoids it.
99
- A pyproj that fails to import is left for whoever uses it to report.
99
+ A pyproj that fails to import is left for whoever uses it to report. This is
100
+ a workaround for https://github.com/ecmwf/eckit/issues/354; once an eckitlib
101
+ wheel no longer clashes with pyproj's PROJ, it can go.
100
102
 
101
103
  Raises:
102
104
  ModuleNotFoundError: eccodes is not installed.
@@ -481,8 +483,11 @@ def open_grib2(
481
483
  needs_select = select is None and not fetched.provenance.is_partial
482
484
  object_messages = fetched.provenance.object_messages
483
485
  selectors = fetched.provenance.selectors if object_messages else []
486
+ fields_seen: dict[int, int] = {}
484
487
  for index, h in _messages(eccodes, fetched.path):
485
488
  count += 1
489
+ # Fields of one message share its index; the nth of them is the index's field n+1.
490
+ field = fields_seen[index] = fields_seen.get(index, -1) + 1
486
491
  if count > 1 and needs_select:
487
492
  continue
488
493
  hits = {key: _matched(eccodes, h, key, wanted) for key, wanted in options.items()}
@@ -536,7 +541,7 @@ def open_grib2(
536
541
  "step": attrs.get("step"),
537
542
  }
538
543
  if selectors:
539
- message["selector"] = selectors[index]
544
+ message["selector"] = fetched.provenance.field_selector(index, field)
540
545
  short = base_name(
541
546
  message["shortName"], [attrs.get(key) for key in PARAMETER_KEYS], fetched.asset.id
542
547
  )
@@ -5,7 +5,7 @@ from __future__ import annotations
5
5
  import json
6
6
  from collections.abc import Iterable
7
7
  from pathlib import Path
8
- from typing import Annotated
8
+ from typing import Annotated, Any
9
9
 
10
10
  import httpx
11
11
  import typer
@@ -20,6 +20,7 @@ from usdata.cli.doctor import doctor
20
20
  from usdata.cli.inspect import inspect
21
21
  from usdata.cli.progress import progress
22
22
  from usdata.manifest import lockfile_path
23
+ from usdata.messages import list_messages
23
24
  from usdata.models import READER_EXTRAS_TEXT, Dataset, Status, describe_duration
24
25
  from usdata.providers import adapter_class, load_adapter
25
26
  from usdata.providers.base import NotImplementedProvider
@@ -67,6 +68,33 @@ _QUERY_FLAGS = {
67
68
  }
68
69
 
69
70
 
71
+ def _parse_params(
72
+ param: list[str] | None, command: str, flags: dict[str, str | None]
73
+ ) -> dict[str, Any]:
74
+ """``--param key=value`` pairs as a dict of strings; exit 2 on a malformed or reserved key.
75
+
76
+ ``flags`` maps each reserved query option to the flag ``command`` takes it
77
+ through, or to ``None`` when ``command`` does not take it.
78
+ """
79
+ params: dict[str, Any] = {}
80
+ for item in param or []:
81
+ key, sep, value = item.partition("=")
82
+ if not sep:
83
+ typer.secho(f"--param expects key=value, got {item!r}", err=True, fg="red")
84
+ raise typer.Exit(code=2)
85
+ if key in flags:
86
+ flag = flags[key]
87
+ hint = (
88
+ f"use {flag} instead of --param"
89
+ if flag is not None
90
+ else f"{command} does not support this option"
91
+ )
92
+ typer.secho(f"{key} is a reserved query option; {hint}", err=True, fg="red")
93
+ raise typer.Exit(code=2)
94
+ params[key] = value
95
+ return params
96
+
97
+
70
98
  def _size_phrase(known_bytes: int, unknown: int, *, sources: Iterable[str] = ()) -> str:
71
99
  """How many bytes a dry run would move, said honestly when some size is missing.
72
100
 
@@ -357,26 +385,7 @@ def fetch(
357
385
  ] = False,
358
386
  ) -> None:
359
387
  """Resolve a query against one dataset and download the matching assets."""
360
- params: dict[str, str] = {}
361
- for item in param or []:
362
- key, sep, value = item.partition("=")
363
- if not sep:
364
- typer.secho(f"--param expects key=value, got {item!r}", err=True, fg="red")
365
- raise typer.Exit(code=2)
366
- if key in _QUERY_FLAGS:
367
- flag = _QUERY_FLAGS[key]
368
- hint = (
369
- f"use {flag} instead of --param"
370
- if flag is not None
371
- else "fetch does not support this option"
372
- )
373
- typer.secho(
374
- f"{key} is a reserved query option; {hint}",
375
- err=True,
376
- fg="red",
377
- )
378
- raise typer.Exit(code=2)
379
- params[key] = value
388
+ params = _parse_params(param, "fetch", _QUERY_FLAGS)
380
389
  box = None
381
390
  if bbox:
382
391
  try:
@@ -437,6 +446,64 @@ def fetch(
437
446
  typer.echo(f"{f.path}\t{tag}\t{f.provenance.size} bytes")
438
447
 
439
448
 
449
+ _MESSAGES_FLAGS: dict[str, str | None] = {
450
+ key: flag if key in ("start", "end") else None for key, flag in _QUERY_FLAGS.items()
451
+ }
452
+ """The query options ``messages`` takes: a model run is selected by its window alone."""
453
+
454
+
455
+ @app.command()
456
+ def messages(
457
+ dataset_id: Annotated[str, typer.Argument(help="Dataset id, e.g. noaa:hrrr")],
458
+ start: Annotated[str | None, typer.Option(help="ISO date or datetime.")] = None,
459
+ end: Annotated[
460
+ str | None,
461
+ typer.Option(help="ISO date or datetime; a date alone runs to the end of that UTC day."),
462
+ ] = None,
463
+ param: Annotated[
464
+ list[str] | None,
465
+ typer.Option("--param", "-p", help="Provider-specific key=value, repeatable."),
466
+ ] = None,
467
+ as_json: Annotated[
468
+ bool,
469
+ typer.Option("--json", help="Emit a JSON array of listings and nothing else on stdout."),
470
+ ] = False,
471
+ ) -> None:
472
+ """List the GRIB2 messages each file of a query holds, spelled as the messages param takes them.
473
+
474
+ One tab-separated line per field: asset id, message number, bytes, and the
475
+ selector that names exactly that field. Only the index sidecars are read.
476
+ """
477
+ params = _parse_params(param, "messages", _MESSAGES_FLAGS)
478
+ try:
479
+ ds = default_registry().get(dataset_id)
480
+ listings = list_messages(ds, build_query(start=start, end=end, **params))
481
+ except (DatasetNotFound, ValueError) as e:
482
+ typer.secho(str(e), err=True, fg="red")
483
+ raise typer.Exit(code=2) from None
484
+ except NotImplementedProvider as e:
485
+ typer.secho(str(e), err=True, fg="yellow")
486
+ raise typer.Exit(code=3) from None
487
+ except httpx.HTTPError as e:
488
+ typer.secho(f"request failed: {e}", err=True, fg="red")
489
+ raise typer.Exit(code=4) from None
490
+ if as_json:
491
+ typer.echo(json.dumps([listing.model_dump(mode="json") for listing in listings], indent=2))
492
+ else:
493
+ for listing in listings:
494
+ for entry in listing.messages:
495
+ number = entry.number if entry.field is None else f"{entry.number}.{entry.field}"
496
+ typer.echo(f"{listing.asset.id}\t{number}\t{entry.length}\t{entry.selector}")
497
+ count = sum(len(listing.messages) for listing in listings)
498
+ typer.echo(
499
+ f"{count} message(s) in {len(listings)} file(s); a selector may stop after its "
500
+ "level text, as CAPE:surface, to match every step",
501
+ err=True,
502
+ )
503
+ if not listings:
504
+ raise typer.Exit(code=1)
505
+
506
+
440
507
  def _print_plan(plan: Plan, *, as_json: bool, quiet: bool) -> None:
441
508
  """Report what a pull would fetch: assets, a subtotal per source, a total on stderr."""
442
509
  if as_json:
@@ -574,7 +641,10 @@ def verify(
574
641
  bool, typer.Option("--json", help="With --listing, emit the comparison as JSON on stdout.")
575
642
  ] = False,
576
643
  ) -> None:
577
- """Check cached files against a manifest's lockfile. Exit 1 on any drift."""
644
+ """Check cached files, or with --listing today's source listings, against the lockfile.
645
+
646
+ Exit 1 on any drift.
647
+ """
578
648
  if as_json and not listing:
579
649
  typer.secho("--json applies to --listing only", err=True, fg="red")
580
650
  raise typer.Exit(code=2)
@@ -605,9 +675,6 @@ def _verify_listing(manifest: Path, *, as_json: bool) -> None:
605
675
  """Compare the lockfile with today's listings; exit 1 when a re-resolve would differ."""
606
676
  try:
607
677
  comparison = compare_manifest_listing(manifest)
608
- except EmptySource as e:
609
- typer.secho(str(e), err=True, fg="yellow")
610
- raise typer.Exit(code=1) from None
611
678
  except (DatasetNotFound, UnknownDatasets, ManifestChanged, UnknownPlace, ValueError) as e:
612
679
  typer.secho(str(e), err=True, fg="red")
613
680
  raise typer.Exit(code=2) from None
@@ -732,6 +732,52 @@ datasets:
732
732
  - { name: "TAVG", units: "degrees Celsius", description: "Monthly mean temperature (metric units)" }
733
733
  adapter: usdata.providers.noaa.gsom:GlobalSummaryMonthly
734
734
 
735
+ - id: noaa:gsom-station-files
736
+ provider: noaa
737
+ status: available
738
+ domain: surface-weather
739
+ since: "0.33"
740
+ title: Global Summary of the Month Station Files
741
+ description: >-
742
+ The same Global Summary of the Month as noaa:gsom, as NCEI's static per-station files:
743
+ one CSV per station holding its whole monthly record, every element with its attributes,
744
+ in metric units. Each station is one plain HTTPS GET that does not go through the Access
745
+ Data Service, which suits pulling many stations' long records. There is no selection by
746
+ time, place, or element; name stations and select months and columns after opening.
747
+ Variables are NCEI element codes, the catalog lists the common ones, and the delivered
748
+ set is open and depends on the station.
749
+ keywords: [climate, monthly, stations, snowfall, temperature, precipitation, gsom, ncei]
750
+ protocol: http
751
+ homepage: https://www.ncei.noaa.gov/data/global-summary-of-the-month/
752
+ license: US Government Work (public domain)
753
+ spatial_extent: { west: -180.0, south: -90.0, east: 180.0, north: 90.0 }
754
+ temporal_extent: { start: "1763-01-01T00:00:00Z" }
755
+ capabilities: { spatial_subset: false, temporal_subset: false, variable_subset: false }
756
+ summary: Whole monthly station records, one file per station
757
+ formats: [CSV]
758
+ selection: One whole file per named station, every month and element it holds
759
+ inputs: Station IDs; no dates, geographic filters, or variables
760
+ reader: pandas
761
+ guide: docs/providers/noaa-gsom-station-files.md
762
+ examples:
763
+ - examples/datasets/noaa-gsom-station-files/noaa-gsom-station-files.ipynb
764
+ - examples/studies/el-nino-snowfall/el-nino-snowfall.ipynb
765
+ resolution:
766
+ spatial: "Land surface stations, derived from GHCN-Daily"
767
+ temporal: "Monthly"
768
+ update_frequency: "Weekly"
769
+ citation: >-
770
+ Lawrimore, Jay H.; Ray, Ron; Applequist, Scott; Korzeniewski, Bryant; Menne, Matthew J.
771
+ (2016): Global Summary of the Month (GSOM), Version 1. NOAA National Centers for
772
+ Environmental Information. https://doi.org/10.7289/V5QV3JJ5
773
+ terms: https://www.ncei.noaa.gov/metadata/geoportal/rest/metadata/item/gov.noaa.ncdc:C00946/html
774
+ variables:
775
+ - { name: "PRCP", units: "mm", description: "Monthly precipitation total" }
776
+ - { name: "SNOW", units: "mm", description: "Monthly snowfall total" }
777
+ - { name: "SNOW_ATTRIBUTES", description: "Days missing, measurement, quality, and source flags for SNOW" }
778
+ - { name: "TAVG", units: "degrees Celsius", description: "Monthly mean temperature" }
779
+ adapter: usdata.providers.noaa.gsom_files:GsomStationFiles
780
+
735
781
  - id: noaa:gsoy
736
782
  provider: noaa
737
783
  status: available
@@ -1125,19 +1171,93 @@ datasets:
1125
1171
 
1126
1172
  - id: noaa:ersst
1127
1173
  provider: noaa
1128
- status: planned
1174
+ status: available
1129
1175
  domain: ocean-physics
1130
- target: later
1131
- title: ERSST Monthly Sea Surface Temperature
1176
+ since: "0.33"
1177
+ title: ERSST v6 Monthly Sea Surface Temperature
1132
1178
  description: >-
1133
- Extended Reconstructed SST v5: monthly global 2 degree analysis since
1134
- 1854, one NetCDF per month in an NCEI HTTPS directory.
1135
- keywords: [sst, sea surface temperature, ocean, climate, ersst, monthly, netcdf]
1179
+ Extended Reconstructed Sea Surface Temperature version 6: NCEI's monthly global
1180
+ analysis on a 2 degree grid since January 1850, built from ship, buoy, and Argo
1181
+ observations. One NetCDF4 file per month in an anonymous NCEI HTTPS directory, holding
1182
+ sea surface temperature and its anomaly from the 1991-2020 ERSSTv6 climatology. Every
1183
+ month a window touches is selected whole; there is no server-side spatial or variable
1184
+ subsetting. Each monthly update writes the month just ended and rewrites the month
1185
+ before it, so the newest file is preliminary.
1186
+ keywords: [sst, sea surface temperature, ocean, climate, ersst, monthly, netcdf, enso, el nino]
1136
1187
  protocol: http
1137
1188
  homepage: https://www.ncei.noaa.gov/products/extended-reconstructed-sst
1138
1189
  license: US Government Work (public domain)
1139
- temporal_extent: { start: "1854-01-01T00:00:00Z" }
1190
+ spatial_extent: { west: -180.0, south: -89.0, east: 180.0, north: 89.0 }
1191
+ temporal_extent: { start: "1850-01-01T00:00:00Z" }
1140
1192
  capabilities: { spatial_subset: false, temporal_subset: true, variable_subset: false }
1193
+ summary: Monthly global 2 degree sea surface temperature since 1850
1194
+ formats: [NetCDF4]
1195
+ selection: Whole global monthly files, one for every month the window touches
1196
+ inputs: Both dates (selects the months they span)
1197
+ reader: netcdf
1198
+ guide: docs/providers/noaa-ersst.md
1199
+ examples:
1200
+ - examples/datasets/noaa-ersst/noaa-ersst.ipynb
1201
+ resolution:
1202
+ spatial: "2.0 degree grid"
1203
+ temporal: "Monthly means"
1204
+ update_frequency: >-
1205
+ Monthly, around the third: each update writes the month just ended and rewrites the
1206
+ month before it
1207
+ citation: >-
1208
+ Huang, B., X. Yin, T. Boyer, C. Liu, M. Menne, Y. D. Rao, T. Smith, R. Vose, and
1209
+ H.-M. Zhang, 2025: Extended Reconstructed Sea Surface Temperature Version 6 (ERSSTv6):
1210
+ Part I. An Artificial Neural Network Approach. Journal of Climate, 38, 1105-1121,
1211
+ doi:10.1175/JCLI-D-23-0707.1
1212
+ terms: https://www.ncei.noaa.gov/products/extended-reconstructed-sst
1213
+ variables:
1214
+ - { name: "sst", units: "degree_C", description: "Extended reconstructed sea surface temperature" }
1215
+ - { name: "ssta", units: "degree_C", description: "Anomaly from the 1991-2020 ERSSTv6 climatology" }
1216
+ adapter: usdata.providers.noaa.ersst:Ersst
1217
+
1218
+ - id: noaa:enso-indices
1219
+ provider: noaa
1220
+ status: available
1221
+ domain: climate
1222
+ since: "0.33"
1223
+ title: CPC ENSO Indices (RONI and ONI)
1224
+ description: >-
1225
+ The Climate Prediction Center's seasonal El Niño-Southern Oscillation indices: the
1226
+ Relative Oceanic Niño Index (RONI), which NOAA has used for official ENSO monitoring
1227
+ since 1 February 2026, and the traditional Oceanic Niño Index (ONI). Both are
1228
+ three-month running means of Niño 3.4 sea surface temperature anomalies from ERSST v6;
1229
+ RONI first subtracts the tropical mean anomaly. Each index is one whole plain-text table
1230
+ from DJF 1950, rewritten in place each month, with no server-side selection.
1231
+ keywords: [enso, el nino, la nina, oni, roni, nino 3.4, climate index, cpc, sea surface temperature]
1232
+ protocol: http
1233
+ homepage: https://www.cpc.ncep.noaa.gov/products/analysis_monitoring/enso/roni/
1234
+ license: US Government Work (public domain)
1235
+ spatial_extent: { west: -170.0, south: -5.0, east: -120.0, north: 5.0 }
1236
+ temporal_extent: { start: "1949-12-01T00:00:00Z" }
1237
+ capabilities: { spatial_subset: false, temporal_subset: false, variable_subset: false }
1238
+ summary: Seasonal El Niño indices, the official RONI and the traditional ONI
1239
+ formats: [whitespace-delimited text (no reader)]
1240
+ selection: One whole index table, every season from DJF 1950; select seasons locally
1241
+ inputs: Optional index (roni or oni); no dates or geographic filters
1242
+ reader: null
1243
+ guide: docs/providers/noaa-enso-indices.md
1244
+ examples:
1245
+ - examples/datasets/noaa-enso-indices/noaa-enso-indices.ipynb
1246
+ - examples/studies/el-nino-snowfall/el-nino-snowfall.ipynb
1247
+ resolution:
1248
+ spatial: "One value for the Niño 3.4 region, 5N-5S and 170W-120W"
1249
+ temporal: "Overlapping three-month seasons, one per month"
1250
+ update_frequency: "Monthly, by the 5th"
1251
+ citation: >-
1252
+ NOAA Climate Prediction Center, Relative Oceanic Niño Index and Oceanic Niño Index,
1253
+ accessed via usdata
1254
+ terms: https://www.weather.gov/disclaimer
1255
+ variables:
1256
+ - { name: "SEAS", description: "Three-month season, such as DJF" }
1257
+ - { name: "YR", description: "Year of the season's middle month" }
1258
+ - { name: "ANOM", units: "degree_C", description: "Index value: the season's anomaly" }
1259
+ - { name: "TOTAL", units: "degree_C", description: "Niño 3.4 mean temperature; ONI table only" }
1260
+ adapter: usdata.providers.noaa.enso:EnsoIndices
1141
1261
 
1142
1262
  - id: noaa:etopo
1143
1263
  provider: noaa
@@ -137,7 +137,8 @@ class Grib2Summary(BaseModel):
137
137
  KeyError: No message here was fetched for that selector; the message
138
138
  lists the selectors that were.
139
139
  ValueError: That selector fetched a message holding several fields,
140
- as RAP publishes wind components, and the record cannot say
140
+ as RAP publishes wind components, and the record was written
141
+ before each field's selector was recorded, so it cannot say
141
142
  which field it named; the message lists their variables.
142
143
  """
143
144
  from usdata._grib import variable_names
@@ -158,7 +159,7 @@ class Grib2Summary(BaseModel):
158
159
  if message.selector == selector
159
160
  ]
160
161
  if len(matched) > 1:
161
- # The record keeps one selector per byte range, and one range can hold several fields.
162
+ # An older record keeps one selector per range, and one range can hold several fields.
162
163
  raise ValueError(
163
164
  f"selector {selector!r} fetched one message holding {len(matched)} fields "
164
165
  f"({', '.join(matched)}); pick the variable by name"
@@ -333,24 +334,26 @@ def _paired(messages: list[GribMessage], record: Provenance) -> list[GribMessage
333
334
  """Each message as the fetch that took it described it, where provenance says.
334
335
 
335
336
  The file itself carries neither number nor selector: a partial fetch recorded
336
- one message number and one selector per range, and ``file_index`` counts the
337
- file's messages the same way, so the fields of a message that holds several
338
- all pair with its number. A whole file, or a record that does not pair,
339
- leaves both unset.
337
+ one message number per range and one selector per field, and ``file_index``
338
+ counts the file's messages the same way, so the fields of a message that
339
+ holds several all pair with its number and each with its own selector. A
340
+ whole file, or a record that does not pair, leaves both unset.
340
341
  """
341
342
  numbers = record.object_messages
342
343
  if not numbers or any(message.file_index >= len(numbers) for message in messages):
343
344
  return messages
344
- selectors = record.selectors if len(record.selectors) == len(numbers) else []
345
- return [
346
- message.model_copy(
347
- update={
348
- "object_index": numbers[message.file_index],
349
- "selector": selectors[message.file_index] if selectors else None,
350
- }
345
+ paired = len(record.selectors) == len(numbers)
346
+ fields_seen: dict[int, int] = {}
347
+ result = []
348
+ for message in messages:
349
+ field = fields_seen[message.file_index] = fields_seen.get(message.file_index, -1) + 1
350
+ selector = record.field_selector(message.file_index, field) if paired else None
351
+ result.append(
352
+ message.model_copy(
353
+ update={"object_index": numbers[message.file_index], "selector": selector}
354
+ )
351
355
  )
352
- for message in messages
353
- ]
356
+ return result
354
357
 
355
358
 
356
359
  def _csv_summary(path: Path, *, units_row: bool = False) -> CsvSummary:
@@ -0,0 +1,60 @@
1
+ """The GRIB2 messages a query's files hold, read from their index sidecars.
2
+
3
+ A dataset declaring ``partial_fetch`` takes a ``messages`` parameter spelled in
4
+ its objects' index vocabulary, and this is where that vocabulary is listed.
5
+ Listing resolves the query as a fetch would and reads each object's index,
6
+ which is a few kilobytes; nothing else is downloaded, and nothing is cached or
7
+ recorded. See ADR 0046.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ from pydantic import BaseModel, Field
13
+
14
+ from usdata.models import Asset, Dataset, IndexEntry, Query
15
+ from usdata.providers import QueryError, load_adapter
16
+
17
+ PARTIAL_PARAM = "messages"
18
+ """The parameter a ``partial_fetch`` dataset selects messages through."""
19
+
20
+
21
+ class MessageListing(BaseModel):
22
+ """One file a query resolved to, and every field its index describes."""
23
+
24
+ asset: Asset = Field(description="The whole-file asset, as a fetch without messages lists it")
25
+ messages: list[IndexEntry] = Field(description="Every field in the object's index, in order")
26
+
27
+
28
+ def list_messages(dataset: Dataset, query: Query) -> list[MessageListing]:
29
+ """List each file a query resolves to with the fields its index describes.
30
+
31
+ Each entry's ``selector`` is a ``messages`` value that names exactly that
32
+ field. A selector may stop after its level text, as ``CAPE:surface``, to
33
+ name that field at whatever step each file holds.
34
+
35
+ Args:
36
+ dataset: A dataset declaring ``partial_fetch``.
37
+ query: The query a fetch would take, without ``messages``.
38
+
39
+ Returns:
40
+ One listing per file, in the order a fetch would list them.
41
+
42
+ Raises:
43
+ QueryError: The dataset selects no messages, the query names
44
+ ``messages``, or a file publishes no readable index.
45
+ """
46
+ if not dataset.capabilities.partial_fetch:
47
+ raise QueryError(
48
+ f"{dataset.id} fetches whole files, so it has no messages to list; "
49
+ "only datasets that declare partial_fetch do"
50
+ )
51
+ if PARTIAL_PARAM in query.params:
52
+ raise QueryError(
53
+ f"drop {PARTIAL_PARAM}: the listing shows every message each file holds, "
54
+ "whatever a selection would keep"
55
+ )
56
+ with load_adapter(dataset) as adapter:
57
+ return [
58
+ MessageListing(asset=asset, messages=adapter.list_messages(asset))
59
+ for asset in adapter.list_assets(query)
60
+ ]
@@ -560,6 +560,42 @@ class ByteRange(BaseModel):
560
560
  return f"bytes={self.start}-{self.end}"
561
561
 
562
562
 
563
+ class IndexEntry(BaseModel):
564
+ """One field of one GRIB2 object, as the object's wgrib2 ``.idx`` sidecar describes it.
565
+
566
+ ``number`` is the GRIB2 message the field belongs to and ``field`` its
567
+ position inside a message that holds several (``12.2``), or ``None`` for a
568
+ message holding one. Fields of one message share an offset and a length.
569
+ ``selector`` is the ``messages`` text that names exactly this field.
570
+ """
571
+
572
+ number: int = Field(ge=1, description="One-based GRIB2 message number in the object")
573
+ offset: int = Field(ge=0, description="Byte offset of the message in the object")
574
+ length: int = Field(gt=0, description="Bytes the message occupies")
575
+ short_name: str = Field(description="wgrib2 short name, such as CAPE")
576
+ level: str = Field(description="Level text, such as '2 m above ground'")
577
+ step: str = Field(description="Step text, such as 'anl' or '1 hour fcst'")
578
+ field: int | None = Field(
579
+ default=None, ge=1, description="Position inside a message holding several fields"
580
+ )
581
+ extra: str = Field(
582
+ default="", description="Further text some products add, such as 'ens std dev'"
583
+ )
584
+
585
+ @property
586
+ def byte_range(self) -> ByteRange:
587
+ """The inclusive byte interval this field's message occupies in the object."""
588
+ return ByteRange(start=self.offset, end=self.offset + self.length - 1)
589
+
590
+ @property
591
+ def selector(self) -> str:
592
+ """The ``messages`` selector naming exactly this field, short name through the last text."""
593
+ parts = [self.short_name, self.level, self.step]
594
+ if self.extra:
595
+ parts.append(self.extra)
596
+ return ":".join(parts)
597
+
598
+
563
599
  PARTIAL_FRAGMENT = "messages"
564
600
  """Href fragment key naming the GRIB2 messages a partial asset selects."""
565
601
 
@@ -583,6 +619,13 @@ class PartialFetch(BaseModel):
583
619
  default_factory=list,
584
620
  description="Index selector naming each of those messages, in the same order",
585
621
  )
622
+ field_selectors: list[list[str]] = Field(
623
+ default_factory=list,
624
+ description=(
625
+ "Index selector naming each field of each of those messages, in the same order; "
626
+ "a message holding several fields, as RAP packs wind components, has one per field"
627
+ ),
628
+ )
586
629
 
587
630
  @model_validator(mode="after")
588
631
  def _aligned(self) -> PartialFetch:
@@ -590,6 +633,10 @@ class PartialFetch(BaseModel):
590
633
  raise ValueError("a partial fetch needs one byte range per selected message")
591
634
  if self.selectors and len(self.selectors) != len(self.messages):
592
635
  raise ValueError("a partial fetch needs one selector per selected message")
636
+ if self.field_selectors and (
637
+ len(self.field_selectors) != len(self.messages) or not all(self.field_selectors)
638
+ ):
639
+ raise ValueError("a partial fetch needs one or more field selectors per message")
593
640
  if sorted(set(self.messages)) != self.messages:
594
641
  raise ValueError("selected messages must be ascending and distinct")
595
642
  return self
@@ -655,6 +702,13 @@ class Provenance(BaseModel):
655
702
  default_factory=list,
656
703
  description="Index selector each of those ranges was fetched for, in the same order",
657
704
  )
705
+ field_selectors: list[list[str]] = Field(
706
+ default_factory=list,
707
+ description=(
708
+ "Index selector naming each field each of those ranges holds, in the same order; "
709
+ "empty in a record written before fields were recorded"
710
+ ),
711
+ )
658
712
  object_size: int | None = Field(
659
713
  default=None, ge=0, description="Size of the whole object those ranges came from"
660
714
  )
@@ -680,6 +734,19 @@ class Provenance(BaseModel):
680
734
  """Whether this record describes selected byte ranges rather than a whole object."""
681
735
  return any(entry.startswith(PARTIAL_TRANSFORMATION) for entry in self.transformations)
682
736
 
737
+ def field_selector(self, message: int, field: int) -> str | None:
738
+ """The selector one field on disk was fetched for, or ``None`` where none was recorded.
739
+
740
+ ``message`` counts the local file's messages from zero and ``field`` the
741
+ fields inside that message from zero, as a reader meets them. A record
742
+ that names no fields, as one written before they were recorded does,
743
+ gives every field of a message that message's one selector.
744
+ """
745
+ fields = self.field_selectors[message] if message < len(self.field_selectors) else []
746
+ if field < len(fields):
747
+ return fields[field]
748
+ return self.selectors[message] if message < len(self.selectors) else None
749
+
683
750
  @property
684
751
  def object_messages(self) -> list[int]:
685
752
  """The source object's own message numbers, in the order the local file holds them.
@@ -50,6 +50,7 @@ def record(
50
50
  index_checksum=None if partial is None else partial.index_checksum,
51
51
  ranges=[] if partial is None else list(partial.ranges),
52
52
  selectors=[] if partial is None else list(partial.selectors),
53
+ field_selectors=[] if partial is None else [list(f) for f in partial.field_selectors],
53
54
  object_size=None if partial is None else partial.object_size,
54
55
  object_etag=None if partial is None else partial.object_etag,
55
56
  credentials=[] if dataset.credentials is None else list(dataset.credentials.variables),