Skip to content

Commit 1420d5e

Browse files
chekosclaude
andauthored
Add Phase 4: tidy output, MOE scaling, geometry for flows, and survey metadata (#298)
* Add Phase 4: tidy output, MOE scaling, geometry for flows, and survey metadata Implement stubbed parameters across estimates and flows modules: - output="tidy" support for get_estimates() and get_flows() - moe_level scaling (90/95/99) for get_flows() - geometry support for get_flows() via attach_geometry() - GEOID construction for flows from origin FIPS columns Add get_survey_metadata() in pypums/survey.py that fetches dataset catalog from the Census API discovery endpoint, with optional year filtering. Export get_survey_metadata from pypums/__init__.py. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * Fix review feedback: cache keys, MSA geometry mapping, stale docs - Include output and moe_level in cache keys for flows.py and estimates.py to prevent stale cache hits across different formats - Map 'metropolitan statistical area' to 'cbsa' before calling attach_geometry() so geometry=True works for MSA-level flows - Remove stale 'Not yet implemented' docstring from estimates.py output parameter - Scaffold column names in get_survey_metadata() so empty catalogs return a DataFrame with the expected columns Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 29fc538 commit 1420d5e

8 files changed

Lines changed: 579 additions & 19 deletions

File tree

pypums/__init__.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,4 +16,5 @@
1616
from .moe import moe_sum as moe_sum
1717
from .moe import significance as significance
1818
from .pums import get_pums as get_pums
19+
from .survey import get_survey_metadata as get_survey_metadata
1920
from .variables import load_variables as load_variables

pypums/estimates.py

Lines changed: 24 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,9 @@
99
from pypums.api.key import census_api_key
1010
from pypums.cache import CensusCache
1111

12+
# Valid output formats.
13+
_VALID_OUTPUTS = frozenset({"tidy", "wide"})
14+
1215
# Product-to-dataset mapping for PEP.
1316
_PRODUCT_DATASETS: dict[str, str] = {
1417
"population": "pep/population",
@@ -78,7 +81,6 @@ def get_estimates(
7881
compatibility but currently has no effect.
7982
output
8083
``"tidy"`` (default) or ``"wide"``.
81-
Not yet implemented — currently returns wide format only.
8284
geometry
8385
If True, return a GeoDataFrame with shapes.
8486
cache_table
@@ -93,6 +95,9 @@ def get_estimates(
9395
pd.DataFrame
9496
Population estimates data.
9597
"""
98+
if output not in _VALID_OUTPUTS:
99+
raise ValueError(f"output must be 'tidy' or 'wide', got {output!r}")
100+
96101
api_key = census_api_key(key) if key else census_api_key()
97102
for_clause, in_clause = build_geography_query(geography, state=state, county=county)
98103

@@ -138,7 +143,7 @@ def get_estimates(
138143
breakdown_str = ",".join(breakdown) if breakdown is not None else ""
139144
cache_key = (
140145
f"est_{vintage}_{resolved_product}_{geography}_{state}_{county}"
141-
f"_{year}_{vars_str}_{breakdown_str}"
146+
f"_{year}_{vars_str}_{breakdown_str}_{output}"
142147
)
143148

144149
# Check cache before calling API.
@@ -171,6 +176,23 @@ def get_estimates(
171176
for col in numeric_cols:
172177
df[col] = pd.to_numeric(df[col], errors="coerce")
173178

179+
# Format output.
180+
if output == "tidy":
181+
id_cols = ["GEOID", "NAME"] if "GEOID" in df.columns else ["NAME"]
182+
# Include any breakdown columns in id_cols.
183+
excluded = geo_set | set(id_cols) | set(numeric_cols)
184+
breakdown_cols = [c for c in df.columns if c not in excluded]
185+
id_cols = id_cols + breakdown_cols
186+
187+
value_cols = [c for c in numeric_cols if c in df.columns]
188+
if value_cols:
189+
df = df.melt(
190+
id_vars=id_cols,
191+
value_vars=value_cols,
192+
var_name="variable",
193+
value_name="value",
194+
)
195+
174196
if geometry:
175197
from pypums.spatial import attach_geometry
176198

pypums/flows.py

Lines changed: 77 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -9,15 +9,12 @@
99
from pypums.api.key import census_api_key
1010
from pypums.cache import CensusCache
1111

12-
# Core flow columns that should be numeric.
13-
_FLOW_NUMERIC_COLS = [
14-
"MOVEDIN",
15-
"MOVEDIN_M",
16-
"MOVEDOUT",
17-
"MOVEDOUT_M",
18-
"MOVEDNET",
19-
"MOVEDNET_M",
20-
]
12+
# Core flow estimate columns and their MOE counterparts.
13+
_FLOW_ESTIMATE_COLS = ["MOVEDIN", "MOVEDOUT", "MOVEDNET"]
14+
_FLOW_MOE_COLS = ["MOVEDIN_M", "MOVEDOUT_M", "MOVEDNET_M"]
15+
16+
# All numeric flow columns.
17+
_FLOW_NUMERIC_COLS = _FLOW_ESTIMATE_COLS + _FLOW_MOE_COLS
2118

2219
# Valid geography levels for migration flows.
2320
_VALID_GEOGRAPHIES = frozenset(
@@ -27,6 +24,13 @@
2724
}
2825
)
2926

27+
# Z-scores for MOE confidence levels (same as acs.py).
28+
_Z_SCORES: dict[int, float] = {
29+
90: 1.645,
30+
95: 1.960,
31+
99: 2.576,
32+
}
33+
3034
_DEFAULT_CACHE_DIR = Path.home() / ".pypums" / "cache" / "api"
3135

3236

@@ -69,19 +73,16 @@ def get_flows(
6973
Data year (default 2019).
7074
output
7175
``"tidy"`` (default) or ``"wide"``.
72-
Not yet implemented — currently returns wide format only.
7376
state
7477
State FIPS code or abbreviation.
7578
county
7679
County FIPS code.
7780
msa
7881
Metropolitan Statistical Area code.
7982
geometry
80-
If True, return a GeoDataFrame with shapes.
81-
Not yet implemented for flows.
83+
If True, return a GeoDataFrame with shapes for the origin geography.
8284
moe_level
8385
Confidence level for MOE: 90, 95, or 99 (default 90).
84-
Not yet implemented — MOE columns are returned as-is at 90%.
8586
cache_table
8687
If True, cache the API response locally to avoid redundant calls.
8788
show_call
@@ -98,6 +99,12 @@ def get_flows(
9899
raise ValueError(
99100
f"geography must be one of {sorted(_VALID_GEOGRAPHIES)}, got {geography!r}"
100101
)
102+
if output not in ("tidy", "wide"):
103+
raise ValueError(f"output must be 'tidy' or 'wide', got {output!r}")
104+
if moe_level not in _Z_SCORES:
105+
raise ValueError(
106+
f"moe_level must be one of {sorted(_Z_SCORES)}, got {moe_level!r}"
107+
)
101108

102109
api_key = census_api_key(key) if key else census_api_key()
103110

@@ -156,7 +163,9 @@ def get_flows(
156163
vars_str = ",".join(variables) if variables is not None else ""
157164
breakdown_str = ",".join(breakdown) if breakdown is not None else ""
158165
cache_key = (
159-
f"flows_{year}_{geography}_{state}_{county}_{msa}_{vars_str}_{breakdown_str}"
166+
f"flows_{year}_{geography}_{state}_{county}"
167+
f"_{msa}_{vars_str}_{breakdown_str}"
168+
f"_{output}_{moe_level}"
160169
)
161170

162171
# Check cache before calling API.
@@ -181,6 +190,60 @@ def get_flows(
181190
if col in df.columns:
182191
df[col] = pd.to_numeric(df[col], errors="coerce")
183192

193+
# Scale MOE if needed (Census API returns MOE at 90% confidence).
194+
if moe_level != 90:
195+
scale_factor = _Z_SCORES[moe_level] / _Z_SCORES[90]
196+
moe_cols_present = [c for c in _FLOW_MOE_COLS if c in df.columns]
197+
df[moe_cols_present] = df[moe_cols_present] * scale_factor
198+
199+
# Build GEOID for origin geography (state1 + county1).
200+
geo_cols = [c for c in ("state1", "county1") if c in df.columns]
201+
if geo_cols:
202+
df["GEOID"] = df[geo_cols].apply(lambda row: "".join(row), axis=1)
203+
204+
# Format output.
205+
if output == "tidy":
206+
# Identify id columns (non-numeric, non-geo FIPS columns).
207+
fips_cols = {"state1", "county1", "state2", "county2"}
208+
id_cols = [
209+
c for c in df.columns if c not in _FLOW_NUMERIC_COLS and c not in fips_cols
210+
]
211+
212+
est_cols = [c for c in _FLOW_ESTIMATE_COLS if c in df.columns]
213+
moe_cols = [c for c in _FLOW_MOE_COLS if c in df.columns]
214+
215+
if est_cols:
216+
est_long = df.melt(
217+
id_vars=id_cols,
218+
value_vars=est_cols,
219+
var_name="variable",
220+
value_name="estimate",
221+
)
222+
moe_long = df.melt(
223+
id_vars=id_cols,
224+
value_vars=moe_cols,
225+
var_name="_moe_var",
226+
value_name="moe",
227+
)
228+
# Map MOE variable back to estimate variable name.
229+
moe_long["variable"] = moe_long["_moe_var"].str.replace(
230+
"_M$", "", regex=True
231+
)
232+
233+
df = est_long.merge(
234+
moe_long[id_cols + ["variable", "moe"]],
235+
on=id_cols + ["variable"],
236+
)
237+
238+
if geometry:
239+
from pypums.spatial import attach_geometry
240+
241+
# Map flows geography names to spatial module names.
242+
geo_name = geography
243+
if geography == "metropolitan statistical area":
244+
geo_name = "cbsa"
245+
df = attach_geometry(df, geo_name, state=state, year=year)
246+
184247
if disk_cache is not None:
185248
disk_cache.set(cache_key, df, ttl_seconds=86400)
186249

pypums/survey.py

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
"""Survey metadata retrieval from the Census API discovery endpoint."""
2+
3+
import pandas as pd
4+
5+
from pypums.api.client import CENSUS_API_BASE, fetch_json
6+
7+
8+
def _fetch_data_json() -> dict:
9+
"""Thin wrapper so tests can mock ``pypums.survey._fetch_data_json``."""
10+
return fetch_json(f"{CENSUS_API_BASE}.json")
11+
12+
13+
def get_survey_metadata(
14+
year: int | None = None,
15+
) -> pd.DataFrame:
16+
"""Fetch available Census Bureau dataset metadata.
17+
18+
Queries the Census API discovery endpoint
19+
(``https://api.census.gov/data.json``) and returns a DataFrame of
20+
available datasets, optionally filtered by year.
21+
22+
Parameters
23+
----------
24+
year
25+
If provided, filter to datasets for that vintage/year.
26+
27+
Returns
28+
-------
29+
pd.DataFrame
30+
Columns: ``title``, ``description``, ``vintage``, ``dataset_name``,
31+
``distribution_url``.
32+
"""
33+
catalog = _fetch_data_json()
34+
35+
datasets = catalog.get("dataset", [])
36+
37+
rows = []
38+
for ds in datasets:
39+
vintage = ds.get("c_vintage")
40+
title = ds.get("title", "")
41+
description = ds.get("description", "")
42+
dataset_name = "/".join(ds.get("c_dataset", []))
43+
44+
# Build the API distribution URL.
45+
dist_url = ""
46+
for dist in ds.get("distribution", []):
47+
if dist.get("accessURL"):
48+
dist_url = dist["accessURL"]
49+
break
50+
51+
rows.append(
52+
{
53+
"title": title,
54+
"description": description,
55+
"vintage": int(vintage) if vintage is not None else None,
56+
"dataset_name": dataset_name,
57+
"distribution_url": dist_url,
58+
}
59+
)
60+
61+
_COLUMNS = [
62+
"title",
63+
"description",
64+
"vintage",
65+
"dataset_name",
66+
"distribution_url",
67+
]
68+
df = pd.DataFrame(rows, columns=_COLUMNS)
69+
70+
if year is not None:
71+
df = df[df["vintage"] == year].reset_index(drop=True)
72+
73+
return df

tests/test_get_estimates.py

Lines changed: 67 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,9 @@
77
* Returns a DataFrame.
88
* ``product`` parameter selects population/components/housing/characteristics.
99
* ``breakdown`` splits characteristics by AGEGROUP, RACE, SEX, HISP.
10-
* ``time_series=True`` returns multiple years of data.
10+
* ``time_series=True`` returns data across multiple years.
11+
* ``output="tidy"`` melts value columns into long format.
12+
* ``output="wide"`` keeps one row per geography (default pre-Phase 4 behavior).
1113
"""
1214

1315
from unittest.mock import patch
@@ -99,3 +101,67 @@ def test_time_series(self, estimates_api_response, fake_api_key):
99101
key=fake_api_key,
100102
)
101103
assert isinstance(df, pd.DataFrame)
104+
105+
106+
class TestGetEstimatesOutput:
107+
"""Test output format parameter."""
108+
109+
def test_tidy_output_has_variable_and_value(
110+
self, estimates_api_response, fake_api_key
111+
):
112+
"""Tidy output should have 'variable' and 'value' columns."""
113+
with _mock_get_estimates(estimates_api_response):
114+
df = get_estimates(
115+
geography="state",
116+
product="population",
117+
variables=["POP_2023", "DENSITY_2023"],
118+
output="tidy",
119+
key=fake_api_key,
120+
)
121+
assert "variable" in df.columns
122+
assert "value" in df.columns
123+
124+
def test_tidy_output_has_more_rows_than_wide(
125+
self, estimates_api_response, fake_api_key
126+
):
127+
"""Tidy format melts variables so there are more rows."""
128+
with _mock_get_estimates(estimates_api_response):
129+
df_tidy = get_estimates(
130+
geography="state",
131+
product="population",
132+
variables=["POP_2023", "DENSITY_2023"],
133+
output="tidy",
134+
key=fake_api_key,
135+
)
136+
with _mock_get_estimates(estimates_api_response):
137+
df_wide = get_estimates(
138+
geography="state",
139+
product="population",
140+
variables=["POP_2023", "DENSITY_2023"],
141+
output="wide",
142+
key=fake_api_key,
143+
)
144+
assert len(df_tidy) > len(df_wide)
145+
146+
def test_wide_output_keeps_columns(self, estimates_api_response, fake_api_key):
147+
"""Wide output keeps variable columns intact."""
148+
with _mock_get_estimates(estimates_api_response):
149+
df = get_estimates(
150+
geography="state",
151+
product="population",
152+
variables=["POP_2023", "DENSITY_2023"],
153+
output="wide",
154+
key=fake_api_key,
155+
)
156+
assert "POP_2023" in df.columns
157+
assert "DENSITY_2023" in df.columns
158+
159+
def test_invalid_output_raises(self, fake_api_key):
160+
"""Invalid output value should raise ValueError."""
161+
with pytest.raises(ValueError, match="output must be"):
162+
get_estimates(
163+
geography="state",
164+
product="population",
165+
output="invalid",
166+
key=fake_api_key,
167+
)

0 commit comments

Comments
 (0)