Analysis

44 - Route Population Reach

Coverage: Coverage window unavailable for this page.

Built 2026-06-15 11:52 UTC · Commit e5cf673

Page Navigation

Analysis Navigation

Data Provenance

flowchart LR
  44_route_population_reach(["44 - Route Population Reach"])
  t_stops[("stops")] --> 44_route_population_reach
  01_data_ingestion[["Data Ingestion"]] --> t_stops
  u1_01_data_ingestion[/"data/routes_by_month.csv"/] --> 01_data_ingestion
  u2_01_data_ingestion[/"data/PRT_Current_Routes_Full_System_de0e48fcbed24ebc8b0d933e47b56682.csv"/] --> 01_data_ingestion
  u3_01_data_ingestion[/"data/Transit_stops_(current)_by_route_e040ee029227468ebf9d217402a82fa9.csv"/] --> 01_data_ingestion
  u4_01_data_ingestion[/"data/PRT_Stop_Reference_Lookup_Table.csv"/] --> 01_data_ingestion
  u5_01_data_ingestion[/"data/average-ridership/12bb84ed-397e-435c-8d1b-8ce543108698.csv"/] --> 01_data_ingestion
  t_route_stops[("route_stops")] --> 44_route_population_reach
  01_data_ingestion[["Data Ingestion"]] --> t_route_stops
  t_routes[("routes")] --> 44_route_population_reach
  01_data_ingestion[["Data Ingestion"]] --> t_routes
  t_census_tracts[("census_tracts")] --> 44_route_population_reach
  d1_44_route_population_reach(("geopandas (lib)")) --> 44_route_population_reach
  d2_44_route_population_reach(("shapely (lib)")) --> 44_route_population_reach
  d3_44_route_population_reach(("polars (lib)")) --> 44_route_population_reach
  d4_44_route_population_reach(("matplotlib (lib)")) --> 44_route_population_reach
  classDef page fill:#dbeafe,stroke:#1d4ed8,color:#1e3a8a,stroke-width:2px;
  classDef table fill:#ecfeff,stroke:#0e7490,color:#164e63;
  classDef dep fill:#fff7ed,stroke:#c2410c,color:#7c2d12,stroke-dasharray: 4 2;
  classDef file fill:#eef2ff,stroke:#6366f1,color:#3730a3;
  classDef api fill:#f0fdf4,stroke:#16a34a,color:#14532d;
  classDef pipeline fill:#f5f3ff,stroke:#7c3aed,color:#4c1d95;
  class 44_route_population_reach page;
  class t_census_tracts,t_route_stops,t_routes,t_stops table;
  class d1_44_route_population_reach,d2_44_route_population_reach,d3_44_route_population_reach,d4_44_route_population_reach dep;
  class u1_01_data_ingestion,u2_01_data_ingestion,u3_01_data_ingestion,u4_01_data_ingestion,u5_01_data_ingestion file;
  class 01_data_ingestion pipeline;

Findings

Findings: Route Population Reach

Summary

Each PRT route's resident population reach was estimated by buffering its stops by ¼ mile (bus) or ½ mile (rail/incline), dissolving the buffers per route, and apportioning ACS 5-year (2018–2022) tract population to those walksheds via areal interpolation. The top-reaching routes are concentrated in the Pittsburgh core: light-rail RED (58k), bus 69 (55k), light-rail SLVR (53k), and the 54 / 61A / 61C corridors all exceed 48,000 residents within walking distance.

Key Numbers

  • 100 routes scored across the PRT system using 6,466 stops and 669 census tracts in Allegheny + Beaver + Butler + Washington + Westmoreland counties (5-county population: 2.17M).
  • Median route reach: 21,393 residents. Mean: 23,943.
  • Top route by total reach: RED Line (LRT) — 58,425 residents across 59 stops.
  • Top bus route by total reach: Route 69 — 55,088 residents across 209 stops.
  • Most efficient reach (population per stop): Mon Incline (2,347), then Duquesne Incline (1,343), then BLUE Line (1,028). Bus routes top out at ~310 residents/stop (61C).
  • Lowest reach: DQI (2,686), O1 (3,015), 18 Manchester (4,169) — short or specialty routes.

Observations

  • Light rail is structurally efficient. Despite having far fewer stops than the busiest bus routes (~45–60 vs ~200), the RED, SLVR, and BLUE lines reach as many or more residents because the larger ½-mile rail walkshed and the South Hills' density combine to broaden each station's catchment.
  • Bus reach concentrates on the Oakland–East End corridor and cross-town spines. The 61A/B/C/67 group (Oakland trunk to Squirrel Hill / Homestead / Swissvale) and routes 54 (north-south crosstown) and 75 (Oakland–Bloomfield) all exceed 43,000 — these are the routes whose disruptions affect the most people.
  • Inclines have the highest population per stop (2,000+ for the Mon Incline) because they sit in dense Mt. Washington / South Side neighborhoods, but absolute reach is small (only 2 stops each).
  • Route length and stop count predict, but do not determine, reach. Route 18 (Manchester) has 43 stops and reaches only 4,169 — its corridor is in low-density industrial North Side. Route O1 has 7 stops and reaches 3,015 — typical of express park-and-ride routes. Density of the served corridor matters as much as the size of the route.

Caveats

  • Walkshed buffers are circular Euclidean (¼ / ½ mile straight-line), not network walking distance. Real walksheds are smaller and irregular — actual usable reach is overstated, especially in areas with rivers, hillsides, or cul-de-sacs (much of Pittsburgh).
  • Areal interpolation assumes uniform population within each tract. Tracts in Pittsburgh are small in the urban core but large at the periphery; for outlying tracts (Beaver, Butler), a small overlap may apportion population from areas with no actual residents.
  • Reach is not ridership. A route can pass through dense neighborhoods that don't ride it (e.g., affluent areas with high car ownership). For demand-weighted impact, see Analysis 22 (Delay Burden).
  • Routes are not mutually exclusive. Summing population_served across all routes (~2.39M) double-counts residents served by multiple routes — the figure exceeds the 5-county population and is not a valid system total.
  • ACS population is 2018–2022 averages. Post-pandemic shifts (e.g., downtown depopulation) are partially captured but lag current conditions.

Validation

  • Data source verified. TIGER 2022 tract polygons and ACS 5-year B01003_001E pulled live from census.gov; 5-county total of 2.17M matches published Census estimates (Allegheny 1.25M + adjacent ~0.9M).
  • Geographic scope matches. All routes' stops fall within the 5-county tract set; no walkshed extends past the loaded tract polygons.
  • Null/missing handling. Stops with NULL lat/lon excluded (none observed in current stops table). Tracts with population NULL contribute zero.
  • Aggregates sanity-checked. Top routes are well-known dense-corridor service (Oakland trunk, Mon Valley LRT). Lowest-reach routes are short specialty/express routes — direction of effect matches expectation.
  • Surprising results investigated. Light rail outranking the busiest bus route was checked: RED has 59 stops with the larger ½-mile rail buffer, giving it 27.7 km² of walkshed vs. 23.4 km² for bus 69 — the result is consistent with the methodology.

Output

Methods

Methods: Route Population Reach

Question

Which PRT routes serve the most residents? That is, for each route, how many people live within walking distance of any stop on the route, and how do routes rank on this measure?

Approach

  • Build a stop-level point geometry from stops.latitude / stops.longitude (WGS84, EPSG:4326), reprojected to a meter-based CRS appropriate for Allegheny County (EPSG:32617, UTM 17N).
  • Buffer each stop by 400 m (≈¼ mile, the standard transit walkshed for bus stops). For rail stops (mode = RAIL, INCLINE), use 800 m (≈½ mile).
  • Dissolve all buffers belonging to a single route (joined via route_stops) into one route-level walkshed polygon, so overlapping stop buffers on the same route are not double-counted.
  • Intersect each route walkshed with 2020 census tract polygons (ACS 5-year 2018–2022 vintage, Allegheny County + adjacent counties). For each tract, compute the share of tract land area covered by the walkshed and apportion tract population by that share (areal interpolation).
  • Sum apportioned population across tracts to produce a population_served value per route.
  • Report alongside stop_count, route_length_km, and population_per_stop to distinguish routes that reach many people because they are long vs. because they traverse dense areas.
  • Rank routes; produce both a system-wide table and a top-25 chart. Stratify by mode (BUS vs. RAIL/INCLINE) since the buffer radius differs.

Data

Name Description Source
stops Stop coordinates and mode prt.db table
route_stops Links routes to stops prt.db table
routes Route mode (BUS/RAIL/LRT/INCLINE) for buffer-radius selection prt.db table
census_tracts 2020 TIGER/Line tract polygons + ACS 5-year (2018–2022) B01003_001E total population, Allegheny + Washington + Westmoreland + Beaver + Butler counties. Materialized into prt.db by a new ingestion step at pipeline/10_census_tracts/. Raw shapefiles + ACS pulls cached under data/census-tracts/. New prt.db table (US Census source)

Filters:

  • Exclude stops with NULL lat/lon.
  • Exclude tracts with zero land area (water-only tracts).

