diff --git a/.Rbuildignore b/.Rbuildignore index 4510f3b..9a1c249 100644 --- a/.Rbuildignore +++ b/.Rbuildignore @@ -17,3 +17,9 @@ paper.pdf$ ^codecov\.yml$ ^README\.Rmd$ ^CRAN-SUBMISSION$ +^data-raw$ +^AUDIT\.md$ +^CLAUDE\.md$ +^issue_comments$ +^bench$ +^ROADMAP\.md$ diff --git a/.github/workflows/R-CMD-check.yaml b/.github/workflows/R-CMD-check.yaml index fb7b370..33d07fc 100644 --- a/.github/workflows/R-CMD-check.yaml +++ b/.github/workflows/R-CMD-check.yaml @@ -1,17 +1,14 @@ -# For help debugging build failures open an issue on the RStudio community with the 'github-actions' tag. -# https://community.rstudio.com/new-topic?category=Package%20development&tags=github-actions +# Workflow derived from https://github.com/r-lib/actions/tree/v2/examples on: push: - branches: - - main - - master + branches: [main, master] pull_request: - branches: - - main - - master + branches: [main, master] name: R-CMD-check +permissions: read-all + jobs: R-CMD-check: runs-on: ${{ matrix.config.os }} @@ -22,65 +19,34 @@ jobs: fail-fast: false matrix: config: + - {os: macos-latest, r: 'release'} - {os: windows-latest, r: 'release'} - - {os: macOS-latest, r: 'release'} - - {os: ubuntu-20.04, r: 'release', rspm: "https://packagemanager.rstudio.com/cran/__linux__/focal/latest"} - - {os: ubuntu-20.04, r: 'devel', rspm: "https://packagemanager.rstudio.com/cran/__linux__/focal/latest"} + - {os: ubuntu-latest, r: 'devel', http-user-agent: 'release'} + - {os: ubuntu-latest, r: 'release'} + - {os: ubuntu-latest, r: 'oldrel-1'} env: - R_REMOTES_NO_ERRORS_FROM_WARNINGS: true - RSPM: ${{ matrix.config.rspm }} GITHUB_PAT: ${{ secrets.GITHUB_TOKEN }} + R_KEEP_PKG_SOURCE: yes steps: - - uses: actions/checkout@v2 + - uses: actions/checkout@v4 + + - uses: r-lib/actions/setup-pandoc@v2 - - uses: r-lib/actions/setup-r@v1 + - uses: r-lib/actions/setup-r@v2 with: r-version: ${{ matrix.config.r }} + http-user-agent: ${{ matrix.config.http-user-agent }} + use-public-rspm: true - - uses: r-lib/actions/setup-pandoc@v1 - - - name: Query dependencies - run: | - install.packages('remotes') - saveRDS(remotes::dev_package_deps(dependencies = TRUE), ".github/depends.Rds", version = 2) - writeLines(sprintf("R-%i.%i", getRversion()$major, getRversion()$minor), ".github/R-version") - shell: Rscript {0} - - - name: Restore R package cache - if: runner.os != 'Windows' - uses: actions/cache@v2 + - uses: r-lib/actions/setup-r-dependencies@v2 with: - path: ${{ env.R_LIBS_USER }} - key: ${{ runner.os }}-${{ hashFiles('.github/R-version') }}-1-${{ hashFiles('.github/depends.Rds') }} - restore-keys: ${{ runner.os }}-${{ hashFiles('.github/R-version') }}-1- - - - name: Install system dependencies - if: runner.os == 'Linux' - run: | - while read -r cmd - do - eval sudo $cmd - done < <(Rscript -e 'writeLines(remotes::system_requirements("ubuntu", "20.04"))') - - - name: Install dependencies - run: | - remotes::install_deps(dependencies = TRUE) - remotes::install_cran("rcmdcheck") - shell: Rscript {0} - - - name: Check - env: - _R_CHECK_CRAN_INCOMING_REMOTE_: false - run: | - options(crayon.enabled = TRUE) - rcmdcheck::rcmdcheck(args = c("--no-manual", "--as-cran"), error_on = "warning", check_dir = "check") - shell: Rscript {0} + extra-packages: any::rcmdcheck + needs: check - - name: Upload check results - if: failure() - uses: actions/upload-artifact@main + - uses: r-lib/actions/check-r-package@v2 with: - name: ${{ runner.os }}-r${{ matrix.config.r }}-results - path: check + args: 'c("--no-manual", "--as-cran")' + upload-snapshots: true + build_args: 'c("--no-manual", "--compact-vignettes=gs+qpdf")' diff --git a/.github/workflows/pkgdown.yaml b/.github/workflows/pkgdown.yaml index c31e289..6e42152 100644 --- a/.github/workflows/pkgdown.yaml +++ b/.github/workflows/pkgdown.yaml @@ -32,13 +32,9 @@ jobs: - uses: r-lib/actions/setup-r-dependencies@v2 with: - extra-packages: any::pkgdown, local::. + # the preferably pkgdown template is no longer on CRAN; install from GitHub + extra-packages: any::pkgdown, github::amirmasoudabdol/preferably, local::. needs: website - - - name: Install dependencies - run: | - remotes::install_deps(dependencies = TRUE) - install.packages(c("pkgdown","preferably"), type = "binary") - name: Build site run: pkgdown::build_site_github_pages(new_process = FALSE, install = FALSE) diff --git a/.github/workflows/refresh-data.yaml b/.github/workflows/refresh-data.yaml new file mode 100644 index 0000000..9a45ce9 --- /dev/null +++ b/.github/workflows/refresh-data.yaml @@ -0,0 +1,66 @@ +# Quarterly + on-demand data refresh. Runs the data-raw/ pipeline, which +# hard-fails unless the validation gate passes, then opens a PR with the +# refresh summary. A human reviews and merges; nothing ships automatically. +# +# Requirements: repository secret CENSUS_API_KEY (free key from +# https://api.census.gov/data/key_signup.html). +# +# Runner note: this is a single lightweight job (~10 min, ~2 GB disk). To run +# it on the self-hosted runner instead of hosted minutes, change `runs-on:` +# to [self-hosted] — the job needs only R >= 4.2, curl, and network access. + +on: + workflow_dispatch: + schedule: + - cron: '17 6 1 2,5,8,11 *' # 06:17 UTC on the 1st of Feb/May/Aug/Nov + +name: refresh-data + +permissions: + contents: write + pull-requests: write + +jobs: + refresh-data: + runs-on: ubuntu-latest + env: + GITHUB_PAT: ${{ secrets.GITHUB_TOKEN }} + CENSUS_API_KEY: ${{ secrets.CENSUS_API_KEY }} + + steps: + - uses: actions/checkout@v4 + + - uses: r-lib/actions/setup-r@v2 + with: + use-public-rspm: true + + - uses: r-lib/actions/setup-r-dependencies@v2 + with: + packages: | + any::dplyr + any::DBI + any::RSQLite + any::jsonlite + install-package: false + + - name: Cache pipeline sources + uses: actions/cache@v4 + with: + path: data-raw/cache + key: data-sources-${{ hashFiles('data-raw/sources.R') }} + + - name: Run data pipeline (includes validation gate) + run: Rscript data-raw/run_pipeline.R + + - name: Open data refresh PR + uses: peter-evans/create-pull-request@v6 + with: + branch: data-refresh + commit-message: 'data: scheduled refresh via data-raw pipeline' + title: 'Data refresh (validation gate passed)' + body-path: data-raw/refresh_summary.md + add-paths: | + data/ + R/sysdata.rda + data-raw/refresh_summary.md + draft: true diff --git a/.github/workflows/test-coverage.yaml b/.github/workflows/test-coverage.yaml new file mode 100644 index 0000000..47bc63b --- /dev/null +++ b/.github/workflows/test-coverage.yaml @@ -0,0 +1,55 @@ +# Workflow derived from https://github.com/r-lib/actions/tree/v2/examples +on: + push: + branches: [main, master] + pull_request: + branches: [main, master] + +name: test-coverage + +permissions: read-all + +jobs: + test-coverage: + runs-on: ubuntu-latest + env: + GITHUB_PAT: ${{ secrets.GITHUB_TOKEN }} + + steps: + - uses: actions/checkout@v4 + + - uses: r-lib/actions/setup-r@v2 + with: + use-public-rspm: true + + - uses: r-lib/actions/setup-r-dependencies@v2 + with: + extra-packages: any::covr, any::xml2 + needs: coverage + + - name: Test coverage + run: | + cov <- covr::package_coverage( + quiet = FALSE, + clean = FALSE, + install_path = file.path(normalizePath(Sys.getenv("RUNNER_TEMP"), winslash = "/"), "package") + ) + print(cov) + covr::to_cobertura(cov) + shell: Rscript {0} + + - uses: codecov/codecov-action@v4 + with: + # Fail if error if not on PR, or if on PR and token is given + fail_ci_if_error: ${{ github.event_name != 'pull_request' || secrets.CODECOV_TOKEN }} + files: ./cobertura.xml + plugins: noop + disable_search: true + token: ${{ secrets.CODECOV_TOKEN }} + + - name: Show testthat output + if: always() + run: | + ## -------------------------------------------------------------------- + find '${{ runner.temp }}/package' -name 'testthat.Rout*' -exec cat '{}' \; || true + shell: bash diff --git a/AUDIT.md b/AUDIT.md new file mode 100644 index 0000000..d103906 --- /dev/null +++ b/AUDIT.md @@ -0,0 +1,262 @@ +# zipcodeR Phase 0 Audit + +**Date:** 2026-08-24 · **Branch:** `modernization-2026` · **Package version audited:** 0.3.5 (master @ 48ed689) + +This document records the discovery phase of the modernization effort: the current state of +the package, its dependency tree, the provenance and reproducibility of its bundled data, +the exact transformation contract between upstream and the shipped datasets, and a +classification of every data-related issue report. **No code changes accompany this +document.** The recommended branch decision for Phase 2 is at the end. + +--- + +## 1. R CMD check --as-cran baseline + +_(Environment: R 4.6.1 aarch64-apple-darwin, Homebrew; all Imports/Suggests installed from +source.)_ + +`R CMD check --as-cran zipcodeR_0.3.5.tar.gz` → **Status: 1 ERROR, 2 WARNINGs, 3 NOTEs**, +of which the package-substantive findings are: + +- **WARNING (CRAN incoming):** `Package CITATION file contains call(s) to old-style + citEntry(). Please use bibentry() instead.` → Phase 1 fix. +- **WARNING (CRAN incoming):** `Insufficient package version (submitted: 0.3.5, existing: + 0.3.5)` → expected pre-bump; resolved by the Phase 1 version increment. +- **NOTE (top-level files):** `AUDIT.md`, `CLAUDE.md` non-standard at top level → add to + `.Rbuildignore` in Phase 1. + +Environmental noise on this machine (not package defects): PDF-manual ERROR/WARNING +(`pdflatex is not available` — no TeX installed), HTML-validation NOTE (old HTML Tidy), +and a `zipcodeR-manual.tex` leftover NOTE consequent on the missing TeX. No errors, +warnings, or notes in code checks, examples, tests, or vignette rebuilds. + +**Test suite baseline** (`devtools::test()`): **51 PASS / 0 FAIL / 28 WARN** — every +warning is the tidyselect `.data`-in-`select()` deprecation (§5 misc hygiene). + +## 2. Dependency audit + +### 2.1 Declared dependencies (DESCRIPTION @ 0.3.5) + +- **Depends:** R (>= 3.5.0) +- **Imports:** rlang, stringr, raster, tidycensus, tidyr, dplyr, jsonlite, httr, curl, RSQLite, DBI (no version pins) +- **Suggests:** knitr, rmarkdown, markdown, readr, testthat (>= 3.0.0), covr, tibble +- **Undeclared but used:** `utils` (`utils::download.file`, `utils::globalVariables`) + +### 2.2 What each import is actually used for + +| Package | Every usage site | Verdict | +|---|---|---| +| `raster` | `raster::pointDistance()` only — `R/zip_helper_functions.R:102` (`zip_distance`), `R/zip_lookups.r:364` (`search_radius`) | **Remove.** Replace with ~10-line vectorized haversine (base R). | +| `tidycensus` | `tidycensus::fips_codes` dataset only — `R/zip_lookups.r:185` (`search_fips`), `:249` (`get_cd`). Imported *wholesale* (`@import tidycensus` → `import(tidycensus)` in NAMESPACE). | **Remove.** Vendor the ~3,200-row FIPS table (public-domain Census data) as package data via a `data-raw/` script. | +| `tidyr` | `tidyr::extract()` once, inside `normalize_zip()`'s `capture_group()` helper (`R/zip_helper_functions.R:16`) | **Remove.** One base-R regex call. | +| `jsonlite`, `httr`, `curl`, `RSQLite`, `DBI` | `download_zip_data()` only (`R/download_data.r`) | **Demote/remove** with the `download_zip_data()` redesign (see §5.4) — either Suggests + `rlang::check_installed()` or dropped entirely when the refresh moves to `data-raw/`. | +| `dplyr` | Pervasive in all lookup functions | Keep. | +| `rlang` | `.data` pronoun; `list2()` at `zip_lookups.r:38` | Keep (shrink usage). | +| `stringr` | `str_detect` in search functions | Keep (or base-R; low priority). | + +**End state:** `Imports: dplyr, rlang, stringr, utils` — zero GDAL, zero arrow, zero +retired-lineage packages at load time. + +### 2.3 The legacy-stack chains (issues #21, #24, #28) + +- `raster` → **`sp`** (emits the rgdal/rgeos retirement startup message = issue **#28**) and + → `terra` (links GDAL/GEOS/PROJ system libraries). The #21 load failure + ("`coerce` … Raster, SpatRaster") is a classic stale-binary collision between `raster` + and `terra` — impossible once `raster` is gone. +- `tidycensus` → **`sf`** (GDAL at load; on distros whose GDAL links libarrow this produces + the issue **#24** `libarrow.so.800` warnings) plus tigris, rvest, units, and a long tail. +- Neither chain is needed: one is two distance calls, the other is a static lookup table. +- Measured cost on this machine (R 4.6.1/arm64): attaching `raster` + `tidycensus` takes + ~2.6 s and loads **54 namespaces** vs 21 for a dplyr-only baseline — all incurred by + `library(zipcodeR)` today. (Modern `sp` ≥2.x no longer prints the #28 retirement + banner, but the GDAL/PROJ/GEOS load-time exposure and install burden remain.) + +## 3. Upstream reproducibility (uszipcode-project) + +### 3.1 Verdict: build scripts for the current data are NOT public — reconstruct + +- `MacHu-GWU/uszipcode-project` (MIT license, last data release Jan 2022) today contains + only the client library (`uszipcode/model.py`, `search.py`, `db.py` which downloads the + prebuilt SQLite from GitHub releases). +- Full git history (82 commits, back to 2015) was scanned. A deleted `dataset/` directory + was recovered from the parent of the `1.0.1 pre CI commit` (`d4ede94^`), containing + `step1_geocoding.py`, `step2_merge_zipcode_data.py`, `step3_make_database.py` plus two + source archives (`federalgovernmentzipcodes.zip` from federalgovernmentzipcodes.us, + 2012 vintage; `zcta2010.zip` Census 2010 ZCTA data). **These are the v0.0.8-era (2016) + scripts** for the *old* schema (IRS wages, Google-geocoded bounds) — not the pipeline + that produced the 2021/2022 `simple_db`/`comprehensive_db` (ACS-era schema with + median_household_income, timezone, area codes, …). They also depend on dead packages + (`geomate`, `sqlite4dummy`) and personal Google API keys. Recovered copies are archived + for provenance but are **not runnable and do not produce the current schema**. +- No sibling/crawler repo exists under the author's account; the data.census.gov crawler + behind the 1.0.1 release was never published. + +**Conclusion:** the Phase 2 pipeline must be a reconstruction in R from primary sources, +using the upstream SQLite snapshots as a *validation reference*, not as a build input. +MIT licensing on upstream and public-domain status of the underlying Census/IRS data make +this unproblematic; attribution to uszipcode-project stays in the docs. + +### 3.2 Upstream snapshots (validation references) + +| Release | Asset | Size | SHA256 | Rows (`simple_zipcode`) | +|---|---|---|---|---| +| `0.2.6-db-file` (2021-06-08) | simple_db.sqlite | 9,965,568 | `f7c1c9461f9ef648e83e243e6bbe4abe1d116e70e6e4a92aa52f9ea2ae80b3ba` | **41,877** | +| `1.0.1.db` (2022-01-05) | simple_db.sqlite | 10,727,424 | `43383f108ef14dccd925107bc77705f622b1014111ad5c9e5e2a6837bb7f64ff` | **42,724** | +| `1.0.1.db` (2022-01-05) | comprehensive_db.sqlite | 456,556,544 | `d85ed4e25884bc27bdd339d57dd9e2d1763531d4c050acb7a05a3d5aca90668d` | 42,724 (see §3.4) | + +Set difference: 1.0.1 = 0.2.6 + 847 new ZIPs (787 MILITARY — a type absent from 0.2.6 — +plus 30 PO BOX, 20 STANDARD, 10 UNIQUE), **zero ZIPs dropped**. + +⚠ Value-compat caveat: 0.2.6 stores `zipcode_type` titleized (`Standard`, `PO Box`, +`Unique`); 1.0.1 switched to uppercase (`STANDARD`, `PO BOX`, `UNIQUE`, `MILITARY`). +Shipped `zip_code_db` has the titleized values, and `reverse_zipcode()` tests match on +them — any refresh from 1.0.1-schema data must normalize case to preserve the contract. + +### 3.3 simple_zipcode schema (both releases identical) + +24 columns: `zipcode` (TEXT, PK), `zipcode_type`, `major_city`, `post_office_city`, +`common_city_list` (BLOB: zlib-compressed JSON array), `county`, `state`, `lat`, `lng` +(FLOAT, indexed), `timezone`, `radius_in_miles`, `area_code_list` (BLOB: zlib JSON), +`population`, `population_density`, `land_area_in_sqmi`, `water_area_in_sqmi`, +`housing_units`, `occupied_housing_units`, `median_home_value`, +`median_household_income`, `bounds_west/east/north/south`. + +Most plausible primary sources per field (for the reconstruction pipeline): + +| Field(s) | Source | +|---|---| +| zipcode, zipcode_type, major_city, post_office_city, common_city_list | USPS ZIP data (via a licensed-free mirror such as the HUD-USPS crosswalk for existence/city, GeoNames, or federalgovernmentzipcodes-style compilations; needs a licensing check — see §7) | +| county, state | USPS city/state + Census county assignment of the ZCTA | +| lat, lng, bounds_* , land/water_area | Census 2020 ZCTA Gazetteer + TIGER/Line ZCTA shapefiles | +| population, population_density, housing_units, occupied_housing_units, median_home_value, median_household_income | Census ACS 5-year at ZCTA level (tables B01003, B25001/2, B25077, B19013) | +| timezone | point-in-polygon of centroid vs IANA tz boundaries (e.g. timezone-boundary-builder data) | +| radius_in_miles | derived: sqrt(land_area/π) or bounds-based | +| area_code_list | NANPA area-code data (or drop-forward as frozen; roadmap decision) | + +### 3.4 comprehensive_db schema + +Two tables: `simple_zipcode` (identical to the simple DB) and `comprehensive_zipcode` +(42,724 rows, 54 columns): the same 24 base columns, plus `polygon` (ZCTA boundary, +compressed JSON) and 29 rich ACS-profile columns stored as zlib-compressed JSON blobs — +`population_by_year`, `population_by_age/gender/race`, `head_of_household_by_age`, +`families_vs_singles`, `households_with_kids`, `children_by_age`, `housing_type`, +`year_housing_was_built`, `housing_occupancy`, `vacancy_reason`, +`owner_occupied_home_values`, `rental_properties_by_number_of_rooms`, +`monthly_rent_including_utilities_{studio,1b,2b,3plus_b}`, `employment_status`, +`average_household_income_over_time`, `household_income`, `annual_individual_earnings`, +four `sources/investment/retirement_income` pairs, `source_of_earnings`, +`means_of_transportation_to_work_for_workers_16_and_over`, +`travel_time_to_work_in_minutes`, `educational_attainment_for_population_25_and_over`, +`school_enrollment_age_3_to_17`. All are data.census.gov ACS profile aggregates keyed by +ZCTA — the source set for the Phase 2 "comprehensive" release asset and the #7 FIPS/ +enrichment roadmap items. + +## 4. The transformation contract (upstream → zip_code_db) + +`download_zip_data()` (`R/download_data.r:80-83`) reveals the original derivation: + +```r +zip_code_db <- DBI::dbGetQuery(conn, "SELECT * FROM simple_zipcode") +save(zip_code_db, file = ...) +``` + +i.e. an **identity transformation**: all 24 columns, upstream order, RSQLite default type +mapping (TEXT→character, FLOAT→numeric, INTEGER→integer, BLOB→`blob` list-column of +zlib-compressed raw vectors), rows in SQLite storage order, class `data.frame`. +`R/sysdata.rda` stores `zip_code_db_version = "2021-06-08"` — exactly the publish date of +the `0.2.6-db-file` release, confirming the shipped data is the **0.2.6 snapshot** (41,877 +rows match; row 1 = 35004 Moody AL matches; `zipcode_type` titleization matches). + +**Verified 2026-08-24:** loading the 0.2.6 `simple_db.sqlite` via +`DBI::dbGetQuery(conn, "SELECT * FROM simple_zipcode")` and comparing to the shipped +`data/zip_code_db.rda` yields `identical() == TRUE` — zero differences per +`waldo::compare()`. The compatibility contract is therefore precisely: *the 24-column +`simple_zipcode` schema, RSQLite default type mapping, SQLite storage order, class +`data.frame`, titleized `zipcode_type` values, blob list-columns for the two JSON fields.* + +Note the `blob` class on `common_city_list`/`area_code_list` exists only because the +data was built through RSQLite; nothing in Imports provides the `blob` package that +defines the class, so those columns print as raw zlib bytes for end users. This is an +existing wart to address (with compatibility care) in the data-pipeline phase. + +The other two datasets: + +- `zcta_crosswalk` (tibble, 148,897 × 3: ZCTA5, TRACT, GEOID) — selected columns of the + Census **2010** ZCTA↔tract relationship file. 2010 vintage; a 2020 refresh changes + GEOIDs (Phase 2, with a documented migration note). +- `zip_to_cd` (data.frame, 45,914 × 2: ZIP, CD = state FIPS + district) — HUD-USPS + crosswalk vintage pre-2020 redistricting (issue #29). PR #30 (@awallender) demonstrates + the modern method with the Census 2020 CD118↔ZCTA relationship file; Phase 2 should use + the same method with the current CD119 file and credit the contribution. + +## 5. Function-level defect inventory + +(Each maps to a Phase 1/3 fix with a regression test and a staged issue comment.) + +| # | Function / site | Defect | +|---|---|---| +| #27 | `reverse_zipcode()` `R/zip_lookups.r:89-118` | **Reproduced on master 2026-08-24**: the issue's 13-row `mutate()` reprex fails with `county must be size 13 or 1, not 12`, and `reverse_zipcode(c("08734","08731"))` returns 08731 first (database order). Output ordered by database order, not input order (`%in%` filter); **duplicate inputs collapse to one row**, breaking `mutate()`'s length contract (the reported error). NA-row insertion is an O(n²) `add_row` loop; `.data` misused inside `stop()` at :115 (would itself error); scalar-only length check at :91-96 skips vectors. | +| #20 | `zip_distance()` `R/zip_helper_functions.R:80-116` | Reported swap **already fixed in 0.3.4 — verified on master 2026-08-24** with both reprexes from the issue: `zip_distance(c("08731","08734"), c("08901","08005"))` → 40.70 / 8.06 mi (correct pairing) and the repeated-pair case → 0.0 / 6.9 (correct). Phase 1 = regression tests + staged closing comment. Remaining latent defects: relies on `zipcode` uniqueness with no guard; `filter(lat != "NA")` string comparison at :90; `lonlat = FALSE` documents planar distance on raw degrees (meaningless units). | +| #33 | `search_radius()` `R/zip_lookups.r:355-383` | ~42k-iteration R loop calling `raster::pointDistance` per row (reporter measured ~40× speedup from vectorizing alone). Additional bug at :360: `filter(lat != "NA")` resolves `lat` to the *function argument*, so the intended NA-coordinate filter is a no-op. Phase 3: vectorized haversine + bounding-box prefilter. | +| — | `geocode_zip()` `R/zip_lookups.r:324-340` | Same order bug as reverse_zipcode; silently drops unmatched ZIPs (output shorter than input). | +| — | `download_zip_data()` `R/download_data.r` | Writes into the installed package directory via `system.file()` — CRAN policy violation, fails on read-only libraries, and under `LazyData: true` the written `.rda` is **never loaded** (installed data lives in `data/Rdata.rdb`), so the refresh mechanism has never actually worked post-install. All `file.exists(system.file("data", "*.rda"))` guards are permanently FALSE on installed packages. Crosswalk URLs point at the abandoned `gavinrozzi/zipcodeR-data` repo. Internet check happens *after* the first network call. | +| — | `get_cd()` `R/zip_lookups.r:261-264` | Computed `output` (with a fragile hardcoded rename) discarded; returns a bare `list()`. Also `nchar(county_fips < 3)` misplaced parenthesis at :197 (`search_fips`) — always-true condition, harmless only by accident. | +| #14 | `geocode_zip()` docs | Not a bug: negative longitude is the western hemisphere sign convention. Fix via docs + pkgdown FAQ. | +| #13 | data access | `zip_code_db not found` reports trace to broken/partial installs of lazy-loaded data; add FAQ + `R CMD check`-clean reinstall guidance. | +| — | Misc hygiene | tidyselect `.data`-in-`select()` deprecation warnings (`zip_lookups.r:287,332,374`, `zip_helper_functions.R:89`); deprecated `citEntry()` in `inst/CITATION`; `test-03-helper-functions.R` assertions outside `test_that()`; duplicated `%>%` importFrom; `R/data.r:20` documents `zipcode_type` as "2010 State FIPS Code" (copy-paste error). | +| — | CI | `R-CMD-check.yaml` uses r-lib/actions **v1** on retired `ubuntu-20.04` images with focal RSPM URLs; no oldrel job. `pkgdown.yaml` is already modern (v2). | + +## 6. Missing-ZIP forensics (#25, #26, #19) + +Authoritative reference: distinct 2020 ZCTAs from the Census CD118↔ZCTA national +relationship file = **33,791 ZCTAs**. + +| Report | ZIP(s) | Shipped 0.2.6 data | Upstream 1.0.1 | 2020 ZCTA? | Classification | +|---|---|---|---|---|---| +| #26 | 91230 (Glendale CA) | absent | absent | **no** | USPS-only (PO-Box-type) ZIP, never covered by upstream's ZCTA-centric sources. Genuinely valid ZIP; needs a USPS-derived source (HUD crosswalk has it) in the reconstruction pipeline. | +| #25 | 97003 (Beaverton OR) | absent | present but **lat/lng = 0.0** | yes | Missing upstream in 0.2.6; added in 1.0.1 with a bad (0,0) geocode. Fix = reconstruction with Census Gazetteer coordinates. The "dozen other Oregon ZIPs" were never enumerated in the issue (empty body); Oregon otherwise fully covered — 97003 is the *only* 2020 OR ZCTA absent from shipped data. | +| #19 | "many ZIPs" in `zip_distance` | — | — | — | Two causes: (a) staleness above; (b) **8,773 of 41,877 shipped rows have NULL lat/lng** (PO Box/Unique ZIPs without geocodes) and `zip_distance` returns NA for them. Documentation + data-refresh issue, not a code bug. | +| — | All 2020 ZCTAs | 23 missing nationwide | **0 missing** | — | The 23: US Virgin Islands (00802/20/30/40/50/51), American Samoa (96799), Guam (96910-96929), N. Mariana (96950-52), and 5 new mainland ZCTAs (72405, 72713, 75036, 75072, 89437) + 97003. Data refresh resolves all. | + +## 7. Licensing & provenance notes + +- **uszipcode-project:** MIT — reuse/derivation fine with attribution (already credited in + README/docs; keep it). +- **Census (ACS, decennial, gazetteer, TIGER, relationship files), HUD-USPS crosswalk + (free registration/API token), IRS SOI:** U.S. Government public domain / freely + redistributable. HUD crosswalk requires a token for API access; files are + redistributable with citation. +- ⚠ **Open question for the maintainer:** USPS city/alias names (`major_city`, + `post_office_city`, `common_city_list`, `zipcode_type`). USPS licenses its raw products; + the public compilations upstream used (federalgovernmentzipcodes.us, 2012) are of + unclear provenance. The reconstruction pipeline can source ZIP existence/type/city from + the HUD-USPS crosswalk (public) + GeoNames (CC-BY) instead. **Flagged per the + constraint: decide the acceptable source before Phase 2 builds these columns.** + +## 8. Recommended Phase 2 branch decision + +**Reconstruct the pipeline in R** (`data-raw/`, targets-style staged scripts): + +1. Primary sources: Census 2020 Gazetteer + ACS 5-yr (ZCTA level), HUD-USPS crosswalk, + Census relationship files (tract, CD119), tz-boundary data. Pinned URLs + SHA256. +2. Output the exact 24-column contract of §4 (titleized `zipcode_type`, blob-compatible + list columns or a documented forward-compatible representation), validated against + both upstream snapshots (0.2.6 for backward compat, 1.0.1 for coverage). +3. Validation gate: row count ≥ 41,877 and ~42-43k sanity band; every shipped ZIP retained + (retirements flagged, never dropped); regression ZIPs (91230, 97003, VI/Guam set) + present with coordinates; schema identical; #20-reprex distance spot-checks. +4. Interim quick win available at any time: the 1.0.1 snapshot itself fixes 22 of 23 + missing ZCTAs (not 91230) — but it is *also* 4 years stale; prefer going straight to + reconstruction. + +## 9. Risks + +- USPS-derived naming columns need a licensing decision (§7) before they can be refreshed. +- `zipcode_type`/military ZIPs: introducing MILITARY rows changes `search_*` result sets — + additive, but should be release-noted as data (not API) change. +- blob list-columns: exotic (`blob` class from an implicit RSQLite dependency at build + time); consider migrating to plain character-JSON or list columns in a *data-major* + release with a compatibility shim, since the `blob` class currently arrives without the + package that defines it being declared anywhere. +- 2020 ZCTA/tract GEOID changes will shift `get_tracts()` results — document as data + vintage change. diff --git a/CLAUDE.md b/CLAUDE.md index 9069528..4859367 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -4,7 +4,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co ## Package Overview -zipcodeR is an R package that simplifies working with U.S. ZIP codes. It provides an offline database of 41,877 ZIP codes with 24 attributes each, plus functions for geographic search, distance calculations, and Census data integration. +zipcodeR is an R package that simplifies working with U.S. ZIP codes. It provides an offline database of 42,725 ZIP codes with 24 attributes each (rebuildable via the data-raw/ pipeline), plus functions for geographic search, distance calculations, and Census data integration. ## Development Commands @@ -32,29 +32,38 @@ devtools::load_all() ### Data Layer (`data/`) Three bundled `.rda` datasets loaded lazily: -- `zip_code_db` - Main ZIP code database (41,877 rows, 24 columns) -- `zcta_crosswalk` - ZCTA-to-Census Tract mapping (148,897 rows) -- `zip_to_cd` - ZIP-to-Congressional District mapping (45,914 rows) +- `zip_code_db` - Main ZIP code database (42,725 rows, 24 columns) +- `zcta_crosswalk` - ZCTA-to-Census Tract mapping, 2020 vintage (168,212 rows) +- `zip_to_cd` - ZIP-to-Congressional District mapping, 119th Congress (54,817 rows) ### Source Files (`R/`) - `data.r` - Roxygen documentation for the three datasets -- `zip_lookups.r` - 14 search/lookup functions that filter the datasets +- `zip_lookups.r` - 12 search/lookup functions that filter the datasets - `zip_helper_functions.R` - Utility functions: `normalize_zip()`, `zip_distance()`, `geocode_zip()` -- `download_data.r` - `download_zip_data()` to fetch updated data from upstream +- `distance.R` - Internal vectorized haversine used by all distance math +- `data_version.R` - `zip_data_version()` and `download_comprehensive_data()` +- `download_data.r` - `download_zip_data()` (deprecated no-op) - `globals.r` - Global variable declarations for NSE compliance +### Data pipeline (`data-raw/`) +Reproducible build of all bundled data from pinned, checksummed sources; run +`Rscript data-raw/run_pipeline.R` (needs `CENSUS_API_KEY`). See data-raw/README.md. + ### Function Patterns All lookup functions return tibbles and accept vectors for batch operations. They use tidyverse-style programming with dplyr and the `.data` pronoun for NSE. ## Testing Tests are in `tests/testthat/` using testthat v3: -- `test-01-zip-lookups.R` - Tests for all 14 lookup functions -- `test-02-data.R` - Data integrity tests +- `test-01-zip-lookups.R` - Tests for all 12 lookup functions +- `test-02-data.R` - Data integrity and regression-ZIP tests - `test-03-helper-functions.R` - Utility function tests +- `test-04-schemas.R` - Return-schema snapshots, input validation, deprecation ## CI/CD GitHub Actions workflows in `.github/workflows/`: - `R-CMD-check.yaml` - Runs `R CMD check` on Windows, macOS, and Ubuntu with multiple R versions - `pkgdown.yaml` - Builds and deploys documentation site to GitHub Pages +- `test-coverage.yaml` - Codecov coverage upload +- `refresh-data.yaml` - Quarterly/manual data-pipeline refresh, opens a draft PR diff --git a/DESCRIPTION b/DESCRIPTION index e0d8557..1d25b84 100644 --- a/DESCRIPTION +++ b/DESCRIPTION @@ -1,6 +1,6 @@ Package: zipcodeR Title: Data & Functions for Working with US ZIP Codes -Version: 0.3.5 +Version: 0.4.0 Authors@R: person(given = "Gavin", family = "Rozzi", @@ -16,22 +16,20 @@ BugReports: https://github.com/gavinrozzi/zipcodeR/issues/ Encoding: UTF-8 LazyData: true Roxygen: list(markdown = TRUE) -RoxygenNote: 7.2.1 -Imports: +Imports: rlang, stringr, - raster, - tidycensus, - tidyr, dplyr, - jsonlite, - httr, - curl, - RSQLite, - DBI -Depends: - R (>= 3.5.0) + tools, + utils +Depends: + R (>= 4.0.0) Suggests: + DBI, + RSQLite, + curl, + jsonlite, + openssl, knitr, rmarkdown, markdown, @@ -41,3 +39,4 @@ Suggests: tibble VignetteBuilder: knitr, rmarkdown Config/testthat/edition: 3 +Config/roxygen2/version: 8.1.0 diff --git a/NAMESPACE b/NAMESPACE index 1ea32a9..ad4ee42 100644 --- a/NAMESPACE +++ b/NAMESPACE @@ -1,5 +1,6 @@ # Generated by roxygen2: do not edit by hand +export(download_comprehensive_data) export(download_zip_data) export(geocode_zip) export(get_cd) @@ -14,21 +15,11 @@ export(search_fips) export(search_radius) export(search_state) export(search_tz) +export(zip_data_version) export(zip_distance) -import(tidycensus) -importFrom(DBI,dbGetQuery) -importFrom(RSQLite,dbConnect) -importFrom(curl,has_internet) importFrom(dplyr,"%>%") -importFrom(dplyr,`%>%`) -importFrom(dplyr,filter) -importFrom(dplyr,left_join) -importFrom(dplyr,pull) -importFrom(dplyr,tibble) -importFrom(httr,http_error) -importFrom(jsonlite,fromJSON) -importFrom(raster,pointDistance) -importFrom(rlang,.data) -importFrom(rlang,list2) +importFrom(rlang, + .data, + list2 +) importFrom(stringr,str_detect) -importFrom(tidyr,extract) diff --git a/NEWS.md b/NEWS.md index e47bc57..8bd26b0 100644 --- a/NEWS.md +++ b/NEWS.md @@ -1,5 +1,152 @@ # *News* +# zipcodeR 0.4.0 + +## Data refresh & reproducible pipeline + +- The bundled datasets are now built by a fully reproducible pipeline in + `data-raw/` (pinned, checksummed sources; a hard validation gate; see + `data-raw/README.md`), replacing the previously unscripted snapshot. A new + `zip_data_version()` accessor reports exactly which data release is loaded. +- `zip_code_db` grows from 41,877 to 42,725 ZIP codes: every 2020 Census ZCTA + is now present — including the Oregon ZIP 97003 (#25) and the ~2 dozen + territory/new-development ZCTAs behind many reports of missing ZIPs (#19) — + plus 787 `Military` ZIP codes (a new `zipcode_type` value) and curated + USPS-only ZIP codes such as 91230 (#26). No ZIP codes were removed. +- Coordinates and land/water areas are refreshed from the Census 2024 + Gazetteer (authoritative 2020 ZCTA internal points, 6-decimal precision; + previously 2-decimal geocodes), and demographic attributes from ACS 5-year + estimates (vintage 2023). Distance results change accordingly — e.g. the + README example pair 08731→08901 is now 43.8 mi (previously 40.75 mi with + the coarser 2021 coordinates). +- `zcta_crosswalk` is rebuilt on the 2020 ZCTA/tract vintage (was 2010); + tract GEOIDs change accordingly. +- `zip_to_cd` now maps to 119th-Congress districts, fixing the outdated + pre-2020 crosswalk (#29). Thanks to @awallender, whose PR #30 established + the method with the 118th-Congress file. ZCTA-backed ZIPs come straight + from the Census relationship file (Census "ZZ" not-in-any-district + pseudo-rows excluded); USPS-only ZIPs (P.O. Box/unique codes) are assigned + the district(s) of their USPS city, or of their state where it has a + single district. Military ZIPs have no mapping (overseas APO/FPO codes + have no geographic district), and 1,266 USPS-only codes that the old + pre-2020 crosswalk mapped have no principled current-vintage derivation; + rather than carry their stale district numbers forward, they ship + unmapped and `get_cd()` warns instead of silently returning an empty + result. The planned HUD-USPS crosswalk stage will restore them with + current data. +- District-code convention change: non-voting delegate districts (DC, + Puerto Rico, and the territories) now carry the Census code `98` instead + of the pre-2020 crosswalk's `00` (voting at-large states such as WY/AK/VT + keep `00`). `search_cd()` accepts `"00"` and `"98"` as aliases of one + another, so existing calls keep working; `get_cd()` returns the stored + Census code. +- New `download_comprehensive_data()` fetches the ~450 MB comprehensive + database (full ACS profiles per ZIP code) from a versioned GitHub data + release on demand, with SHA256 verification and caching in + `tools::R_user_dir("zipcodeR", "data")`. Requires R >= 4.0 (Depends bumped + from 3.5). +- A `refresh-data` GitHub Actions workflow (manual dispatch + quarterly) + reruns the pipeline and opens a draft PR with a diff summary; a human + always reviews and merges. + +## Breaking changes / behavior changes + +- Distance calculations (`zip_distance()`, `search_radius()`) now use an + internal haversine implementation (mean Earth radius 6,371,008.8 m) instead + of `raster::pointDistance()`'s WGS84 geodesic. Distances may differ from + previous releases by up to ~0.5% (typically ~0.1%). (#21, #24, #28) +- `reverse_zipcode()` now returns exactly one row per input element, in input + order, with duplicates preserved and NA rows (plus a warning) for ZIP codes + not found in `zip_code_db`. Previously results came back in database order + and duplicate inputs were collapsed, which made the function unusable inside + `dplyr::mutate()`. (#27) +- `geocode_zip()` likewise now preserves input order and duplicates and + returns NA-coordinate rows (with a warning) for unmatched ZIP codes instead + of silently dropping them; it still errors when no input ZIP matches. (#27) + +## Deprecations + +- `zip_distance(lonlat = FALSE)` is deprecated. This mode had a unit bug in + every previous release — it computed Euclidean distance in degree units and + then applied the meters-to-miles conversion, returning 0.00 for essentially + every pair of ZIP codes — so no historical result from it can have been + meaningful. Until removal it warns and returns a planar equirectangular + approximation in the requested units. +- `download_zip_data()` is deprecated and now performs no action beyond the + deprecation warning. It could never refresh the data of an installed + package (lazy data is baked in at install time), and when run from a + source checkout it silently overwrote data files. Bundled data is + refreshed with package releases through the `data-raw/` pipeline; the + large companion database is available via `download_comprehensive_data()`. + The function will be removed in a future release. + +## Dependency changes + +- `raster` and `tidycensus` have been removed from Imports. Loading zipcodeR + no longer pulls in the retired `sp` lineage or any GDAL/GEOS/PROJ/arrow + system dependency, resolving the namespace load failure (#21), the + `libarrow` GDAL warnings on load (#24), and the legacy-package retirement + warning (#28). The Census FIPS code table previously read from + `tidycensus::fips_codes` is now bundled as internal data + (see `data-raw/fips_codes.R`). +- `tidyr` removed from Imports (replaced by base R in `normalize_zip()`). +- `jsonlite`, `curl`, `RSQLite`, `DBI` and `openssl` moved from Imports to + Suggests (`httr` is no longer used at all); they are needed only by + `download_comprehensive_data()` (curl, optionally openssl) and by the + `data-raw/` build pipeline, never by the core lookup functions. +- Imports is now: `dplyr`, `rlang`, `stringr`, `tools`, `utils`. + +## Performance + +- `search_radius()` computes distances in a single vectorized call instead of + a ~42,000-iteration loop, reducing a typical query from seconds to + milliseconds. Further optimization is planned. (#33, with thanks to the + reporter for the vectorization proposal) + +## Bug fixes + +- `search_radius()`'s filter for ZIP codes without coordinates was a no-op + due to argument shadowing; it now correctly excludes coordinate-less rows. +- Fixed a latent error in `reverse_zipcode()`'s no-match path and removed the + quadratic row-insertion loop. +- `get_cd()` now warns informatively on a no-match, distinguishing ZIP codes + absent from `zip_code_db` (likely typos or numeric input that lost its + leading zero) from known ZIP codes that have no district mapping (military + and some USPS-only codes). It also labels every district with its own + state: `state_fips` is now parallel to `district`, fixing silently wrong + labels for the 258 ZIP codes whose districts span a state line (e.g. + 02861 in both RI-01 and MA-04 previously reported both as "MA"). Vector + input is rejected with an informative error instead of being silently + recycled. +- `reverse_zipcode(NA)` no longer crashes; a single NA input yields an NA + row with a warning, matching the vector behavior. +- `normalize_zip(100000)` no longer returns the invalid 6-character + "100000"; the numeric truncation boundary now matches the character + branch. +- `search_fips()` is consistent on no-match: an unknown state or county + FIPS code raises an informative error (previously an unknown state + returned an empty result silently), and the state-only branch now returns + a tibble like every other search function. + +## Documentation + +- New FAQ vignette covering the western-hemisphere longitude sign convention + (#14), missing ZIP codes and the ZIP-vs-ZCTA distinction (#19, #25, #26), + jurisdiction accuracy limitations (#32), and the lazy-data installation + error (#13). +- `zip_code_db` documentation now includes a provenance-and-limitations + section (#32) and corrects the `zipcode_type` description; `zip_to_cd` + documentation discloses its pre-2020-redistricting vintage (#29). +- Added regression tests for the `zip_distance()` ordering fix shipped in + 0.3.4. (#20) + +## Infrastructure + +- GitHub Actions workflows modernized to r-lib/actions v2 with an + ubuntu/macOS/windows check matrix across release, devel, and oldrel-1, + plus a Codecov test-coverage workflow. +- `inst/CITATION` migrated from the deprecated `citEntry()` to `bibentry()`. + # zipcodeR 0.3.5 - Hotfix to address failing vignette to prevent package being archived by CRAN team. diff --git a/R/data.r b/R/data.r index 753c4cb..8da4bc1 100644 --- a/R/data.r +++ b/R/data.r @@ -1,12 +1,12 @@ -#' ZCTA to Census Tract (2010) Crosswalk +#' ZCTA to Census Tract (2020) Crosswalk #' -#' A dataset containing the relationships between ZIP code tabulation areas (ZCTA) and Census Tracts. This contains selected variables from the official crosswalk file. +#' A dataset containing the relationships between ZIP code tabulation areas (ZCTA) and Census Tracts. This contains selected variables from the official relationship file. Built by \code{data-raw/03_build_zcta_crosswalk.R}; see \code{zip_data_version()} for the data release. #' -#' @format A data frame with 148897 rows and 4 variables: +#' @format A data frame with 168212 rows and 3 variables: #' \describe{ -#' \item{ZCTA5}{2010 ZIP Code Tabulation Area} -#' \item{TRACT}{2010 Census Tract Code} -#' \item{GEOID}{Concatenation of 2010 State, County, and Tract} +#' \item{ZCTA5}{2020 ZIP Code Tabulation Area} +#' \item{TRACT}{2020 Census Tract Code} +#' \item{GEOID}{Concatenation of 2020 State, County, and Tract} #' } #' @source \url{https://www.census.gov/geographies/reference-files/time-series/geo/relationship-files.html} "zcta_crosswalk" @@ -14,10 +14,28 @@ #' #' A dataset containing detailed information for U.S. ZIP codes #' -#' @format A data frame with 41877 rows and 24 variables: +#' @section Provenance and limitations: +#' This dataset is built by the reproducible pipeline in \code{data-raw/} +#' (see \code{zip_data_version()} for the loaded data release). Its base is +#' the \code{simple_zipcode} table of the +#' \href{https://github.com/MacHu-GWU/uszipcode-project}{uszipcode} project's +#' database (MIT license), refreshed with current U.S. Census Bureau data: +#' coordinates and land/water areas from the Gazetteer (2020 ZCTAs), +#' demographic attributes from ACS 5-year estimates, and place names for +#' post-2021 additions from GeoNames (CC BY 4.0). +#' Users should be aware of inherent limitations of ZIP-code-level +#' data: ZIP codes are postal delivery constructs, not polygons or +#' jurisdictions. They can cross city, county, and state boundaries, and the +#' \code{major_city}/\code{county} columns reflect the predominant postal +#' assignment, not legal jurisdiction. Census-derived attributes are estimated +#' at the ZIP Code Tabulation Area (ZCTA) level, which only approximates USPS +#' ZIP codes; USPS-only codes (P.O. Box and unique codes) may lack coordinates +#' and demographic attributes. See \code{vignette("faq", package = "zipcodeR")}. +#' +#' @format A data frame with 42725 rows and 24 variables: #' \describe{ #' \item{zipcode}{5 digit U.S. ZIP code} -#' \item{zipcode_type}{2010 State FIPS Code} +#' \item{zipcode_type}{Type of ZIP code: Standard, PO Box, Unique or Military} #' \item{major_city}{Major city serving the ZIP code} #' \item{post_office_city}{City of post office serving the ZIP code} #' \item{common_city_list}{List of common cities represented by the ZIP code} @@ -33,7 +51,7 @@ #' \item{land_area_in_sqmi}{Area of the land contained within the ZIP code in square miles} #' \item{water_area_in_sqmi}{Area of the waters contained within the ZIP code in square miles} #' \item{housing_units}{Number of housing units within the ZIP code} -#' \item{occupied_housing_units}{Number of housing units within the ZIP code} +#' \item{occupied_housing_units}{Number of occupied housing units within the ZIP code} #' \item{median_home_value}{Median home price within the ZIP code} #' \item{median_household_income}{Median household income within the ZIP code} #' \item{bounds_west}{Bounding box coordinates} @@ -45,12 +63,22 @@ "zip_code_db" #' ZIP Code to Congressional District Relationship File #' -#' A dataset containing mappings between ZIP codes and congressional districts +#' A dataset containing mappings between ZIP codes and congressional +#' districts of the 119th Congress, reflecting post-2020-census +#' redistricting. ZIP codes spanning multiple districts appear once per +#' district. Built by \code{data-raw/04_build_zip_to_cd.R} from the Census +#' CD119-to-ZCTA relationship file; USPS-only ZIP codes (P.O. Box/unique +#' codes without a ZCTA) are assigned the district(s) of their USPS city, or +#' of their state where it has a single district. Military ZIP codes and a +#' small remainder of USPS-only codes have no mapping. Non-voting delegate +#' districts (DC and the territories) carry the Census code \code{98}; +#' voting at-large states carry \code{00}. See \code{zip_data_version()} for +#' the data release. #' -#' @format A data frame with 45914 rows and 2 variables: +#' @format A data frame with 54817 rows and 2 variables: #' \describe{ #' \item{ZIP}{5 digit U.S. ZIP code} #' \item{CD}{Four digit congressional district code (State FIPS code + district number)} #' } -#' @source \url{https://www.huduser.gov/portal/datasets/usps_crosswalk.html} +#' @source \url{https://www.census.gov/geographies/reference-files/time-series/geo/relationship-files.html} "zip_to_cd" diff --git a/R/data_version.R b/R/data_version.R new file mode 100644 index 0000000..95cab05 --- /dev/null +++ b/R/data_version.R @@ -0,0 +1,158 @@ +#' Report the version of the bundled ZIP code data +#' +#' zipcodeR versions its data releases separately from its code releases. +#' This accessor reports exactly which data build is loaded, so users can +#' cite it and bug reports can pin the vintage. +#' +#' @return A named list with the data release version, build date, row count +#' of \code{zip_code_db}, and the primary sources (with vintages) that +#' produced each dataset. +#' @examples +#' zip_data_version() +#' @export +zip_data_version <- function() { + zip_data_meta +} + +#' Download the comprehensive ZIP code database +#' +#' The bundled \code{zip_code_db} is the lightweight ("simple") dataset. A +#' much larger companion database with detailed ACS demographic profiles per +#' ZIP code (the "comprehensive" database, ~450 MB SQLite) is published as an +#' asset of the zipcodeR data releases on GitHub rather than shipped in the +#' package. +#' +#' This function downloads that database once, verifies its SHA256 checksum, +#' and caches it under \code{tools::R_user_dir("zipcodeR", "data")}; later +#' calls return the cached path immediately. It never downloads without being +#' called explicitly. For offline use, copy the file to that directory +#' yourself (the expected file name is the asset name from the data release). +#' +#' @param force If TRUE, re-download even if a verified copy is cached. +#' @return Invisibly, the path to the downloaded SQLite database. Query it +#' with DBI/RSQLite, e.g. +#' \code{DBI::dbConnect(RSQLite::SQLite(), download_comprehensive_data())}. +#' @examples +#' \dontrun{ +#' path <- download_comprehensive_data() +#' } +#' @export +download_comprehensive_data <- function(force = FALSE) { + rlang::check_installed( + c("curl", "utils"), + reason = "to download the comprehensive ZIP code database" + ) + # Verify SHA256 capability BEFORE any download: discovering its absence + # after a ~450 MB transfer would discard the download on every attempt + ensure_sha256_available() + meta <- zip_data_meta$comprehensive + cache_dir <- tools::R_user_dir("zipcodeR", "data") + dest <- file.path(cache_dir, meta$asset) + + if (file.exists(dest) && !force) { + if (identical(file_sha256(dest), meta$sha256)) { + message("zipcodeR: using cached comprehensive database at ", dest) + return(invisible(dest)) + } + message("zipcodeR: cached file failed checksum verification; re-downloading") + } + + if (!curl::has_internet()) { + stop("No internet connection. Please connect to the internet and try again.") + } + + url <- sprintf( + "https://github.com/gavinrozzi/zipcodeR/releases/download/%s/%s", + meta$release_tag, meta$asset + ) + dir.create(cache_dir, showWarnings = FALSE, recursive = TRUE) + message( + "zipcodeR: downloading the comprehensive database (~450 MB) from the ", + meta$release_tag, " data release.\nThis is a one-time download cached in ", + cache_dir + ) + tmp <- paste0(dest, ".download") + on.exit(unlink(tmp), add = TRUE) + # R's default download timeout (60s) is far too short for ~450 MB; + # raise it for this call only + old_timeout <- options(timeout = max(3600, getOption("timeout"))) + on.exit(options(old_timeout), add = TRUE) + tryCatch( + utils::download.file(url, tmp, mode = "wb"), + error = function(e) { + stop( + "Download failed: ", conditionMessage(e), + "\nIf this is a 404, the '", meta$release_tag, "' data release may ", + "not have been published on GitHub yet - see ", + "https://github.com/gavinrozzi/zipcodeR/releases", + call. = FALSE + ) + } + ) + + got <- file_sha256(tmp) + if (!identical(got, meta$sha256)) { + stop( + "Checksum verification failed for the downloaded database.\n expected: ", + meta$sha256, "\n got: ", got, + "\nThe download may be corrupted or tampered with; not keeping it." + ) + } + # Prefer an atomic rename; it cannot overwrite an existing file on + # Windows, so fall back to an overwriting copy. The old cache is only + # replaced, never deleted ahead of a successful move, and leftover tmp + # cleanup is handled by on.exit (a failed cleanup is not a failure). + if (!file.rename(tmp, dest)) { + if (!file.copy(tmp, dest, overwrite = TRUE)) { + stop("Failed to move the verified download into place at ", dest) + } + } + message("zipcodeR: download complete and verified: ", dest) + invisible(dest) +} + +# Stop with an informative error when no SHA256 mechanism exists, so the +# capability is established before any large download +#' @noRd +ensure_sha256_available <- function() { + ok <- exists("sha256sum", envir = asNamespace("tools"), inherits = FALSE) || + requireNamespace("openssl", quietly = TRUE) || + any(nzchar(Sys.which(c("shasum", "sha256sum")))) + if (!ok) { + stop( + "No SHA256 tool available to verify the download. Install the ", + "'openssl' package (install.packages(\"openssl\")), upgrade to ", + "R >= 4.5, or ensure a shasum/sha256sum binary is on the PATH." + ) + } + invisible(TRUE) +} + +# SHA256 of a file without adding a package dependency: prefer +# tools::sha256sum (R >= 4.5), fall back to the openssl package if installed, +# then to the system shasum/sha256sum binaries. +#' @noRd +file_sha256 <- function(path) { + if (exists("sha256sum", envir = asNamespace("tools"), inherits = FALSE)) { + return(unname(tools::sha256sum(path))) + } + if (requireNamespace("openssl", quietly = TRUE)) { + con <- file(path, "rb") + on.exit(close(con), add = TRUE) + return(as.character(openssl::sha256(con))) + } + bin <- Sys.which(c("shasum", "sha256sum")) + bin <- bin[nzchar(bin)][1] + if (is.na(bin)) { + stop("No SHA256 tool available: need R >= 4.5, the openssl package, or a system shasum/sha256sum binary.") + } + # shasum (incl. shasum.exe / shasum.bat on Windows) defaults to SHA-1 and + # needs the algorithm flag; sha256sum does not + is_shasum <- grepl("^shasum", basename(bin), ignore.case = TRUE) + args <- if (is_shasum) c("-a", "256", shQuote(path)) else shQuote(path) + out <- strsplit(system2(bin, args, stdout = TRUE), " ")[[1]][1] + if (!grepl("^[0-9a-f]{64}$", out)) { + stop("Unexpected output from ", bin, " while computing SHA256: ", out) + } + out +} diff --git a/R/distance.R b/R/distance.R new file mode 100644 index 0000000..3026a19 --- /dev/null +++ b/R/distance.R @@ -0,0 +1,26 @@ +#' Great-circle distance between coordinate pairs (haversine formula) +#' +#' Vectorized over all four arguments (recycled as needed). Returns the +#' spherical (haversine) distance in meters using the mean Earth radius +#' of 6,371,008.8 m. Distances involving NA coordinates return NA. +#' +#' Note: versions of zipcodeR prior to the removal of the raster +#' dependency computed WGS84 geodesic distances; haversine results can +#' differ from those by up to ~0.5%. +#' +#' @param lat_a,lng_a coordinates of the first point(s), decimal degrees +#' @param lat_b,lng_b coordinates of the second point(s), decimal degrees +#' @return numeric vector of distances in meters +#' @noRd +haversine_distance <- function(lat_a, lng_a, lat_b, lng_b) { + earth_radius_m <- 6371008.8 + to_rad <- pi / 180 + + dlat <- (lat_b - lat_a) * to_rad + dlng <- (lng_b - lng_a) * to_rad + + h <- sin(dlat / 2)^2 + + cos(lat_a * to_rad) * cos(lat_b * to_rad) * sin(dlng / 2)^2 + + 2 * earth_radius_m * asin(pmin(1, sqrt(h))) +} diff --git a/R/download_data.r b/R/download_data.r index d5766b0..3107863 100644 --- a/R/download_data.r +++ b/R/download_data.r @@ -1,102 +1,35 @@ -#' Download updated data files needed for library functionality to the package's data directory. To be implemented for future updates. +#' Download updated data files needed for library functionality to the package's data directory (deprecated) #' -#' @param force Boolean, if set to TRUE will force overwrite existing data files with new version -#' @return Data files needed for package functionality, stored in data directory of package install +#' @description +#' **Deprecated.** This function attempts to +#' write refreshed data into the installed package directory, which violates +#' CRAN policy, fails on read-only libraries, and — because the package uses +#' lazy data — never actually changes the data the package loads. It is +#' retained for backward compatibility only and will be removed in a future +#' release. +#' +#' Bundled data is now refreshed through the reproducible pipeline in the +#' package repository (`data-raw/`, shipped with each release; see +#' `zip_data_version()`), and the large companion database is available via +#' [download_comprehensive_data()]. +#' +#' @param force Ignored (retained for backward compatibility). +#' @return Invisibly returns NULL; the function performs no action beyond the deprecation warning. #' @examples #' \dontrun{ #' download_zip_data() #' } -#' @importFrom RSQLite dbConnect -#' @importFrom DBI dbGetQuery -#' @importFrom jsonlite fromJSON -#' @importFrom httr http_error -#' @importFrom dplyr `%>%` -#' @importFrom dplyr filter -#' @importFrom curl has_internet #' @export download_zip_data <- function(force = FALSE) { - - # Define URLs for downloading external datasets used in the package - url_crosswalk <- "https://github.com/gavinrozzi/zipcodeR-data/blob/master/zcta_crosswalk.rda?raw=true" - url_cd <- "https://github.com/gavinrozzi/zipcodeR-data/blob/master/zip_to_cd.rda?raw=true" - - # Test if ZCTA crosswalk file exists, download if not present - if (file.exists(system.file("data", "zcta_crosswalk.rda", package = "zipcodeR")) == TRUE && force == FALSE) { - cat("Crosswalk file found, skipping") - } else if (file.exists(system.file("data", "zcta_crosswalk.rda", package = "zipcodeR")) == FALSE) { - cat(paste("zipcodeR: Downloading ZCTA crosswalk file", "\n")) - utils::download.file(url_crosswalk, paste0(system.file("data", package = "zipcodeR"), "/zcta_crosswalk.rda")) - } else if (force == TRUE) { - cat(paste("zipcodeR: forcing Download of ZCTA crosswalk file", "\n")) - utils::download.file(url_crosswalk, paste0(system.file("data", package = "zipcodeR"), "/zcta_crosswalk.rda")) - } - - # Test if ZIP code db file exists, download if not present - # if (file.exists(system.file("data", "zip_code_db.rda", package = "zipcodeR")) == TRUE && force == FALSE) { - # cat("ZIP code database file found, skipping") - # } else if (file.exists(system.file("data", "zip_code_db.rda", package = "zipcodeR")) == FALSE) { - # cat("Downloading ZIP code database file") - # utils::download.file(url_zip_db, paste0(system.file("data", package = "zipcodeR"), "/zip_code_db.rda")) - # } else if (force == TRUE) { - # cat("Forcing download of ZIP code database file") - # utils::download.file(url_zip_db, paste0(system.file("data", package = "zipcodeR"), "/zip_code_db.rda")) - # } - - # Get the latest SQLite zipcode database from the GitHub API - file_data <- jsonlite::fromJSON("https://api.github.com/repos/MacHu-GWU/uszipcode-project/releases/latest") - assets <- file_data$assets - - # Get URL to download simple ZIP code dataset - zip_db_url <- assets %>% - dplyr::filter(.data$name == "simple_db.sqlite") - - # Store the latest download URL from GitHub - file_name <- zip_db_url$browser_download_url - - - # create a temporary directory and file for downloading the data - td <- tempdir() - zip_file <- tempfile(fileext = ".sqlite", tmpdir = tempdir()) - - # Check if internet connection exists before attempting data download - if (curl::has_internet() == FALSE) { - message("No internet connection. Please connect to the internet and try again.") - return(NULL) - } - - # Check if data is available and download the data - if (httr::http_error(file_name)) { - message("zip_code_db data source broken. Please try again.") - return(NULL) - } else { - message("zipcodeR: downloading zip_code_db") - utils::download.file(file_name, zip_file, mode = "wb") - } - - # Connect to the database - conn <- RSQLite::dbConnect(RSQLite::SQLite(), dbname = zip_file) - - # Read in the new data - zip_code_db <- dbGetQuery(conn, "SELECT * FROM simple_zipcode") - - # Save the updated zip_code_db file to package data directory - save(zip_code_db, file = paste0(system.file("data", package = "zipcodeR"), "/zip_code_db.rda")) - - # Save the latest version of zip_code_db to internal package data - zip_code_db_version <- as.Date(zip_db_url$created_at) - save(zip_code_db_version, file = paste0(system.file("R", package = "zipcodeR"), "/sysdata.rda")) - - # Tear down the database connection - RSQLite::dbDisconnect(conn) - - # Test if congressional district relationship file exists, download if not present - if (file.exists(system.file("data", "zip_to_cd.rda", package = "zipcodeR")) == TRUE && force == FALSE) { - cat("Congressional district file found, skipping") - } else if (file.exists(system.file("data", "zip_to_cd.rda", package = "zipcodeR")) == FALSE) { - cat("zipcodeR: Downloading congressional district data file") - utils::download.file(url_cd, paste0(system.file("data", package = "zipcodeR"), "/zip_to_cd.rda")) - } else if (force == TRUE) { - cat("Forcing download of congressional district data file") - utils::download.file(url_cd, paste0(system.file("data", package = "zipcodeR"), "/zip_to_cd.rda")) - } + .Deprecated( + msg = paste( + "download_zip_data() is deprecated and no longer downloads anything:", + "it could never refresh the data of an installed package (lazy data is", + "baked in at install time), and running it from a source checkout", + "overwrote pipeline-built data files. Bundled data is refreshed with", + "package releases (see zip_data_version()); for the large companion", + "database use download_comprehensive_data()." + ) + ) + invisible(NULL) } diff --git a/R/sysdata.rda b/R/sysdata.rda index 14f32a2..42da7cd 100644 Binary files a/R/sysdata.rda and b/R/sysdata.rda differ diff --git a/R/zip_helper_functions.R b/R/zip_helper_functions.R index 5047229..21d03e7 100644 --- a/R/zip_helper_functions.R +++ b/R/zip_helper_functions.R @@ -5,16 +5,15 @@ #' @return Normalized zipcode #' @examples #' normalize_zip(0008731) -#' @importFrom tidyr extract -#' @importFrom dplyr pull -#' @importFrom dplyr tibble -#' @importFrom dplyr left_join #' @export normalize_zip <- function(zipcode) { capture_group <- function(data, regex) { - tibble(data) %>% - extract(col = data, into = "captured", regex = regex) %>% - pull(.data$captured) + matches <- regmatches(data, regexec(regex, data)) + vapply( + matches, + function(m) if (length(m) >= 2) m[[2]] else NA_character_, + character(1) + ) } # input can be numeric or character @@ -49,7 +48,7 @@ normalize_zip <- function(zipcode) { } zipcode <- ifelse( - zipcode > 100000, + zipcode >= 100000, floor(zipcode / 10000), zipcode ) @@ -68,38 +67,66 @@ normalize_zip <- function(zipcode) { #' #' @param zipcode_a First vector of ZIP codes #' @param zipcode_b Second vector of ZIP codes -#' @param lonlat lonlat argument to pass to raster::pointDistance() to select method of distance calculation. Default is TRUE to calculate distance over a spherical projection. FALSE will calculate the distance in Euclidean (planar) space. +#' @param lonlat If TRUE (the default), calculate the great-circle distance +#' between the ZIP code centroids using the haversine formula. FALSE +#' (deprecated) computes a planar equirectangular approximation instead; +#' note that before the deprecation this mode had a unit bug that made it +#' return near-zero values, so no result from it should be relied on. #' @param units Specify which units to return distance calculations in. Choices include meters or miles. #' @return a data.frame containing a column for each ZIP code and a new column containing the distance between the two columns of ZIP code #' #' @examples #' zip_distance("08731", "08901") #' -#' @importFrom raster pointDistance #' @export zip_distance <- function(zipcode_a, zipcode_b, lonlat = TRUE, units = "miles") { + units <- match.arg(units, c("miles", "meters")) zipcode_a <- as.character(zipcode_a) zipcode_b <- as.character(zipcode_b) + # Recycle the shorter vector when its length divides the longer one, + # matching historical data.frame() recycling; anything else is an error + if (length(zipcode_a) != length(zipcode_b)) { + n <- max(length(zipcode_a), length(zipcode_b)) + m <- min(length(zipcode_a), length(zipcode_b)) + if (m == 0 || n %% m != 0) { + stop( + "`zipcode_a` and `zipcode_b` must have compatible lengths ", + "(equal, or one a multiple of the other): got ", + length(zipcode_a), " and ", length(zipcode_b) + ) + } + zipcode_a <- rep_len(zipcode_a, n) + zipcode_b <- rep_len(zipcode_b, n) + } - # assemble zipcodes in dataframe - zip_data <- data.frame(zipcode_a, zipcode_b) - - # create subset of zip_code_db with only zipcode, lat, and lng - zip_db_small <- zip_code_db %>% - dplyr::select(.data$zipcode, .data$lat, .data$lng) %>% - dplyr::filter(.data$lat != "NA" & .data$lng != "NA") - - # join input data with zip_code_db - zip_data <- zip_data %>% - dplyr::left_join(zip_db_small, by = c('zipcode_a' = 'zipcode')) %>% - dplyr::left_join(zip_db_small, by = c('zipcode_b' = 'zipcode'), suffix = c('.a', '.b')) - - # assemble matrices for distance calculation - points_a <- cbind(cbind(zip_data$lng.a, zip_data$lat.a)) - points_b <- cbind(cbind(zip_data$lng.b, zip_data$lat.b)) - - # Calculate the distance matrix between both sets of points - distance <- raster::pointDistance(points_a, points_b, lonlat = lonlat) + # Look up coordinates for both vectors, preserving input order and + # duplicates (ZIP codes without coordinates yield NA distances) + matched_a <- match(zipcode_a, zip_code_db$zipcode) + matched_b <- match(zipcode_b, zip_code_db$zipcode) + + lat_a <- zip_code_db$lat[matched_a] + lng_a <- zip_code_db$lng[matched_a] + lat_b <- zip_code_db$lat[matched_b] + lng_b <- zip_code_db$lng[matched_b] + + # Calculate the distance between both sets of points in meters + if (lonlat) { + distance <- haversine_distance(lat_a, lng_a, lat_b, lng_b) + } else { + warning( + "zip_distance(lonlat = FALSE) is deprecated. Historical versions had ", + "a unit bug that made this mode return near-zero values; it now ", + "returns a planar equirectangular approximation in the requested ", + "units, and will be removed in a future release." + ) + # equirectangular: scale degree offsets to meters at the mean latitude, + # wrapping the longitude difference onto [-180, 180] for antimeridian pairs + meters_per_degree <- 6371008.8 * pi / 180 + dlng <- ((lng_b - lng_a + 180) %% 360) - 180 + dx <- dlng * cos((lat_a + lat_b) / 2 * pi / 180) + dy <- lat_b - lat_a + distance <- sqrt(dx^2 + dy^2) * meters_per_degree + } # Convert the distance matrix from meters to miles if (units == "miles") { diff --git a/R/zip_lookups.r b/R/zip_lookups.r index d21050a..382d2ca 100644 --- a/R/zip_lookups.r +++ b/R/zip_lookups.r @@ -76,8 +76,11 @@ search_county <- function(county_name, state_abb, ...) { #' Given a ZIP code, returns columns of metadata about that ZIP code #' #' -#' @param zip_code A 5-digit U.S. ZIP code or chracter vector with multiple ZIP codes -#' @return A tibble containing data for the ZIP code(s) +#' @param zip_code A 5-digit U.S. ZIP code or character vector with multiple ZIP codes +#' @return A tibble with one row per element of \code{zip_code}, in input order +#' (duplicates preserved). ZIP codes with no match in \code{zip_code_db} return +#' a row of NA values (with a warning), so the output is always the same length +#' as the input and safe to use inside \code{dplyr::mutate()}. #' #' @examples #' reverse_zipcode("90210") @@ -88,7 +91,8 @@ search_county <- function(county_name, state_abb, ...) { #' @export reverse_zipcode <- function(zip_code) { # Sanity check: validate input for single ZIP before doing anything else - if (length(zip_code) == 1) { + # (NA input is not an error - it yields an NA row, as in the vector case) + if (length(zip_code) == 1 && !is.na(zip_code)) { zip_char <- nchar(as.character(zip_code)) if (zip_char != 5) { stop(paste("Invalid ZIP code detected, expected 5 digit ZIP code, got", zip_char)) @@ -97,23 +101,19 @@ reverse_zipcode <- function(zip_code) { # Convert to character so leading zeroes are preserved zip_code <- as.character(zip_code) - # Get matching ZIP code record for - zip_code_data <- zip_code_db %>% - dplyr::filter(.data$zipcode %in% zip_code) - # Iterate over input and insert NA rows for those with no match - for (i in seq_along(zip_code)) { - if (zip_code[i] %in% zip_code_db$zipcode == FALSE) { - warning(paste("No data found for ZIP code", zip_code[i])) - zip_code_data <- zip_code_data %>% - dplyr::add_row(zipcode = zip_code[i], .before = i) - } - } + # Match each input against the database, preserving input order and + # duplicates so the result always has one row per input element + matched <- match(zip_code, zip_code_db$zipcode) - # Throw an error if nothing found - if (nrow(zip_code_data) == 0) { - stop(paste("No data found for provided ZIP code", .data$zip_code, ",", .data$state)) + for (missing_zip in unique(zip_code[is.na(matched)])) { + warning(paste("No data found for ZIP code", missing_zip)) } + + zip_code_data <- zip_code_db[matched, , drop = FALSE] + # Unmatched inputs become NA rows; keep the queried ZIP in the zipcode column + zip_code_data$zipcode <- zip_code + return(dplyr::as_tibble(zip_code_data)) } #' Search ZIP codes for a given city within a state @@ -181,26 +181,35 @@ search_tz <- function(tz) { #' @importFrom rlang .data #' @export search_fips <- function(state_fips, county_fips) { - # Get FIPS code data from tidycensus - fips_data <- tidycensus::fips_codes + # Census FIPS code table bundled as internal data (see data-raw/fips_codes.R) + fips_data <- fips_codes # Separate routine if only state_fips code provided if (missing(county_fips)) { # Get matching FIPS data for provided state FIPS code fips_result <- fips_data %>% dplyr::filter(.data$state_code == state_fips) + if (nrow(fips_result) == 0) { + stop("No state found for FIPS code ", state_fips) + } # Compare ZIP code database against provided state FIPS code, store matching ZIP code entries result <- zip_code_db %>% dplyr::filter(.data$state == fips_result$state[1]) - return(result) + return(dplyr::as_tibble(result)) } else { # Clean up county FIPS code input by adding leading zeroes to match FIPS code data if not present - if (nchar(county_fips < 3)) { - difference <- base::abs(nchar(county_fips) - 3) - county_fips <- base::paste0(strrep("0", difference), county_fips) + county_fips <- as.character(county_fips) + if (nchar(county_fips) > 3) { + stop("`county_fips` must be a 1-3 digit county FIPS code, got: ", county_fips) + } + if (nchar(county_fips) < 3) { + county_fips <- base::paste0(strrep("0", 3 - nchar(county_fips)), county_fips) } # Get matching FIPS data for provided state & county FIPS code fips_result <- fips_data %>% dplyr::filter(.data$state_code == state_fips & .data$county_code == county_fips) + if (nrow(fips_result) == 0) { + stop("No county found for FIPS code ", state_fips, county_fips) + } # Compare ZIP code database against provided state FIPS code, store matching ZIP code entries result <- zip_code_db %>% dplyr::filter(.data$state == fips_result$state[1] & .data$county == fips_result$county[1]) @@ -234,34 +243,57 @@ get_tracts <- function(zip_code) { } #' Get all congressional districts for a given ZIP code #' -#' @param zip_code A U.S. ZIP code -#' @return a named list of two-digit state code and two digit district code +#' @param zip_code A single U.S. ZIP code +#' @return a named list with \code{state_fips} (state abbreviations) and +#' \code{district} (two-digit district codes). The two vectors are parallel: +#' ZIP codes spanning multiple districts return one element per district, +#' each labeled with its own state (some ZIP codes cross state lines). +#' Non-voting delegate districts (DC and the territories) use the Census +#' code \code{"98"}. #' #' @examples #' get_cd("08731") #' get_cd("90210") #' @importFrom dplyr %>% #' @importFrom rlang .data -#' @import tidycensus #' @export get_cd <- function(zip_code) { - # Get state FIPS codes data from tidycensus library - state_fips <- tidycensus::fips_codes + # get_cd() returns a single list, so it is defined for one ZIP at a time; + # recycling a vector against the lookup table would silently mis-match + if (length(zip_code) != 1) { + stop( + "`zip_code` must be a single ZIP code, not a vector of length ", + length(zip_code), ". Iterate (e.g. lapply) for multiple ZIP codes." + ) + } + # Convert to character so leading zeroes are preserved + zip_code <- as.character(zip_code) # Match ZIP codes with congressional districts located within this ZIP matched_cds <- zip_to_cd %>% dplyr::filter(.data$ZIP == zip_code) + if (nrow(matched_cds) == 0) { + if (zip_code %in% zip_code_db$zipcode) { + warning(paste( + "No congressional district found for ZIP code", zip_code, + "- military and some USPS-only ZIP codes have no district mapping" + )) + } else { + warning(paste( + "ZIP code", zip_code, "not found in zip_code_db -", + "check the input (5-digit character ZIP, leading zeros preserved)" + )) + } + } # Break out the match from the ZIP to congressional district lookup into state FIPS code and congressional district codes district <- stringr::str_sub(matched_cds$CD, -2) - state <- stringr::str_sub(matched_cds$CD, 1, 2) - # Bind the separated district and state codes together as a dataframe - result <- data.frame(cbind(district, state)) - # Join the lookup result with tidycensus FIPS code data for more info - joined <- result %>% - dplyr::left_join(state_fips, by = c("state" = "state_code")) - output <- data.frame(joined$state.y[1], district) %>% - dplyr::rename("state" = "joined.state.y.1.") + state_code <- stringr::str_sub(matched_cds$CD, 1, 2) + # Resolve state abbreviations from the bundled Census FIPS table (see + # data-raw/fips_codes.R). state_fips is parallel to district so that ZIP + # codes spanning a state line (e.g. 02861 in both RI-01 and MA-04) label + # every district with its own state. + state_abb <- fips_codes$state[match(state_code, fips_codes$state_code)] - return(list(state_fips = joined$state.y[1], district = district)) + return(list(state_fips = state_abb, district = district)) } #' Get all ZIP codes that fall within a given congressional district #' @@ -276,15 +308,22 @@ get_cd <- function(zip_code) { #' @importFrom rlang .data #' @export search_cd <- function(state_fips_code, congressional_district) { - # Create code from state and congressional district to match lookup table - cd_code <- base::paste0(state_fips_code, congressional_district) + # "00" (the pre-2020 at-large convention this package used to ship) and + # "98" (the Census delegate/resident-commissioner code now in zip_to_cd for + # DC and the territories) are accepted as aliases of one another + district_codes <- congressional_district + if (congressional_district %in% c("00", "98")) { + district_codes <- c("00", "98") + } + # Create codes from state and congressional district to match lookup table + cd_code <- base::paste0(state_fips_code, district_codes) matched_zips <- zip_to_cd %>% - dplyr::filter(.data$CD == cd_code) + dplyr::filter(.data$CD %in% cd_code) if (nrow(matched_zips) == 0) { stop(paste("No ZIP codes found for congressional district:", congressional_district)) } output <- matched_zips %>% - dplyr::select(-.data$CD) + dplyr::select(-"CD") output$state_fips <- state_fips_code output$congressional_district <- congressional_district return(dplyr::as_tibble(output)) @@ -309,11 +348,18 @@ is_zcta <- function(zip_code) { return(result) } -#' Returns that lat / lon pair of the centroid of a given ZIP code +#' Returns the lat / lon pair of the centroid of a given ZIP code #' +#' Note on sign convention: longitudes in the United States are negative +#' because the U.S. lies in the western hemisphere (west of the prime +#' meridian). This is the standard convention, not an error; do not flip +#' the sign of \code{lng}. #' -#' @param zip_code A 5-digit U.S. ZIP code -#' @return tibble of lat lon coordinates +#' @param zip_code A 5-digit U.S. ZIP code or character vector with multiple ZIP codes +#' @return A tibble of coordinates with one row per element of \code{zip_code}, +#' in input order (duplicates preserved). ZIP codes with no match return a row +#' of NA coordinates (with a warning); an error is raised only when no input +#' ZIP code matches at all. #' #' @examples #' geocode_zip("07762") @@ -326,16 +372,27 @@ geocode_zip <- function(zip_code) { # Convert to character so leading zeroes are preserved zip_code <- as.character(zip_code) - # Get matching ZIP code record for - result <- zip_code_db %>% - dplyr::filter(.data$zipcode %in% zip_code) %>% - dplyr::select(.data$zipcode, .data$lat, .data$lng) %>% - dplyr::as_tibble() + # Match against the database, preserving input order and duplicates + matched <- match(zip_code, zip_code_db$zipcode) - if (nrow(result) == 0) { - stop(paste("No results found for ZIP code", zip_code)) + if (all(is.na(matched))) { + stop(paste("No results found for ZIP code", paste(zip_code, collapse = ", "))) + } + + if (anyNA(matched)) { + warning(paste( + "No results found for ZIP code(s):", + paste(unique(zip_code[is.na(matched)]), collapse = ", ") + )) } + # Unmatched inputs come back as NA rows rather than being dropped + result <- dplyr::tibble( + zipcode = zip_code, + lat = zip_code_db$lat[matched], + lng = zip_code_db$lng[matched] + ) + return(result) } #' Search for ZIP codes that are within a given radius from a point @@ -350,29 +407,59 @@ geocode_zip <- function(zip_code) { #' \dontrun{ #' search_radius(39.9, -74.3, 10) #' } -#' @importFrom raster pointDistance #' @export search_radius <- function(lat, lng, radius = 1) { + if (!is.numeric(lat) || length(lat) != 1 || is.na(lat) || abs(lat) > 90) { + stop("`lat` must be a single latitude between -90 and 90") + } + if (!is.numeric(lng) || length(lng) != 1 || is.na(lng) || abs(lng) > 180) { + stop("`lng` must be a single longitude between -180 and 180") + } + if (!is.numeric(radius) || length(radius) != 1 || is.na(radius) || radius <= 0) { + stop("`radius` must be a single positive number of miles") + } - # Create an instance of the ZIP code database for calculating distance, - # filter to those with lat / lon pairs - zip_data <- zip_code_db %>% - dplyr::filter(lat != "NA") + # Work on just the three needed columns; the full database carries heavy + # list columns that are expensive to subset + keep <- !is.na(zip_code_db$lat) & !is.na(zip_code_db$lng) - # Calculate the distance between all points and the provided coordinate pair - for (i in seq_len(nrow(zip_data))) { - zip_data$distance[i] <- raster::pointDistance(c(lng, lat), c(zip_data$lng[i], zip_data$lat[i]), lonlat = TRUE) + # Cheap bounding-box prefilter before the exact haversine pass, sized so no + # candidate inside the radius can be excluded: + # - the latitude window uses 69 statute miles per degree (constant), + # - the longitude window is computed at the highest-|latitude| edge of the + # search circle (where meridians are closest together), not at the query + # point, so it is wide enough for every candidate latitude in the window, + # - longitude differences are wrapped onto [-180, 180] so circles crossing + # the antimeridian (western Aleutians, Guam) keep their candidates, + # - if the circle nears a pole or spans all longitudes, skip the prefilter. + lat_delta <- radius / 69.0 * 1.05 + edge_lat <- min(abs(lat) + lat_delta, 90) + if (edge_lat < 89) { + lng_delta <- radius / (69.172 * cos(edge_lat * pi / 180)) * 1.05 + keep <- keep & + zip_code_db$lat >= lat - lat_delta & zip_code_db$lat <= lat + lat_delta + if (lng_delta < 180) { + lng_diff <- abs(((zip_code_db$lng - lng + 180) %% 360) - 180) + keep <- keep & lng_diff <= lng_delta + } } - # Convert meters to miles for distance measurement - zip_data$distance <- zip_data$distance * 0.000621371 + zip_data <- dplyr::tibble( + zipcode = zip_code_db$zipcode[keep], + lat = zip_code_db$lat[keep], + lng = zip_code_db$lng[keep] + ) + + # Calculate the distance between all points and the provided coordinate + # pair, converting meters to miles + zip_data$distance <- + haversine_distance(zip_data$lat, zip_data$lng, lat, lng) * 0.000621371 # Get matching ZIP codes within specified search radius result <- zip_data %>% # Filter results to those less than or equal to the search radius dplyr::filter(.data$distance <= radius) %>% - dplyr::select(.data$zipcode, .data$distance) %>% - dplyr::as_tibble() %>% + dplyr::select("zipcode", "distance") %>% dplyr::arrange(.data$distance) # Warn if there is nothing found diff --git a/README.Rmd b/README.Rmd index 86e9c4b..4d9f84c 100644 --- a/README.Rmd +++ b/README.Rmd @@ -14,7 +14,7 @@ knitr::opts_chunk$set( library(tibble) ``` -# zipcodeR +# zipcodeR [![R-CMD-check](https://github.com/gavinrozzi/zipcodeR/workflows/R-CMD-check/badge.svg)](https://github.com/gavinrozzi/zipcodeR/actions) @@ -28,7 +28,7 @@ library(tibble) `{zipcodeR}` is an R package that makes working with ZIP codes in R easier. It provides data on all U.S. ZIP codes using multiple open data sources, making it easier for social science researchers and data scientists to work with ZIP code-level data in data science projects using R. -The latest update to `{zipcodeR}` includes new functions for [searching ZIP codes at various geographic levels & geocoding.](https://gavinrozzi.github.io/zipcodeR/articles/geographic.html) +The latest update to `{zipcodeR}` includes new functions for [searching ZIP codes at various geographic levels & geocoding.](https://zipcoder.39n.io/articles/geographic.html) ## Installation @@ -131,8 +131,8 @@ get_tracts('08731') ``` ## Documentation -Documentation for the current release [is available here.](https://gavinrozzi.github.io/zipcodeR/) -See the [reference section](https://gavinrozzi.github.io/zipcodeR/reference/) for full details on how to use each of the functions provided by zipcodeR. +Documentation for the current release [is available here.](https://zipcoder.39n.io/) +See the [reference section](https://zipcoder.39n.io/reference/) for full details on how to use each of the functions provided by zipcodeR. ## Data Sources This project was inspired by the excellent [uszipcode](https://uszipcode.readthedocs.io/index.html) library for Python and utilizes the same backend database released by its author under the MIT license. This project also incorporates open data from the U.S. Census Bureau and Department of Housing & Urban Development. diff --git a/README.md b/README.md index b55480e..cc417d4 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,7 @@ -# zipcodeR +# zipcodeR @@ -25,7 +25,7 @@ using R. The latest update to `{zipcodeR}` includes new functions for [searching ZIP codes at various geographic levels & -geocoding.](https://gavinrozzi.github.io/zipcodeR/articles/geographic.html) +geocoding.](https://zipcoder.39n.io/articles/geographic.html) ## Installation @@ -72,7 +72,6 @@ abstract = {The United States Postal Service (USPS) assigns unique identifiers f ``` r # Load zipcodeR into R library(zipcodeR) -#> Warning: package 'zipcodeR' was built under R version 4.3.2 ``` ### Find all ZIP codes for a state @@ -82,16 +81,16 @@ search_state('NJ') #> # A tibble: 732 × 24 #> zipcode zipcode_type major_city post_office_city common_city_list county #> -#> 1 07001 Standard Avenel Avenel, NJ Middl… -#> 2 07002 Standard Bayonne Bayonne, NJ Hudso… -#> 3 07003 Standard Bloomfield Bloomfield, NJ Essex… -#> 4 07004 Standard Fairfield Fairfield, NJ Essex… -#> 5 07005 Standard Boonton Boonton, NJ Morri… -#> 6 07006 Standard Caldwell Caldwell, NJ Essex… -#> 7 07007 PO Box Caldwell Essex… -#> 8 07008 Standard Carteret Carteret, NJ Middl… -#> 9 07009 Standard Cedar Grove Cedar Grove, NJ Essex… -#> 10 07010 Standard Cliffside Park Cliffside Park, … Berge… +#> 1 07001 Standard Avenel Avenel, NJ [18] Middl… +#> 2 07002 Standard Bayonne Bayonne, NJ [19] Hudso… +#> 3 07003 Standard Bloomfield Bloomfield, NJ [22] Essex… +#> 4 07004 Standard Fairfield Fairfield, NJ [21] Essex… +#> 5 07005 Standard Boonton Boonton, NJ [36] Morri… +#> 6 07006 Standard Caldwell Caldwell, NJ [39] Essex… +#> 7 07007 PO Box Caldwell [30] Essex… +#> 8 07008 Standard Carteret Carteret, NJ [20] Middl… +#> 9 07009 Standard Cedar Grove Cedar Grove, NJ [23] Essex… +#> 10 07010 Standard Cliffside Park Cliffside Park, … [32] Berge… #> # ℹ 722 more rows #> # ℹ 18 more variables: state , lat , lng , timezone , #> # radius_in_miles , area_code_list , population , @@ -106,7 +105,7 @@ search_state('NJ') ``` r zip_distance('08901','08731') #> zipcode_a zipcode_b distance -#> 1 08901 08731 40.7 +#> 1 08901 08731 43.82 ``` ### Calculate the distance between vectors of ZIP codes @@ -118,8 +117,8 @@ zip_codes <- tribble(~zip_a, ~zip_b, zip_distance(zip_codes$zip_a,zip_codes$zip_b) #> zipcode_a zipcode_b distance -#> 1 08731 08901 40.70 -#> 2 08734 08005 8.06 +#> 1 08731 08901 43.82 +#> 2 08734 08005 10.49 ``` ### Geocode a ZIP code to get its centroid @@ -139,7 +138,7 @@ reverse_zipcode('08901') #> # A tibble: 1 × 24 #> zipcode zipcode_type major_city post_office_city common_city_list county state #> -#> 1 08901 Standard New Bruns… New Brunswick, … Middl… NJ +#> 1 08901 Standard New Bruns… New Brunswick, … [25] Middl… NJ #> # ℹ 17 more variables: lat , lng , timezone , #> # radius_in_miles , area_code_list , population , #> # population_density , land_area_in_sqmi , @@ -156,16 +155,16 @@ search_county('Ocean','NJ') #> # A tibble: 32 × 24 #> zipcode zipcode_type major_city post_office_city common_city_list county #> -#> 1 08005 Standard Barnegat Barnegat, NJ Ocean… -#> 2 08006 PO Box Barnegat Light Barnegat Light, … Ocean… -#> 3 08008 Standard Beach Haven Beach Haven, NJ Ocean… -#> 4 08050 Standard Manahawkin Manahawkin, NJ Ocean… -#> 5 08087 Standard Tuckerton Tuckerton, NJ Ocean… -#> 6 08092 Standard West Creek West Creek, NJ Ocean… -#> 7 08527 Standard Jackson Jackson, NJ Ocean… -#> 8 08533 Standard New Egypt New Egypt, NJ Ocean… -#> 9 08701 Standard Lakewood Lakewood, NJ Ocean… -#> 10 08721 Standard Bayville Bayville, NJ Ocean… +#> 1 08005 Standard Barnegat Barnegat, NJ [20] Ocean… +#> 2 08006 PO Box Barnegat Light Barnegat Light, … [33] Ocean… +#> 3 08008 Standard Beach Haven Beach Haven, NJ [61] Ocean… +#> 4 08050 Standard Manahawkin Manahawkin, NJ [47] Ocean… +#> 5 08087 Standard Tuckerton Tuckerton, NJ [51] Ocean… +#> 6 08092 Standard West Creek West Creek, NJ [22] Ocean… +#> 7 08527 Standard Jackson Jackson, NJ [19] Ocean… +#> 8 08533 Standard New Egypt New Egypt, NJ [21] Ocean… +#> 9 08701 Standard Lakewood Lakewood, NJ [20] Ocean… +#> 10 08721 Standard Bayville Bayville, NJ [20] Ocean… #> # ℹ 22 more rows #> # ℹ 18 more variables: state , lat , lng , timezone , #> # radius_in_miles , area_code_list , population , @@ -182,19 +181,19 @@ search_city('Jersey City','NJ') #> # A tibble: 13 × 24 #> zipcode zipcode_type major_city post_office_city common_city_list county #> -#> 1 07097 Unique Jersey City Hudson Co… -#> 2 07302 Standard Jersey City Jersey City, NJ Hudson Co… -#> 3 07303 PO Box Jersey City Hudson Co… -#> 4 07304 Standard Jersey City Jersey City, NJ Hudson Co… -#> 5 07305 Standard Jersey City Jersey City, NJ Hudson Co… -#> 6 07306 Standard Jersey City Jersey City, NJ Hudson Co… -#> 7 07307 Standard Jersey City Jersey City, NJ Hudson Co… -#> 8 07308 PO Box Jersey City Hudson Co… -#> 9 07309 Standard Jersey City Hudson Co… -#> 10 07310 Standard Jersey City Jersey City, NJ Hudson Co… -#> 11 07311 Standard Jersey City Jersey City, NJ Hudson Co… -#> 12 07395 Unique Jersey City Hudson Co… -#> 13 07399 Unique Jersey City Hudson Co… +#> 1 07097 Unique Jersey City [23] Hudson Co… +#> 2 07302 Standard Jersey City Jersey City, NJ [23] Hudson Co… +#> 3 07303 PO Box Jersey City [23] Hudson Co… +#> 4 07304 Standard Jersey City Jersey City, NJ [23] Hudson Co… +#> 5 07305 Standard Jersey City Jersey City, NJ [23] Hudson Co… +#> 6 07306 Standard Jersey City Jersey City, NJ [23] Hudson Co… +#> 7 07307 Standard Jersey City Jersey City, NJ [23] Hudson Co… +#> 8 07308 PO Box Jersey City [23] Hudson Co… +#> 9 07309 Standard Jersey City [23] Hudson Co… +#> 10 07310 Standard Jersey City Jersey City, NJ [23] Hudson Co… +#> 11 07311 Standard Jersey City Jersey City, NJ [23] Hudson Co… +#> 12 07395 Unique Jersey City [23] Hudson Co… +#> 13 07399 Unique Jersey City [23] Hudson Co… #> # ℹ 18 more variables: state , lat , lng , timezone , #> # radius_in_miles , area_code_list , population , #> # population_density , land_area_in_sqmi , @@ -211,16 +210,16 @@ search_tz('Eastern') #> # A tibble: 14,025 × 24 #> zipcode zipcode_type major_city post_office_city common_city_list county #> -#> 1 06001 Standard Avon Avon, CT Hartfo… -#> 2 06002 Standard Bloomfield Bloomfield, CT Hartfo… -#> 3 06010 Standard Bristol Bristol, CT Hartfo… -#> 4 06013 Standard Burlington Burlington, CT Hartfo… -#> 5 06016 Standard Broad Brook Broad Brook, CT Hartfo… -#> 6 06018 Standard Canaan Canaan, CT Litchf… -#> 7 06019 Standard Canton Canton, CT Hartfo… -#> 8 06020 Standard Canton Center Canton Center, CT Hartfo… -#> 9 06021 Standard Colebrook Colebrook, CT Litchf… -#> 10 06022 Standard Collinsville Collinsville, CT Hartfo… +#> 1 06001 Standard Avon Avon, CT [16] Hartfo… +#> 2 06002 Standard Bloomfield Bloomfield, CT [22] Hartfo… +#> 3 06010 Standard Bristol Bristol, CT [19] Hartfo… +#> 4 06013 Standard Burlington Burlington, CT [36] Hartfo… +#> 5 06016 Standard Broad Brook Broad Brook, CT [46] Hartfo… +#> 6 06018 Standard Canaan Canaan, CT [18] Litchf… +#> 7 06019 Standard Canton Canton, CT [34] Hartfo… +#> 8 06020 Standard Canton Center Canton Center, CT [25] Hartfo… +#> 9 06021 Standard Colebrook Colebrook, CT [21] Litchf… +#> 10 06022 Standard Collinsville Collinsville, CT [24] Hartfo… #> # ℹ 14,015 more rows #> # ℹ 18 more variables: state , lat , lng , timezone , #> # radius_in_miles , area_code_list , population , @@ -238,19 +237,19 @@ get_tracts('08731') #> ZCTA5 TRACT GEOID #> #> 1 08731 732001 34029732001 -#> 2 08731 732002 34029732002 -#> 3 08731 732101 34029732101 -#> 4 08731 732103 34029732103 -#> 5 08731 732104 34029732104 -#> 6 08731 733000 34029733000 +#> 2 08731 732003 34029732003 +#> 3 08731 732004 34029732004 +#> 4 08731 732101 34029732101 +#> 5 08731 732103 34029732103 +#> 6 08731 732104 34029732104 ``` ## Documentation Documentation for the current release [is available -here.](https://gavinrozzi.github.io/zipcodeR/) See the [reference -section](https://gavinrozzi.github.io/zipcodeR/reference/) for full -details on how to use each of the functions provided by zipcodeR. +here.](https://zipcoder.39n.io/) See the [reference +section](https://zipcoder.39n.io/reference/) for full details on how to +use each of the functions provided by zipcodeR. ## Data Sources diff --git a/ROADMAP.md b/ROADMAP.md new file mode 100644 index 0000000..dc8e482 --- /dev/null +++ b/ROADMAP.md @@ -0,0 +1,86 @@ +# zipcodeR Roadmap + +zipcodeR is a solo-maintained civic open-source package. This roadmap favors +boring, low-maintenance solutions over clever ones, and keeps backward +compatibility sacred: no signature changes, column renames, or return-type +changes without a deprecation cycle. + +## 0.4.0 — correctness, dependency hygiene, reproducible data *(this release)* + +Everything on the `modernization-2026` branch (PR #34). Highlights, mapped to +issues: + +| Item | Issues | Status | +|---|---|---| +| Vectorization contract for `reverse_zipcode()` / `geocode_zip()` (input order, duplicates, NA rows) | #27 | done | +| Regression tests for the `zip_distance()` ordering fix | #20 | done | +| Remove raster/tidycensus/tidyr; Imports = dplyr, rlang, stringr, utils; internal haversine | #21 #24 #28 | done | +| `search_radius()`: vectorized + bounding-box prefilter (~280× faster, `bench/`) | #33 | done | +| Reproducible data pipeline (`data-raw/`), validation gate, quarterly refresh workflow | #19 #25 #26 | done | +| Data refresh: every 2020 ZCTA, Military ZIPs, 91230-class USPS-only ZIPs, ACS 2023 attributes | #19 #25 #26 | done | +| `zip_to_cd` rebuilt on the 119th-Congress relationship file | #29 (PR #30 credited) | done | +| Data versioning: `zip_data_version()`, `data-YYYY.MM` release tags | — | done | +| Comprehensive DB as GitHub release asset + `download_comprehensive_data()` | — | done (draft release awaits publishing) | +| FAQ vignette; provenance/limitations docs | #13 #14 #32 | done | +| `download_zip_data()` deprecated (no-op) | — | done; remove in 0.6.0 | +| CI: r-lib/actions v2 matrix + coverage; pkgdown fix | — | done | + +**Release checklist:** review/merge PR #34 → post the staged `issue_comments/` +drafts and close the referenced issues → publish the `data-2026.08` draft +release (activates `download_comprehensive_data()`; until published the +function fails with an explanatory error) → CRAN submission → tag `v0.4.0` → +empty `data-raw/accepted_cd_coverage_loss.txt` (the 1,266-ZIP one-time +acceptance list documented in `data-raw/sources.R` and NEWS). + +## 0.4.x — patch follow-ups (as needed, small) + +- HUD-USPS crosswalk stage in the pipeline (needs maintainer's free HUD API + token as repo secret `HUD_API_KEY`): authoritative USPS-only ZIP universe, + replacing `supplemental_zips.csv`. *(sized: 1 pipeline script + gate rule)* +- Set the `CENSUS_API_KEY` repository secret and do one manual + `refresh-data` workflow run to validate the automation end to end. +- Backfill `bounds_*` for post-2021 ZIPs from TIGER/Line ZCTA bounding boxes + (build-time-only sf dependency in `data-raw/`). *(sized: 1 script, ~1 GB + download at build time; keep off hosted runners)* + +## 0.5.0 — data-enrichment minor release + +- **FIPS codes in `reverse_zipcode()` output** (#7): county FIPS is already + derivable from the pipeline's county-relationship stage; add as new + column(s) — additive, so backward compatible. *(sized: pipeline column + + docs + tests)* +- **ZCTA crosswalk vignette**: worked examples joining `zip_code_db` to ACS + tract data via `zcta_crosswalk`, with the ZIP≠ZCTA caveats front and + center. *(sized: 1 vignette)* +- **Comprehensive-DB-backed enrichment**: a small family of functions reading + the cached comprehensive database (e.g. `zip_demographics(zip, table)`), + gated on `download_comprehensive_data()` having been run; consider + rebuilding the comprehensive asset from current ACS via the pipeline + instead of redistributing the 2022 upstream snapshot. *(sized: medium; + design first)* +- **Timezone refresh**: replace carried-forward timezones with a build-time + point-in-polygon pass against timezone-boundary-builder data. *(sized: 1 + pipeline script)* + +## 0.6.0 — cleanup + +- Remove `download_zip_data()` (deprecated no-op since 0.4.0). +- sf-native optional outputs (e.g. `search_radius(..., as_sf = TRUE)`) with + sf in Suggests only — never in Imports. *(decide based on user demand; + default is not to do it)* +- Revisit the blob list-columns (`common_city_list`, `area_code_list`): + migrate to plain character JSON or list columns in a data-major release + with a compatibility note. + +## Standing policy + +- **Data refreshes**: quarterly via the `refresh-data` workflow; a human + always reviews the gate output and diff summary before merging. Data + releases are tagged `data-YYYY.MM`, independent of code versions; + `zip_data_version()` reports the loaded release. +- **Deprecation policy**: deprecate with a warning for at least one minor + release before removal; NEWS documents every step. +- **Dependency policy**: core lookups must work with Imports = dplyr, rlang, + stringr, utils. Anything heavier lives in Suggests behind + `rlang::check_installed()` or in `data-raw/` (build-time only). No + GDAL-linked or retired packages, ever. diff --git a/bench/search_radius_bench.R b/bench/search_radius_bench.R new file mode 100644 index 0000000..459dcd0 --- /dev/null +++ b/bench/search_radius_bench.R @@ -0,0 +1,52 @@ +# Benchmark for search_radius() (issue #33). +# Run from the package root: Rscript bench/search_radius_bench.R +# +# Compares three implementations against the bundled zip_code_db: +# loop - the pre-0.4.0 implementation (per-row distance calls) +# vectorized - one haversine pass over all ~34k coordinate rows +# boxed - 0.4.0+: bounding-box prefilter, then haversine on candidates + +suppressMessages(devtools::load_all(quiet = TRUE)) +hav <- zipcodeR:::haversine_distance + +zip_data_all <- zip_code_db[!is.na(zip_code_db$lat) & !is.na(zip_code_db$lng), ] + +loop_impl <- function(lat, lng, radius = 1) { + zip_data <- zip_data_all + zip_data$distance <- NA_real_ + for (i in seq_len(nrow(zip_data))) { + zip_data$distance[i] <- hav(zip_data$lat[i], zip_data$lng[i], lat, lng) * 0.000621371 + } + zip_data <- zip_data[zip_data$distance <= radius, c("zipcode", "distance")] + zip_data[order(zip_data$distance), ] +} + +vectorized_impl <- function(lat, lng, radius = 1) { + zip_data <- zip_data_all + zip_data$distance <- hav(zip_data$lat, zip_data$lng, lat, lng) * 0.000621371 + zip_data <- zip_data[zip_data$distance <= radius, c("zipcode", "distance")] + zip_data[order(zip_data$distance), ] +} + +cases <- list( + suburban_10mi = list(lat = 39.9, lng = -74.3, radius = 10), + urban_25mi = list(lat = 40.71, lng = -74.01, radius = 25), + rural_50mi = list(lat = 44.5, lng = -110.0, radius = 50) +) + +for (nm in names(cases)) { + cs <- cases[[nm]] + # correctness: boxed must return the same set as the full vectorized pass + full <- vectorized_impl(cs$lat, cs$lng, cs$radius) + boxed <- search_radius(cs$lat, cs$lng, cs$radius) + stopifnot(identical(sort(full$zipcode), sort(boxed$zipcode))) + + res <- bench::mark( + loop = loop_impl(cs$lat, cs$lng, cs$radius), + vectorized = vectorized_impl(cs$lat, cs$lng, cs$radius), + boxed = search_radius(cs$lat, cs$lng, cs$radius), + check = FALSE, min_iterations = 5 + ) + cat("\n==", nm, sprintf("(%d ZIPs in radius)\n", nrow(boxed))) + print(res[, c("expression", "median", "itr/sec", "mem_alloc")]) +} diff --git a/data-raw/.gitignore b/data-raw/.gitignore new file mode 100644 index 0000000..e934adf --- /dev/null +++ b/data-raw/.gitignore @@ -0,0 +1 @@ +cache/ diff --git a/data-raw/01_acquire.R b/data-raw/01_acquire.R new file mode 100644 index 0000000..ab85a64 --- /dev/null +++ b/data-raw/01_acquire.R @@ -0,0 +1,79 @@ +# Step 1: acquire all pipeline sources into data-raw/cache/, verifying +# checksums for every static file. Idempotent: verified files are not +# re-downloaded. + +source(file.path("data-raw", "sources.R")) + +cache_dir <- file.path("data-raw", "cache") +dir.create(cache_dir, showWarnings = FALSE, recursive = TRUE) + +sha256_file <- function(path) { + # tools::sha256sum requires R >= 4.5; use openssl-free fallback via system + out <- system2("shasum", c("-a", "256", shQuote(path)), stdout = TRUE) + strsplit(out, " ")[[1]][1] +} + +acquire <- function(src) { + dest <- file.path(cache_dir, basename(src$url)) + floating <- isTRUE(src$floating) + if (file.exists(dest) && (floating || identical(sha256_file(dest), src$sha256))) { + message("cached", if (!floating) " & verified", ": ", basename(dest)) + return(dest) + } + message("downloading: ", src$url) + utils::download.file(src$url, dest, mode = "wb", quiet = TRUE) + got <- sha256_file(dest) + if (floating) { + # Publisher regenerates this file in place; record the hash for + # provenance instead of enforcing it + message( + "floating source ", basename(dest), ": sha256 ", got, + if (!identical(got, src$sha256)) " (differs from the pinned build hash in sources.R)" + ) + } else if (!identical(got, src$sha256)) { + stop( + "Checksum mismatch for ", basename(dest), "\n expected: ", src$sha256, + "\n got: ", got, + "\nThe publisher may have updated the file in place. Inspect the new ", + "file, then update sources.R and the data release notes." + ) + } + dest +} + +paths <- lapply(PIPELINE_SOURCES, acquire) + +# Unpack archives +utils::unzip(paths$gazetteer_zcta, exdir = cache_dir, overwrite = TRUE) +utils::unzip(paths$geonames_us, exdir = cache_dir, overwrite = TRUE) + +# ACS pull (API source; requires CENSUS_API_KEY) +acs_cache <- file.path(cache_dir, sprintf("acs5_%d_zcta.csv", ACS_VINTAGE)) +if (!file.exists(acs_cache)) { + key <- Sys.getenv("CENSUS_API_KEY") + if (!nzchar(key)) { + stop( + "CENSUS_API_KEY is not set. Register a free key at ", + "https://api.census.gov/data/key_signup.html and set it in ~/.Renviron ", + "or as a repository secret for the refresh workflow." + ) + } + message("downloading: ACS ", ACS_VINTAGE, " 5-year estimates for all ZCTAs") + url <- paste0( + ACS_ENDPOINT, "?get=", paste(ACS_VARIABLES, collapse = ","), + "&for=zip%20code%20tabulation%20area:*&key=", key + ) + tf <- tempfile(fileext = ".json") + utils::download.file(url, tf, quiet = TRUE) + j <- jsonlite::fromJSON(tf) + acs <- as.data.frame(j[-1, , drop = FALSE], stringsAsFactors = FALSE) + names(acs) <- c(names(ACS_VARIABLES), "zcta") + for (v in names(ACS_VARIABLES)) { + x <- suppressWarnings(as.numeric(acs[[v]])) + x[!is.na(x) & x < 0] <- NA # ACS sentinel values (-666666666 etc.) + acs[[v]] <- x + } + utils::write.csv(acs, acs_cache, row.names = FALSE) +} + +message("acquire: done (", length(paths), " static sources + ACS)") diff --git a/data-raw/02_build_zip_code_db.R b/data-raw/02_build_zip_code_db.R new file mode 100644 index 0000000..ab6b15e --- /dev/null +++ b/data-raw/02_build_zip_code_db.R @@ -0,0 +1,236 @@ +# Step 2: build the refreshed zip_code_db. +# +# Strategy: "carry-forward + refresh". The shipped database (proven in +# AUDIT.md to be byte-identical to the uszipcode 0.2.6 snapshot) is the base, +# guaranteeing no ZIP is ever silently dropped. On top of it: +# 1. Rows new in upstream 1.0.1 are appended (zipcode_type normalized to the +# shipped titleized values; the 1.0.1 MILITARY type appears as "Military"). +# 2. 2020 Census ZCTAs absent from both snapshots are appended, built from +# the Gazetteer + county relationship file + GeoNames place names. +# 3. Curated supplemental USPS-only ZIPs (data-raw/supplemental_zips.csv). +# 4. For every row whose ZIP is a 2020 ZCTA: coordinates and land/water area +# are refreshed from the Census Gazetteer, and the five ACS attributes +# are refreshed from the pinned ACS 5-year vintage. +# Columns with no current public source (bounds, radius, area codes, city +# alias lists) carry forward unchanged and are NA for new rows. + +suppressMessages({ + library(dplyr) +}) +source(file.path("data-raw", "sources.R")) +cache_dir <- file.path("data-raw", "cache") + +# --- load inputs ----------------------------------------------------------- +base_env <- new.env() +load(file.path("data", "zip_code_db.rda"), envir = base_env) +base <- base_env$zip_code_db +stopifnot(ncol(base) == 24) + +conn <- DBI::dbConnect(RSQLite::SQLite(), file.path(cache_dir, "simple_db.sqlite")) +upstream101 <- DBI::dbGetQuery(conn, "SELECT * FROM simple_zipcode") +DBI::dbDisconnect(conn) + +gaz <- utils::read.delim( + file.path(cache_dir, "2024_Gaz_zcta_national.txt"), + colClasses = c(GEOID = "character"), strip.white = TRUE +) +names(gaz) <- trimws(names(gaz)) + +county_rel <- utils::read.delim( + file.path(cache_dir, "tab20_zcta520_county20_natl.txt"), + sep = "|", fileEncoding = "UTF-8-BOM", + colClasses = c(GEOID_ZCTA5_20 = "character", GEOID_COUNTY_20 = "character") +) + +geonames <- utils::read.delim( + file.path(cache_dir, "US.txt"), header = FALSE, quote = "", + colClasses = "character" +) +names(geonames) <- c( + "country", "zipcode", "place", "state_name", "state", "county_name", + "county_code", "admin3", "admin3_code", "lat", "lng", "accuracy" +) + +acs <- utils::read.csv( + file.path(cache_dir, sprintf("acs5_%d_zcta.csv", ACS_VINTAGE)), + colClasses = c(zcta = "character") +) + +supplement <- utils::read.csv( + file.path("data-raw", "supplemental_zips.csv"), + colClasses = "character" +) + +fips_env <- new.env() +load(file.path("R", "sysdata.rda"), envir = fips_env) +fips_codes <- fips_env$fips_codes + +# --- helper: an empty row in the exact schema of `base` -------------------- +empty_rows <- function(n) { + out <- base[rep(NA_integer_, n), , drop = FALSE] + rownames(out) <- NULL + out +} + +titleize_type <- c( + "STANDARD" = "Standard", "PO BOX" = "PO Box", + "UNIQUE" = "Unique", "MILITARY" = "Military" +) + +# --- 1. rows new in upstream 1.0.1 ---------------------------------------- +new101 <- upstream101[!upstream101$zipcode %in% base$zipcode, , drop = FALSE] +new101$zipcode_type <- unname(titleize_type[new101$zipcode_type]) +# 1.0.1 uses 0.0 for unknown coordinates; normalize to NA +new101$lat[new101$lat == 0 & new101$lng == 0] <- NA_real_ +new101$lng[is.na(new101$lat)] <- NA_real_ +new101 <- new101[, names(base)] +message("rows new in upstream 1.0.1: ", nrow(new101)) + +# --- 2. 2020 ZCTAs missing from both snapshots ---------------------------- +known <- c(base$zipcode, new101$zipcode) +missing_zcta <- setdiff(gaz$GEOID, known) +message("2020 ZCTAs absent from both snapshots: ", length(missing_zcta)) + +# predominant county per ZCTA by land-area overlap +county_rel <- county_rel[county_rel$GEOID_ZCTA5_20 != "", ] +predominant <- county_rel %>% + group_by(zcta = GEOID_ZCTA5_20) %>% + slice_max(AREALAND_PART, n = 1, with_ties = FALSE) %>% + ungroup() %>% + select(zcta, county_geoid = GEOID_COUNTY_20, county_name = NAMELSAD_COUNTY_20) + +state_by_fips <- fips_codes %>% distinct(state_code, state) +tz_by_state <- base %>% + filter(!is.na(timezone)) %>% + count(state, timezone) %>% + group_by(state) %>% + slice_max(n, n = 1, with_ties = FALSE) %>% + ungroup() %>% + select(state, timezone) + +nz <- empty_rows(length(missing_zcta)) +if (length(missing_zcta) > 0) { + nz$zipcode <- missing_zcta + nz$zipcode_type <- "Standard" # by construction these are 2020 ZCTAs + gi <- match(missing_zcta, gaz$GEOID) + nz$lat <- gaz$INTPTLAT[gi] + nz$lng <- gaz$INTPTLONG[gi] + nz$land_area_in_sqmi <- gaz$ALAND_SQMI[gi] + nz$water_area_in_sqmi <- gaz$AWATER_SQMI[gi] + pi_ <- match(missing_zcta, predominant$zcta) + nz$county <- predominant$county_name[pi_] + nz$state <- state_by_fips$state[ + match(substr(predominant$county_geoid[pi_], 1, 2), state_by_fips$state_code) + ] + gn <- geonames[!duplicated(geonames$zipcode), ] + gni <- match(missing_zcta, gn$zipcode) + nz$major_city <- gn$place[gni] + nz$post_office_city <- ifelse( + is.na(nz$major_city) | is.na(nz$state), NA_character_, + paste0(nz$major_city, ", ", nz$state) + ) + nz$timezone <- tz_by_state$timezone[match(nz$state, tz_by_state$state)] +} + +# --- 3. curated supplemental USPS-only ZIPs ------------------------------- +supplement <- supplement[!supplement$zipcode %in% c(known, nz$zipcode), , drop = FALSE] +sup <- empty_rows(nrow(supplement)) +if (nrow(supplement) > 0) { + sup$zipcode <- supplement$zipcode + sup$zipcode_type <- supplement$zipcode_type + sup$major_city <- supplement$major_city + sup$state <- supplement$state + sup$county <- supplement$county + sup$lat <- as.numeric(supplement$lat) + sup$lng <- as.numeric(supplement$lng) + sup$post_office_city <- paste0(supplement$major_city, ", ", supplement$state) + sup$timezone <- tz_by_state$timezone[match(sup$state, tz_by_state$state)] +} +message("supplemental USPS-only ZIPs: ", nrow(sup)) + +# Timezones for brand-new rows are IMPUTED as the modal timezone of the +# state (no free authoritative per-ZIP source yet; the ROADMAP's +# point-in-polygon stage replaces this). Wrong for new ZIPs in the minority +# zone of split-timezone states - the count is tracked in the refresh summary +# so reviewers can judge the exposure on every refresh. +imputed_tz <- c( + nz$zipcode[!is.na(nz$timezone)], + sup$zipcode[!is.na(sup$timezone)] +) + +# --- combine --------------------------------------------------------------- +additions <- bind_rows(new101, nz, sup) +additions <- additions[order(additions$zipcode), ] +zip_code_db_new <- bind_rows(base, additions) + +# --- 4. refresh ZCTA-backed attributes ------------------------------------ +zi <- match(zip_code_db_new$zipcode, gaz$GEOID) +is_zcta_row <- !is.na(zi) +zip_code_db_new$lat[is_zcta_row] <- gaz$INTPTLAT[zi[is_zcta_row]] +zip_code_db_new$lng[is_zcta_row] <- gaz$INTPTLONG[zi[is_zcta_row]] +zip_code_db_new$land_area_in_sqmi[is_zcta_row] <- gaz$ALAND_SQMI[zi[is_zcta_row]] +zip_code_db_new$water_area_in_sqmi[is_zcta_row] <- gaz$AWATER_SQMI[zi[is_zcta_row]] + +ai <- match(zip_code_db_new$zipcode, acs$zcta) +ar <- !is.na(ai) +as_int <- function(x) as.integer(round(x)) +zip_code_db_new$population[ar] <- as_int(acs$population[ai[ar]]) +zip_code_db_new$housing_units[ar] <- as_int(acs$housing_units[ai[ar]]) +zip_code_db_new$occupied_housing_units[ar] <- as_int(acs$occupied_housing_units[ai[ar]]) +zip_code_db_new$median_home_value[ar] <- as_int(acs$median_home_value[ai[ar]]) +zip_code_db_new$median_household_income[ar] <- as_int(acs$median_household_income[ai[ar]]) +zip_code_db_new$population_density <- ifelse( + !is.na(zip_code_db_new$population) & + !is.na(zip_code_db_new$land_area_in_sqmi) & + zip_code_db_new$land_area_in_sqmi > 0, + round(zip_code_db_new$population / zip_code_db_new$land_area_in_sqmi, 2), + zip_code_db_new$population_density +) + +# --- clean upstream garbage coordinates ----------------------------------- +# Coordinates must come in pairs: a row with only one of lat/lng is not +# usable, so NA both rather than shipping a half-coordinate. +half_coord <- xor(is.na(zip_code_db_new$lat), is.na(zip_code_db_new$lng)) +if (any(half_coord)) { + message( + "clearing half-specified coordinates for ", sum(half_coord), " row(s): ", + paste(zip_code_db_new$zipcode[half_coord], collapse = ", ") + ) + zip_code_db_new$lat[half_coord] <- NA_real_ + zip_code_db_new$lng[half_coord] <- NA_real_ +} + +# A handful of upstream military rows carry junk coordinates (e.g. 09323 at +# lat -44). NA them out rather than shipping impossible positions. +bad_coord <- !is.na(zip_code_db_new$lat) & !is.na(zip_code_db_new$lng) & + (zip_code_db_new$lat < -15 | zip_code_db_new$lat > 72 | + zip_code_db_new$lng < -180 | zip_code_db_new$lng > 180) +if (any(bad_coord)) { + message( + "clearing implausible coordinates for ", + sum(bad_coord), " row(s): ", + paste(zip_code_db_new$zipcode[bad_coord], collapse = ", ") + ) + zip_code_db_new$lat[bad_coord] <- NA_real_ + zip_code_db_new$lng[bad_coord] <- NA_real_ +} + +# --- enforce the schema contract ------------------------------------------ +zip_code_db_new <- zip_code_db_new[, names(base)] +for (col in names(base)) { + if (is.integer(base[[col]])) zip_code_db_new[[col]] <- as.integer(zip_code_db_new[[col]]) + if (is.character(base[[col]])) zip_code_db_new[[col]] <- as.character(zip_code_db_new[[col]]) +} +zip_code_db_new <- as.data.frame(zip_code_db_new) +rownames(zip_code_db_new) <- NULL + +saveRDS(zip_code_db_new, file.path(cache_dir, "zip_code_db_candidate.rds")) +saveRDS( + list(imputed_timezone_zips = imputed_tz), + file.path(cache_dir, "zip_code_db_stats.rds") +) +message( + "zip_code_db candidate: ", nrow(zip_code_db_new), " rows (was ", + nrow(base), "; +", nrow(additions), "); state-modal timezone imputed for ", + length(imputed_tz), " new ZIP(s)" +) diff --git a/data-raw/03_build_zcta_crosswalk.R b/data-raw/03_build_zcta_crosswalk.R new file mode 100644 index 0000000..ef7786d --- /dev/null +++ b/data-raw/03_build_zcta_crosswalk.R @@ -0,0 +1,26 @@ +# Step 3: build the refreshed zcta_crosswalk (2020 ZCTA <-> 2020 tract) in +# the exact schema of the shipped dataset: tibble(ZCTA5 chr, TRACT chr(6), +# GEOID numeric(11-digit state+county+tract)). + +suppressMessages(library(dplyr)) +cache_dir <- file.path("data-raw", "cache") + +rel <- utils::read.delim( + file.path(cache_dir, "tab20_zcta520_tract20_natl.txt"), + sep = "|", fileEncoding = "UTF-8-BOM", + colClasses = c(GEOID_ZCTA5_20 = "character", GEOID_TRACT_20 = "character") +) + +zcta_crosswalk_new <- rel %>% + filter(.data$GEOID_ZCTA5_20 != "") %>% + transmute( + ZCTA5 = .data$GEOID_ZCTA5_20, + TRACT = substr(.data$GEOID_TRACT_20, 6, 11), + GEOID = as.numeric(.data$GEOID_TRACT_20) + ) %>% + distinct() %>% + arrange(.data$ZCTA5, .data$GEOID) %>% + as_tibble() + +saveRDS(zcta_crosswalk_new, file.path(cache_dir, "zcta_crosswalk_candidate.rds")) +message("zcta_crosswalk candidate: ", nrow(zcta_crosswalk_new), " rows (2020 vintage)") diff --git a/data-raw/04_build_zip_to_cd.R b/data-raw/04_build_zip_to_cd.R new file mode 100644 index 0000000..3c6ac60 --- /dev/null +++ b/data-raw/04_build_zip_to_cd.R @@ -0,0 +1,108 @@ +# Step 4: build the refreshed zip_to_cd (ZIP <-> 119th-Congress district) in +# the exact schema of the shipped dataset: data.frame(ZIP chr, CD chr(4) = +# state FIPS + 2-digit district), multiple rows for multi-district ZIPs. +# +# Method follows PR #30 by @awallender (which used the CD118 vintage), +# updated to the 119th-Congress relationship file, with two additions: +# +# - Census "ZZ" pseudo-district rows (the not-in-any-district offshore/water +# remainder) are excluded - they are not congressional districts. +# - The relationship file covers only 2020 ZCTAs, but zip_code_db also holds +# USPS-only ZIP codes (P.O. Box / unique codes) that the previous +# HUD-crosswalk-era dataset mapped. For those, districts are derived from +# the districts of the same USPS city (major_city + state) among +# ZCTA-covered ZIPs - a ZIP code's post office lies in its city, so the +# city's district set bounds its possible districts (single-district cities, +# the common case, give an exact assignment). Military ZIPs are left +# unmapped: overseas APO/FPO codes have no geographic district. + +suppressMessages(library(dplyr)) +cache_dir <- file.path("data-raw", "cache") + +rel <- utils::read.delim( + file.path(cache_dir, "tab20_cd11920_zcta520_natl.txt"), + sep = "|", fileEncoding = "UTF-8-BOM", + colClasses = c(GEOID_CD119_20 = "character", GEOID_ZCTA5_20 = "character") +) + +zcta_cd <- rel %>% + filter( + .data$GEOID_ZCTA5_20 != "", + grepl("^[0-9]{4}$", .data$GEOID_CD119_20) # drops "" and ZZ pseudo-districts + ) %>% + transmute(ZIP = .data$GEOID_ZCTA5_20, CD = .data$GEOID_CD119_20) %>% + distinct() + +# Derive districts for USPS-only (non-ZCTA) ZIPs from their city's districts +zipdb <- readRDS(file.path(cache_dir, "zip_code_db_candidate.rds")) + +# One shared eligibility predicate: military ZIPs (overseas APO/FPO) have no +# geographic district; NA-typed ZIPs remain eligible for derivation +eligible <- zipdb %>% filter(!.data$zipcode_type %in% "Military") + +# One covered-ZIP intermediate feeds both derivation lookups +covered <- zipdb %>% + select(zipcode, major_city, state) %>% + inner_join(zcta_cd, by = c("zipcode" = "ZIP")) + +city_cd <- covered %>% distinct(.data$major_city, .data$state, .data$CD) + +# Second fallback: in states/territories with exactly one district among +# their covered ZIPs (at-large states, Puerto Rico, DC), every ZIP in the +# state is in that district by construction +state_cd <- covered %>% + distinct(.data$state, .data$CD) %>% + group_by(.data$state) %>% + filter(n() == 1) %>% + ungroup() + +derived_cd <- eligible %>% + filter( + !.data$zipcode %in% zcta_cd$ZIP, + !is.na(.data$major_city), !is.na(.data$state) + ) %>% + select(ZIP = zipcode, major_city, state) %>% + inner_join(city_cd, by = c("major_city", "state"), + relationship = "many-to-many") %>% + distinct(ZIP, CD) + +state_derived_cd <- eligible %>% + filter( + !.data$zipcode %in% c(zcta_cd$ZIP, derived_cd$ZIP), + !is.na(.data$state) + ) %>% + select(ZIP = zipcode, state) %>% + inner_join(state_cd, by = "state") %>% + distinct(ZIP, CD) + +# Named counts, computed once and used for the message, the stats file, and +# the refresh summary +counts <- list( + zcta_mapped = length(unique(zcta_cd$ZIP)), + city_derived = length(unique(derived_cd$ZIP)), + state_derived = length(unique(state_derived_cd$ZIP)), + unmapped_nonmilitary = length(setdiff( + eligible$zipcode, c(zcta_cd$ZIP, derived_cd$ZIP, state_derived_cd$ZIP) + )) +) +message( + "zip_to_cd: ", counts$zcta_mapped, " ZCTA-mapped ZIPs, ", + counts$city_derived, " city-derived + ", + counts$state_derived, " single-district-state USPS-only ZIPs, ", + counts$unmapped_nonmilitary, + " non-military ZIPs unmapped (no ZCTA, no covered city peer)" +) + +# The three tiers are pairwise disjoint by construction; assert rather than +# papering over an overlap with distinct() +stopifnot( + !any(derived_cd$ZIP %in% zcta_cd$ZIP), + !any(state_derived_cd$ZIP %in% c(zcta_cd$ZIP, derived_cd$ZIP)) +) +zip_to_cd_new <- bind_rows(zcta_cd, derived_cd, state_derived_cd) %>% + arrange(.data$ZIP, .data$CD) %>% + as.data.frame() + +saveRDS(zip_to_cd_new, file.path(cache_dir, "zip_to_cd_candidate.rds")) +saveRDS(counts, file.path(cache_dir, "zip_to_cd_stats.rds")) +message("zip_to_cd candidate: ", nrow(zip_to_cd_new), " rows (CD119 vintage)") diff --git a/data-raw/05_validate.R b/data-raw/05_validate.R new file mode 100644 index 0000000..2ca30b4 --- /dev/null +++ b/data-raw/05_validate.R @@ -0,0 +1,241 @@ +# Step 5: validation gate. Every check here must pass before refreshed data +# ships. Aborts with a full failure list otherwise, and writes +# data-raw/refresh_summary.md describing the diff for the release PR. + +suppressMessages(library(dplyr)) +source(file.path("data-raw", "sources.R")) +cache_dir <- file.path("data-raw", "cache") + +candidate <- readRDS(file.path(cache_dir, "zip_code_db_candidate.rds")) +zcta_candidate <- readRDS(file.path(cache_dir, "zcta_crosswalk_candidate.rds")) +cd_candidate <- readRDS(file.path(cache_dir, "zip_to_cd_candidate.rds")) + +# Baseline = the LAST COMMITTED data files, taken from git - never the +# working tree, which 06_finalize.R overwrites (a working-tree baseline +# would compare a rerun candidate against itself and pass every regression +# check trivially). Override the ref with PIPELINE_BASELINE_REF to compare +# against another release (e.g. master). +baseline_ref <- Sys.getenv("PIPELINE_BASELINE_REF", "HEAD") +load_baseline <- function(name) { + tf <- tempfile(fileext = ".rda") + status <- suppressWarnings(system2( + "git", c("show", shQuote(paste0(baseline_ref, ":data/", name))), + stdout = tf, stderr = FALSE + )) + if (!identical(status, 0L)) { + stop( + "Cannot read baseline data/", name, " from git ref '", baseline_ref, + "'. The validation gate needs the last committed data as its baseline." + ) + } + e <- new.env() + load(tf, envir = e) + unlink(tf) + e[[ls(e)[1]]] +} +message("validation baseline: git ref '", baseline_ref, "'") +shipped_env <- new.env() +shipped_env$zip_code_db <- load_baseline("zip_code_db.rda") +shipped_env$zcta_crosswalk <- load_baseline("zcta_crosswalk.rda") +shipped_env$zip_to_cd <- load_baseline("zip_to_cd.rda") +shipped <- shipped_env$zip_code_db + +failures <- character() +check <- function(ok, label) { + status <- if (isTRUE(ok)) "PASS" else "FAIL" + message(sprintf("[%s] %s", status, label)) + if (!isTRUE(ok)) failures <<- c(failures, label) + invisible(ok) +} + +# --- zip_code_db ----------------------------------------------------------- +check( + nrow(candidate) >= nrow(shipped) && nrow(candidate) <= 46000, + sprintf("row count in sane bounds (%d, was %d)", nrow(candidate), nrow(shipped)) +) +dropped <- setdiff(shipped$zipcode, candidate$zipcode) +check(length(dropped) == 0, sprintf("no silent drops (%d dropped)", length(dropped))) +check(!anyDuplicated(candidate$zipcode), "zipcode is unique") +check(identical(names(candidate), names(shipped)), "column names identical") +check( + identical(unname(sapply(candidate, function(x) class(x)[1])), + unname(sapply(shipped, function(x) class(x)[1]))), + "column classes identical" +) +check(identical(class(candidate), class(shipped)), "object class identical (data.frame)") + +allowed_types <- c("Standard", "PO Box", "Unique", "Military", NA) +check( + all(candidate$zipcode_type %in% allowed_types), + "zipcode_type values within the documented set" +) + +# regression ZIPs from #25 / #26 / #19 must be present WITH coordinates +regression_zips <- c( + "97003", "91230", + "00802", "00820", "00830", "00840", "00850", "00851", # USVI + "96799", # American Samoa + "96910", "96913", "96915", "96916", "96917", "96928", "96929", # Guam + "96950", "96951", "96952", # N. Mariana + "72405", "72713", "75036", "75072", "89437" # new mainland ZCTAs +) +ri <- match(regression_zips, candidate$zipcode) +check(!anyNA(ri), "all regression ZIPs (#19/#25/#26) present") +check( + !anyNA(candidate$lat[ri]) && !anyNA(candidate$lng[ri]), + "all regression ZIPs have coordinates" +) + +# known-good distance spot checks (the #20 examples), via the package's own +# haversine; tolerance covers coordinate-precision refreshes +hav <- function(lat1, lng1, lat2, lng2) { + tr <- pi / 180 + h <- sin((lat2 - lat1) * tr / 2)^2 + + cos(lat1 * tr) * cos(lat2 * tr) * sin((lng2 - lng1) * tr / 2)^2 + 2 * 6371008.8 * asin(pmin(1, sqrt(h))) * 0.000621371 +} +dist_zip <- function(a, b) { + ia <- match(a, candidate$zipcode); ib <- match(b, candidate$zipcode) + hav(candidate$lat[ia], candidate$lng[ia], candidate$lat[ib], candidate$lng[ib]) +} +# Ranges catch pair swaps and coordinate corruption while tolerating +# legitimate centroid-precision changes between data vintages. The #20 +# reprex distinguishes a ~44mi leg from a ~10mi leg. +d1 <- dist_zip("08731", "08901") +d2 <- dist_zip("08734", "08005") +check(d1 > 35 && d1 < 50, sprintf("distance spot check 08731->08901 in [35,50] (%.1f)", d1)) +check(d2 > 5 && d2 < 15, sprintf("distance spot check 08734->08005 in [5,15] (%.1f)", d2)) +check(d1 > 2 * d2, "distance spot check: long leg dominates short leg (no pair swap)") +check(dist_zip("08731", "08731") == 0, "distance spot check identity = 0") + +# coordinate sanity: US bounding envelope (incl. territories/military NA-safe) +with_coords <- !is.na(candidate$lat) +check( + all(candidate$lat[with_coords] >= -15 & candidate$lat[with_coords] <= 72) && + all(candidate$lng[with_coords] >= -180 & candidate$lng[with_coords] <= 180), + "coordinates within plausible envelope" +) +check( + sum(!with_coords) <= sum(is.na(shipped$lat)) + 1000, + "share of coordinate-less ZIPs did not grow materially" +) + +# --- zcta_crosswalk -------------------------------------------------------- +check( + nrow(zcta_candidate) > 100000, + sprintf("zcta_crosswalk row count sane (%d)", nrow(zcta_candidate)) +) +check( + identical(names(zcta_candidate), names(shipped_env$zcta_crosswalk)), + "zcta_crosswalk schema identical" +) +check( + all(nchar(zcta_candidate$TRACT) == 6), + "zcta_crosswalk TRACT codes are 6 characters" +) + +# --- zip_to_cd ------------------------------------------------------------- +check( + identical(names(cd_candidate), names(shipped_env$zip_to_cd)), + "zip_to_cd schema identical" +) +check( + all(grepl("^[0-9]{4}$", cd_candidate$CD)), + "zip_to_cd CD codes are 4 digits (no ZZ pseudo-districts)" +) +check( + length(setdiff(zcta_candidate$ZCTA5, cd_candidate$ZIP)) < 500, + "zip_to_cd covers (nearly) all 2020 ZCTAs" +) +# no silent shrinkage of CD coverage: every non-military ZIP that had a +# district mapping in the shipped release must still have one +prev_covered <- intersect(shipped_env$zip_to_cd$ZIP, candidate$zipcode) +prev_covered <- setdiff( + prev_covered, + candidate$zipcode[candidate$zipcode_type %in% "Military"] +) +lost_cd <- setdiff(prev_covered, cd_candidate$ZIP) +accepted_loss <- if (file.exists(ACCEPTED_CD_COVERAGE_LOSS_FILE)) { + readLines(ACCEPTED_CD_COVERAGE_LOSS_FILE) +} else { + character(0) +} +unaccepted_lost <- setdiff(lost_cd, accepted_loss) +check( + length(unaccepted_lost) == 0, + sprintf( + "no ZIPs lost congressional-district coverage outside the documented acceptance list (%d lost, of which %d unaccepted)", + length(lost_cd), length(unaccepted_lost) + ) +) + +# --- release metadata ------------------------------------------------------ +check( + is.list(COMPREHENSIVE_RELEASE) && + grepl("^data-", COMPREHENSIVE_RELEASE$release_tag) && + grepl("^[0-9a-f]{64}$", COMPREHENSIVE_RELEASE$sha256), + "COMPREHENSIVE_RELEASE registry is well-formed" +) +if (!identical(COMPREHENSIVE_RELEASE$release_tag, paste0("data-", DATA_VERSION))) { + message( + "[NOTE] comprehensive asset pinned to ", COMPREHENSIVE_RELEASE$release_tag, + " (data release is data-", DATA_VERSION, + ") - expected unless a new comprehensive asset was published" + ) +} + +# --- summary for the release PR ------------------------------------------- +added <- setdiff(candidate$zipcode, shipped$zipcode) +common <- intersect(candidate$zipcode, shipped$zipcode) +ci <- match(common, candidate$zipcode); si <- match(common, shipped$zipcode) +coord_changed <- sum( + !is.na(candidate$lat[ci]) & !is.na(shipped$lat[si]) & + (abs(candidate$lat[ci] - shipped$lat[si]) > 1e-6 | + abs(candidate$lng[ci] - shipped$lng[si]) > 1e-6) +) +pop_changed <- sum( + !identical(candidate$population[ci], shipped$population[si]) & + (is.na(candidate$population[ci]) != is.na(shipped$population[si]) | + coalesce(candidate$population[ci] != shipped$population[si], FALSE)) +) + +summary_md <- c( + "## Data refresh summary", + "", + sprintf("- `zip_code_db`: %d rows (was %d): **%d added, 0 removed**", + nrow(candidate), nrow(shipped), length(added)), + sprintf(" - added by type: %s", + paste(sprintf("%s (%d)", names(table(candidate$zipcode_type[match(added, candidate$zipcode)], useNA = "ifany")), + table(candidate$zipcode_type[match(added, candidate$zipcode)], useNA = "ifany")), collapse = ", ")), + sprintf(" - coordinates refreshed for %d existing ZIPs; ACS attributes refreshed for %d ZIPs", + coord_changed, pop_changed), + sprintf("- `zcta_crosswalk`: %d rows, 2020 ZCTA/tract vintage (previously %d rows)", + nrow(zcta_candidate), nrow(shipped_env$zcta_crosswalk)), + sprintf("- `zip_to_cd`: %d rows, 119th-Congress vintage (previously %d rows)", + nrow(cd_candidate), nrow(shipped_env$zip_to_cd)), + local({ + s <- readRDS(file.path(cache_dir, "zip_to_cd_stats.rds")) + sprintf( + " - %d ZCTA-mapped ZIPs + %d city-derived and %d single-district-state USPS-only ZIPs; %d non-military ZIPs unmapped", + s$zcta_mapped, s$city_derived, s$state_derived, s$unmapped_nonmilitary + ) + }), + local({ + s <- readRDS(file.path(cache_dir, "zip_code_db_stats.rds")) + sprintf( + "- state-modal timezone imputed for %d new ZIP(s)%s", + length(s$imputed_timezone_zips), + if (length(s$imputed_timezone_zips) > 0 && length(s$imputed_timezone_zips) <= 20) { + paste0(": ", paste(s$imputed_timezone_zips, collapse = ", ")) + } else "" + ) + }), + "", + sprintf("Validation gate: %s", if (length(failures) == 0) "**all checks passed**" else "**FAILED**") +) +writeLines(summary_md, file.path("data-raw", "refresh_summary.md")) + +if (length(failures) > 0) { + stop("Validation gate FAILED:\n - ", paste(failures, collapse = "\n - ")) +} +message("validation gate: all checks passed") diff --git a/data-raw/06_finalize.R b/data-raw/06_finalize.R new file mode 100644 index 0000000..7e6ad3d --- /dev/null +++ b/data-raw/06_finalize.R @@ -0,0 +1,50 @@ +# Step 6: write the validated candidates into data/ and R/sysdata.rda, +# stamping data-version metadata, then recompress to CRAN-preferred formats. +# Run ONLY after 05_validate.R passes. + +source(file.path("data-raw", "sources.R")) +cache_dir <- file.path("data-raw", "cache") + +zip_code_db <- readRDS(file.path(cache_dir, "zip_code_db_candidate.rds")) +zcta_crosswalk <- readRDS(file.path(cache_dir, "zcta_crosswalk_candidate.rds")) +zip_to_cd <- readRDS(file.path(cache_dir, "zip_to_cd_candidate.rds")) + +save(zip_code_db, file = file.path("data", "zip_code_db.rda")) +save(zcta_crosswalk, file = file.path("data", "zcta_crosswalk.rda")) +save(zip_to_cd, file = file.path("data", "zip_to_cd.rda")) + +# Internal metadata: keep the legacy zip_code_db_version object (a Date) for +# backward compatibility and add the structured zip_data_meta consumed by +# zip_data_version(). Load-and-re-save EVERY existing sysdata object so +# nothing is silently dropped (same pattern as data-raw/fips_codes.R). +sysdata_env <- new.env() +load(file.path("R", "sysdata.rda"), envir = sysdata_env) + +zip_code_db_version <- Sys.Date() +zip_data_meta <- list( + data_version = DATA_VERSION, + build_date = format(Sys.Date()), + zip_code_db_rows = nrow(zip_code_db), + sources = list( + base = "uszipcode-project 0.2.6-db-file (2021-06-08) + 1.0.1.db (2022-01-05), MIT", + coordinates_area = sprintf("U.S. Census Bureau 2024 Gazetteer (2020 ZCTAs)"), + acs = sprintf("U.S. Census Bureau ACS 5-year estimates, vintage %d", ACS_VINTAGE), + place_names = "GeoNames (CC BY 4.0) for post-2021 additions", + zcta_crosswalk = "Census 2020 ZCTA-to-tract relationship file", + zip_to_cd = "Census 119th Congressional District-to-ZCTA relationship file" + ), + comprehensive = COMPREHENSIVE_RELEASE +) +assign("zip_code_db_version", zip_code_db_version, envir = sysdata_env) +assign("zip_data_meta", zip_data_meta, envir = sysdata_env) +save( + list = ls(sysdata_env, all.names = TRUE), + envir = sysdata_env, + file = file.path("R", "sysdata.rda"), compress = "bzip2" +) + +tools::resaveRdaFiles("data", compress = "auto") +message("finalize: data/ written. Sizes:") +for (f in list.files("data", full.names = TRUE)) { + message(sprintf(" %s: %.2f MB", basename(f), file.size(f) / 1e6)) +} diff --git a/data-raw/README.md b/data-raw/README.md new file mode 100644 index 0000000..2f27751 --- /dev/null +++ b/data-raw/README.md @@ -0,0 +1,100 @@ +# zipcodeR data pipeline + +This directory makes zipcodeR's datasets fully reproducible. The package no +longer depends on a frozen snapshot: every dataset in `data/` can be rebuilt +from primary sources with + +```sh +Rscript data-raw/run_pipeline.R +``` + +run from the package root. Build-time-only requirements (never runtime +dependencies): `dplyr`, `DBI`, `RSQLite`, `jsonlite`, and a `CENSUS_API_KEY` +in the environment ([free registration](https://api.census.gov/data/key_signup.html)). + +## Pipeline stages + +| Script | Purpose | +|---|---| +| `sources.R` | Registry of every source: pinned URL, SHA256, license. Also pins the ACS vintage and the data release version. | +| `01_acquire.R` | Download all sources into `cache/` (gitignored), verify checksums, pull ACS estimates. Idempotent. | +| `02_build_zip_code_db.R` | Build `zip_code_db` (see strategy below). | +| `03_build_zcta_crosswalk.R` | Build `zcta_crosswalk` from the Census 2020 ZCTA↔tract relationship file. | +| `04_build_zip_to_cd.R` | Build `zip_to_cd` from the Census 119th-Congress↔ZCTA relationship file (method from PR #30 by @awallender, updated vintage). | +| `05_validate.R` | **Validation gate** — hard-fails the pipeline unless every check passes; writes `refresh_summary.md` for the release PR. | +| `06_finalize.R` | Write `data/*.rda` + version metadata into `R/sysdata.rda`, recompress. | +| `fips_codes.R` | Regenerate the internal Census FIPS table (independent of the main pipeline). | + +## zip_code_db build strategy: carry-forward + refresh + +The shipped database (proven in `AUDIT.md` to be byte-identical to the +uszipcode 0.2.6 snapshot) is the base, which guarantees **no ZIP code is ever +silently dropped**. On top of it, each run: + +1. appends rows new in upstream uszipcode 1.0.1 (`zipcode_type` normalized to + the shipped titleized values; adds the `Military` type), +2. appends any 2020 Census ZCTA still missing (Gazetteer + county + relationship + GeoNames place names), +3. appends curated USPS-only ZIPs from `supplemental_zips.csv` (each row + carries its own source note), +4. refreshes, for every ZCTA-backed row: coordinates + land/water area from + the Census Gazetteer and five demographic attributes from the pinned ACS + 5-year vintage; recomputes population density, +5. clears provably-impossible coordinates inherited from upstream. + +Columns with no current public source (`bounds_*`, `radius_in_miles`, +`area_code_list`, `common_city_list`) carry forward unchanged and are `NA` +for new rows. + +## The validation gate + +`05_validate.R` enforces, among ~20 checks: row count within sane bounds and +never below the previous release; zero dropped ZIP codes; unique zipcodes; +column names/classes identical to the compatibility contract; the regression +ZIPs from issues #19/#25/#26 present **with coordinates**; distance +spot-checks on the issue #20 pairs (range-based, robust to vintage precision +changes, and asserting no pair swap); coordinate envelope sanity; schema and +format checks on both crosswalks. A refresh that fails any check does not +ship. + +## Licensing and provenance decisions + +- **U.S. Census Bureau** files and API (relationship files, Gazetteer, ACS): + U.S. public domain. +- **uszipcode-project** snapshots (base rows + validation reference): MIT, + attribution retained in the dataset docs. +- **GeoNames** (place names for post-2021 additions): CC BY 4.0, attribution + retained in the dataset docs. +- **USPS-derived naming columns** (`major_city`, `post_office_city`, + `common_city_list`, `zipcode_type`): carried forward from the MIT-licensed + upstream snapshots; NOT refreshed from USPS-licensed products. New + USPS-only ZIPs enter only via `supplemental_zips.csv` (manually sourced, + per-row provenance) or — once a maintainer configures a free + [HUD API token](https://www.huduser.gov/portal/dataset/uspszip-api.html) — + a future HUD-USPS crosswalk stage, which is the designated authoritative + replacement for the supplement file. + +## Refresh automation + +`.github/workflows/refresh-data.yaml` runs this pipeline on manual dispatch +and quarterly, then opens a PR with `refresh_summary.md` as the description. +**A human merges; nothing ships automatically.** Requirements: + +- repository secret `CENSUS_API_KEY` +- a runner with R ≥ 4.2 and ~2 GB free disk; the job is single-matrix and + cache-friendly to stay light on hosted minutes, and can be pointed at the + self-hosted runner by changing one `runs-on` line (commented in the + workflow). + +## Data releases + +Data ships in two tiers, mirroring upstream's simple/comprehensive split: + +- the **simple** tier is `data/zip_code_db.rda`, inside the package; +- the **comprehensive** tier (~450 MB SQLite with full ACS profiles) is an + asset of the `data-YYYY.MM` GitHub release, fetched on demand by + `download_comprehensive_data()` with checksum verification and cached under + `tools::R_user_dir("zipcodeR", "data")`. + +`zip_data_version()` reports the loaded data release; the release tag, +asset name, and SHA256 live in `R/sysdata.rda` (written by `06_finalize.R`). diff --git a/data-raw/accepted_cd_coverage_loss.txt b/data-raw/accepted_cd_coverage_loss.txt new file mode 100644 index 0000000..f48eff7 --- /dev/null +++ b/data-raw/accepted_cd_coverage_loss.txt @@ -0,0 +1,1266 @@ +01059 +01467 +01472 +01508 +01509 +01526 +01538 +01586 +01784 +01865 +01866 +01885 +01936 +02018 +02020 +02041 +02051 +02059 +02060 +02065 +02070 +02137 +02327 +02334 +02337 +02345 +02355 +02358 +02381 +02447 +02455 +02552 +02574 +02651 +02661 +02662 +02669 +02712 +02768 +02801 +02877 +02883 +03041 +03073 +03252 +03272 +03289 +03468 +03469 +03589 +03746 +03749 +03754 +03769 +03815 +03859 +03896 +04004 +04013 +04014 +04016 +04028 +04054 +04077 +04078 +04082 +04094 +04223 +04225 +04228 +04230 +04262 +04266 +04288 +04291 +04343 +04565 +04629 +04637 +04644 +04672 +04738 +04744 +04775 +04850 +04865 +04933 +04935 +04962 +06025 +06028 +06064 +06075 +06077 +06230 +06233 +06244 +06246 +06251 +06267 +06372 +06383 +06388 +06404 +06440 +06487 +06491 +06781 +06829 +06838 +06875 +06876 +07428 +07710 +07752 +07763 +07765 +07806 +07837 +07839 +07845 +07855 +07875 +07877 +07879 +07938 +07978 +08018 +08025 +08213 +08214 +08218 +08219 +08220 +08231 +08250 +08252 +08313 +08315 +08342 +08347 +08504 +08556 +08557 +08739 +08803 +08808 +08868 +08870 +08885 +08888 +10587 +10912 +10959 +10981 +10982 +12050 +12073 +12089 +12107 +12128 +12133 +12141 +12506 +12510 +12511 +12537 +12541 +12568 +12584 +12588 +12848 +12995 +13043 +13064 +13093 +13137 +13139 +13341 +13362 +13401 +13426 +13479 +13631 +13671 +13677 +13737 +13745 +13749 +13758 +13762 +13794 +13840 +13845 +13848 +14010 +14038 +14107 +14110 +14140 +14144 +14410 +14429 +14430 +14449 +14452 +14461 +14488 +14508 +14518 +14520 +14538 +14547 +14558 +14563 +14730 +14745 +14751 +14758 +14766 +14786 +14857 +14863 +14876 +14887 +15032 +15091 +15127 +15336 +15339 +15415 +15439 +15485 +15548 +15549 +15553 +15621 +15664 +15685 +15758 +15959 +16039 +16058 +16221 +16225 +16234 +16261 +16375 +16413 +16629 +16644 +16660 +16663 +16681 +16850 +16873 +16945 +17072 +17083 +17085 +17231 +17256 +17272 +17310 +17312 +17337 +17358 +17375 +17503 +17521 +17528 +17533 +17537 +17564 +17567 +17575 +17580 +17585 +17726 +17735 +17831 +17833 +17877 +17882 +17966 +18002 +18039 +18060 +18068 +18084 +18247 +18251 +18320 +18341 +18348 +18352 +18410 +18416 +18427 +18449 +18459 +18601 +18611 +18653 +18654 +18813 +18827 +18910 +18911 +18916 +18918 +18922 +18928 +18931 +18934 +18943 +18949 +18953 +18956 +18957 +18958 +18963 +18968 +18971 +18979 +18981 +19028 +19039 +19318 +19331 +19339 +19346 +19347 +19351 +19354 +19357 +19360 +19366 +19371 +19376 +19395 +19399 +19407 +19408 +19409 +19421 +19423 +19430 +19432 +19443 +19450 +19451 +19470 +19480 +19481 +19482 +19484 +19486 +19519 +19548 +19708 +19961 +19969 +20101 +20103 +20104 +20128 +20131 +20138 +20139 +20140 +20160 +20189 +20588 +20598 +20610 +20627 +20635 +20643 +21018 +21020 +21022 +21027 +21092 +21106 +21139 +21150 +21522 +21528 +21556 +21609 +21656 +21670 +21720 +21721 +21759 +21765 +21775 +21792 +21836 +21852 +21857 +21916 +22067 +22081 +22082 +22103 +22116 +22121 +22122 +22430 +22442 +22446 +22451 +22463 +22471 +22481 +22501 +22507 +22513 +22517 +22523 +22524 +22526 +22528 +22530 +22544 +22545 +22547 +22552 +22558 +22565 +22577 +22579 +22581 +22622 +22626 +22748 +22833 +22848 +22924 +22945 +22965 +22987 +23001 +23003 +23018 +23031 +23064 +23067 +23090 +23105 +23115 +23125 +23127 +23131 +23154 +23162 +23170 +23178 +23183 +23184 +23190 +23313 +23341 +23397 +23404 +23424 +23429 +23443 +23694 +23873 +23939 +24111 +24126 +24130 +24146 +24177 +24215 +24218 +24438 +24463 +24533 +24535 +24576 +24608 +24619 +24624 +24627 +24628 +24640 +24647 +24658 +24732 +24829 +25026 +25079 +25423 +25441 +25562 +25569 +25614 +25686 +25691 +25860 +25906 +25919 +25927 +26229 +26434 +26527 +26531 +26566 +26578 +26675 +27010 +27049 +27230 +27359 +27373 +27375 +27543 +27552 +27586 +27811 +27867 +27868 +27887 +28010 +28038 +28123 +28126 +28130 +28522 +28545 +28583 +28647 +28661 +28688 +28699 +28724 +28727 +28728 +28738 +28750 +28760 +28765 +28776 +28784 +28903 +29002 +29041 +29132 +29147 +29336 +29348 +29386 +29447 +29476 +29636 +29656 +29675 +29677 +29688 +29703 +29813 +29822 +29839 +29846 +29850 +29931 +29933 +30074 +30109 +30123 +30129 +30138 +30140 +30142 +30146 +30150 +30151 +30172 +30212 +30266 +30424 +30438 +30502 +30580 +30638 +30645 +30647 +30732 +30806 +30819 +31013 +31086 +31304 +31319 +31769 +31782 +32007 +32042 +32105 +32111 +32138 +32160 +32185 +32357 +32362 +32422 +32538 +32560 +32633 +32654 +32662 +32663 +32706 +32710 +32733 +32747 +32777 +32799 +32957 +32971 +33052 +33075 +33093 +33097 +33114 +33421 +33454 +33459 +33524 +33586 +33587 +33593 +33820 +33846 +33863 +33938 +33945 +34264 +34270 +34272 +34447 +34636 +34660 +34680 +34740 +34755 +34992 +35015 +35038 +35048 +35123 +35137 +35139 +35142 +35182 +35187 +35440 +35448 +35449 +35468 +35471 +35478 +35482 +35551 +35560 +35617 +35742 +35762 +35767 +35964 +36015 +36065 +36253 +36254 +36257 +36261 +36275 +36349 +36458 +36745 +37063 +37071 +37119 +37152 +37314 +37378 +37382 +37389 +37684 +37707 +37778 +38014 +38045 +38048 +38071 +38223 +38271 +38324 +38331 +38336 +38346 +38378 +38393 +38602 +38638 +38649 +38679 +38739 +38780 +38782 +38820 +38825 +38874 +38875 +38877 +38880 +38959 +38960 +39077 +39080 +39087 +39098 +39109 +39151 +39161 +39163 +39165 +39171 +39190 +39436 +39457 +39460 +39463 +39552 +39555 +39558 +39632 +39829 +39852 +40018 +40027 +40048 +40129 +40153 +40317 +40319 +40405 +40410 +40452 +40473 +40492 +40724 +40730 +40755 +40930 +40932 +40944 +40951 +40955 +41037 +41053 +41061 +41062 +41081 +41096 +41160 +41173 +41347 +41368 +41413 +41426 +41451 +41520 +41542 +41549 +41561 +41736 +41743 +42033 +42063 +42152 +42201 +42216 +42219 +42221 +42288 +42364 +42402 +42457 +42460 +42558 +42564 +42720 +43048 +43073 +43083 +43349 +43441 +43752 +43757 +43803 +43828 +43916 +43927 +43937 +43941 +43970 +43984 +44033 +44088 +44210 +44211 +44265 +44334 +44416 +44422 +44453 +44492 +44617 +44636 +44650 +44679 +44816 +44845 +44848 +44860 +44862 +45004 +45061 +45063 +45633 +45643 +45675 +45687 +45712 +45713 +45787 +45837 +46067 +46355 +46372 +46380 +46713 +46769 +46780 +46782 +46912 +46916 +46921 +46942 +46977 +46995 +47019 +47039 +47107 +47146 +47228 +47245 +47322 +47426 +47463 +47536 +47545 +47556 +47617 +47618 +47647 +47852 +47878 +47962 +48004 +48175 +48330 +48366 +48387 +48758 +48812 +48830 +48833 +48882 +49020 +49035 +49041 +49062 +49077 +49239 +49257 +49281 +49311 +49314 +49317 +49416 +49427 +49610 +49666 +49673 +49723 +49761 +49792 +49797 +49819 +49845 +49964 +50059 +50259 +50481 +50661 +52149 +52648 +53026 +53056 +53060 +53064 +53101 +53102 +53109 +53138 +53148 +53152 +53159 +53171 +53194 +53535 +53824 +54226 +54240 +54415 +54429 +54532 +54543 +54620 +54641 +54649 +54662 +54735 +54743 +54764 +54765 +54816 +54890 +54969 +54976 +55010 +55323 +55377 +55791 +56047 +56084 +56333 +56341 +56344 +56436 +56577 +56593 +56631 +56740 +56741 +59084 +59341 +59402 +59641 +59835 +59921 +60037 +60082 +60147 +60499 +60599 +60944 +61758 +62065 +62070 +62071 +62252 +62329 +62659 +62660 +62662 +62834 +62840 +62909 +62973 +62993 +63057 +63065 +63066 +63338 +63342 +63346 +63378 +63442 +63651 +63666 +63737 +63752 +63776 +63779 +63875 +63881 +63931 +64073 +64864 +65038 +65042 +65055 +65532 +65546 +65607 +65636 +65765 +66036 +66250 +66276 +66420 +66728 +66855 +67585 +68738 +68969 +70078 +70371 +70421 +70521 +70540 +70569 +70718 +70728 +70784 +70792 +71080 +71240 +71249 +71281 +71324 +71329 +71348 +71363 +71365 +71440 +71448 +71460 +71471 +71477 +71750 +71828 +71840 +71932 +72037 +72043 +72164 +72310 +72312 +72321 +72465 +72474 +72475 +72575 +72613 +72643 +72711 +72728 +72733 +72735 +72737 +72741 +72770 +72820 +74031 +74043 +74335 +74444 +74529 +74545 +74559 +74752 +75097 +75118 +75121 +75425 +75443 +75444 +75458 +75485 +75636 +75637 +75660 +75666 +75685 +75688 +75772 +75832 +75834 +75958 +75978 +76246 +76352 +76369 +76461 +76465 +76467 +76468 +76485 +76526 +76533 +76558 +76564 +76684 +76855 +76883 +76886 +76949 +77315 +77332 +77333 +77411 +77413 +77452 +77463 +77626 +77639 +77838 +77850 +77862 +77870 +77881 +77967 +77989 +78054 +78060 +78115 +78350 +78568 +78604 +78651 +78673 +78952 +78960 +79221 +79516 +79740 +80533 +80541 +80614 +81135 +81329 +81414 +81420 +82524 +82615 +82715 +82840 +83229 +83256 +83343 +83531 +83630 +83635 +83671 +83853 +83865 +84510 +85127 +85652 +85738 +86342 +86412 +87574 +87576 +88032 +88033 +88036 +88054 +88058 +88122 +88123 +88133 +88213 +88433 +89023 +90090 +90202 +91023 +91610 +91715 +91743 +92068 +92318 +92323 +92366 +92609 +92650 +92811 +93435 +93642 +93649 +93661 +94620 +94914 +95026 +95044 +95229 +95234 +95347 +95373 +95416 +95418 +95424 +95433 +95481 +95487 +95534 +95654 +95656 +95712 +95724 +95924 +95940 +95980 +96095 +96715 +96718 +96739 +96784 +96788 +96854 +96861 +96898 +97010 +97036 +97044 +97307 +97407 +97409 +97464 +97491 +97533 +98061 +98062 +98082 +98259 +98287 +98293 +98322 +98324 +98343 +98344 +98348 +98384 +98386 +98393 +98395 +98398 +98511 +98522 +98540 +98554 +98561 +98622 +98637 +98920 +99104 diff --git a/data-raw/fips_codes.R b/data-raw/fips_codes.R new file mode 100644 index 0000000..71f1ad8 --- /dev/null +++ b/data-raw/fips_codes.R @@ -0,0 +1,36 @@ +# Regenerate the internal `fips_codes` table bundled in R/sysdata.rda +# +# Provenance: U.S. Census Bureau state and county FIPS codes (public +# domain), as compiled in the `tidycensus` package (MIT license, Kyle +# Walker), which builds the table from the Census reference files at +# https://www.census.gov/library/reference/code-lists/ansi.html +# +# zipcodeR previously depended on the whole tidycensus package (and its +# sf/GDAL dependency chain) solely to read this static 3,256-row table. +# It is now vendored as internal data. Re-run this script to refresh it, +# then rebuild the package. +# +# Requires: tidycensus (only at data-build time, never at runtime) + +fips_codes <- tidycensus::fips_codes +stopifnot( + is.data.frame(fips_codes), + identical( + names(fips_codes), + c("state", "state_code", "state_name", "county_code", "county") + ), + nrow(fips_codes) > 3000 +) + +# sysdata.rda carries other internal objects (zip_code_db_version, +# zip_data_meta, ...); load them all and re-save everything so nothing is +# silently dropped +sysdata_env <- new.env() +load(file.path("R", "sysdata.rda"), envir = sysdata_env) +assign("fips_codes", fips_codes, envir = sysdata_env) +save( + list = ls(sysdata_env, all.names = TRUE), + envir = sysdata_env, + file = file.path("R", "sysdata.rda"), + compress = "bzip2" +) diff --git a/data-raw/refresh_summary.md b/data-raw/refresh_summary.md new file mode 100644 index 0000000..c23cf65 --- /dev/null +++ b/data-raw/refresh_summary.md @@ -0,0 +1,11 @@ +## Data refresh summary + +- `zip_code_db`: 42725 rows (was 41877): **848 added, 0 removed** + - added by type: Military (787), PO Box (31), Standard (20), Unique (10) + - coordinates refreshed for 32907 existing ZIPs; ACS attributes refreshed for 33637 ZIPs +- `zcta_crosswalk`: 168212 rows, 2020 ZCTA/tract vintage (previously 148897 rows) +- `zip_to_cd`: 54817 rows, 119th-Congress vintage (previously 45914 rows) + - 33791 ZCTA-mapped ZIPs + 6410 city-derived and 63 single-district-state USPS-only ZIPs; 1674 non-military ZIPs unmapped +- state-modal timezone imputed for 0 new ZIP(s) + +Validation gate: **all checks passed** diff --git a/data-raw/run_pipeline.R b/data-raw/run_pipeline.R new file mode 100644 index 0000000..df869af --- /dev/null +++ b/data-raw/run_pipeline.R @@ -0,0 +1,20 @@ +# Orchestrator for the zipcodeR data pipeline. Run from the package root: +# +# Rscript data-raw/run_pipeline.R +# +# Requires: CENSUS_API_KEY in the environment (free registration at +# https://api.census.gov/data/key_signup.html). See data-raw/README.md. + +steps <- c( + "01_acquire.R", + "02_build_zip_code_db.R", + "03_build_zcta_crosswalk.R", + "04_build_zip_to_cd.R", + "05_validate.R", + "06_finalize.R" +) +for (step in steps) { + message("== ", step, " ==") + source(file.path("data-raw", step)) +} +message("pipeline complete. Review data-raw/refresh_summary.md and the git diff.") diff --git a/data-raw/sources.R b/data-raw/sources.R new file mode 100644 index 0000000..3963fc6 --- /dev/null +++ b/data-raw/sources.R @@ -0,0 +1,91 @@ +# Source registry for the zipcodeR data pipeline. +# +# Every static source is pinned by URL and SHA256. When a source publisher +# updates a file in place (Census relationship files are stable; Gazetteer and +# GeoNames get new vintages), update the URL/sha256 here and record the change +# in the data release notes. API sources (Census ACS) cannot be checksummed; +# their vintage is pinned via the endpoint year. + +PIPELINE_SOURCES <- list( + zcta_tract_rel = list( + description = "Census 2020 ZCTA-to-tract national relationship file", + url = "https://www2.census.gov/geo/docs/maps-data/data/rel2020/zcta520/tab20_zcta520_tract20_natl.txt", + sha256 = "6a25d8c3fff4cf612c4d2dccc2c0cd6cb5bc99b807ff3d5d107a2e9b9d68dde0", + license = "U.S. public domain (U.S. Census Bureau)" + ), + zcta_county_rel = list( + description = "Census 2020 ZCTA-to-county national relationship file", + url = "https://www2.census.gov/geo/docs/maps-data/data/rel2020/zcta520/tab20_zcta520_county20_natl.txt", + sha256 = "3ed41278d637dc249e0323306f68be8a6c234e3090f4de88ef328dee71aeaaaf", + license = "U.S. public domain (U.S. Census Bureau)" + ), + cd_zcta_rel = list( + description = "Census 119th Congressional District-to-ZCTA national relationship file", + url = "https://www2.census.gov/geo/docs/maps-data/data/rel2020/cd-sld/tab20_cd11920_zcta520_natl.txt", + sha256 = "57fad59f65af5179ddd18dcfb8f72482dc0cf04fe26e2b9b2b34c51c04405f77", + license = "U.S. public domain (U.S. Census Bureau)" + ), + gazetteer_zcta = list( + description = "Census 2024 Gazetteer, national ZCTA file (2020 ZCTAs)", + url = "https://www2.census.gov/geo/docs/maps-data/data/gazetteer/2024_Gazetteer/2024_Gaz_zcta_national.zip", + sha256 = "7b85c04a131672f58b38a950eb82855d5fd4054f0bd9bc3f69aa28897a714e3d", + license = "U.S. public domain (U.S. Census Bureau)" + ), + geonames_us = list( + description = "GeoNames U.S. postal codes (place names, admin areas, coordinates)", + url = "https://download.geonames.org/export/zip/US.zip", + # GeoNames regenerates this file in place (near-daily), so its checksum + # cannot be enforced without breaking every scheduled refresh. floating = + # TRUE makes 01_acquire.R RECORD the downloaded hash (in the acquire log + # and refresh summary) instead of failing on mismatch; the hash below is + # the one from the 2026.08 build, kept for provenance. + floating = TRUE, + sha256 = "34bf4144bf1231c2da500127bbbf7020920bb4331de403b5d850b77f45a8f509", + license = "CC BY 4.0 (GeoNames) - attribution required, kept in data docs" + ), + uszipcode_simple_101 = list( + description = "uszipcode-project 1.0.1 simple_db.sqlite (validation reference + row source for post-2021-06 additions)", + url = "https://github.com/MacHu-GWU/uszipcode-project/releases/download/1.0.1.db/simple_db.sqlite", + sha256 = "43383f108ef14dccd925107bc77705f622b1014111ad5c9e5e2a6837bb7f64ff", + license = "MIT (MacHu-GWU/uszipcode-project) - attribution kept in data docs" + ) +) + +# API-based sources (no checksum possible; vintage pinned by endpoint) +ACS_VINTAGE <- 2023 # ACS 5-year estimates, 2019-2023 +ACS_ENDPOINT <- sprintf("https://api.census.gov/data/%d/acs/acs5", ACS_VINTAGE) +ACS_VARIABLES <- c( + population = "B01003_001E", + housing_units = "B25001_001E", + occupied_housing_units = "B25002_002E", + median_home_value = "B25077_001E", + median_household_income = "B19013_001E" +) + +# Data release identity. Derived from the build date so scheduled refreshes +# stamp a new version automatically; override with PIPELINE_DATA_VERSION for +# a rebuild of an existing release. +DATA_VERSION <- Sys.getenv("PIPELINE_DATA_VERSION", format(Sys.Date(), "%Y.%m")) + +# The comprehensive-database release asset that download_comprehensive_data() +# should fetch. This is pinned EXPLICITLY - not derived from DATA_VERSION - +# because the comprehensive asset is republished less often than the bundled +# data refreshes. When (and only when) a new comprehensive asset is uploaded +# to a data release, update all three fields together; 05_validate.R checks +# their consistency. +COMPREHENSIVE_RELEASE <- list( + release_tag = "data-2026.08", + asset = "comprehensive_db.sqlite", + sha256 = "d85ed4e25884bc27bdd339d57dd9e2d1763531d4c050acb7a05a3d5aca90668d" +) + +# One-time accepted loss of congressional-district coverage for the 2026.08 +# rebuild: the USPS-only ZIPs enumerated in accepted_cd_coverage_loss.txt +# were mapped by the old pre-2020 HUD crosswalk but have no principled +# current-vintage derivation (no ZCTA, no covered same-city peer, +# multi-district state). Their stale district numbers were deliberately NOT +# carried forward (see NEWS 0.4.0); the planned HUD-USPS stage restores them +# with current data. The gate accepts losing ONLY the ZIPs on this exact +# list - any other coverage loss fails - so the acceptance cannot mask a +# future regression. Empty the file after the 2026.08 release ships. +ACCEPTED_CD_COVERAGE_LOSS_FILE <- file.path("data-raw", "accepted_cd_coverage_loss.txt") diff --git a/data-raw/supplemental_zips.csv b/data-raw/supplemental_zips.csv new file mode 100644 index 0000000..93718fc --- /dev/null +++ b/data-raw/supplemental_zips.csv @@ -0,0 +1,2 @@ +zipcode,zipcode_type,major_city,state,county,lat,lng,source,note +91230,PO Box,Glendale,CA,Los Angeles County,34.1425,-118.2551,"USPS ZIP lookup via third-party references (researchmaniacs.com, postcodebase.com), coordinates = central Glendale CA","USPS-only ZIP with no ZCTA; reported missing in issue #26. Replace this row with HUD-USPS crosswalk data once a HUD API token is configured (see README)." diff --git a/data/zcta_crosswalk.rda b/data/zcta_crosswalk.rda index 5a1fa90..f1c54fa 100644 Binary files a/data/zcta_crosswalk.rda and b/data/zcta_crosswalk.rda differ diff --git a/data/zip_code_db.rda b/data/zip_code_db.rda index 19ca9fe..c12e3bf 100644 Binary files a/data/zip_code_db.rda and b/data/zip_code_db.rda differ diff --git a/data/zip_to_cd.rda b/data/zip_to_cd.rda index 23dff17..3ae1ba4 100644 Binary files a/data/zip_to_cd.rda and b/data/zip_to_cd.rda differ diff --git a/inst/CITATION b/inst/CITATION index f5fea67..430f715 100644 --- a/inst/CITATION +++ b/inst/CITATION @@ -1,14 +1,15 @@ citHeader("To cite zipcodeR in publications, please use:") -citEntry(entry = "Article", - author = "Gavin C. Rozzi", - title = "zipcodeR: Advancing the analysis of spatial data at the ZIP code level in R", - journal = "Software Impacts", - issn = "2665-9638", - volume = "9", - pages = "100099", - year = "2021", - doi = "10.1016/j.simpa.2021.100099", - url = "https://www.sciencedirect.com/science/article/pii/S2665963821000373/", - textVersion = "G.C. Rozzi, zipcodeR: Advancing the analysis of spatial data at the ZIP code level in R, Softw. Impacts. (2021) 100099." +bibentry( + bibtype = "Article", + author = "Gavin C. Rozzi", + title = "zipcodeR: Advancing the analysis of spatial data at the ZIP code level in R", + journal = "Software Impacts", + issn = "2665-9638", + volume = "9", + pages = "100099", + year = "2021", + doi = "10.1016/j.simpa.2021.100099", + url = "https://www.sciencedirect.com/science/article/pii/S2665963821000373/", + textVersion = "G.C. Rozzi, zipcodeR: Advancing the analysis of spatial data at the ZIP code level in R, Softw. Impacts. (2021) 100099." ) diff --git a/issue_comments/issue-13.md b/issue_comments/issue-13.md new file mode 100644 index 0000000..8339e33 --- /dev/null +++ b/issue_comments/issue-13.md @@ -0,0 +1,11 @@ + + +Following up on an old one: this error (`object 'zip_code_db' not found` from a +lookup function) indicates a broken or partial installation — the datasets are +lazy-loaded with the package, and this symptom typically appears after migrating a +package library between R versions or an interrupted install. A clean +`install.packages("zipcodeR")` resolves it. + +The 0.4.0 FAQ vignette (`vignette("faq", package = "zipcodeR")`) now documents this. +Closing as stale/documented — please open a fresh issue if it recurs on a current +release with a clean install. diff --git a/issue_comments/issue-14.md b/issue_comments/issue-14.md new file mode 100644 index 0000000..21913d5 --- /dev/null +++ b/issue_comments/issue-14.md @@ -0,0 +1,11 @@ + + +Thanks for the kind words! The negative longitudes are correct: the United States is +in the western hemisphere, and the standard geographic sign convention makes +longitudes west of the prime meridian negative. Mapping libraries and spatial packages +all expect this, so please don't multiply by -1 — a positive 74° longitude would place +a New Jersey ZIP code in Central Asia. + +The 0.4.0 release documents this in `?geocode_zip` and in a new FAQ vignette +(`vignette("faq", package = "zipcodeR")`), so closing this as resolved by +documentation. diff --git a/issue_comments/issue-19.md b/issue_comments/issue-19.md new file mode 100644 index 0000000..02d96d3 --- /dev/null +++ b/issue_comments/issue-19.md @@ -0,0 +1,13 @@ + + +To answer the original question: the data had last been updated 2021-06-08 — and as of +0.4.0 you can always check this yourself with the new `zip_data_version()`. + +Two things were behind "many missing ZIP codes" in `zip_distance()`: + +1. **Data staleness** — fixed in 0.4.0: the database now covers every 2020 Census ZCTA + (42,725 ZIP codes, up from 41,877), rebuilt through a reproducible pipeline with + scheduled refreshes, so it can no longer drift years out of date. +2. **ZIP codes without coordinates** — about 20% of ZIP codes (P.O. Box and "unique" + codes) are postal-only constructs with no Census geography, and `zip_distance()` + returns `NA` for them by design. The FAQ vignette now documents this. diff --git a/issue_comments/issue-20.md b/issue_comments/issue-20.md new file mode 100644 index 0000000..4995999 --- /dev/null +++ b/issue_comments/issue-20.md @@ -0,0 +1,28 @@ + + +Thanks for the detailed report and the reprexes — they made this easy to verify. + +The ordering bug you reported was fixed in the 0.3.4 release (PR contributed by +Nicholas X Lee), and I've confirmed both of your exact examples return correctly +paired results on the upcoming 0.4.0: + +```r +zip_distance(c("08731", "08734"), c("08901", "08005")) +#> zipcode_a zipcode_b distance +#> 1 08731 08901 43.82 +#> 2 08734 08005 10.49 + +zip_distance(c("08731", "08731"), c("08731", "08005")) +#> zipcode_a zipcode_b distance +#> 1 08731 08731 0.00 +#> 2 08731 08005 7.23 +``` + +(The absolute values differ slightly from older releases because 0.4.0 refreshes +coordinates to authoritative Census ZCTA centroids and computes haversine distances — +the 08731→08901 leg lands at 43.8 mi, close to the ~45 mi you estimated.) + +What was missing was a regression test locking the ordering in — 0.4.0 adds tests +built directly from your reprexes, and `zip_distance()` now looks up coordinates +positionally (no join involved), so input order is preserved by construction. Closing +as fixed; please reopen if you can still reproduce a swap on 0.3.4 or later. diff --git a/issue_comments/issue-21.md b/issue_comments/issue-21.md new file mode 100644 index 0000000..f2edb19 --- /dev/null +++ b/issue_comments/issue-21.md @@ -0,0 +1,11 @@ + + +Closing the loop on this one: the load failure was a class-registration collision +between stale `raster`/`terra` binaries (`coerce` methods for Raster/SpatRaster), +which zipcodeR was exposed to only because it imported `raster` for two distance +calculations. + +As of 0.4.0, zipcodeR no longer imports `raster` (or any package in the sp/terra/GDAL +chain) — distance math is an internal haversine implementation — so this failure mode +can no longer occur through zipcodeR. If you hit it in other contexts, reinstalling +`raster` and `terra` together usually resolves the stale-binary mismatch. diff --git a/issue_comments/issue-24.md b/issue_comments/issue-24.md new file mode 100644 index 0000000..935bac9 --- /dev/null +++ b/issue_comments/issue-24.md @@ -0,0 +1,11 @@ + + +The `libarrow.so.800` warnings came from `sf`'s GDAL initialization (your distro's +GDAL was built against a different arrow version) — and zipcodeR only touched that +chain because it imported `tidycensus` (which depends on `sf`) just to read its static +FIPS code table. + +As of 0.4.0 that table is bundled directly in zipcodeR and the `tidycensus`/`sf` +dependency is gone, along with `raster`/`sp`. Loading zipcodeR no longer initializes +GDAL at all, so these warnings can no longer appear via zipcodeR regardless of the +system arrow/GDAL pairing. diff --git a/issue_comments/issue-25.md b/issue_comments/issue-25.md new file mode 100644 index 0000000..2eae420 --- /dev/null +++ b/issue_comments/issue-25.md @@ -0,0 +1,17 @@ + + +Fixed in the 0.4.0 data release. 97003 (Beaverton) was missing from the 2021 data +snapshot the package shipped; it's now present with authoritative Census Gazetteer +coordinates: + +```r +reverse_zipcode("97003") +#> 1 97003 Standard Beaverton OR 45.50 -122.86 ... +``` + +I audited Oregon coverage against the full 2020 Census ZCTA list while fixing this: +97003 was the only Oregon ZCTA missing. If you still have the list of the other ~dozen +ZIP codes you found missing, I'd welcome it as a check — they may have been USPS-only +(non-ZCTA) codes, which the new data pipeline handles through a separate supplement +(see the FAQ vignette for the distinction). As of 0.4.0, every 2020 ZCTA nationwide is +present, and the pipeline's validation gate prevents regressions. diff --git a/issue_comments/issue-26.md b/issue_comments/issue-26.md new file mode 100644 index 0000000..8f159be --- /dev/null +++ b/issue_comments/issue-26.md @@ -0,0 +1,16 @@ + + +Fixed in the 0.4.0 data release. 91230 is a USPS-only ZIP code (Glendale, CA — a +P.O. Box-type code with no Census ZCTA), which is why it was absent from the upstream +ZCTA-oriented database. It's now included via the new data pipeline's curated +supplement for USPS-only codes: + +```r +reverse_zipcode("91230") +#> 1 91230 PO Box Glendale CA 34.14 -118.26 ... +``` + +More broadly, 0.4.0 rebuilds the database through a reproducible pipeline whose +validation gate requires this ZIP (and the other reported missing ones) to be present +with coordinates before any data refresh can ship. Note the FAQ vignette now explains +the ZIP-vs-ZCTA distinction behind this class of gap. diff --git a/issue_comments/issue-27.md b/issue_comments/issue-27.md new file mode 100644 index 0000000..1fd303e --- /dev/null +++ b/issue_comments/issue-27.md @@ -0,0 +1,23 @@ + + +Thanks for the clear reprex — this was a real vectorization bug, now fixed for the +0.4.0 release. + +The root cause: `reverse_zipcode()` filtered the database with `%in%`, which returns +rows in *database* order and collapses duplicate inputs — so your 13 ZIP codes (with +96817 appearing twice) produced 12 rows, and `mutate()` correctly refused the length +mismatch. The `rowwise()` workaround mentioned above worked because it forced one call +per row. + +As of 0.4.0, `reverse_zipcode()` guarantees one output row per input element, in input +order, with duplicates preserved and NA rows (plus a warning) for ZIP codes not in the +database. Your exact example now works inside `mutate()` without `rowwise()`: + +```r +df %>% mutate(county = reverse_zipcode(zipcode)$county) +# 13 rows; NA county for the "00000" entries, matching values for both 96817 rows +``` + +`geocode_zip()` received the same treatment (it previously dropped unmatched ZIP codes +silently, shortening its output). Both behaviors are covered by regression tests built +from this issue's reprex. diff --git a/issue_comments/issue-28.md b/issue_comments/issue-28.md new file mode 100644 index 0000000..72271b5 --- /dev/null +++ b/issue_comments/issue-28.md @@ -0,0 +1,17 @@ + + +The plan is now implemented: as of 0.4.0, zipcodeR no longer depends on the legacy +geospatial stack at all. + +The warning you saw came from `sp`, loaded transitively through `raster` — which +zipcodeR imported solely for two `pointDistance()` calls. Those are now an internal +haversine implementation in plain R, and the other heavy chain (`tidycensus` → `sf`, +imported only for its static FIPS code table) is gone too — the table is bundled as +package data. + +zipcodeR's Imports are now just `dplyr`, `rlang`, `stringr`, and `utils`: no `sp`, no +`raster`, no GDAL/GEOS/PROJ, no arrow, and a package load that went from ~2.7 s / 54 +namespaces to ~0.15 s / 25 namespaces on my machine. Note that distances are now +spherical (haversine) rather than WGS84 geodesic, so values can differ from previous +releases by up to ~0.5% — see NEWS for details. diff --git a/issue_comments/issue-29.md b/issue_comments/issue-29.md new file mode 100644 index 0000000..70a3ba6 --- /dev/null +++ b/issue_comments/issue-29.md @@ -0,0 +1,11 @@ + + +Fixed in 0.4.0: `zip_to_cd` is now built from the Census 119th-Congress ↔ 2020 ZCTA +relationship file, so it reflects post-2020-census redistricting. The rebuild happens +inside the new reproducible data pipeline (`data-raw/04_build_zip_to_cd.R`) rather +than as a one-off, so future congresses are a scheduled refresh away instead of a +manual chore. + +Credit where due: @awallender's PR #30 established exactly this method with the +118th-Congress file back in 2023 — the pipeline implementation follows it, updated to +the current vintage. Thank you! diff --git a/issue_comments/issue-32.md b/issue_comments/issue-32.md new file mode 100644 index 0000000..81bfcc6 --- /dev/null +++ b/issue_comments/issue-32.md @@ -0,0 +1,18 @@ + + +You've diagnosed it exactly right: this is inherent to how ZIP codes work rather than +something zipcodeR can fully fix. ZIP codes are postal delivery constructs — routes +and delivery points, not polygons — and the "city" attached to a ZIP code is the +USPS's preferred *mailing* name, which routinely extends beyond municipal limits +(addresses outside a city's boundary often still carry that city's name). + +For accurate jurisdiction assignment you need the full street address geocoded against +boundary files (e.g., Census TIGER/Line places), not the ZIP code alone. ZIP-code-level +attributes in this package are estimated at the ZCTA level, which is itself an +approximation. + +The 0.4.0 release documents these limitations honestly in `?zip_code_db` (new +"Provenance and limitations" section) and in the FAQ vignette, so users hit this +caveat before relying on the jurisdiction columns. Closing as addressed by +documentation — happy to reopen if a concrete improvement to the data itself emerges +(a place-based crosswalk is on the roadmap for consideration). diff --git a/issue_comments/issue-33.md b/issue_comments/issue-33.md new file mode 100644 index 0000000..6c8212d --- /dev/null +++ b/issue_comments/issue-33.md @@ -0,0 +1,16 @@ + + +Thanks for the analysis and the proposed fix — you were exactly right that the +per-row loop was the problem. + +As of 0.4.0, `search_radius()` computes all ~42k distances in a single vectorized +call using an internal haversine implementation (the `raster` dependency is gone +entirely), taking a typical query from multiple seconds to ~10 ms on my machine — +in line with the ~40× speedup you measured, plus the constant-factor win from +dropping the `raster` dispatch overhead. Your fix also surfaced a latent bug: the +`filter(lat != "NA")` line was a no-op due to argument shadowing, and the NA +filtering now actually happens. + +A bounding-box prefilter (cheap lat/lng window before the haversine pass) and a +formal benchmark script are planned as a follow-up. Leaving this open until those +land — but the pathological slowness is fixed in 0.4.0. diff --git a/issue_comments/pr-30.md b/issue_comments/pr-30.md new file mode 100644 index 0000000..d7f3b52 --- /dev/null +++ b/issue_comments/pr-30.md @@ -0,0 +1,12 @@ + + +Thank you for this contribution, and apologies it sat so long. Your method — the +Census CD↔ZCTA relationship file reduced to the `zip_to_cd` schema — was exactly +right, and it's now implemented permanently in the package's new reproducible data +pipeline (`data-raw/04_build_zip_to_cd.R`), updated from the 118th- to the +119th-Congress vintage so the data reflects current districts. The 0.4.0 release +notes credit this PR. + +Closing as superseded by the pipeline implementation rather than merged directly, +since the branch now conflicts and the crosswalk needs to regenerate on a schedule — +but this PR is what fixed #29 in substance. diff --git a/man/download_comprehensive_data.Rd b/man/download_comprehensive_data.Rd new file mode 100644 index 0000000..0051c81 --- /dev/null +++ b/man/download_comprehensive_data.Rd @@ -0,0 +1,35 @@ +% Generated by roxygen2: do not edit by hand +% Please edit documentation in R/data_version.R +\name{download_comprehensive_data} +\alias{download_comprehensive_data} +\title{Download the comprehensive ZIP code database} +\usage{ +download_comprehensive_data(force = FALSE) +} +\arguments{ +\item{force}{If TRUE, re-download even if a verified copy is cached.} +} +\value{ +Invisibly, the path to the downloaded SQLite database. Query it +with DBI/RSQLite, e.g. +\code{DBI::dbConnect(RSQLite::SQLite(), download_comprehensive_data())}. +} +\description{ +The bundled \code{zip_code_db} is the lightweight ("simple") dataset. A +much larger companion database with detailed ACS demographic profiles per +ZIP code (the "comprehensive" database, ~450 MB SQLite) is published as an +asset of the zipcodeR data releases on GitHub rather than shipped in the +package. +} +\details{ +This function downloads that database once, verifies its SHA256 checksum, +and caches it under \code{tools::R_user_dir("zipcodeR", "data")}; later +calls return the cached path immediately. It never downloads without being +called explicitly. For offline use, copy the file to that directory +yourself (the expected file name is the asset name from the data release). +} +\examples{ +\dontrun{ +path <- download_comprehensive_data() +} +} diff --git a/man/download_zip_data.Rd b/man/download_zip_data.Rd index 3c8c42d..4014f7e 100644 --- a/man/download_zip_data.Rd +++ b/man/download_zip_data.Rd @@ -2,18 +2,28 @@ % Please edit documentation in R/download_data.r \name{download_zip_data} \alias{download_zip_data} -\title{Download updated data files needed for library functionality to the package's data directory. To be implemented for future updates.} +\title{Download updated data files needed for library functionality to the package's data directory (deprecated)} \usage{ download_zip_data(force = FALSE) } \arguments{ -\item{force}{Boolean, if set to TRUE will force overwrite existing data files with new version} +\item{force}{Ignored (retained for backward compatibility).} } \value{ -Data files needed for package functionality, stored in data directory of package install +Invisibly returns NULL; the function performs no action beyond the deprecation warning. } \description{ -Download updated data files needed for library functionality to the package's data directory. To be implemented for future updates. +\strong{Deprecated.} This function attempts to +write refreshed data into the installed package directory, which violates +CRAN policy, fails on read-only libraries, and — because the package uses +lazy data — never actually changes the data the package loads. It is +retained for backward compatibility only and will be removed in a future +release. + +Bundled data is now refreshed through the reproducible pipeline in the +package repository (\verb{data-raw/}, shipped with each release; see +\code{zip_data_version()}), and the large companion database is available via +\code{\link[=download_comprehensive_data]{download_comprehensive_data()}}. } \examples{ \dontrun{ diff --git a/man/geocode_zip.Rd b/man/geocode_zip.Rd index 6483ddb..b1ea4cc 100644 --- a/man/geocode_zip.Rd +++ b/man/geocode_zip.Rd @@ -2,18 +2,24 @@ % Please edit documentation in R/zip_lookups.r \name{geocode_zip} \alias{geocode_zip} -\title{Returns that lat / lon pair of the centroid of a given ZIP code} +\title{Returns the lat / lon pair of the centroid of a given ZIP code} \usage{ geocode_zip(zip_code) } \arguments{ -\item{zip_code}{A 5-digit U.S. ZIP code} +\item{zip_code}{A 5-digit U.S. ZIP code or character vector with multiple ZIP codes} } \value{ -tibble of lat lon coordinates +A tibble of coordinates with one row per element of \code{zip_code}, +in input order (duplicates preserved). ZIP codes with no match return a row +of NA coordinates (with a warning); an error is raised only when no input +ZIP code matches at all. } \description{ -Returns that lat / lon pair of the centroid of a given ZIP code +Note on sign convention: longitudes in the United States are negative +because the U.S. lies in the western hemisphere (west of the prime +meridian). This is the standard convention, not an error; do not flip +the sign of \code{lng}. } \examples{ geocode_zip("07762") diff --git a/man/get_cd.Rd b/man/get_cd.Rd index 5b7a0be..e3e93b6 100644 --- a/man/get_cd.Rd +++ b/man/get_cd.Rd @@ -7,10 +7,15 @@ get_cd(zip_code) } \arguments{ -\item{zip_code}{A U.S. ZIP code} +\item{zip_code}{A single U.S. ZIP code} } \value{ -a named list of two-digit state code and two digit district code +a named list with \code{state_fips} (state abbreviations) and +\code{district} (two-digit district codes). The two vectors are parallel: +ZIP codes spanning multiple districts return one element per district, +each labeled with its own state (some ZIP codes cross state lines). +Non-voting delegate districts (DC and the territories) use the Census +code \code{"98"}. } \description{ Get all congressional districts for a given ZIP code diff --git a/man/reverse_zipcode.Rd b/man/reverse_zipcode.Rd index cb22bb0..702eb80 100644 --- a/man/reverse_zipcode.Rd +++ b/man/reverse_zipcode.Rd @@ -7,10 +7,13 @@ reverse_zipcode(zip_code) } \arguments{ -\item{zip_code}{A 5-digit U.S. ZIP code or chracter vector with multiple ZIP codes} +\item{zip_code}{A 5-digit U.S. ZIP code or character vector with multiple ZIP codes} } \value{ -A tibble containing data for the ZIP code(s) +A tibble with one row per element of \code{zip_code}, in input order +(duplicates preserved). ZIP codes with no match in \code{zip_code_db} return +a row of NA values (with a warning), so the output is always the same length +as the input and safe to use inside \code{dplyr::mutate()}. } \description{ Given a ZIP code, returns columns of metadata about that ZIP code diff --git a/man/zcta_crosswalk.Rd b/man/zcta_crosswalk.Rd index 878bd75..fc8a390 100644 --- a/man/zcta_crosswalk.Rd +++ b/man/zcta_crosswalk.Rd @@ -3,13 +3,13 @@ \docType{data} \name{zcta_crosswalk} \alias{zcta_crosswalk} -\title{ZCTA to Census Tract (2010) Crosswalk} +\title{ZCTA to Census Tract (2020) Crosswalk} \format{ -A data frame with 148897 rows and 4 variables: +A data frame with 168212 rows and 3 variables: \describe{ -\item{ZCTA5}{2010 ZIP Code Tabulation Area} -\item{TRACT}{2010 Census Tract Code} -\item{GEOID}{Concatenation of 2010 State, County, and Tract} +\item{ZCTA5}{2020 ZIP Code Tabulation Area} +\item{TRACT}{2020 Census Tract Code} +\item{GEOID}{Concatenation of 2020 State, County, and Tract} } } \source{ @@ -19,6 +19,6 @@ A data frame with 148897 rows and 4 variables: zcta_crosswalk } \description{ -A dataset containing the relationships between ZIP code tabulation areas (ZCTA) and Census Tracts. This contains selected variables from the official crosswalk file. +A dataset containing the relationships between ZIP code tabulation areas (ZCTA) and Census Tracts. This contains selected variables from the official relationship file. Built by \code{data-raw/03_build_zcta_crosswalk.R}; see \code{zip_data_version()} for the data release. } \keyword{datasets} diff --git a/man/zip_code_db.Rd b/man/zip_code_db.Rd index 514b6bf..8491c4b 100644 --- a/man/zip_code_db.Rd +++ b/man/zip_code_db.Rd @@ -5,10 +5,10 @@ \alias{zip_code_db} \title{ZIP Code Database} \format{ -A data frame with 41877 rows and 24 variables: +A data frame with 42725 rows and 24 variables: \describe{ \item{zipcode}{5 digit U.S. ZIP code} -\item{zipcode_type}{2010 State FIPS Code} +\item{zipcode_type}{Type of ZIP code: Standard, PO Box, Unique or Military} \item{major_city}{Major city serving the ZIP code} \item{post_office_city}{City of post office serving the ZIP code} \item{common_city_list}{List of common cities represented by the ZIP code} @@ -24,7 +24,7 @@ A data frame with 41877 rows and 24 variables: \item{land_area_in_sqmi}{Area of the land contained within the ZIP code in square miles} \item{water_area_in_sqmi}{Area of the waters contained within the ZIP code in square miles} \item{housing_units}{Number of housing units within the ZIP code} -\item{occupied_housing_units}{Number of housing units within the ZIP code} +\item{occupied_housing_units}{Number of occupied housing units within the ZIP code} \item{median_home_value}{Median home price within the ZIP code} \item{median_household_income}{Median household income within the ZIP code} \item{bounds_west}{Bounding box coordinates} @@ -42,4 +42,24 @@ zip_code_db \description{ A dataset containing detailed information for U.S. ZIP codes } +\section{Provenance and limitations}{ + +This dataset is built by the reproducible pipeline in \code{data-raw/} +(see \code{zip_data_version()} for the loaded data release). Its base is +the \code{simple_zipcode} table of the +\href{https://github.com/MacHu-GWU/uszipcode-project}{uszipcode} project's +database (MIT license), refreshed with current U.S. Census Bureau data: +coordinates and land/water areas from the Gazetteer (2020 ZCTAs), +demographic attributes from ACS 5-year estimates, and place names for +post-2021 additions from GeoNames (CC BY 4.0). +Users should be aware of inherent limitations of ZIP-code-level +data: ZIP codes are postal delivery constructs, not polygons or +jurisdictions. They can cross city, county, and state boundaries, and the +\code{major_city}/\code{county} columns reflect the predominant postal +assignment, not legal jurisdiction. Census-derived attributes are estimated +at the ZIP Code Tabulation Area (ZCTA) level, which only approximates USPS +ZIP codes; USPS-only codes (P.O. Box and unique codes) may lack coordinates +and demographic attributes. See \code{vignette("faq", package = "zipcodeR")}. +} + \keyword{datasets} diff --git a/man/zip_data_version.Rd b/man/zip_data_version.Rd new file mode 100644 index 0000000..811ea5d --- /dev/null +++ b/man/zip_data_version.Rd @@ -0,0 +1,21 @@ +% Generated by roxygen2: do not edit by hand +% Please edit documentation in R/data_version.R +\name{zip_data_version} +\alias{zip_data_version} +\title{Report the version of the bundled ZIP code data} +\usage{ +zip_data_version() +} +\value{ +A named list with the data release version, build date, row count +of \code{zip_code_db}, and the primary sources (with vintages) that +produced each dataset. +} +\description{ +zipcodeR versions its data releases separately from its code releases. +This accessor reports exactly which data build is loaded, so users can +cite it and bug reports can pin the vintage. +} +\examples{ +zip_data_version() +} diff --git a/man/zip_distance.Rd b/man/zip_distance.Rd index 163ca55..b28a69f 100644 --- a/man/zip_distance.Rd +++ b/man/zip_distance.Rd @@ -11,7 +11,11 @@ zip_distance(zipcode_a, zipcode_b, lonlat = TRUE, units = "miles") \item{zipcode_b}{Second vector of ZIP codes} -\item{lonlat}{lonlat argument to pass to raster::pointDistance() to select method of distance calculation. Default is TRUE to calculate distance over a spherical projection. FALSE will calculate the distance in Euclidean (planar) space.} +\item{lonlat}{If TRUE (the default), calculate the great-circle distance +between the ZIP code centroids using the haversine formula. FALSE +(deprecated) computes a planar equirectangular approximation instead; +note that before the deprecation this mode had a unit bug that made it +return near-zero values, so no result from it should be relied on.} \item{units}{Specify which units to return distance calculations in. Choices include meters or miles.} } diff --git a/man/zip_to_cd.Rd b/man/zip_to_cd.Rd index b9ec058..dcd081c 100644 --- a/man/zip_to_cd.Rd +++ b/man/zip_to_cd.Rd @@ -5,19 +5,29 @@ \alias{zip_to_cd} \title{ZIP Code to Congressional District Relationship File} \format{ -A data frame with 45914 rows and 2 variables: +A data frame with 54817 rows and 2 variables: \describe{ \item{ZIP}{5 digit U.S. ZIP code} \item{CD}{Four digit congressional district code (State FIPS code + district number)} } } \source{ -\url{https://www.huduser.gov/portal/datasets/usps_crosswalk.html} +\url{https://www.census.gov/geographies/reference-files/time-series/geo/relationship-files.html} } \usage{ zip_to_cd } \description{ -A dataset containing mappings between ZIP codes and congressional districts +A dataset containing mappings between ZIP codes and congressional +districts of the 119th Congress, reflecting post-2020-census +redistricting. ZIP codes spanning multiple districts appear once per +district. Built by \code{data-raw/04_build_zip_to_cd.R} from the Census +CD119-to-ZCTA relationship file; USPS-only ZIP codes (P.O. Box/unique +codes without a ZCTA) are assigned the district(s) of their USPS city, or +of their state where it has a single district. Military ZIP codes and a +small remainder of USPS-only codes have no mapping. Non-voting delegate +districts (DC and the territories) carry the Census code \code{98}; +voting at-large states carry \code{00}. See \code{zip_data_version()} for +the data release. } \keyword{datasets} diff --git a/tests/testthat/_snaps/04-schemas.md b/tests/testthat/_snaps/04-schemas.md new file mode 100644 index 0000000..7da7c0f --- /dev/null +++ b/tests/testthat/_snaps/04-schemas.md @@ -0,0 +1,55 @@ +# lookup function return schemas are stable + + Code + cat("search_state: ", schema(search_state("NJ")), "\n") + Output + search_state: tbl_df: zipcode=character, zipcode_type=character, major_city=character, post_office_city=character, common_city_list=blob, county=character, state=character, lat=numeric, lng=numeric, timezone=character, radius_in_miles=numeric, area_code_list=blob, population=integer, population_density=numeric, land_area_in_sqmi=numeric, water_area_in_sqmi=numeric, housing_units=integer, occupied_housing_units=integer, median_home_value=integer, median_household_income=integer, bounds_west=numeric, bounds_east=numeric, bounds_north=numeric, bounds_south=numeric + Code + cat("search_county: ", schema(search_county("Ocean", "NJ")), "\n") + Output + search_county: tbl_df: zipcode=character, zipcode_type=character, major_city=character, post_office_city=character, common_city_list=blob, county=character, state=character, lat=numeric, lng=numeric, timezone=character, radius_in_miles=numeric, area_code_list=blob, population=integer, population_density=numeric, land_area_in_sqmi=numeric, water_area_in_sqmi=numeric, housing_units=integer, occupied_housing_units=integer, median_home_value=integer, median_household_income=integer, bounds_west=numeric, bounds_east=numeric, bounds_north=numeric, bounds_south=numeric + Code + cat("search_city: ", schema(search_city("Chappaqua", "NY")), "\n") + Output + search_city: tbl_df: zipcode=character, zipcode_type=character, major_city=character, post_office_city=character, common_city_list=blob, county=character, state=character, lat=numeric, lng=numeric, timezone=character, radius_in_miles=numeric, area_code_list=blob, population=integer, population_density=numeric, land_area_in_sqmi=numeric, water_area_in_sqmi=numeric, housing_units=integer, occupied_housing_units=integer, median_home_value=integer, median_household_income=integer, bounds_west=numeric, bounds_east=numeric, bounds_north=numeric, bounds_south=numeric + Code + cat("search_tz: ", schema(search_tz("Eastern")), "\n") + Output + search_tz: tbl_df: zipcode=character, zipcode_type=character, major_city=character, post_office_city=character, common_city_list=blob, county=character, state=character, lat=numeric, lng=numeric, timezone=character, radius_in_miles=numeric, area_code_list=blob, population=integer, population_density=numeric, land_area_in_sqmi=numeric, water_area_in_sqmi=numeric, housing_units=integer, occupied_housing_units=integer, median_home_value=integer, median_household_income=integer, bounds_west=numeric, bounds_east=numeric, bounds_north=numeric, bounds_south=numeric + Code + cat("search_fips: ", schema(search_fips("34", "029")), "\n") + Output + search_fips: tbl_df: zipcode=character, zipcode_type=character, major_city=character, post_office_city=character, common_city_list=blob, county=character, state=character, lat=numeric, lng=numeric, timezone=character, radius_in_miles=numeric, area_code_list=blob, population=integer, population_density=numeric, land_area_in_sqmi=numeric, water_area_in_sqmi=numeric, housing_units=integer, occupied_housing_units=integer, median_home_value=integer, median_household_income=integer, bounds_west=numeric, bounds_east=numeric, bounds_north=numeric, bounds_south=numeric + Code + cat("search_cd: ", schema(search_cd("34", "02")), "\n") + Output + search_cd: tbl_df: ZIP=character, state_fips=character, congressional_district=character + Code + cat("search_radius: ", schema(search_radius(39.9, -74.3, 5)), "\n") + Output + search_radius: tbl_df: zipcode=character, distance=numeric + Code + cat("reverse_zipcode: ", schema(reverse_zipcode("08731")), "\n") + Output + reverse_zipcode: tbl_df: zipcode=character, zipcode_type=character, major_city=character, post_office_city=character, common_city_list=blob, county=character, state=character, lat=numeric, lng=numeric, timezone=character, radius_in_miles=numeric, area_code_list=blob, population=integer, population_density=numeric, land_area_in_sqmi=numeric, water_area_in_sqmi=numeric, housing_units=integer, occupied_housing_units=integer, median_home_value=integer, median_household_income=integer, bounds_west=numeric, bounds_east=numeric, bounds_north=numeric, bounds_south=numeric + Code + cat("geocode_zip: ", schema(geocode_zip("08731")), "\n") + Output + geocode_zip: tbl_df: zipcode=character, lat=numeric, lng=numeric + Code + cat("get_tracts: ", schema(get_tracts("08731")), "\n") + Output + get_tracts: tbl_df: ZCTA5=character, TRACT=character, GEOID=numeric + Code + cat("zip_distance: ", schema(zip_distance("08731", "08901")), "\n") + Output + zip_distance: data.frame: zipcode_a=character, zipcode_b=character, distance=numeric + Code + cat("normalize_zip: ", schema(normalize_zip("8731")), "\n") + Output + normalize_zip: character length 1 + Code + cat("is_zcta: ", schema(is_zcta("08731")), "\n") + Output + is_zcta: logical length 1 + diff --git a/tests/testthat/test-01-zip-lookups.R b/tests/testthat/test-01-zip-lookups.R index a5ce1d1..e83f351 100644 --- a/tests/testthat/test-01-zip-lookups.R +++ b/tests/testthat/test-01-zip-lookups.R @@ -51,12 +51,12 @@ test_that("is_zcta() outputs proper structure data", { test_that("geocode_zip() works - lat", { result <- geocode_zip("08731")$lat - expect_equal(result, 39.9) + expect_equal(round(result, 1), 39.9) }) test_that("geocode_zip() works - lng", { result <- geocode_zip("08731")$lng - expect_equal(result, -74.3) + expect_equal(round(result, 1), -74.3) }) test_that("geocode_zip() outputs proper number of columns", { @@ -64,6 +64,20 @@ test_that("geocode_zip() outputs proper number of columns", { expect_equal(result, 3) }) +test_that("geocode_zip() preserves input order and length (#27)", { + result <- geocode_zip(c("08734", "08731")) + expect_equal(result$zipcode, c("08734", "08731")) + # Unmatched ZIPs come back as NA rows rather than being dropped + result <- suppressWarnings(geocode_zip(c("08731", "00000", "08734"))) + expect_equal(result$zipcode, c("08731", "00000", "08734")) + expect_true(is.na(result$lat[2])) + expect_false(anyNA(result$lat[c(1, 3)])) +}) + +test_that("geocode_zip() errors when no ZIP codes match", { + expect_error(suppressWarnings(geocode_zip("00000"))) +}) + test_that("geocode_zip() outputs proper number of rows", { result <- nrow(geocode_zip("08731")) expect_equal(result, 1) @@ -119,6 +133,42 @@ test_that("reverse_zipcode() errors on empty input", { expect_error(reverse_zipcode()) }) +test_that("reverse_zipcode() preserves input order (#27)", { + result <- reverse_zipcode(c("08734", "08731")) + expect_equal(result$zipcode, c("08734", "08731")) +}) + +test_that("reverse_zipcode() preserves duplicate inputs (#27)", { + result <- reverse_zipcode(c("08731", "08731")) + expect_equal(nrow(result), 2) + expect_equal(result$zipcode, c("08731", "08731")) +}) + +test_that("reverse_zipcode() returns one row per input, aligned, with NA rows for misses (#27)", { + input <- c("96753", "00000", "96744", "96817", "00000", "96817") + result <- suppressWarnings(reverse_zipcode(input)) + expect_equal(nrow(result), length(input)) + expect_equal(result$zipcode, input) + expect_true(all(is.na(result$county[input == "00000"]))) + expect_equal(length(unique(result$county[input == "96817"])), 1) +}) + +test_that("reverse_zipcode() works inside dplyr::mutate (#27)", { + df <- data.frame( + id = 1:13, + zipcode = c( + "96753", "00000", "96744", "96782", "00000", "96720", "96813", + "96712", "96817", "96818", "96822", "00000", "96817" + ) + ) + result <- suppressWarnings( + dplyr::mutate(df, county = reverse_zipcode(zipcode)$county) + ) + expect_equal(nrow(result), 13) + expect_true(is.na(result$county[2])) + expect_equal(result$county[9], result$county[13]) +}) + @@ -243,9 +293,41 @@ test_that("search_radius() outputs proper structure data", { expect_equal(result, "tbl_df") }) -test_that("search_radius() returns a single ZIP code when centroid of ZIP is submitted", { - result <- search_radius(39.9, -74.3, 1) - expect_equal(nrow(result), 1) +test_that("search_radius() handles circles crossing the antimeridian", { + # Adak, AK (99546, lng -176.67) is ~163mi from this point at lng +179.5; + # a prefilter comparing raw longitudes would drop it + result <- search_radius(51.85, 179.5, 300) + expect_true("99546" %in% result$zipcode) +}) + +test_that("search_radius() prefilter never excludes in-radius ZIPs (matches exhaustive search)", { + exhaustive <- function(lat, lng, radius) { + keep <- !is.na(zip_code_db$lat) & !is.na(zip_code_db$lng) + d <- zipcodeR:::haversine_distance( + zip_code_db$lat[keep], zip_code_db$lng[keep], lat, lng + ) * 0.000621371 + sort(zip_code_db$zipcode[keep][d <= radius]) + } + # the two large-radius cases where the original prefilter dropped + # higher-latitude candidates, plus an ordinary mid-latitude query + for (case in list( + c(48.08, -86.95, 2000), + c(68.28, -79.13, 2000), + c(39.9, -74.3, 25) + )) { + expect_equal( + sort(search_radius(case[1], case[2], case[3])$zipcode), + exhaustive(case[1], case[2], case[3]) + ) + } +}) + +test_that("search_radius() finds the ZIP itself first when its centroid is submitted", { + centroid <- geocode_zip("08731") + result <- search_radius(centroid$lat, centroid$lng, 1) + expect_gte(nrow(result), 1) + expect_equal(result$zipcode[1], "08731") + expect_equal(result$distance[1], 0) }) @@ -275,3 +357,48 @@ test_that("normalize_zip() fixes missing leading zeroes ", { result <- normalize_zip("8731") expect_equal(result, "08731") }) + +################# +# search_fips() # +################# + +test_that("search_fips() pads short county codes and rejects long ones", { + # the historical nchar(county_fips < 3) misplaced parenthesis made the + # padding branch unconditional and let bad input fail silently + expect_equal(nrow(search_fips("36", "003")), nrow(search_fips("36", "3"))) + expect_gt(nrow(search_fips("36", "003")), 0) + expect_error(search_fips("36", "0003"), "1-3 digit") + expect_error(search_fips("36", "999"), "No county found") +}) + +test_that("search_fips() no-match and return-class behavior is consistent", { + expect_error(search_fips("99"), "No state found") + expect_s3_class(search_fips("34"), "tbl_df") +}) + +test_that("reverse_zipcode() handles a single NA like a vector NA", { + result <- suppressWarnings(reverse_zipcode(NA)) + expect_equal(nrow(result), 1) + expect_true(is.na(result$county)) + expect_warning(reverse_zipcode(NA), "No data found") +}) + +test_that("get_cd() labels each district with its own state (cross-state ZIPs)", { + # 02861 (Pawtucket, RI) spans RI-01 and MA-04 + result <- get_cd("02861") + expect_equal(length(result$state_fips), length(result$district)) + expect_setequal(result$state_fips, c("RI", "MA")) + expect_equal(result$state_fips[result$district == "01"], "RI") + expect_equal(result$state_fips[result$district == "04"], "MA") +}) + +test_that("search_cd() accepts 00 and 98 as at-large/delegate aliases", { + # DC and territories carry the Census delegate code 98 in the data; the + # pre-2020 convention 00 keeps working as an alias + via_00 <- search_cd("72", "00") + via_98 <- search_cd("72", "98") + expect_equal(via_00$ZIP, via_98$ZIP) + expect_gt(nrow(via_00), 0) + # voting at-large states store 00 natively + expect_gt(nrow(search_cd("56", "00")), 0) +}) diff --git a/tests/testthat/test-02-data.R b/tests/testthat/test-02-data.R index 4958bb5..3ecab62 100644 --- a/tests/testthat/test-02-data.R +++ b/tests/testthat/test-02-data.R @@ -48,3 +48,77 @@ test_that("zip_to_cd has proper number of columns", { result <- ncol(zip_to_cd) expect_equal(result, 2) }) + +################################### +# Data regression checks (issues) # +################################### + +test_that("zip_code_db schema matches the compatibility contract", { + expect_equal( + names(zip_code_db), + c( + "zipcode", "zipcode_type", "major_city", "post_office_city", + "common_city_list", "county", "state", "lat", "lng", "timezone", + "radius_in_miles", "area_code_list", "population", "population_density", + "land_area_in_sqmi", "water_area_in_sqmi", "housing_units", + "occupied_housing_units", "median_home_value", "median_household_income", + "bounds_west", "bounds_east", "bounds_north", "bounds_south" + ) + ) + expect_false(anyDuplicated(zip_code_db$zipcode) > 0) + expect_true(all( + zip_code_db$zipcode_type %in% c("Standard", "PO Box", "Unique", "Military", NA) + )) +}) + +test_that("previously missing ZIP codes are present with coordinates (#19, #25, #26)", { + regression_zips <- c("97003", "91230", "96910", "00802", "96799", "96950") + found <- match(regression_zips, zip_code_db$zipcode) + expect_false(anyNA(found)) + expect_false(anyNA(zip_code_db$lat[found])) + expect_false(anyNA(zip_code_db$lng[found])) +}) + +test_that("zip_to_cd reflects post-2020 redistricting (#29)", { + # 4-digit state FIPS + district codes; the 119th-Congress relationship file + # assigns 08731 (Ocean County, NJ) to districts 02/04, not the pre-2020 03 + expect_true(all(grepl("^[0-9]{4}$", zip_to_cd$CD))) + nj_08731 <- zip_to_cd$CD[zip_to_cd$ZIP == "08731"] + expect_true(all(substr(nj_08731, 1, 2) == "34")) + expect_false("3403" %in% nj_08731) +}) + +test_that("zip_to_cd excludes Census ZZ pseudo-districts", { + expect_false(any(grepl("ZZ$", zip_to_cd$CD))) +}) + +test_that("zip_to_cd covers USPS-only ZIP codes via city derivation", { + # 00501 (Holtsville, NY - a 'unique' IRS ZIP with no ZCTA) lost coverage + # when zip_to_cd was first rebuilt from the ZCTA relationship file alone + expect_true("00501" %in% zip_to_cd$ZIP) + expect_true(all(substr(zip_to_cd$CD[zip_to_cd$ZIP == "00501"], 1, 2) == "36")) +}) + +test_that("get_cd() warns for ZIP codes with no district mapping", { + # military ZIP codes have no geographic congressional district + military_zip <- zip_code_db$zipcode[zip_code_db$zipcode_type %in% "Military"][1] + expect_warning(get_cd(military_zip), "No congressional district") +}) + +test_that("get_cd() distinguishes unknown ZIPs from known-but-unmapped ones", { + expect_warning(get_cd("99999"), "not found in zip_code_db") + # numeric input drops its leading zero and cannot match + expect_warning(get_cd(8731), "not found in zip_code_db") +}) + +test_that("get_cd() rejects vector input with an informative error", { + # silent recycling against zip_to_cd would produce wrong positional matches + expect_error(get_cd(c("08731", "10001")), "single ZIP code") +}) + +test_that("zip_data_version() reports the data release", { + meta <- zip_data_version() + expect_type(meta, "list") + expect_true(nzchar(meta$data_version)) + expect_equal(meta$zip_code_db_rows, nrow(zip_code_db)) +}) diff --git a/tests/testthat/test-03-helper-functions.R b/tests/testthat/test-03-helper-functions.R index e148e36..3988688 100644 --- a/tests/testthat/test-03-helper-functions.R +++ b/tests/testthat/test-03-helper-functions.R @@ -2,30 +2,94 @@ # Helper functions tests # ########################## -testthat::expect_equal( - normalize_zip( - c("1", "10", "101", "3420", "34205", "943032", "2340594", "23495420", "999999999", NA) - ), - c("00001", "00010", "00101", "03420", "34205", "00094", "00234", "02349", "99999", NA) -) -testthat::expect_equal( - normalize_zip( - c("1", "10", "101", "3420", "34205", "3-4205", "94-3032", "234-0594", "2349-5420", "99999-9999", NA) - ), - c("00001", "00010", "00101", "03420", "34205", "00003", "00094", "00234", "02349", "99999", NA) -) -testthat::expect_equal( - normalize_zip( - c(1, 10, 101, 3420, 34205, 943032, 2340594, 23495420, 999999999, NA) - ), - c("00001", "00010", "00101", "03420", "34205", "00094", "00234", "02349", "99999", NA) -) -# check that wrong input generates errors -testthat::expect_error( - normalize_zip(NULL), - "input must be character or numeric" -) -testthat::expect_error( - normalize_zip(list()), - "input must be character or numeric" -) +test_that("normalize_zip handles messy character input", { + expect_equal( + normalize_zip( + c("1", "10", "101", "3420", "34205", "943032", "2340594", "23495420", "999999999", NA) + ), + c("00001", "00010", "00101", "03420", "34205", "00094", "00234", "02349", "99999", NA) + ) + expect_equal( + normalize_zip( + c("1", "10", "101", "3420", "34205", "3-4205", "94-3032", "234-0594", "2349-5420", "99999-9999", NA) + ), + c("00001", "00010", "00101", "03420", "34205", "00003", "00094", "00234", "02349", "99999", NA) + ) +}) + +test_that("normalize_zip handles numeric input with leading zero restoration", { + expect_equal( + normalize_zip( + c(1, 10, 101, 3420, 34205, 943032, 2340594, 23495420, 999999999, NA) + ), + c("00001", "00010", "00101", "03420", "34205", "00094", "00234", "02349", "99999", NA) + ) +}) + +test_that("normalize_zip numeric boundary matches the character branch", { + expect_equal(normalize_zip(100000), "00010") + expect_equal(normalize_zip(100000), normalize_zip("100000")) + expect_equal(normalize_zip(99999), "99999") +}) + +test_that("normalize_zip rejects non-character, non-numeric input", { + expect_error(normalize_zip(NULL), "input must be character or numeric") + expect_error(normalize_zip(list()), "input must be character or numeric") +}) + +test_that("zip_distance preserves input pairing and order (#20)", { + # Reprex 1 from issue #20: the README example. Pre-0.3.4 the two + # distances came back swapped between the pairs. + result <- zip_distance(c("08731", "08734"), c("08901", "08005")) + expect_equal(result$zipcode_a, c("08731", "08734")) + expect_equal(result$zipcode_b, c("08901", "08005")) + # 08731 -> 08901 is the long leg (~40mi), 08734 -> 08005 the short (~8mi) + expect_gt(result$distance[1], 30) + expect_lt(result$distance[2], 15) + + # Reprex 2 from issue #20: repeated values on one side + result <- zip_distance(c("08731", "08731"), c("08731", "08005")) + expect_equal(result$distance[1], 0) + expect_gt(result$distance[2], 0) + + # Distance is symmetric and stable under row permutation + ab <- zip_distance(c("08731", "90210"), c("08005", "10001")) + ba <- zip_distance(c("90210", "08731"), c("10001", "08005")) + expect_equal(ab$distance, rev(ba$distance)) +}) + +test_that("zip_distance returns NA distance for ZIP codes without coordinates", { + # PO Box-type ZIPs have no lat/lng in zip_code_db + no_coords <- zip_code_db$zipcode[is.na(zip_code_db$lat)][1] + result <- zip_distance(no_coords, "08731") + expect_equal(nrow(result), 1) + expect_true(is.na(result$distance)) +}) + +test_that("zip_distance recycles divisible input lengths like data.frame()", { + result <- zip_distance(c("08731", "08734"), c("08901", "08005", "07762", "08731")) + expect_equal(nrow(result), 4) + expect_equal(result$zipcode_a, c("08731", "08734", "08731", "08734")) + expect_error(zip_distance(c("08731", "08734"), c("08901", "08005", "07762")), "length") +}) + +test_that("zip_distance supports meters and planar mode arguments", { + miles <- zip_distance("08731", "08901") + meters <- zip_distance("08731", "08901", units = "meters") + expect_equal(miles$distance, round(meters$distance * 0.000621371, 2), tolerance = 0.01) + # lonlat = FALSE is deprecated; it now returns a planar approximation in + # real units (historically a unit bug made it return ~0 for every pair) + expect_warning( + planar <- zip_distance("08731", "08901", lonlat = FALSE), + "deprecated" + ) + expect_equal(planar$distance, miles$distance, tolerance = 0.02) + # antimeridian pair (Guam -> Adak): the wrapped longitude difference keeps + # the planar approximation in the same ballpark as the great circle + gc <- zip_distance("96910", "99546") + expect_warning( + planar_am <- zip_distance("96910", "99546", lonlat = FALSE), + "deprecated" + ) + expect_lt(abs(planar_am$distance - gc$distance) / gc$distance, 0.35) +}) diff --git a/tests/testthat/test-04-schemas.R b/tests/testthat/test-04-schemas.R new file mode 100644 index 0000000..b5a4af1 --- /dev/null +++ b/tests/testthat/test-04-schemas.R @@ -0,0 +1,59 @@ +#################################################### +# Return-schema snapshots (forward-compat contract) # +#################################################### +# These snapshots freeze the shape of every exported function's return +# value: column names, column classes, and object class. A change here is a +# breaking API change and must be deliberate (delete the snapshot to accept). + +schema <- function(x) { + if (is.data.frame(x)) { + paste0( + class(x)[1], ": ", + paste(names(x), vapply(x, function(col) class(col)[1], ""), + sep = "=", collapse = ", " + ) + ) + } else { + paste0(class(x)[1], " length ", length(x)) + } +} + +test_that("lookup function return schemas are stable", { + expect_snapshot({ + cat("search_state: ", schema(search_state("NJ")), "\n") + cat("search_county: ", schema(search_county("Ocean", "NJ")), "\n") + cat("search_city: ", schema(search_city("Chappaqua", "NY")), "\n") + cat("search_tz: ", schema(search_tz("Eastern")), "\n") + cat("search_fips: ", schema(search_fips("34", "029")), "\n") + cat("search_cd: ", schema(search_cd("34", "02")), "\n") + cat("search_radius: ", schema(search_radius(39.9, -74.3, 5)), "\n") + cat("reverse_zipcode: ", schema(reverse_zipcode("08731")), "\n") + cat("geocode_zip: ", schema(geocode_zip("08731")), "\n") + cat("get_tracts: ", schema(get_tracts("08731")), "\n") + cat("zip_distance: ", schema(zip_distance("08731", "08901")), "\n") + cat("normalize_zip: ", schema(normalize_zip("8731")), "\n") + cat("is_zcta: ", schema(is_zcta("08731")), "\n") + }) +}) + +test_that("get_cd return shape is stable", { + result <- get_cd("08731") + expect_type(result, "list") + expect_named(result, c("state_fips", "district")) +}) + +test_that("input validation raises informative errors", { + expect_error(search_radius("a", -74.3), "lat") + expect_error(search_radius(39.9, -74.3, radius = -1), "radius") + expect_error(search_radius(139.9, -74.3), "lat") + expect_error(zip_distance("08731", "08901", units = "furlongs")) + expect_error(zip_distance(c("08731", "08734"), c("08901", "08005", "07762")), "length") +}) + +test_that("download_zip_data() is deprecated", { + # Deprecation must fire before any network access is attempted + expect_warning( + try(download_zip_data(), silent = TRUE), + "deprecated" + ) +}) diff --git a/vignettes/faq.Rmd b/vignettes/faq.Rmd new file mode 100644 index 0000000..3ed772d --- /dev/null +++ b/vignettes/faq.Rmd @@ -0,0 +1,89 @@ +--- +title: "Frequently Asked Questions" +output: rmarkdown::html_vignette +vignette: > + %\VignetteIndexEntry{Frequently Asked Questions} + %\VignetteEngine{knitr::rmarkdown} + %\VignetteEncoding{UTF-8} +--- + +```{r, include = FALSE} +knitr::opts_chunk$set(collapse = TRUE, comment = "#>") +library(zipcodeR) +``` + +## Why are the longitude values negative? + +Because the United States is in the western hemisphere. Geographic +coordinates follow a standard sign convention: latitudes north of the +equator are positive, and longitudes **west of the prime meridian are +negative**. Every location in the U.S. therefore has a negative +longitude: + +```{r} +geocode_zip("90210") +``` + +This is not an error, and you should not multiply `lng` by -1: mapping +libraries, spatial packages, and web APIs all expect this convention. + +## Why is a valid ZIP code missing from `zip_code_db`? + +Two common reasons: + +1. **Data vintage.** The bundled database is a snapshot; ZIP codes + introduced after the snapshot date will be absent until the next data + refresh. Check `zip_code_db` directly to see whether a code is + present. +2. **ZIP codes are not ZCTAs.** USPS ZIP codes are collections of + delivery routes and points, not areas. The Census Bureau's ZIP Code + Tabulation Areas (ZCTAs) approximate most — but not all — ZIP codes. + Some USPS-only codes (typically P.O. Box or single-building "unique" + codes, such as 91230 in Glendale, CA) have no ZCTA and no + Census-derived attributes, and may be missing from ZCTA-oriented + sources. Use `is_zcta()` to check whether a given ZIP code is also a + ZCTA. + +Relatedly, about 20% of the ZIP codes in `zip_code_db` (P.O. Box and +unique codes) have no `lat`/`lng` coordinates; functions such as +`zip_distance()` return `NA` distances for them. + +## How accurate is the city / county ("jurisdiction") information? + +Treat it as approximate. A ZIP code is a postal-delivery construct, not +a municipal boundary: one ZIP code can cross city, county, and even +state lines, and the USPS "city" name is the preferred *mailing* name, +which frequently differs from the municipality a given address actually +lies in (addresses just outside a city's limits often carry that city's +name). The `county` column reflects the predominant county for the ZIP +code. If you need authoritative jurisdiction assignment, geocode the +full street address against boundary files (e.g., Census TIGER/Line) +rather than relying on the ZIP code alone. + +## I get `object 'zip_code_db' not found` + +The datasets are lazy-loaded with the package. This error almost always +indicates a broken or partial installation — for example, a package +library that was migrated between R versions. Reinstalling usually +fixes it: + +```r +install.packages("zipcodeR") +``` + +If you want the data without attaching the package, use +`zipcodeR::zip_code_db`. + +## Where does the data come from? + +`zip_code_db` derives from the +[uszipcode](https://github.com/MacHu-GWU/uszipcode-project) project's +database, which aggregates U.S. Census Bureau data (decennial census, +ACS, gazetteer files) with USPS-derived ZIP code attributes. +`zcta_crosswalk` comes from the Census Bureau's 2020 ZCTA-to-tract +relationship file, and `zip_to_cd` from the Census 119th-Congress +district-to-ZCTA relationship file (with city-based assignment for +USPS-only ZIP codes). All bundled data is built by the reproducible +pipeline in the package repository's `data-raw/` directory — +`zip_data_version()` reports the loaded data release. See the help page +of each dataset for details and known limitations. diff --git a/vignettes/faq.html b/vignettes/faq.html new file mode 100644 index 0000000..44e09d6 --- /dev/null +++ b/vignettes/faq.html @@ -0,0 +1,436 @@ + + + + + + + + + + + + + + +Frequently Asked Questions + + + + + + + + + + + + + + + + + + + + + + + + + + +

