Analysis

04 - Tract Equity

Core OTP Patterns

Coverage: 2019-01 to 2025-11 (from otp_monthly).

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

Page Navigation

Analysis Navigation

Data Provenance

flowchart LR
  04_neighborhood_equity(["04 - Tract Equity"])
  t_otp_monthly[("otp_monthly")] --> 04_neighborhood_equity
  01_data_ingestion[["Data Ingestion"]] --> t_otp_monthly
  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")] --> 04_neighborhood_equity
  01_data_ingestion[["Data Ingestion"]] --> t_route_stops
  t_routes[("routes")] --> 04_neighborhood_equity
  01_data_ingestion[["Data Ingestion"]] --> t_routes
  t_stops[("stops")] --> 04_neighborhood_equity
  01_data_ingestion[["Data Ingestion"]] --> t_stops
  t_census_tracts[("census_tracts")] --> 04_neighborhood_equity
  d1_04_neighborhood_equity(("polars (lib)")) --> 04_neighborhood_equity
  d2_04_neighborhood_equity(("geopandas (lib)")) --> 04_neighborhood_equity
  d3_04_neighborhood_equity(("shapely (lib)")) --> 04_neighborhood_equity
  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 04_neighborhood_equity page;
  class t_census_tracts,t_otp_monthly,t_route_stops,t_routes,t_stops table;
  class d1_04_neighborhood_equity,d2_04_neighborhood_equity,d3_04_neighborhood_equity 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: Tract Equity

Summary

Across 247 ranked tracts (vs 89 hand-curated neighborhoods previously), the spread between the best- and worst-served tracts is 27 percentage points of OTP. The lowest-income tract quintile experiences a trip-weighted OTP of 65.2%, vs 68.8% for the second-richest quintile -- a ~3.6 pp gradient that previous neighborhood-level analyses missed. The richest quintile drops back to 67.2%, so the relationship is non-monotonic (a U-shape inverted toward the upper-middle), but low-income tracts are unambiguously the worst-served.

What changed

  • Replaced fuzzy stops.hood (NULL for ~58% of stops, 89 hand-curated areas) with point-in-polygon assignment to TIGER 2022 census tracts. All 6,466 stops now have a tract assignment (up from ~2,706 with a hood). 343 tracts contain at least one PRT stop; 247 are served by 2+ routes and so are ranked.
  • Demographic columns from the ACS join (median_household_income, zero-vehicle households, race composition) are now carried through to per-tract output, enabling the income-gradient analysis below.

OTP by tract income quintile

Quintile Mean median income n tracts Total trips/7d Trip-weighted OTP
Q1 (lowest) $32,280 48 480,438 65.2%
Q2 $50,516 47 322,944 67.1%
Q3 $61,906 47 264,184 68.3%
Q4 $77,248 47 252,379 68.8%
Q5 (highest) $107,458 47 330,967 67.2%

Q5 - Q1 trip-weighted OTP gap: +2.0 pp (richest minus poorest); Q4 - Q1 gap: +3.6 pp. Q1 also concentrates the most service: 480k weekly trips vs 252k-330k in the upper quintiles -- so the worst OTP is borne by the largest share of riders.

Worst-served tracts

Label Weighted OTP Routes Median income % zero-vehicle % non-white
Penn Hills 523300 57.1% 3 $74,740 7.8% 44%
Plum 526202 57.2% 2 $95,438 6.6% 22%
Penn Hills 523702 57.7% 2 $55,532 20.8% 36%
Plum 526201 57.8% 2 $85,903 5.4% 5%
Penn Hills 523500 (or similar) 57.8% 2 $64,219 3.5% 60%

The bottom of the distribution is dominated by Penn Hills and Plum -- eastern Allegheny County suburbs reached almost entirely by a few long bus routes. These are not the lowest-income tracts; they are at the end of the line. The income gradient is driven less by the very worst tracts and more by the difference between Q1 and the upper quintiles across all 247 tracts.

Best-served tracts

Label Weighted OTP Routes Median income
Castle Shannon 476100 84.0% 3 $59,868
Pittsburgh 981200 83.9% 3 n/a (special-purpose tract)
Pittsburgh 320700 83.9% 3 $73,314
Bethel Park (Allegheny) 82.8% 3 $104,732
Bethel Park (Allegheny) 82.5% 4 $92,863

The top is dominated by short-line southern suburbs and rail-served tracts (the T runs through Castle Shannon and Bethel Park).

Frequency-weighting effect

Tract-level mean otp_gap = weighted - unweighted = -0.4 pp (median -0.3 pp; range -6.8 pp to +4.1 pp). On average the high-frequency routes serving a tract perform slightly worse than that tract's route average -- consistent with the system-wide pattern (Analysis 19, Analysis 45). Largest negative gaps cluster in Swissvale and Edgewood, where lateness-prone Frankstown/Forbes corridor routes dominate trip volume.

Observations

  • The non-monotonic income gradient (Q1 < Q2 < Q3 < Q4 > Q5) is consistent with a service-design pattern: dense urban-core tracts (which include many low-income tracts) have frequent, slow, congestion-prone routes; mid-income inner suburbs sit on cleaner short-haul corridors; the highest-income tracts often sit at the end of long suburban routes that accumulate delay (echoing the Penn Hills / Plum pattern at the very bottom).
  • The 3.6 pp Q1-vs-Q4 gap is meaningful at scale: Q1 tracts host ~37% of system weekly trips in the analyzed cohort (480k of 1.65M), so a sub-1pp shift here moves the system OTP noticeably.
  • Ranking 247 tracts (vs 89 hoods) puts substance behind the Pittsburgh-city-vs-suburb story: only 17 of the 30 worst-served tracts are inside Pittsburgh city limits; the rest (esp. Penn Hills, Plum, Wilkinsburg-area) had previously been masked by being assigned NULL hood or by being aggregated into wide municipalities.
  • 0 of the 6,466 stops were dropped for missing tract -- the previous analysis silently dropped 3,760 stops (58%) for missing hood.

Caveats

  • Sample size varies. Tracts have between 2 and 32 routes; a tract with 2 routes carries an OTP estimate driven by 2 routes' performance and should not be over-interpreted individually.
  • Trip-weighted, not rider-weighted. trips_7d is scheduled weekly trips, not boardings. A tract can have many high-frequency routes passing through (especially downtown / busways) without having many residents who actually board there. Per-resident OTP weighting is what Analysis 45 explores at the route level.
  • Median income suppression. ~14 of the 343 stop-bearing tracts have NULL median_household_income (Census suppresses small-sample estimates). These tracts are excluded from the quintile assignment but still appear in ranked output. The "Pittsburgh 981200" top-served tract is one of these -- it appears to be a special-purpose tract (parks/waterway) with very few residents.
  • Static weights. trips_7d is a current snapshot, not a monthly time series. Tracts whose service has expanded or contracted within the 2018-2025 window are weighted by today's footprint.
  • Ecological framing. Findings describe area-level associations between tract demographics and the OTP of routes touching that tract -- not per-resident outcomes. A resident's actual experienced OTP depends on which route they ride.
  • Tract polygons are 2020 geometry; demographics are 2018-2022 ACS 5-year. Boundary changes within the period are not reflected.

Validation

  • Data source verified. census_tracts columns checked against data/DATA_DICTIONARY.md (post Pipeline 10 expansion). Spatial join via geopandas.sjoin(predicate="within") in EPSG:32617 (UTM zone 17N).
  • Geographic/temporal scope. All three OTP measures use the identical 247-tract / 94-route / 2018-2025 cohort; bus-only stratification is a strict subset.
  • Coverage check. All 6,466 stops with non-null lat/lon assigned to exactly one tract; no overlap (tracts are non-overlapping by construction). 343 distinct tracts touched, 247 with >= 2 routes.
  • Aggregates sanity-checked. Trip-weighted system OTP across all 247 tracts (~67%) matches the system-wide trip-weighted OTP from Analysis 19 within rounding.
  • Direction of effects. Q1 (lowest income) showing worst OTP is the expected sign for an equity gradient. Penn Hills and Plum at the bottom of the ranking are also consistent with the long-suburban-bus-route lateness pattern from Analysis 10.
  • Surprising results investigated. The non-monotonic Q5 dip was investigated -- highest-income tracts in Allegheny County are clustered in southern/eastern suburbs reached by long routes, which matches the long-route lateness pattern.
  • Small-sample tracts flagged. MIN_ROUTES = 2 filter applied; below that, single-route tracts dominated by a single route's noise.
  • Ecological framing in FINDINGS.md. Income-OTP relationship described as area-level association, never as per-resident claims.