Output

  • output/route_population_reach.csv -- one row per route: route_id, mode, stop_count, route_length_km, walkshed_area_km2, population_served, population_per_stop.
  • output/route_population_reach_top25.png -- horizontal bar chart of the top 25 routes by population_served, colored by mode.
  • output/walkshed_map.png -- map showing the union of all route walksheds over Allegheny County tract population density, for visual sanity-check.

Source Code

"""Analysis 44: rank PRT routes by resident population within walking distance of their stops.

Buffers each stop, dissolves per route, areal-interpolates against ACS tract population.
"""

from pathlib import Path

import geopandas as gpd
import matplotlib.pyplot as plt
import polars as pl

from prt_otp_analysis.common import (
    MODE_COLORS,
    output_dir,
    phase,
    run_analysis,
    save_chart,
    save_csv,
    setup_plotting,
)
from prt_otp_analysis.walksheds import (
    build_route_walksheds,
    load_stops_with_routes,
    load_tracts,
    population_per_route,
)

HERE = Path(__file__).resolve().parent
OUT = output_dir(HERE)


def assemble(
    stops_routes_df: pl.DataFrame,
    walksheds: gpd.GeoDataFrame,
    pop_df: pl.DataFrame,
) -> pl.DataFrame:
    """Combine per-route stop count, walkshed area, and population into the final table."""
    stop_counts = (
        stops_routes_df.group_by("route_id")
        .agg(stop_count=pl.col("stop_id").n_unique(), mode=pl.col("mode").first())
    )
    ws_df = pl.from_pandas(walksheds[["route_id", "walkshed_area_km2"]])
    out = (
        stop_counts.join(ws_df, on="route_id")
        .join(pop_df, on="route_id", how="left")
        .with_columns(
            population_served=pl.col("population_served").fill_null(0).round(0).cast(pl.Int64),
        )
        .with_columns(
            population_per_stop=(pl.col("population_served") / pl.col("stop_count")).round(0).cast(pl.Int64),
        )
        .sort("population_served", descending=True)
        .select(
            "route_id", "mode", "stop_count",
            "walkshed_area_km2", "population_served", "population_per_stop",
        )
    )
    return out


def chart_top_routes(df: pl.DataFrame, n: int = 25) -> None:
    """Horizontal bar chart of top-N routes by population served, colored by mode."""
    top = df.head(n).reverse()
    fig, ax = plt.subplots(figsize=(9, 8))
    colors = [MODE_COLORS.get(m, MODE_COLORS["UNKNOWN"]) for m in top["mode"].to_list()]
    ax.barh(top["route_id"].to_list(), top["population_served"].to_list(), color=colors)
    ax.set_xlabel("Residents within walking distance")
    ax.set_title(f"Top {n} PRT routes by population served")
    ax.xaxis.set_major_formatter(lambda x, _: f"{int(x):,}")
    handles = [
        plt.Rectangle((0, 0), 1, 1, color=MODE_COLORS[m])
        for m in ("BUS", "RAIL", "INCLINE")
    ]
    ax.legend(handles, ["Bus", "Rail (LRT)", "Incline"], loc="lower right")
    save_chart(fig, OUT / "route_population_reach_top25.png")


def chart_walkshed_map(walksheds: gpd.GeoDataFrame, tracts: gpd.GeoDataFrame) -> None:
    """Sanity-check map: union of walksheds over tract population density."""
    tracts = tracts.copy()
    tracts["pop_density"] = tracts["population"].fillna(0) / (tracts["land_area_m2"] / 1_000_000)
    union = walksheds.geometry.union_all()
    fig, ax = plt.subplots(figsize=(10, 10))
    tracts.plot(
        column="pop_density",
        cmap="Greys",
        ax=ax,
        edgecolor="white",
        linewidth=0.2,
        legend=True,
        legend_kwds={"label": "Population density (per km²)", "shrink": 0.5},
    )
    gpd.GeoSeries([union], crs=tracts.crs).plot(
        ax=ax, facecolor=MODE_COLORS["BUS"], alpha=0.35, edgecolor="none"
    )
    ax.set_title("PRT system walkshed (all routes) over tract population density")
    ax.set_axis_off()
    save_chart(fig, OUT / "walkshed_map.png")


