Handling Toll and Ferry Penalties in Drive-Time Models

This page solves one exact problem: making a drive-time isochrone charge for the tolls, bridges, and ferries a customer actually experiences, instead of routing across them for free and inflating the catchment.

A router that treats a tolled bridge or a car ferry as an ordinary edge will happily expand a catchment across a bay or a river as though the crossing were a normal stretch of arterial. Real shoppers do not behave that way. A $9 bridge toll or a ferry that runs every 40 minutes is a behavioral barrier long before it is a physical one, and a catchment that ignores it over-counts population on the far shore. This page covers how to represent that friction in OpenRouteService and OSRM, how it shows up in the edge-cost model, and how to measure the area difference in a CRS-honest way.

Prerequisites

Before running this task you need:

  • Python packages: requests for the HTTP call, plus geopandas, shapely, and pandas to parse and compare geometries. Install with pip install requests geopandas shapely pandas.
  • A routing engine that exposes crossing controls. The hosted ORS /v2/isochrones/driving-car endpoint accepts avoid_features directly. For penalty weights (as opposed to hard avoidance) you generally need a self-hosted OSRM or ORS instance whose profile you control — see the OSRM vs Valhalla comparison for how the two engines expose this differently.
  • A graph extract that actually contains the features. avoid_features: ["ferries"] does nothing if the OSM extract was clipped so the ferry route (route=ferry) never made it into the graph. Confirm the crossing is in the network before you try to penalize it.
  • A projected CRS for area math. All area comparisons below use EPSG:5070 (CONUS Albers Equal Area). Never compare polygon areas in EPSG:4326 degrees.

Configuration and Execution Parameters

Two mechanisms model crossing friction, and they are not interchangeable. avoid_features is a hard exclusion — the edge is removed from consideration entirely. A penalty keeps the edge but multiplies or adds to its traversal cost, so the router still uses it when no reasonable alternative exists. Retail catchments almost always want a penalty, not a hard cut: a ferry that adds 12 minutes of wait is still a real path to your store, just a discounted one.

Parameter Engine Value for this task Effect
avoid_features ORS ["tollways"], ["ferries"], or both Hard-removes tolled ways / ferry edges from the traversal
avoid_polygons ORS GeoJSON MultiPolygon over a crossing Hard-excludes a specific bridge or terminal footprint
options.profile_params ORS (self-host) weight/penalty overrides Scales edge weights instead of removing edges
duration on route=ferry OSRM profile Lua way:get_value_by_key("duration") + wait penalty Adds boarding + wait time to ferry edges
toll handler OSRM profile Lua additive seconds on toll=yes Converts a monetary toll into an equivalent time cost
range both [900] (seconds) The isochrone budget the penalty eats into

The key modeling decision is the penalty magnitude. A toll has no native time units, so you convert it: pick a value-of-time rate (retail planning commonly uses $12–$18/hour for routine trips) and translate the toll into equivalent seconds. A $9 toll at $15/hour is 9 / 15 * 3600 = 2160 equivalent seconds — 36 minutes of deterrence, which will collapse a 15-minute contour on the far side of the bridge to almost nothing. That is the point: the penalty should hurt.

The edge-cost model

An isochrone is a shortest-path expansion that accumulates edge cost until the budget is spent. For an edge ee the base traversal cost is length over speed:

cbase(e)=(e)v(e)c_{\text{base}}(e) = \frac{\ell(e)}{v(e)}

To model crossing friction you add a penalty term that is nonzero only on tolled or ferry edges:

c(e)=(e)v(e)+τ(e)toll, in equiv. seconds+w(e)+ϕ(e)ferry wait + boardingc(e) = \frac{\ell(e)}{v(e)} + \underbrace{\tau(e)}_{\text{toll, in equiv. seconds}} + \underbrace{w(e) + \phi(e)}_{\text{ferry wait + boarding}}

where τ(e)=toll$(e)r3600\tau(e) = \dfrac{\text{toll}_\$(e)}{r} \cdot 3600 converts a monetary toll to seconds at value-of-time rate rr (dollars/hour), w(e)w(e) is expected ferry wait (half the headway, on average), and ϕ(e)\phi(e) is fixed boarding/loading time. A hard avoid_features is the limit case c(e)c(e) \to \infty. The total cost of any path is ePc(e)\sum_{e \in P} c(e), and the isochrone is the boundary of the node set whose minimum-cost path stays under the budget TT:

iso(T)={n:minP:snePc(e)T}\text{iso}(T) = \{\, n : \min_{P: s \to n} \textstyle\sum_{e \in P} c(e) \le T \,\}

Because the penalty enters the same sum the expansion already computes, you do not post-process the polygon — you change the cost function and let the shortest-path frontier bend around the expensive crossing on its own.

Choosing between hard avoidance and a weighted crossing penalty For each candidate crossing, first confirm the feature exists in the graph extract. If it does not, the penalty is silently a no-op and must be fixed at the extract step. If it exists, decide whether the crossing is a real customer path: a hard avoid removes it entirely, while a weighted penalty converts toll dollars and ferry wait into equivalent seconds added to the edge cost. Both feed a shortest-path expansion that produces the isochrone, whose area is then compared against the unpenalized baseline in EPSG:5070. Identify crossing edge toll=yes · route=ferry In graph extract? no No-op fix OSM extract yes Real customer path? no Hard avoid avoid_features yes Weighted penalty toll$/r · 3600 + wait

Annotated Implementation

The function below requests two 15-minute isochrones for the same origin — one baseline, one with crossings penalized — then reprojects both to EPSG:5070 and reports the area the penalty removed. It uses ORS avoid_features for the hard case; the same structure carries a self-hosted options.profile_params block when you weight rather than exclude.

python
import requests
import geopandas as gpd
import pandas as pd
from shapely.geometry import shape
from shapely.validation import make_valid

ORS_URL = "https://api.openrouteservice.org/v2/isochrones/driving-car"
WGS84 = "EPSG:4326"
ALBERS = "EPSG:5070"          # CONUS Albers Equal Area — metres, area-true


def _fetch_isochrone(api_key, lon, lat, minutes=15, avoid=None):
    """Return one drive-time contour as a GeoDataFrame in EPSG:4326.

    `avoid` is an ORS avoid_features list, e.g. ["tollways", "ferries"].
    ORS expects [longitude, latitude] order.
    """
    payload = {
        "locations": [[lon, lat]],       # ORS: [lon, lat], not [lat, lon]
        "range_type": "time",
        "range": [minutes * 60],
        "attributes": ["area"],
    }
    if avoid:
        # Hard exclusion. For weighted penalties on a self-hosted engine,
        # swap this for: payload["options"] = {"profile_params": {...}}
        payload["options"] = {"avoid_features": avoid}

    headers = {"Authorization": api_key, "Content-Type": "application/json"}
    resp = requests.post(ORS_URL, json=payload, headers=headers, timeout=30)
    resp.raise_for_status()
    feats = resp.json().get("features", [])
    if not feats:
        raise ValueError("Empty isochrone: point off-network or crossing "
                         "avoidance disconnected the origin.")
    geom = make_valid(shape(feats[0]["geometry"]))
    return gpd.GeoDataFrame(
        pd.DataFrame([feats[0]["properties"]]),
        geometry=[geom],
        crs=WGS84,
    )


def compare_crossing_penalty(api_key, lon, lat, minutes=15,
                             avoid=("tollways", "ferries")):
    """Quantify how much catchment area the crossing penalty removes."""
    base = _fetch_isochrone(api_key, lon, lat, minutes)
    pen = _fetch_isochrone(api_key, lon, lat, minutes, avoid=list(avoid))

    # Area is only meaningful in a projected, equal-area CRS.
    base_m = base.to_crs(ALBERS)
    pen_m = pen.to_crs(ALBERS)
    assert base_m.crs.to_epsg() == 5070 and pen_m.crs.to_epsg() == 5070

    base_km2 = base_m.geometry.area.iloc[0] / 1_000_000
    pen_km2 = pen_m.geometry.area.iloc[0] / 1_000_000
    delta = base_km2 - pen_km2
    pct = 100 * delta / base_km2 if base_km2 else float("nan")

    # Seed containment: the origin must stay inside the penalized contour.
    origin = gpd.GeoSeries.from_xy([lon], [lat], crs=WGS84).to_crs(ALBERS)
    assert pen_m.geometry.iloc[0].contains(origin.iloc[0]), \
        "Origin fell outside penalized isochrone — crossing avoidance " \
        "disconnected the seed node."

    return {
        "baseline_km2": round(base_km2, 3),
        "penalized_km2": round(pen_km2, 3),
        "area_removed_km2": round(delta, 3),
        "area_removed_pct": round(pct, 1),
    }


