Analysis

50 - Bus vs. Rail: Productivity by Mode

Equity and Strategic Planning

Coverage: 2002-01 to 2025-12 (from ntd_ridership).

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

Page Navigation

Analysis Navigation

Data Provenance

flowchart LR
  50_mode_productivity(["50 - Bus vs. Rail: Productivity by Mode"])
  t_ntd_ridership[("ntd_ridership")] --> 50_mode_productivity
  05_ntd_ridership[["NTD Ridership ETL"]] --> t_ntd_ridership
  u1_05_ntd_ridership[/"data/ntd-monthly-ridership/December 2025 Complete Monthly Ridership (with adjustments and estimates)_260202.xlsx"/] --> 05_ntd_ridership
  d1_50_mode_productivity(("polars (lib)")) --> 50_mode_productivity
  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 50_mode_productivity page;
  class t_ntd_ridership table;
  class d1_50_mode_productivity dep;
  class u1_05_ntd_ridership file;
  class 05_ntd_ridership pipeline;

Findings

Findings: Bus vs. Rail — Productivity by Mode

Key findings

  1. Rail runs fuller than bus. In 2024 PRT's light rail "T" carried 25.7 passengers per vehicle revenue hour, against 22.2 for the motor bus network — rail was about 16% more productive per hour of service operated. The common assumption that buses are the workhorse and rail the showpiece has it backwards on this measure: each hour of light rail service moved more riders.

  2. Paratransit drags the agency average far below the bus-and-rail network. PRT's demand-response ACCESS service carried just 1.9 passengers per revenue hour in 2024 — roughly one-twelfth the fixed-route rate. It accounted for only 2.4% of PRT's trips but consumed 22.8% of all revenue hours. This is not waste: door-to-door service for riders with disabilities is inherently low-productivity and is federally required under the Americans with Disabilities Act. But it means the agency-wide productivity figure is a blend that no rider of the bus or "T" actually experiences.

  3. The fixed-route network is meaningfully more productive than the agency number. Analysis 48 reported PRT's agency-wide productivity at about 18 passengers per revenue hour. Strip out paratransit and the incline, and PRT's conventional bus-plus-rail network ran at 22.5 in 2024 — 26% higher. The service most riders use is healthier than the all-mode blend suggests.

  4. The incline is PRT's most productive service — but tiny and volatile. The Monongahela Incline carried 35.2 passengers per revenue hour in 2024, the highest of any mode: a short, frequently-boarded ride up Mount Washington. It is also the smallest, at 0.7% of trips, and its productivity swings sharply year to year because its revenue hours are small and sensitive to maintenance closures (it dropped to 35 in 2020, rebounded to 57 in 2022, then fell again).

  5. The post-2019 decline hit every mode. From 2019 to 2024, productivity fell 34% for motor bus (33.7 → 22.2), 42% for light rail (44.6 → 25.7), 21% for paratransit (2.4 → 1.9), and 62% for the incline (93.3 → 35.2). Light rail's percentage drop was the steeper of the two fixed-route modes — its ridership fell faster than the bus while its service hours were cut less.

  6. Before COVID, bus and rail moved in opposite directions. Motor bus productivity rose from 28.4 in 2002 to a peak near 38 around 2011, then held in the low 30s: PRT cut bus service hours (2.23M → 1.47M VRH) faster than bus ridership fell — a genuine right-sizing. Light rail did the reverse, falling from about 73 in 2002 to 45 by 2019, as the 2012 North Shore Connector extension added service hours without adding proportional ridership. The agency-wide story of "service held flat while riders disappeared" is really two opposite mode trends averaged together.

Limitations

  • Agency and mode level only. NTD reports each mode for the whole agency, so this cannot distinguish a busy bus route from a coverage route, or one rail line from another. The per-mode figures are network averages.
  • The incline series is small-sample and volatile. The incline operates a few thousand revenue hours a year, so its productivity is sensitive to short closures and reporting quirks. Its 2019→2024 change should be read as directional, not precise.
  • Paratransit VRH begins in 2007. The NTD Monthly Module does not report revenue hours for demand-response or incline service before 2007, so those two productivity series — and the all-mode roll-up — start that year. Motor bus and light rail have a complete record from 2002.
  • UPT counts boardings, not riders. A passenger who transfers is counted on each vehicle boarded, so productivity here is boardings per service hour, not unique people moved. This inflates bus productivity slightly relative to rail, since bus trips involve more transfers.
  • VRH excludes deadhead. Revenue hours count only time in passenger service, not pull-out, layover, or repositioning, so this understates total operating effort behind each productive hour — equally across modes.
  • Productivity is not the only goal. Paratransit's low figure reflects a legal service mandate, not inefficiency; a coverage-oriented service is expected to score low. The metric describes how full a service runs, not whether it is worth running.

Validation

  • Data source verified. upt and vrh columns of ntd_ridership, sourced from the NTD Monthly Module workbook (UPT and VRH sheets). The vrh column was added to the table for this analysis; the ETL (src/prt_otp_analysis/ntd_ridership.py) unpivots the VRH sheet on the same agency/mode/TOS/month grain as UPT. No column mapping was written from memory.
  • Temporal scope matches. All four modes are summed over the same calendar years (2002–2024). The all-mode and paratransit/incline series are restricted to 2007–2024, the span where every mode has reported VRH; this restriction is stated in METHODS.md.
  • Null/missing handling. Productivity is computed only where reported VRH is greater than zero. Pre-2007 demand-response and incline months report VRH = 0; these are left null, never treated as real zero-hour service that would yield an infinite ratio.
  • Aggregates sanity-checked. The all-mode 2024 figure (17.8) reconciles with Analysis 48's 18.3: Analysis 48 uses the NTD annual TS2.2 workbook (2024 UPT 37.9M), this analysis uses the NTD Monthly Module (2024 UPT 36.9M). The ~3% gap between the two NTD products is normal cross-source variance; both place PRT near 18. Motor bus 2024 UPT (32.5M) is consistent with PRT carrying ~88% of its trips on the bus.
  • Direction of effects checked. Productivity fell at every mode after 2019, consistent with the documented national post-COVID ridership decline. No directional reversal.
  • Surprising result investigated. Light rail out-productive of motor bus is plausible, not a data error: rail serves dense, high-demand corridors at high frequency, while the bus network includes many low-density coverage routes. The incline ranking highest is also expected — a short ride with rapid boardings packs many trips into few service hours. Both were checked against the underlying UPT and VRH totals.
  • Ecological framing. All results are described as mode-level network averages for PRT, never as statements about individual routes or riders.

Output

Methods

Methods: Bus vs. Rail — Productivity by Mode

Question

Analysis 48 found that PRT's agency-wide service productivity — passengers carried per hour of service operated (UPT / VRH) — has roughly halved, to about 18 passengers per revenue hour in 2024. But that single number blends four very different services: the motor bus network, the light rail "T", the door-to-door ACCESS paratransit service, and the Monongahela Incline. This analysis splits the agency figure apart. How productive is each mode? Is the low agency average dragged down by one service? And has the post-2019 decline hit bus and rail equally?

Approach

  • Define productivity exactly as Analysis 48 does — unlinked passenger trips per vehicle revenue hour, productivity = UPT / VRH — but compute it per mode instead of agency-wide.
  • Pull PRT's monthly UPT and VRH by mode from ntd_ridership and aggregate to calendar years. The four NTD mode codes map to: MB = Motor Bus, LR = Light Rail, DR = Paratransit (demand-response ACCESS service), IP = Incline.
  • Compute productivity only for mode-years with reported VRH > 0. The NTD Monthly Module does not report VRH for DR or IP before 2007, so their productivity series begin in 2007; MB and LR have a complete VRH record from 2002. Mode-years without VRH are left null, never treated as zero.
  • Build two roll-ups: fixed-route (MB + LR, the conventional bus + rail network) and all-mode (every mode, which reproduces the agency-wide figure of Analysis 48). The all-mode series is reported only for 2007–2024, the span where every mode has VRH.
  • Compare modes three ways: a 2024 productivity snapshot, the 2019→2024 change (the COVID window used by Analysis 48), and each mode's 2024 share of trips versus its share of revenue hours — which exposes whether a mode consumes service out of proportion to the riders it carries.
  • Reconcile the all-mode 2024 figure against Analysis 48's 18.3, which is built from a different NTD source (the annual TS2.2 workbook).

Data

  • ntd_ridership: columns ntd_id, mode, tos, month, upt, vrh. Filtered to ntd_id = 30022 (PRT). Monthly records summed to calendar years 2002–2024. The vrh and vrm columns were added to this table specifically to support a per-mode productivity breakdown — the agency-level ntd_annual_service table used by Analysis 48 cannot be split by mode.
  • The IP mode has two type-of-service records (IP/DO and IP/PT); the purchased-transportation record ends in 2012. Summing UPT and VRH across both records for each month combines them into one Incline series.

Output

  • output/mode_productivity_by_year.csvUPT, VRH, and productivity for every mode and calendar year 2002–2024.
  • output/mode_summary.csv — per mode (plus fixed-route and all-mode roll-ups): productivity at 2002/2007, 2019, and 2024, the 2019→2024 percent change, and the 2024 shares of trips and revenue hours.
  • output/mode_productivity_trends.png — productivity by mode, 2002–2024.
  • output/mode_productivity_2024.png — bar ranking of 2024 productivity by mode, with the fixed-route and all-mode roll-ups marked.
  • output/service_vs_ridership_2024.png — each mode's share of 2024 trips versus its share of 2024 revenue hours.
  • output/productivity_decline_2019_2024.png — productivity by mode in 2019 versus 2024.

Source Code

"""Analysis 50: splits PRT's agency-wide service productivity (passengers per
vehicle revenue hour) into its four modes — motor bus, light rail, paratransit,
and the incline — to see which service the low agency average is hiding."""

import polars as pl

from prt_otp_analysis.common import (
    COLOR_GRAY,
    analysis_dir,
    phase,
    query_to_polars,
    run_analysis,
    save_chart,
    save_csv,
    setup_plotting,
)
from prt_otp_analysis.common.schemas import NTD_RIDERSHIP, validate

OUT = analysis_dir(__file__)

PRT_NTD_ID = 30022
START_YEAR = 2002
BASELINE_YEAR = 2019
COMPARE_YEAR = 2024
# VRH for paratransit and the incline is first reported in 2007; before that the
# all-mode roll-up would understate revenue hours and overstate productivity.
ALL_MODE_START = 2007

# NTD mode codes -> readable names. PRT runs exactly these four.
MODE_NAMES = {
    "MB": "Motor Bus",
    "LR": "Light Rail",
    "DR": "Paratransit",
    "IP": "Incline",
}
MODE_ORDER = ["MB", "LR", "DR", "IP"]
MODE_COLORS = {
    "MB": "#3b82f6",  # blue   — bus
    "LR": "#22c55e",  # green  — rail
    "DR": "#f59e0b",  # amber  — paratransit
    "IP": "#a855f7",  # purple — incline
}
FIXED_ROUTE_MODES = ["MB", "LR"]
ROLLUP_NAMES = {
    "FIXED_ROUTE": "Fixed route (bus + rail)",
    "ALL_MODE": "All modes (agency-wide)",
}


def load_mode_year() -> pl.DataFrame:
    """Load PRT monthly UPT/VRH by mode and aggregate to calendar years.

    Productivity (UPT / VRH) is computed only where VRH > 0; mode-years with no
    reported revenue hours are left null rather than counted as zero service.
    """
    raw_df = query_to_polars(
        "SELECT mode, month, upt, vrh FROM ntd_ridership WHERE ntd_id = ?",
        (PRT_NTD_ID,),
    )
    validate(raw_df, NTD_RIDERSHIP, subset=True)

    year_df = (
        raw_df.with_columns(year=pl.col("month").str.slice(0, 4).cast(pl.Int64))
        .filter(pl.col("year").is_between(START_YEAR, COMPARE_YEAR))
        .group_by("mode", "year")
        .agg(upt=pl.col("upt").sum(), vrh=pl.col("vrh").sum())
        .sort("mode", "year")
    )
    return year_df.with_columns(
        productivity=pl.when(pl.col("vrh") > 0)
        .then(pl.col("upt") / pl.col("vrh"))
        .otherwise(None)
    )


def rollup(mode_df: pl.DataFrame, modes: list[str], label: str) -> pl.DataFrame:
    """Sum UPT and VRH across a set of modes into one labelled productivity series."""
    sub = mode_df.filter(pl.col("mode").is_in(modes))
    agg = (
        sub.group_by("year")
        .agg(upt=pl.col("upt").sum(), vrh=pl.col("vrh").sum())
        .sort("year")
    )
    return agg.with_columns(
        mode=pl.lit(label),
        productivity=pl.when(pl.col("vrh") > 0)
        .then(pl.col("upt") / pl.col("vrh"))
        .otherwise(None),
    ).select("mode", "year", "upt", "vrh", "productivity")


def _val(df: pl.DataFrame, mode: str, year: int, col: str) -> float | None:
    """Return one cell from a long mode/year frame, or None if absent."""
    rows = df.filter((pl.col("mode") == mode) & (pl.col("year") == year))
    return rows.row(0, named=True)[col] if len(rows) else None


def build_summary(mode_df: pl.DataFrame, rollup_df: pl.DataFrame) -> pl.DataFrame:
    """One row per mode (and roll-up): productivity at key years and 2024 shares."""
    compare_df = mode_df.filter(pl.col("year") == COMPARE_YEAR)
    total_upt = compare_df["upt"].sum()
    total_vrh = compare_df["vrh"].sum()

    rows = []
    for mode in MODE_ORDER + ["FIXED_ROUTE", "ALL_MODE"]:
        src = rollup_df if mode in ROLLUP_NAMES else mode_df
        p07 = _val(src, mode, ALL_MODE_START, "productivity")
        p19 = _val(src, mode, BASELINE_YEAR, "productivity")
        p24 = _val(src, mode, COMPARE_YEAR, "productivity")
        upt24 = _val(src, mode, COMPARE_YEAR, "upt")
        vrh24 = _val(src, mode, COMPARE_YEAR, "vrh")
        rows.append({
            "mode": ROLLUP_NAMES.get(mode, MODE_NAMES.get(mode, mode)),
            "prod_2007": p07,
            "prod_2019": p19,
            "prod_2024": p24,
            "pct_change_2019_2024": (p24 - p19) / p19 * 100 if p19 and p24 else None,
            "upt_share_2024": upt24 / total_upt * 100 if upt24 else None,
            "vrh_share_2024": vrh24 / total_vrh * 100 if vrh24 else None,
        })
    return pl.DataFrame(rows)


def chart_trends(plt, mode_df: pl.DataFrame) -> None:
    """Productivity by mode, 2002-2024 — one line per mode."""
    fig, ax = plt.subplots(figsize=(12, 6))
    for mode in MODE_ORDER:
        sub = mode_df.filter(
            (pl.col("mode") == mode) & pl.col("productivity").is_not_null()
        ).sort("year")
        ax.plot(sub["year"].to_list(), sub["productivity"].to_list(),
                label=MODE_NAMES[mode], color=MODE_COLORS[mode],
                linewidth=2.5, marker="o", markersize=3)

    ax.axvline(2020, color=COLOR_GRAY, linestyle="--", linewidth=1)
    ax.text(2020.3, ax.get_ylim()[1] * 0.95, "COVID-19", fontsize=9, color="#555555")
    ax.set_ylabel("Passengers per Vehicle Revenue Hour")
    ax.set_xlabel("Year")
    ax.set_ylim(bottom=0)
    ax.set_title("PRT Service Productivity by Mode, 2002–2024")
    ax.legend()
    save_chart(fig, OUT / "mode_productivity_trends.png")


