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/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