Skip to content

Complete feature parity: stubbed params, datasets, functions, and CLI#299

Merged
chekos merged 3 commits into
mainfrom
claude/gracious-gates
Mar 8, 2026
Merged

Complete feature parity: stubbed params, datasets, functions, and CLI#299
chekos merged 3 commits into
mainfrom
claude/gracious-gates

Conversation

@chekos

@chekos chekos commented Mar 7, 2026

Copy link
Copy Markdown
Owner

Summary

  • Stubbed parameters now functional: time_series and breakdown_labels in get_estimates(), breakdown_labels in get_flows(), keep_geo_vars in get_acs() and get_decennial()
  • New datasets: pums_variables() (API-fetched with cache), acs5_geography(), mig_recodes
  • New functions: to_survey() with replicate-weight standard errors, interpolate_pw() for population-weighted areal interpolation, summary_files(), get_pop_groups()
  • CLI commands: config set-key, acs, decennial, variables, estimates
  • Eval doc: Section 11 checklist updated — all implemented features marked Done
  • Tests: 70+ new test cases across 7 files (162 total passing, 0 failures)

Test plan

  • uv run --extra test pytest tests/ -v — 162 passed, 2 skipped (spatial/geopandas)
  • All pre-commit hooks pass (ruff check, ruff format)
  • Verify CLI commands work with real API key: pypums acs state --variables B01001_001 --state CA
  • Verify to_survey() with real PUMS data from get_pums(rep_weights="person")

🤖 Generated with Claude Code

… and CLI

Implement all remaining items from the tidycensus evaluation checklist:

- Stubbed parameters: time_series and breakdown_labels in estimates.py,
  breakdown_labels in flows.py, keep_geo_vars in get_acs/get_decennial
- Datasets: pums_variables (API-fetched with cache), acs5_geography,
  mig_recodes
- Functions: to_survey() with replicate-weight SE, interpolate_pw()
  for population-weighted areal interpolation, summary_files(),
  get_pop_groups()
- CLI: config set-key, acs, decennial, variables, estimates commands
- Tests: 70+ new test cases across 7 test files (162 total passing)
- Eval doc: Section 11 checklist updated from Planned to Done

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@claude

claude Bot commented Mar 7, 2026

Copy link
Copy Markdown

Claude finished @chekos's task in 2m 32s —— View job


PR #299 Code Review

Tasks:

  • Gather context (reading CLAUDE.md and changed files)
  • Review core modules (estimates.py, flows.py, acs.py, decennial.py)
  • Review new modules (census_helpers.py, survey.py, spatial.py)
  • Review new datasets (pums_vars.py, acs5_geography.py, mig_recodes.py)
  • Review CLI changes (cli.py)
  • Review tests
  • Post comprehensive review

Overall this is a solid, well-structured feature parity PR. The code is readable, consistently formatted, and 70+ tests with all passing is reassuring. Below are the issues I found, roughly ordered by severity.


Bugs

1. interpolate_pw() — index mismatch when to_gdf has a non-default index [pypums/spatial.py:301]

_to_idx is always range(len(to_copy)) (i.e. 0, 1, 2, …), but result.index preserves to_gdf's original index. If to_gdf was filtered or sliced before being passed in, result.index.map(...) will find no matching keys and fill everything with 0.0, silently producing wrong results.

# line 258-259
to_copy["_to_idx"] = range(len(to_copy))   # always 0..n-1
# ...
# line 301 — result keeps to_gdf's original index, not 0..n-1
result[value_col] = result.index.map(
    dict(zip(target_values.index, target_values.values, strict=True))
).fillna(0.0)

Fix: either result = result.reset_index(drop=True) after copying, or map via _to_idx instead of result.index. Fix this →


2. get_pop_groups() — state filter reimplements what df already did [pypums/census_helpers.py:99-115]

The state filter builds the full df first (line 97), then throws it away and re-parses the raw JSON again to create a filtered version. On top of that, the filtering loop creates two dict(zip(...)) calls per row — one for the if condition and one for building state_rows — making it O(n) extra dict allocations for no benefit.

# Simpler fix — filter the already-built df:
if state is not None and "state" in [h.lower() for h in headers]:
    state_col = next(h for h in headers if h.lower() == "state")
    # df already has code/label; re-add state col for filtering
    ...

Fix this →


3. as_dot_density() — point-in-polygon loop has poor performance [pypums/spatial.py:191-209]

The function tests each candidate point individually with polygon.contains(pt) inside a Python loop. For features that need many dots this becomes very slow. The batch of xs/ys is generated via NumPy, but then iterated one at a time defeating the purpose. Consider using shapely.vectorized.contains or gpd.GeoSeries containment instead. Fix this →


Design / Correctness Issues

4. to_survey() shadows the Python builtin type [pypums/survey.py:150]

