Maps and analysis of NFL team territories and fan bases across the United States, based on proximity to NFL stadiums.
The project is being modernized in vertical slices — each slice cuts through the whole stack and leaves the app working. This is slice 0a: the relational schema, the data-pipeline framework, and a guarded ingest, all proven to reproduce the legacy map exactly.
Requires uv and Python 3.12+ (uv will fetch the
interpreter for you).
git clone https://github.com/poulh/nflMaps.git
cd nflMaps
uv sync --all-extrasBuild the database and compute a territory map:
uv run alembic upgrade head # create the schema
uv run nflmaps ingest # load the legacy CSV/JSON inputs
uv run nflmaps territory # compute and store a territory runFetch external data (re-runnable at any time):
uv run nflmaps pipeline list
uv run nflmaps pipeline run census_pop_centers
uv run nflmaps pipeline run census_pop_centers --force # re-download
uv run nflmaps pipeline run census_pop_centers --dry-run # parse onlyRun the tests:
uv run pytestmain.py still works and is deliberately untouched, so the old and new pipelines
can be compared. It needs one file that is not committed:
curl -L -o geojson-counties-fips-pretty.json \
https://raw.githubusercontent.com/plotly/datasets/master/geojson-counties-fips.json
uv run python -c "import main; main.show_nfl_map()"Note that plain python main.py also recomputes and overwrites
closest_team_to_each_county.csv, which takes minutes and dirties your git
status. The import form above just renders the cached data.
src/nflmaps/
models/ Pydantic domain models — geography, sports, statistics, territory
db/ SQLAlchemy tables and session management
pipelines/ Re-runnable external data sources (fetch → parse → validate → load)
ingest/ Loading the original CSV/JSON inputs
territory/ Distance and county-to-team assignment
reference/ Static reference data (state FIPS codes)
guards.py Explicit join guards
geo.py Coordinate primitives and geodesic distance
Four bounded areas meet at narrow seams so that work on one does not ripple through the others.
Teams have stable slug primary keys. The legacy pipeline identified a team by
its row position in an alphabetically-sorted CSV (main.py:169). Renaming a
team would have silently repointed every county on the map to the wrong team.
Franchises are separate from team identities. Oakland Raiders and
Las Vegas Raiders are one franchise with two dated identities — which is why the
legacy data could contain both and produce 33 stadiums for 32 teams.
Statistics are stored long, not wide. One row per (county, statistic, year).
Adding income or TV-market data is an insert, never a migration. The legacy layout
was wide, which is why laucnty16.csv was never read and only one of
county_population.csv's ~50 columns was used.
Every join is guarded. Unmatched rows are either an error or an explicitly acknowledged exclusion with a stated reason — never a silent omission. All three of the legacy pipeline's silent data losses are now declared and count-checked.
Territory assignments store distance and rank. Storing the distance is what makes incremental recalculation possible: adding a team in Portland only has to beat each county's stored distance, so Tampa Bay is never recomputed. Storing the top 5 makes team removal incremental and contested markets visible.
Runs record their parameters. Centroid type, allegiance model, and loyalty factor are stored per run, so two maps can be compared knowing how they differ. The legacy state-loyalty weighting was commented-out code with no record of the value ever used.
Two disciplines, applied to two kinds of code:
- Characterization tests (
-m characterization) pin behavior the legacy pipeline produced. They exist to stay green through refactors. The acceptance gate is a parity test asserting that all 3,142 counties get the same team and the same distance as the committed legacy output. These are updated only when a behavior change is deliberate — never as routine cleanup, because that destroys their value as evidence. - TDD for new features, where there is no existing behavior to preserve.
Coverage is a ratchet: fail_under in pyproject.toml may only ever go up.
Lowering it to make a build pass is how a coverage gate becomes decoration.
- Shared venues break ties alphabetically. The Jets and the Rams currently own zero counties each — not for any geographic reason, but because "Giants" sorts before "Jets" and "Chargers" before "Rams" at MetLife and SoFi. Faithfully reproduced for parity; needs a deliberate fix.
- Reference data is stale.
Washington Redskins,University of Phoenix Stadium,Heinz Field,CenturyLink Fieldand others. Preserved on purpose in this slice so the parity test means something; corrected in slice 1 from nflverse. - County vintage mismatches. Geography uses Connecticut's 8 legacy counties;
the Census replaced these with 9 planning regions in 2022, so any 2023+ Census
series will not join for CT. Likewise Alaska split Valdez-Cordova Census Area
(
02261) into Chugach (02063) and Copper River (02066) in 2019, which this project's geography predates. Both are surfaced by the population-centre pipeline's join guard rather than silently dropped. - Territory computation is not yet optimized. Still O(counties × teams) geodesic calls, though now ~4s rather than minutes. Slice 2 addresses this.
| Source | Used for |
|---|---|
| US Census — Centers of Population (2020) | Population-weighted county centroids |
stadiums.json, team_colors.dat |
Legacy stadium locations and display colors |
county_geo_center.csv |
County geographic centroids |
county_population.csv, laucnty16.csv |
Population and unemployment (slice 3) |
M.I.T. — see LICENSE.md.