|
| 1 | +"""American Community Survey data retrieval via the Census API.""" |
| 2 | + |
| 3 | +import pandas as pd |
| 4 | +from pypums.api.client import CENSUS_API_BASE, call_census_api |
| 5 | +from pypums.api.geography import build_geography_query |
| 6 | +from pypums.api.key import census_api_key |
| 7 | + |
| 8 | +# Z-scores for MOE confidence levels. |
| 9 | +_Z_SCORES: dict[int, float] = { |
| 10 | + 90: 1.645, |
| 11 | + 95: 1.960, |
| 12 | + 99: 2.576, |
| 13 | +} |
| 14 | + |
| 15 | +# Geography columns that appear in Census API responses. |
| 16 | +_GEO_COLUMNS = frozenset({ |
| 17 | + "state", "county", "tract", "block group", "block", |
| 18 | + "place", "congressional district", |
| 19 | + "state legislative district (upper chamber)", |
| 20 | + "state legislative district (lower chamber)", |
| 21 | + "zip code tabulation area", |
| 22 | + "school district (unified)", |
| 23 | + "school district (elementary)", |
| 24 | + "school district (secondary)", |
| 25 | + "metropolitan statistical area/micropolitan statistical area", |
| 26 | + "combined statistical area", |
| 27 | + "public use microdata area", |
| 28 | + "american indian area/alaska native area/hawaiian home land", |
| 29 | + "us", "region", "division", "county subdivision", |
| 30 | +}) |
| 31 | + |
| 32 | + |
| 33 | +def _call_census_api(url: str, params: dict) -> list[list[str]]: |
| 34 | + """Thin wrapper so tests can mock ``pypums.acs._call_census_api``.""" |
| 35 | + return call_census_api(url, params) |
| 36 | + |
| 37 | + |
| 38 | +def get_acs( |
| 39 | + geography: str, |
| 40 | + variables: str | list[str] | None = None, |
| 41 | + table: str | None = None, |
| 42 | + state: str | None = None, |
| 43 | + county: str | None = None, |
| 44 | + year: int = 2023, |
| 45 | + output: str = "tidy", |
| 46 | + moe_level: int = 90, |
| 47 | + summary_var: str | None = None, |
| 48 | + geometry: bool = False, |
| 49 | + key: str | None = None, |
| 50 | +) -> pd.DataFrame: |
| 51 | + """Retrieve American Community Survey data from the Census API. |
| 52 | +
|
| 53 | + Parameters |
| 54 | + ---------- |
| 55 | + geography |
| 56 | + Geography level (e.g. ``"state"``, ``"county"``, ``"tract"``). |
| 57 | + variables |
| 58 | + Variable ID or list of IDs (e.g. ``"B01001_001"``). |
| 59 | + table |
| 60 | + Census table ID (e.g. ``"B01001"``). Alternative to *variables*. |
| 61 | + state |
| 62 | + State FIPS code or abbreviation. |
| 63 | + county |
| 64 | + County FIPS code. |
| 65 | + year |
| 66 | + Data year (default 2023). |
| 67 | + output |
| 68 | + ``"tidy"`` (default) or ``"wide"``. |
| 69 | + moe_level |
| 70 | + Confidence level for MOE: 90, 95, or 99 (default 90). |
| 71 | + summary_var |
| 72 | + Variable ID to include as denominator columns. |
| 73 | + geometry |
| 74 | + If True, return a GeoDataFrame with shapes (not yet implemented). |
| 75 | + key |
| 76 | + Census API key. Falls back to ``census_api_key()``. |
| 77 | +
|
| 78 | + Returns |
| 79 | + ------- |
| 80 | + pd.DataFrame |
| 81 | + Census data in tidy or wide format. |
| 82 | + """ |
| 83 | + if geometry: |
| 84 | + raise NotImplementedError("geometry=True is not yet supported.") |
| 85 | + if output not in ("tidy", "wide"): |
| 86 | + raise ValueError(f"output must be 'tidy' or 'wide', got {output!r}") |
| 87 | + if moe_level not in _Z_SCORES: |
| 88 | + raise ValueError(f"moe_level must be one of {sorted(_Z_SCORES)}, got {moe_level!r}") |
| 89 | + |
| 90 | + api_key = census_api_key(key) if key else census_api_key() |
| 91 | + for_clause, in_clause = build_geography_query(geography, state=state, county=county) |
| 92 | + |
| 93 | + # Build the variable list for the API request. |
| 94 | + if variables is not None: |
| 95 | + if isinstance(variables, str): |
| 96 | + variables = [variables] |
| 97 | + # Census API needs E/M suffixes for ACS. |
| 98 | + api_vars = [] |
| 99 | + for v in variables: |
| 100 | + api_vars.append(f"{v}E") |
| 101 | + api_vars.append(f"{v}M") |
| 102 | + elif table is not None: |
| 103 | + api_vars = [f"group({table})"] |
| 104 | + else: |
| 105 | + raise ValueError("Must provide either 'variables' or 'table'.") |
| 106 | + |
| 107 | + # Add summary variable if requested. |
| 108 | + if summary_var is not None: |
| 109 | + api_vars.append(f"{summary_var}E") |
| 110 | + api_vars.append(f"{summary_var}M") |
| 111 | + |
| 112 | + url = f"{CENSUS_API_BASE}/{year}/acs/acs5" |
| 113 | + params: dict[str, str] = { |
| 114 | + "get": f"NAME,{','.join(api_vars)}", |
| 115 | + "for": for_clause, |
| 116 | + "key": api_key, |
| 117 | + } |
| 118 | + if in_clause is not None: |
| 119 | + params["in"] = in_clause |
| 120 | + |
| 121 | + data = _call_census_api(url, params) |
| 122 | + |
| 123 | + # Convert JSON rows to DataFrame. |
| 124 | + headers = data[0] |
| 125 | + df = pd.DataFrame(data[1:], columns=headers) |
| 126 | + |
| 127 | + # Build GEOID from FIPS columns. |
| 128 | + geo_cols = [c for c in df.columns if c in _GEO_COLUMNS] |
| 129 | + if geo_cols: |
| 130 | + df["GEOID"] = df[geo_cols].apply(lambda row: "".join(row), axis=1) |
| 131 | + |
| 132 | + # Identify estimate and MOE columns. |
| 133 | + estimate_cols = [c for c in df.columns if c.endswith("E") and c != "NAME"] |
| 134 | + moe_cols = [c for c in df.columns if c.endswith("M")] |
| 135 | + |
| 136 | + # Convert to numeric. |
| 137 | + for col in estimate_cols + moe_cols: |
| 138 | + df[col] = pd.to_numeric(df[col], errors="coerce") |
| 139 | + |
| 140 | + # Scale MOE if needed. |
| 141 | + if moe_level != 90: |
| 142 | + scale_factor = _Z_SCORES[moe_level] / _Z_SCORES[90] |
| 143 | + df[moe_cols] = df[moe_cols] * scale_factor |
| 144 | + |
| 145 | + if output == "wide": |
| 146 | + keep_cols = ["GEOID", "NAME"] + estimate_cols + moe_cols |
| 147 | + return df[[c for c in keep_cols if c in df.columns]] |
| 148 | + |
| 149 | + # Tidy format: melt estimate and MOE columns separately, then merge. |
| 150 | + id_cols = ["GEOID", "NAME"] if "GEOID" in df.columns else ["NAME"] |
| 151 | + |
| 152 | + # Exclude summary_var columns from the main melt. |
| 153 | + summary_est_col = f"{summary_var}E" if summary_var else None |
| 154 | + summary_moe_col = f"{summary_var}M" if summary_var else None |
| 155 | + main_est_cols = [c for c in estimate_cols if c != summary_est_col] |
| 156 | + main_moe_cols = [c for c in moe_cols if c != summary_moe_col] |
| 157 | + |
| 158 | + est_long = df.melt( |
| 159 | + id_vars=id_cols, |
| 160 | + value_vars=main_est_cols, |
| 161 | + var_name="_est_var", |
| 162 | + value_name="estimate", |
| 163 | + ) |
| 164 | + est_long["variable"] = est_long["_est_var"].str[:-1] |
| 165 | + |
| 166 | + moe_long = df.melt( |
| 167 | + id_vars=id_cols, |
| 168 | + value_vars=main_moe_cols, |
| 169 | + var_name="_moe_var", |
| 170 | + value_name="moe", |
| 171 | + ) |
| 172 | + moe_long["variable"] = moe_long["_moe_var"].str[:-1] |
| 173 | + |
| 174 | + tidy = est_long[id_cols + ["variable", "estimate"]].merge( |
| 175 | + moe_long[id_cols + ["variable", "moe"]], |
| 176 | + on=id_cols + ["variable"], |
| 177 | + ) |
| 178 | + |
| 179 | + # Add summary variable columns if requested. |
| 180 | + if summary_var is not None and summary_est_col in df.columns: |
| 181 | + summary_df = df[id_cols + [summary_est_col, summary_moe_col]].rename( |
| 182 | + columns={summary_est_col: "summary_est", summary_moe_col: "summary_moe"}, |
| 183 | + ) |
| 184 | + tidy = tidy.merge(summary_df, on=id_cols) |
| 185 | + |
| 186 | + return tidy |
0 commit comments