def to_survey(df, type="person", design="rep_weights"):  # shadows builtin!

This trips up linters and makes it impossible to use type() inside the function body without workarounds. Rename to weight_type or pums_type. Fix this →


5. config set-key doesn't actually persist the key [pypums/cli.py:99-117]

The command sets os.environ["CENSUS_API_KEY"] (process-scope only) and calls census_api_key(key) while discarding its return value. The --install/--no-install flag changes the console message but has no effect on what the code does. Users expecting the key to survive the CLI invocation will be confused. Either write to a dotenv/config file, or drop the install flag and clarify in the help text that the key is only set for the current session.


6. time_series=True is not validated against non-time-series products [pypums/estimates.py:190-193]

If a user passes time_series=True, product="housing" or product="characteristics", the code appends DATE_CODE,DATE_DESC to the get vars and sends DATE_CODE=* — which the Census API will reject. Add a guard:

if time_series and resolved_product != "population":
    raise ValueError("time_series is only supported for product='population'")

Fix this →


7. breakdown_labels silently drops unknown dimensions [pypums/estimates.py:237-243, pypums/flows.py:205-213]

If a breakdown dimension isn't in _BREAKDOWN_LABELS / MIG_RECODE_LABELS, no label column is added and no warning is emitted. This is silent failure. A warnings.warn() would help users discover the gap.


8. Zero-padded vs. integer code mismatch in flow breakdown_labels [pypums/flows.py:212]

MIG_RECODE_LABELS["MOTEFG"] has keys like "00", "01", but df[dim_upper].astype(str) converts an integer 1 to "1", not "01". All flow labels for MOTEFG/AGE_GROUP/RACE_GROUP would come back as NaN. Fix this →


Minor / Style

9. _AGEGROUP lookup has duplicate key "12" / ambiguous range [pypums/estimates.py:38-39]

"7": "Age 65 years and over",
...
"12": "Age 65 years and over",  # duplicate description for different code

Code "7" and "12" both map to "Age 65 years and over" — that looks like a copy-paste error. The Census PEP codebook has different descriptions for these.


10. CLI test patches wrong target [tests/test_cli_new.py:42]

with patch("pypums.acs.get_acs", return_value=mock_df):

The CLI function imports get_acs locally inside acs_data() (from .acs import get_acs), so the patch target is technically the module-level function. This works because Python's module cache is shared, but it's clearer to patch pypums.cli.get_acs after the import occurs, or restructure so the import is at module level (matching the patch target). Low severity — the tests pass — but worth noting for maintainability.


11. pums_variables() caches with no TTL [pypums/datasets/pums_vars.py:73]

disk_cache.set(cache_key, df)   # no ttl_seconds!

The comment says "PUMS variables rarely change within a year" — fair, but a silent perpetual cache could cause stale data across Census releases. Consider a long TTL (e.g., 30 days) instead of indefinite. Same applies to acs5_geography.py:72.


12. summary_files() only includes dec-prefixed datasets [pypums/census_helpers.py:44]

The function is documented as "list available summary files for a given Census year" but silently filters to decennial datasets only. The summary_files name implies broader discovery. Either rename to decennial_summary_files() or expose the filter as a parameter.


Summary

Severity Count
Bug (correctness) 3
Design/correctness 5
Minor/style 4