@run_analysis(44, "Route Population Reach")
def main() -> None:
    setup_plotting()

    with phase("Loading stops, routes, and tracts"):
        stops_routes_df = load_stops_with_routes()
        print(f"  {stops_routes_df['route_id'].n_unique()} routes, "
              f"{stops_routes_df['stop_id'].n_unique()} unique stops")
        tracts = load_tracts()
        print(f"  {len(tracts)} census tracts")

    with phase("Building per-route walksheds"):
        walksheds = build_route_walksheds(stops_routes_df)
        print(f"  {len(walksheds)} route walksheds; "
              f"total area {walksheds['walkshed_area_km2'].sum():.1f} km²")

    with phase("Areal interpolation against tract population"):
        pop_df = population_per_route(walksheds, tracts)
        print(f"  computed for {len(pop_df)} routes")

    with phase("Assembling output"):
        out = assemble(stops_routes_df, walksheds, pop_df)
        save_csv(out, OUT / "route_population_reach.csv")
        print("\n  Top 10 routes by population served:")
        for row in out.head(10).iter_rows(named=True):
            print(f"    {row['route_id']:>5s}  {row['mode']:<7s}  "
                  f"{row['population_served']:>8,}  ({row['stop_count']} stops)")

    with phase("Charting"):
        chart_top_routes(out)
        chart_walkshed_map(walksheds, tracts)


if __name__ == "__main__":
    main()

Sources

NameTypeWhy It MattersOwnerFreshnessCaveat
stops table Primary analytical table used in this page's computations. Produced by Data Ingestion. Updated when the producing pipeline step is rerun. Coverage depends on upstream source availability and ETL assumptions.
Upstream sources (5)
  • file data/routes_by_month.csv — Monthly route OTP source table in wide format.
  • file data/PRT_Current_Routes_Full_System_de0e48fcbed24ebc8b0d933e47b56682.csv — Current route metadata and mode classifications.
  • file data/Transit_stops_(current)_by_route_e040ee029227468ebf9d217402a82fa9.csv — Current stop-to-route coverage and trip counts.
  • file data/PRT_Stop_Reference_Lookup_Table.csv — Historical stop reference file with geography attributes.
  • file data/average-ridership/12bb84ed-397e-435c-8d1b-8ce543108698.csv — Average ridership by route and month.
route_stops table Primary analytical table used in this page's computations. Produced by Data Ingestion. Updated when the producing pipeline step is rerun. Coverage depends on upstream source availability and ETL assumptions.
Upstream sources (5)
  • file data/routes_by_month.csv — Monthly route OTP source table in wide format.
  • file data/PRT_Current_Routes_Full_System_de0e48fcbed24ebc8b0d933e47b56682.csv — Current route metadata and mode classifications.
  • file data/Transit_stops_(current)_by_route_e040ee029227468ebf9d217402a82fa9.csv — Current stop-to-route coverage and trip counts.
  • file data/PRT_Stop_Reference_Lookup_Table.csv — Historical stop reference file with geography attributes.
  • file data/average-ridership/12bb84ed-397e-435c-8d1b-8ce543108698.csv — Average ridership by route and month.
routes table Primary analytical table used in this page's computations. Produced by Data Ingestion. Updated when the producing pipeline step is rerun. Coverage depends on upstream source availability and ETL assumptions.
Upstream sources (5)
  • file data/routes_by_month.csv — Monthly route OTP source table in wide format.
  • file data/PRT_Current_Routes_Full_System_de0e48fcbed24ebc8b0d933e47b56682.csv — Current route metadata and mode classifications.
  • file data/Transit_stops_(current)_by_route_e040ee029227468ebf9d217402a82fa9.csv — Current stop-to-route coverage and trip counts.
  • file data/PRT_Stop_Reference_Lookup_Table.csv — Historical stop reference file with geography attributes.
  • file data/average-ridership/12bb84ed-397e-435c-8d1b-8ce543108698.csv — Average ridership by route and month.
census_tracts table Primary analytical table used in this page's computations. Project pipeline owner not linked. Refresh cadence unknown. Coverage depends on upstream source availability and ETL assumptions.
geopandas dependency Runtime dependency required for this page's pipeline or analysis code. Open-source Python ecosystem maintainers. Version pinned by project environment until dependency updates are applied. Library updates may change behavior or defaults.
shapely dependency Runtime dependency required for this page's pipeline or analysis code. Open-source Python ecosystem maintainers. Version pinned by project environment until dependency updates are applied. Library updates may change behavior or defaults.
polars dependency Runtime dependency required for this page's pipeline or analysis code. Open-source Python ecosystem maintainers. Version pinned by project environment until dependency updates are applied. Library updates may change behavior or defaults.
matplotlib dependency Runtime dependency required for this page's pipeline or analysis code. Open-source Python ecosystem maintainers. Version pinned by project environment until dependency updates are applied. Library updates may change behavior or defaults.