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.

Closing flooded edges and rerouting evacuation traffic to a safe exit A road network graph links an evacuation-zone origin to a safe exit. A rising flood polygon covers two edges of the original shortest route, so those edges are removed from the graph and marked closed. The router recomputes a detour path around the flood to the same exit and tests every zone for a remaining connection. The pipeline on the right runs four ordered stages — intersect the flood extent against edges, remove the closed edges, reroute only the affected origins, and detect unreachable zones — and any zone with no remaining path is escalated with an audit record. rising flood extent original route · closed reroute · dry detour evac zone origin safe exit Reroute pipeline 1 · intersect flood ∩ edges reproject + buffer, then test 2 · remove closed edges delete, don't just penalize 3 · reroute affected origins only routes touching a closure 4 · detect unreachable zone with no exit → escalate Fallback + audit emit closed edges, reroutes, unreachable zones + snapshot id reinstate edges when water recedes

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.

  1. 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.
  2. 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.
  3. 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.
  4. 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.
  5. 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.

python
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_id is 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 every pyproj transform with always_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 bridge attribute and exclude them from closure unless the flood stage exceeds the deck height.
  • Missing edge geometry. An edge without a geometry cannot 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.

Up: Evacuation Routing & Road Network Analysis