Review History

  • 2026-02-11: RED-TEAM-REPORTS/2026-02-11-analyses-01-05-07-11.md -- 7 issues (1 significant). Fixed time-pooled weighting (pre-aggregate OTP to route level before joining), added bus-only stratification revealing Simpson's paradox in Bon Air and Beechview, added NULL trips_7d filter, added minimum-month filter, documented panel balance caveat, added sample-size caveat, and clarified METHODS.md weighting description.
  • 2026-05-10: Tract-level upgrade. Replaced fuzzy stops.hood (NULL for ~58% of stops, 89 hand-curated areas) with point-in-polygon assignment to ACS 2022 census tracts (343 served tracts, 247 with 2+ routes). Added income-quintile gradient analysis using the expanded census_tracts ACS columns from Pipeline 10. Tract-level dataset confirms the previously-hood-only pattern and reveals a 3.6 pp Q1-vs-Q4 income gap in trip-weighted OTP that the hood-level analysis could not surface.

Output

Methods

Methods: Tract Equity

Question

How does on-time performance vary across the geographic and demographic landscape PRT serves -- by census tract, by tract income level, and by race composition?

Approach

  • Replace the fuzzy stops.hood field (NULL for ~58% of stops, only 89 hand-curated areas) with point-in-polygon assignment of every PRT stop to its containing 2020 census tract (TIGER 2022 polygons in census_tracts). All 6,466 stops map to a tract; 343 tracts have at least one stop.
  • Pre-aggregate OTP to one row per route (AVG(otp) GROUP BY route_id, HAVING COUNT(*) >= 12) so each route contributes one weight regardless of how many months it has data for.
  • Join route-level mean OTP to route_stops (filtered to non-null trips_7d) and to the stop→tract assignment.
  • For each tract, compute:
    • Weighted OTP: route-level mean OTP weighted by trips_7d -- "what OTP does the average trip in this tract experience?"
    • Unweighted OTP: simple average across the unique routes touching the tract -- "what is the average reliability of routes serving this area?"
    • otp_gap = weighted - unweighted (where high-frequency routes over- or under-perform their route average).
  • Filter to tracts served by at least 2 routes (MIN_ROUTES = 2); single-route tracts are too noisy to rank.
  • Bus-only stratification: re-run weighted OTP using only BUS-mode routes to detect Simpson's paradox (rail inflating an area's apparent equity).
  • Income gradient: bin tracts into 5 quintiles by median_household_income (B19013), then compute trip-weighted mean OTP per quintile.
  • Quintile time series: per-tract-month weighted OTP, then trailing 12-month rolling assignment to OTP quintiles (avoids look-ahead) to track whether the equity gap is widening or narrowing.

Data

Name Description Source
otp_monthly Monthly OTP per route (routes with < 12 months excluded) prt.db table
route_stops Routes ↔ stops with trips_7d for trip weighting prt.db table
stops lat/lon (for point-in-polygon) and muni/county (for tract labels) prt.db table
routes mode for bus-only stratification prt.db table
census_tracts TIGER 2022 tract polygons + ACS 5-year (2018-2022) demographics: population (B01003), median_household_income (B19013), households_zero_vehicle (derived from B25044), race composition (B03002) prt.db table (Pipeline 10)

