Analysis

58 - Stop Signal Placement Equity

Coverage: Coverage window unavailable for this page.

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

Page Navigation

Analysis Navigation

Data Provenance

flowchart LR
  58_stop_signal_equity(["58 - Stop Signal Placement Equity"])
  f1_58_stop_signal_equity[/"data/bus-stop-usage/wprdc_stop_data.csv"/] --> 58_stop_signal_equity
  t_stop_signals[("stop_signals")] --> 58_stop_signal_equity
  15_stop_signals[["PRT Stop-Signal Classification ETL"]] --> t_stop_signals
  u1_15_stop_signals[/"data/prt-stop-signals/bus_stops_with_signals_2602.xlsx"/] --> 15_stop_signals
  t_stops[("stops")] --> 58_stop_signal_equity
  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_census_tracts[("census_tracts")] --> 58_stop_signal_equity
  d1_58_stop_signal_equity(("numpy (lib)")) --> 58_stop_signal_equity
  d2_58_stop_signal_equity(("polars (lib)")) --> 58_stop_signal_equity
  d3_58_stop_signal_equity(("scipy (lib)")) --> 58_stop_signal_equity
  d4_58_stop_signal_equity(("geopandas (lib)")) --> 58_stop_signal_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 58_stop_signal_equity page;
  class t_census_tracts,t_stop_signals,t_stops table;
  class d1_58_stop_signal_equity,d2_58_stop_signal_equity,d3_58_stop_signal_equity,d4_58_stop_signal_equity dep;
  class f1_58_stop_signal_equity,u1_01_data_ingestion,u1_15_stop_signals,u2_01_data_ingestion,u3_01_data_ingestion,u4_01_data_ingestion,u5_01_data_ingestion file;
  class 01_data_ingestion,15_stop_signals pipeline;

Findings

Findings: Stop Signal Placement Equity

Summary

PRT's signalized stops — and the operationally worse near-side placement documented in Analysis 53 — show no meaningful demographic disparity. The near-side share of signalized stops holds at roughly 85% across every income quartile (86.2% in the lowest-income quartile vs. 84.9% in the highest), and at the census-tract level neither the signalized-stop share nor the near-side share correlates with median household income, zero-vehicle household share, or Black population share (all |Spearman ρ| ≤ 0.11, all p > 0.07). The null holds when stops are weighted by ridership — per-rider near-side exposure is 85% in the lowest-income quartile and does not rise monotonically with poverty (it is 83% in the highest-income quartile and peaks in the middle), so riders in low-income areas are not disproportionately exposed to near-side stops even after accounting for how heavily each stop is used. Near-side placement is a system-wide legacy inheritance, not a pattern concentrated in disadvantaged neighborhoods. The equity implication is encouraging in one sense — riders in low-income areas are not disproportionately burdened by the worse placement — and practical in another: converting near-side stops to far-side (where warranted) would benefit riders broadly rather than redress a specific inequity.

Key Numbers

  • 6,299 stops matched to a census tract; 6,223 have a tract median income.
  • Signalized-stop share by income quartile: Q1 25.7%, Q2 19.0%, Q3 24.3%, Q4 27.9% — no monotonic income gradient.
  • Near-side share by income quartile: Q1 86.2%, Q2 88.2%, Q3 85.3%, Q4 84.9% — essentially flat.
  • Tract-level signalized-stop share (n = 294 tracts with ≥ 5 stops) vs:
    • median household income: ρ = +0.044 (p = 0.46)
    • zero-vehicle household share: ρ = +0.105 (p = 0.07)
    • Black population share: ρ = −0.040 (p = 0.50)
  • Tract-level near-side share (n = 201 tracts with ≥ 3 signalized stops) vs:
    • median household income: ρ = −0.044 (p = 0.55)
    • zero-vehicle household share: ρ = +0.062 (p = 0.39)
    • Black population share: ρ = +0.036 (p = 0.62)
  • Ridership-weighted near-side share by income quartile: Q1 85.3%, Q2 90.2%, Q3 93.1%, Q4 83.2% — non-monotonic, lowest-income quartile near the bottom of the range (98% of signal stops, 6,174 / 6,299, have usage data).
  • Ridership-weighted tract correlations are also null: near-side share vs income ρ = −0.051 (p = 0.48), vs zero-vehicle share ρ = +0.017 (p = 0.82), vs Black share ρ = +0.043 (p = 0.55). The only correlation to cross p < 0.05 is signalized share vs zero-vehicle share (ρ = +0.121, p = 0.04) — the same urban-density signal seen unweighted, and not the near-side outcome.

