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.

The penalty decides which side of the water the catchment reaches The ferry route covers the distance in 14 minutes of driving but carries a 22-minute expected wait; the road route takes 31 minutes with no wait. Without the wait modelled the engine picks the ferry and the catchment jumps the water; with it modelled the catchment stops at the shoreline, which is what shoppers do. A ferry is fast and a ferry is slow, depending on the timetable route driving time wait / toll penalty effective cost via the ferry 14 min 22 min expected wait 36 min around the estuary 31 min none 31 min via the toll bridge 17 min 4 min equivalent for $6 21 min With no penalties the engine ranks ferry, bridge, long way round — and a 15-minute catchment crosses the water. With them, the bridge wins and the ferry is never chosen for a shopping trip, which matches what the loyalty data shows about where these customers come from. The toll's minute-equivalent is a value-of-time assumption; state it explicitly rather than tuning it until the map looks right.

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.

Choosing the penalty from evidence, not from feel Across four penalty settings the share of modelled customers from across the water is 31, 18, 9 and 3 per cent, while the observed share from loyalty data is 8 per cent. The setting that reproduces the observed share is the defensible one, and it is close to a value of time of about ninety cents a minute. The loyalty data already knows the answer observed 8% 31% no penalty 18% 10 min wait 9% 22 min wait 3% 40 min wait The third setting reproduces what actually happens, and it can be defended by pointing at the timetable: a half-hourly service has a mean wait near fifteen minutes and a tail that shoppers plan around.

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