def chart_2024(plt, mode_df: pl.DataFrame, rollup_df: pl.DataFrame) -> None:
    """Bar ranking of 2024 productivity by mode, with roll-up reference lines."""
    fig, ax = plt.subplots(figsize=(10, 6))
    snap = mode_df.filter(pl.col("year") == COMPARE_YEAR).sort("productivity")
    labels = [MODE_NAMES[m] for m in snap["mode"].to_list()]
    vals = snap["productivity"].to_list()
    colors = [MODE_COLORS[m] for m in snap["mode"].to_list()]

    ax.barh(labels, vals, color=colors, edgecolor="white")
    for i, v in enumerate(vals):
        ax.text(v + 0.4, i, f"{v:.1f}", va="center", fontsize=10, fontweight="bold")

    fixed = _val(rollup_df, "FIXED_ROUTE", COMPARE_YEAR, "productivity")
    allm = _val(rollup_df, "ALL_MODE", COMPARE_YEAR, "productivity")
    ax.axvline(fixed, color="#1f2937", linestyle="--", linewidth=1.4)
    ax.text(fixed + 0.4, -0.55, f"Fixed route (bus + rail): {fixed:.1f}",
            fontsize=8.5, color="#1f2937")
    ax.axvline(allm, color=COLOR_GRAY, linestyle=":", linewidth=1.4)
    ax.text(allm + 0.4, -0.85, f"All modes: {allm:.1f}", fontsize=8.5, color="#555555")

    ax.set_xlabel("Passengers per Vehicle Revenue Hour")
    ax.set_title("PRT Service Productivity by Mode, 2024")
    ax.set_ylim(-1.1, len(labels) - 0.4)
    save_chart(fig, OUT / "mode_productivity_2024.png")


def chart_service_vs_ridership(plt, summary_df: pl.DataFrame) -> None:
    """Each mode's 2024 share of trips vs its share of revenue hours."""
    fig, ax = plt.subplots(figsize=(10, 6))
    modes = [MODE_NAMES[m] for m in MODE_ORDER]
    sub = summary_df.filter(pl.col("mode").is_in(modes))
    upt_share = sub["upt_share_2024"].to_list()
    vrh_share = sub["vrh_share_2024"].to_list()

    y = list(range(len(modes)))
    h = 0.38
    ax.barh([i + h / 2 for i in y], upt_share, height=h,
            color="#2563eb", label="Share of trips (UPT)")
    ax.barh([i - h / 2 for i in y], vrh_share, height=h,
            color="#f59e0b", label="Share of service hours (VRH)")
    for i in y:
        ax.text(upt_share[i] + 0.8, i + h / 2, f"{upt_share[i]:.1f}%",
                va="center", fontsize=8.5)
        ax.text(vrh_share[i] + 0.8, i - h / 2, f"{vrh_share[i]:.1f}%",
                va="center", fontsize=8.5)

    ax.set_yticks(y)
    ax.set_yticklabels(modes)
    ax.set_xlabel("Share of PRT total, 2024 (%)")
    ax.set_title("Who Carries the Riders vs. Who Uses the Service Hours, 2024")
    ax.legend()
    save_chart(fig, OUT / "service_vs_ridership_2024.png")


