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.
- {usdata-0.30.0 → usdata-0.31.0}/PKG-INFO +2 -2
- {usdata-0.30.0 → usdata-0.31.0}/README.md +1 -1
- {usdata-0.30.0 → usdata-0.31.0}/pyproject.toml +1 -1
- {usdata-0.30.0 → usdata-0.31.0}/pyproject.toml.orig +1 -1
- usdata-0.31.0/src/usdata/_census.py +51 -0
- {usdata-0.30.0 → usdata-0.31.0}/src/usdata/_fetch.py +1 -1
- {usdata-0.30.0 → usdata-0.31.0}/src/usdata/data/registry.yaml +43 -8
- usdata-0.31.0/src/usdata/providers/census/__init__.py +1 -0
- usdata-0.31.0/src/usdata/providers/census/acs.py +243 -0
- {usdata-0.30.0 → usdata-0.31.0}/src/usdata/query.py +18 -0
- {usdata-0.30.0 → usdata-0.31.0}/src/usdata/readers.py +15 -4
- {usdata-0.30.0 → usdata-0.31.0}/src/usdata/testing.py +2 -1
- {usdata-0.30.0 → usdata-0.31.0}/src/usdata/__init__.py +0 -0
- {usdata-0.30.0 → usdata-0.31.0}/src/usdata/__main__.py +0 -0
- {usdata-0.30.0 → usdata-0.31.0}/src/usdata/_aqs.py +0 -0
- {usdata-0.30.0 → usdata-0.31.0}/src/usdata/_files.py +0 -0
- {usdata-0.30.0 → usdata-0.31.0}/src/usdata/_grib.py +0 -0
- {usdata-0.30.0 → usdata-0.31.0}/src/usdata/_hurdat2.py +0 -0
- {usdata-0.30.0 → usdata-0.31.0}/src/usdata/_netcdf.py +0 -0
- {usdata-0.30.0 → usdata-0.31.0}/src/usdata/_progress.py +0 -0
- {usdata-0.30.0 → usdata-0.31.0}/src/usdata/_radar.py +0 -0
- {usdata-0.30.0 → usdata-0.31.0}/src/usdata/cache.py +0 -0
- {usdata-0.30.0 → usdata-0.31.0}/src/usdata/cache_ops.py +0 -0
- {usdata-0.30.0 → usdata-0.31.0}/src/usdata/cite.py +0 -0
- {usdata-0.30.0 → usdata-0.31.0}/src/usdata/cli/__init__.py +0 -0
- {usdata-0.30.0 → usdata-0.31.0}/src/usdata/cli/app.py +0 -0
- {usdata-0.30.0 → usdata-0.31.0}/src/usdata/cli/cache.py +0 -0
- {usdata-0.30.0 → usdata-0.31.0}/src/usdata/cli/cite.py +0 -0
- {usdata-0.30.0 → usdata-0.31.0}/src/usdata/cli/doctor.py +0 -0
- {usdata-0.30.0 → usdata-0.31.0}/src/usdata/cli/inspect.py +0 -0
- {usdata-0.30.0 → usdata-0.31.0}/src/usdata/cli/progress.py +0 -0
- {usdata-0.30.0 → usdata-0.31.0}/src/usdata/data/nexrad_sites.csv +0 -0
- {usdata-0.30.0 → usdata-0.31.0}/src/usdata/data/places.csv +0 -0
- {usdata-0.30.0 → usdata-0.31.0}/src/usdata/data/places.sources.json +0 -0
- {usdata-0.30.0 → usdata-0.31.0}/src/usdata/doctor.py +0 -0
- {usdata-0.30.0 → usdata-0.31.0}/src/usdata/inspect.py +0 -0
- {usdata-0.30.0 → usdata-0.31.0}/src/usdata/manifest.py +0 -0
- {usdata-0.30.0 → usdata-0.31.0}/src/usdata/mirror.py +0 -0
- {usdata-0.30.0 → usdata-0.31.0}/src/usdata/models.py +0 -0
- {usdata-0.30.0 → usdata-0.31.0}/src/usdata/protocols/__init__.py +0 -0
- {usdata-0.30.0 → usdata-0.31.0}/src/usdata/protocols/erddap.py +0 -0
- {usdata-0.30.0 → usdata-0.31.0}/src/usdata/protocols/http.py +0 -0
- {usdata-0.30.0 → usdata-0.31.0}/src/usdata/protocols/listing.py +0 -0
- {usdata-0.30.0 → usdata-0.31.0}/src/usdata/protocols/s3.py +0 -0
- {usdata-0.30.0 → usdata-0.31.0}/src/usdata/provenance.py +0 -0
- {usdata-0.30.0 → usdata-0.31.0}/src/usdata/providers/__init__.py +0 -0
- {usdata-0.30.0 → usdata-0.31.0}/src/usdata/providers/base.py +0 -0
- {usdata-0.30.0 → usdata-0.31.0}/src/usdata/providers/credentials.py +0 -0
- {usdata-0.30.0 → usdata-0.31.0}/src/usdata/providers/epa/__init__.py +0 -0
- {usdata-0.30.0 → usdata-0.31.0}/src/usdata/providers/epa/aqs.py +0 -0
- {usdata-0.30.0 → usdata-0.31.0}/src/usdata/providers/fema/__init__.py +0 -0
- {usdata-0.30.0 → usdata-0.31.0}/src/usdata/providers/fema/declarations.py +0 -0
- {usdata-0.30.0 → usdata-0.31.0}/src/usdata/providers/http.py +0 -0
- {usdata-0.30.0 → usdata-0.31.0}/src/usdata/providers/noaa/__init__.py +0 -0
- {usdata-0.30.0 → usdata-0.31.0}/src/usdata/providers/noaa/coastwatch.py +0 -0
- {usdata-0.30.0 → usdata-0.31.0}/src/usdata/providers/noaa/coops.py +0 -0
- {usdata-0.30.0 → usdata-0.31.0}/src/usdata/providers/noaa/gfs.py +0 -0
- {usdata-0.30.0 → usdata-0.31.0}/src/usdata/providers/noaa/ghcnd.py +0 -0
- {usdata-0.30.0 → usdata-0.31.0}/src/usdata/providers/noaa/glm.py +0 -0
- {usdata-0.30.0 → usdata-0.31.0}/src/usdata/providers/noaa/goes.py +0 -0
- {usdata-0.30.0 → usdata-0.31.0}/src/usdata/providers/noaa/grib_index.py +0 -0
- {usdata-0.30.0 → usdata-0.31.0}/src/usdata/providers/noaa/gsom.py +0 -0
- {usdata-0.30.0 → usdata-0.31.0}/src/usdata/providers/noaa/gsoy.py +0 -0
- {usdata-0.30.0 → usdata-0.31.0}/src/usdata/providers/noaa/hrrr.py +0 -0
- {usdata-0.30.0 → usdata-0.31.0}/src/usdata/providers/noaa/hurdat2.py +0 -0
- {usdata-0.30.0 → usdata-0.31.0}/src/usdata/providers/noaa/ibtracs.py +0 -0
- {usdata-0.30.0 → usdata-0.31.0}/src/usdata/providers/noaa/lcd.py +0 -0
- {usdata-0.30.0 → usdata-0.31.0}/src/usdata/providers/noaa/mrms.py +0 -0
- {usdata-0.30.0 → usdata-0.31.0}/src/usdata/providers/noaa/nbm.py +0 -0
- {usdata-0.30.0 → usdata-0.31.0}/src/usdata/providers/noaa/nexrad.py +0 -0
- {usdata-0.30.0 → usdata-0.31.0}/src/usdata/providers/noaa/nexrad_level3.py +0 -0
- {usdata-0.30.0 → usdata-0.31.0}/src/usdata/providers/noaa/normals.py +0 -0
- {usdata-0.30.0 → usdata-0.31.0}/src/usdata/providers/noaa/nws_vtec.py +0 -0
- {usdata-0.30.0 → usdata-0.31.0}/src/usdata/providers/noaa/rap.py +0 -0
- {usdata-0.30.0 → usdata-0.31.0}/src/usdata/providers/noaa/sites.py +0 -0
- {usdata-0.30.0 → usdata-0.31.0}/src/usdata/providers/noaa/spc.py +0 -0
- {usdata-0.30.0 → usdata-0.31.0}/src/usdata/providers/noaa/storm_events.py +0 -0
- {usdata-0.30.0 → usdata-0.31.0}/src/usdata/providers/params.py +0 -0
- {usdata-0.30.0 → usdata-0.31.0}/src/usdata/providers/usgs/__init__.py +0 -0
- {usdata-0.30.0 → usdata-0.31.0}/src/usdata/providers/usgs/daily.py +0 -0
- {usdata-0.30.0 → usdata-0.31.0}/src/usdata/providers/usgs/earthquakes.py +0 -0
- {usdata-0.30.0 → usdata-0.31.0}/src/usdata/pull.py +0 -0
- {usdata-0.30.0 → usdata-0.31.0}/src/usdata/py.typed +0 -0
- {usdata-0.30.0 → usdata-0.31.0}/src/usdata/registry.py +0 -0
- {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.
|
|
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-
|
|
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-
|
|
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
|
|
@@ -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
|
|
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:
|
|
1559
|
+
status: available
|
|
1560
1560
|
domain: demographics
|
|
1561
|
-
|
|
1561
|
+
since: "0.31"
|
|
1562
1562
|
title: American Community Survey 5-Year Estimates
|
|
1563
1563
|
description: >-
|
|
1564
|
-
Population, housing, income, and demographic estimates
|
|
1565
|
-
|
|
1566
|
-
|
|
1567
|
-
|
|
1568
|
-
|
|
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:
|
|
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
|
|
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,
|
|
396
|
-
JSON one row per monitor, day, and standard
|
|
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
|
-
"
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
File without changes
|