Skip to content

Repository files navigation

Care Access Map

A national, ZIP-level explorer of U.S. health-care access - all 32,879 ZCTAs scored from CDC PLACES, CMS NPPES, Census ACS, and HRSA HPSA, with 2SFCA spatial provider supply and a drill-down map.

screenshot

Click any ZIP to decompose its score. Re-weight the three dimensions live with sliders. Search a ZIP, or read the ranked list of worst-access areas.

git clone https://github.com/gclluch/health-access-map && cd health-access-map
make setup                                  # venv + python/node deps
cp .env.example .env                        # paste a free Census API key
make data-ca                                # CA dev slice, minutes (full `make data` needs ~25 GB)
make web                                    # map at localhost:5173

Tests need no data download at all - pytest runs 158 tests against a committed 800-ZCTA fixture.

Read the model →  ·  What it can't tell you →  ·  Methodology →


The model

It is hierarchical: one tunable Access Gap composite → 3 dimensions → scored sub-scores plus context/process indicators → ~50 measures, all drill-downable in the detail panel.

  1. Health need - chronic disease, behavioral risk, mental/social health, disability (CDC PLACES)
  2. Social vulnerability - socioeconomic, housing/transport, unmet social needs, and digital/telehealth barriers (Census ACS + PLACES SDOH)
  3. Care access - provider supply (2SFCA spatial catchment, not ZIP containment), HPSA shortage, insurance, and medical-debt burden; safety-net and preventive-use rows are displayed as context/process indicators, not scored
  4. Access Gap Score - the relative national-rank composite of the three

The map is the product: pan/zoom a cividis choropleth, click a ZIP for a decomposed detail panel, switch the coloring metric, search a ZIP, read a ranked list of worst-access areas, and re-weight the score live with sliders (recomputed client-side - no backend round-trip). The sliders are an honest sensitivity probe, not a control that rewrites the map: because the three dimensions are strongly collinear, re-weighting moves ranks by only Spearman ~0.999 / ~±6 pts (see Scoring methodology below) - that near-inertness is the finding, surfaced rather than hidden behind a knob.

Going deeper? docs/METHODOLOGY.md is the "follow the logic" guide - every design choice, its rationale, and how to extend the model safely. Then docs/PRIMER.md (dataset/field dictionary), docs/RATIONALE.md (per-formula math + precedent), docs/DECISIONS.md (the ledger of what we tried, kept, and rejected - don't re-run these), docs/VALIDATION.md (outcomes, the sub-county gate, comparability, and uncertainty), and docs/BACKLOG.md (open edges & known limitations as pick-up-ready tickets - start here if you're extending the project).


How to read this map

A relative screening lens for where health-care-access disadvantage concentrates - not an absolute verdict, an eligibility tool, or a causal claim. A score of 95 means "worse access than 95% of U.S. ZIPs," not "objectively bad." Use it to prioritize investigation, not to settle it. Because a 95 can be almost all health need or almost all no-providers - different interventions - the detail panel leads with the need-vs-access decomposition: a need-driven / access-driven / need + access profile and the per-dimension driver shares come first, with the composite demoted to a secondary "screening priority," not a how-bad verdict.

Trust it for Broad geography (regional clusters, high-gap belts, urban/rural contrasts), state-level targeting, and the dimension pattern behind a headline score.
Trust it coarsely for Decile-scale ZIP comparisons (top-decile vs middle vs low-gap). The UI leads with deciles + a 5-95 reliable rank band for this reason.
Don't trust it for Fine rank order. Two ZIPs are reliably different only ~10-15 percentile points apart (~7-10 tiers, not 33,000 ranks). If two reliable ranges overlap, treat the ZIPs as tied.

What it is. A transparent hierarchy (≈50 measures → sub-scores → 3 dimensions → one composite). Two distinct claims, kept separate: it is internally reliable (split-half 0.95 - the ~50 measures cohere; this is internal consistency, not outcome validity), and it tracks 6 independent outcomes it never ingests (CMS claims + NCHS vital records, never the BRFSS/PLACES inputs): life expectancy (+0.52), premature death (+0.49), and - against the access-sensitive ruler the field actually uses - treatable/amenable mortality net of deprivation (care-access partial r +0.419, state-blocked 95% CI [0.33, 0.46]). Read those correlations' precision, not their decimals: 5 of the 6 outcomes are county-level, broadcast to ZCTAs, so the honest sample is ~3,225 counties / ~50 state blocks - not 33k ZIPs - and every CI here is spatially clustered to match (see docs/VALIDATION.md §1, §4). Sub-county discrimination is confirmed in five states + nationally.

