usdata 0.30.0__tar.gz → 0.32.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 (85) hide show
  1. {usdata-0.30.0 → usdata-0.32.0}/PKG-INFO +2 -2
  2. {usdata-0.30.0 → usdata-0.32.0}/README.md +1 -1
  3. {usdata-0.30.0 → usdata-0.32.0}/pyproject.toml +1 -1
  4. {usdata-0.30.0 → usdata-0.32.0}/pyproject.toml.orig +1 -1
  5. {usdata-0.30.0 → usdata-0.32.0}/src/usdata/__init__.py +2 -1
  6. usdata-0.32.0/src/usdata/_census.py +51 -0
  7. {usdata-0.30.0 → usdata-0.32.0}/src/usdata/_fetch.py +1 -1
  8. {usdata-0.30.0 → usdata-0.32.0}/src/usdata/_grib.py +23 -2
  9. {usdata-0.30.0 → usdata-0.32.0}/src/usdata/cli/app.py +80 -1
  10. {usdata-0.30.0 → usdata-0.32.0}/src/usdata/data/registry.yaml +43 -8
  11. {usdata-0.30.0 → usdata-0.32.0}/src/usdata/doctor.py +3 -2
  12. usdata-0.32.0/src/usdata/providers/census/__init__.py +1 -0
  13. usdata-0.32.0/src/usdata/providers/census/acs.py +243 -0
  14. {usdata-0.30.0 → usdata-0.32.0}/src/usdata/pull.py +136 -6
  15. {usdata-0.30.0 → usdata-0.32.0}/src/usdata/query.py +18 -0
  16. {usdata-0.30.0 → usdata-0.32.0}/src/usdata/readers.py +15 -4
  17. {usdata-0.30.0 → usdata-0.32.0}/src/usdata/testing.py +2 -1
  18. {usdata-0.30.0 → usdata-0.32.0}/src/usdata/__main__.py +0 -0
  19. {usdata-0.30.0 → usdata-0.32.0}/src/usdata/_aqs.py +0 -0
  20. {usdata-0.30.0 → usdata-0.32.0}/src/usdata/_files.py +0 -0
  21. {usdata-0.30.0 → usdata-0.32.0}/src/usdata/_hurdat2.py +0 -0
  22. {usdata-0.30.0 → usdata-0.32.0}/src/usdata/_netcdf.py +0 -0
  23. {usdata-0.30.0 → usdata-0.32.0}/src/usdata/_progress.py +0 -0
  24. {usdata-0.30.0 → usdata-0.32.0}/src/usdata/_radar.py +0 -0
  25. {usdata-0.30.0 → usdata-0.32.0}/src/usdata/cache.py +0 -0
  26. {usdata-0.30.0 → usdata-0.32.0}/src/usdata/cache_ops.py +0 -0
  27. {usdata-0.30.0 → usdata-0.32.0}/src/usdata/cite.py +0 -0
  28. {usdata-0.30.0 → usdata-0.32.0}/src/usdata/cli/__init__.py +0 -0
  29. {usdata-0.30.0 → usdata-0.32.0}/src/usdata/cli/cache.py +0 -0
  30. {usdata-0.30.0 → usdata-0.32.0}/src/usdata/cli/cite.py +0 -0
  31. {usdata-0.30.0 → usdata-0.32.0}/src/usdata/cli/doctor.py +0 -0
  32. {usdata-0.30.0 → usdata-0.32.0}/src/usdata/cli/inspect.py +0 -0
  33. {usdata-0.30.0 → usdata-0.32.0}/src/usdata/cli/progress.py +0 -0
  34. {usdata-0.30.0 → usdata-0.32.0}/src/usdata/data/nexrad_sites.csv +0 -0
  35. {usdata-0.30.0 → usdata-0.32.0}/src/usdata/data/places.csv +0 -0
  36. {usdata-0.30.0 → usdata-0.32.0}/src/usdata/data/places.sources.json +0 -0
  37. {usdata-0.30.0 → usdata-0.32.0}/src/usdata/inspect.py +0 -0
  38. {usdata-0.30.0 → usdata-0.32.0}/src/usdata/manifest.py +0 -0
  39. {usdata-0.30.0 → usdata-0.32.0}/src/usdata/mirror.py +0 -0
  40. {usdata-0.30.0 → usdata-0.32.0}/src/usdata/models.py +0 -0
  41. {usdata-0.30.0 → usdata-0.32.0}/src/usdata/protocols/__init__.py +0 -0
  42. {usdata-0.30.0 → usdata-0.32.0}/src/usdata/protocols/erddap.py +0 -0
  43. {usdata-0.30.0 → usdata-0.32.0}/src/usdata/protocols/http.py +0 -0
  44. {usdata-0.30.0 → usdata-0.32.0}/src/usdata/protocols/listing.py +0 -0
  45. {usdata-0.30.0 → usdata-0.32.0}/src/usdata/protocols/s3.py +0 -0
  46. {usdata-0.30.0 → usdata-0.32.0}/src/usdata/provenance.py +0 -0
  47. {usdata-0.30.0 → usdata-0.32.0}/src/usdata/providers/__init__.py +0 -0
  48. {usdata-0.30.0 → usdata-0.32.0}/src/usdata/providers/base.py +0 -0
  49. {usdata-0.30.0 → usdata-0.32.0}/src/usdata/providers/credentials.py +0 -0
  50. {usdata-0.30.0 → usdata-0.32.0}/src/usdata/providers/epa/__init__.py +0 -0
  51. {usdata-0.30.0 → usdata-0.32.0}/src/usdata/providers/epa/aqs.py +0 -0
  52. {usdata-0.30.0 → usdata-0.32.0}/src/usdata/providers/fema/__init__.py +0 -0
  53. {usdata-0.30.0 → usdata-0.32.0}/src/usdata/providers/fema/declarations.py +0 -0
  54. {usdata-0.30.0 → usdata-0.32.0}/src/usdata/providers/http.py +0 -0
  55. {usdata-0.30.0 → usdata-0.32.0}/src/usdata/providers/noaa/__init__.py +0 -0
  56. {usdata-0.30.0 → usdata-0.32.0}/src/usdata/providers/noaa/coastwatch.py +0 -0
  57. {usdata-0.30.0 → usdata-0.32.0}/src/usdata/providers/noaa/coops.py +0 -0
  58. {usdata-0.30.0 → usdata-0.32.0}/src/usdata/providers/noaa/gfs.py +0 -0
  59. {usdata-0.30.0 → usdata-0.32.0}/src/usdata/providers/noaa/ghcnd.py +0 -0
  60. {usdata-0.30.0 → usdata-0.32.0}/src/usdata/providers/noaa/glm.py +0 -0
  61. {usdata-0.30.0 → usdata-0.32.0}/src/usdata/providers/noaa/goes.py +0 -0
  62. {usdata-0.30.0 → usdata-0.32.0}/src/usdata/providers/noaa/grib_index.py +0 -0
  63. {usdata-0.30.0 → usdata-0.32.0}/src/usdata/providers/noaa/gsom.py +0 -0
  64. {usdata-0.30.0 → usdata-0.32.0}/src/usdata/providers/noaa/gsoy.py +0 -0
  65. {usdata-0.30.0 → usdata-0.32.0}/src/usdata/providers/noaa/hrrr.py +0 -0
  66. {usdata-0.30.0 → usdata-0.32.0}/src/usdata/providers/noaa/hurdat2.py +0 -0
  67. {usdata-0.30.0 → usdata-0.32.0}/src/usdata/providers/noaa/ibtracs.py +0 -0
  68. {usdata-0.30.0 → usdata-0.32.0}/src/usdata/providers/noaa/lcd.py +0 -0
  69. {usdata-0.30.0 → usdata-0.32.0}/src/usdata/providers/noaa/mrms.py +0 -0
  70. {usdata-0.30.0 → usdata-0.32.0}/src/usdata/providers/noaa/nbm.py +0 -0
  71. {usdata-0.30.0 → usdata-0.32.0}/src/usdata/providers/noaa/nexrad.py +0 -0
  72. {usdata-0.30.0 → usdata-0.32.0}/src/usdata/providers/noaa/nexrad_level3.py +0 -0
  73. {usdata-0.30.0 → usdata-0.32.0}/src/usdata/providers/noaa/normals.py +0 -0
  74. {usdata-0.30.0 → usdata-0.32.0}/src/usdata/providers/noaa/nws_vtec.py +0 -0
  75. {usdata-0.30.0 → usdata-0.32.0}/src/usdata/providers/noaa/rap.py +0 -0
  76. {usdata-0.30.0 → usdata-0.32.0}/src/usdata/providers/noaa/sites.py +0 -0
  77. {usdata-0.30.0 → usdata-0.32.0}/src/usdata/providers/noaa/spc.py +0 -0
  78. {usdata-0.30.0 → usdata-0.32.0}/src/usdata/providers/noaa/storm_events.py +0 -0
  79. {usdata-0.30.0 → usdata-0.32.0}/src/usdata/providers/params.py +0 -0
  80. {usdata-0.30.0 → usdata-0.32.0}/src/usdata/providers/usgs/__init__.py +0 -0
  81. {usdata-0.30.0 → usdata-0.32.0}/src/usdata/providers/usgs/daily.py +0 -0
  82. {usdata-0.30.0 → usdata-0.32.0}/src/usdata/providers/usgs/earthquakes.py +0 -0
  83. {usdata-0.30.0 → usdata-0.32.0}/src/usdata/py.typed +0 -0
  84. {usdata-0.30.0 → usdata-0.32.0}/src/usdata/registry.py +0 -0
  85. {usdata-0.30.0 → usdata-0.32.0}/src/usdata/selection.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: usdata