Observations

  • No income gradient in either outcome. Whether a stop sits at a signal, and whether that signal stop is near-side, is independent of neighborhood income. The quartile bars are visually flat (see signal_share_by_income_quartile.png).
  • The one borderline signal is intuitive and non-significant. Tracts with more zero-vehicle (transit-dependent) households have very slightly more signalized stops (ρ = +0.105, p = 0.07) — consistent with transit-dependent areas being denser, more urban places that simply have more signals overall. It does not reach significance and does not appear in the near-side outcome.
  • Near-side dominance is universal. The ~85% near-side share is remarkably stable across the income distribution, reinforcing Analysis 53's reading that near-side placement reflects historical engineering practice applied system-wide, not a targeted or recent siting decision.
  • Direction of the (null) effects. A genuine equity problem would have shown lower-income or more-transit-dependent tracts with higher near-side shares. No such pattern exists; if anything the near-side share trends marginally lower in lower-income tracts, the opposite of a disparity.
  • Ridership weighting does not change the conclusion. Weighting each stop by its pre-pandemic weekday usage — so the figures reflect rider experience rather than stop counts — leaves the picture flat. Per-rider near-side exposure is 85.3% in the lowest-income quartile, peaks at 93.1% in Q3, and falls to 83.2% in the highest-income quartile: no income gradient, and the most disadvantaged riders are not the most exposed. This was the one view most likely to reveal a hidden disparity (busy stops in poor, dense neighborhoods could have dominated), and it did not. See nearside_share_rider_weighted.png.

Caveats

  • Ecological framing. All results are tract-level associations between demographics and stop placement. They describe neighborhoods, not individual riders, and make no individual-level claim.
  • Null result, not proof of no effect. Failing to detect a disparity at the tract level does not prove perfect equity at finer geographies; it means no meaningful tract-level relationship is present in these data.
  • Tract assignment is point-in-polygon. Each stop is assigned to the tract containing its coordinate; a stop near a tract boundary serves residents of adjacent tracts whose demographics are not counted.
  • Demographic coverage. ACS demographics attach only to stops inside a 5-county PA tract; a small number of out-of-region stops (and tracts with null ACS values) drop from the relevant correlation.
  • Ridership data is pre-pandemic. The usage weights come from pre-pandemic weekday boardings/alightings (the WPRDC bus-stop-usage dataset, also used by Analyses 32 and 34). Post-pandemic ridership patterns have shifted; if the shift correlated with both demographics and near-side placement it could in principle move the weighted result, though the per-stop (unweighted) null is unaffected by this.
  • Usage coverage is 98%, not 100%. 6,174 of 6,299 tract-matched signal stops carry a usage record; the 125 without one drop from the weighted figures only (they remain in the per-stop figures). Their omission cannot manufacture a gradient that the per-stop view does not show.

