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.

  1. 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.
  2. 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.
  3. 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.
Component count against false merges, by snapping tolerance A chart over four snapping tolerances of 5, 15, 25 and 50 metres. One series shows the number of connected components falling from 412 to 118 to 37 to 9. A second shows voltage-mismatched merges rising from 0 to 0 to 2 to 14. The band between 15 and 25 metres is marked as the working range, and a note explains that a false merge is more damaging than a fragment because it produces a shorter path that does not exist. The tolerance that consolidates without inventing connections working range 412 0 5 m 118 0 15 m 37 2 25 m 9 14 50 m connected components voltage-mismatched merges A fragment announces itself as "no path". A false merge returns a shorter path through two circuits that were never connected — so tolerance is chosen against merges, not against components.

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.

python
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

python
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.

The same graph, unfiltered and filtered to usable voltage Two graph diagrams over the same eight nodes. In the unfiltered graph a three-hop path runs from the project through two 69 kilovolt feeder edges to a 345 kilovolt substation, drawn as the shortest path. In the filtered graph those two edges are removed because they fall below the 138 kilovolt threshold, and the shortest remaining path takes five hops and 41 kilometres more. Each edge is labelled with its voltage class, and a note records that the unfiltered answer is topologically correct and electrically meaningless. Three hops the project cannot use, or five it can unfiltered — shortest path 69 kV 69 kV 345 kV 230 kV 230 kV 345 kV P A B C D T 3 hops · 62 km · unusable filtered to ≥ 138 kV 345 kV 230 kV 230 kV 345 kV P A B C D T 5 hops · 103 km · usable The unfiltered answer is topologically correct and electrically meaningless — the graph has no concept of capacity until the voltage filter gives it one.

Fallback routing and performance tuning

  • Use a spatial index for snapping. The nearest_node loop above is clear and quadratic; on a national extract, replace it with a cKDTree query over substation coordinates.
  • Prefer MultiGraph over Graph. 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

python
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
Component size distribution on a national graph A distribution of connected component sizes for a national transmission graph snapped at 15 metres. The largest component holds 94 percent of nodes. Four further components hold between 0.3 and 2.1 percent each and correspond to genuine electrical islands. The remaining 113 components hold one or two nodes each and total 1.4 percent, and are annotated as unsnapped endpoints rather than real islands. A threshold marks 60 percent as the point below which the graph should be treated as shattered. One large component, a few real islands, and a tail of artefacts largest component 94.0% island A (ERCOT tie) 2.1% island B 1.2% island C 0.6% island D 0.3% 113 single-node fragments 1.4% 60% floor — below this the graph is shattered The single-node tail is the diagnostic: 113 fragments at 15 metres means 113 endpoints that did not snap, and each one is a line whose connection the graph cannot see.

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.