A traffic-incident feed reporting a closed lane or a blocked intersection is only useful if it changes route decisions for the handful of vehicles actually driving toward it — broadcasting a full graph rebuild to every active trip wastes compute and floods drivers with irrelevant reroute prompts. This page is the incident-response half of webhook-driven dynamic re-routing within the broader routing API automation and fleet integration workflow, and it narrows the general re-routing problem to one specific trigger: a geofenced incident polygon that needs to become an edge-weight penalty, propagate through osrm-customize, and reach only the trips whose remaining path actually crosses it. The pattern below assumes you already have a live incident feed (a traffic-management-center API, a Waze-style crowd feed, or an internal ops dashboard) and a running OSRM MLD deployment.

When to Use This Approach

Geofenced penalty updates make sense when incidents are localized, transient, and frequent enough that a full graph re-extract is impractical — road closures, crashes, flooding, construction lane drops, and temporary event road closures all fit this profile.

Traffic-incident re-routing pipeline Five-stage pipeline. An incident polygon from a live feed is spatially joined against a maintained edge index to flag affected road segments. Flagged segments receive a heavy speed penalty written to a segment-speed file. osrm-customize rebuilds the MLD metric graph and osrm-datastore swaps it into shared memory. Only active trips whose remaining geometry intersects the incident buffer are rerouted. Incident-triggered re-routing pipeline incident polygon over road segments Incident Feed GeoJSON polygon live feed poll Spatial Join edges ∩ buffer geopandas.sjoin Penalty Overlay speed → 2 km/h segment-speed CSV osrm-customize rebuild .osrm.mldgr + osrm-datastore swap Reroute Trips affected trips only POST /route/v1 Only trips whose remaining geometry intersects the incident buffer are queried in the final stage

Use this pattern when:

  • Incidents arrive as point, line, or polygon geometry with a defined (or estimated) clearance time
  • Only a small fraction of active trips are geographically near the incident at any moment
  • Your OSRM deployment runs MLD, so osrm-customize can apply new weights without a full osrm-partition rebuild
  • Vehicles are tracked with a live position and a stored planned route, so “affected” can be computed rather than guessed

Do not use this pattern when:

  • The disruption is network-wide (a citywide event, a data source outage) — a coordinated graph re-extract or a manual profile swap is more appropriate than penalizing individual segments
  • Your routing engine only supports contraction hierarchies with no dynamic-weight path — see integrating custom traffic weights into OSRM for the CH-vs-MLD trade-off before committing to this design
  • The incident feed has no reliable geometry, only a text description — geocode and validate it before it ever reaches the spatial join

Implementation

This snippet assumes a maintained edge_index.parquet GeoDataFrame with columns from_node, to_node, speed_kph, and geometry (a LineString per directed edge), built once during graph extraction and refreshed alongside the OSM data. It focuses on the incident-specific steps — polygon buffering, spatial join, penalty write, and re-customization — not the base OSRM container setup covered in the parent guide.

# requires: geopandas, shapely, pandas, requests (pip install geopandas shapely pandas requests)
# Python 3.9+  |  geopandas >= 0.14

import subprocess
from pathlib import Path

import geopandas as gpd
import pandas as pd
from shapely.geometry import shape

EDGE_INDEX_PATH = Path("/data/edge_index.parquet")
SEGMENT_SPEED_PATH = Path("/data/incident_speeds.csv")
OSRM_BASE = Path("/data/metro-latest.osrm")
METRIC_CRS = "EPSG:32633"          # local UTM zone; swap per deployment region
INCIDENT_SPEED_KPH = 2.0           # near-zero, not zero — keeps the edge routable as a last resort
BUFFER_METERS = 35.0


def load_edge_index() -> gpd.GeoDataFrame:
    edges = gpd.read_parquet(EDGE_INDEX_PATH)
    return edges.to_crs(METRIC_CRS)


def flag_affected_edges(edges: gpd.GeoDataFrame, incident_geojson: dict) -> gpd.GeoDataFrame:
    """Spatial-join the edge index against a buffered incident polygon."""
    incident_geom = gpd.GeoSeries([shape(incident_geojson)], crs="EPSG:4326").to_crs(METRIC_CRS)
    buffered = gpd.GeoDataFrame(geometry=incident_geom.buffer(BUFFER_METERS), crs=METRIC_CRS)

    hits = gpd.sjoin(edges, buffered, how="inner", predicate="intersects")
    return hits.drop_duplicates(subset=["from_node", "to_node"])


def write_segment_speed_file(affected: gpd.GeoDataFrame) -> None:
    """OSRM segment-speed format: from_node,to_node,speed_kph (no header)."""
    out = pd.DataFrame({
        "from_node": affected["from_node"],
        "to_node": affected["to_node"],
        "speed_kph": INCIDENT_SPEED_KPH,
    })
    out.to_csv(SEGMENT_SPEED_PATH, index=False, header=False)
    print(f"[penalty] {len(out)} directed edges penalized to {INCIDENT_SPEED_KPH} km/h")


def recustomize_and_swap(dataset_name: str = "metro-live") -> None:
    """Rebuild the MLD metric graph, then swap it into shared memory atomically."""
    subprocess.run([
        "docker", "run", "--rm", "-t",
        "-v", "/data:/data",
        "osrm/osrm-backend", "osrm-customize",
        str(OSRM_BASE).replace("/data", "/data"),
        "--segment-speed-file", str(SEGMENT_SPEED_PATH).replace("/data", "/data"),
    ], check=True)

    subprocess.run([
        "docker", "exec", "osrm-live", "osrm-datastore",
        "--dataset-name", dataset_name,
        str(OSRM_BASE),
    ], check=True)
    print(f"[customize] shared-memory dataset '{dataset_name}' swapped in")


def apply_incident(incident_geojson: dict) -> int:
    edges = load_edge_index()
    affected = flag_affected_edges(edges, incident_geojson)
    if affected.empty:
        print("[penalty] incident buffer intersects no known edges — skipping customize")
        return 0
    write_segment_speed_file(affected)
    recustomize_and_swap()
    return len(affected)

The --segment-speed-file flag is OSRM’s supported mechanism for live weight overrides on an already-partitioned MLD graph — it re-walks only the affected cell boundaries rather than the full network, which is what keeps this step fast enough to run per incident rather than per nightly batch. osrm-datastore --dataset-name performs the shared-memory swap; pointing osrm-routed at a named dataset (rather than a file path) is what allows this update to land without dropping in-flight connections.

Key Parameters and Tuning

Parameter Typical value Effect
BUFFER_METERS 25–100 m Widens or narrows which edges near the incident geometry get flagged; too narrow misses parallel lanes, too wide over-penalizes unaffected roads
INCIDENT_SPEED_KPH 1–5 km/h Near-zero speed rather than edge deletion — keeps the segment technically routable so osrm-customize never disconnects the graph outright
--segment-speed-file path CSV of from_node,to_node,speed_kph; only listed directed edges are overridden, all others retain their base profile speed
dataset-name string Named shared-memory slot for osrm-datastore; alternate between two names (blue/green) to avoid clobbering a swap that’s mid-flight
Incident feed poll interval 10–30 s Must stay below your osrm-customize runtime plus network latency, or incidents queue up faster than they clear
Remaining-route filter trip geometry from current position Determines which active trips are considered “affected” — see Integration Points below

Why speed instead of deletion. Setting a segment’s speed to zero, or omitting it from the graph entirely, risks producing a genuinely disconnected component if the incident sits on a bridge, tunnel, or the only connector between two neighborhoods. A very low but nonzero speed (1–5 km/h) makes the segment enormously expensive — so routing avoids it whenever any alternative exists — while still returning a route instead of NoRoute for the pathological case where it is the only connection.

Blue/green dataset naming. Because osrm-customize can take anywhere from a few seconds to over a minute on larger metro graphs, alternate --dataset-name between two fixed values (metro-live-a / metro-live-b) so a second incident arriving mid-customize does not race the first swap. Track which name is currently active in a small state file or Redis key.

Integration Points

Upstream trigger. The incident polygon typically arrives through the same channel described in building a FastAPI re-route webhook — a BackgroundTask calls apply_incident() after HMAC verification and idempotency-key deduplication, so a single incident event never triggers osrm-customize twice.

Affected-trip detection. After the penalty lands, filter your active-trip table to those whose remaining route geometry — not the original planned route — intersects the same buffered incident geometry used for the edge flag:

# requires: geopandas, shapely
def affected_trip_ids(active_trips: gpd.GeoDataFrame, incident_buffer: gpd.GeoSeries) -> list[str]:
    """active_trips must carry a 'remaining_geometry' LineString column, updated on each GPS ping."""
    hits = gpd.sjoin(active_trips.set_geometry("remaining_geometry"),
                      gpd.GeoDataFrame(geometry=incident_buffer), predicate="intersects")
    return hits["trip_id"].unique().tolist()

Requesting new routes. For each affected trip_id, call OSRM’s /route/v1 endpoint with the vehicle’s current position as origin and the original destination retained — the updated .osrm.mldgr weights ensure the returned path already avoids the penalized segments without any extra avoidance logic on the client side.

Broader weight-overlay workflow. This page applies a narrow, transient penalty; for recurring time-of-day congestion patterns rather than one-off incidents, the general-purpose mechanism is covered in integrating custom traffic weights into OSRM, which this incident pipeline reuses under the hood.

Validation Checklist

  1. Non-empty spatial join sanity check. Before writing the segment-speed file, assert that flag_affected_edges() returns at least one row for a known test incident placed directly on a mapped road; zero rows usually means a CRS mismatch between the incident feed (WGS84) and the edge index.
  2. Graph connectivity after customize. After each osrm-customize run, query /route/v1 between two points on opposite sides of the incident buffer and confirm code == "Ok" rather than "NoRoute" — a NoRoute result signals the penalty accidentally disconnected the graph.
  3. Rerouted path avoids the incident. Convert the new route’s geometry to a LineString and assert it does not intersect the incident buffer, except at legitimate crossing points explicitly outside the closed segment.
  4. Dataset swap latency. Time the full apply_incident() call end-to-end and log it; if it regularly exceeds your incident feed’s polling interval, incidents will queue and stale weights will serve requests longer than intended.
  5. Trip-filter precision. Sample a batch of trips flagged as affected and manually confirm their remaining geometry, not just their historical geometry, actually crosses the buffer — this catches the common bug of filtering on full trip geometry instead of remaining geometry.
  6. Clearance rollback. When the incident feed reports the event cleared, confirm a corresponding osrm-customize run restores the original speed_kph values from the edge index rather than leaving the penalty permanently applied.
osrm-customize succeeds but /route still returns the old path

The customize step completed, but osrm-routed is still serving the previous shared-memory dataset. Confirm osrm-datastore --dataset-name ran successfully and that osrm-routed was started with a dataset name (not a direct file path) so it can pick up the swap. Check docker logs for the osrm-datastore process for a confirmation line before assuming the swap failed.

Every vehicle near the city gets flagged as affected

This almost always means the buffer or the trip-geometry filter is using unprojected degree units instead of meters. Confirm BUFFER_METERS is applied after reprojecting to METRIC_CRS, not on the raw WGS84 geometry — a 35-unit buffer in degrees covers tens of kilometers, not 35 meters.