Validation

  1. Data source verified. stop_signals (pipeline 15) validated against the STOP_SIGNALS schema with validate(..., subset=True); demographics come from the shared assign_stops_to_tracts helper (point-in-polygon to census_tracts), the same path used by Analysis 04.
  2. Scope match. Stops are joined on the PRT internal stop_id (the namespace shared by stop_signals, the stops table, and the WPRDC usage CSV); 6,299 of 6,306 PRT stops carry a resolved stop_id and match, and 6,174 of those (98%) carry pre-pandemic weekday usage for the ridership-weighted view.
  3. Null handling. Stops without a tract income are dropped from the income-quartile view; share denominators are guarded with when(... > 0) so a tract with zero households or zero signalized stops yields null (dropped by correlate), never a divide-by-zero or a NaN counted as a real value. In the ridership-weighted aggregates, usage is summed with filter, so the 125 usage-null stops contribute 0 to both numerator and denominator (they drop out) rather than injecting a null weight.
  4. Aggregate sanity check. Overall signalized share (~24%) and near-side share (~85%) match the system totals in Analysis 53, confirming the join did not drop or duplicate stops.
  5. Surprising-result check. The result is a null, which is the expected direction here (a legacy system-wide default should not track income); the one borderline correlation (zero-vehicle share) was examined and explained as urban density, not a disparity.
  6. Robustness to weighting. The null was re-tested with stops weighted by ridership — the view most likely to surface a hidden disparity — and the conclusion held (no income gradient, near-side correlations still null). A result that flipped under weighting would have been a red flag; it did not.
  7. Small-sample routes/areas flagged. Tracts below 5 stops (signalized share) and 3 signalized stops (near-side share) are excluded; thresholds are reported in the output and chart titles.
  8. Multicollinearity. Not applicable — these are bivariate Spearman correlations, not a multi-predictor regression.
  9. Ecological framing. Documented in Caveats; all language is tract-level.

Output

Methods

Methods: Stop Signal Placement Equity

Question

Do stops at traffic signals — and specifically the operationally worse near-side placement — cluster in lower-income, more transit-dependent, or higher-minority neighborhoods? In other words, is the legacy near-side default (Analysis 53) distributed unequally across the population it serves?

Approach

  1. Load PRT's authoritative per-stop signal classification from the stop_signals table (signal_class, has_signal).
  2. Assign every stop to its containing ACS census tract via point-in-polygon (assign_stops_to_tracts), attaching the tract's median household income, total/zero-vehicle households, population, and Black (non-Hispanic) population.
  3. Income-quartile view. Bin stops by their tract's median household income into quartiles. For each quartile compute (a) the signalized-stop share (fraction of stops at a signal) and (b) the near-side share (fraction of signalized stops that are near-side). A disparity would show as a monotonic gradient across quartiles.
  4. Tract-level correlations. For each tract with enough stops, compute the signalized-stop share and near-side share, then correlate (Spearman) against three demographic measures: median household income, zero-vehicle household share, and Black population share. Spearman is used because the demographic distributions are skewed and the relationship need not be linear.
  5. Minimum-sample thresholds. Tracts need ≥ 5 stops for the signalized-share correlation and ≥ 3 signalized stops for the near-side-share correlation; thresholds are reported. Quartile shares use all stops with a tract income.
  6. Ridership-weighted view. A per-stop count treats a busy downtown stop and a rarely used suburban stop equally, but riders do not experience them equally. We therefore recompute both the quartile shares and the tract-level shares weighted by pre-pandemic weekday usage (boardings + alightings per stop, from the WPRDC bus-stop-usage CSV). The weighted near-side share answers "what fraction of signalized-stop boardings happen at a near-side stop?" If low-income riders disproportionately use near-side stops, the per-rider view would show a gradient the per-stop view misses. Spearman correlations are re-run on the ridership-weighted tract shares.

Data

  • stop_signals table in prt.db (PRT authoritative per-stop signal class; built by pipeline/15_stop_signals/). Join key: PRT internal stop_id.
  • stops table in prt.db (lat/lon for tract assignment, via the shared assign_stops_to_tracts helper).
  • census_tracts table in prt.db (ACS demographics: median household income, households_total, households_zero_vehicle, population, pop_black_nh).
  • data/bus-stop-usage/wprdc_stop_data.csv — pre-pandemic weekday boardings and alightings per stop, keyed by the same PRT internal stop_id; used for the ridership-weighted view. Covers 98% (6,174 / 6,299) of tract-matched signal stops; the rest have no usage record and drop out of the weighted figures only.
  • Scope caveat: ACS demographics are only joined for stops inside a 5-county PA tract; a handful of out-of-region stops get null demographics and drop out.