3
- Version: 0.30.0
3
+ Version: 0.32.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-seven datasets are available today and nineteen more are planned, grouped
116
+ Twenty-eight datasets are available today and eighteen 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-seven datasets are available today and nineteen more are planned, grouped
71
+ Twenty-eight datasets are available today and eighteen 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.30.0"
3
+ version = "0.32.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.30.0"
3
+ version = "0.32.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"
@@ -23,7 +23,7 @@ from usdata.models import (
23
23
  TemporalSelection,
24
24
  TimeRange,
25
25
  )
26
- from usdata.pull import pull, verify
26
+ from usdata.pull import compare_listing, pull, verify
27
27
  from usdata.query import build_query
28
28
  from usdata.registry import DatasetNotFound, Registry, SearchResult, default_registry
29
29
  from usdata.selection import select_by_time
@@ -47,6 +47,7 @@ __all__ = [
47
47
  "build_query",
48
48
  "cite_dataset",
49
49
  "cite_lockfile",
50
+ "compare_listing",
50
51
  "datasets",
51
52
  "default_registry",
52
53
  "fetch",
@@ -0,0 +1,51 @@
1
+ """Local reading of Census Data API tables into a pandas DataFrame, behind pandas.
2
+
3
+ A fetched ``census:acs-5year`` file is the service's response as served: a JSON
4
+ array whose first row names the columns and whose other rows are one geography
5
+ each, every value a string. The table is those rows under those names, with the
6
+ estimate and margin-of-error columns as numbers and everything else, geography
7
+ codes and their leading zeros included, as text.
8
+
9
+ Negative annotation codes such as ``-555555555`` stay the numbers they are.
10
+ Each means something different (too few sample cases, not applicable, a
11
+ controlled estimate), so they are not turned into missing values (ADR 0045).
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import json
17
+ import re
18
+ from importlib import import_module
19
+ from typing import TYPE_CHECKING, Any
20
+
21
+ from usdata.readers import MissingReaderDependency, source_attrs
22
+
23
+ if TYPE_CHECKING:
24
+ from usdata._fetch import FetchedAsset
25
+
26
+ NUMERIC = re.compile(r"[A-Z]\d{5}[A-Z0-9]*_\d{3}[EM]")
27
+ """An estimate (``...E``) or margin of error (``...M``) variable, such as ``B01003_001E``.
28
+
29
+ Annotation columns (``...EA``, ``...MA``) and ``NAME`` do not match and stay text.
30
+ """
31
+
32
+
33
+ def open_acs(fetched: FetchedAsset) -> Any:
34
+ """Parse a fetched ACS table into a pandas DataFrame, one row per geography."""
35
+ try:
36
+ pandas = import_module("pandas")
37
+ except ModuleNotFoundError as error:
38
+ if error.name != "pandas":
39
+ raise
40
+ raise MissingReaderDependency(
41
+ 'ACS reading requires pandas; install it with: pip install "usdata[pandas]" '
42
+ '(or uv add "usdata[pandas]")'
43
+ ) from error
44
+ # Reading a fetched asset is strictly local; the cached file is never rewritten.
45
+ header, *rows = json.loads(fetched.path.read_text(encoding="utf-8"))
46
+ frame = pandas.DataFrame(rows, columns=header, dtype="string")
47
+ for column in header:
48
+ if NUMERIC.fullmatch(column):
49
+ frame[column] = pandas.to_numeric(frame[column])
50
+ frame.attrs["usdata"] = source_attrs(fetched)
51
+ return frame
@@ -39,7 +39,7 @@ class FetchedAsset(BaseModel):
39
39
  def open(self) -> Any:
40
40
  """Open local data with the reader its format implies, with that reader's defaults.
41
41
 
42
- CSV and ERDDAP CSV, HURDAT2 and AQS daily JSON return a pandas DataFrame;
42
+ CSV and ERDDAP CSV, HURDAT2, AQS daily JSON, and ACS tables return a pandas DataFrame;
43
43
  NetCDF4 and GRIB2 a loaded xarray Dataset; NEXRAD Level II an xarray
44
44
  DataTree. Provenance is kept in the result's ``attrs["usdata"]``. A file
45
45
  that needs options, or whose metadata leaves its format ambiguous, is
@@ -19,9 +19,10 @@ import gzip
19
19
  import re
20
20
  import warnings
21
21
  from collections.abc import Iterator, Mapping, Sequence
22
- from contextlib import contextmanager
22
+ from contextlib import contextmanager, suppress
23
23
  from datetime import UTC, datetime
24
24
  from importlib import import_module
25
+ from importlib.util import find_spec
25
26
  from pathlib import Path
26
27
  from typing import TYPE_CHECKING, Any, NamedTuple
27
28
 
@@ -86,9 +87,29 @@ PROJECTION_KEYS = (
86
87
  )
87
88
 
88
89
 
90
+ def import_eccodes() -> Any:
91
+ """Import the ecCodes bindings, loading pyproj first when it is installed.
92
+
93
+ The eckitlib wheel that the ecCodes library wheel depends on bundles its own
94
+ copy of the PROJ library, and so does pyproj's wheel, which xradar and many
95
+ analysis stacks bring in. When eckitlib's copy is loaded first and pyproj is
96
+ imported later in the same process, both copies free the same memory as the
97
+ interpreter exits, which aborts it with "double free or corruption" and exit
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.
100
+
101
+ Raises:
102
+ ModuleNotFoundError: eccodes is not installed.
103
+ """
104
+ if find_spec("pyproj") is not None:
105
+ with suppress(ImportError, OSError):
106
+ import_module("pyproj")
107
+ return import_module("eccodes")
108
+
109
+
89
110
  def _modules() -> tuple[Any, Any, Any]:
90
111
  try:
91
- eccodes = import_module("eccodes")
112
+ eccodes = import_eccodes()
92
113
  xarray = import_module("xarray")
93
114
  numpy = import_module("numpy")
94
115
  except ModuleNotFoundError as error:
@@ -23,7 +23,15 @@ from usdata.manifest import lockfile_path
23
23
  from usdata.models import READER_EXTRAS_TEXT, Dataset, Status, describe_duration
24
24
  from usdata.providers import adapter_class, load_adapter
25
25
  from usdata.providers.base import NotImplementedProvider
26
- from usdata.pull import EmptySource, ManifestChanged, Plan, UnknownDatasets, UpstreamChanged
26
+ from usdata.pull import (
27
+ EmptySource,
28
+ ListingComparison,
29
+ ManifestChanged,
30
+ Plan,
31
+ UnknownDatasets,
32
+ UpstreamChanged,
33
+ )
34
+ from usdata.pull import compare_listing as compare_manifest_listing
27
35
  from usdata.pull import plan as plan_manifest
28
36
  from usdata.pull import pull as pull_manifest
29
37
  from usdata.pull import verify as verify_manifest
@@ -554,12 +562,32 @@ def pull(
554
562
  def verify(
555
563
  manifest: Annotated[Path, typer.Argument(exists=True, dir_okay=False)],
556
564
  cache_dir: Annotated[Path | None, typer.Option(help="Override the cache directory.")] = None,
565
+ listing: Annotated[
566
+ bool,
567
+ typer.Option(
568
+ "--listing",
569
+ help="Instead of the cache, compare the lockfile with what each source lists today; "
570
+ "downloads nothing, but asks every source.",
571
+ ),
572
+ ] = False,
573
+ as_json: Annotated[
574
+ bool, typer.Option("--json", help="With --listing, emit the comparison as JSON on stdout.")
575
+ ] = False,
557
576
  ) -> None:
558
577
  """Check cached files against a manifest's lockfile. Exit 1 on any drift."""
578
+ if as_json and not listing:
579
+ typer.secho("--json applies to --listing only", err=True, fg="red")
580
+ raise typer.Exit(code=2)
581
+ if listing and cache_dir is not None:
582
+ typer.secho("--listing reads no cache; drop --cache-dir", err=True, fg="red")
583
+ raise typer.Exit(code=2)
559
584
  lock = lockfile_path(manifest)
560
585
  if not lock.exists():
561
586
  typer.secho(f"no lockfile at {lock}; run pull first", err=True, fg="red")
562
587
  raise typer.Exit(code=2)
588
+ if listing:
589
+ _verify_listing(manifest, as_json=as_json)
590
+ return
563
591
  try:
564
592
  drift = verify_manifest(manifest, root=cache_dir)
565
593
  except (ManifestChanged, ValueError, OSError) as e:
@@ -573,4 +601,55 @@ def verify(
573
601
  typer.echo(f"all assets match {lock.name}", err=True)
574
602
 
575
603
 
604
+ def _verify_listing(manifest: Path, *, as_json: bool) -> None:
605
+ """Compare the lockfile with today's listings; exit 1 when a re-resolve would differ."""
606
+ try:
607
+ 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
+ except (DatasetNotFound, UnknownDatasets, ManifestChanged, UnknownPlace, ValueError) as e:
612
+ typer.secho(str(e), err=True, fg="red")
613
+ raise typer.Exit(code=2) from None
614
+ except NotImplementedProvider as e:
615
+ typer.secho(str(e), err=True, fg="yellow")
616
+ raise typer.Exit(code=3) from None
617
+ except httpx.HTTPError as e:
618
+ typer.secho(f"listing failed: {e}", err=True, fg="red")
619
+ raise typer.Exit(code=4) from None
620
+ _print_listing(comparison, as_json=as_json)
621
+ if not comparison.matches:
622
+ raise typer.Exit(code=1)
623
+
624
+
625
+ def _print_listing(comparison: ListingComparison, *, as_json: bool) -> None:
626
+ """One line per difference, a summary on stderr; or the whole comparison as JSON."""
627
+ name = comparison.lockfile.name
628
+ if as_json:
629
+ typer.echo(comparison.model_dump_json(indent=2))
630
+ else:
631
+ for source in comparison.sources:
632
+ for asset in source.added:
633
+ typer.echo(f"{source.source}\tadded\t{asset.id}\t{asset.href}")
634
+ for asset in source.removed:
635
+ typer.echo(f"{source.source}\tremoved\t{asset.id}\t{asset.href}")
636
+ for change in source.changed:
637
+ sizes = f"{_size(change.locked_size)} -> {_size(change.listed_size)} bytes"
638
+ typer.echo(f"{source.source}\tchanged\t{change.asset_id}\t{sizes}")
639
+ differing = [source.source for source in comparison.sources if not source.matches]
640
+ if differing:
641
+ typer.secho(
642
+ f"{len(differing)} source(s) list something other than {name} pins: "
643
+ f"{', '.join(differing)}. Pass pull --force to re-resolve.",
644
+ err=True,
645
+ fg="red",
646
+ )
647
+ else:
648
+ typer.echo(f"every source lists what {name} pins", err=True)
649
+
650
+
651
+ def _size(value: int | None) -> str:
652
+ return "?" if value is None else str(value)
653
+
654
+
576
655
  app.command()(doctor)
@@ -1556,20 +1556,55 @@ datasets:
1556
1556
  # ---------------------------------------------------------------- Census
1557
1557
  - id: census:acs-5year
1558
1558
  provider: census
1559
- status: planned
1559
+ status: available
1560
1560
  domain: demographics
1561
- target: later
1561
+ since: "0.31"
1562
1562
  title: American Community Survey 5-Year Estimates
1563
1563
  description: >-
1564
- Population, housing, income, and demographic estimates for every
1565
- geography down to block group, via the Census Data API, selected by state
1566
- and county FIPS code rather than by box. Requires a free API key on every
1567
- data request; only the dataset and variable metadata are served without one.
1568
- keywords: [population, demographics, housing, income, acs, census]
1564
+ Population, housing, income, and demographic estimates from the American
1565
+ Community Survey's detailed tables, through the Census Data API: one row
1566
+ per geography, for one to fifty API variables, including margins of error
1567
+ and annotations. A query names a vintage, the last year of the five-year
1568
+ period, rather than a window, and a state or county rather than a box;
1569
+ a state can return its own row or one row per county. The delivered
1570
+ variable set is open, tens of thousands of variables per vintage.
1571
+ Requires a free API key on every data request; the response is stored as
1572
+ served, since it carries no copy of the key.
1573
+ keywords: [population, demographics, housing, income, acs, census, american community survey]
1569
1574
  protocol: http
1570
1575
  homepage: https://www.census.gov/data/developers/data-sets/acs-5year.html
1571
1576
  license: US Government Work (public domain)
1572
- capabilities: { spatial_subset: false, place_subset: true, temporal_subset: true, variable_subset: true }
1577
+ capabilities: { spatial_subset: false, place_subset: true, temporal_subset: false, variable_subset: true }
1578
+ credentials:
1579
+ variables: [USDATA_CENSUS_KEY]
1580
+ signup: https://api.census.gov/data/key_signup.html
1581
+ summary: Five-year population, housing, and income estimates by state or county
1582
+ formats: [JSON]
1583
+ selection: One vintage's estimates for up to fifty variables over a state, its counties, or one county
1584
+ inputs: A vintage; one to fifty variables; a state or county location
1585
+ reader: pandas
1586
+ guide: docs/providers/census-acs-5year.md
1587
+ examples:
1588
+ - examples/datasets/census-acs-5year/census-acs-5year.ipynb
1589
+ resolution:
1590
+ spatial: Detailed tables are available down to the block group; usdata selects states and counties
1591
+ temporal: Five-year period estimates, one vintage per year
1592
+ update_frequency: >-
1593
+ Released once a year; the 2020-2024 estimates were released on 8 January 2026
1594
+ citation: >-
1595
+ U.S. Census Bureau, American Community Survey 5-Year Estimates, Detailed Tables, Census Data
1596
+ API, accessed via usdata
1597
+ terms: https://www.census.gov/data/developers/about/terms-of-service.html
1598
+ variables:
1599
+ - { name: "NAME", description: "Geographic area name, such as Osage County, Oklahoma" }
1600
+ - { name: "B01003_001E", description: "Total population, estimate" }
1601
+ - { name: "B01003_001M", description: "Total population, margin of error" }
1602
+ - { name: "B25001_001E", description: "Housing units, estimate" }
1603
+ - { name: "B25001_001M", description: "Housing units, margin of error" }
1604
+ - { name: "B19013_001E", units: "US dollars of the vintage year", description: "Median household income in the past 12 months, inflation-adjusted to the vintage year, estimate" }
1605
+ - { name: "state", description: "FIPS code of the state, appended by the service" }
1606
+ - { name: "county", description: "FIPS code of the county within the state, appended by the service for county rows" }
1607
+ adapter: usdata.providers.census.acs:Acs5Year
1573
1608
 
1574
1609
  # ---------------------------------------------------------------- USDA
1575
1610
  - id: usda:cropland-data-layer
@@ -28,7 +28,7 @@ import httpx
28
28
  from pydantic import BaseModel
29
29
 
30
30
  from usdata import __version__
31
- from usdata._grib import LIBRARY_HINT
31
+ from usdata._grib import LIBRARY_HINT, import_eccodes
32
32
  from usdata.cache import ENV_VAR, cache_dir
33
33
  from usdata.mirror import ENV_VAR as MIRROR_ENV_VAR
34
34
  from usdata.models import READER_EXTRAS, Status
@@ -145,7 +145,8 @@ def _reader_check(extra: str) -> Check:
145
145
  modules: list[Any] = []
146
146
  for module in READER_MODULES[extra]:
147
147
  try:
148
- modules.append(import_module(module))
148
+ # ecCodes goes through the reader's import, which orders it after pyproj.
149
+ modules.append(import_eccodes() if module == "eccodes" else import_module(module))
149
150
  except (ImportError, RuntimeError, OSError) as error:
150
151
  # eccodes imports and then fails to load its shared library.
151
152
  return Check(
@@ -0,0 +1 @@
1
+ """Census Bureau dataset adapters."""
@@ -0,0 +1,243 @@
1
+ """American Community Survey 5-year estimates from the Census Data API, which needs a free key.
2
+
3
+ The service answers one query per request with a JSON array of arrays: a header
4
+ row naming the columns, then one row per geography, every value a string. The
5
+ requested variables come first and the geography columns (``state``,
6
+ ``county``) follow. Every data request carries a ``key`` query parameter, which
7
+ usdata reads from ``USDATA_CENSUS_KEY`` and adds only as each request is sent
8
+ (ADR 0039).
9
+
10
+ Selection (ADR 0045):
11
+
12
+ - ``vintage`` names the estimates by the last year of their five-year period, so
13
+ 2023 is 2019 through 2023. A time window is refused: a five-year estimate
14
+ describes a period, and choosing one from a window would be a guess.
15
+ - The query's ``variables`` name one to fifty API variables, ``NAME``
16
+ included, the service's limit per request. Margins of error and annotations
17
+ are variables too, such as ``B01003_001M`` and ``B01003_001EA``.
18
+ - A ``location`` names one state or county. ``geography`` says what each row
19
+ is: the place itself by default, or, for a state, every county in it. A box
20
+ names no place and is refused.
21
+ - Connecticut's codes depend on the vintage: its eight counties through 2021,
22
+ its nine planning regions from 2022. A place the vintage does not have is
23
+ refused with the ones that it does.
24
+
25
+ The response is written as served. It carries no copy of the key and identical
26
+ requests return identical bytes, so there is nothing to canonicalize. The
27
+ service answers a geography it does not have with an empty ``204`` and a bad
28
+ key with a redirect; both are refused rather than written.
29
+ """
30
+
31
+ from __future__ import annotations
32
+
33
+ import hashlib
34
+ import json
35
+ import re
36
+ from datetime import UTC, datetime, time
37
+ from pathlib import Path
38
+ from typing import Annotated
39
+
40
+ import httpx
41
+ from pydantic import BaseModel, ConfigDict, Field
42
+
43
+ from usdata.models import Asset, Place, Protocol, Query, TimeRange
44
+ from usdata.protocols import http
45
+ from usdata.providers.base import QueryError
46
+ from usdata.providers.http import HttpProvider
47
+ from usdata.providers.params import choice, int_range
48
+ from usdata.query import legacy_counties, planning_regions
49
+
50
+ SERVICE_URL = "https://api.census.gov/data"
51
+ KEY = "USDATA_CENSUS_KEY"
52
+ FIRST_VINTAGE = 2009
53
+ """The first ACS 5-year vintage the API lists, covering 2005 through 2009."""
54
+
55
+ REGIONS_FROM = 2022
56
+ """The first vintage keyed by Connecticut's planning regions rather than its old counties."""
57
+
58
+ MAX_VARIABLES = 50
59
+ """The most variables, ``NAME`` included, the service returns for one request."""
60
+
61
+ _VARIABLE = re.compile(r"[A-Z][A-Z0-9_]*")
62
+
63
+
64
+ class CensusError(QueryError):
65
+ """The Census Data API refused the request and said why, or refused the key."""
66
+
67
+
68
+ class AcsParams(BaseModel):
69
+ """Which estimates one ACS query names, and what each of its rows is."""
70
+
71
+ model_config = ConfigDict(extra="forbid")
72
+
73
+ vintage: Annotated[int, int_range(FIRST_VINTAGE, 9999)] = Field(
74
+ description=(
75
+ "Required last year of the five-year period, such as 2023 for 2019-2023; "
76
+ "the estimates are selected by vintage, not by a date window."
77
+ )
78
+ )
79
+ geography: Annotated[str, choice("state", "county")] | None = Field(
80
+ default=None,
81
+ description=(
82
+ "What each row is: state or county. Defaults to the kind of place the location "
83
+ "names; county with a state location gives every county in it."
84
+ ),
85
+ )
86
+
87
+
88
+ def _variables(names: list[str], dataset_id: str) -> list[str]:
89
+ """The query's variables upper-cased, repeats dropped, in the order given."""
90
+ ordered = list(dict.fromkeys(name.strip().upper() for name in names))
91
+ if not ordered or not all(ordered):
92
+ raise QueryError(
93
+ f"{dataset_id} needs variables naming the estimates to return, such as "
94
+ "NAME,B01003_001E,B01003_001M"
95
+ )
96
+ if bad := [name for name in ordered if not _VARIABLE.fullmatch(name)]:
97
+ raise QueryError(f"{dataset_id} variable {bad[0]!r} is not a Census API variable name")
98
+ if len(ordered) > MAX_VARIABLES:
99
+ raise QueryError(
100
+ f"{dataset_id} was given {len(ordered)} variables; the service returns at most "
101
+ f"{MAX_VARIABLES} per request, NAME included, so split them across manifest sources"
102
+ )
103
+ return ordered
104
+
105
+
106
+ def _labels(places: tuple[Place, ...]) -> str:
107
+ return "; ".join(place.label for place in places)
108
+
109
+
110
+ class Acs5Year(HttpProvider):
111
+ """ACS 5-year detailed-table estimates for one vintage, variable list, and place."""
112
+
113
+ params_model = AcsParams
114
+
115
+ def list_assets(self, query: Query) -> list[Asset]:
116
+ """One JSON asset for the query's vintage, variables, and place; nothing is requested."""
117
+ params = self.parse_params(query, AcsParams)
118
+ self.reject(
119
+ query,
120
+ "text",
121
+ "time",
122
+ hint="choose the five-year period with vintage, such as vintage=2023 for 2019-2023",
123
+ )
124
+ variables = _variables(query.variables, self.dataset.id)
125
+ place = self.place_of(query, hint="the estimates are keyed by state and county")
126
+ if place is None:
127
+ raise QueryError(f"{self.dataset.id} needs a location naming a state or county")
128
+ self._check_connecticut(place, params.vintage)
129
+ geography = params.geography or place.kind
130
+ if geography == "state" and place.kind == "county":
131
+ raise QueryError(
132
+ f"{self.dataset.id} geography=state needs a state location, not {place.label}"
133
+ )
134
+ selection: dict[str, str]
135
+ if place.kind == "state" and geography == "state":
136
+ selection, label = {"for": f"state:{place.state_fips}"}, place.geoid
137
+ elif place.kind == "state":
138
+ selection = {"for": "county:*", "in": f"state:{place.state_fips}"}
139
+ label = f"{place.geoid}-counties"
140
+ else:
141
+ selection = {"for": f"county:{place.county_fips}", "in": f"state:{place.state_fips}"}
142
+ label = place.geoid
143
+ url = httpx.URL(
144
+ f"{SERVICE_URL}/{params.vintage}/acs/acs5",
145
+ params={"get": ",".join(variables), **selection},
146
+ )
147
+ digest = hashlib.sha256(",".join(variables).encode()).hexdigest()[:12]
148
+ first = params.vintage - 4
149
+ return [
150
+ Asset(
151
+ id=f"acs5_{params.vintage}_{label}_{digest}.json",
152
+ dataset_id=self.dataset.id,
153
+ href=str(url),
154
+ protocol=Protocol.HTTP,
155
+ media_type="application/json",
156
+ time=TimeRange(
157
+ start=datetime(first, 1, 1, tzinfo=UTC),
158
+ end=datetime.combine(datetime(params.vintage, 12, 31), time.max, UTC),
159
+ ),
160
+ bbox=query.bbox,
161
+ properties={"vintage": str(params.vintage), "period": f"{first}-{params.vintage}"},
162
+ )
163
+ ]
164
+
165
+ def _check_connecticut(self, place: Place, vintage: int) -> None:
166
+ """Refuse a Connecticut place the vintage's geography does not have (ADR 0045)."""
167
+ if vintage < REGIONS_FROM and (counties := legacy_counties(place)):
168
+ raise QueryError(
169
+ f"{self.dataset.id} vintage {vintage} keys Connecticut by its eight counties "
170
+ f"before 2022, and {place.label} is a planning region that replaced them; name "
171
+ f"a county it overlaps with location: {_labels(counties)}"
172
+ )
173
+ if vintage >= REGIONS_FROM and (regions := planning_regions(place)):
174
+ raise QueryError(
175
+ f"{self.dataset.id} vintage {vintage} keys Connecticut by the planning regions "
176
+ f"that replaced its counties in 2022, and {place.label} is one of those "
177
+ f"counties; name a region it overlaps with location: {_labels(regions)}"
178
+ )
179
+
180
+ def fetch(self, asset: Asset, dest: Path) -> Path:
181
+ """Request the estimates with this adapter's key and write the response as served."""
182
+ vintage = asset.properties.get("vintage", "?")
183
+ with self.redacted_errors():
184
+ keyed = httpx.URL(asset.href).copy_merge_params({"key": self.credentials[KEY]})
185
+ try:
186
+ # A bad key is a redirect to an explanation page, which is refused, not followed.
187
+ response = http.get(keyed, self._http(), follow_redirects=False)
188
+ except httpx.HTTPStatusError as error:
189
+ # Any other status, such as a 503, stays an upstream failure.
190
+ if (refusal := _refusal(error.response, vintage)) is None:
191
+ raise
192
+ raise refusal from error
193
+ if response.status_code == 204:
194
+ raise CensusError(
195
+ f"the Census Data API has no rows for {_where(asset.href)} in ACS 5-year "
196
+ f"vintage {vintage}; the place's code may differ in that vintage"
197
+ )
198
+ _check_table(response)
199
+ dest.write_bytes(response.content)
200
+ return dest
201
+
202
+
203
+ def _refusal(response: httpx.Response, vintage: str) -> CensusError | None:
204
+ """The service's refusal as an error that says why, or None for an upstream failure."""
205
+ status = response.status_code
206
+ location = response.headers.get("location", "")
207
+ if response.is_redirect and "invalid_key" in location:
208
+ return CensusError(
209
+ f"the Census Data API refused the key in {KEY} as invalid; check it, and that the "
210
+ "activation link in the signup email was followed"
211
+ )
212
+ if response.is_redirect and "missing_key" in location:
213
+ return CensusError(f"the Census Data API answered as if no key was sent; check {KEY}")
214
+ if status == 400 and response.headers.get("content-type", "").startswith("text/plain"):
215
+ reason = response.text.strip().removeprefix("error:").strip()
216
+ return CensusError(f"the Census Data API refused the request: {reason}")
217
+ if status == 404:
218
+ return CensusError(f"the Census Data API publishes no ACS 5-year vintage {vintage}")
219
+ return None
220
+
221
+
222
+ def _where(href: str) -> str:
223
+ """The geography a request names, as the service's ``for`` and ``in`` spell it."""
224
+ params = httpx.URL(href).params
225
+ place = params.get("for", "")
226
+ return f"{place} in {params['in']}" if "in" in params else place
227
+
228
+
229
+ def _check_table(response: httpx.Response) -> None:
230
+ """Raise unless the body is the array of rows, header first, that the service promises."""
231
+ try:
232
+ body = json.loads(response.content)
233
+ except ValueError:
234
+ body = None
235
+ if not (
236
+ isinstance(body, list)
237
+ and body
238
+ and all(isinstance(row, list) and len(row) == len(body[0]) for row in body)
239
+ ):
240
+ raise httpx.DecodingError(
241
+ "the Census Data API answered with something other than a table of rows",
242
+ request=response.request,
243
+ )
@@ -157,6 +157,22 @@ class PullResult(BaseModel):
157
157
  return items[0]
158
158
 
159
159
 
160
+ def _source_keys(entries: Iterable[LockedAsset]) -> list[str]:
161
+ """The manifest source key each lockfile entry belongs to, in entry order.
162
+
163
+ A lockfile written before sources had keys records none; such an entry falls
164
+ back to the one-based position of its dataset id among the datasets seen.
165
+ """
166
+ positions: dict[str, str] = {}
167
+ keys: list[str] = []
168
+ for entry in entries:
169
+ dataset_id = entry.asset.dataset_id
170
+ if dataset_id not in positions:
171
+ positions[dataset_id] = str(len(positions) + 1)
172
+ keys.append(entry.source or positions[dataset_id])
173
+ return keys
174
+
175
+
160
176
  def _by_source(pairs: Iterable[tuple[LockedAsset, FetchedAsset]]) -> dict[str, list[FetchedAsset]]:
161
177
  """Group fetched assets by the source key their lockfile entry records.
162
178
 
@@ -169,12 +185,9 @@ def _by_source(pairs: Iterable[tuple[LockedAsset, FetchedAsset]]) -> dict[str, l
169
185
  them and a forced pull rewrites the lockfile.
170
186
  """
171
187
  grouped: dict[str, list[FetchedAsset]] = {}
172
- positions: dict[str, str] = {}
173
- for entry, item in pairs:
174
- dataset_id = entry.asset.dataset_id
175
- if dataset_id not in positions:
176
- positions[dataset_id] = str(len(positions) + 1)
177
- grouped.setdefault(entry.source or positions[dataset_id], []).append(item)
188
+ pairs = list(pairs)
189
+ for key, (_, item) in zip(_source_keys([entry for entry, _ in pairs]), pairs, strict=True):
190
+ grouped.setdefault(key, []).append(item)
178
191
  by_id: dict[str, FetchedAsset] = {}
179
192
  for items in grouped.values():
180
193
  by_id.update({item.asset.id: item for item in items})
@@ -381,6 +394,123 @@ def plan(manifest_path: str | os.PathLike[str], *, registry: Registry | None = N
381
394
  return Plan(manifest=manifest.name, sources=sources)
382
395
 
383
396
 
397
+ class AssetChange(BaseModel):
398
+ """A pinned asset that today's listing still names, but describes differently."""
399
+
400
+ asset_id: str
401
+ locked_size: int | None = Field(description="Size the listing gave when the pin was written")
402
+ listed_size: int | None = Field(description="Size today's listing gives")
403
+ locked_href: str
404
+ listed_href: str
405
+
406
+
407
+ class SourceComparison(BaseModel):
408
+ """How one source's pinned assets compare with what its adapter lists today."""
409
+
410
+ source: str = Field(description="The source's manifest key: its name, or its position")
411
+ dataset_id: str
412
+ added: list[Asset] = Field(description="Listed today and not pinned")
413
+ removed: list[Asset] = Field(description="Pinned and no longer listed")
414
+ changed: list[AssetChange] = Field(
415
+ description="Pinned and listed, with a different href, or a different size where both "
416
+ "the pin and the listing give one"
417
+ )
418
+ unchanged: int = Field(description="How many pinned assets are listed exactly as pinned")
419
+
420
+ @computed_field(description="Whether a re-resolve of this source would pin the same assets")
421
+ @property
422
+ def matches(self) -> bool:
423
+ """True when nothing was added, removed, or changed."""
424
+ return not (self.added or self.removed or self.changed)
425
+
426
+
427
+ class ListingComparison(BaseModel):
428
+ """A lockfile against today's listings: whether a re-resolve would pin the same assets.
429
+
430
+ Nothing is downloaded, so the bytes are not compared: restore checks those,
431
+ and ``verify`` checks the cache. What this sees is the set of files, an
432
+ asset added, removed, or re-described since the lockfile was written.
433
+ """
434
+
435
+ manifest: str
436
+ lockfile: Path
437
+ sources: list[SourceComparison] = Field(description="One entry per manifest source, in order")
438
+
439
+ @computed_field(description="Whether a re-resolve of every source would pin the same assets")
440
+ @property
441
+ def matches(self) -> bool:
442
+ """True when every source matches."""
443
+ return all(source.matches for source in self.sources)
444
+
445
+
446
+ def _changed(pinned: Asset, listed: Asset) -> AssetChange | None:
447
+ """How ``listed`` differs from the pin of the same id, or None when it does not."""
448
+ sizes_differ = None not in (pinned.size, listed.size) and pinned.size != listed.size
449
+ if not sizes_differ and pinned.href == listed.href:
450
+ return None
451
+ return AssetChange(
452
+ asset_id=pinned.id,
453
+ locked_size=pinned.size,
454
+ listed_size=listed.size,
455
+ locked_href=pinned.href,
456
+ listed_href=listed.href,
457
+ )
458
+
459
+
460
+ def compare_listing(
461
+ manifest_path: str | os.PathLike[str], *, registry: Registry | None = None
462
+ ) -> ListingComparison:
463
+ """Compare a manifest's lockfile with what its sources list today; download nothing.
464
+
465
+ Every source is listed as ``plan`` lists it, ``select`` rules included, and
466
+ its assets are matched to the lockfile's entries for that source by id. An
467
+ asset only in the listing is ``added``, one only in the lockfile ``removed``,
468
+ and one in both with a different href, or a different size where both give
469
+ one, ``changed``. A listing that reports no size cannot show a size change;
470
+ restoring the pin checks the bytes themselves.
471
+
472
+ Args:
473
+ manifest_path: Path of the manifest YAML file, as a string or any ``os.PathLike``.
474
+ registry: Registry to resolve dataset ids against; the default one if omitted.
475
+
476
+ Returns:
477
+ A ``ListingComparison`` with one entry per manifest source.
478
+
479
+ Raises:
480
+ FileNotFoundError: The manifest has no lockfile yet.
481
+ ManifestChanged: The manifest changed since its lockfile was written.
482
+ """
483
+ manifest_path = Path(manifest_path)
484
+ lock_path = lockfile_path(manifest_path)
485
+ if not lock_path.exists():
486
+ raise FileNotFoundError(f"no lockfile at {lock_path}; run pull first")
487
+ lock = Lockfile.load(lock_path)
488
+ _check_manifest(manifest_path, lock)
489
+ pinned: dict[str, dict[str, Asset]] = {}
490
+ for key, entry in zip(_source_keys(lock.assets), lock.assets, strict=True):
491
+ pinned.setdefault(key, {})[entry.asset.id] = entry.asset
492
+ sources: list[SourceComparison] = []
493
+ for listed in plan(manifest_path, registry=registry).sources:
494
+ locked = pinned.get(listed.source, {})
495
+ today = {asset.id: asset for asset in listed.assets}
496
+ changes = [
497
+ change
498
+ for asset_id, asset in locked.items()
499
+ if asset_id in today and (change := _changed(asset, today[asset_id])) is not None
500
+ ]
501
+ sources.append(
502
+ SourceComparison(
503
+ source=listed.source,
504
+ dataset_id=listed.dataset_id,
505
+ added=[asset for asset in listed.assets if asset.id not in locked],
506
+ removed=[asset for asset_id, asset in locked.items() if asset_id not in today],
507
+ changed=changes,
508
+ unchanged=sum(1 for asset_id in locked if asset_id in today) - len(changes),
509
+ )
510
+ )
511
+ return ListingComparison(manifest=lock.manifest, lockfile=lock_path, sources=sources)
512
+
513
+
384
514
  def _selected(lock: Lockfile, update: Iterable[str]) -> set[str]:
385
515
  """Ids of the locked assets that ``update`` selectors (asset or dataset ids) name."""
386
516
  selectors = set(update)
@@ -89,6 +89,24 @@ def legacy_counties(place: Place) -> tuple[Place, ...]:
89
89
  return tuple(table.places[geoid][0] for geoid in table.legacy.get(place.geoid, ()))
90
90
 
91
91
 
92
+ def planning_regions(place: Place) -> tuple[Place, ...]:
93
+ """The Connecticut planning regions a pre-2022 county overlaps, or () for any other place.
94
+
95
+ The reverse of ``legacy_counties``, for a source that switched to the
96
+ regions in 2022, such as Census geography from that vintage on, and so has
97
+ no rows under an old county's code.
98
+
99
+ Args:
100
+ place: Any place from ``find_place`` or ``build_query``.
101
+
102
+ Returns:
103
+ The planning regions, in FIPS order, when ``place`` is a legacy county.
104
+ """
105
+ table = _places()
106
+ regions = sorted(region for region, counties in table.legacy.items() if place.geoid in counties)
107
+ return tuple(table.places[geoid][0] for geoid in regions)
108
+
109
+
92
110
  def resolve_place(name: str) -> BBox:
93
111
  """Resolve a state, qualified county name, or quoted two/five-digit FIPS code to its box."""
94
112
  return find_place(name)[1]
@@ -47,6 +47,12 @@ AQS_DATASET = "epa:aqs-daily"
47
47
  AQS_PREFIX = "aqs-daily_"
48
48
  """How an ``epa:aqs-daily`` asset id begins, so a copied file is still recognized."""
49
49
 
50
+ ACS_DATASET = "census:acs-5year"
51
+ """The dataset whose Census Data API tables the ``acs`` reader opens."""
52
+
53
+ ACS_PREFIX = "acs5_"
54
+ """How a ``census:acs-5year`` asset id begins, so a copied file is still recognized."""
55
+
50
56
  STORM_EVENTS_DATASET = "noaa:storm-events"
51
57
  """The dataset whose local timestamps the CSV reader pairs with UTC columns."""
52
58
 
@@ -388,12 +394,13 @@ def open_asset(fetched: FetchedAsset) -> Any:
388
394
 
389
395
  The reader is inferred from the asset's dataset, media type, and id:
390
396
  ``noaa:nexrad-level2`` volumes open as with ``open_nexrad``, HURDAT2 best
391
- tracks and AQS daily-summary JSON by their dataset or filename, NetCDF4 as
397
+ tracks, AQS daily-summary JSON, and ACS tables by their dataset or filename, NetCDF4 as
392
398
  with ``open_netcdf``, GRIB2 as with ``open_grib2``, and CSV as with
393
399
  ``open_csv``, with its units row detected. Each takes its defaults; a file
394
400
  that needs options is opened with the function for its format instead.
395
- HURDAT2 best-track text returns one row per track point, and AQS daily-summary
396
- JSON one row per monitor, day, and standard; neither takes options.
401
+ HURDAT2 best-track text returns one row per track point, AQS daily-summary
402
+ JSON one row per monitor, day, and standard, and an ACS table one row per
403
+ geography; none takes options.
397
404
 
398
405
  Raises:
399
406
  UnsupportedFormat: Nothing about the asset names a format usdata reads.
@@ -411,6 +418,10 @@ def open_asset(fetched: FetchedAsset) -> Any:
411
418
  from usdata._aqs import open_aqs
412
419
 
413
420
  return open_aqs(fetched)
421
+ if dataset_id == ACS_DATASET or (name.startswith(ACS_PREFIX) and name.endswith(".json")):
422
+ from usdata._census import open_acs
423
+
424
+ return open_acs(fetched)
414
425
  if media_type in NETCDF_MEDIA_TYPES:
415
426
  return open_netcdf(fetched)
416
427
  if media_type in GRIB2_MEDIA_TYPES or (
@@ -424,7 +435,7 @@ def open_asset(fetched: FetchedAsset) -> Any:
424
435
  raise UnsupportedFormat(
425
436
  f"no reader for {fetched.asset.media_type!r}; supported formats are CSV, "
426
437
  "ERDDAP CSV, NetCDF4, GRIB2, NEXRAD Level II, HURDAT2 best tracks, "
427
- "and AQS daily JSON. For a CSV with ambiguous metadata, use open_csv; "
438
+ "AQS daily JSON, and ACS tables. For a CSV with ambiguous metadata, use open_csv; "
428
439
  "otherwise use fetched.path with a format-specific reader"
429
440
  )
430
441
 
@@ -335,7 +335,8 @@ def check_declared_capabilities(
335
335
  variable = dataset.variables[0].name
336
336
  with _patched_client(unexpected_client), adapter_factory() as adapter:
337
337
  base = scenario_query
338
- boxed = base.model_copy(update={"bbox": PROBE_BBOX})
338
+ # A scenario may name a place of its own; the bare-box probe must not keep it.
339
+ boxed = base.model_copy(update={"bbox": PROBE_BBOX, "place": None})
339
340
  placed = boxed.model_copy(update={"place": PROBE_PLACE})
340
341
  bare_box_refused = _refuses_bare_box(adapter, boxed)
341
342
  bbox_refused = bare_box_refused or _refuses(adapter, boxed, "bbox")
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes