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).

Ferry edge bridging a road graph across a water gap Left and right land masses each contain a small road node network. A road node on each coast connects via a short board or alight connector edge to a terminal node. The two terminal nodes are joined by a dashed ferry edge labeled route=ferry with a duration-based weight, bridging the graph across open water where no OSM way exists. Road graph — west coast Road graph — east coast water gap — no OSM way road node board +90s term A route=ferry duration=5400s (90 min) term B alight +90s road node Three edges per crossing: board connector, ferry edge, alight connector

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

  1. No zero-weight crossing edges. Assert G.edges[u, v]["weight"] > 0 for every edge with kind == "ferry_crossing". A zero weight usually means parse_osm_duration() failed silently and the fallback branch was skipped by mistake.

  2. Snap distance audit. Log the snap distance for every terminal; anything above max_snap_m should 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.

  3. Connectivity improves, not regresses. Compare nx.number_weakly_connected_components(G) before and after add_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.

  4. Access-tag correctness. For a sample of freight and pedestrian routing queries, confirm vehicle routes never traverse a crossing where motor_vehicle access is False, and pedestrian routes never traverse a vehicle-only rail shuttle.

  5. 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.

  6. 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.