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.
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.
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.
Related
- Implementing multi-modal transit layers — the layered graph and transfer edges this joins into
- Layering ferry and rail edges onto road graphs — the other place a mode switch needs its own edge semantics
- GTFS multi-modal trip-planning automation — the schedule layer the platform node belongs to
- Mapping node attributes for urban delivery zones — the same three-state attribute discipline applied to capacity and availability