Crew Dispatch & Route Optimization for Outage Response
The Failure Mode: A Correct Impact Set Squandered by an Ad-Hoc Dispatch Board
The failure this guide solves happens after the analysis is right. A utility can resolve every fault to the correct feature, compute a flawless downstream impact set, and derive a defensible isolation boundary — and still lose hours of restoration time because the dispatch decision that follows is made by a human dragging pins on a wall board. During a blue-sky day that improvises acceptably. During a wind event with two hundred open work orders, four crew types, and switching steps that must happen in a strict order, manual dispatch degrades into a queue sorted by whoever called last, crews sent to a repair before the section is isolated, and specialist crews stuck on work a general crew could have cleared. The impact set was a deterministic computation; the dispatch that consumes it is guesswork, and the reliability metrics record the difference.
Crew dispatch and route optimization is the discipline that closes that gap: it treats the transition from what failed and who is out to which crew goes where, in what order as a second deterministic computation rather than an improvisation. It sits directly downstream of the outage routing and impact automation loop, consumes the outputs of impact analysis and affected-customer tracing, and depends for its switching steps on the barrier states produced by valve and isolator mapping. This reference targets utility engineers, dispatch-system builders, and Python automation teams, and it covers five stages: assembling work orders from the impact set and isolation boundary, deterministic priority scoring, resolving switching-order dependencies, solving the constrained routing problem, and re-solving continuously with a clean handoff to the enterprise CRM and mobile workforce.
Prerequisite Checklist
Confirm every item below before wiring dispatch automation to a production outage feed. The scoring-input and switching-dependency checks are the usual causes of a plan that looks optimal but sends a crew into an energized section.
Core Data Model: Work Orders, Priority, and the Switching-Order Graph
Dispatch automation reduces to three structures, each derived deterministically from upstream outputs. The first is the work order: one record per unit of field work, whether that is operating a switch on the isolation boundary or repairing the faulted asset itself. A work order carries the skill it demands, an estimated service duration, a geographic location resolved to the travel-time matrix, an access window (some sites are gated or roadside), and a back-reference to the fault event that generated it. Assembling work orders from the impact set is a mechanical fan-out: every operable device in the isolation boundary becomes a switching order, and every faulted segment becomes a repair order that inherits the customer count and critical-facility flags of the impact set downstream of it.
The second structure is the priority score — a single deterministic number that orders the queue. It is a weighted combination of the customer count carried by the work order, a critical-facility multiplier, and the remaining time on any regulatory SLA clock. Determinism is the non-negotiable property: given the same inputs the score must be identical every run, so that two dispatchers, or the same dispatcher re-solving after a new fault, never see the queue re-order for reasons they cannot explain. The full scoring model, including how customer-hours map onto the reliability metrics, is worked end to end in prioritizing restoration with SAIDI/SAIFI scoring.
The third structure is the switching-order dependency graph, and it is the one that keeps dispatch safe rather than merely fast. A repair cannot begin until the devices that isolate its section have been operated, and some switching steps themselves depend on others — you open the sectionalizing switch before you close the tie that back-feeds the healthy portion. Modeled as a directed acyclic graph whose edges are “must happen before” relations, this becomes a topological-ordering problem: any valid dispatch sequence is a topological sort of the graph, and a cycle is a planning error to surface rather than a schedule to execute.
from dataclasses import dataclass, field
import networkx as nx
@dataclass(frozen=True)
class WorkOrder:
"""A single unit of field work derived from an outage impact set."""
wo_id: str
kind: str # "switching" or "repair"
skill: str # required crew qualification
duration_min: int # estimated service time on site
customers: int # affected customers downstream of this work
critical: bool # feeds a critical facility (hospital, lift station)
depends_on: tuple[str, ...] = field(default_factory=tuple)
def switching_sequence(orders: list[WorkOrder]) -> list[str]:
"""Return a safe execution order via topological sort of the dependency graph.
Raises ValueError if the dependencies contain a cycle, which indicates a
switching-plan error that must be resolved before any crew is dispatched.
"""
graph: nx.DiGraph = nx.DiGraph()
graph.add_nodes_from(wo.wo_id for wo in orders)
for wo in orders:
for predecessor in wo.depends_on:
graph.add_edge(predecessor, wo.wo_id) # predecessor before wo
if not nx.is_directed_acyclic_graph(graph):
cycle = nx.find_cycle(graph)
raise ValueError(f"switching-order cycle detected: {cycle}")
return list(nx.topological_sort(graph))
if __name__ == "__main__":
plan = [
WorkOrder("SW-01", "switching", "switching", 15, 0, False),
WorkOrder("SW-02", "switching", "switching", 15, 0, False, depends_on=("SW-01",)),
WorkOrder("RP-09", "repair", "cable-splice", 120, 340, True, depends_on=("SW-02",)),
]
print(switching_sequence(plan)) # ['SW-01', 'SW-02', 'RP-09']
The depends_on relation is what makes the whole plan auditable: the dependency graph is the machine-readable form of the switching order a control-room engineer would otherwise hand-write, and it is derived from the isolation boundary rather than typed by hand under storm pressure.
Step-by-Step Implementation
Follow this sequence to take a set of confirmed fault events to a routed, safe crew plan.
- Assemble work orders. Fan out each fault event’s impact set and isolation boundary into
WorkOrderrecords, attaching the required skill, estimated duration, and downstream customer count to each. - Score every work order. Apply the deterministic priority function so the queue is ordered before any routing runs. Repair orders inherit the customer impact; switching orders inherit the priority of the repairs they unblock.
- Resolve switching-order dependencies. Build the dependency DAG and topologically sort it, so the router is never free to schedule a repair ahead of the switching that isolates it.
- Solve the constrained routing problem. Feed crews, the travel-time matrix, skills, and time windows into a capacitated vehicle-routing solver that minimizes weighted travel plus priority-scaled delay. The complete solver, using Google OR-Tools with time windows, is built in solving crew routing with OR-Tools.
- Publish and re-solve. Write the assignments to the CRM and mobile workforce over idempotent REST calls, then re-run scoring and routing whenever the input changes.
The priority function that step 2 depends on must be deterministic and cheap enough to run on every re-solve. The pattern below scores a work order from its customer count, a critical-facility multiplier, and the fraction of its SLA window already consumed, then propagates each repair’s priority back onto the switching steps that unblock it so the router never leaves a high-value repair stranded behind a low-scored switch.
from dataclasses import dataclass
@dataclass(frozen=True)
class ScoreWeights:
"""Deterministic weights for the restoration-priority score."""
per_customer: float = 1.0
critical_bonus: float = 5000.0
sla_urgency: float = 2000.0
def priority_score(customers: int, critical: bool, sla_fraction_used: float,
weights: ScoreWeights = ScoreWeights()) -> float:
"""Compute a reproducible restoration-priority score for one work order.
`sla_fraction_used` is the share of the regulatory notification window
already elapsed (0.0 fresh, 1.0 at the deadline). Higher score = do first.
"""
if not 0.0 <= sla_fraction_used <= 1.5:
raise ValueError(f"sla_fraction_used out of range: {sla_fraction_used}")
score = customers * weights.per_customer
if critical:
score += weights.critical_bonus
score += sla_fraction_used * weights.sla_urgency
return round(score, 3)
def propagate_to_switching(orders: list[dict]) -> dict[str, float]:
"""Give each switching order the max priority of the repairs it unblocks.
Each order dict carries: id, kind, score, unblocks (ids of repairs).
Returns a mapping of order id -> effective priority.
"""
base = {o["id"]: o["score"] for o in orders}
effective = dict(base)
for order in orders:
if order["kind"] == "switching":
downstream = [base[r] for r in order.get("unblocks", []) if r in base]
if downstream:
effective[order["id"]] = max(order["score"], max(downstream))
return effective
if __name__ == "__main__":
repair = priority_score(customers=340, critical=True, sla_fraction_used=0.4)
orders = [
{"id": "RP-09", "kind": "repair", "score": repair, "unblocks": []},
{"id": "SW-02", "kind": "switching", "score": 0.0, "unblocks": ["RP-09"]},
]
print(round(repair, 1)) # 6800.0
print(propagate_to_switching(orders)) # SW-02 inherits RP-09's score
With scores assigned and the switching sequence resolved, routing becomes a constrained optimization: assign work orders to crews and order each crew’s visits to minimize total weighted cost, subject to skill matching, shift and work-rule windows, and the precedence constraints from the dependency graph. That is a capacitated vehicle-routing problem with time windows, and it is solved with a dedicated constraint engine rather than a hand-rolled loop, because the constraint interactions — a splice crew that must reach a critical facility before its SLA expires, but only after a switching crew has isolated the section — defeat greedy heuristics quickly.
The routing stage returns a per-crew ordered list of stops with arrival times. That structured output is what step 5 pushes to the field: each assignment carries its work-order id, the crew, the sequence position, and the projected arrival, so the mobile application and the CRM show the same plan the solver produced. Because the whole pipeline — assemble, score, sequence, solve — is a pure function of its inputs, a re-solve triggered by a new fault or a closed road produces a fresh plan that a dispatcher can diff against the previous one rather than reconstruct from scratch.
Diagnostic Protocol
When a dispatch plan looks wrong — a crew idle, a critical facility served late, or a repair scheduled before its isolation — work this checklist in order. The first item is the most common root cause.
- Stale or missing scores first. Confirm every work order carries a
priority_scorecomputed this cycle. An order that defaults to zero because its customer count never propagated from the impact set sinks to the bottom of the queue and looks “deprioritized” when it is really unscored. - Broken switching dependencies. Re-run
switching_sequenceand inspect for aValueError. A cycle, or adepends_onreference to a work order that was never assembled, lets the solver schedule a repair ahead of its isolation — a safety defect, not an efficiency one. - Skill mismatch starving a crew. Verify the skill on each work order matches at least one available crew qualification. An order demanding a skill no on-shift crew holds is infeasible, and a solver may drop it silently or report no solution.
- Time-window infeasibility. Check that each work order’s access window and each crew’s shift window actually overlap given travel time. A window that closes before any crew can arrive makes the whole model infeasible; relax or flag it rather than letting the solver return nothing.
- Travel matrix drift. Compare a sample of matrix entries against the current road network. A matrix built before a bridge closure routes a crew across a road that no longer exists, and every downstream arrival time is wrong.
- Non-determinism across re-solves. If two runs on identical inputs produce different plans, pin the solver’s random seed and search parameters. A dispatch plan that changes without an input change is impossible to audit and erodes dispatcher trust.
Performance & Scale Considerations
At storm volume the routing problem grows in two dimensions at once — more work orders and more crews as mutual-aid arrives — and a naive full re-solve on every telemetry tick will not keep up. Partition the problem geographically: outage work rarely benefits from a crew crossing a service territory, so solve one routing problem per operating district or feeder group and let each run independently and in parallel. Within a partition, cap the solver’s search time and accept the best feasible solution found within a fixed budget rather than proving optimality; a good plan now beats an optimal plan after the next three faults have already changed the inputs.
Re-solve on change, not on a timer. Debounce the input stream so a burst of correlated telemetry produces one re-solve rather than dozens, and warm-start each run from the previous plan so the solver improves an existing assignment instead of rediscovering it. Keep the travel-time matrix on its own refresh cadence — pull road-network updates into a staging table and rebuild the matrix in batches, exactly as the batch topology processing patterns stage and apply updates, rather than querying a live routing service inside the solve loop. Cache crew rosters and skill sets as in-memory structures keyed by shift, and apply only deltas as crews check in or time out, so the model rebuild between re-solves stays cheap.
Compliance Notes
Dispatch automation produces records that a regulator will later read. Because restoration timing feeds the publicly reported reliability metrics — SAIDI, SAIFI, and CAIDI — the timestamps a dispatch system records (assignment, en route, on site, restored) become part of the compliance evidence, and their immutability matters as much as their accuracy. Every re-solve should be logged with the inputs that triggered it and the plan it produced, so an auditor can reconstruct why a given crew was sent where at a given moment. Critical-facility customers carry statutory notification and restoration-priority obligations; the critical-facility multiplier in the priority score is the machine-enforced form of that obligation, and its configuration belongs under version control so a change to the weighting is itself an auditable event.
The switching-order dependency graph is a safety record as well as a scheduling input: it demonstrates that the system never authorized a repair before the section was isolated, which is the evidence that underpins utility switching and tagging procedures and the emergency-response records that align with NERC operating requirements and, for water systems, AWWA G400 operational-control practice. Standard Python logging is sufficient to capture the audit event for each dispatch cycle — the triggering change, the scored queue, the resolved switching order, and the published assignments — and routing those events to an asset-management system or SIEM gives the durable trail. Authority for the reliability standards themselves should be drawn only from primary sources such as the NERC reliability standards and the AWWA standards program.
Related
- Up to the parent: Outage Routing & Impact Automation
- Impact Analysis & Affected-Customer Tracing — the impact set and critical-facility flags that dispatch consumes.
- Real-Time Telemetry: SCADA & AMI Integration — the fault events that generate work orders.
- Valve & Isolator Mapping Strategies — the barrier states behind every switching-order dependency.
- Solving Crew Routing with OR-Tools — the constrained vehicle-routing solver in full.
- Prioritizing Restoration with SAIDI/SAIFI Scoring — the deterministic score that orders the queue.