The most impactful fixes are the interpolate_pw() index bug (#1), the breakdown_labels zero-padding issue in flows (#8), and adding product validation for time_series (#6). The rest are polish items.

Use sys.modules to get the actual module object instead of
import-as, which resolves to the re-exported function.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@claude

claude Bot commented Mar 7, 2026

Copy link
Copy Markdown

Claude finished @chekos's task in 3m 35s —— View job


PR #299 Bug Fixes Applied

Tasks:

  • Gather context (files read, reviewed PR diff)
  • Fix interpolate_pw() index mismatch bug (spatial.py:301)
  • Fix time_series validation for non-population products (estimates.py)
  • Fix to_survey() shadowing Python builtin type (survey.py, tests)
  • Fix get_pop_groups() redundant JSON re-parsing (census_helpers.py)
  • Commit changes (push blocked — see note below)

I implemented the four most impactful fixes from the review. Here's a summary of what was changed:

Fix 1 — interpolate_pw() index mismatch (spatial.py)

result = to_gdf.copy() preserved to_gdf's original index, but target_values.index was always 0..n-1 (from groupby on _to_idx). The .map() call produced NaN for any to_gdf with a non-default index.

# Before
result = to_gdf.copy()
# After
result = to_gdf.copy().reset_index(drop=True)

Fix 2 — time_series validation (estimates.py)

Added a guard before the DATE_CODE params are set, since the Census API only supports DATE_CODE on the pep/population endpoint:

if time_series:
    if resolved_product != "population":
        raise ValueError("time_series=True is only supported for product='population'")
    ...

Fix 3 — to_survey() builtin shadow (survey.py, tests/test_to_survey.py)

Renamed parameter typeweight_type throughout the function signature, body, error messages, and all test call-sites.

Fix 4 — get_pop_groups() redundant JSON re-parsing (census_helpers.py)

The state filter was re-parsing raw[1:] via two dict(zip(...)) calls per row after df was already built. Now the state column is captured once during initial row construction and filtered with a simple df[df["_state"] == state].


Note: Push to claude/gracious-gates was blocked (GitHub Actions bot lacks write permission to this repo). The commit 3665284 is ready locally — you can cherry-pick or apply the diff manually. The remaining review items (#7 silent breakdown_labels drop, #9 duplicate AGEGROUP code, #11 cache TTL, #12 summary_files naming) are lower-severity polish items that don't require immediate fixes.

- Fix interpolate_pw() index mismatch when to_gdf has non-default index
- Add validation: time_series=True only for product='population'
- Rename to_survey() param type→weight_type to avoid shadowing builtin
- Simplify get_pop_groups() state filter (avoid redundant JSON re-parsing)
- Fix flow breakdown_labels zero-padded code mismatch (drop .astype(str))

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@claude

claude Bot commented Mar 8, 2026

Copy link
Copy Markdown

Claude finished @chekos's task in 1m 44s —— View job


PR #299 Post-Fix Review

Tasks:

  • Gather context from PR history
  • Read current state of fixed files
  • Verify which bugs were resolved in the latest commits
  • Check remaining issues from original review
  • Post comprehensive status update

What Was Fixed in Commit 669a3c2

The author applied 5 of the 12 issues from the original review — great turnaround. Here's a verification of each:

# Issue Status
1 interpolate_pw() index mismatch ✅ Fixed — to_gdf.copy().reset_index(drop=True) at spatial.py:300
2 get_pop_groups() redundant JSON re-parsing ✅ Fixed — _state captured inline, filtered via df[df["_state"] == state]
4 to_survey() shadows type builtin ✅ Fixed — renamed to weight_type throughout survey.py and tests
6 time_series=True not validated against non-population products ✅ Fixed — ValueError guard at estimates.py:190-194
8 Zero-padded key mismatch in flow breakdown_labels ✅ Fixed — .astype(str) removed; Census API strings are mapped directly

Remaining Issues

These issues from the original review were not addressed in the latest commit:

Design / Correctness

5. config set-key still doesn't persist [pypums/cli.py:111-113]

os.environ["CENSUS_API_KEY"] = key only lasts for the process lifetime. census_api_key(key) return value is discarded. The --install/--no-install flag still produces different console messages but has no behavioral difference. Users who run pypums config <key> expecting it to survive shell restarts will be confused.

7. breakdown_labels silently drops unknown dimensions [pypums/estimates.py:244, pypums/flows.py:210]

If a breakdown dimension isn't found in _BREAKDOWN_LABELS / MIG_RECODE_LABELS, nothing happens and no feedback is given. A warnings.warn(f"No labels found for breakdown dimension {dim_upper!r}", stacklevel=2) would save debugging time. Fix this →

Minor / Style

9. _AGEGROUP duplicate key "12" [pypums/estimates.py:30]

Both "7" and "12" map to "Age 65 years and over", which is clearly a copy-paste error. According to the Census PEP codebook, code 12 should be a distinct age range. Fix this →

11. pums_variables() and acs5_geography() cache with no TTL [pypums/datasets/pums_vars.py:73, pypums/datasets/acs5_geography.py:72]

Both call disk_cache.set(cache_key, df) without a ttl_seconds argument, creating perpetual caches. After a Census release cycle, stale variables/geographies will be served silently. A 30-day TTL (e.g., ttl_seconds=30*86400) is a reasonable default. Fix this →

12. summary_files() silently filters to decennial datasets [pypums/census_helpers.py:44]

The function name implies broad dataset discovery, but the implementation silently filters to dec-prefixed datasets only. Either rename to decennial_summary_files() or expose the prefix as a parameter.

3. as_dot_density() performance [pypums/spatial.py:198-200]

The NumPy batch generation at line 194-195 is good, but point containment testing reverts to a Python for loop with polygon.contains(pt) one-at-a-time. Vectorising with shapely.vectorized.contains(polygon, xs, ys) would significantly speed up large polygons with many dots.


Summary

The 5 most impactful fixes (bugs 1, 6, 8 and design issues 2, 4) were correctly applied and verified. The remaining 5 items are all minor/style issues that won't cause incorrect results in the typical case — they're good candidates for a follow-up cleanup commit.

@chekos chekos merged commit 8081679 into main Mar 8, 2026
13 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant