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.

Low-Emission-Zone Penalty Encoding Pipeline A zone polygon is spatially joined against graph edges to flag which edges fall inside the regulated area. The flagged edges are combined with a per-emission-class fee table and gated by zone operating hours before being merged into the freight cost attribute. 1 — Spatial join: zone polygon × graph edges LEZ polygon edges.geometry.intersects(zone) → in_lez = True/False per edge projected CRS · flagged edges shown in gold 2 — Per-emission-class rule (fee or hard filter) Euro 6 diesel / EV → compliant, surcharge = 0 Euro 4 diesel → daily_fee_usd → lez_time_equivalent_sec Euro 3 or unclassified (ban zone) → freight_cost_sec = inf 3 — Time-of-day gate surcharge applies only within zone operating hours — e.g. Mo-Fr 07:00-19:00; zero outside → freight_cost_sec += lez_time_equivalent_sec (or → inf for banned classes)

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 as maxweight and hgv=no filtering — 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

  1. 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.
  2. Spot-check against the city’s official checker. Pick 10–15 known streets and compare in_lez against the municipality’s published zone-checker tool. A systematic mismatch near the boundary usually means the buffer or MIN_OVERLAP_FRAC needs adjustment.
  3. 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 the banned flag is misconfigured.
  4. Time gate unit test. Assert the surcharge is exactly zero for a query_time outside operating hours and strictly positive (or inf) for one inside, across a Saturday, a weekday morning, and a weekday evening.
  5. 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.
  6. 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.