if __name__ == "__main__":
    # Origin near a tolled bridge / ferry corridor.
    result = compare_crossing_penalty(
        api_key="YOUR_KEY", lon=-122.478, lat=37.832
    )
    print(result)

The compare_crossing_penalty result is the number that justifies the modeling effort to a real-estate committee: “penalizing the toll bridge removes 41 km² and roughly 28,000 people from this catchment” is a defensible statement in a way that an unpenalized polygon never is. Feed the penalized contour — not the baseline — into the downstream demographic join.

Failure Modes and Debugging

Symptom Cause Fix
Penalty has no visible effect The tolled way or ferry route is not in the graph extract Re-clip the OSM extract to include the crossing; verify with an overpass check on toll=yes / route=ferry before trusting the isochrone.
Empty feature collection with avoid set Hard avoidance disconnected the origin’s only routable exit Switch from avoid_features to a weighted penalty, or confirm the origin has a non-tolled egress.
Origin outside the penalized contour Seed node sits on an island reachable only by ferry Expected on true islands; treat the catchment as ferry-dependent rather than forcing a fix.
Far-shore blob persists A second, untolled crossing exists and is doing the work Correct — the catchment reaches the far shore by a free route; verify that route is real, not an OSM tagging error.
Nonsensical area jump Comparing areas in EPSG:4326 degrees Reproject both contours to EPSG:5070 (or a local UTM) before any .area call.

The disconnected-components failure is the subtle one. When a candidate site sits on an island or a peninsula whose only land connection is a tolled bridge, hard-avoiding that bridge severs the origin from the mainland graph and the router returns an empty or degenerate polygon. That is not a bug to suppress — it is the model correctly telling you the site is crossing-dependent. Switch to a weighted penalty so the ferry or bridge remains a costly-but-usable edge, and the frontier will still cross it when nothing cheaper exists.

Verification

Confirm the penalized output before it reaches capital-allocation models:

  1. Area delta sanity. area_removed_pct should be positive and, for a meaningful crossing, non-trivial (single digits to tens of percent). A delta of exactly 0.0% means the penalty never bound — the crossing is not in the graph, or the profile ignored it.
  2. Seed containment. The origin must lie inside the penalized polygon (asserted above). A failure means avoidance disconnected the seed, signaling an island or a peninsula.
  3. Direction of change. Penalized area must be <= baseline area. A penalized catchment larger than baseline indicates a coordinate swap or a mismatched profile between the two calls.
  4. Geometry validity. assert pen.geometry.is_valid.all() after make_valid, so the far-shore clip does not leave self-intersecting rings for the 15-minute drive-time workflow or later joins to choke on.
python
res = compare_crossing_penalty(api_key="YOUR_KEY", lon=-122.478, lat=37.832)
assert res["area_removed_pct"] >= 0, "penalized catchment grew — check inputs"
assert res["penalized_km2"] <= res["baseline_km2"]
print(f"Toll/ferry penalty removed {res['area_removed_km2']} km2 "
      f"({res['area_removed_pct']}%) of catchment")

Run this comparison whenever you refresh the routing graph: a graph rebuild can change how a ferry or toll way is tagged, and a penalty that bound last quarter can silently become a no-op after an OSM update. The area-delta metric is your regression signal against that drift.

← Back to Configuring OpenRouteService for Drive-Time Maps