Output

  • output/tract_otp.csv -- per-tract weighted/unweighted OTP, otp_gap, route/stop counts, bus-only OTP, plus tract demographics (population, median income, %zero-vehicle households, %non-white population, primary muni/county)
  • output/tract_otp_bus_only.csv -- bus-only weighted OTP per tract
  • output/otp_by_income_quintile.csv -- mean OTP and trip-weighted OTP for each tract income quintile
  • output/tract_equity.png -- top/bottom tracts bar chart and quintile time series
  • output/weighted_vs_unweighted_otp.png -- scatter and gap chart for the frequency-weighting effect
  • output/otp_by_income.png -- tract OTP scattered against median income, plus per-quintile means

Source Code

"""Tract-level equity analysis: OTP aggregated by ACS census tract.

Replaces the fuzzy `stops.hood` field (NULL for ~58% of stops, only 89 hand-curated
neighborhoods) with point-in-polygon assignment to TIGER 2022 census tracts. Adds
income/vehicle/race demographic context per tract.
"""

import polars as pl

from prt_otp_analysis.common import (
    analysis_dir,
    phase,
    query_to_polars,
    run_analysis,
    save_chart,
    save_csv,
    setup_plotting,
    weighted_mean,
)
from prt_otp_analysis.stop_tracts import assign_stops_to_tracts

OUT = analysis_dir(__file__)

MIN_MONTHS = 12  # minimum months of OTP data per route
MIN_ROUTES = 2   # tract-level estimates from a single route are too noisy to rank


def load_route_otp() -> pl.DataFrame:
    """Route-level mean OTP, restricted to routes with >= MIN_MONTHS observations."""
    return query_to_polars(f"""
        SELECT route_id, AVG(otp) AS avg_otp
        FROM otp_monthly
        GROUP BY route_id
        HAVING COUNT(*) >= {MIN_MONTHS}
    """)


def load_route_stops() -> pl.DataFrame:
    """route_stops with non-null trips_7d for trip weighting."""
    return query_to_polars(
        "SELECT route_id, stop_id, trips_7d FROM route_stops WHERE trips_7d IS NOT NULL"
    )


def load_stop_muni() -> pl.DataFrame:
    """muni/county from stops table for tract-label readability."""
    return query_to_polars("SELECT stop_id, muni, county FROM stops")


def load_route_modes() -> pl.DataFrame:
    return query_to_polars("SELECT route_id, mode FROM routes")


def load_monthly_route() -> pl.DataFrame:
    """Monthly OTP for routes meeting MIN_MONTHS, for the quintile time series."""
    return query_to_polars(f"""
        SELECT route_id, month, otp
        FROM otp_monthly
        WHERE route_id IN (
            SELECT route_id FROM otp_monthly GROUP BY route_id HAVING COUNT(*) >= {MIN_MONTHS}
        )
    """)


def build_route_stop_tract(
    route_otp_df: pl.DataFrame,
    route_stops_df: pl.DataFrame,
    stop_tract_df: pl.DataFrame,
) -> pl.DataFrame:
    """Join route-level OTP, route_stops weights, and stop→tract assignment."""
    return (
        route_stops_df.join(route_otp_df, on="route_id")
        .join(stop_tract_df, on="stop_id")
    )


def primary_muni_per_tract(stop_tract_df: pl.DataFrame, stop_muni_df: pl.DataFrame) -> pl.DataFrame:
    """Pick the most-common (muni, county) among the stops in each tract for human-readable labels."""
    joined = stop_tract_df.select("stop_id", "geoid").join(stop_muni_df, on="stop_id")
    counts = (
        joined.filter(pl.col("muni").is_not_null() & (pl.col("muni") != "0"))
        .group_by(["geoid", "muni", "county"])
        .agg(n=pl.len())
    )
    return (
        counts.sort(["geoid", "n"], descending=[False, True])
        .group_by("geoid", maintain_order=True)
        .agg(primary_muni=pl.col("muni").first(), primary_county=pl.col("county").first())
    )


def tract_label(geoid: str, muni: str | None) -> str:
    """Human-readable tract label: 'Pittsburgh 020100' or just '020100' if no muni."""
    code = geoid[-6:].lstrip("0") or geoid[-6:]
    return f"{muni} {code}" if muni else f"Tract {code}"


