usdata 0.30.0__tar.gz → 0.31.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.31.0}/PKG-INFO +2 -2
  2. {usdata-0.30.0 → usdata-0.31.0}/README.md +1 -1
  3. {usdata-0.30.0 → usdata-0.31.0}/pyproject.toml +1 -1
  4. {usdata-0.30.0 → usdata-0.31.0}/pyproject.toml.orig +1 -1
  5. usdata-0.31.0/src/usdata/_census.py +51 -0
  6. {usdata-0.30.0 → usdata-0.31.0}/src/usdata/_fetch.py +1 -1
  7. {usdata-0.30.0 → usdata-0.31.0}/src/usdata/data/registry.yaml +43 -8
  8. usdata-0.31.0/src/usdata/providers/census/__init__.py +1 -0
  9. usdata-0.31.0/src/usdata/providers/census/acs.py +243 -0
  10. {usdata-0.30.0 → usdata-0.31.0}/src/usdata/query.py +18 -0
  11. {usdata-0.30.0 → usdata-0.31.0}/src/usdata/readers.py +15 -4
  12. {usdata-0.30.0 → usdata-0.31.0}/src/usdata/testing.py +2 -1
  13. {usdata-0.30.0 → usdata-0.31.0}/src/usdata/__init__.py +0 -0
  14. {usdata-0.30.0 → usdata-0.31.0}/src/usdata/__main__.py +0 -0
  15. {usdata-0.30.0 → usdata-0.31.0}/src/usdata/_aqs.py +0 -0
  16. {usdata-0.30.0 → usdata-0.31.0}/src/usdata/_files.py +0 -0
  17. {usdata-0.30.0 → usdata-0.31.0}/src/usdata/_grib.py +0 -0
  18. {usdata-0.30.0 → usdata-0.31.0}/src/usdata/_hurdat2.py +0 -0
  19. {usdata-0.30.0 → usdata-0.31.0}/src/usdata/_netcdf.py +0 -0
  20. {usdata-0.30.0 → usdata-0.31.0}/src/usdata/_progress.py +0 -0
  21. {usdata-0.30.0 → usdata-0.31.0}/src/usdata/_radar.py +0 -0
  22. {usdata-0.30.0 → usdata-0.31.0}/src/usdata/cache.py +0 -0
  23. {usdata-0.30.0 → usdata-0.31.0}/src/usdata/cache_ops.py +0 -0
  24. {usdata-0.30.0 → usdata-0.31.0}/src/usdata/cite.py +0 -0
  25. {usdata-0.30.0 → usdata-0.31.0}/src/usdata/cli/__init__.py +0 -0
  26. {usdata-0.30.0 → usdata-0.31.0}/src/usdata/cli/app.py +0 -0
  27. {usdata-0.30.0 → usdata-0.31.0}/src/usdata/cli/cache.py +0 -0
  28. {usdata-0.30.0 → usdata-0.31.0}/src/usdata/cli/cite.py +0 -0
  29. {usdata-0.30.0 → usdata-0.31.0}/src/usdata/cli/doctor.py +0 -0
  30. {usdata-0.30.0 → usdata-0.31.0}/src/usdata/cli/inspect.py +0 -0
  31. {usdata-0.30.0 → usdata-0.31.0}/src/usdata/cli/progress.py +0 -0
  32. {usdata-0.30.0 → usdata-0.31.0}/src/usdata/data/nexrad_sites.csv +0 -0
  33. {usdata-0.30.0 → usdata-0.31.0}/src/usdata/data/places.csv +0 -0
  34. {usdata-0.30.0 → usdata-0.31.0}/src/usdata/data/places.sources.json +0 -0
  35. {usdata-0.30.0 → usdata-0.31.0}/src/usdata/doctor.py +0 -0
  36. {usdata-0.30.0 → usdata-0.31.0}/src/usdata/inspect.py +0 -0
  37. {usdata-0.30.0 → usdata-0.31.0}/src/usdata/manifest.py +0 -0
  38. {usdata-0.30.0 → usdata-0.31.0}/src/usdata/mirror.py +0 -0
  39. {usdata-0.30.0 → usdata-0.31.0}/src/usdata/models.py +0 -0
  40. {usdata-0.30.0 → usdata-0.31.0}/src/usdata/protocols/__init__.py +0 -0
  41. {usdata-0.30.0 → usdata-0.31.0}/src/usdata/protocols/erddap.py +0 -0
  42. {usdata-0.30.0 → usdata-0.31.0}/src/usdata/protocols/http.py +0 -0
  43. {usdata-0.30.0 → usdata-0.31.0}/src/usdata/protocols/listing.py +0 -0
  44. {usdata-0.30.0 → usdata-0.31.0}/src/usdata/protocols/s3.py +0 -0
  45. {usdata-0.30.0 → usdata-0.31.0}/src/usdata/provenance.py +0 -0
  46. {usdata-0.30.0 → usdata-0.31.0}/src/usdata/providers/__init__.py +0 -0
  47. {usdata-0.30.0 → usdata-0.31.0}/src/usdata/providers/base.py +0 -0
  48. {usdata-0.30.0 → usdata-0.31.0}/src/usdata/providers/credentials.py +0 -0
  49. {usdata-0.30.0 → usdata-0.31.0}/src/usdata/providers/epa/__init__.py +0 -0
  50. {usdata-0.30.0 → usdata-0.31.0}/src/usdata/providers/epa/aqs.py +0 -0
  51. {usdata-0.30.0 → usdata-0.31.0}/src/usdata/providers/fema/__init__.py +0 -0
  52. {usdata-0.30.0 → usdata-0.31.0}/src/usdata/providers/fema/declarations.py +0 -0
  53. {usdata-0.30.0 → usdata-0.31.0}/src/usdata/providers/http.py +0 -0
  54. {usdata-0.30.0 → usdata-0.31.0}/src/usdata/providers/noaa/__init__.py +0 -0
  55. {usdata-0.30.0 → usdata-0.31.0}/src/usdata/providers/noaa/coastwatch.py +0 -0
  56. {usdata-0.30.0 → usdata-0.31.0}/src/usdata/providers/noaa/coops.py +0 -0
  57. {usdata-0.30.0 → usdata-0.31.0}/src/usdata/providers/noaa/gfs.py +0 -0
  58. {usdata-0.30.0 → usdata-0.31.0}/src/usdata/providers/noaa/ghcnd.py +0 -0
  59. {usdata-0.30.0 → usdata-0.31.0}/src/usdata/providers/noaa/glm.py +0 -0
  60. {usdata-0.30.0 → usdata-0.31.0}/src/usdata/providers/noaa/goes.py +0 -0
  61. {usdata-0.30.0 → usdata-0.31.0}/src/usdata/providers/noaa/grib_index.py +0 -0
  62. {usdata-0.30.0 → usdata-0.31.0}/src/usdata/providers/noaa/gsom.py +0 -0
  63. {usdata-0.30.0 → usdata-0.31.0}/src/usdata/providers/noaa/gsoy.py +0 -0
  64. {usdata-0.30.0 → usdata-0.31.0}/src/usdata/providers/noaa/hrrr.py +0 -0
  65. {usdata-0.30.0 → usdata-0.31.0}/src/usdata/providers/noaa/hurdat2.py +0 -0
  66. {usdata-0.30.0 → usdata-0.31.0}/src/usdata/providers/noaa/ibtracs.py +0 -0
  67. {usdata-0.30.0 → usdata-0.31.0}/src/usdata/providers/noaa/lcd.py +0 -0
  68. {usdata-0.30.0 → usdata-0.31.0}/src/usdata/providers/noaa/mrms.py +0 -0
  69. {usdata-0.30.0 → usdata-0.31.0}/src/usdata/providers/noaa/nbm.py +0 -0
  70. {usdata-0.30.0 → usdata-0.31.0}/src/usdata/providers/noaa/nexrad.py +0 -0
  71. {usdata-0.30.0 → usdata-0.31.0}/src/usdata/providers/noaa/nexrad_level3.py +0 -0
  72. {usdata-0.30.0 → usdata-0.31.0}/src/usdata/providers/noaa/normals.py +0 -0
  73. {usdata-0.30.0 → usdata-0.31.0}/src/usdata/providers/noaa/nws_vtec.py +0 -0
  74. {usdata-0.30.0 → usdata-0.31.0}/src/usdata/providers/noaa/rap.py +0 -0
  75. {usdata-0.30.0 → usdata-0.31.0}/src/usdata/providers/noaa/sites.py +0 -0
  76. {usdata-0.30.0 → usdata-0.31.0}/src/usdata/providers/noaa/spc.py +0 -0
  77. {usdata-0.30.0 → usdata-0.31.0}/src/usdata/providers/noaa/storm_events.py +0 -0
  78. {usdata-0.30.0 → usdata-0.31.0}/src/usdata/providers/params.py +0 -0
  79. {usdata-0.30.0 → usdata-0.31.0}/src/usdata/providers/usgs/__init__.py +0 -0
  80. {usdata-0.30.0 → usdata-0.31.0}/src/usdata/providers/usgs/daily.py +0 -0
  81. {usdata-0.30.0 → usdata-0.31.0}/src/usdata/providers/usgs/earthquakes.py +0 -0
  82. {usdata-0.30.0 → usdata-0.31.0}/src/usdata/pull.py +0 -0
  83. {usdata-0.30.0 → usdata-0.31.0}/src/usdata/py.typed +0 -0
  84. {usdata-0.30.0 → usdata-0.31.0}/src/usdata/registry.py +0 -0
  85. {usdata-0.30.0 → usdata-0.31.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.31.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.31.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.31.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"
@@ -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
@@ -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
@@ -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
+ )
@@ -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
File without changes
File without changes
File without changes
File without changes
File without changes