Output

  • output/stop_equity.csv — per-stop: stop_id, signal_class, has_signal, usage, geoid, median household income.
  • output/tract_equity_summary.csv — per-tract signalized/near-side shares (per-stop and ridership-weighted) with demographics.
  • output/income_quartile_shares.csv — quartile shares, per-stop and ridership-weighted.
  • output/demographic_correlations.csv — Spearman correlations of each share against the three demographics, for both per-stop and per-rider weightings.
  • output/signal_share_by_income_quartile.png — bar chart of signalized-stop share and near-side share by income quartile.
  • output/tract_income_vs_signal_share.png — scatter of tract median income vs. signalized-stop share with fitted trend.
  • output/nearside_share_rider_weighted.png — near-side share by income quartile, per stop vs. per rider (ridership-weighted).

Source Code

"""Analysis 58: Test whether traffic-signal stop placement varies with neighborhood demographics."""

from prt_otp_analysis.common import (
    DATA_DIR,
    analysis_dir,
    correlate,
    phase,
    query_to_polars,
    run_analysis,
    save_chart,
    save_csv,
    setup_plotting,
)
from prt_otp_analysis.common.schemas import STOP_SIGNALS, validate
from prt_otp_analysis.stop_tracts import assign_stops_to_tracts

import numpy as np
import polars as pl
from scipy import stats

OUT = analysis_dir(__file__)

MIN_STOPS_PER_TRACT = 5        # for signalized-share correlation
MIN_SIGNALIZED_PER_TRACT = 3   # for near-side-share correlation

QUARTILE_LABELS = ["Q1 (lowest income)", "Q2", "Q3", "Q4 (highest income)"]

# Demographic measures tested against signal placement.
DEMOGRAPHICS = [
    ("median_household_income", "median household income"),
    ("zero_vehicle_share", "zero-vehicle household share"),
    ("black_share", "Black population share"),
]


def load_stop_signals() -> pl.DataFrame:
    """Per-stop authoritative signal class, keyed by PRT internal stop_id."""
    df = query_to_polars(
        """SELECT stop_id, signal_class, has_signal
           FROM stop_signals WHERE stop_id IS NOT NULL"""
    )
    validate(df, STOP_SIGNALS, subset=True)
    return df


def load_stop_ridership() -> pl.DataFrame:
    """Per-stop pre-pandemic weekday usage (boardings + alightings).

    Keyed by the PRT internal `stop_id` (E-code), the same namespace as
    `stop_signals` and the `stops` table, so it joins directly. Usage is
    averaged across measurement days within a route, then summed across the
    routes serving each physical stop — the same construction Analysis 32/34
    use for stop-level ridership.
    """
    csv_path = DATA_DIR / "bus-stop-usage" / "wprdc_stop_data.csv"
    df = pl.read_csv(csv_path, null_values=["NA", ""]).filter(
        (pl.col("time_period") == "Pre-pandemic")
        & (pl.col("serviceday") == "Weekday")
    )
    per_stop_route = (
        df.group_by(["stop_id", "route_name"])
        .agg(pl.col("avg_ons").mean(), pl.col("avg_offs").mean())
    )
    return (
        per_stop_route.group_by("stop_id")
        .agg(pl.col("avg_ons").sum(), pl.col("avg_offs").sum())
        .with_columns(usage=pl.col("avg_ons") + pl.col("avg_offs"))
        .select("stop_id", "usage")
    )


def build_stop_demographics() -> pl.DataFrame:
    """Join authoritative signal class to each stop's census-tract demographics."""
    signals_df = load_stop_signals()
    ridership_df = load_stop_ridership()
    tract_df = assign_stops_to_tracts(
        ["median_household_income", "households_total",
         "households_zero_vehicle", "pop_black_nh"],
    )
    df = signals_df.join(
        tract_df.select(
            "stop_id", "geoid", "population", "median_household_income",
            "households_total", "households_zero_vehicle", "pop_black_nh",
        ),
        on="stop_id",
        how="inner",
    ).join(ridership_df, on="stop_id", how="left")
    return df.with_columns(
        is_nearside=(pl.col("signal_class") == "nearside").cast(pl.Int64),
        is_signalized=pl.col("has_signal"),
    )