def analyze(
    rst_df: pl.DataFrame,
    muni_df: pl.DataFrame,
    tract_demo_df: pl.DataFrame,
) -> pl.DataFrame:
    """Compute per-tract weighted/unweighted OTP, demographics, and a readable label."""
    tract_summary = (
        rst_df.group_by("geoid")
        .agg(
            weighted_otp=weighted_mean("avg_otp", "trips_7d"),
            route_count=pl.col("route_id").n_unique(),
            stop_count=pl.col("stop_id").n_unique(),
            total_trips_7d=pl.col("trips_7d").sum(),
        )
    )

    route_tract = (
        rst_df.group_by(["geoid", "route_id"])
        .agg(avg_otp=pl.col("avg_otp").first())
    )
    tract_unweighted = (
        route_tract.group_by("geoid")
        .agg(unweighted_otp=pl.col("avg_otp").mean())
    )

    out = (
        tract_summary
        .join(tract_unweighted, on="geoid", how="left")
        .join(muni_df, on="geoid", how="left")
        .join(tract_demo_df, on="geoid", how="left")
        .with_columns(
            otp_gap=pl.col("weighted_otp") - pl.col("unweighted_otp"),
            pct_zero_vehicle=(
                pl.col("households_zero_vehicle") / pl.col("households_total")
            ),
            pct_nonwhite=(
                1.0 - (pl.col("pop_white_nh") / pl.col("population"))
            ),
        )
        .with_columns(
            label=pl.struct("geoid", "primary_muni").map_elements(
                lambda r: tract_label(r["geoid"], r["primary_muni"]),
                return_dtype=pl.Utf8,
            ),
        )
        .filter(pl.col("route_count") >= MIN_ROUTES)
        .sort("weighted_otp")
    )
    return out


def analyze_bus_only(
    rst_df: pl.DataFrame,
    route_modes_df: pl.DataFrame,
) -> pl.DataFrame:
    bus = (
        rst_df.join(route_modes_df, on="route_id")
        .filter(pl.col("mode") == "BUS")
    )
    return (
        bus.group_by("geoid")
        .agg(
            bus_weighted_otp=weighted_mean("avg_otp", "trips_7d"),
            bus_route_count=pl.col("route_id").n_unique(),
        )
    )


def analyze_quintile_ts(
    monthly_df: pl.DataFrame,
    route_stops_df: pl.DataFrame,
    stop_tract_df: pl.DataFrame,
) -> pl.DataFrame:
    """Tract-month weighted OTP, then trailing-12-month rolling quintiles."""
    rs_tract = route_stops_df.join(stop_tract_df.select("stop_id", "geoid"), on="stop_id")

    rsm = (
        rs_tract.join(monthly_df, on="route_id")
        .group_by(["geoid", "month"])
        .agg(weighted_otp=weighted_mean("otp", "trips_7d"))
        .sort(["geoid", "month"])
    )
    rsm = rsm.with_columns(
        rolling_otp=pl.col("weighted_otp")
        .rolling_mean(window_size=12, min_samples=6)
        .over("geoid"),
    ).filter(pl.col("rolling_otp").is_not_null())

    rsm = rsm.with_columns(
        quintile=(
            ((pl.col("rolling_otp").rank().over("month") - 1)
             / pl.col("rolling_otp").count().over("month") * 5)
            .cast(pl.Int32).clip(0, 4) + 1
        ),
    )
    return (
        rsm.group_by(["quintile", "month"])
        .agg(avg_otp=pl.col("weighted_otp").mean())
        .sort(["quintile", "month"])
    )


def analyze_income_gradient(tract_summary: pl.DataFrame) -> pl.DataFrame:
    """Bin tracts by trip-weighted-population income quintile, report mean OTP per bin."""
    df = tract_summary.filter(
        pl.col("median_household_income").is_not_null()
        & pl.col("weighted_otp").is_not_null()
    )
    if len(df) < 5:
        return pl.DataFrame()
    df = df.with_columns(
        income_quintile=(
            ((pl.col("median_household_income").rank() - 1)
             / pl.len() * 5)
            .cast(pl.Int32).clip(0, 4) + 1
        ),
    )
    return (
        df.group_by("income_quintile")
        .agg(
            mean_otp=pl.col("weighted_otp").mean(),
            mean_income=pl.col("median_household_income").mean(),
            n_tracts=pl.len(),
            total_trips_7d=pl.col("total_trips_7d").sum(),
            trip_weighted_otp=weighted_mean("weighted_otp", "total_trips_7d"),
        )
        .sort("income_quintile")
    )