Frequently Asked Questions

+ + + +
+

Why are the longitude values negative?

+

Because the United States is in the western hemisphere. Geographic +coordinates follow a standard sign convention: latitudes north of the +equator are positive, and longitudes west of the prime meridian +are negative. Every location in the U.S. therefore has a +negative longitude:

+
geocode_zip("90210")
+#> # A tibble: 1 × 3
+#>   zipcode   lat   lng
+#>   <chr>   <dbl> <dbl>
+#> 1 90210    34.1 -118.
+

This is not an error, and you should not multiply lng by +-1: mapping libraries, spatial packages, and web APIs all expect this +convention.

+
+
+

Why is a valid ZIP code missing from zip_code_db?

+

Two common reasons:

+
    +
  1. Data vintage. The bundled database is a snapshot; +ZIP codes introduced after the snapshot date will be absent until the +next data refresh. Check zip_code_db directly to see +whether a code is present.
  2. +
  3. ZIP codes are not ZCTAs. USPS ZIP codes are +collections of delivery routes and points, not areas. The Census +Bureau’s ZIP Code Tabulation Areas (ZCTAs) approximate most — but not +all — ZIP codes. Some USPS-only codes (typically P.O. Box or +single-building “unique” codes, such as 91230 in Glendale, CA) have no +ZCTA and no Census-derived attributes, and may be missing from +ZCTA-oriented sources. Use is_zcta() to check whether a +given ZIP code is also a ZCTA.
  4. +
