A road graph built from highway=* ways stops at the water’s edge — there is no way to cross a strait, a fjord, or a river gap without a mode change, so the graph fragments into disconnected islands unless something explicitly bridges them. This page is the specific technique for that bridging step within implementing multi-modal transit layers, the section of OSM Graph Architecture & Network Modeling covering how road, transit, and pedestrian networks get fused into one routable graph. Ferries and car-shuttle trains are structurally identical from a graph-construction standpoint: both are a single long edge with a schedule-driven duration, bracketed by two short connector edges that model the walk or drive onto a vehicle before the crossing.
The failure mode this page addresses is narrow but common: a graph builder correctly extracts highway ways, discards everything else as noise, and ends up with a road network that has silently split at every coastline. The fix is not a generic “add transit support” effort — it is a targeted pass that reads route=ferry (and the rail equivalent) ways, snaps their endpoints into the existing road topology, and inserts three edges per crossing with duration-based weights that behave correctly under Dijkstra or A*.
When to Use This Approach
Layering ferry and rail-shuttle edges is worth the engineering effort when:
- The road graph’s operating region includes coastlines, islands, or river crossings where a
highway=*way genuinely does not exist — the graph fragmentation prevention checks flag disconnected components that align with water bodies. - Freight or passenger routes legitimately use ferries or car-shuttle trains (Eurotunnel-style vehicle trains, for example) as part of a normal itinerary, not as an edge case to exclude.
- You need realistic total trip time, not just “reachable/unreachable” — a ferry crossing can dominate total travel time on a route, so its duration has to be a first-class weighted edge rather than a flat penalty.
- The graph is built directly from an OSM extract (via building directed graphs from OSM PBF files) rather than sourced from a commercial network dataset that already encodes crossings.
Skip this layer if your service area is entirely inland with no relevant crossings, or if downstream routing already goes through a compiled engine (OSRM, Valhalla) whose default vehicle profile already ingests route=ferry ways with duration natively during extraction — in that case the work described here is redundant, and you only need to confirm the duration tag is present and well-formed on the source ways (see Integration Points below).
Implementation
The snippet below assumes a road graph already exists as a NetworkX DiGraph with lat/lon node attributes (built per building directed graphs from OSM PBF files). It reads ferry ways directly from the same PBF using pyosmium, snaps each endpoint to its nearest road node with a KD-tree, and writes board, crossing, and alight edges with duration-derived weights. Graph-construction boilerplate already covered in the parent section is not repeated here.
# requires: osmium, scipy, networkx (pip install osmium scipy networkx)
# Python 3.9+
import math
import re
import osmium
import networkx as nx
import numpy as np
from scipy.spatial import cKDTree
FALLBACK_CROSSING_SPEED_KMH = 15.0 # used only when duration is missing
def parse_osm_duration(value: str) -> float | None:
"""Convert an OSM duration tag to seconds. Handles 'HH:MM', 'HH:MM:SS',
and ISO 8601 'PT#H#M#S' forms. Returns None if unparseable."""
if not value:
return None
value = value.strip()
if value.upper().startswith("PT"):
h = re.search(r"(\d+)H", value.upper())
m = re.search(r"(\d+)M", value.upper())
s = re.search(r"(\d+)S", value.upper())
total = 0
if h: total += int(h.group(1)) * 3600
if m: total += int(m.group(1)) * 60
if s: total += int(s.group(1))
return float(total) if total else None
parts = value.split(":")
if len(parts) == 2:
hh, mm = parts
return int(hh) * 3600 + int(mm) * 60
if len(parts) == 3:
hh, mm, ss = parts
return int(hh) * 3600 + int(mm) * 60 + int(ss)
return None
def haversine_m(lat1, lon1, lat2, lon2) -> float:
R = 6_371_000.0
dlat, dlon = math.radians(lat2 - lat1), math.radians(lon2 - lon1)
a = (math.sin(dlat / 2) ** 2 + math.cos(math.radians(lat1))
* math.cos(math.radians(lat2)) * math.sin(dlon / 2) ** 2)
return R * 2 * math.asin(math.sqrt(a))
class FerryWayHandler(osmium.SimpleHandler):
"""Collects route=ferry ways with endpoint coordinates and access tags."""
def __init__(self):
super().__init__()
self.crossings: list[dict] = []
def way(self, w):
if w.tags.get("route") != "ferry" or len(w.nodes) < 2:
return
first, last = w.nodes[0], w.nodes[-1]
if not (first.location.valid() and last.location.valid()):
return
duration_s = parse_osm_duration(w.tags.get("duration", ""))
length_m = haversine_m(first.lat, first.lon, last.lat, last.lon)
if duration_s is None:
duration_s = (length_m / 1000.0) / FALLBACK_CROSSING_SPEED_KMH * 3600.0
self.crossings.append({
"way_id": w.id,
"start": (first.lat, first.lon),
"end": (last.lat, last.lon),
"duration_s": duration_s,
"fallback_duration": w.tags.get("duration") is None,
"motor_vehicle": w.tags.get("motor_vehicle", "yes") != "no",
"foot": w.tags.get("foot", "yes") != "no",
})
def snap_endpoint(kdtree: cKDTree, node_ids: list, lat: float, lon: float,
max_snap_m: float = 200.0):
"""Find the nearest road node to a ferry endpoint; None if beyond threshold."""
dist_deg, idx = kdtree.query([lat, lon])
dist_m = dist_deg * 111_320 # coarse deg->m; fine at terminal scale
if dist_m > max_snap_m:
return None, dist_m
return node_ids[idx], dist_m
def add_ferry_edges(G: nx.DiGraph, pbf_path: str, board_penalty_s: float = 90.0,
max_snap_m: float = 200.0) -> nx.DiGraph:
handler = FerryWayHandler()
handler.apply_file(pbf_path, locations=True)
node_ids = list(G.nodes)
coords = np.array([[G.nodes[n]["lat"], G.nodes[n]["lon"]] for n in node_ids])
kdtree = cKDTree(coords)
for crossing in handler.crossings:
term_a = f"ferry:{crossing['way_id']}:a"
term_b = f"ferry:{crossing['way_id']}:b"
lat_a, lon_a = crossing["start"]
lat_b, lon_b = crossing["end"]
G.add_node(term_a, lat=lat_a, lon=lon_a, kind="ferry_terminal")
G.add_node(term_b, lat=lat_b, lon=lon_b, kind="ferry_terminal")
road_a, dist_a = snap_endpoint(kdtree, node_ids, lat_a, lon_a, max_snap_m)
road_b, dist_b = snap_endpoint(kdtree, node_ids, lat_b, lon_b, max_snap_m)
if road_a is None or road_b is None:
continue # endpoint too far from any road node — skip, log for review
# Board / alight connectors — short, direction-aware, fixed dwell cost
G.add_edge(road_a, term_a, weight=board_penalty_s, kind="board")
G.add_edge(term_b, road_b, weight=board_penalty_s, kind="alight")
G.add_edge(road_b, term_b, weight=board_penalty_s, kind="board")
G.add_edge(term_a, road_a, weight=board_penalty_s, kind="alight")
# Crossing edge — duration-weighted, both directions unless access forbids
G.add_edge(term_a, term_b, weight=crossing["duration_s"], kind="ferry_crossing",
fallback_duration=crossing["fallback_duration"])
G.add_edge(term_b, term_a, weight=crossing["duration_s"], kind="ferry_crossing",
fallback_duration=crossing["fallback_duration"])
return G
The same handler pattern applies to car-shuttle rail (Eurotunnel-style vehicle trains): filter on railway=rail ways that carry motor_vehicle=yes and a duration tag instead of route=ferry, and reuse add_ferry_edges unchanged — the graph-topology problem (a long duration-weighted edge bracketed by connectors) is identical.
Key Parameters and Tuning
| Parameter | Typical value | Effect |
|---|---|---|
board_penalty_s / alight penalty |
60-180s | Fixed dwell cost for boarding or disembarking; too low makes the solver treat ferries as free shortcuts, too high suppresses legitimate crossings |
max_snap_m |
150-300m | Maximum distance between a ferry endpoint and the nearest road node; wider thresholds risk snapping to the wrong quay in dense port areas |
FALLBACK_CROSSING_SPEED_KMH |
12-18 km/h | Used only when duration is missing; open-water passenger ferries run faster, short river crossings run slower — calibrate per region |
motor_vehicle / foot access filter |
tag-driven | Determines whether the crossing edge is usable by vehicle profiles, pedestrian profiles, or both — a passenger-only ferry must not carry freight routes |
| Duration tag format | HH:MM, HH:MM:SS, PT#H#M#S |
OSM mappers use all three inconsistently; the parser must handle all of them or silently drop valid crossings |
| Edge weight units | seconds | Must match the unit convention used by the rest of the graph’s weight attribute, or the ferry edge will be over- or under-weighted relative to road edges |
Asymmetric crossings. Some ferry ways carry duration:forward and duration:backward separately when the return leg runs a different schedule (current, wind, or route length differences). When both are present, weight the two directional edges independently rather than reusing one duration value for both.
Multiple daily departures. This model produces a static duration-weighted edge suitable for distance/time-minimizing routing. It does not account for wait time until the next scheduled departure. If your use case needs schedule-accurate departure times, treat the crossing as a lookup against a timetable rather than a flat edge weight — that is a materially different problem, closer to the time-expanded modeling used for GTFS transit legs than to a static road edge.
Integration Points
NetworkX graphs. The add_ferry_edges function above mutates the graph in place and is safe to run after the base road graph is fully built, since it only adds nodes and edges rather than modifying existing road topology.
OSRM. OSRM’s default car.lua and bicycle.lua profiles already recognize route=ferry ways during osrm-extract and weight them using the duration tag when present, falling back to a configurable average speed otherwise. If you are routing through OSRM rather than NetworkX directly, the Python-side connector-edge logic here is unnecessary — instead, audit source data so every route=ferry way in your extract carries a well-formed duration tag, since OSRM’s fallback speed is a single global constant, not the calibrated-per-region value used above.
Valhalla. Valhalla’s costing models expose a use_ferry parameter (0-1) per profile that biases the cost function toward or away from ferry legs, combined with the same route=ferry and duration tag reading at graph-build time. See Valhalla configuration for multi-modal analysis for tuning that parameter alongside other multi-modal costing knobs.
Connectivity validation. After inserting crossing edges, re-run the same component-counting checks used for graph fragmentation prevention in OSM data — the expected outcome is that island components merge into the main graph, and any component that remains isolated after this pass indicates a missing or unsnappable ferry way rather than a genuine data gap.
Validation Checklist
-
No zero-weight crossing edges. Assert
G.edges[u, v]["weight"] > 0for every edge withkind == "ferry_crossing". A zero weight usually meansparse_osm_duration()failed silently and the fallback branch was skipped by mistake. -
Snap distance audit. Log the snap distance for every terminal; anything above
max_snap_mshould have been rejected, and anything close to the threshold (say, within 20%) deserves a manual look — it may indicate the nearest road node is on the wrong side of a breakwater. -
Connectivity improves, not regresses. Compare
nx.number_weakly_connected_components(G)before and afteradd_ferry_edges. The count should drop by roughly the number of previously isolated island clusters; if it does not change at all, the snap step is failing silently. -
Access-tag correctness. For a sample of freight and pedestrian routing queries, confirm vehicle routes never traverse a crossing where
motor_vehicleaccess isFalse, and pedestrian routes never traverse a vehicle-only rail shuttle. -
Fallback duration flag coverage. Query the fraction of crossing edges with
fallback_duration=True. A high fraction (over roughly 15-20% in a region with active ferry service) is a data-quality signal worth escalating upstream rather than routing around indefinitely. -
Round-trip weight sanity. For crossings without directional tags, confirm both directions carry the same weight; for crossings with
duration:forward/duration:backward, confirm the two directions are intentionally different, not accidentally swapped.
Why does a shortest-path query never select the ferry edge?
Almost always a unit mismatch. If road edges are weighted in seconds derived from length divided by speed, and the ferry edge weight is left in raw OSM duration units (minutes, or an unparsed HH:MM string coerced to a number), the ferry edge looks enormously more expensive than it is, so the solver always prefers the road detour. Convert every duration tag to seconds before writing the edge weight, and print a sample of edge attributes directly rather than assuming the parser worked.
What if a ferry way has no duration tag at all?
Fall back to a distance-based estimate using a conservative average crossing speed (12-18 km/h for open-water passenger and vehicle ferries) rather than dropping the way. Flag these edges with a fallback_duration=True attribute so they can be audited and replaced once real schedule data becomes available.
How should multi-leg ferry routes with intermediate stops be modeled?
Split the route=ferry way at each intermediate stop node and treat each leg as its own crossing edge with its own duration share, rather than one edge spanning the entire route. This lets vehicles board or alight at intermediate ports and keeps the duration weight proportional to the leg actually traversed.
Related
- Implementing multi-modal transit layers — the broader workflow for fusing road, transit, and pedestrian networks into one graph
- Building directed graphs from OSM PBF files — constructing the base road graph that ferry crossings attach to
- Graph fragmentation prevention in OSM data — detecting and diagnosing the disconnected components that ferry edges are meant to bridge
- OSM Graph Architecture & Network Modeling — the full set of graph-construction techniques this page is part of