def make_chart(tract_summary: pl.DataFrame, quintile_ts: pl.DataFrame) -> None:
    plt = setup_plotting()
    fig, (ax1, ax2) = plt.subplots(2, 1, figsize=(14, 10))

    n_show = 15
    bottom = tract_summary.sort("weighted_otp").head(n_show)
    top = tract_summary.sort("weighted_otp", descending=True).head(n_show).sort("weighted_otp")
    combined = pl.concat([bottom, top])

    labels = combined["label"].to_list()
    values = combined["weighted_otp"].to_list()
    median = combined["weighted_otp"].median()
    colors = ["#ef4444" if v < median else "#22c55e" for v in values]

    y_pos = range(len(labels))
    ax1.barh(y_pos, values, color=colors)
    ax1.set_yticks(y_pos)
    ax1.set_yticklabels(labels, fontsize=7)
    ax1.set_xlabel("Trip-weighted average OTP")
    ax1.set_title(f"Bottom {n_show} & Top {n_show} census tracts by OTP "
                  f"(min {MIN_ROUTES} routes)")
    ax1.set_xlim(0, 1)

    quintile_colors = {1: "#ef4444", 2: "#f59e0b", 3: "#9ca3af", 4: "#60a5fa", 5: "#22c55e"}
    quintile_labels = {1: "Q1 (worst)", 2: "Q2", 3: "Q3", 4: "Q4", 5: "Q5 (best)"}

    months_all = sorted(quintile_ts["month"].unique().to_list())
    tick_pos = [i for i, m in enumerate(months_all) if m.endswith("-01")]
    tick_lbl = [months_all[i][:4] for i in tick_pos]

    for q in [1, 2, 3, 4, 5]:
        q_data = quintile_ts.filter(pl.col("quintile") == q).sort("month")
        months = q_data["month"].to_list()
        vals = q_data["avg_otp"].to_list()
        x = [months_all.index(m) for m in months]
        lw = 1.8 if q in (1, 5) else 0.8
        alpha = 1.0 if q in (1, 5) else 0.5
        ax2.plot(x, vals, color=quintile_colors[q], linewidth=lw, alpha=alpha,
                 label=quintile_labels[q])

    q1_data = quintile_ts.filter(pl.col("quintile") == 1).sort("month")
    q5_data = quintile_ts.filter(pl.col("quintile") == 5).sort("month")
    shared = q1_data.select("month").join(q5_data.select("month"), on="month")
    shared_months = shared["month"].to_list()
    q1_vals = q1_data.filter(pl.col("month").is_in(shared_months)).sort("month")["avg_otp"].to_list()
    q5_vals = q5_data.filter(pl.col("month").is_in(shared_months)).sort("month")["avg_otp"].to_list()
    shared_x = [months_all.index(m) for m in shared_months]
    ax2.fill_between(shared_x, q1_vals, q5_vals, alpha=0.1, color="#7c3aed")

    ax2.set_ylabel("Average OTP")
    ax2.set_title("OTP by tract quintile over time")
    ax2.set_xticks(tick_pos)
    ax2.set_xticklabels(tick_lbl)
    ax2.set_xlabel("Month")
    ax2.legend(fontsize=8, loc="lower left")
    ax2.set_ylim(0, 1)

    save_chart(fig, OUT / "tract_equity.png")