What it is not - and read this before acting. It is descriptive, not causal. Cross-sectionally the index is statistically indistinguishable from a poverty/deprivation map (a negative-control test returns a clean null - it does not flag deaths timely care could have prevented over those it could not), and the one temporal "access lever" signal was overturned by a cross-state falsification control. So the map tells you where access disadvantage is concentrated; it does not demonstrate that putting a clinic, coverage, or program where the index is high will change outcomes. It is also not complete access reality - capacity, Medicaid acceptance, appointment availability, hours, and true drive-time are only partly observed or proxied (see the 5 A's coverage in the methodology panel). Treat it as exploration, grant targeting, needs assessment, and hypothesis generation - not precise ranking, eligibility, or diagnosis.

The full uncertainty accounting (rank bands, selection-bias caveat, PLACES circularity bound, the complete causal ladder) lives in docs/VALIDATION.md §5-§7 and the in-product "How to read this" panel.


Quickstart

Just want the data? Don't build it.

The scored table is published as a release asset, so you do not need the pipeline, the API keys, or the 11 GB provider download to use, check, or cite this work:

# 33,791 ZIP areas x 181 documented columns
curl -LO https://github.com/gclluch/health-access-map/releases/latest/download/metrics.parquet
curl -LO https://github.com/gclluch/health-access-map/releases/latest/download/data_dictionary.csv
Asset What it is
metrics.parquet (20 MB) the scored table - every dimension, sub-score, percentile and flag
metrics.csv.gz (23 MB) the same table for anything that doesn't read parquet
data_dictionary.csv one row per shipped column: description + units, generated from taxonomy.py so it cannot drift from the model
provenance.json what each stage ingested, written by the build itself

Dropping metrics.parquet into data/processed/ is also enough to run make web and make acceptance against real national data with no pipeline run at all.

Build the pipeline instead when you want to change the model, refresh the vintages, or verify the table from source. That is what the rest of this section is for.

Prerequisites (two manual steps make setup cannot do for you)

1. tippecanoe builds the vector tiles and is neither pip- nor npm-installable. Preflight fails without it and nothing builds:

brew install tippecanoe        # or build from source: github.com/felt/tippecanoe

2. The NPPES provider file must be downloaded by hand. CMS publishes only the current month, under a filename that changes every month, so there is no stable URL to automate:

  • Go to https://download.cms.gov/nppes/NPI_Files.html
  • Download the full replacement monthly NPI file (~1 GB zip, ~11 GB unzipped)
  • Drop it in data/raw/ unrenamed - the pipeline globs NPPES_Data_Dissemination_*.zip and takes the newest

You also need a free Census API key in .env; the build aborts without one.

Build

make setup            # venv + python deps + mapshaper + frontend deps
cp .env.example .env  # then paste your Census API key

make preflight        # verifies the prerequisites above -- run this first

make data-ca          # fast California vertical slice (minutes) -- recommended first
# or
make data             # full national build (~33k ZIPs, ~1.5-3 h, ~25 GB peak disk)

make api              # FastAPI backend on :8000   (terminal 1)
make web              # Vite dev server on :5173    (terminal 2)
make acceptance       # run the acceptance suite

Validation-only targets are heavier: make causal and make fqhc-lever (the §7 causal frontier) hit live data sources. make fqhc-lever in particular streams the Texas DSHS PUDF for 2011-2019 on a fresh clone (~150-700 MB/quarter; only the small ZIP-level aggregates are cached, never the raw files), so its first run is slow. These are read-only diagnostics and never feed the shipped composite.

Requires Python ≥ 3.10, Node ≥ 18, and ~25 GB free disk for the national NPPES stage.


Architecture

data/raw/*  ──(pipeline: Python + DuckDB + mapshaper + tippecanoe)──►  metrics.parquet
                                                  zcta.pmtiles + zcta_overview.geojson (geometry)
                                                                 │
                          ┌──────────────────────────────────────┼───────────────────────────┐
                          ▼                                       ▼                            ▼
              per-ZIP3 drill-down shards           pmtiles + overview + map_frame        static files
              zcta/*.json (static)                 + subscores, copied to public          (Vite / CDN)
                          │                                       │
                          └──────────────► React + deck.gl + MapLibre map ◄───────────────────┘

Deployment model: static-only. The deployed app is entirely static files (map frame, sub-scores, geometry, and pre-built per-ZIP3 drill-down shards) - there is no live backend in production; the frontend reads shards directly (frontend/src/lib/api.ts). backend/ (FastAPI over the in-memory metrics table) is a dev/test convenience - handy for local queries and exercised by tests/test_backend.py - not part of the prod data path.

  • DuckDB streams the ~11 GB NPPES CSV (projecting 3 columns) - never loaded into pandas.
  • SQLite/Postgres rejected: 33k rows of attribute lookups fit trivially in memory.
  • Hybrid geometry: a small all-ZCTA overview (mapshaper, heavily simplified) keeps the national choropleth dense at low zoom, while detail streams from range-requested PMTiles vector tiles (tippecanoe) at z>=6 - so cold-load geometry and resident memory stay bounded.
  • Base metrics precomputed server-side; the Access Gap is recomputed client-side from the stored component percentiles, which is what makes the weight sliders instant.

See pipeline/ for the stages and data/processed/provenance.json for the exact dataset ids and vintages each run resolved.


Data sources & vintages

Layer Source Notes
Disease & health need CDC PLACES, ZCTA GIS-Friendly (2025 release, kee5-23sr) Crude prevalence across ~30 measures - chronic disease (diabetes, CHD, COPD, …), behavioral risk, mental/social distress, disability, plus SDOH + preventive-care use. Dataset id resolved + asserted at runtime.
Provider supply CMS NPPES monthly full file Individuals only (Entity Type 1); taxonomy classified via the NUCC crosswalk.
Economic / insurance Census ACS 5-year (2023) Variable codes resolved by label from variables.json; uninsured summed from the B27001 group in one call.
Geometry Census TIGER cb_2020_us_zcta520_500k The only vintage that publishes ZCTA cartographic boundaries; field ZCTA5CE20.
Human geography Census ZCTA→county relationship (2020) + NPPES County name from the relationship file (dominant by land area); city is the modal provider city from NPPES; full state name + median age for context.

Vintage alignment: PLACES, ACS, and TIGER are all kept on the 2020 ZCTA basis so the join doesn't silently drop ZCTAs that were renumbered between the 2010 and 2020 vintages.


Scoring methodology

A hierarchy (SVI method - percentile-rank, average, re-rank at each level so every node is a uniform 0-100 "higher = worse"). See docs/METHODOLOGY.md for the full logic.

  1. Each measure (~50) is oriented (higher = worse access) and percentile-ranked nationally (ordinal → immune to the heavy right-skew of provider density / income).
  2. Sub-scores = re-ranked mean of their available member percentiles. Some rows are computed and displayed but unscored when validation says they are context/process measures rather than upstream barriers: safetynet_access is wrong-signed within counties, and preventive_use is realized care use rather than a scored barrier (see docs/VALIDATION.md).
  3. Dimensions (3) = re-ranked mean of their sub-scores: health need, social vulnerability, care access.
  4. Access Gap = 0.35·need + 0.30·vulnerability + 0.35·care-access (default; a conceptual value judgment, as in County Health Rankings). The client sliders re-weight live from the stored dimension percentiles. A multiplicative "coincidence" lens (weighted geometric mean - lights up only where need and barriers coincide) is selectable alongside the additive default.
  5. A ZCTA is scoreable only with population present and ≥ 2 of 3 dimensions; otherwise it renders gray. Low-population ZCTAs are flagged low_confidence and kept out of headline rankings. A 2-of-3 (partial) composite matches the scale of a full score but not the estimand - and the partial set is systematically rural/tiny/low-confidence (MNAR; see docs/VALIDATION.md §6d) - so it is also held out of the reliable rank band on composite-family lenses, desaturated on the map, and labelled "partial" in the detail panel, rather than co-ranked with full scores.

The three dimensions are strongly collinear (need↔vulnerability 0.73, need↔access 0.66, vulnerability↔access 0.59; reported in provenance.json and the methodology panel). At the dimension level PC1 explains 78% of the joint variance and the participation ratio is ~1.6 effective dimensions - the index is closer to one "general deprivation" gradient than to three independent axes. Two consequences, both stated in-product: (a) the weighted sum double-counts shared variance, which is why the weights are user-tunable rather than presented as truth; and (b) because the dimensions move together, re-weighting barely moves ranks (Spearman ~0.999, ~±6 pts) - so the sliders are an honest sensitivity probe, not a knob that rewrites the map. (An earlier draft cited "~0.5" here; the live build is higher - see the bootstrap-gate note in docs/VALIDATION.md.)


Limitations (read this - integrity hidden is integrity absent)

This tool can mislead about real communities. Each flaw below is stated plainly in the in-app "How to read this" panel as well:

  • Relative, not absolute. A score of 95 means "worse access than 95% of U.S. ZIPs," not "objectively bad." Absolute values are shown beside every percentile.
  • Modeled disease estimates. PLACES is a model partly conditioned on socioeconomic structure, so the disease↔poverty correlation partly recovers the model's own assumptions - not two independent measurements confirming each other.
  • Registered providers ≠ capacity. An NPI is not an FTE and says nothing about Medicaid/uninsured acceptance. Supply uses an E2SFCA variable/adaptive spatial catchment (not ZIP containment), which fixed the urbanicity artifact; but it remains straight-line, not drive-time, and counts registrations, not active accepting capacity.
  • Small-area noise. Low-population ZCTAs have wide ACS margins of error; they are flagged and de-emphasized.
  • Different vintages & universes. NPPES (this month), ACS (centered ~2-3 yrs back), PLACES (a BRFSS year) describe different times and populations (adults 18+, civilian noninstitutionalized, total). Recorded in provenance.json.
  • Ecological fallacy / age. Area patterns are not individual-level facts; crude prevalence reflects age mix.

HRSA HPSA as an independent validation anchor is intentionally out of v1: HPSA is itself a scored input (the shortage_designation care sub-score), so it shares inputs with the score - it can only be a consistency check against the federal definition, not independent ground truth.


Project layout

pipeline/      ETL stages (config, preflight, build_*, join_and_score, run)
backend/       FastAPI over the in-memory metrics table (dev/test only; not the prod data path)
frontend/      Vite + React + TS + MapLibre + deck.gl
data/          raw/ (gitignored downloads) + processed/ (gitignored outputs)
tests/         acceptance suite (definition of done)

Volatile identifiers (PLACES dataset id, ACS variable codes, NPPES/NUCC/TIGER links) are resolved and asserted at runtime so drift fails loudly at a validation gate rather than silently producing a wrong column.


Production & ops

  • CI (.github/workflows/ci.yml): pytest (pipeline + backend), frontend typecheck + Vitest unit + production build, and a Playwright smoke/compare e2e on a tiny fixture. Data-level acceptance (make acceptance) runs against a real build, gated pre-deploy.
  • Deploy (docs/DEPLOY.md): the production app is static-only (map frame, sub-scores, geometry, and per-ZIP3 drill-down shards served as static files - no live backend). docker compose up additionally runs the FastAPI backend + an nginx /api proxy for local/self-host use; CORS is env-driven (ALLOWED_ORIGINS).
  • Gate with error bars (make gate): pipeline.bootstrap_gate puts 95% CIs (cluster bootstrap over county, paired) on every diagnostics margin - ship only if the relevant CI excludes 0. It also runs the amenable-mortality focus: care_access partial r vs CDC WONDER treatable mortality (age-adjusted, 0-74, 2016-2020) is +0.419 (county-clustered CI [0.39, 0.45]; state-blocked, the conservative spatial bound, [0.33, 0.46]) - strong and net of the deprivation gradient, vs only +0.084 against all-cause life expectancy. This is the field's gold-standard validation and confirms the care-access dimension is descriptively sound (docs/VALIDATION.md §4). Re-run with make amenable.
  • Descriptive, not (yet) prescriptive - the honest causal ceiling (docs/VALIDATION.md §7). The partial-r above says the access dimension correlates with treatable mortality net of deprivation; it does not say access is an actionable lever. We tested that directly: a cross-sectional negative-control (§7a) is null, and a cross-state difference-in-differences around the 2014 ACA expansion - New York (expanded) vs Texas (never expanded, the falsification control) - does not support a causal lever (the high-barrier ACSC decline appears in both states, so it was secular convergence, not the expansion; §7e). So this is a well-validated map of where access is poor, not a demonstrated tool for fixing it - stated here because integrity hidden is integrity absent.
  • Observability: lib/observability.ts - env-gated, dependency-free error + usage hooks (VITE_SENTRY_DSN, VITE_ANALYTICS_URL); no-ops when unset.
  • Freshness: the pipeline emits frontend/public/meta.json; the UI shows a "data as of" badge.

Roadmap / honestly not done yet

  • Full time dimension. The app is a single snapshot. A display-only poverty-rank trend across two ACS vintages ships (make trends; shown in the tooltip), but a true multi-dimension trend view needs historical ACS/PLACES/NPPES re-run and stored per year - a real pipeline effort. PLACES year-over-year is also model-drift-contaminated and NPPES history is ~1 GB/month.
  • Drive-time E2SFCA (vs the straight-line adaptive catchment) and the acceptability (Medicaid/new-patient acceptance) axis remain open - the latter was tested and collapses in partial-r (make acceptability); see docs/METHODOLOGY.md §10 and docs/DECISIONS.md.

About

National ZIP-level (ZCTA) US health-care-access explorer: hierarchical Access Gap score from CDC PLACES, CMS NPPES, Census ACS; 2SFCA spatial supply; drill-down map.

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages