census-loader 0.1.0__py3-none-any.whl
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.
- census_loader/__init__.py +19 -0
- census_loader/load.py +42 -0
- census_loader/py.typed +0 -0
- census_loader/series.py +847 -0
- census_loader/utils.py +938 -0
- census_loader-0.1.0.dist-info/METADATA +295 -0
- census_loader-0.1.0.dist-info/RECORD +10 -0
- census_loader-0.1.0.dist-info/WHEEL +5 -0
- census_loader-0.1.0.dist-info/licenses/LICENSE +21 -0
- census_loader-0.1.0.dist-info/top_level.txt +1 -0
census_loader/utils.py
ADDED
|
@@ -0,0 +1,938 @@
|
|
|
1
|
+
from typing import Optional, Any
|
|
2
|
+
from pathlib import Path
|
|
3
|
+
import pandas as pd
|
|
4
|
+
import requests
|
|
5
|
+
from dotenv import load_dotenv
|
|
6
|
+
from census import Census
|
|
7
|
+
import os, time
|
|
8
|
+
from functools import reduce
|
|
9
|
+
import pickle
|
|
10
|
+
from .series import (
|
|
11
|
+
ALL_SERIES,
|
|
12
|
+
PREDICATES,
|
|
13
|
+
POPULATION,
|
|
14
|
+
RACE_ETHNICITY,
|
|
15
|
+
NATIVITY_MIGRATION,
|
|
16
|
+
LANGUAGE,
|
|
17
|
+
EDUCATION,
|
|
18
|
+
HOUSEHOLDS,
|
|
19
|
+
INCOME,
|
|
20
|
+
POVERTY,
|
|
21
|
+
HEALTH_INSURANCE,
|
|
22
|
+
EMPLOYMENT,
|
|
23
|
+
HOUSING,
|
|
24
|
+
DISABILITY,
|
|
25
|
+
VETERANS,
|
|
26
|
+
PEP_POPULATION,
|
|
27
|
+
DECENNIAL,
|
|
28
|
+
DATA_PROFILES,
|
|
29
|
+
GEO,
|
|
30
|
+
STATE_FIPS,
|
|
31
|
+
FIPS_STATES,
|
|
32
|
+
CATEGORIES,
|
|
33
|
+
SUBCATEGORIES,
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
# ── Dataset dispatch ─────────────────────────────────────────────────────────
|
|
37
|
+
|
|
38
|
+
_BASE_URL = "https://api.census.gov/data"
|
|
39
|
+
|
|
40
|
+
RAW_ENDPOINTS = {
|
|
41
|
+
"acs5/flows": "{base}/{yr}/acs/flows",
|
|
42
|
+
"pep": "{base}/{yr}/pep/charv",
|
|
43
|
+
"saipe": "{base}/timeseries/poverty/saipe",
|
|
44
|
+
"saipe/schdist": "{base}/timeseries/poverty/saipe/schdist",
|
|
45
|
+
"sahie": "{base}/timeseries/healthins/sahie",
|
|
46
|
+
"dec/pl": "{base}/2020/dec/pl",
|
|
47
|
+
"dec/dhc": "{base}/2020/dec/dhc",
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
DATASET_DISPATCH = {
|
|
51
|
+
"acs5": lambda c: c.acs5,
|
|
52
|
+
"acs5/subject": lambda c: c.acs5st,
|
|
53
|
+
"acs5/profile": lambda c: c.acs5dp,
|
|
54
|
+
"acs1": lambda c: c.acs1,
|
|
55
|
+
"acs5/flows": RAW_ENDPOINTS["acs5/flows"],
|
|
56
|
+
"pep": RAW_ENDPOINTS["pep"],
|
|
57
|
+
"saipe": RAW_ENDPOINTS["saipe"],
|
|
58
|
+
"saipe/schdist": RAW_ENDPOINTS["saipe/schdist"],
|
|
59
|
+
"sahie": RAW_ENDPOINTS["sahie"],
|
|
60
|
+
"dec/pl": RAW_ENDPOINTS["dec/pl"],
|
|
61
|
+
"dec/dhc": RAW_ENDPOINTS["dec/dhc"],
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
# ╔═══════════════════════════════════════════════════════════════════════════╗
|
|
66
|
+
# ║ SERIES RESOLVER — the user-facing abstraction layer ║
|
|
67
|
+
# ╚═══════════════════════════════════════════════════════════════════════════╝
|
|
68
|
+
|
|
69
|
+
_SUBCATEGORY_FLAT: dict[str, dict] = {}
|
|
70
|
+
for _cat, _subs in SUBCATEGORIES.items():
|
|
71
|
+
for _subname, _subdict in _subs.items():
|
|
72
|
+
_SUBCATEGORY_FLAT[_subname] = _subdict
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def _resolve_one(token: str) -> dict:
|
|
76
|
+
"""
|
|
77
|
+
Resolve a single string token into a series dict.
|
|
78
|
+
|
|
79
|
+
Resolution order:
|
|
80
|
+
1. Individual series key → "TOTAL_POP", "MEDIAN_RENT"
|
|
81
|
+
2. Category name → "INCOME", "HOUSING"
|
|
82
|
+
3. Subcategory name → "HOUSEHOLD_INCOME", "COMMUTING"
|
|
83
|
+
|
|
84
|
+
Raises KeyError with a helpful message if nothing matches.
|
|
85
|
+
"""
|
|
86
|
+
token_upper = token.upper()
|
|
87
|
+
|
|
88
|
+
# 1 — exact series key
|
|
89
|
+
if token_upper in ALL_SERIES:
|
|
90
|
+
return {token_upper: ALL_SERIES[token_upper]}
|
|
91
|
+
|
|
92
|
+
# 2 — category
|
|
93
|
+
if token_upper in CATEGORIES:
|
|
94
|
+
return CATEGORIES[token_upper]
|
|
95
|
+
|
|
96
|
+
# 3 — subcategory
|
|
97
|
+
if token_upper in _SUBCATEGORY_FLAT:
|
|
98
|
+
return _SUBCATEGORY_FLAT[token_upper]
|
|
99
|
+
|
|
100
|
+
# Nothing matched — helpful error
|
|
101
|
+
raise KeyError(
|
|
102
|
+
f"'{token}' is not a recognized series, category, or subcategory.\n"
|
|
103
|
+
f" Use available() to browse, or search('keyword') to find series."
|
|
104
|
+
)
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def resolve_series(spec) -> dict:
|
|
108
|
+
"""
|
|
109
|
+
Turn a flexible user specification into the internal series dict.
|
|
110
|
+
|
|
111
|
+
Accepted inputs
|
|
112
|
+
---------------
|
|
113
|
+
None → ALL_SERIES (everything)
|
|
114
|
+
"TOTAL_POP" → single series
|
|
115
|
+
"INCOME" → entire category
|
|
116
|
+
"HOUSEHOLD_INCOME" → subcategory
|
|
117
|
+
["TOTAL_POP", "INCOME"] → mix-and-match, merged
|
|
118
|
+
dict → pass through (backward compat / power users)
|
|
119
|
+
|
|
120
|
+
Returns
|
|
121
|
+
-------
|
|
122
|
+
dict : {series_key: (name, dataset, variables), ...}
|
|
123
|
+
"""
|
|
124
|
+
if spec is None:
|
|
125
|
+
return ALL_SERIES
|
|
126
|
+
|
|
127
|
+
if isinstance(spec, dict):
|
|
128
|
+
return spec
|
|
129
|
+
|
|
130
|
+
if isinstance(spec, str):
|
|
131
|
+
return _resolve_one(spec)
|
|
132
|
+
|
|
133
|
+
if isinstance(spec, (list, tuple)):
|
|
134
|
+
merged = {}
|
|
135
|
+
for token in spec:
|
|
136
|
+
merged.update(_resolve_one(token))
|
|
137
|
+
return merged
|
|
138
|
+
|
|
139
|
+
raise TypeError(
|
|
140
|
+
f"series must be None, str, list[str], or dict — got {type(spec).__name__}"
|
|
141
|
+
)
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
# ╔═══════════════════════════════════════════════════════════════════════════╗
|
|
145
|
+
# ║ DISCOVERY — browse & search the catalog without reading series.py ║
|
|
146
|
+
# ╚═══════════════════════════════════════════════════════════════════════════╝
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def available(category: str | None = None) -> None:
|
|
150
|
+
"""
|
|
151
|
+
Print what's available in the catalog.
|
|
152
|
+
|
|
153
|
+
Call with no args to see categories.
|
|
154
|
+
Pass a category or subcategory name to see its series::
|
|
155
|
+
|
|
156
|
+
available() # list all categories
|
|
157
|
+
available("INCOME") # series inside INCOME
|
|
158
|
+
available("HOUSEHOLD_INCOME") # series inside a subcategory
|
|
159
|
+
"""
|
|
160
|
+
if category is None:
|
|
161
|
+
print("Categories (pass one to available() to drill down)\n")
|
|
162
|
+
for cat_name, cat_dict in CATEGORIES.items():
|
|
163
|
+
subs = list(SUBCATEGORIES.get(cat_name, {}).keys())
|
|
164
|
+
sub_str = f"\n └ {', '.join(subs)}" if subs else ""
|
|
165
|
+
print(f" {cat_name:<24s} ({len(cat_dict):>3d} series){sub_str}")
|
|
166
|
+
print(f"\n {'TOTAL':<24s} ({len(ALL_SERIES):>3d} series)")
|
|
167
|
+
return
|
|
168
|
+
|
|
169
|
+
cat_upper = category.upper()
|
|
170
|
+
|
|
171
|
+
if cat_upper in CATEGORIES:
|
|
172
|
+
target = CATEGORIES[cat_upper]
|
|
173
|
+
label = f"Category: {cat_upper}"
|
|
174
|
+
elif cat_upper in _SUBCATEGORY_FLAT:
|
|
175
|
+
target = _SUBCATEGORY_FLAT[cat_upper]
|
|
176
|
+
label = f"Subcategory: {cat_upper}"
|
|
177
|
+
else:
|
|
178
|
+
print(f"'{category}' not found. Run available() with no args to see options.")
|
|
179
|
+
return
|
|
180
|
+
|
|
181
|
+
print(f"{label} ({len(target)} series)\n")
|
|
182
|
+
for key, (name, dataset, variables) in target.items():
|
|
183
|
+
print(f" {key:<30s} {name}")
|
|
184
|
+
print()
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def search(keyword: str) -> list[str]:
|
|
188
|
+
"""
|
|
189
|
+
Search the catalog by keyword (case-insensitive).
|
|
190
|
+
|
|
191
|
+
Searches series keys AND friendly names::
|
|
192
|
+
|
|
193
|
+
search("poverty")
|
|
194
|
+
search("median")
|
|
195
|
+
search("hispanic")
|
|
196
|
+
|
|
197
|
+
Returns list of matching series keys.
|
|
198
|
+
"""
|
|
199
|
+
kw = keyword.lower()
|
|
200
|
+
hits = []
|
|
201
|
+
for key, (name, _, _) in ALL_SERIES.items():
|
|
202
|
+
if kw in key.lower() or kw in name.lower():
|
|
203
|
+
hits.append(key)
|
|
204
|
+
|
|
205
|
+
if not hits:
|
|
206
|
+
print(f"No series matching '{keyword}'.")
|
|
207
|
+
return []
|
|
208
|
+
|
|
209
|
+
print(f"Found {len(hits)} series matching '{keyword}':\n")
|
|
210
|
+
for key in hits:
|
|
211
|
+
name = ALL_SERIES[key][0]
|
|
212
|
+
print(f" {key:<30s} {name}")
|
|
213
|
+
print()
|
|
214
|
+
return hits
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
def info(series_key: str) -> None:
|
|
218
|
+
"""
|
|
219
|
+
Print full details about a single series::
|
|
220
|
+
|
|
221
|
+
info("MEDIAN_HH_INCOME")
|
|
222
|
+
"""
|
|
223
|
+
key = series_key.upper()
|
|
224
|
+
if key not in ALL_SERIES:
|
|
225
|
+
print(f"'{series_key}' not found. Try search('{series_key}').")
|
|
226
|
+
return
|
|
227
|
+
|
|
228
|
+
name, dataset, variables = ALL_SERIES[key]
|
|
229
|
+
|
|
230
|
+
parent_cat = parent_sub = None
|
|
231
|
+
for cat_name, cat_dict in CATEGORIES.items():
|
|
232
|
+
if key in cat_dict:
|
|
233
|
+
parent_cat = cat_name
|
|
234
|
+
for sub_name, sub_dict in SUBCATEGORIES.get(cat_name, {}).items():
|
|
235
|
+
if key in sub_dict:
|
|
236
|
+
parent_sub = sub_name
|
|
237
|
+
break
|
|
238
|
+
|
|
239
|
+
print(f"\n Key: {key}")
|
|
240
|
+
print(f" Name: {name}")
|
|
241
|
+
print(f" Category: {parent_cat or '—'}")
|
|
242
|
+
print(f" Subcategory: {parent_sub or '—'}")
|
|
243
|
+
print(f" Dataset: {dataset}")
|
|
244
|
+
if isinstance(variables, str):
|
|
245
|
+
print(f" Variables: {variables} (full table group)")
|
|
246
|
+
else:
|
|
247
|
+
print(
|
|
248
|
+
f" Variables: {len(variables)} code{'s' if len(variables) > 1 else ''}"
|
|
249
|
+
)
|
|
250
|
+
for v in variables:
|
|
251
|
+
print(f" {v}")
|
|
252
|
+
print()
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
def geos(quiet: Optional[bool] = False) -> None | dict[str, str]:
|
|
256
|
+
"""Print available geography queries and their required parameters."""
|
|
257
|
+
requires_remap = {
|
|
258
|
+
"st": "state",
|
|
259
|
+
"co": "county",
|
|
260
|
+
"tr": "tract",
|
|
261
|
+
}
|
|
262
|
+
if not quiet:
|
|
263
|
+
print("Available Geometry Queries:\n")
|
|
264
|
+
for key, val in GEO.items():
|
|
265
|
+
val_str = str(val)
|
|
266
|
+
requires = [
|
|
267
|
+
requires_remap[code]
|
|
268
|
+
for code in ("st", "co", "tr")
|
|
269
|
+
if f"{{{code}}}" in val_str
|
|
270
|
+
]
|
|
271
|
+
print(f" {key}")
|
|
272
|
+
print(f" Query: {val}")
|
|
273
|
+
print(f" Requires: {', '.join(requires) if requires else 'None'}")
|
|
274
|
+
print()
|
|
275
|
+
return None
|
|
276
|
+
else:
|
|
277
|
+
"""Returns a dict of geo query keys and their required parameters"""
|
|
278
|
+
result = {}
|
|
279
|
+
for key, val in GEO.items():
|
|
280
|
+
val_str = str(val)
|
|
281
|
+
requires = [
|
|
282
|
+
requires_remap[code]
|
|
283
|
+
for code in ("st", "co", "tr")
|
|
284
|
+
if f"{{{code}}}" in val_str
|
|
285
|
+
]
|
|
286
|
+
result[key] = ", ".join(requires) if requires else "None"
|
|
287
|
+
return result
|
|
288
|
+
|
|
289
|
+
|
|
290
|
+
def pickle_loader(filepath: Path | str) -> pd.DataFrame:
|
|
291
|
+
"""
|
|
292
|
+
Load a dict-of-DataFrames pickle and flatten it into a single
|
|
293
|
+
DataFrame by outer-merging on columns shared across all frames.
|
|
294
|
+
|
|
295
|
+
Parameters
|
|
296
|
+
----------
|
|
297
|
+
filepath : Path or str
|
|
298
|
+
Path to the .pkl file.
|
|
299
|
+
|
|
300
|
+
Returns
|
|
301
|
+
-------
|
|
302
|
+
pd.DataFrame
|
|
303
|
+
One row per shared-key combination, with all frames' columns merged in.
|
|
304
|
+
"""
|
|
305
|
+
with open(filepath, "rb") as f:
|
|
306
|
+
data = pickle.load(f)
|
|
307
|
+
if isinstance(data, pd.DataFrame):
|
|
308
|
+
return data
|
|
309
|
+
if not isinstance(data, dict) or len(data) == 0:
|
|
310
|
+
return pd.DataFrame()
|
|
311
|
+
frames = list(data.values())
|
|
312
|
+
if len(frames) == 1:
|
|
313
|
+
return frames[0]
|
|
314
|
+
shared = set(frames[0].columns)
|
|
315
|
+
for df in frames[1:]:
|
|
316
|
+
shared &= set(df.columns)
|
|
317
|
+
merge_keys = []
|
|
318
|
+
for col in shared:
|
|
319
|
+
if all(not pd.api.types.is_numeric_dtype(df[col]) for df in frames):
|
|
320
|
+
merge_keys.append(col)
|
|
321
|
+
if not merge_keys:
|
|
322
|
+
parts = []
|
|
323
|
+
for name, df in data.items():
|
|
324
|
+
chunk = df.copy()
|
|
325
|
+
chunk.insert(0, "series", name)
|
|
326
|
+
parts.append(chunk)
|
|
327
|
+
return pd.concat(parts, ignore_index=True)
|
|
328
|
+
merged = reduce(
|
|
329
|
+
lambda left, right: pd.merge(left, right, on=merge_keys, how="outer"),
|
|
330
|
+
frames,
|
|
331
|
+
)
|
|
332
|
+
return merged
|
|
333
|
+
|
|
334
|
+
|
|
335
|
+
# ╔═══════════════════════════════════════════════════════════════════════════╗
|
|
336
|
+
# ║ CONFIGURATION OBJECT ║
|
|
337
|
+
# ╚═══════════════════════════════════════════════════════════════════════════╝
|
|
338
|
+
|
|
339
|
+
|
|
340
|
+
class Config:
|
|
341
|
+
"""
|
|
342
|
+
Configuration for a Census Bureau data pull.
|
|
343
|
+
|
|
344
|
+
Parameters
|
|
345
|
+
----------
|
|
346
|
+
filename : str
|
|
347
|
+
Output CSV filename.
|
|
348
|
+
output_path : Path or str
|
|
349
|
+
Directory where output files are saved.
|
|
350
|
+
year : int
|
|
351
|
+
ACS / PEP vintage year (default 2022).
|
|
352
|
+
geo : str or dict
|
|
353
|
+
Geography level — a key from GEO (e.g. "state_all",
|
|
354
|
+
"county_in_state", "tract_in_county") or a raw dict.
|
|
355
|
+
state : str, optional
|
|
356
|
+
State name ("Massachusetts") or FIPS code ("25").
|
|
357
|
+
county : str, optional
|
|
358
|
+
County FIPS code (3-digit, e.g. "017").
|
|
359
|
+
tract : str, optional
|
|
360
|
+
Tract code.
|
|
361
|
+
series : str, list[str], dict, or None
|
|
362
|
+
What to pull. Accepts any of::
|
|
363
|
+
|
|
364
|
+
series=None # everything
|
|
365
|
+
series="TOTAL_POP" # one series
|
|
366
|
+
series="INCOME" # whole category
|
|
367
|
+
series="HOUSEHOLD_INCOME" # subcategory
|
|
368
|
+
series=["TOTAL_POP", "INCOME", "MEDIAN_RENT"] # mix & match
|
|
369
|
+
|
|
370
|
+
Use ``available()``, ``search()``, ``info()`` to explore options.
|
|
371
|
+
batch_size : int
|
|
372
|
+
Max series per API batch (default 50).
|
|
373
|
+
api_key : str, optional
|
|
374
|
+
Census Bureau API key. If omitted, CENSUS_API_KEY is read from the
|
|
375
|
+
environment (including a .env file). Get a free key at
|
|
376
|
+
https://api.census.gov/data/key_signup.html.
|
|
377
|
+
"""
|
|
378
|
+
|
|
379
|
+
def __init__(
|
|
380
|
+
self,
|
|
381
|
+
filename: str,
|
|
382
|
+
output_path: Path | str,
|
|
383
|
+
year: int = 2022,
|
|
384
|
+
geo: str | dict | None = None,
|
|
385
|
+
state: str | None = None,
|
|
386
|
+
county: str | None = None,
|
|
387
|
+
tract: str | None = None,
|
|
388
|
+
series: str | list | dict | None = None,
|
|
389
|
+
batch_size: int = 50,
|
|
390
|
+
api_key: str | None = None,
|
|
391
|
+
) -> None:
|
|
392
|
+
def _validate_geo_inputs(geo, state, county, tract):
|
|
393
|
+
# ── (raw query) ─────────────────────────────
|
|
394
|
+
if isinstance(geo, dict):
|
|
395
|
+
return geo
|
|
396
|
+
# ── None: default to state_all ──────────────────────────────
|
|
397
|
+
if geo is None:
|
|
398
|
+
return GEO["state_all"]
|
|
399
|
+
# ── String key: validate + resolve ──────────────────────────
|
|
400
|
+
tmp = geos(quiet=True)
|
|
401
|
+
if tmp:
|
|
402
|
+
required_dct = dict(tmp)
|
|
403
|
+
else:
|
|
404
|
+
raise ValueError("Failed to retrieve geo templates for validation.")
|
|
405
|
+
if geo not in required_dct:
|
|
406
|
+
raise ValueError(
|
|
407
|
+
f"Geo template '{geo}' not recognized. "
|
|
408
|
+
f"Use geos() to see available templates."
|
|
409
|
+
)
|
|
410
|
+
required_params = [
|
|
411
|
+
p.strip() for p in required_dct[geo].split(",") if p.strip() != "None"
|
|
412
|
+
]
|
|
413
|
+
missing_params = []
|
|
414
|
+
for param in required_params:
|
|
415
|
+
if param == "state" and state is None:
|
|
416
|
+
missing_params.append("state")
|
|
417
|
+
elif param == "county" and county is None:
|
|
418
|
+
missing_params.append("county")
|
|
419
|
+
elif param == "tract" and tract is None:
|
|
420
|
+
missing_params.append("tract")
|
|
421
|
+
if missing_params:
|
|
422
|
+
raise ValueError(
|
|
423
|
+
f"Geo template '{geo}' requires parameters: "
|
|
424
|
+
f"{', '.join(missing_params)}"
|
|
425
|
+
)
|
|
426
|
+
# ── Resolve FIPS + format ────────────────────────────────────
|
|
427
|
+
geo_template = GEO[geo]
|
|
428
|
+
fips_kwargs = {}
|
|
429
|
+
if state is not None:
|
|
430
|
+
fips_kwargs["st"] = STATE_FIPS[state] if state in STATE_FIPS else state
|
|
431
|
+
if county is not None:
|
|
432
|
+
fips_kwargs["co"] = county
|
|
433
|
+
if tract is not None:
|
|
434
|
+
fips_kwargs["tr"] = tract
|
|
435
|
+
return {
|
|
436
|
+
k: v.format(**fips_kwargs) if fips_kwargs else v
|
|
437
|
+
for k, v in geo_template.items()
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
# BATCH_SIZE IS CURRENTLY INERT, and saying so is the point.
|
|
441
|
+
#
|
|
442
|
+
# The Census API caps a single request at about 50 variables. That cap
|
|
443
|
+
# applies per CALL, so honouring it means splitting one wide series
|
|
444
|
+
# across several requests and joining the responses on their geography
|
|
445
|
+
# columns. That is not implemented.
|
|
446
|
+
#
|
|
447
|
+
# What this parameter used to do was chunk the SERIES dictionary, which
|
|
448
|
+
# changed nothing: every series already gets its own request because
|
|
449
|
+
# each one carries its own dataset and variable list. The chunking was
|
|
450
|
+
# removed rather than left in place looking meaningful.
|
|
451
|
+
#
|
|
452
|
+
# It is kept as an accepted argument so existing callers do not break,
|
|
453
|
+
# and it warns rather than pretending to protect anything.
|
|
454
|
+
if batch_size > 50:
|
|
455
|
+
print(
|
|
456
|
+
f"Note: batch_size={batch_size} is above the Census per-request "
|
|
457
|
+
f"variable cap of ~50, but batch_size is not currently enforced "
|
|
458
|
+
f"anywhere. A series whose variable list exceeds the cap will "
|
|
459
|
+
f"fail on its own request regardless of this value."
|
|
460
|
+
)
|
|
461
|
+
self.FILENAME: str = filename
|
|
462
|
+
self.OUTPUT_PATH: Path = Path(output_path).resolve()
|
|
463
|
+
self.YEAR: int = year
|
|
464
|
+
self.BATCH_SIZE: int = batch_size
|
|
465
|
+
self.API_KEY: str | None = api_key
|
|
466
|
+
# ── Resolve series spec → internal dict ──────────────────────────
|
|
467
|
+
self._series_input = series
|
|
468
|
+
self.SERIES: dict = resolve_series(series)
|
|
469
|
+
# A bad geo spec fails construction. It used to be caught here, printed
|
|
470
|
+
# as a warning, and downgraded to an empty GEO, so a misspelled
|
|
471
|
+
# template or a missing required state/county silently built a Config
|
|
472
|
+
# that then failed much later with an unrelated-looking error. The
|
|
473
|
+
# guard already raises an actionable message; let it through.
|
|
474
|
+
self.GEO: dict = _validate_geo_inputs(geo, state, county, tract)
|
|
475
|
+
|
|
476
|
+
def __str__(self) -> str:
|
|
477
|
+
si = self._series_input
|
|
478
|
+
if si is None:
|
|
479
|
+
series_label = f"ALL ({len(self.SERIES)} series)"
|
|
480
|
+
elif isinstance(si, str):
|
|
481
|
+
series_label = f'"{si}" ({len(self.SERIES)} series)'
|
|
482
|
+
elif isinstance(si, (list, tuple)):
|
|
483
|
+
preview = ", ".join(si[:4])
|
|
484
|
+
more = f" + {len(si)-4} more" if len(si) > 4 else ""
|
|
485
|
+
series_label = f"[{preview}{more}] ({len(self.SERIES)} series)"
|
|
486
|
+
else:
|
|
487
|
+
series_label = f"custom dict ({len(self.SERIES)} series)"
|
|
488
|
+
|
|
489
|
+
return (
|
|
490
|
+
"Census Bureau Pull Configuration:\n"
|
|
491
|
+
f" filename = {self.FILENAME}\n"
|
|
492
|
+
f" output_path = {self.OUTPUT_PATH}\n"
|
|
493
|
+
f" year = {self.YEAR}\n"
|
|
494
|
+
f" geo = {self.GEO}\n"
|
|
495
|
+
f" series = {series_label}\n"
|
|
496
|
+
f" batch_size = {self.BATCH_SIZE}"
|
|
497
|
+
)
|
|
498
|
+
|
|
499
|
+
def geo_formatted(self, **fips) -> dict:
|
|
500
|
+
"""Return self.GEO with FIPS placeholders re-filled."""
|
|
501
|
+
resolved = {}
|
|
502
|
+
for key, val in fips.items():
|
|
503
|
+
if key == "st" and val in STATE_FIPS:
|
|
504
|
+
resolved[key] = STATE_FIPS[val]
|
|
505
|
+
else:
|
|
506
|
+
resolved[key] = val
|
|
507
|
+
return {k: v.format(**resolved) for k, v in self.GEO.items()}
|
|
508
|
+
|
|
509
|
+
def show_series(self) -> None:
|
|
510
|
+
"""Print the resolved series that will be pulled."""
|
|
511
|
+
print(f"Resolved series ({len(self.SERIES)}):\n")
|
|
512
|
+
for key, (name, _, _) in self.SERIES.items():
|
|
513
|
+
print(f" {key:<30s} {name}")
|
|
514
|
+
print()
|
|
515
|
+
|
|
516
|
+
|
|
517
|
+
# ╔═══════════════════════════════════════════════════════════════════════════╗
|
|
518
|
+
# ║ INTERNAL PULL HELPERS ║
|
|
519
|
+
# ╚═══════════════════════════════════════════════════════════════════════════╝
|
|
520
|
+
|
|
521
|
+
|
|
522
|
+
def _parse_variables(variables) -> tuple[list[str], str | None]:
|
|
523
|
+
if isinstance(variables, str) and variables.startswith("group("):
|
|
524
|
+
return [], variables[6:-1]
|
|
525
|
+
return list(variables), None
|
|
526
|
+
|
|
527
|
+
|
|
528
|
+
def _pull_wrapped(client_attr, var_list, group_name, geo, year) -> pd.DataFrame:
|
|
529
|
+
if group_name:
|
|
530
|
+
fields = (f"group({group_name})",)
|
|
531
|
+
else:
|
|
532
|
+
fields = ("NAME", *var_list)
|
|
533
|
+
geo_kwargs = {}
|
|
534
|
+
if "for" in geo:
|
|
535
|
+
geo_kwargs["geo"] = {"for": geo["for"]}
|
|
536
|
+
if "in" in geo:
|
|
537
|
+
geo_kwargs["geo"]["in"] = geo["in"]
|
|
538
|
+
data = client_attr.get(fields, geo_kwargs.get("geo", {}), year=year)
|
|
539
|
+
return pd.DataFrame(data)
|
|
540
|
+
|
|
541
|
+
|
|
542
|
+
def _pull_raw(url_template, var_list, group_name, geo, year, api_key) -> pd.DataFrame:
|
|
543
|
+
url = url_template.format(base=_BASE_URL, yr=year)
|
|
544
|
+
if group_name:
|
|
545
|
+
get_param = f"group({group_name})"
|
|
546
|
+
else:
|
|
547
|
+
get_param = "NAME," + ",".join(var_list)
|
|
548
|
+
params = {"get": get_param, "key": api_key}
|
|
549
|
+
params.update(geo)
|
|
550
|
+
if "timeseries" in url:
|
|
551
|
+
params["YEAR"] = str(year)
|
|
552
|
+
resp = requests.get(url, params=params, timeout=120)
|
|
553
|
+
resp.raise_for_status()
|
|
554
|
+
rows = resp.json()
|
|
555
|
+
if not rows or len(rows) < 2:
|
|
556
|
+
return pd.DataFrame()
|
|
557
|
+
return pd.DataFrame(rows[1:], columns=rows[0])
|
|
558
|
+
|
|
559
|
+
|
|
560
|
+
# ── Group variable label cache ───────────────────────────────────────────────
|
|
561
|
+
|
|
562
|
+
_DATASET_PATHS = {
|
|
563
|
+
"acs5": "acs/acs5",
|
|
564
|
+
"acs5/subject": "acs/acs5/subject",
|
|
565
|
+
"acs5/profile": "acs/acs5/profile",
|
|
566
|
+
"acs1": "acs/acs1",
|
|
567
|
+
"dec/pl": "dec/pl",
|
|
568
|
+
"dec/dhc": "dec/dhc",
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
_group_label_cache: dict[str, dict[str, str]] = {}
|
|
572
|
+
|
|
573
|
+
|
|
574
|
+
def _fetch_group_labels(group_name: str, dataset: str, year: int) -> dict[str, str]:
|
|
575
|
+
"""
|
|
576
|
+
Fetch human-readable labels for every variable in a Census group.
|
|
577
|
+
|
|
578
|
+
Returns {var_code: short_label} e.g.:
|
|
579
|
+
{"B19001_001E": "Total", "B19001_002E": "Less than $10,000", ...}
|
|
580
|
+
|
|
581
|
+
Results are cached per session. Falls back to empty dict on failure.
|
|
582
|
+
"""
|
|
583
|
+
cache_key = f"{dataset}/{year}/{group_name}"
|
|
584
|
+
if cache_key in _group_label_cache:
|
|
585
|
+
return _group_label_cache[cache_key]
|
|
586
|
+
|
|
587
|
+
ds_path = _DATASET_PATHS.get(dataset)
|
|
588
|
+
if not ds_path:
|
|
589
|
+
return {}
|
|
590
|
+
|
|
591
|
+
if dataset.startswith("dec/"):
|
|
592
|
+
url = f"{_BASE_URL}/2020/{ds_path}/groups/{group_name}.json"
|
|
593
|
+
else:
|
|
594
|
+
url = f"{_BASE_URL}/{year}/{ds_path}/groups/{group_name}.json"
|
|
595
|
+
|
|
596
|
+
try:
|
|
597
|
+
resp = requests.get(url, timeout=30)
|
|
598
|
+
resp.raise_for_status()
|
|
599
|
+
raw = resp.json().get("variables", {})
|
|
600
|
+
|
|
601
|
+
labels = {}
|
|
602
|
+
for var_code, meta in raw.items():
|
|
603
|
+
label = meta.get("label", var_code)
|
|
604
|
+
# Census labels look like "Estimate!!Total:!!Male:!!65 and 66 years"
|
|
605
|
+
# Strip colons, split on "!!", drop boilerplate prefixes,
|
|
606
|
+
# keep enough context to disambiguate (e.g. "Male - 65 and 66 years")
|
|
607
|
+
parts = [p.strip() for p in label.replace(":", "").split("!!") if p.strip()]
|
|
608
|
+
# Drop leading "Estimate"
|
|
609
|
+
skip = {"Estimate"}
|
|
610
|
+
meaningful = [p for p in parts if p not in skip]
|
|
611
|
+
short = (
|
|
612
|
+
" - ".join(meaningful)
|
|
613
|
+
if meaningful
|
|
614
|
+
else (parts[-1] if parts else var_code)
|
|
615
|
+
)
|
|
616
|
+
labels[var_code] = short
|
|
617
|
+
|
|
618
|
+
_group_label_cache[cache_key] = labels
|
|
619
|
+
return labels
|
|
620
|
+
|
|
621
|
+
except Exception as e:
|
|
622
|
+
# Say so. Falling back to raw variable codes is a legitimate degradation,
|
|
623
|
+
# but a SILENT one is indistinguishable from a group that genuinely has
|
|
624
|
+
# no labels, and the caller then ships raw codes as if that were correct.
|
|
625
|
+
print(f" ! label lookup failed for {group_name} ({dataset}/{year}): {e}")
|
|
626
|
+
print(" falling back to raw variable codes for this group")
|
|
627
|
+
_group_label_cache[cache_key] = {}
|
|
628
|
+
return {}
|
|
629
|
+
|
|
630
|
+
|
|
631
|
+
def _fetch_var_labels(var_codes: list[str], dataset: str, year: int) -> dict[str, str]:
|
|
632
|
+
"""
|
|
633
|
+
Fetch labels for individual variable codes by looking up their parent
|
|
634
|
+
group. Reuses _fetch_group_labels so the metadata is cached.
|
|
635
|
+
|
|
636
|
+
Variable codes follow the pattern TABLE_SEQE (e.g. B25003_002E).
|
|
637
|
+
The group/table name is everything before the underscore-number suffix.
|
|
638
|
+
|
|
639
|
+
Returns {var_code: short_label} for the requested codes only.
|
|
640
|
+
"""
|
|
641
|
+
# Group var codes by their parent table
|
|
642
|
+
# B25003_002E → B25003, DP03_0096E → DP03, S0101_C01_001E → S0101
|
|
643
|
+
tables: dict[str, list[str]] = {}
|
|
644
|
+
for code in var_codes:
|
|
645
|
+
# Walk backwards to find the table prefix:
|
|
646
|
+
# split on '_', the table is everything except the last segment
|
|
647
|
+
# B25003_002E → ["B25003", "002E"] → table = "B25003"
|
|
648
|
+
# S0101_C01_001E → ["S0101", "C01", "001E"] → table = "S0101"
|
|
649
|
+
parts = code.split("_")
|
|
650
|
+
# Table name = first part for B/C tables, or first part for S/DP tables
|
|
651
|
+
# The group name is typically the first segment
|
|
652
|
+
table = parts[0]
|
|
653
|
+
tables.setdefault(table, []).append(code)
|
|
654
|
+
|
|
655
|
+
# Fetch each table's full label set (cached after first call)
|
|
656
|
+
result = {}
|
|
657
|
+
for table, codes in tables.items():
|
|
658
|
+
all_labels = _fetch_group_labels(table, dataset, year)
|
|
659
|
+
for code in codes:
|
|
660
|
+
if code in all_labels:
|
|
661
|
+
result[code] = all_labels[code]
|
|
662
|
+
|
|
663
|
+
return result
|
|
664
|
+
|
|
665
|
+
|
|
666
|
+
# ── Annotation suffixes to drop from group() pulls ──────────────────────────
|
|
667
|
+
_JUNK_SUFFIXES = ("EA", "MA", "M")
|
|
668
|
+
|
|
669
|
+
|
|
670
|
+
def _clean_frame(
|
|
671
|
+
df: pd.DataFrame,
|
|
672
|
+
name: str,
|
|
673
|
+
var_list: list[str],
|
|
674
|
+
group_name: str | None,
|
|
675
|
+
dataset: str = "",
|
|
676
|
+
year: int = 2022,
|
|
677
|
+
) -> pd.DataFrame:
|
|
678
|
+
"""
|
|
679
|
+
Post-process a raw Census DataFrame into something user-friendly.
|
|
680
|
+
|
|
681
|
+
1. Parse NAME column → geo name columns (county_name, etc.)
|
|
682
|
+
2. Resolve FIPS state codes → state names
|
|
683
|
+
3. Drop annotation / margin-of-error columns from group() pulls
|
|
684
|
+
4. Rename variable columns (friendly names or group labels)
|
|
685
|
+
5. Move geo columns to front in sensible order
|
|
686
|
+
6. Cast numeric columns from strings
|
|
687
|
+
"""
|
|
688
|
+
df = df.copy()
|
|
689
|
+
|
|
690
|
+
# ── 1. Parse NAME into geo name columns ─────────────────────────────
|
|
691
|
+
# Census NAME field examples:
|
|
692
|
+
# state: "Massachusetts"
|
|
693
|
+
# county: "Norfolk County, Massachusetts"
|
|
694
|
+
# tract: "Census Tract 4011, Norfolk County, Massachusetts"
|
|
695
|
+
# place: "Boston city, Massachusetts"
|
|
696
|
+
if "NAME" in df.columns:
|
|
697
|
+
if "county" in df.columns:
|
|
698
|
+
df.insert(
|
|
699
|
+
int(df.columns.get_loc("county")) + int(1),
|
|
700
|
+
"county_name",
|
|
701
|
+
df["NAME"].str.split(",").str[0].str.strip(),
|
|
702
|
+
)
|
|
703
|
+
elif "place" in df.columns:
|
|
704
|
+
df.insert(
|
|
705
|
+
int(df.columns.get_loc("place")) + int(1),
|
|
706
|
+
"place_name",
|
|
707
|
+
df["NAME"].str.split(",").str[0].str.strip(),
|
|
708
|
+
)
|
|
709
|
+
elif "tract" in df.columns:
|
|
710
|
+
df.insert(
|
|
711
|
+
int(df.columns.get_loc("tract")) + int(1),
|
|
712
|
+
"tract_name",
|
|
713
|
+
df["NAME"].str.split(",").str[0].str.strip(),
|
|
714
|
+
)
|
|
715
|
+
df = df.drop(columns=["NAME"])
|
|
716
|
+
|
|
717
|
+
# ── 2. Add state name alongside FIPS code ───────────────────────────
|
|
718
|
+
if "state" in df.columns:
|
|
719
|
+
state_idx = int(df.columns.get_loc("state")) + int(1)
|
|
720
|
+
if "state_name" not in df.columns:
|
|
721
|
+
df.insert(
|
|
722
|
+
state_idx,
|
|
723
|
+
"state_name",
|
|
724
|
+
df["state"].map(lambda x: FIPS_STATES.get(x, x)),
|
|
725
|
+
)
|
|
726
|
+
|
|
727
|
+
# ── 3. Drop annotation / metadata columns from group() pulls ────────
|
|
728
|
+
if group_name is not None:
|
|
729
|
+
drop = [c for c in df.columns if c.endswith(_JUNK_SUFFIXES) or c == "GEO_ID"]
|
|
730
|
+
df = df.drop(columns=[c for c in drop if c in df.columns], errors="ignore")
|
|
731
|
+
|
|
732
|
+
# ── 4. Rename variable columns ──────────────────────────────────────
|
|
733
|
+
if group_name is not None:
|
|
734
|
+
labels = _fetch_group_labels(group_name, dataset, year)
|
|
735
|
+
if labels:
|
|
736
|
+
rename_map = {}
|
|
737
|
+
for col in df.columns:
|
|
738
|
+
if col in labels:
|
|
739
|
+
rename_map[col] = f"{name}__{labels[col]}"
|
|
740
|
+
seen = set()
|
|
741
|
+
safe_map = {}
|
|
742
|
+
for old, new in rename_map.items():
|
|
743
|
+
if new in seen or new in df.columns:
|
|
744
|
+
safe_map[old] = f"{new} ({old})"
|
|
745
|
+
else:
|
|
746
|
+
safe_map[old] = new
|
|
747
|
+
seen.add(new)
|
|
748
|
+
df = df.rename(columns=safe_map)
|
|
749
|
+
|
|
750
|
+
elif var_list:
|
|
751
|
+
if len(var_list) == 1:
|
|
752
|
+
df = df.rename(columns={var_list[0]: name})
|
|
753
|
+
else:
|
|
754
|
+
# Multi-var → fetch labels from metadata, prefix with series name
|
|
755
|
+
labels = _fetch_var_labels(var_list, dataset, year)
|
|
756
|
+
rename_map = {}
|
|
757
|
+
for v in var_list:
|
|
758
|
+
if v not in df.columns:
|
|
759
|
+
continue
|
|
760
|
+
if v in labels:
|
|
761
|
+
rename_map[v] = f"{name}__{labels[v]}"
|
|
762
|
+
else:
|
|
763
|
+
rename_map[v] = f"{name}__{v}" # fallback to raw code
|
|
764
|
+
seen = set()
|
|
765
|
+
safe_map = {}
|
|
766
|
+
for old, new in rename_map.items():
|
|
767
|
+
if new in seen or new in df.columns:
|
|
768
|
+
safe_map[old] = f"{new} ({old})"
|
|
769
|
+
else:
|
|
770
|
+
safe_map[old] = new
|
|
771
|
+
seen.add(new)
|
|
772
|
+
df = df.rename(columns=safe_map)
|
|
773
|
+
|
|
774
|
+
# ── 4b. Decode categorical predicate codes into readable labels ─────
|
|
775
|
+
# PEP and SAHIE return their breakdown dimensions as bare integers: a RACE
|
|
776
|
+
# column holding 0-6, a SEXCAT column holding 0-2. series.PREDICATES has
|
|
777
|
+
# held the meaning of every one of those codes since the catalog was
|
|
778
|
+
# written, and nothing ever applied it, so these pulls shipped raw numbers
|
|
779
|
+
# while the decode map sat one import away. The keys are prefixed by
|
|
780
|
+
# dataset family (PEP_RACE, SAHIE_SEXCAT), and the API column is the part
|
|
781
|
+
# after that prefix.
|
|
782
|
+
#
|
|
783
|
+
# The original code is kept beside the label in a `<col>_code` column: it
|
|
784
|
+
# is what the API returned, some callers will have written it into
|
|
785
|
+
# downstream joins, and dropping it silently would break them.
|
|
786
|
+
_prefix = None
|
|
787
|
+
if dataset.startswith("pep"):
|
|
788
|
+
_prefix = "PEP"
|
|
789
|
+
elif dataset.startswith("timeseries/healthins") or "sahie" in dataset:
|
|
790
|
+
_prefix = "SAHIE"
|
|
791
|
+
|
|
792
|
+
# Decoded columns hold text and must survive step 6, which otherwise
|
|
793
|
+
# coerces every non-geo column to a number and would turn every label
|
|
794
|
+
# straight back into NaN. The `<col>_code` twin is deliberately NOT
|
|
795
|
+
# exempt: it carries exactly what the column used to carry, so it gets
|
|
796
|
+
# cast exactly as that column used to be, and anything downstream joining
|
|
797
|
+
# on the numeric code keeps working.
|
|
798
|
+
_decoded_cols: set[str] = set()
|
|
799
|
+
|
|
800
|
+
if _prefix is not None:
|
|
801
|
+
for _key, _mapping in PREDICATES.items():
|
|
802
|
+
if not _key.startswith(_prefix + "_"):
|
|
803
|
+
continue
|
|
804
|
+
_col = _key[len(_prefix) + 1:]
|
|
805
|
+
if _col not in df.columns:
|
|
806
|
+
continue
|
|
807
|
+
# The API sends these as strings; the map is keyed by int.
|
|
808
|
+
_numeric = pd.to_numeric(df[_col], errors="coerce")
|
|
809
|
+
_decoded = _numeric.map(_mapping)
|
|
810
|
+
# Only rewrite where the code was recognised. An unmapped value
|
|
811
|
+
# keeps its original text rather than becoming NaN, because a new
|
|
812
|
+
# category appearing upstream must not silently blank a column.
|
|
813
|
+
if _decoded.notna().any():
|
|
814
|
+
_idx = int(df.columns.get_loc(_col))
|
|
815
|
+
df.insert(_idx + 1, f"{_col}_code", df[_col])
|
|
816
|
+
df[_col] = _decoded.where(_decoded.notna(), df[_col].astype(object))
|
|
817
|
+
_decoded_cols.add(_col)
|
|
818
|
+
|
|
819
|
+
# ── 5. Move geo columns to front in a sensible order ───────────────
|
|
820
|
+
_GEO_ORDER = [
|
|
821
|
+
"state",
|
|
822
|
+
"state_name",
|
|
823
|
+
"county",
|
|
824
|
+
"county_name",
|
|
825
|
+
"tract",
|
|
826
|
+
"tract_name",
|
|
827
|
+
"block group",
|
|
828
|
+
"block",
|
|
829
|
+
"place",
|
|
830
|
+
"place_name",
|
|
831
|
+
"zip code tabulation area",
|
|
832
|
+
"metropolitan statistical area/micropolitan statistical area",
|
|
833
|
+
"congressional district",
|
|
834
|
+
"school district (unified)",
|
|
835
|
+
"us",
|
|
836
|
+
]
|
|
837
|
+
geo_present = [c for c in _GEO_ORDER if c in df.columns]
|
|
838
|
+
other_cols = [c for c in df.columns if c not in geo_present]
|
|
839
|
+
df = df[geo_present + other_cols]
|
|
840
|
+
|
|
841
|
+
# ── 6. Cast numeric columns ─────────────────────────────────────────
|
|
842
|
+
# `errors="coerce"` means any column that is genuinely textual is blanked,
|
|
843
|
+
# so decoded predicate labels are held out by name. Note the broader edge
|
|
844
|
+
# this leaves in place: any OTHER non-numeric column the API returns is
|
|
845
|
+
# still silently coerced to NaN here.
|
|
846
|
+
for col in df.columns:
|
|
847
|
+
if col not in geo_present and col not in _decoded_cols:
|
|
848
|
+
df[col] = pd.to_numeric(df[col], errors="coerce")
|
|
849
|
+
|
|
850
|
+
return df
|
|
851
|
+
|
|
852
|
+
|
|
853
|
+
# ╔═══════════════════════════════════════════════════════════════════════════╗
|
|
854
|
+
# ║ MAIN LOADER ║
|
|
855
|
+
# ╚═══════════════════════════════════════════════════════════════════════════╝
|
|
856
|
+
|
|
857
|
+
|
|
858
|
+
def _load_census_bureau(config: Config) -> dict[str, pd.DataFrame] | None:
|
|
859
|
+
"""
|
|
860
|
+
Pull Census Bureau data according to *config*.
|
|
861
|
+
|
|
862
|
+
Returns a dict of DataFrames keyed by friendly name, or None on failure.
|
|
863
|
+
"""
|
|
864
|
+
try:
|
|
865
|
+
load_dotenv()
|
|
866
|
+
api_key = getattr(config, "API_KEY", None) or os.getenv("CENSUS_API_KEY")
|
|
867
|
+
if not api_key:
|
|
868
|
+
raise ValueError(
|
|
869
|
+
"CENSUS_API_KEY not found. Pass api_key= to Config or set "
|
|
870
|
+
"CENSUS_API_KEY in your environment or .env file. Get a free "
|
|
871
|
+
"key at https://api.census.gov/data/key_signup.html"
|
|
872
|
+
)
|
|
873
|
+
except Exception as e:
|
|
874
|
+
print(f"Invalid / No API Key provided. {e}")
|
|
875
|
+
return None
|
|
876
|
+
|
|
877
|
+
try:
|
|
878
|
+
census_client = Census(key=api_key)
|
|
879
|
+
except Exception as e:
|
|
880
|
+
print(f"Error initializing Census client: {e}")
|
|
881
|
+
return None
|
|
882
|
+
|
|
883
|
+
series_dict = config.SERIES
|
|
884
|
+
geo = config.GEO
|
|
885
|
+
year = config.YEAR
|
|
886
|
+
|
|
887
|
+
frames: dict[str, pd.DataFrame] = {}
|
|
888
|
+
failed: list[tuple[str, str, str]] = []
|
|
889
|
+
|
|
890
|
+
# One request per series, with a delay between each to stay inside the
|
|
891
|
+
# rate limit. This used to be wrapped in a `_batched()` generator that
|
|
892
|
+
# chunked `series_dict` by BATCH_SIZE, which had NO functional effect: each
|
|
893
|
+
# series carries its own dataset and its own variable list, so it always
|
|
894
|
+
# got its own call regardless of which chunk it landed in. The chunking
|
|
895
|
+
# only made the code look like it batched.
|
|
896
|
+
#
|
|
897
|
+
# The real Census constraint BATCH_SIZE alludes to is a per-CALL cap of
|
|
898
|
+
# about 50 variables, which applies to one series' variable list, not to
|
|
899
|
+
# the number of series. Honouring it means splitting a wide series across
|
|
900
|
+
# several calls and joining the responses on their geography columns.
|
|
901
|
+
# That is a feature, not a fix, and it is not implemented: see the note on
|
|
902
|
+
# BATCH_SIZE in Config.
|
|
903
|
+
for series_id, (name, dataset, variables) in series_dict.items():
|
|
904
|
+
try:
|
|
905
|
+
var_list, group_name = _parse_variables(variables)
|
|
906
|
+
dispatch = DATASET_DISPATCH.get(dataset)
|
|
907
|
+
|
|
908
|
+
if dispatch is None:
|
|
909
|
+
raise KeyError(f"Unknown dataset '{dataset}' for {series_id}")
|
|
910
|
+
|
|
911
|
+
if callable(dispatch):
|
|
912
|
+
client_attr = dispatch(census_client)
|
|
913
|
+
df = _pull_wrapped(client_attr, var_list, group_name, geo, year)
|
|
914
|
+
else:
|
|
915
|
+
df = _pull_raw(dispatch, var_list, group_name, geo, year, api_key)
|
|
916
|
+
|
|
917
|
+
df = _clean_frame(df, name, var_list, group_name, dataset, year)
|
|
918
|
+
df.name = name
|
|
919
|
+
frames[name] = df
|
|
920
|
+
|
|
921
|
+
var_label = (
|
|
922
|
+
f"group({group_name})" if group_name else f"{len(var_list)} vars"
|
|
923
|
+
)
|
|
924
|
+
print(f" ✓ {name:<40s} ({series_id:<30s} [{dataset}] {var_label})")
|
|
925
|
+
time.sleep(0.5)
|
|
926
|
+
|
|
927
|
+
except Exception as e:
|
|
928
|
+
failed.append((series_id, name, str(e)))
|
|
929
|
+
print(f" ✗ {name:<40s} ({series_id}) — {e}")
|
|
930
|
+
time.sleep(0.5)
|
|
931
|
+
|
|
932
|
+
if failed:
|
|
933
|
+
print(f"\n⚠ {len(failed)} series failed:")
|
|
934
|
+
for sid, nm, err in failed:
|
|
935
|
+
print(f" {nm} ({sid}): {err}")
|
|
936
|
+
|
|
937
|
+
print(f"\nLoaded {len(frames)} series | {len(failed)} failed")
|
|
938
|
+
return frames
|