def make_comparison_chart(tract_summary: pl.DataFrame) -> None:
    plt = setup_plotting()
    fig, (ax1, ax2) = plt.subplots(1, 2, figsize=(14, 6))

    weighted = tract_summary["weighted_otp"].to_list()
    unweighted = tract_summary["unweighted_otp"].to_list()
    trips = tract_summary["total_trips_7d"].to_list()

    max_trips = max(trips)
    sizes = [20 + 80 * (t / max_trips) for t in trips]
    ax1.scatter(unweighted, weighted, s=sizes, alpha=0.5, c="#6366f1",
                edgecolors="white", linewidths=0.3)
    ax1.plot([0, 1], [0, 1], color="#9ca3af", linestyle="--", linewidth=1, zorder=0)
    ax1.set_xlabel("Unweighted OTP (equal weight per route)")
    ax1.set_ylabel("Weighted OTP (weighted by trip frequency)")
    ax1.set_title("Weighted vs unweighted OTP by tract")
    ax1.set_xlim(0, 1)
    ax1.set_ylim(0, 1)
    ax1.set_aspect("equal")

    sorted_by_gap = tract_summary.with_columns(abs_gap=pl.col("otp_gap").abs()).sort(
        "abs_gap", descending=True
    )
    for row in sorted_by_gap.head(5).iter_rows(named=True):
        ax1.annotate(
            row["label"], (row["unweighted_otp"], row["weighted_otp"]),
            fontsize=6, alpha=0.8,
            xytext=(4, 4), textcoords="offset points",
        )

    n_show = 15
    biggest_positive = tract_summary.sort("otp_gap", descending=True).head(n_show)
    biggest_negative = tract_summary.sort("otp_gap").head(n_show)
    combined = pl.concat([biggest_negative, biggest_positive.sort("otp_gap")])
    gap_labels = combined["label"].to_list()
    gap_vals = combined["otp_gap"].to_list()
    gap_colors = ["#ef4444" if g < 0 else "#22c55e" for g in gap_vals]
    y_pos = range(len(gap_labels))
    ax2.barh(y_pos, gap_vals, color=gap_colors)
    ax2.set_yticks(y_pos)
    ax2.set_yticklabels(gap_labels, fontsize=6)
    ax2.set_xlabel("OTP gap (weighted - unweighted)")
    ax2.set_title("Frequency-weighting effect by tract")
    ax2.axvline(0, color="#9ca3af", linewidth=0.8)

    save_chart(fig, OUT / "weighted_vs_unweighted_otp.png")


def make_income_chart(tract_summary: pl.DataFrame, gradient_df: pl.DataFrame) -> None:
    """Scatter of tract OTP vs median household income, plus per-quintile means."""
    plt = setup_plotting()
    fig, (ax1, ax2) = plt.subplots(1, 2, figsize=(14, 5))

    df = tract_summary.filter(pl.col("median_household_income").is_not_null())
    incomes = df["median_household_income"].to_list()
    otps = df["weighted_otp"].to_list()
    trips = df["total_trips_7d"].to_list()
    max_trips = max(trips)
    sizes = [10 + 80 * (t / max_trips) for t in trips]
    ax1.scatter(incomes, otps, s=sizes, alpha=0.4, c="#6366f1",
                edgecolors="white", linewidths=0.3)
    ax1.set_xlabel("Median household income (tract, 2018-2022 ACS, $)")
    ax1.set_ylabel("Trip-weighted OTP")
    ax1.set_title("Tract OTP vs median household income")
    ax1.set_ylim(0.4, 1.0)
    ax1.xaxis.set_major_formatter(lambda x, _: f"${int(x/1000)}k")

    if len(gradient_df) > 0:
        quintiles = gradient_df["income_quintile"].to_list()
        bar_otp = gradient_df["trip_weighted_otp"].to_list()
        ax2.bar(quintiles, bar_otp, color="#3b82f6", edgecolor="white")
        ax2.set_xlabel("Tract income quintile (1 = lowest income)")
        ax2.set_ylabel("Trip-weighted OTP")
        ax2.set_title("OTP by tract income quintile")
        ax2.set_ylim(0.5, 0.85)
        for q, v in zip(quintiles, bar_otp):
            ax2.text(q, v + 0.005, f"{v:.1%}", ha="center", fontsize=10)

    save_chart(fig, OUT / "otp_by_income.png")


