Analysis

52 - VRH and Vehicle Revenue Miles

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
  52_vrh_with_miles(["52 - VRH and Vehicle Revenue Miles"])
  t_ntd_ridership[("ntd_ridership")] --> 52_vrh_with_miles
  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
  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 52_vrh_with_miles page;
  class t_ntd_ridership table;
  class u1_05_ntd_ridership file;
  class 05_ntd_ridership pipeline;

Findings

Findings: VRH and Vehicle Revenue Miles

Summary

PRT's bus operating speed has been remarkably stable at roughly 12.8–13.0 mph over the full 2002–2024 record, suggesting that traffic congestion has not measurably degraded fixed-route service speeds system-wide. Bus and light rail VRH and VRM have both declined sharply since 2002 (~35% and ~40% respectively), but because they track each other closely, the speed ratio has stayed flat. The most notable speed shift is in paratransit, which slowed from 15.2 mph in 2019 to 13.8 mph in 2024 (−9%), likely reflecting longer or more complex trip patterns.

Key Numbers

Mode 2019 mph 2024 mph Change
Motor Bus 12.85 12.83 −0.2%
Light Rail 12.96 12.95 −0.0%
Paratransit 15.18 13.80 −9.1%

Observations

  • Bus speed is flat over 22 years. Motor bus VRM/VRH has barely moved — from 13.0 mph in 2002 to 12.8 mph in 2024. The COVID dip in 2021 (12.47 mph) reversed quickly. There is no evidence of a long-run speed decline.
  • Service volume fell substantially. Bus VRH fell from ~2.2 M hours in 2002 to ~1.47 M in 2024, a 34% drop. VRM fell in nearly equal proportion, keeping speed constant. This means the system is running fewer hours and fewer miles — a contraction in service, not a slowdown.
  • Light rail is similarly stable. Speed fluctuated 11–13.5 mph in early years (likely tied to service restructuring around the South Hills extensions) and has settled near 13 mph since 2006.
  • Paratransit speed decline is the standout finding. ACCESS paratransit went from 15.2 mph in 2019 to 13.8 mph in 2024. Over the same period VRH grew faster than VRM (VRH up ~24% from 2019 baseline vs VRM up ~25%), so the decline is small but consistent. This may reflect shifts toward more complex trip routing or longer average trip distances.
  • Paratransit VRH and VRM both grew sharply post-2007 (when NTD began reporting them) to become a large fraction of agency-wide service hours by 2024 (~24% of all VRH).

Caveats

  • NTD data is agency-reported and aggregated annually; it cannot distinguish speed changes on specific corridors or times of day.
  • Paratransit data (VRH and VRM) is not available before 2007, so the long-run speed trend for that mode is incomplete.
  • The Incline (IP) is excluded from speed analysis; its 2.3 mph figure reflects extremely short, near-vertical track rather than congestion or service efficiency.
  • "Speed" here is an average across all trips for the year and does not capture peak-hour vs. off-peak variation.

Validation

  1. Data source verified. ntd_ridership columns confirmed against DATA_DICTIONARY.md; mode codes validated against Analysis 50 constants.
  2. Temporal scope. All data filtered to 2002–2024 calendar years; paratransit zeros before 2007 treated as no service and excluded from speed calculations (VRH = 0 guard).
  3. Plausibility. Bus speeds of 12–13 mph are consistent with published literature for U.S. urban bus systems. Paratransit speeds (13–15 mph) are plausible for demand-responsive service with deadheading.
  4. Surprising result investigated. The paratransit speed decline (−9%) was checked against raw VRH and VRM year-by-year; the trend is consistent across 2020–2024 and not attributable to a single outlier year.
  5. Known relationships verified. VRH and VRM track each other for bus and rail as expected (stable speed). No reversals noted.

Output

Methods

Methods: VRH and Vehicle Revenue Miles

Question

How has PRT's operating speed (vehicle revenue miles per vehicle revenue hour) changed over time, and does that speed vary by mode? Specifically, has bus speed declined in recent years — a signal of growing traffic congestion or service restructuring — relative to light rail and paratransit?

Approach

  1. Load monthly VRH and VRM from ntd_ridership for PRT (ntd_id = 30022) for 2002–2024.
  2. Aggregate to annual totals by mode.
  3. Compute average operating speed in miles per hour: mph = vrm / vrh (only where vrh > 0).
  4. Plot speed (mph) by mode over time — one line per mode — to reveal trends.
  5. Plot indexed VRH and VRM for each mode (base = 2019 = 100) to show whether hours and miles diverged post-pandemic.
  6. Produce a summary table of mph at key years (2007, 2019, 2024) and percent change.
  7. Exclude the Incline (IP) from speed comparisons because its extremely short track and near-vertical geometry make the mph figure uninformative for congestion analysis.

Data

  • ntd_ridership (ntd_id, mode, month, vrh, vrm): PRT-only rows filtered to modes MB, LR, DR, IP.
  • Rows with vrh IS NULL or vrh = 0 are excluded from speed calculations.
  • Calendar year aggregates: sum VRH and VRM across all months for a given mode-year.

Output

  • output/speed_by_mode.csv — annual VRH, VRM, and mph by mode (2002–2024)
  • output/speed_trends.png — time-series of mph by mode (bus, rail, paratransit), 2002–2024
  • output/vrh_vrm_index.png — indexed VRH and VRM for each mode (2019 = 100)
  • output/mode_speed_summary.csv — mph at key years and % change 2019→2024

Source Code

"""Analysis 52: trends in PRT's vehicle revenue hours and vehicle revenue miles
by mode, and what the VRM/VRH ratio (operating speed) reveals about service change."""

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

