Low-emission and congestion-charge zones turn a subset of otherwise ordinary edges into cost anomalies that no amount of speed or grade calibration will surface on its own — the road segment looks identical to its neighbors in every OSM tag except its location relative to a municipal boundary that OSM itself rarely encodes. This page extends the surcharge pattern introduced in configuring edge weights for freight logistics with a dedicated geometric layer: flagging which edges fall inside a regulated zone and translating that flag, plus a vehicle’s emission class, into either a monetary surcharge or an outright access ban. It sits within the same OSM Graph Architecture & Network Modeling pipeline that produces the base freight_cost_sec attribute this page modifies.
Two zone models exist in production and they require different treatment. Fee-based zones — London’s ULEZ, Stockholm’s congestion tax, Milan’s Area C — charge a daily amount and remain drivable for every vehicle, so the correct encoding is a soft penalty: a time-equivalent surcharge added to freight_cost_sec, following the same normalization used for tolls in the parent weight pipeline. Ban-based zones — most German Umweltzonen, several Belgian and Dutch LEZs — prohibit non-compliant emission classes outright, so the correct encoding is a hard filter that assigns float("inf") to non-compliant vehicle profiles, mirroring the dimensional hard filters already used for maxweight and maxheight. Getting this distinction wrong in either direction either produces routes that are illegal to drive or routes that avoid a zone a compliant vehicle could have entered for free.
When to Use This Approach
Apply this layer whenever a fleet routes through, or near, a regulated urban zone and the vehicle profile’s emission class is known at query time. Two branches determine the implementation:
- Fee-based zone, soft penalty. The zone charges a daily rate but does not prohibit entry. Encode as a time-equivalent surcharge added to
freight_cost_sec, exactly like the toll normalization already in the parent weight pipeline. The solver will route around the zone only when the detour costs less than the fee. - Ban-based zone, hard filter. The zone prohibits specific emission classes outright regardless of willingness to pay. Encode as
float("inf")on edges inside the zone for non-compliant vehicle profiles, following the same pattern asmaxweightandhgv=nofiltering — never as a large-but-finite penalty, because a finite cost implies the route is legally drivable if the alternative is expensive enough.
Skip this layer entirely for routes that never enter a city with an active zone, and re-evaluate it whenever a municipality changes its boundary or its emission-class thresholds — these change on multi-year cycles (Berlin’s Umweltzone tightened its minimum sticker requirement in stages) and a stale polygon silently produces non-compliant routes.
Implementation
OSM’s boundary=low_emission_zone tag exists but coverage is inconsistent — most cities with an active zone do not map it, and where it is mapped the geometry often lags the authoritative boundary published by the city itself. Source the polygon from the municipality’s open-data portal or a maintained third-party dataset, and treat the OSM tag as a secondary cross-check rather than the primary input.
# requires: geopandas>=0.14, pandas>=2.0, shapely>=2.0, numpy>=1.24
import geopandas as gpd
import pandas as pd
import numpy as np
# `edges` is the freight edge GeoDataFrame produced by the parent weight pipeline,
# indexed by (u, v, key) and already carrying a `freight_cost_sec` column.
lez = gpd.read_file("london_ulez_2024.geojson")
# Reproject both layers to a local metric CRS before any spatial predicate —
# intersects() in EPSG:4326 treats degrees as if they were a flat metric grid,
# which distorts simplified boundary edges by tens of metres in some cities.
LOCAL_CRS = 27700 # OSGB36 / British National Grid
lez = lez.to_crs(epsg=LOCAL_CRS)
edges = edges.to_crs(epsg=LOCAL_CRS)
# Small negative buffer absorbs boundary-simplification noise so edges that
# merely touch a simplified vertex aren't flagged as fully inside the zone.
zone_geom = lez.geometry.union_all().buffer(-3.0)
edges["in_lez"] = edges.geometry.intersects(zone_geom)
edges["lez_overlap_frac"] = (
edges.geometry.intersection(zone_geom).length / edges.geometry.length.replace(0, np.nan)
).fillna(0.0)
# Treat edges with only a sliver inside the zone as outside — avoids false
# positives on segments that merely clip a corner of the boundary.
MIN_OVERLAP_FRAC = 0.08
edges["in_lez"] = edges["in_lez"] & (edges["lez_overlap_frac"] >= MIN_OVERLAP_FRAC)
With edges flagged, apply a per-emission-class rule. Fee-based zones amortize the daily charge into a time-equivalent surcharge using the same HOURLY_RATE_USD normalization already established for tolls; ban-based zones assign an infinite cost.
# requires: pandas>=2.0, numpy>=1.24, dataclasses (stdlib)
from dataclasses import dataclass
@dataclass
class EmissionClass:
label: str
daily_fee_usd: float
banned: bool # True → hard filter regardless of willingness to pay
EMISSION_CLASSES: dict[str, EmissionClass] = {
"euro_6_diesel": EmissionClass("Euro 6 diesel", 0.0, banned=False),
"electric": EmissionClass("Battery electric", 0.0, banned=False),
"euro_4_diesel": EmissionClass("Euro 4 diesel", 15.90, banned=False),
"euro_3_petrol": EmissionClass("Euro 3 petrol", 15.90, banned=False),
"pre_euro_3": EmissionClass("Pre-Euro 3 / unclassified", 0.0, banned=True),
}
HOURLY_RATE_USD = 90.0
EXPECTED_ENTRIES_PER_SHIFT = 1 # raise for multi-stop routes re-entering the zone
def lez_surcharge_sec(vehicle_class: str, hourly_rate: float = HOURLY_RATE_USD) -> float:
ec = EMISSION_CLASSES[vehicle_class]
if ec.banned:
return float("inf")
return (ec.daily_fee_usd / EXPECTED_ENTRIES_PER_SHIFT) / hourly_rate * 3600
def apply_lez_penalty(edges: gpd.GeoDataFrame, vehicle_class: str) -> pd.Series:
"""Vectorized merge of the LEZ surcharge into freight_cost_sec."""
surcharge = lez_surcharge_sec(vehicle_class)
if np.isinf(surcharge):
return edges["freight_cost_sec"].where(~edges["in_lez"], other=float("inf"))
return edges["freight_cost_sec"] + np.where(edges["in_lez"], surcharge, 0.0)
edges["freight_cost_sec"] = apply_lez_penalty(edges, "euro_4_diesel")
Zone operating hours are the final gate. Most zones are inactive overnight and on weekends, so the surcharge must be evaluated against the query timestamp rather than baked permanently into freight_cost_sec:
# requires: pandas>=2.0
import datetime as dt
def zone_active(now: dt.datetime, weekdays: set[int] = frozenset({0, 1, 2, 3, 4}),
start_hour: int = 7, end_hour: int = 19) -> bool:
"""weekdays: Mon=0 ... Sun=6, matching Python's datetime.weekday()."""
return now.weekday() in weekdays and start_hour <= now.hour < end_hour
def query_time_lez_cost(edges: gpd.GeoDataFrame, vehicle_class: str,
query_time: dt.datetime) -> pd.Series:
if not zone_active(query_time):
return edges["freight_cost_sec"] # no surcharge outside operating hours
return apply_lez_penalty(edges, vehicle_class)
Key Parameters and Tuning
| Parameter | Recommended value | Notes |
|---|---|---|
| Polygon source | City open-data portal / maintained dataset | boundary=low_emission_zone coverage in OSM is inconsistent; use it only as a cross-check |
| CRS for spatial join | Local projected CRS (e.g. EPSG:27700, EPSG:25832) |
intersects() in EPSG:4326 distorts simplified boundaries |
| Boundary buffer | −2 to −5 m | Absorbs polygon-simplification noise at zone edges |
MIN_OVERLAP_FRAC |
0.05–0.15 | Below this fraction of edge length inside the zone, treat as outside |
EXPECTED_ENTRIES_PER_SHIFT |
1–4 | Multi-stop urban routes re-entering the zone should amortize the fee over fewer trips per entry, raising per-entry surcharge |
HOURLY_RATE_USD |
40–120 | Reuse the fleet operating rate already established in the parent weight pipeline |
| Operating hours window | Per-city schedule, not assumed | Some zones (Stockholm) vary by season; refresh the schedule quarterly |
| Non-compliant fallback | banned=True / highest fee tier |
Never default an unknown emission class to compliant |
Integration Points
The output of this layer is a modified freight_cost_sec (or inf) column that plugs directly into the multi-profile weight table pattern from configuring edge weights for freight logistics — store it as freight_cost_<profile>_lez alongside the base profile columns so a routing query selects the correct pre-computed column without recomputing penalties per request.
Because the surcharge depends on query time, it cannot be a single static edge attribute for engines that expect one weight per edge. For OSRM, expose the gated cost through a Lua profile that checks a zone_active flag injected at osrm-customize time, or precompute two weight sets — zone-active and zone-inactive — and swap the active traffic layer on a schedule, the same mechanism described in traffic-incident-triggered re-routing for geofenced incident penalties. For NetworkX, pass query_time_lez_cost as a weight callable so the gate evaluates lazily per query.
Terminal-node placement matters as much as edge weighting: a depot or delivery point located inside the zone changes EXPECTED_ENTRIES_PER_SHIFT from one entry per route to one entry per stop. Cross-check zone membership against depot and delivery-node locations using the same attribute pipeline described in mapping node attributes for urban delivery zones so entry-count assumptions stay consistent between the node layer and the edge layer.
Where zone rules interact with time windows more generally — variable hours, holiday exemptions, seasonal boundaries — the condition-parsing patterns used for parsing conditional access restrictions apply directly; a zone’s operating-hours string is structurally the same problem as an hgv:conditional interval.
Validation Checklist
- Polygon validity and CRS. Confirm
lez.geometry.is_valid.all()and that both layers share the same projected CRS before joining — a silent CRS mismatch produces a join that runs without error but flags the wrong edges. - Spot-check against the city’s official checker. Pick 10–15 known streets and compare
in_lezagainst the municipality’s published zone-checker tool. A systematic mismatch near the boundary usually means the buffer orMIN_OVERLAP_FRACneeds adjustment. - Compliant classes carry zero surcharge. Assert
apply_lez_penalty(edges, "euro_6_diesel").equals(edges["freight_cost_sec"])— a non-zero result for a compliant class indicates the fee table or thebannedflag is misconfigured. - Time gate unit test. Assert the surcharge is exactly zero for a
query_timeoutside operating hours and strictly positive (orinf) for one inside, across a Saturday, a weekday morning, and a weekday evening. - No stranded components from ban-based filters. Run
networkx.strongly_connected_components()on the post-filter graph, following the same connectivity check used for dimensional hard filters — a zone that fully encloses a depot with no compliant-vehicle route in or out is a data problem, not a routing one. - Regression on known avoid-the-zone routes. Maintain a small set of origin-destination pairs where dispatchers have confirmed the expected detour behavior, and rerun them after every polygon or fee-table update.
Why do edges just outside the low-emission zone still get penalized?
This is almost always a coordinate reference system mismatch. If the spatial join runs in EPSG:4326, intersects() operates on degrees rather than metres, and simplified zone polygons can bleed a few metres past their true boundary. Reproject both the polygon and the edge GeoDataFrame to a local projected CRS before joining, and apply a small negative buffer to the zone polygon rather than joining against the raw published boundary.
Why does the surcharge apply on days when the zone is not active?
The in_lez flag from the spatial join is static, but the surcharge itself must be evaluated against the current query time, not baked into a fixed edge weight. If the surcharge is precomputed once and cached, it applies uniformly regardless of day or hour. Wrap it in a callable weight function that checks the active-hours window against the query timestamp before adding the time-equivalent cost.
How should I handle vehicles with no registered emission class?
Default to the highest surcharge tier or, for ban-based zones, treat the vehicle as non-compliant and apply the hard filter. Silently assuming compliance for unclassified vehicles produces routes that are illegal to drive and erodes trust in the routing output faster than an overly conservative fallback.
Related
- Configuring edge weights for freight logistics — the parent weight pipeline this surcharge layer plugs into, including the toll-normalization pattern it reuses
- Mapping node attributes for urban delivery zones — synchronizing depot and delivery-node zone membership with entry-count assumptions
- Parsing conditional access restrictions — the same interval-parsing approach applied to zone operating-hours schedules
- Traffic-incident-triggered re-routing — the geofenced spatial-join-and-recustomize mechanism this page’s time-gated surcharge reuses for live re-routing