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.
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-customizecan apply new weights without a fullosrm-partitionrebuild - 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
- 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. - Graph connectivity after customize. After each
osrm-customizerun, query/route/v1between two points on opposite sides of the incident buffer and confirmcode == "Ok"rather than"NoRoute"— aNoRouteresult signals the penalty accidentally disconnected the graph. - Rerouted path avoids the incident. Convert the new route’s geometry to a
LineStringand assert it does not intersect the incident buffer, except at legitimate crossing points explicitly outside the closed segment. - 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. - 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.
- Clearance rollback. When the incident feed reports the event cleared, confirm a corresponding
osrm-customizerun restores the originalspeed_kphvalues 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.
Related
- Webhook-driven dynamic re-routing — the overview of event schemas, idempotency, and device push patterns this page’s incident trigger plugs into
- Building a FastAPI re-route webhook — the HTTP entry point that receives incident events and calls this penalty pipeline
- Integrating custom traffic weights into OSRM — the general segment-speed and re-customization mechanism this page specializes for transient incidents
- Deploying OSRM with Docker for local routing — MLD vs. contraction-hierarchy trade-offs and the base container setup this pipeline assumes