def income_quartile_table(df: pl.DataFrame) -> pl.DataFrame:
    """Signalized-stop and near-side shares by tract-income quartile of each stop."""
    inc_df = df.drop_nulls("median_household_income")
    inc = inc_df["median_household_income"].to_numpy()
    cuts = np.quantile(inc, [0.25, 0.5, 0.75])

    def to_quartile(value: float) -> str:
        idx = int(np.searchsorted(cuts, value, side="right"))
        return QUARTILE_LABELS[min(idx, 3)]

    inc_df = inc_df.with_columns(
        pl.col("median_household_income")
        .map_elements(to_quartile, return_dtype=pl.Utf8)
        .alias("income_quartile"),
    )
    # Ridership weights (usage at signalized / near-side stops vs all stops).
    classified = pl.col("signal_class").is_in(["nearside", "farside"])
    use = pl.col("usage")
    return (
        inc_df.group_by("income_quartile")
        .agg(
            n_stops=pl.len(),
            n_signalized=pl.col("is_signalized").sum(),
            n_nearside=pl.col("is_nearside").sum(),
            n_signal_classified=classified.sum(),
            # Ridership-weighted numerators/denominators (usage-null stops drop out).
            use_total=use.sum(),
            use_signalized=use.filter(pl.col("is_signalized") == 1).sum(),
            use_classified=use.filter(classified).sum(),
            use_nearside=use.filter(pl.col("is_nearside") == 1).sum(),
        )
        .with_columns(
            signalized_share=pl.col("n_signalized") / pl.col("n_stops"),
            nearside_share=pl.when(pl.col("n_signal_classified") > 0)
            .then(pl.col("n_nearside") / pl.col("n_signal_classified"))
            .otherwise(None),
            rw_signalized_share=pl.when(pl.col("use_total") > 0)
            .then(pl.col("use_signalized") / pl.col("use_total"))
            .otherwise(None),
            rw_nearside_share=pl.when(pl.col("use_classified") > 0)
            .then(pl.col("use_nearside") / pl.col("use_classified"))
            .otherwise(None),
        )
        .sort("income_quartile")
    )


def tract_summary(df: pl.DataFrame) -> pl.DataFrame:
    """Per-tract signalized/near-side shares plus demographic ratios."""
    classified = pl.col("signal_class").is_in(["nearside", "farside"])
    use = pl.col("usage")
    return (
        df.group_by("geoid")
        .agg(
            n_stops=pl.len(),
            n_signalized=pl.col("is_signalized").sum(),
            n_nearside=pl.col("is_nearside").sum(),
            n_signal_classified=classified.sum(),
            use_total=use.sum(),
            use_signalized=use.filter(pl.col("is_signalized") == 1).sum(),
            use_classified=use.filter(classified).sum(),
            use_nearside=use.filter(pl.col("is_nearside") == 1).sum(),
            median_household_income=pl.col("median_household_income").first(),
            population=pl.col("population").first(),
            households_total=pl.col("households_total").first(),
            households_zero_vehicle=pl.col("households_zero_vehicle").first(),
            pop_black_nh=pl.col("pop_black_nh").first(),
        )
        .with_columns(
            signalized_share=pl.col("n_signalized") / pl.col("n_stops"),
            nearside_share=pl.when(pl.col("n_signal_classified") > 0)
            .then(pl.col("n_nearside") / pl.col("n_signal_classified"))
            .otherwise(None),
            rw_signalized_share=pl.when(pl.col("use_total") > 0)
            .then(pl.col("use_signalized") / pl.col("use_total"))
            .otherwise(None),
            rw_nearside_share=pl.when(pl.col("use_classified") > 0)
            .then(pl.col("use_nearside") / pl.col("use_classified"))
            .otherwise(None),
            zero_vehicle_share=pl.when(pl.col("households_total") > 0)
            .then(pl.col("households_zero_vehicle") / pl.col("households_total"))
            .otherwise(None),
            black_share=pl.when(pl.col("population") > 0)
            .then(pl.col("pop_black_nh") / pl.col("population"))
            .otherwise(None),
        )
    )