@run_analysis(4, "Tract Equity")
def main() -> None:
    with phase("Loading route-level OTP and stops"):
        route_otp_df = load_route_otp()
        route_stops_df = load_route_stops()
        stop_muni_df = load_stop_muni()
        print(f"  {len(route_otp_df)} routes with >= {MIN_MONTHS} months of OTP")
        print(f"  {len(route_stops_df):,} route-stop edges with non-null trips_7d")

    with phase("Assigning stops to census tracts"):
        stop_tract_df = assign_stops_to_tracts()
        print(f"  {len(stop_tract_df):,} stops mapped to "
              f"{stop_tract_df['geoid'].n_unique()} tracts "
              f"({stop_tract_df['geoid'].is_null().sum()} unmapped)")

    with phase("Building per-tract analysis"):
        rst_df = build_route_stop_tract(route_otp_df, route_stops_df, stop_tract_df)
        muni_df = primary_muni_per_tract(stop_tract_df, stop_muni_df)
        tract_demo_df = stop_tract_df.unique("geoid").select(
            "geoid", "population", "median_household_income",
            "households_total", "households_zero_vehicle",
            "pop_white_nh", "pop_black_nh", "pop_asian_nh", "pop_hispanic",
        )
        tract_summary = analyze(rst_df, muni_df, tract_demo_df)
        print(f"  {len(tract_summary)} tracts with >= {MIN_ROUTES} routes ranked")

        best = tract_summary.sort("weighted_otp", descending=True).head(3)
        worst = tract_summary.sort("weighted_otp").head(3)
        print("\n  Top 3 tracts (weighted):")
        for row in best.iter_rows(named=True):
            print(f"    {row['label']}: {row['weighted_otp']:.1%}")
        print("  Bottom 3 tracts (weighted):")
        for row in worst.iter_rows(named=True):
            print(f"    {row['label']}: {row['weighted_otp']:.1%}")

        spread = tract_summary["weighted_otp"].max() - tract_summary["weighted_otp"].min()
        print(f"\n  Spread (max - min): {spread:.1%}")

    with phase("Bus-only stratification"):
        route_modes_df = load_route_modes()
        bus_summary = analyze_bus_only(rst_df, route_modes_df)
        tract_summary = tract_summary.join(bus_summary, on="geoid", how="left")
        bus_count = tract_summary.filter(pl.col("bus_weighted_otp").is_not_null()).height
        print(f"  {bus_count} tracts with bus-mode service")

    with phase("Income-gradient analysis"):
        gradient_df = analyze_income_gradient(tract_summary)
        if len(gradient_df) > 0:
            for row in gradient_df.iter_rows(named=True):
                print(f"    Q{row['income_quintile']} (mean ${row['mean_income']:,.0f}, "
                      f"n={row['n_tracts']}): trip-weighted OTP {row['trip_weighted_otp']:.1%}")
            q1 = gradient_df.filter(pl.col("income_quintile") == 1)["trip_weighted_otp"][0]
            q5 = gradient_df.filter(pl.col("income_quintile") == 5)["trip_weighted_otp"][0]
            print(f"  Q5 - Q1 OTP gap: {(q5 - q1) * 100:+.2f} pp")

    with phase("Quintile time series"):
        monthly_df = load_monthly_route()
        quintile_ts = analyze_quintile_ts(monthly_df, route_stops_df, stop_tract_df)

    with phase("Saving CSVs"):
        save_csv(tract_summary, OUT / "tract_otp.csv")
        save_csv(bus_summary, OUT / "tract_otp_bus_only.csv")
        if len(gradient_df) > 0:
            save_csv(gradient_df, OUT / "otp_by_income_quintile.csv")

    with phase("Generating charts"):
        make_chart(tract_summary, quintile_ts)
        make_comparison_chart(tract_summary)
        make_income_chart(tract_summary, gradient_df)


if __name__ == "__main__":
    main()

Sources

NameTypeWhy It MattersOwnerFreshnessCaveat
otp_monthly 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.
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.
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.
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.
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.