Modelling Switching Order Dependencies as a DAG
A switching order written as a numbered list encodes two different things in one structure: what must happen before what, and what somebody decided to do first. The router cannot tell them apart, so it either respects the whole sequence — serialising work that could run in parallel and leaving crews idle — or it reorders freely and produces a plan that opens a switch after the repair it was supposed to isolate. Modelling the order as a directed acyclic graph separates the two: each step declares only its real dependencies, the sort produces a valid order, and levelling the graph reveals which steps can be executed at the same time. This guide builds that model and wires it into the dispatch loop described in crew dispatch and route optimization.
Environment Prerequisites
- Python 3.11 with
networkx>=3.0for the graph operations; the rest is standard library. - Work orders carrying explicit dependencies — a
depends_onlist of order identifiers rather than a sequence number. - A step type per order: isolation, repair, test, restoration. The type is what lets the validator catch a plan that restores before it tests.
- The isolation set for the fault, from an isolation trace, because the isolation steps are derived from it rather than authored by hand.
- Crew skills and availability as the router sees them, since levelling only helps if there are crews to fill a wave.
- A completion feed from the field, so a finished step releases its dependents promptly.
Schema-Aware Validation Protocol — Run Before Publishing a Plan
- Assert the graph is acyclic. A cycle means the plan cannot be executed in any order, and the correct response is to stop rather than to break the cycle arbitrarily.
- Check every dependency resolves. A reference to a cancelled or superseded order is a dangling edge, and the plan built on it is missing a constraint.
- Verify type ordering. Every repair must depend on at least one isolation step, and every restoration must depend on a test. These are cheap assertions that catch an authored plan with a missing safety step.
- Confirm each isolation step names an operable device. A plan that depends on a seized valve is unexecutable, which the valve operability audit is there to prevent.
- Check the levels against crew availability. A wave of six parallel steps with two crews is not parallelism; it is a queue, and the plan should say so.
Minimal Reproducible Implementation
from __future__ import annotations
import logging
from dataclasses import dataclass, field
import networkx as nx
logging.basicConfig(level=logging.INFO, format="%(levelname)s: %(message)s")
LOG = logging.getLogger("switching-dag")
ISOLATION, REPAIR, TEST, RESTORATION = "ISOLATION", "REPAIR", "TEST", "RESTORATION"
@dataclass
class Step:
order_id: str
step_type: str
device_id: str | None = None
depends_on: list[str] = field(default_factory=list)
@dataclass
class Plan:
waves: list[list[str]] = field(default_factory=list)
problems: list[str] = field(default_factory=list)
@property
def executable(self) -> bool:
return not self.problems
def build_plan(steps: list[Step], operable: set[str]) -> Plan:
"""Turn declared dependencies into levelled waves of independently executable steps.
Levelling is what exposes parallelism: every step whose dependencies are already
satisfied belongs in the same wave and may be assigned to a different crew. A cycle
or a dangling dependency stops the plan, because a switching order that cannot be
sorted has no safe execution order at all.
"""
plan = Plan()
graph = nx.DiGraph()
by_id = {s.order_id: s for s in steps}
for step in steps:
graph.add_node(step.order_id, type=step.step_type)
for step in steps:
for dep in step.depends_on:
if dep not in by_id:
plan.problems.append(
f"{step.order_id} depends on {dep}, which does not exist")
continue
graph.add_edge(dep, step.order_id)
if not nx.is_directed_acyclic_graph(graph):
cycle = nx.find_cycle(graph)
plan.problems.append(f"dependency cycle: {cycle}")
return plan
for step in steps:
deps = {by_id[d].step_type for d in step.depends_on if d in by_id}
if step.step_type == REPAIR and ISOLATION not in deps:
plan.problems.append(f"{step.order_id}: repair with no isolation dependency")
if step.step_type == RESTORATION and TEST not in deps:
plan.problems.append(f"{step.order_id}: restoration with no test dependency")
if step.step_type == ISOLATION and step.device_id not in operable:
plan.problems.append(
f"{step.order_id}: device {step.device_id} is not operable")
if plan.problems:
return plan
for wave in nx.topological_generations(graph):
plan.waves.append(sorted(wave))
LOG.info("plan: %d wave(s), widest %d step(s)", len(plan.waves),
max((len(w) for w in plan.waves), default=0))
return plan
nx.topological_generations is doing the levelling in one call, and it is the difference between
a plan and a schedule. A list tells a dispatcher what order to work in; a set of waves tells them
how many crews they can usefully deploy right now.
Executing Against the Graph
Publishing waves rather than a list changes how completion is handled. A crew finishing a step does not advance a pointer; it satisfies a dependency, and whichever steps that releases become available. Two consequences follow.
The first is that a delayed step blocks only what actually depended on it. In a linear plan, a crew held up at switch nineteen stops everything behind it; in a levelled plan, the other isolation step and anything else independent continues. Dispatchers know this intuitively and work around linear plans by ignoring them, which is precisely the behaviour that makes plans and reality diverge.
The second is that the plan can be re-levelled mid-event without being rewritten. A step that turns out to be impossible — a device that will not operate — is marked failed, its dependents stay blocked, and an alternative isolation step can be inserted with its own dependencies. The graph absorbs the change; a numbered list would have to be reissued.
Production Deployment Pattern
- Derive isolation steps from the trace, not by hand. Hand-authored isolation is where missing steps come from, and the trace already knows the minimal set.
- Refuse to publish an unsortable plan. A cycle or a dangling dependency is a stop condition, and breaking it arbitrarily produces an order somebody will execute.
- Assert the type rules on every plan. Repair after isolation, restoration after test. They are trivial checks and they catch the plans that matter.
- Publish waves with crew counts. A wave wider than the available crews should be shown as what it is, so nobody plans around parallelism that does not exist.
- Release on completion, not on a schedule. The completion feed is what makes the graph worth having; polling for it hourly discards most of the benefit.
- Log the executed order alongside the planned one. The difference between them is the best available evidence about whether the dependency model matches how the work is really done.
Conclusion
Modelling a switching order as a dependency graph separates what must be ordered from what merely was. The sort produces a valid sequence, the levelling exposes the parallelism a numbered list hides, and the type assertions catch the plans that skip a safety step. Executing against the graph rather than a pointer means a delay blocks only its dependents and an alternative can be inserted without reissuing anything — which is closer to how switching is actually run, and therefore likelier to survive contact with an event.
Related
- Up to the parent topic: Crew Dispatch & Route Optimization
- Up to the section: Outage Routing & Impact Automation
- Solving Crew Routing with OR-Tools
- Emergency Isolation Scripting for Water Main Breaks
- Auditing Valve Operability and Turn-Count Records
For authoritative reference, consult the NetworkX DAG documentation and the Python dataclasses library.