#> 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:
+
+- 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.
+- 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.
+
+
+
+
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.
+
+
## # 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
+
+
+
+
+
+
+
+
+
+
+
+