Every field on this site, where it comes from, what we did to it, and what we don't know. Cite it.
A "project" here means any housing development, transit infrastructure, zoning permit, or city capital project that we found in one of the source feeds. One row in the civic.projects table represents one real-world project, deduplicated by (datasource, external_id). The same physical site can show up multiple times across different feeds (a housing site might appear in both Affordable Housing Production and in Zoning Permits) and they get separate rows because they're tracking different things.
fiscal_year_complete is sometimes just a year value (2018); we fold to January 1 of that year. We don't have AMI breakdown, unit mix, or council district on the source rows: district + tract are joined in spatially. Status is inferred from development_type + fiscal_year_complete.B03002 series divided by total population. Some tracts have null values where the source row had no margin of error or was suppressed.ST_Intersects) and stored on the project row so we don't have to re-join at read time. Reruns of the backfill update any project whose location changed.developer: pulled from developer_name on housing rows andcontractorname on zoning rows. The same firm may appear under slightly different spellings; we don't normalize beyond trimming whitespace.
status_history: every time the scrape pipeline sees a project's status change vs the last snapshot, we record a new row. This is what powers the "stalled projects" view and the timeline on each project page. Since the table just started populating, the initial backfill stamps every project with its current status; the meaningful history accumulates from there.
search vector: a Postgres tsvector generated column over name (weight A), address + developer (weight B), and description (weight C). Queries hit the GIN index using websearch_to_tsquery so multi-word and quoted phrase searches both work.
The displacement pressure index (DPI) scores each census tract 0–100 by combining four signals, each converted to a citywide percentile and then averaged (equal weight; renormalized when a component is missing). Because every component is a percentile, DPI is a relative ranking of tracts against each other, not an absolute measurement.
Honest limits: the event windows are bounded by what the loaders actually hold — violations are effectively a recent slice (capped at 5,000 citywide) and transfers span ~18 months — so DPI reflects present pressure, not a longitudinal gentrification trajectory. Flips are matched by address (there is no parcel id in the transfer feed) and are not flagged for arm's-length, so they over- and under-count in ways we can't fully separate. Tracts under 200 residents are dropped to avoid small-number spikes. Where event counts are zero, the score is carried by rent burden alone — the hotspots list shows the raw counts so you can see which tracts those are.
All underlying data is in the public domain (City of Philadelphia, SEPTA, US Census). Code is MIT. If you cite this site in reporting, the line is:
civic-philly (civic-philly.vercel.app), accessed 2026-07-08. Aggregated from OpenDataPhilly, US Census ACS 5-year 2018-2022, and SEPTA.
Code at github.com/c-tonneslan/civic-philly. The scripts/ directory contains every loader. Anyone can run them against their own Postgres + PostGIS instance with a Census API key.