Chris Parmer — home

Marey / string-line diagram for Amtrak Lincoln Service

Rail — CHI-STL

Example from the compendium of canonical charts

Rail — Marey / string-line diagram for Amtrak Lincoln Service (CHI-STL)

Python Code

"""Rail — Marey / string-line diagram for Amtrak Lincoln Service (CHI-STL)."""


import numpy as np
import pandas as pd
import plotly.graph_objects as go
import io, zipfile, requests, warnings, math


GTFS_URL = "https://content.amtrak.com/content/gtfs/GTFS.zip"

STATION_NAMES = ["Chicago Union Station", "Joliet", "Pontiac",
                 "Bloomington-Normal", "Springfield", "Carlinville", "Alton",
                 "St. Louis"]
MILE_POSTS = [0, 37, 97, 136, 188, 228, 270, 284]
DWELL_MIN  = 3   # minutes at each intermediate stop


def haversine(lat1, lon1, lat2, lon2):
    R = 3958.8
    phi1, phi2 = math.radians(lat1), math.radians(lat2)
    dphi = math.radians(lat2 - lat1)
    dlam = math.radians(lon2 - lon1)
    a = math.sin(dphi/2)**2 + math.cos(phi1)*math.cos(phi2)*math.sin(dlam/2)**2
    return 2 * R * math.asin(math.sqrt(a))


def parse_time(s):
    try:
        parts = str(s).strip().split(":")
        return int(parts[0]) * 60 + int(parts[1]) + int(parts[2]) / 60
    except Exception:
        return None


def train_times(dep_min, mile_posts, speed_mph, dwell_min):
    """Return list of (arrival_min, departure_min) pairs per station.
    Works for both southbound (ascending mileposts) and northbound (descending)."""
    origin_mp = mile_posts[0]
    dwell_elapsed = 0
    pairs = []
    for j, mp in enumerate(mile_posts):
        travel = abs(mp - origin_mp) / speed_mph * 60
        arrival = dep_min + travel + dwell_elapsed
        if j > 0 and j < len(mile_posts) - 1:
            departure = arrival + dwell_min
            dwell_elapsed += dwell_min
        else:
            departure = arrival
        pairs.append((arrival, departure))
    return pairs


def add_train(fig, dep_min, mile_posts, speed_mph, dwell_min, color, name):
    """Add a single train trace with visible station dwell segments."""
    pairs = train_times(dep_min, mile_posts, speed_mph, dwell_min)
    x_pts, y_pts = [], []
    for j, ((arr, dep), mp) in enumerate(zip(pairs, mile_posts)):
        x_pts.append(arr / 60)
        y_pts.append(mp)
        if arr != dep:  # show dwell as horizontal segment
            x_pts.append(dep / 60)
            y_pts.append(mp)
    fig.add_trace(go.Scatter(
        x=x_pts, y=y_pts,
        mode="lines+markers",
        line=dict(color=color, width=4),
        marker=dict(size=9, color=color),
        name=name,
        hovertemplate="<b>%{fullData.name}</b><br>Time: %{x:.2f}h<br>Mile: %{y:.0f}<extra></extra>",
    ))


def build_synthetic_fig():
    speed_mph = 60
    # Southbound departures from Chicago (hours from midnight)
    sb_deps = [7*60, 11*60 + 30, 16*60]
    # Northbound departures from St. Louis
    nb_deps = [8*60, 13*60, 17*60 + 30]

    sb_colors = [VIOLET, GREEN, TEAL]
    nb_colors = [TEAL, PINK, MUTED]

    fig = go.Figure()

    trains = sorted(
        [(dep, MILE_POSTS, sb_colors[i], f"SB-{i+1}") for i, dep in enumerate(sb_deps)] +
        [(dep, list(reversed(MILE_POSTS)), nb_colors[i], f"NB-{i+1}") for i, dep in enumerate(nb_deps)],
        key=lambda t: t[0],
    )
    for dep, miles, color, name in trains:
        add_train(fig, dep, miles, speed_mph, DWELL_MIN, color, name)

    for mp in MILE_POSTS:
        fig.add_hline(y=mp, line=dict(color="#e0e0e0", width=0.8))

    fig.update_layout(
        xaxis=dict(
            title="Time of day",
            range=[5.8, 23.5],
            tickvals=list(range(6, 24, 2)),
            ticktext=[f"{h}:00" for h in range(6, 24, 2)],
            showgrid=False,
        ),
        yaxis=dict(
            title="Distance from Chicago (miles)",
            range=[-8, 295],
            showgrid=False,
            showticklabels=True,
            tickvals=MILE_POSTS,
            ticktext=STATION_NAMES,
            tickfont=dict(size=12, color="#1a1a1a"),
        ),
        legend=dict(orientation="h", x=0, y=1.0, xanchor="left", yanchor="bottom", font=dict(size=11)),
        margin=dict(t=50, b=50, l=160, r=40),
    )
    return fig