def chart_decline(plt, mode_df: pl.DataFrame) -> None:
    """Productivity by mode in 2019 vs 2024."""
    fig, ax = plt.subplots(figsize=(10, 6))
    p19 = [_val(mode_df, m, BASELINE_YEAR, "productivity") for m in MODE_ORDER]
    p24 = [_val(mode_df, m, COMPARE_YEAR, "productivity") for m in MODE_ORDER]

    x = list(range(len(MODE_ORDER)))
    w = 0.38
    ax.bar([i - w / 2 for i in x], p19, width=w, color=COLOR_GRAY, label="2019")
    ax.bar([i + w / 2 for i in x], p24, width=w,
           color=[MODE_COLORS[m] for m in MODE_ORDER], label="2024")
    for i in x:
        pct = (p24[i] - p19[i]) / p19[i] * 100
        ax.text(i, max(p19[i], p24[i]) + 1.5, f"{pct:+.0f}%", ha="center",
                fontsize=9, fontweight="bold", color="#b91c1c")

    ax.set_xticks(x)
    ax.set_xticklabels([MODE_NAMES[m] for m in MODE_ORDER])
    ax.set_ylabel("Passengers per Vehicle Revenue Hour")
    ax.set_title("Productivity Fell at Every Mode — 2019 vs. 2024")
    ax.legend()
    save_chart(fig, OUT / "productivity_decline_2019_2024.png")


@run_analysis(50, "Bus vs. Rail — Productivity by Mode")
def main():
    plt = setup_plotting()

    with phase("Loading per-mode UPT and VRH"):
        mode_df = load_mode_year()
        rollup_df = pl.concat([
            rollup(mode_df, FIXED_ROUTE_MODES, "FIXED_ROUTE"),
            rollup(mode_df, MODE_ORDER, "ALL_MODE"),
        ])
        print(f"   {len(mode_df)} mode-years across {mode_df['mode'].n_unique()} modes")
        print(f"   record: {mode_df['year'].min()}–{mode_df['year'].max()}")

    with phase("2024 productivity by mode"):
        for mode in MODE_ORDER:
            p = _val(mode_df, mode, COMPARE_YEAR, "productivity")
            print(f"   {MODE_NAMES[mode]:<12s} {p:6.1f} pax/VRH")
        fixed = _val(rollup_df, "FIXED_ROUTE", COMPARE_YEAR, "productivity")
        allm = _val(rollup_df, "ALL_MODE", COMPARE_YEAR, "productivity")
        print(f"   {'Fixed route':<12s} {fixed:6.1f} pax/VRH  (bus + rail only)")
        print(f"   {'All modes':<12s} {allm:6.1f} pax/VRH  (cf. Analysis 48: 18.3)")

    with phase("Building per-mode summary"):
        summary_df = build_summary(mode_df, rollup_df)
        print(f"   {'Mode':<26s} {'2007':>7s} {'2019':>7s} {'2024':>7s} {'19→24':>8s}")
        for r in summary_df.iter_rows(named=True):
            def fmt(v: float | None) -> str:
                return f"{v:7.1f}" if v is not None else f"{'—':>7s}"
            pct = (f"{r['pct_change_2019_2024']:+6.0f}%"
                   if r["pct_change_2019_2024"] is not None else f"{'—':>8s}")
            print(f"   {r['mode']:<26s} {fmt(r['prod_2007'])} {fmt(r['prod_2019'])} "
                  f"{fmt(r['prod_2024'])} {pct}")

    with phase("Saving data"):
        save_csv(
            mode_df.with_columns(mode_name=pl.col("mode").replace(MODE_NAMES))
            .select("year", "mode", "mode_name", "upt", "vrh", "productivity"),
            OUT / "mode_productivity_by_year.csv",
        )
        save_csv(summary_df, OUT / "mode_summary.csv")

    with phase("Generating charts"):
        chart_trends(plt, mode_df)
        chart_2024(plt, mode_df, rollup_df)
        chart_service_vs_ridership(plt, summary_df)
        chart_decline(plt, mode_df)


if __name__ == "__main__":
    main()

Sources

NameTypeWhy It MattersOwnerFreshnessCaveat
ntd_ridership table Primary analytical table used in this page's computations. Produced by NTD Ridership ETL. Updated when the producing pipeline step is rerun. Coverage depends on upstream source availability and ETL assumptions.
Upstream sources (1)
  • file data/ntd-monthly-ridership/December 2025 Complete Monthly Ridership (with adjustments and estimates)_260202.xlsx — NTD monthly ridership workbook containing agency metadata and UPT series.
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.