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
Deleting the incident edge against slowing it to a crawl An incident falls on the only bridge linking two districts. Deleting the edge splits the graph and every cross-river query returns no route. Setting the speed to two kilometres per hour makes the bridge enormously expensive but still traversable, so a route is returned whenever no alternative exists. Incident on the only crossing between two districts delete the edge north south gone every cross-river query returns NoRoute set speed to 2 km/h north south cost ×40 avoided wherever an alternative exists, still routable A disconnected component is a much worse failure than an expensive one: dispatch cannot plan around a NoRoute, but it can plan around a slow crossing. Keep the speed above zero as well — a zero divides into an infinite travel time and reintroduces the same problem by another route.

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.

Blue-green dataset naming exists because incidents do not wait for the previous customise to finish. Alternating between two fixed slot names means a second incident arriving mid-swap builds into the idle slot instead of racing the one being loaded.

Alternating dataset slots under overlapping incidents Incident one triggers a customise run. Incident two arrives before it completes. With a single dataset name the second run overwrites the slot the first is still loading. With alternating slots the second builds into the idle slot and the active pointer moves once each build finishes. Incident 2 arrives 20 s into a 45 s customise run one slot customise → metro-live customise → metro-live the second write lands on the slot the first is still loading from blue-green customise → metro-live-a customise → metro-live-b active → a active → b Track the active slot name in Redis or a small state file — the alternation only works if both the builder and the loader agree on which is live. Keep the incident poll interval above customise duration plus load time, or incidents arrive faster than slots free and the alternation degrades.

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.