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:
requestsfor the HTTP call, plusgeopandas,shapely, andpandasto parse and compare geometries. Install withpip install requests geopandas shapely pandas. - A routing engine that exposes crossing controls. The hosted ORS
/v2/isochrones/driving-carendpoint acceptsavoid_featuresdirectly. 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 the base traversal cost is length over speed:
To model crossing friction you add a penalty term that is nonzero only on tolled or ferry edges:
where converts a monetary toll to seconds at value-of-time rate (dollars/hour), is expected ferry wait (half the headway, on average), and is fixed boarding/loading time. A hard avoid_features is the limit case . The total cost of any path is , and the isochrone is the boundary of the node set whose minimum-cost path stays under the budget :
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.
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.
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:
- Area delta sanity.
area_removed_pctshould 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. - 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.
- 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. - Geometry validity.
assert pen.geometry.is_valid.all()aftermake_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.
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.
Related
- Configuring OpenRouteService for Drive-Time Maps — the parent configuration and request lifecycle this penalty layer plugs into.
- Comparing OSRM vs Valhalla for Retail Catchment Analysis — how each engine exposes toll and ferry weighting differently.
- Isochrone Generation & Network Analysis — the routing workflow these penalized catchments feed into.