Modeling Substation Connectivity Graphs with NetworkX
The scenario: a screening tool reports that a candidate site can reach a 345 kV substation in three hops, the developer builds a case around it, and a network planner points out that two of those hops are 69 kV distribution feeders that cannot carry the project at all. The graph was topologically correct and electrically meaningless. This page builds a connectivity graph that carries the attributes the question needs, and it complements grid routing and least-cost path analysis by answering “what is connected to what” rather than “where would a new line go”.
Root-cause analysis
Three failures turn transmission geometry into a graph that misleads.
- Geometric adjacency treated as electrical connection. Two lines whose endpoints are three metres apart in OpenStreetMap may be one circuit or two unconnected circuits crossing at different heights. Snapping without a voltage and operator check manufactures connections that do not exist.
- Voltage ignored on the edges. A path is only usable if every edge on it can carry the project. A shortest-path query over an unfiltered graph will happily route a 200 MW project through a distribution tap, because the graph has no concept of capacity.
- Unsnapped endpoints producing a shattered graph. The opposite failure: with too tight a tolerance the network fragments into hundreds of components and every query returns “no path”, which is usually read as a data problem rather than a parameter one — the tolerance sweep in snapping transmission lines to substation nodes is the direct remedy.
Pre-flight validation
Before building the graph, measure how fragmented the geometry is at several tolerances. The right tolerance is the largest one that produces no false merges, and false merges are detectable: they join segments whose voltage or operator disagree.
import geopandas as gpd
import numpy as np
from scipy.spatial import cKDTree
def endpoint_gap_profile(lines: gpd.GeoDataFrame, tolerances=(5, 15, 25, 50)) -> dict:
"""How many endpoint pairs merge at each tolerance, and how many disagree on voltage."""
ends = []
for idx, geom in zip(lines.index, lines.geometry):
coords = list(geom.coords)
ends.append((idx, coords[0]))
ends.append((idx, coords[-1]))
xy = np.array([c for _, c in ends])
owners = np.array([i for i, _ in ends])
volts = lines["voltage_kv"].reindex(owners).to_numpy()
tree = cKDTree(xy)
out = {}
for tol in tolerances:
pairs = tree.query_pairs(tol, output_type="ndarray")
cross = pairs[owners[pairs[:, 0]] != owners[pairs[:, 1]]]
mismatch = int(np.sum(volts[cross[:, 0]] != volts[cross[:, 1]])) if len(cross) else 0
out[tol] = {"merged_pairs": int(len(cross)), "voltage_mismatched": mismatch}
return out
Fix implementation
import geopandas as gpd
import networkx as nx
from shapely.geometry import Point
def build_grid_graph(
lines: gpd.GeoDataFrame,
substations: gpd.GeoDataFrame,
*,
snap_tolerance_m: float = 15.0,
voltage_field: str = "voltage_kv",
) -> nx.MultiGraph:
"""A graph whose nodes are substations and junctions, and whose edges carry voltage."""
g = nx.MultiGraph()
for idx, row in substations.iterrows():
g.add_node(
f"S{idx}",
kind="substation",
geometry=row.geometry,
voltage_kv=row.get(voltage_field),
name=row.get("name"),
)
sub_pts = {n: d["geometry"] for n, d in g.nodes(data=True)}
def nearest_node(pt: Point) -> str | None:
best, best_d = None, snap_tolerance_m
for name, geom in sub_pts.items():
d = pt.distance(geom)
if d <= best_d:
best, best_d = name, d
return best
for idx, row in lines.iterrows():
coords = list(row.geometry.coords)
a_pt, b_pt = Point(coords[0]), Point(coords[-1])
a = nearest_node(a_pt) or f"J{idx}a"
b = nearest_node(b_pt) or f"J{idx}b"
for node, pt in ((a, a_pt), (b, b_pt)):
if node not in g:
g.add_node(node, kind="junction", geometry=pt, voltage_kv=row.get(voltage_field))
g.add_edge(
a, b,
key=f"L{idx}",
length_km=row.geometry.length / 1000.0,
voltage_kv=row.get(voltage_field),
circuits=row.get("circuits"),
operator=row.get("operator"),
)
return g
def usable_subgraph(g: nx.MultiGraph, *, min_voltage_kv: float) -> nx.MultiGraph:
"""The graph a project of this size can actually use."""
keep = [
(u, v, k) for u, v, k, d in g.edges(keys=True, data=True)
if (d.get("voltage_kv") or 0) >= min_voltage_kv
]
return g.edge_subgraph(keep).copy()
The usable_subgraph step is the substance. Filtering edges by voltage before any path query is what
turns “three hops” into “three hops the project can use”, and it costs one pass over the edge list.
Fallback routing and performance tuning
- Use a spatial index for snapping. The
nearest_nodeloop above is clear and quadratic; on a national extract, replace it with acKDTreequery over substation coordinates. - Prefer
MultiGraphoverGraph. Parallel circuits between the same pair of substations are real, and collapsing them loses the redundancy a reliability question depends on. - Store geometry on nodes, not on edges. Edge geometry duplicates the source layer; a key back to the line identifier is enough and keeps the graph small enough to pickle.
- Filter before you query, not inside the query. Building the usable subgraph once and querying it many times is far cheaper than a per-query predicate, and it makes the filter visible.
- Contract degree-two junctions. A chain of collinear segments between two substations is one electrical edge; contracting them shrinks a national graph by an order of magnitude without changing any answer.
Downstream validation
import networkx as nx
def assert_graph_sane(g: nx.MultiGraph, *, max_components: int = 50) -> dict:
"""Catch both fragmentation and manufactured connectivity."""
comps = list(nx.connected_components(g))
largest = max((len(c) for c in comps), default=0)
report = {
"nodes": g.number_of_nodes(),
"edges": g.number_of_edges(),
"components": len(comps),
"largest_component_share": largest / max(g.number_of_nodes(), 1),
}
assert report["components"] <= max_components, (
f"{report['components']} components — snapping tolerance is too tight"
)
assert report["largest_component_share"] > 0.6, (
"the largest component holds under 60% of nodes — the network is shattered"
)
for u, v, d in g.edges(data=True):
assert d.get("voltage_kv") is not None, f"edge {u}-{v} has no voltage — it cannot be filtered"
return report
Contracting the graph without changing any answer
A national transmission graph built directly from line geometry carries a node at every vertex where two segments meet, and most of those nodes are degree two — a point where one circuit simply continues. Contracting them is the single largest reduction available and it changes no query result.
The rule is narrow: a degree-two node may be contracted when both incident edges share a voltage, an operator and a circuit count, and neither endpoint is a substation. The contracted edge inherits the sum of the two lengths and the shared attributes. On a typical national extract this removes 80 to 90 percent of nodes, which turns a graph that takes minutes to traverse into one that takes seconds and fits comfortably in memory for interactive work.
Two nodes must never be contracted. A substation is a query target even when it happens to sit mid-span, and a junction where three or more circuits meet is where a path can branch. Contracting either produces a graph that is smaller and answers a different question — which is the failure mode worth guarding against, because the resulting graph still looks entirely plausible.
Keep the original line identifiers on the contracted edge as a list. When a query returns a path, the identifiers are what let it be drawn back onto the source geometry, and without them the contracted graph can answer questions but cannot show its work.
Frequently asked questions
Should the graph be directed?
Not for connectivity questions, and not for screening. Power flow direction is a function of dispatch rather than of topology, and it reverses. Where direction genuinely matters — a radial feeder with a defined source — encode it as an attribute rather than as a directed edge, so the same graph can answer both kinds of question.
How do I find every substation a project could reach?
Take the usable subgraph at the project’s voltage class, then run a single-source shortest path from the nearest node with the edge length as weight. One traversal returns the distance to every reachable substation, which is both faster and more useful than repeated pairwise queries.
What does a high component count actually mean?
Almost always a snapping tolerance that is too tight for the source data, not a genuinely disconnected grid. National extracts routinely fragment into hundreds of components at 5 metres and consolidate into a handful at 25. The tolerance sweep in the pre-flight step is the fastest way to find the value where components collapse without voltage-mismatched merges appearing.
Can this graph estimate available capacity?
Not on its own — it answers topology, not power flow. What it does provide is the set of candidate substations and the electrical distance to each, which is what feeds the headroom calculation in grid capacity buffer analysis. Treating a graph distance as a capacity proxy is the mistake this page’s opening scenario describes.
How should transformers between voltage levels be represented?
As explicit edges with a kind of transformer and both voltages recorded, connecting the two nodes
that represent the same yard at different levels. Collapsing a substation to one node hides the
transformer, and a path that crosses voltage levels without one is not a path a project can use.
Is it worth persisting the graph?
Yes, keyed on the source layer vintages and the snapping tolerance. Rebuilding a national graph takes minutes and the inputs change monthly at most, so a cached graph with its provenance recorded turns an interactive query from a coffee break into a second — and the provenance is what stops two analysts comparing results from different tolerances.
Related
- Grid Routing & Least-Cost Path Analysis — routing a new line where no connection exists
- Snapping Transmission Lines to Substation Nodes with Shapely — the tolerance sweep this graph depends on
- Network Attribute Validation — making the voltage attribute trustworthy enough to filter on
- Grid Capacity Buffer Analysis — turning reachable substations into available headroom