Rerouting Around Dynamically Closed Roads During Flooding
A river gauge upstream trips its second threshold at 02:40 and the flood model pushes a new inundation polygon to the incident feed. Three of the evacuation routes your dashboard published an hour ago now cross water that is knee-deep and rising — including the primary artery out of a low-lying mobile-home park. Buses are already staging against those routes. If the routing layer keeps returning the same paths because they are still the shortest on paper, it will drive evacuees toward the one place they must not go. This page solves that single narrow failure mode: taking a live flood polygon that closes roads mid-incident and turning it into an updated, physically passable set of evacuation routes — while detecting any zone that has just been cut off, falling back safely, and recording every closure so the decision holds up in review.
Root Cause and Operational Impact
An evacuation router models the road network as a graph: intersections are nodes, road segments are edges, and each edge carries a cost such as travel time. Shortest-path routing over that graph is only ever as correct as the graph’s assumptions, and the deepest assumption is that every edge is passable. Flooding breaks that assumption dynamically. A polygon that did not exist ten minutes ago now covers a set of edges, and the router has no idea unless something intersects that polygon against the network and mutates the graph.
Two failure patterns follow. The first is the stale route: the router keeps returning a path that is now underwater because nothing invalidated it, so evacuees are steered into the hazard. The second is the soft penalty trap: a well-meaning implementation raises the cost of flooded edges instead of removing them, and under enough network pressure — when every dry alternative is longer — the router still selects the flooded edge because it remains the cheapest option. Both patterns put people in moving water, which is the leading cause of flood fatalities.
The impact is not merely a slower route; it is a routing decision that is actively wrong at the moment it matters most. The National Incident Management System (NIMS) and the Federal Emergency Management Agency (FEMA) both expect evacuation decisions to be reconstructable during after-action review, so a router that silently mutated its graph without a trail cannot defend why it sent traffic where it did. And a zone that has been cut off entirely — surrounded by closed edges with no remaining exit — is the single most consequential output the router can produce, yet the naive implementation returns it as an empty path indistinguishable from “no request.” The closure has to be detected, the graph edit has to be auditable, and the flood polygon must be intersected against the network in a shared coordinate frame, which is exactly why this work depends on the coordinate reference system standard for disaster zones and consumes closures from the same live incident feed transport as the rest of the operating picture.
Tiered Resolution Strategy
Handle each flood update in ordered tiers, from the definitive fix down to a safe default that always leaves an audit flag. Never return an empty route silently — an unrouted zone is a life-safety event, not a null result.
- Close inundated edges as a hard constraint (definitive). Intersect the reprojected, safety-buffered flood polygon against every road edge and remove the intersecting edges from the routing graph entirely. Deletion — not a cost penalty — guarantees the router can never select a flooded segment no matter how much cheaper it looks.
- Reroute only the affected origins. Recompute shortest paths solely for origins whose current route traversed a newly closed edge. Untouched routes are left in place, keeping the update fast enough to run on every hazard-feed tick.
- Detect and escalate unreachable zones (safe default). After the graph edit, test each evacuation-zone node for a remaining path to any safe exit. A zone with no path is returned with an explicit unreachable status and escalated for air or water rescue tasking, never dropped.
- Prefer the previous route when a fresh reroute fails validation (fallback). If a recomputed path cannot be verified as edge-disjoint from the flood, hold the last known-good route for that origin, mark it degraded, and flag it for a human check rather than publishing an unverified path.
- Emit an audit record for every mutation. Each closed edge, each rerouted origin, and each unreachable zone is written with the flood-snapshot version so the entire routing decision is reproducible against the exact hazard state that produced it.
Production Python Implementation
The routine below carries the full resolution path over a networkx graph: flood intersection and edge closure, incremental rerouting of only affected origins, unreachable-zone detection, a safe fallback to the last valid route, structured logging, explicit exception handling, and an immutable audit record per mutation. Thresholds such as the safety buffer are parameters, not literals, so they are committed alongside the rest of the routing configuration. Senior-engineer assumptions apply: networkx, shapely, and pyproj are available; the graph edges carry a geometry and a weight; and the flood polygon and network share the projected coordinate frame established by the coordinate reference system standard for disaster zones.
from __future__ import annotations
import logging
from dataclasses import dataclass, field, asdict
from datetime import datetime, timezone
from typing import Optional
import networkx as nx
from shapely.geometry import base as shapely_base
from shapely.errors import GEOSException
logger = logging.getLogger("incidentgis.reroute")
@dataclass
class RouteResult:
origin: int
path: list[int]
reachable: bool
degraded: bool = False
reason: str = "ok"
@dataclass
class AuditEntry:
"""Immutable record of one reroute cycle, emitted to the audit trail."""
snapshot_id: str
closed_edges: list[tuple[int, int]]
rerouted_origins: list[int]
unreachable_zones: list[int]
degraded_origins: list[int]
recorded_at: str = field(
default_factory=lambda: datetime.now(timezone.utc).isoformat()
)
class FloodReroute:
"""Close flooded road edges and reroute evacuation traffic incrementally.
Every graph mutation is logged and appended to ``audit_log`` so a routing
decision can be reconstructed against the exact flood snapshot that
produced it. The base ``graph`` is never mutated; closures are applied to
a working copy and tracked in ``_closed`` for later reinstatement.
"""
def __init__(
self,
graph: nx.Graph,
exits: set[int],
safety_buffer_m: float = 15.0,
) -> None:
self.graph = graph
self.exits = exits
self.safety_buffer_m = safety_buffer_m
self._routes: dict[int, RouteResult] = {} # origin -> last valid route
self._closed: set[tuple[int, int]] = set()
self.audit_log: list[AuditEntry] = []
def _closed_edges(
self, work: nx.Graph, flood: shapely_base.BaseGeometry
) -> list[tuple[int, int]]:
"""Return edges whose geometry intersects the buffered flood extent."""
hazard = flood.buffer(self.safety_buffer_m)
hit: list[tuple[int, int]] = []
for u, v, data in work.edges(data=True):
geom = data.get("geometry")
if geom is None:
# Cannot prove the edge is dry: treat missing geometry as unsafe.
logger.warning("edge_missing_geometry", extra={"edge": (u, v)})
hit.append((u, v))
continue
if geom.intersects(hazard):
hit.append((u, v))
return hit
def _shortest(self, work: nx.Graph, origin: int) -> Optional[list[int]]:
"""Shortest path from origin to the nearest safe exit, or None."""
best: Optional[list[int]] = None
best_cost = float("inf")
for exit_node in self.exits:
try:
cost, path = nx.single_source_dijkstra(
work, origin, target=exit_node, weight="weight"
)
except nx.NetworkXNoPath:
continue
except nx.NodeNotFound:
# Origin or exit removed with its edges: no route through it.
continue
if cost < best_cost:
best_cost, best = cost, path
return best
def update(
self,
origins: set[int],
flood: shapely_base.BaseGeometry,
snapshot_id: str,
) -> dict[int, RouteResult]:
"""Apply a flood snapshot and return the current route per origin."""
try:
work = self.graph.copy()
newly_closed = self._closed_edges(work, flood)
work.remove_edges_from(newly_closed)
# Incremental: only reroute origins whose last path used a closure.
closed_set = set(newly_closed) | {(v, u) for u, v in newly_closed}
affected = {
o for o in origins
if o not in self._routes
or any(
(a, b) in closed_set
for a, b in zip(self._routes[o].path, self._routes[o].path[1:])
)
}
rerouted: list[int] = []
unreachable: list[int] = []
degraded: list[int] = []
for origin in affected:
path = self._shortest(work, origin)
if path is None:
prior = self._routes.get(origin)
if prior is not None and prior.reachable:
# Hold last known-good, but flag it for a human check.
prior.degraded = True
prior.reason = "held_no_new_path"
degraded.append(origin)
logger.error(
"reroute_degraded", extra={"origin": origin}
)
else:
self._routes[origin] = RouteResult(
origin, [], reachable=False, reason="unreachable"
)
unreachable.append(origin)
logger.critical(
"zone_unreachable",
extra={"origin": origin, "snapshot": snapshot_id},
)
else:
self._routes[origin] = RouteResult(origin, path, reachable=True)
rerouted.append(origin)
self._closed |= closed_set
entry = AuditEntry(
snapshot_id=snapshot_id,
closed_edges=newly_closed,
rerouted_origins=rerouted,
unreachable_zones=unreachable,
degraded_origins=degraded,
)
self.audit_log.append(entry)
logger.info("reroute_cycle", extra={"audit": asdict(entry)})
return {o: self._routes[o] for o in origins if o in self._routes}
except (GEOSException, ValueError, KeyError) as exc:
# Never crash the routing loop: hold all prior routes as degraded.
logger.error("reroute_cycle_failed", exc_info=exc)
for res in self._routes.values():
res.degraded = True
res.reason = "cycle_error_hold"
return dict(self._routes)
The audit_log is the load-bearing output. Persisting it as a committed, content-hashed artifact lets a post-incident reviewer replay exactly which edges were closed at 02:40, which zones were rerouted, and which were escalated for rescue — the reproducibility that a defensible evacuation decision requires and that the parent evacuation routing and road network analysis workflow is built around.
Validation Checklist
Verify every item before wiring the reroute engine to a live flood feed.
- The flood polygon is reprojected into the network’s projected CRS and buffered by the safety margin before any intersection test runs.
- Flooded edges are removed from the working graph, not merely assigned a high cost, so no route can traverse standing water under pressure.
- The base graph is never mutated in place; closures apply to a copy and are tracked in a closed set for reinstatement when the water recedes.
- Only origins whose current route touched a newly closed edge are rerouted; untouched routes are left unchanged.
- A zone with no remaining path to any exit returns an explicit unreachable status, logs at critical severity, and escalates — it never returns an empty path silently.
-
snapshot_idis set from the flood-feed message identifier so each audit entry ties back to a specific hazard state. - Structured logs route to the incident logging sink, not stdout, and every closed edge, reroute, and unreachable zone appears in
audit_log. - The engine is unit-tested against a synthetic network where a known flood polygon cuts a known set of edges, asserting the expected reroutes and the expected unreachable zone.
Edge Cases and Gotchas
- Axis-order inversion. If the flood polygon arrives as GeoJSON in
(lon, lat)order but the network geometries are in a projected CRS, the intersection either returns nothing or closes the wrong edges. Normalize axis order at ingest and run everypyprojtransform withalways_xy=True, per the coordinate reference system standard for disaster zones; an unbuffered geographic-versus-projected mismatch is the most common cause of a reroute that “does nothing.” - Bridges and grade separation. A 2D intersection closes an overpass whenever the flood footprint passes beneath it, stranding traffic that could have crossed safely. Tag grade-separated edges with an elevation or
bridgeattribute and exclude them from closure unless the flood stage exceeds the deck height. - Missing edge geometry. An edge without a
geometrycannot be proven dry, so the implementation closes it defensively rather than assuming it is safe. Backfill geometries in the source network; a network full of geometry-less edges will over-close and manufacture false unreachable zones. - Flood polygon flapping. A model that oscillates a boundary edge in and out of the extent on successive ticks will open and close the same road repeatedly, thrashing routes. Apply hysteresis — require an edge to be clear for N consecutive snapshots before reinstatement — so a receding-then-rising boundary does not whipsaw evacuees.
- Directed one-way and contraflow edges. On a directed graph, removing
(u, v)leaves(v, u)live, so a one-way segment can still be routed the wrong way against contraflow. Close both directions of a physically flooded segment, and treat any contraflow reversal as a separate, explicitly modeled edge rather than an implicit one.
Frequently Asked Questions
Should a flooded road be removed from the graph or just penalized with a high cost? Remove it. Raising an edge’s cost only discourages a router from using the road; under enough pressure a cost-based penalty can still route evacuees through standing water because every alternative is worse. Deleting the edge makes the closure a hard constraint, so the only routes returned are physically passable ones. Keep the removed edges in a separate closed set with their closure reason and timestamp so they can be reinstated when the water recedes and so every closure is auditable.
How do you avoid recomputing every evacuation route each time the flood polygon updates? Reroute incrementally. Track which edges each active route traverses, and when a flood update closes a set of edges, recompute paths only for the origins whose current route included at least one newly closed edge. Origins whose route is untouched keep their existing path, which turns a full all-pairs recomputation into a small targeted one and keeps latency low enough to run on every hazard-feed tick.
What should happen when an evacuation zone becomes completely cut off? Never return an empty result silently. When no path remains from a zone to any safe exit, the router must emit an explicit unreachable status with the zone identifier and the flood-snapshot version, escalate it to incident command for air or water rescue tasking, and record it in the audit trail. A cut-off zone is the single most safety-critical output of the whole routine, so it has to be surfaced loudly rather than hidden behind a missing route.
Related
- Evacuation Routing & Road Network Analysis — the routing model whose graph this reroute engine mutates in place.
- WebSocket & MQTT for Live Incident Feeds — the transport that delivers each flood snapshot the engine reacts to.
- Coordinate Reference Systems for Disaster Zones — the CRS and axis-order contract that keeps flood-versus-network intersection honest.