def make_quartile_chart(quartile_df: pl.DataFrame) -> None:
    """Grouped bar chart: signalized-stop share and near-side share by income quartile."""
    plt = setup_plotting()
    fig, ax = plt.subplots(figsize=(9, 6))

    order = quartile_df.sort("income_quartile")
    labels = order["income_quartile"].to_list()
    sig = (order["signalized_share"] * 100).to_list()
    near = (order["nearside_share"] * 100).to_list()

    x = np.arange(len(labels))
    width = 0.38
    b1 = ax.bar(x - width / 2, sig, width, label="Signalized-stop share",
                color="#2563eb", alpha=0.85)
    b2 = ax.bar(x + width / 2, near, width, label="Near-side share (of signalized)",
                color="#e11d48", alpha=0.85)
    ax.bar_label(b1, fmt="%.1f%%", padding=3, fontsize=9)
    ax.bar_label(b2, fmt="%.1f%%", padding=3, fontsize=9)

    ax.set_xticks(x)
    ax.set_xticklabels(labels, fontsize=9)
    ax.set_ylabel("Percent")
    ax.set_ylim(0, max(near + sig) * 1.18)
    ax.set_title("Signal Stop Placement by Neighborhood Income Quartile\n"
                 "(flat bars = no income disparity)", fontweight="bold")
    ax.legend(fontsize=9)
    save_chart(fig, OUT / "signal_share_by_income_quartile.png")


def make_scatter_chart(tract_df: pl.DataFrame) -> None:
    """Scatter: tract median income vs signalized-stop share."""
    plt = setup_plotting()
    fig, ax = plt.subplots(figsize=(8, 6))

    d = tract_df.filter(
        (pl.col("n_stops") >= MIN_STOPS_PER_TRACT)
        & pl.col("median_household_income").is_not_null()
    )
    income = d["median_household_income"].to_numpy() / 1000.0
    share = d["signalized_share"].to_numpy() * 100

    ax.scatter(income, share, alpha=0.5, s=30, color="#2563eb",
               edgecolors="white", linewidth=0.5)
    slope, intercept, r, p, _ = stats.linregress(income, share)
    x_line = np.array([income.min(), income.max()])
    ax.plot(x_line, slope * x_line + intercept, color="#e11d48", linewidth=1.5,
            linestyle="--", alpha=0.7, label=f"r={r:.3f}, p={p:.4f}")

    ax.set_xlabel("Tract median household income ($1,000s)")
    ax.set_ylabel("Signalized-stop share (%)")
    ax.set_title(f"Tract Income vs Signalized-Stop Share "
                 f"(>= {MIN_STOPS_PER_TRACT} stops, n = {d.height})", fontweight="bold")
    ax.legend(fontsize=9)
    save_chart(fig, OUT / "tract_income_vs_signal_share.png")


def make_weighted_comparison_chart(quartile_df: pl.DataFrame) -> None:
    """Stop-counted vs ridership-weighted near-side share by income quartile."""
    plt = setup_plotting()
    fig, ax = plt.subplots(figsize=(9, 6))

    order = quartile_df.sort("income_quartile")
    labels = order["income_quartile"].to_list()
    unweighted = (order["nearside_share"] * 100).to_list()
    weighted = (order["rw_nearside_share"] * 100).to_list()

    x = np.arange(len(labels))
    width = 0.38
    b1 = ax.bar(x - width / 2, unweighted, width, label="Per stop (unweighted)",
                color="#64748b", alpha=0.85)
    b2 = ax.bar(x + width / 2, weighted, width, label="Per rider (ridership-weighted)",
                color="#e11d48", alpha=0.85)
    ax.bar_label(b1, fmt="%.1f%%", padding=3, fontsize=9)
    ax.bar_label(b2, fmt="%.1f%%", padding=3, fontsize=9)

    ax.set_xticks(x)
    ax.set_xticklabels(labels, fontsize=9)
    ax.set_ylabel("Near-side share of signalized stops (%)")
    ax.set_ylim(0, max(unweighted + weighted) * 1.18)
    ax.set_title("Near-Side Exposure by Income Quartile: Per Stop vs Per Rider\n"
                 "(flat across quartiles under both weightings)", fontweight="bold")
    ax.legend(fontsize=9)
    save_chart(fig, OUT / "nearside_share_rider_weighted.png")


