A park-and-ride site is where two graph layers with different rules meet, and the join is more constrained than an ordinary transfer. A traveller can leave a car and board a train; they cannot board a car they did not arrive in. This page covers modelling that asymmetry correctly, as part of implementing multi-modal transit layers within OSM Graph Architecture & Network Modeling.

Modelled as a plain bidirectional transfer, a park-and-ride site becomes a free teleport between the drive and transit networks, and the router will use it in both directions with equal enthusiasm.

When to use this approach

Model park-and-ride explicitly wherever a multi-modal planner covers a region with genuine sites — most European and many North American metros. The alternative, treating car and transit as separate unlinked networks, produces plans that are individually correct and collectively useless: a fifty-minute drive or a seventy-minute transit journey, when the twenty-five-minute drive-plus-train combination is what people actually do.

Skip it for pure freight or pure pedestrian planners, where no mode switch of this kind occurs.

Two one-way edges, not one bidirectional transfer The drive layer connects to the site, a one-way park edge crosses to the platform carrying search and walk time, and a separate one-way collect edge returns from the platform to the drive layer with a smaller cost. The mode switch is directional and asymmetric drive layer site node transit layer platform park — search + walk, 4–9 min collect — walk only, 3 min one bidirectional edge instead lets a traveller arrive by train and leave in a car that was never parked there The two directions differ in cost as well as in validity — walking to a known car takes no search time.

Implementation

Sites come from OSM tags where they are mapped and from operator data where they are not.

# requires: geopandas, shapely (pip install geopandas shapely)
import geopandas as gpd


def park_ride_sites(pois: gpd.GeoDataFrame) -> gpd.GeoDataFrame:
    """Candidate park-and-ride sites from OSM tagging."""
    tagged = pois[
        (pois.get("park_ride").notna() & (pois["park_ride"] != "no"))
        | ((pois.get("amenity") == "parking") & (pois.get("park_ride") == "yes"))
    ].copy()
    # Capacity drives search time; without it, assume a small site
    tagged["capacity"] = tagged.get("capacity").fillna(120).astype(int)
    return tagged

park_ride tagging is inconsistent enough that operator data is usually the better primary source, with OSM as a cross-check. Where the two disagree on capacity, the operator figure wins — OSM capacity tags are frequently the number of marked bays rather than the number actually usable.

The two edges are then added separately, with different costs:

# requires: networkx (pip install networkx)
import math
import networkx as nx


def search_time_s(capacity: int, occupancy: float) -> int:
    """Time to find a space, rising sharply as the site fills."""
    occupancy = min(max(occupancy, 0.0), 0.99)
    # Roughly hyperbolic: comfortable until ~80 %, then steep
    base = 45.0
    return int(base / (1.0 - occupancy) ** 0.9)


def add_park_and_ride(G: nx.DiGraph, drive_node: int, platform_node: int,
                      *, capacity: int, occupancy: float,
                      walk_s: int) -> None:
    """One-way park and collect edges with distinct costs."""
    park_s = search_time_s(capacity, occupancy) + walk_s
    G.add_edge(
        drive_node, platform_node,
        travel_time_s=park_s,
        mode="park",
        capacity=capacity,
    )
    # The return leg has no search component — the car's location is known
    G.add_edge(
        platform_node, drive_node,
        travel_time_s=walk_s,
        mode="collect",
    )

The occupancy term is what makes this model worth building rather than using a flat penalty. A site at eighty percent behaves quite differently from the same site at ninety-five, and a planner that ignores the difference will keep recommending a full car park through the morning peak.

Capacity gating is the other half, and it belongs at query time rather than in the graph:

# requires: none beyond the standard library
def park_edge_usable(data: dict, occupancy_now: float,
                     query_hour: int, opening: tuple[int, int]) -> bool:
    """Whether a park edge may be traversed for this query."""
    if data.get("mode") != "park":
        return True                       # collect and ordinary edges are unaffected
    if occupancy_now >= 0.98:
        return False                      # effectively full
    open_h, close_h = opening
    return open_h <= query_hour < close_h

Gating at query time rather than by deleting the edge preserves the topology across occupancy changes, which matters because occupancy changes every few minutes and the graph should not.

One property of this design is worth stating explicitly because it is easy to lose in a later refactor: the search term belongs to the park edge only, never to the collect edge. A traveller arriving at the site in the evening walks straight to a car that is already there, so applying the occupancy penalty to the return leg would charge them twice for a queue that only ever formed once, in the morning. The asymmetry is the whole reason the two edges are modelled separately rather than as one bidirectional link with a shared weight. Reviewers who see a symmetric weight on a park-and-ride pair should treat it as a bug rather than a simplification, because it silently inflates every return journey through the site.

Key parameters and tuning

Parameter Recommended value Notes
Park edge one-way, drive → platform Bidirectional lets a traveller collect a car never parked
Collect edge one-way, platform → drive Walk only; no search component
Base search time 45 s At low occupancy; scale up with the occupancy term
Full threshold 0.98 Above this a site is effectively unusable
Capacity source operator data, OSM as cross-check OSM capacity often counts marked bays, not usable ones
Walk time measured, not assumed Platform distance varies enormously between sites
Gating query time Occupancy changes faster than the graph should

Integration points

With the transit layer. The platform node is the same one the transfer edges in implementing multi-modal transit layers attach to, so a park-and-ride site is a third kind of edge into an existing node rather than a new structure. Keeping the mode tags distinct is what stops the transfer penalty being applied twice.

With the time-dependent search. A park edge is traversed at a specific time, and the occupancy that applies is the occupancy then rather than now. Where the planner already carries a running clock for transit — as it must — the same clock indexes the occupancy profile.

With multimodal costing engines. Valhalla’s multimodal costing does not model park-and-ride directly, so a planner needing it either builds the layered graph itself or stitches a drive leg and a transit leg together at a chosen site. The second is simpler and gives up the ability to let the router choose the site, which is usually the interesting part.

Search time against occupancy Time to find a space is under a minute up to about seventy percent occupancy, reaches two minutes at eighty-five, and rises past six minutes above ninety-five, at which point the site is effectively full. Time to find a space, against how full the site is 8 min 0 0 % 70 % 90 % 99 % flat and cheap steep — the last spaces cost minutes 98 % — treat as full A flat parking penalty gets the shape wrong at both ends: too pessimistic at an empty site, far too optimistic at a nearly full one. Why the combination has to be modelled Driving the whole way takes 52 minutes and taking transit the whole way takes 71, but driving to a park-and-ride site and continuing by rail takes 27 — a combination invisible to a planner that treats the two networks separately. Journey time by mode combination, suburb to city centre drive throughout 52 min — city congestion transit throughout 71 min — two transfers drive to P+R, then rail 27 min — what people do A planner with unlinked networks answers 52 minutes and is not wrong about the drive; it is simply unable to see the option that everyone actually takes. The whole value of the layer is that the router picks the site, which requires the mode switch to be an edge rather than a hard-coded stitch point.

Validation checklist

  • No route arrives by transit and leaves by car. Search returned itineraries for a collect edge preceding a drive leg with no prior park edge. Any hit means an edge is bidirectional that should not be.
  • The two edge costs differ. Assert that park cost exceeds collect cost at every site. Equal costs mean the collect edge was created by reversing rather than by construction.
  • Search time responds to occupancy. Evaluate the cost at 50 and 95 percent and confirm a multiple, not an increment.
  • A full site is unreachable. Set occupancy above the threshold and confirm the planner routes to a different site or falls back to a single mode.
  • Walk times are site-specific. Assert that walk time varies across sites. A constant means it was assumed rather than measured, and platform distances differ by minutes.