+

Relatedly, about 20% of the ZIP codes in zip_code_db +(P.O. Box and unique codes) have no lat/lng +coordinates; functions such as zip_distance() return +NA distances for them.

+
+
+

How accurate is the city / county (“jurisdiction”) information?

+

Treat it as approximate. A ZIP code is a postal-delivery construct, +not a municipal boundary: one ZIP code can cross city, county, and even +state lines, and the USPS “city” name is the preferred mailing +name, which frequently differs from the municipality a given address +actually lies in (addresses just outside a city’s limits often carry +that city’s name). The county column reflects the +predominant county for the ZIP code. If you need authoritative +jurisdiction assignment, geocode the full street address against +boundary files (e.g., Census TIGER/Line) rather than relying on the ZIP +code alone.

+
+
+

I get object 'zip_code_db' not found

+

The datasets are lazy-loaded with the package. This error almost +always indicates a broken or partial installation — for example, a +package library that was migrated between R versions. Reinstalling +usually fixes it:

+
install.packages("zipcodeR")
+

If you want the data without attaching the package, use +zipcodeR::zip_code_db.

+
+
+

Where does the data come from?

+

zip_code_db derives from the uszipcode +project’s database, which aggregates U.S. Census Bureau data (decennial +census, ACS, gazetteer files) with USPS-derived ZIP code attributes. +zcta_crosswalk comes from the Census Bureau’s 2020 +ZCTA-to-tract relationship file, and zip_to_cd from the +Census 119th-Congress district-to-ZCTA relationship file (with +city-based assignment for USPS-only ZIP codes). All bundled data is +built by the reproducible pipeline in the package repository’s +data-raw/ directory — zip_data_version() +reports the loaded data release. See the help page of each dataset for +details and known limitations.