@run_analysis(58, "Stop Signal Placement Equity")
def main() -> None:
    """Entry point: join signals to tract demographics and test for disparity."""
    with phase("Joining signal class to census-tract demographics"):
        df = build_stop_demographics()
        n_income = df.drop_nulls("median_household_income").height
        print(f"  {df.height} stops joined to a tract; {n_income} have median income")

    with phase("Income-quartile shares (per stop and per rider)"):
        quartile_df = income_quartile_table(df)
        for row in quartile_df.iter_rows(named=True):
            print(f"  {row['income_quartile']:<22s} n={row['n_stops']:5d}  "
                  f"near-side per-stop={row['nearside_share']:.1%}  "
                  f"per-rider={row['rw_nearside_share']:.1%}")

    with phase("Tract-level demographic correlations (per stop and per rider)"):
        tract_df = tract_summary(df)
        sig_df = tract_df.filter(pl.col("n_stops") >= MIN_STOPS_PER_TRACT)
        near_df = tract_df.filter(pl.col("n_signal_classified") >= MIN_SIGNALIZED_PER_TRACT)
        corr_rows = []
        # (outcome label, tract subset, unweighted col, ridership-weighted col)
        outcomes = [
            ("signalized_share", sig_df, "signalized_share", "rw_signalized_share"),
            ("nearside_share", near_df, "nearside_share", "rw_nearside_share"),
        ]
        for name, subset_df, uw_col, rw_col in outcomes:
            print(f"  {name} (n = {subset_df.height} tracts):")
            for col, label in DEMOGRAPHICS:
                cu = correlate(subset_df, col, uw_col)
                cw = correlate(subset_df, col, rw_col)
                print(f"    vs {label:<28s}: per-stop rho={cu['spearman_r']:+.3f} "
                      f"(p={cu['spearman_p']:.3f})  per-rider rho={cw['spearman_r']:+.3f} "
                      f"(p={cw['spearman_p']:.3f})")
                corr_rows.append({"outcome": name, "demographic": col, "weighting": "per_stop",
                                  "spearman_r": cu["spearman_r"], "spearman_p": cu["spearman_p"],
                                  "n": cu["n"]})
                corr_rows.append({"outcome": name, "demographic": col, "weighting": "per_rider",
                                  "spearman_r": cw["spearman_r"], "spearman_p": cw["spearman_p"],
                                  "n": cw["n"]})

    with phase("Saving outputs"):
        save_csv(
            df.select("stop_id", "signal_class", "has_signal", "usage", "geoid",
                      "median_household_income"),
            OUT / "stop_equity.csv",
        )
        save_csv(tract_df.sort("median_household_income"), OUT / "tract_equity_summary.csv")
        save_csv(quartile_df, OUT / "income_quartile_shares.csv")
        save_csv(pl.DataFrame(corr_rows), OUT / "demographic_correlations.csv")

    with phase("Generating charts"):
        make_quartile_chart(quartile_df)
        make_scatter_chart(tract_df)
        make_weighted_comparison_chart(quartile_df)


if __name__ == "__main__":
    main()

Sources

NameTypeWhy It MattersOwnerFreshnessCaveat
data/bus-stop-usage/wprdc_stop_data.csv file Pre-pandemic weekday boardings and alightings per stop (WPRDC), keyed by PRT internal stop_id; used to weight signal-stop placement by ridership. Local project data owner not specified. Snapshot file; refresh by rerunning its pipeline step. May lag upstream source updates.
stop_signals table Primary analytical table used in this page's computations. Produced by PRT Stop-Signal Classification ETL. Updated when the producing pipeline step is rerun. Coverage depends on upstream source availability and ETL assumptions.
Upstream sources (1)
  • file data/prt-stop-signals/bus_stops_with_signals_2602.xlsx — PRT-provided spreadsheet (Samuel Buckley, 2026) tagging each of 6,306 bus stops as no-signal, near-side, far-side, or busway/BRT.
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.
numpy 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.
scipy 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.