def try_gtfs():
    """Attempt to build figure from Amtrak GTFS."""
    warnings.filterwarnings("ignore")
    requests.packages.urllib3.disable_warnings()
    r = requests.get(GTFS_URL, verify=False, timeout=60)
    r.raise_for_status()
    zf = zipfile.ZipFile(io.BytesIO(r.content))

    routes_df  = pd.read_csv(io.BytesIO(zf.read("routes.txt")))
    trips_df   = pd.read_csv(io.BytesIO(zf.read("trips.txt")))
    stop_times = pd.read_csv(io.BytesIO(zf.read("stop_times.txt")))
    stops      = pd.read_csv(io.BytesIO(zf.read("stops.txt")))

    routes_df["_name_str"] = routes_df["route_long_name"].fillna("").astype(str)
    lincoln_routes = routes_df[routes_df["_name_str"].str.contains("Lincoln", case=False)]
    if lincoln_routes.empty:
        raise ValueError("No Lincoln routes found")

    route_ids = lincoln_routes["route_id"].tolist()
    lincoln_trips = trips_df[trips_df["route_id"].isin(route_ids)]
    trip_ids = lincoln_trips["trip_id"].tolist()

    lincoln_st = stop_times[stop_times["trip_id"].isin(trip_ids)].copy()
    lincoln_st = lincoln_st.merge(
        stops[["stop_id", "stop_lat", "stop_lon", "stop_name"]], on="stop_id", how="left")
    lincoln_st["dep_min"] = lincoln_st["departure_time"].apply(parse_time)
    lincoln_st = lincoln_st.dropna(subset=["dep_min"]).sort_values(
        ["trip_id", "stop_sequence"])
    if lincoln_st.empty:
        raise ValueError("No valid stop times")

    # Build cumulative distance from reference southbound trip
    ref_trip = lincoln_trips[lincoln_trips["direction_id"] == 0]["trip_id"].iloc[0]
    ref_stops = lincoln_st[lincoln_st["trip_id"] == ref_trip].sort_values("stop_sequence")
    lats, lons = ref_stops["stop_lat"].values, ref_stops["stop_lon"].values
    cum_dist = [0.0]
    for i in range(1, len(lats)):
        if pd.notna(lats[i]) and pd.notna(lats[i-1]):
            cum_dist.append(cum_dist[-1] + haversine(lats[i-1], lons[i-1], lats[i], lons[i]))
        else:
            cum_dist.append(cum_dist[-1])
    seq_to_dist = dict(zip(ref_stops["stop_sequence"].values, cum_dist))
    total_dist = cum_dist[-1]
    station_labels = list(zip(ref_stops["stop_name"].tolist(), cum_dist))

    color_cycle = [VIOLET, GREEN, TEAL, TEAL, PINK, MUTED, "#e63030", "#a0a0a0"]
    fig = go.Figure()

    trip_ids = sorted(trip_ids, key=lambda tid: lincoln_st[lincoln_st["trip_id"] == tid]["dep_min"].min())

    for i, tid in enumerate(trip_ids):
        trip = lincoln_st[lincoln_st["trip_id"] == tid].sort_values("stop_sequence")
        seqs = trip["stop_sequence"].values
        dists_mapped = [seq_to_dist.get(s) for s in seqs]
        trip = trip.copy()
        trip["dist_mi"] = dists_mapped
        trip = trip.dropna(subset=["dist_mi"])
        if len(trip) < 2:
            continue

        dir_id = lincoln_trips[lincoln_trips["trip_id"] == tid]["direction_id"].values
        if len(dir_id) and dir_id[0] == 1:
            trip["dist_mi"] = total_dist - trip["dist_mi"]
            direction = "NB"
        else:
            direction = "SB"

        fig.add_trace(go.Scatter(
            x=trip["dep_min"] / 60,
            y=trip["dist_mi"],
            mode="lines+markers",
            line=dict(color=color_cycle[i % len(color_cycle)], width=4),
            marker=dict(size=9),
            name=f"{direction}-{tid}",
            hovertemplate="<b>%{fullData.name}</b><br>Time: %{x:.2f}h<br>Mile: %{y:.1f}<extra></extra>",
        ))

    for _, dist in station_labels:
        fig.add_hline(y=dist, line=dict(color="#e0e0e0", width=0.8))

    fig.update_layout(
        xaxis=dict(
            title="Time of day",
            tickvals=list(range(4, 25, 2)),
            ticktext=[f"{h}:00" for h in range(4, 25, 2)],
            range=[3.8, 24],
            showgrid=False,
        ),
        yaxis=dict(
            title="Distance from Chicago (miles)",
            range=[-5, total_dist + 10],
            showgrid=False,
            showticklabels=True,
            tickvals=[dist for _, dist in station_labels],
            ticktext=[name for name, _ in station_labels],
            tickfont=dict(size=12, color="#1a1a1a"),
        ),
        legend=dict(orientation="h", x=0, y=1.0, xanchor="left", yanchor="bottom", font=dict(size=11)),
        margin=dict(t=50, b=50, l=160, r=40),
    )
    return fig


def generate():
    print("building Amtrak Lincoln Service Marey diagram …")
    try:
        fig = try_gtfs()
        print("  using real GTFS data")
    except Exception as e:
        print(f"  GTFS failed ({e}), using synthetic timetable")
        fig = build_synthetic_fig()
    return fig


if __name__ == "__main__":
    generate()

Made with Plotly