MODE_NAMES = {
    "MB": "Motor Bus",
    "LR": "Light Rail",
    "DR": "Paratransit",
    "IP": "Incline",
}
# IP excluded from speed analysis — short vertical track makes mph uninformative.
SPEED_MODES = ["MB", "LR", "DR"]
MODE_COLORS = {
    "MB": "#3b82f6",
    "LR": "#22c55e",
    "DR": "#f59e0b",
}


def load_mode_year() -> pl.DataFrame:
    """Load PRT monthly VRH/VRM by mode and aggregate to calendar years."""
    raw_df = query_to_polars(
        "SELECT mode, month, vrh, vrm FROM ntd_ridership WHERE ntd_id = ?",
        (PRT_NTD_ID,),
    )
    validate(raw_df, NTD_RIDERSHIP, subset=True)

    return (
        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(vrh=pl.col("vrh").sum(), vrm=pl.col("vrm").sum())
        .sort("mode", "year")
        .with_columns(
            mph=pl.when(pl.col("vrh") > 0)
            .then(pl.col("vrm") / pl.col("vrh"))
            .otherwise(None)
        )
    )


def _val(df: pl.DataFrame, mode: str, year: int, col: str) -> float | None:
    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) -> pl.DataFrame:
    rows = []
    for mode in SPEED_MODES:
        mph07 = _val(mode_df, mode, 2007, "mph")
        mph19 = _val(mode_df, mode, BASELINE_YEAR, "mph")
        mph24 = _val(mode_df, mode, COMPARE_YEAR, "mph")
        rows.append({
            "mode": MODE_NAMES[mode],
            "mph_2007": mph07,
            "mph_2019": mph19,
            "mph_2024": mph24,
            "pct_change_2019_2024": (mph24 - mph19) / mph19 * 100
            if mph19 and mph24 else None,
        })
    return pl.DataFrame(rows)


def chart_speed_trends(plt, mode_df: pl.DataFrame) -> None:
    """Operating speed (mph) by mode, 2002–2024."""
    fig, ax = plt.subplots(figsize=(12, 6))
    for mode in SPEED_MODES:
        sub = mode_df.filter(
            (pl.col("mode") == mode) & pl.col("mph").is_not_null()
        ).sort("year")
        ax.plot(
            sub["year"].to_list(),
            sub["mph"].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("Miles per Vehicle Revenue Hour (mph)")
    ax.set_xlabel("Year")
    ax.set_ylim(bottom=0)
    ax.set_title(
        "PRT Operating Speed by Mode, 2002–2024\n"
        "(Vehicle Revenue Miles ÷ Vehicle Revenue Hours)"
    )
    ax.legend()
    save_chart(fig, OUT / "speed_trends.png")


def chart_vrh_vrm_index(plt, mode_df: pl.DataFrame) -> None:
    """Indexed VRH and VRM by mode (2019 = 100)."""
    fig, axes = plt.subplots(1, len(SPEED_MODES), figsize=(15, 5), sharey=True)
    fig.suptitle(
        "PRT Service Volume by Mode — VRH and VRM Indexed to 2019", fontsize=13
    )

    for ax, mode in zip(axes, SPEED_MODES):
        sub = mode_df.filter(
            (pl.col("mode") == mode)
            & pl.col("vrh").is_not_null()
            & pl.col("vrm").is_not_null()
        ).sort("year")

        base_vrh = _val(sub, mode, BASELINE_YEAR, "vrh")
        base_vrm = _val(sub, mode, BASELINE_YEAR, "vrm")
        if not base_vrh or not base_vrm:
            continue

        years = sub["year"].to_list()
        vrh_idx = [v / base_vrh * 100 for v in sub["vrh"].to_list()]
        vrm_idx = [v / base_vrm * 100 for v in sub["vrm"].to_list()]

        ax.plot(years, vrh_idx, label="VRH", color="#2563eb", linewidth=2)
        ax.plot(
            years, vrm_idx, label="VRM", color="#dc2626", linewidth=2, linestyle="--"
        )
        ax.axhline(100, color=COLOR_GRAY, linewidth=0.8, linestyle=":")
        ax.axvline(2020, color=COLOR_GRAY, linestyle="--", linewidth=0.8)
        ax.set_title(MODE_NAMES[mode])
        ax.set_xlabel("Year")
        if ax == axes[0]:
            ax.set_ylabel("Index (2019 = 100)")
        ax.legend(fontsize=8)

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


@run_analysis(52, "VRH and Vehicle Revenue Miles")
def main():
    plt = setup_plotting()

    with phase("Loading VRH and VRM data"):
        mode_df = load_mode_year()
        print(
            f"   {len(mode_df)} mode-years; range "
            f"{mode_df['year'].min()}–{mode_df['year'].max()}"
        )

    with phase("Operating speed by mode"):
        for mode in SPEED_MODES:
            mph19 = _val(mode_df, mode, BASELINE_YEAR, "mph")
            mph24 = _val(mode_df, mode, COMPARE_YEAR, "mph")
            delta = (
                (mph24 - mph19) / mph19 * 100
                if mph19 and mph24
                else float("nan")
            )
            print(
                f"   {MODE_NAMES[mode]:<12s} "
                f"2019: {mph19:5.2f} mph  "
                f"2024: {mph24:5.2f} mph  ({delta:+.1f}%)"
            )

    with phase("Building summary"):
        summary_df = build_summary(mode_df)

    with phase("Saving data"):
        save_csv(
            mode_df.with_columns(mode_name=pl.col("mode").replace(MODE_NAMES)),
            OUT / "speed_by_mode.csv",
        )
        save_csv(summary_df, OUT / "mode_speed_summary.csv")

    with phase("Generating charts"):
        chart_speed_trends(plt, mode_df)
        chart_vrh_vrm_index(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.