A toolkit for finding a place to live in the contiguous United States by scoring every town against a set of climate, health, environment, and access criteria — then exploring the trade-offs interactively.
It has two parts:
-
Data pipeline (
tools/generate_candidate_data.py) — builds a per-criterion scoring matrix for every Census-designated place in the lower 48 (~31,500 towns). For each criterion it produces araw_<name>column (the real-world value, e.g. inches of rain) and ascore_<name>column (0–100, higher = better fit), plus acolumn_metadata.csvdescribing every column's units, method, and source. No cross-criterion weighting happens here — the pipeline just measures each town. -
Weighting & visualization app (
index.html) — a self-contained static web page that loads the pipeline's output and lets you set the importance of each criterion with live sliders, apply hard-minimum filters, rank towns by a weighted-average composite, see the results on a US map, and open any town for a full per-criterion breakdown.
The pipeline writes to output/:
output/candidate_scores.csv— one row per town: identifying columns (place_geoid, name, state, county, lat, lon, elevation_ft, population, …) followed by araw_<name>andscore_<name>for each criterion.output/candidate_scores.parquet— same data, columnar (for downstream analysis).output/column_metadata.csv— per-column description, units, source, and method.
index.html the weighting / visualization web app
assets/ basemap data fetched by the app (coastline, borders, rivers, relief)
output/ generated scoring matrix + metadata (what the app reads)
tools/generate_candidate_data.py pipeline orchestrator / CLI
tools/publish_data.sh regenerates the data and publishes it for the live site
tools/build_basemap.py regenerates the app's basemap assets
tools/diagnose.py data-source connectivity probe
data_pipeline/ pipeline modules (fetch, places, sampling, scoring, …)
spec/ pipeline + app design specs
requirements.txt Python dependencies
DATA_VERSION the data release the live site serves
.github/workflows/pages.yml deploys the site to GitHub Pages
pip install -r requirements.txt # geopandas + rasterio pull in GDAL / PROJ
python tools/generate_candidate_data.py # full run — all ~31.5k places
python tools/generate_candidate_data.py --sample # offline self-test on synthetic data
python tools/generate_candidate_data.py --force lyme,wildfire # recompute specific criteria--sample needs only pandas/numpy/pyarrow; it validates the scoring math and output schema
without any downloads (writing to sample_run/). Each criterion's raw values are cached
under data/work/cache/, so a normal run only recomputes new or changed criteria while
scores and metadata are always rebuilt from the cached raws.
Keys and options live in a git-ignored .env:
NREL_API_KEY=... # optional sunlight API fallback
CENSUS_API_KEY=... # optional; fills the population column (https://api.census.gov/data/key_signup.html)
SUNLIGHT_MODE=raster # "raster" (sample a local GHI GeoTIFF, default) or "api" (per-point)
# CDSAPI_KEY=... # ERA5 pressure (or use ~/.cdsapirc)
Most data sources download automatically and are cached under data/raw/. Two need a
one-time setup:
- ERA5 pressure (Copernicus CDS): create a free account at
https://cds.climate.copernicus.eu, put your token in
~/.cdsapirc(orCDSAPI_KEY), and accept the dataset terms once. Hourly mean-sea-level pressure is downloaded over the CONUS box forERA5_YEARS(default 2021–2023). - Population & Lyme incidence: a free Census API key (
CENSUS_API_KEY) fills the population column and the per-capita Lyme rate; without it those are left null and everything else still runs. - Provide-once rasters: two CONUS layers are distributed only as portal/bundle
downloads, so supply them once — NLCD land cover (nature access) and an annual-mean
surface PM2.5 grid (air quality, e.g. WashU ACAG — a global GeoTIFF or the
North-America NetCDF, both sampled automatically). Drop each into
data/raw/nlcd//data/raw/pm25/or pointNLCD_RASTER/PM25_RASTERat it; each criterion is skipped gracefully if its file is absent.
If any upstream URL has rotated, drop the file into data/raw/ under the name the fetcher
expects (see data_pipeline/config.py) and the pipeline will use it.
index.html reads its data with fetch, so it must be served over http(s) — opening
the file directly from disk (file://) will not load. Serve the project folder with any
static server:
python3 -m http.server 8000 # then open http://localhost:8000/The app loads output/candidate_scores.csv, output/column_metadata.csv, and the basemap
files in assets/. It is metadata-driven: any score_<name> column present in the CSV
automatically appears as its own toggle, importance slider, map layer, distribution
histogram, and town-detail row — so regenerating the data with a new criterion needs no
changes to the app. To update what the app shows, re-run the pipeline and refresh
output/candidate_scores.csv and output/column_metadata.csv.
The basemap files (assets/basemap.json, assets/relief.png) are generated separately by
tools/build_basemap.py and only need rebuilding if the geography layers change.
The site is deployed to GitHub Pages by .github/workflows/pages.yml on every push to
main that touches index.html, assets/, or DATA_VERSION (or manually from the
Actions tab). The pipeline's output isn't in git — it's built from ~12 GB of local raw
inputs — so each data snapshot is published as a GitHub release (data-YYYYMMDD-HHMM)
with the CSVs and parquet attached, and DATA_VERSION names the release the site serves.
- Code-only changes (e.g.
index.html) go live when merged tomain. - Data updates: run
tools/publish_data.sh. It sets up.venvif needed, runs the pipeline (arguments such as--force lymeare passed through), stops if the smoke tests fail, uploads the output as a new release, and commits the newDATA_VERSION. Push or merge that commit to deploy.--skip-generatepublishes the currentoutput/as-is, and nothing is published if the CSV is unchanged from the current release. - Changes that need both (new data columns plus
index.htmlchanges that use them): runtools/publish_data.shon the feature branch so the page and its data go live in the same merge.
To roll back the data, point DATA_VERSION at an earlier data-* release and push.
Every criterion is scored 0–100, where higher is a better fit. Percentile scores are ranked across all towns. Each town keeps both the raw measured value and the score.
| Criterion | Measures | Source |
|---|---|---|
| Heat | Days per year with a daytime high above 85°F; fewer scores higher. Scored independently of cold so the two can be weighted separately — useful for part-year living, where summer heat in a winter-only base may not matter. Per-month counts are kept as raw columns to show when the heat falls. | PRISM 1991–2020 daily maximum-temperature normals (4 km) |
| Cold | Days per year with a daytime high below 50°F; fewer scores higher. Independent of heat, with per-month counts kept as raw columns. | PRISM 1991–2020 daily maximum-temperature normals (4 km) |
| Sunlight | Annual solar resource; sunnier scores higher. | Global Solar Atlas annual GHI (Solargis / World Bank) |
| Dryness | Mould-growth propensity from humidity: annual mean relative humidity, inverted so drier air scores higher (RH is the direct driver of surface/airborne mould). | PRISM 1991–2020 dewpoint/temperature normals → annual mean RH (4 km) |
| Rainfall | Wet-climate / liquid-water moisture load: annual precipitation, inverted so a drier climate scores higher. Separate low-weight axis from humidity — and not covered by sunlight (rainfall and sun are nearly uncorrelated across US towns). | PRISM 1991–2020 precipitation normals (4 km) |
| Pressure — diurnal | Day-to-day comfort for the pressure-sensitive: mean daily swing (max − min) of sea-level pressure; smaller swings score higher. | ERA5 hourly mean-sea-level pressure, 2021–2023 (Copernicus CDS) |
| Pressure — synoptic | Frontal frequency: standard deviation of daily-mean sea-level pressure; steadier scores higher (correctly flags stormy maritime regions). | ERA5 hourly mean-sea-level pressure, 2021–2023 (Copernicus CDS) |
| Air quality | Chronic fine-particulate exposure: long-term annual-mean ground-level PM2.5, sampled from a gridded surface; cleaner air scores higher. A different geography from local fire hazard (which the former episodic smoke-days metric largely duplicated). | Satellite-derived annual-mean surface PM2.5 (WashU ACAG, van Donkelaar et al., ~0.01° CONUS) |
| Wildfire | Local fire hazard: modelled annual burn probability around the town; lower scores higher. | USDA FSim 270 m burn probability, CONUS (RDS-2016-0034-3) |
| Lyme disease | Tick-borne disease risk: county Lyme incidence (reported cases per 100k). Since most counties report essentially none, zero-case counties get the top score and only endemic counties are ranked below. | CDC reported Lyme cases by county (2023) ÷ county population |
| Nature access | Access to wild land: nearness to a large protected area (GAP 1/2, ≥100 km²) combined with the fraction of natural land cover within 10 km; closer + wilder scores higher. | USGS PAD-US 4.0 + NLCD land cover (CONUS, 30 m) |
| Isolation | Regional remoteness, using terrain-aware distance: a distance-weighted count of nearby population plus the effective distance to the nearest city of ≥50k, where straight-line distance is inflated by the climb along the path. A metro behind a mountain range counts as far (a town over the San Gabriels from LA scores isolated), while a flat suburb the same distance out does not. | Census places + 2020 decennial population + Copernicus DEM GLO-90 |
| Airport | Air-travel access: great-circle distance to the nearest large- or medium-hub commercial airport with scheduled service; closer scores higher. | OurAirports (US large/medium hubs) |
| Amenities | Everyday big-box amenity access, proxied by distance to the nearest Costco warehouse; closer scores higher. | OpenStreetMap (Overpass) |
| Politics | Local political lean: Democratic share of the two-party 2024 presidential vote in the town's precinct; more Democratic-voting scores higher. Precinct-level (not county), so a left-leaning town in a right-leaning county is captured. | NYT national 2024 presidential precinct results, spatial-joined to the town centroid |
- Every raster sampler reprojects each town into the raster's own coordinate system before sampling, so points never drift into the ocean or a neighboring state.
- PRISM temperatures are converted from °C to °F before applying comfort thresholds.
- Town-to-county joins use a point-in-polygon match with a nearest-county fallback for coastal rounding; Lyme is joined on county FIPS. Connecticut's 2022 switch from counties to planning regions is handled by keying TIGER 2023, the CDC 2023 file, and ACS 2023 all on the same regions.
- Yes this is entirely vibe coded, so direct code judgments towards Claude, and AI use judgment towards me.