Computing Affected-Customer Counts with NetworkX
The affected-customer count is the number the control room, the regulator, and the public all read first, and it is deceptively easy to compute wrongly. Estimating it by drawing a polygon around a de-energised area and counting the meters inside ignores the fact that outage extent is a function of network connectivity and current switch positions, not geography — two premises a metre apart can sit on different feeders, one out and one live. Doing it by hand does not survive storm volume, when the count must be recomputed thousands of times an hour as faults arrive and crews operate devices, with no engineer in the loop to sanity-check each answer. NetworkX gives a precise, testable alternative: model the feeder as a directed graph, drop the devices that are open, take the descendants of the faulted asset, and count the service points. This page provides a complete, copy-paste implementation of that trace. It is a concrete realisation of the model established in impact analysis and affected-customer tracing, applies the mechanics of upstream and downstream tracing algorithms, and feeds the reporting obligations described across outage routing and impact automation. Because the count drives publicly reported reliability metrics, a wrong answer is not a cosmetic defect — it is a compliance and safety failure delivered with false confidence.
Environment Prerequisites
Lock the following before running the workflow; a mismatched version or an undefined orientation produces a count that looks plausible and is wrong.
- Python runtime: Python 3.11 in an isolated environment. Create it with
conda create -n un-impact python=3.11so traversal behaviour stays reproducible across engineers. - Dependencies:
networkx>=3.0andpandas>=2.0, installed viaconda install -c conda-forge networkx pandas. NetworkX 3.x is assumed fordescendantsandhas_pathsemantics; the 2.x API differs subtly in edge-view behaviour. - Graph orientation: The
DiGraphmust be oriented from energised sources toward loads, so that “downstream of the fault” is exactlydescendants(graph, faulted_node). A graph built with reversed or undirected edges silently returns the upstream set or the whole component. - Node typing: Every load-serving node must carry
kind == "service"and resolve to a premise or meter count; every operable device must carry adevice_stateattribute. Untyped junctions are ignored by the aggregation and drop out of the count. - Device-state feed: A current source of open/closed states — SCADA points or a field-inspection table — each row stamped with an as-of time, so the graph reflects the network as it stands rather than as designed.
- Validated topology: The graph must be built from a topology that passes topology validation; a sub-tolerance gap splits the feeder and makes the descendants trace return a confidently short count.
Schema-Aware Validation Protocol — Run Before the Trace
Most wrong counts originate in the graph setup, not the traversal. Work this ordered checklist first; the earliest item is the most frequent culprit.
- Confirm edge orientation points source-to-load. Spot-check that
descendants(graph, source)returns loads and not an empty set. A reversed or undirected build is the most common reason a count comes back as zero or as the entire network. - Verify device-state currency. Confirm every operable device carries a
device_stateand that its as-of timestamp is recent. A trace over the design configuration ignores every switch a crew has operated and overstates or understates the count outright. - Check service-point typing and multiplicity. Ensure load nodes carry
kind == "service"and acustomerscount where a single service point represents multiple premises (an apartment meter bank). Counting service-point nodes instead of premises undercounts dense loads. - Guard against fragmentation. A silently disconnected span makes
descendantsstop early. Compare the reachable-node total against the feeder’s known premise count; an implausibly small result points to a connectivity gap, not a small outage. - Confirm alternate-feed handling. Identify tie switches whose closure back-feeds part of the impact area. The trace must subtract anything still reachable from a live source, or those back-fed premises are wrongly counted as out.
Minimal Reproducible Implementation
The following is a complete, runnable workflow. It builds a directed feeder graph from edge and node tables, overlays the latest device states, drops open devices so the traversal is state-aware, takes the descendants of the faulted asset, subtracts any load still reachable from a live source, and aggregates reachable service points — honouring multi-premise meters — into a structured count. Every failure mode raises or is logged rather than silently returning a wrong number.
import networkx as nx
import pandas as pd
import logging
from datetime import datetime
logging.basicConfig(level=logging.INFO, format="%(levelname)s: %(message)s")
def build_feeder_graph(edges: pd.DataFrame, nodes: pd.DataFrame) -> nx.DiGraph:
"""Build a source-to-load DiGraph from edge and node tables.
`edges` has FROM_NODE, TO_NODE; `nodes` has NODE_ID, KIND, and optional
CUSTOMERS (premises behind a service point) and DEVICE_STATE.
"""
graph = nx.DiGraph()
for _, e in edges.iterrows():
graph.add_edge(e["FROM_NODE"], e["TO_NODE"])
for _, n in nodes.iterrows():
graph.add_node(
n["NODE_ID"],
kind=n.get("KIND", "junction"),
customers=int(n.get("CUSTOMERS", 1) or 1),
device_state=n.get("DEVICE_STATE"),
)
return graph
def affected_customer_count(
graph: nx.DiGraph,
faulted_node: str,
sources: set[str],
) -> dict:
"""Return a structured affected-customer count for a fault.
Open devices are removed so traversal is state-aware; loads still reachable
from a live source (alternate feed) are subtracted. Raises KeyError if the
faulted node is absent from the graph snapshot.
"""
if faulted_node not in graph:
raise KeyError(f"faulted node {faulted_node!r} not in snapshot")
# State-aware energised graph: an OPEN device is simply absent, so
# reachability stops there without any special-case branching.
energised = graph.copy()
open_devices = [
n for n, d in graph.nodes(data=True) if d.get("device_state") == "OPEN"
]
energised.remove_nodes_from(open_devices)
if faulted_node not in energised:
logging.info("fault %s is itself an open device; no downstream load", faulted_node)
return {"faulted_node": faulted_node, "customers_out": 0,
"service_points_out": [], "ok": True}
downstream = nx.descendants(energised, faulted_node) | {faulted_node}
# Subtract loads still fed from a live source via an alternate path.
still_sourced: set = set()
for src in sources & set(energised.nodes):
still_sourced |= nx.descendants(energised, src) | {src}
affected = downstream - still_sourced
service_points = sorted(
n for n in affected if graph.nodes[n].get("kind") == "service"
)
customers_out = sum(graph.nodes[n]["customers"] for n in service_points)
result = {
"faulted_node": faulted_node,
"service_points_out": service_points,
"customers_out": customers_out,
"computed_at": datetime.utcnow().isoformat(timespec="seconds") + "Z",
"ok": bool(service_points),
}
logging.info("fault %s -> %d customers across %d service points",
faulted_node, customers_out, len(service_points))
return result
if __name__ == "__main__":
edge_df = pd.DataFrame(
[
{"FROM_NODE": "SRC", "TO_NODE": "SW1"},
{"FROM_NODE": "SW1", "TO_NODE": "XFMR1"},
{"FROM_NODE": "XFMR1", "TO_NODE": "SVC1"},
{"FROM_NODE": "XFMR1", "TO_NODE": "SVC2"},
{"FROM_NODE": "SW1", "TO_NODE": "TIE"},
{"FROM_NODE": "TIE", "TO_NODE": "SVC3"},
]
)
node_df = pd.DataFrame(
[
{"NODE_ID": "SRC", "KIND": "source"},
{"NODE_ID": "SW1", "KIND": "device", "DEVICE_STATE": "CLOSED"},
{"NODE_ID": "XFMR1", "KIND": "device", "DEVICE_STATE": "CLOSED"},
{"NODE_ID": "TIE", "KIND": "device", "DEVICE_STATE": "OPEN"},
{"NODE_ID": "SVC1", "KIND": "service", "CUSTOMERS": 1},
{"NODE_ID": "SVC2", "KIND": "service", "CUSTOMERS": 48},
{"NODE_ID": "SVC3", "KIND": "service", "CUSTOMERS": 1},
]
)
g = build_feeder_graph(edge_df, node_df)
print(affected_customer_count(g, "XFMR1", {"SRC"}))
Running the example returns 49 customers across SVC1 and SVC2 — the 48-premise meter bank on SVC2 is honoured, and SVC3 is excluded because the TIE device is open, so its branch never energises. That single worked case exercises the three behaviours that separate a correct count from a plausible one: multi-premise aggregation, state-aware truncation at an open device, and orientation from source to load.
The result structure is deliberately flat and serialisable so it can be written straight to the immutable impact report described in the parent guide. The computed_at stamp and the explicit service_points_out list make the count reproducible and auditable: given the same graph snapshot and the same fault, the function returns the same customers-out figure, and the enumerated service points let a reviewer re-derive it.
Two implementation choices are worth defending because they are the ones engineers most often get wrong. First, descendants is preferred over a hand-rolled breadth-first walk not for brevity but for correctness: it returns the transitive closure over the directed graph in one call, so a branch that rejoins downstream is counted once, and there is no traversal-depth ceiling to truncate a long rural feeder. Second, the open-device removal happens on a copy() of the graph rather than by mutating the cached snapshot, so a single storm evaluation cannot corrupt the reusable feeder graph that the next fault will trace against. On very large feeders where the copy itself is costly, substitute an edge-filter view — nx.restricted_view over the open-device set — which yields the same energised reachability without duplicating the node and edge stores.
The following reference table maps the common failure symptoms to their graph-level root cause and the fix, so a wrong count during an event can be triaged without re-reading the whole implementation.
| Symptom | Graph-level root cause | Fix |
|---|---|---|
| Count is zero on a real outage | Edges reversed or undirected | Rebuild DiGraph oriented source-to-load |
| Count equals the whole feeder | Faulted node upstream of all devices, or no open devices applied | Overlay current device_state before tracing |
| Count too low on a large circuit | Sub-tolerance gap fragments the span | Re-run topology validation on the snapshot |
| Dense premises undercounted | Service points counted as one each | Populate and sum the customers attribute |
| Back-fed premises counted as out | Alternate-feed subtraction skipped | Keep the still_sourced subtraction; confirm tie states |
Production Deployment Pattern
A notebook that prints a count is a diagnostic; an enforced service is an engineering control. Promote the trace into the outage pipeline as follows.
- Trace against a pinned snapshot. Build the graph from a versioned read or a snapshot geodatabase captured at the fault timestamp, so the count is reproducible and cannot drift as later device operations arrive. Cache one
DiGraphper feeder and apply state changes as node-attribute deltas rather than rebuilding. - Wire it behind an idempotent consumer. Fire the trace from a durable queue keyed on fault-event id, so a duplicate alarm burst or a retried message recomputes the same count rather than double-reporting an outage.
- Emit structured output for downstream systems. Serialise the result to JSON and publish it as the single impact record that dispatch scoring and customer notification both read, so the map and the hotline never disagree.
- Apply retry with backoff on the state feed. SCADA and enterprise-geodatabase reads fail intermittently under storm load; wrap the state fetch in a bounded exponential backoff so a momentary lock produces a retry, not a stale-state count.
- Gate in CI/CD. Exercise the trace against a mock feeder and asserted expected counts on every commit to the impact-scoring logic, and promote only when the assertions, topology validation, and a performance benchmark pass.
Conclusion
Computing an affected-customer count with NetworkX turns a soft, map-driven estimate into a deterministic reachability query whose answer can be reproduced and audited. Orienting the graph from source to load, removing open devices so the trace is state-aware, subtracting alternate feeds, and summing premises behind each service point yields an exact count from explicit inputs. Because that count feeds SAIDI, SAIFI, and public communications, the discipline of pinning a snapshot and enumerating the service points is what makes the number defensible rather than merely fast. The natural next step is to carry the same impact set into the critical-facility branch, where statutory notification windows attach to specific service points inside the count.
Related
- Up to the parent topic: Impact Analysis & Affected-Customer Tracing
- Up to the section: Outage Routing & Impact Automation
- Automating Critical-Facility Outage Notifications
- Upstream & Downstream Tracing Algorithms
- Prioritizing Restoration with SAIDI/SAIFI Scoring
For authoritative reference, consult the NetworkX descendants documentation and the NetworkX DiGraph reference.