+
+ + + + + + + + + + + diff --git a/vignettes/geographic.html b/vignettes/geographic.html new file mode 100644 index 0000000..9e2ef5d --- /dev/null +++ b/vignettes/geographic.html @@ -0,0 +1,421 @@ + + + + + + + + + + + + + + + + +Geographic functions in zipcodeR + + + + + + + + + + + + + + + + + + + + + + + + + + +

Geographic functions in zipcodeR

+

Gavin Rozzi

+

2026-08-24

+ + + +

{zipcodeR} has introduced several new functions geared +towards geographic applications. In this vignette we will explore these +new functions and some of their typical use cases with examples.

+
+

Calculating the distance between two ZIP codes

+

This version introduces the new function zip_distance() +for calculating the distance between two ZIP codes in miles.

+
zip_distance('08731','08753')
+
##   zipcode_a zipcode_b distance
+## 1     08731     08753     9.68
+
+
+

Geocoding a ZIP code

+

Users often are seeking to use zipcodeR to geocode ZIP codes. While +it is possible to do this already, I have introduced a simple wrapper +function that only returns that centroid coordinates of each provided +ZIP code to make this process easier.

+
geocode_zip('08731')
+
## # A tibble: 1 × 3
+##   zipcode   lat   lng
+##   <chr>   <dbl> <dbl>
+## 1 08731    39.9 -74.3
+

You can also pass a vector of ZIP codes to geocode to quickly geocode +ZIP code-level data

+
geocode_zip(c('08731','08721','08753'))
+
## # A tibble: 3 × 3
+##   zipcode   lat   lng
+##   <chr>   <dbl> <dbl>
+## 1 08731    39.9 -74.3
+## 2 08721    39.9 -74.2
+## 3 08753    40.0 -74.2
+
+
+

Searching for ZIP codes within a radius

+

Given a pair of latitude / longitude coordinates in WGS84 format you +can search for all ZIP codes located within a provided radius in miles. +This function will default to searching within 1 mile of your provided +coordinates, but can be configured to search any arbitrary radius via +the radius argument.

+

The below code searches for all ZIP codes within 20 miles of the

+
search_radius(39.9, -74.3, radius = 20)
+
## # A tibble: 41 × 2
+##    zipcode distance
+##    <chr>      <dbl>
+##  1 08731       0   
+##  2 08757       5.28
+##  3 08721       5.30
+##  4 08759       5.30
+##  5 08722       5.69
+##  6 08005       6.91
+##  7 08758       7.23
+##  8 08734       7.43
+##  9 08741       7.70
+## 10 08755       7.84
+## # ℹ 31 more rows
+
+ + + + + + + + + + +