Detecting Voltage Topology Inconsistencies in Transmission Networks

The scenario: a screening query finds a 345 kV path to a candidate site, the developer prices an interconnection around it, and a planner points out that the path passes through a node where a 345 kV circuit meets a 138 kV circuit with no transformer between them. The dataset is internally inconsistent, the graph traversed it happily, and nothing in the pipeline was looking. This page finds those places before a query does, and it extends network attribute validation.

Root-cause analysis

Voltage inconsistencies come from three sources, and separating them decides whether to repair or to report.

  1. Missing transformers. Open datasets map circuits far more completely than substation internals, so a node where two voltage classes meet is usually a real substation whose transformer was never mapped. The topology is wrong; the geography is right.
  2. Mis-tagged voltage. A single way tagged 138,000 where its neighbours are 345,000, or a transposed 1150 for 115. Here the topology is right and the attribute is wrong, and the correct repair is the opposite of the previous case.
  3. Snapping artefacts. Two circuits that merely cross — one overhead, one underground, at different heights — snapped into a shared node by a tolerance that was too generous. Neither the topology nor the attribute is wrong; the join is.
Three inconsistency classes and the direction each distorts a screen A table of three voltage-topology inconsistency classes. An unmapped transformer, typically at a mapped substation, lets a query cross voltage levels without one and makes a screen optimistic; the repair is to split the node and insert an inferred transformer. A mis-tagged voltage, typically one way in an otherwise consistent run, makes the screen pessimistic when tagged too low and optimistic when tagged too high; the repair is comparison against neighbours or an authoritative source. A snapping artefact, typically a degree-two node in open country, invents a connection and makes the screen optimistic; the repair is to split the node or reduce the snapping tolerance. Three contradictions, three repairs, three directions of error unmapped transformer a mapped substation node repair: split the node, insert an inferred transformer optimistic mis-tagged voltage one way in a consistent run repair: compare against neighbours or an authority both directions snapping artefact degree-two node in open country repair: split the node or lower the tolerance optimistic Counting them together hides the fix: two of the three are data defects and one is a parameter choice.

Pre-flight validation

Every inconsistency is a local property of a node and its incident edges, so the detection is one pass over the graph.

python
import networkx as nx


def find_voltage_inconsistencies(g: nx.MultiGraph, *, voltage_field: str = "voltage_kv") -> list[dict]:
    """Nodes where the incident voltages cannot be reconciled without a transformer."""
    findings = []
    for node in g.nodes:
        voltages = {
            d.get(voltage_field)
            for _, _, d in g.edges(node, data=True)
            if d.get(voltage_field) is not None
        }
        if len(voltages) <= 1:
            continue
        kind = g.nodes[node].get("kind")
        has_transformer = any(
            d.get("kind") == "transformer" for _, _, d in g.edges(node, data=True)
        )
        if has_transformer:
            continue
        findings.append({
            "node": node,
            "node_kind": kind,
            "voltages": sorted(v for v in voltages if v is not None),
            "degree": g.degree(node),
            "likely_cause": (
                "unmapped transformer" if kind == "substation"
                else "snapping artefact" if g.degree(node) == 2
                else "mis-tagged voltage"
            ),
        })
    return findings

The likely_cause heuristic is coarse and useful: a mixed-voltage node that is a mapped substation is almost always a missing transformer, a degree-two mixed node in open country is almost always two circuits snapped together, and everything else needs a human.

Fix implementation

python
import networkx as nx


def repair_voltage_topology(
    g: nx.MultiGraph,
    findings: list[dict],
    *,
    voltage_field: str = "voltage_kv",
    snap_split_tolerance_m: float = 25.0,
) -> tuple[nx.MultiGraph, list[dict]]:
    """Insert implied transformers, split snapping artefacts, quarantine the rest."""
    out = g.copy()
    actions = []

    for f in findings:
        node = f["node"]
        if f["likely_cause"] == "unmapped transformer":
            # Split the node by voltage level and connect the levels with a transformer edge.
            levels = f["voltages"]
            for v in levels:
                out.add_node(f"{node}@{v:g}", kind="bus", voltage_kv=v,
                             geometry=out.nodes[node].get("geometry"))
            for u, w, key, d in list(out.edges(node, keys=True, data=True)):
                v = d.get(voltage_field)
                if v is None:
                    continue
                other = w if u == node else u
                out.add_edge(f"{node}@{v:g}", other, key=key, **d)
                out.remove_edge(u, w, key)
            for a, b in zip(levels, levels[1:]):
                out.add_edge(f"{node}@{a:g}", f"{node}@{b:g}",
                             key=f"XF-{node}-{a:g}-{b:g}", kind="transformer",
                             voltage_kv=None, length_km=0.0, inferred=True)
            out.remove_node(node)
            actions.append({"node": node, "action": "transformer inserted", "levels": levels})

        elif f["likely_cause"] == "snapping artefact":
            # Two circuits that merely cross: separate them into two coincident nodes.
            edges = list(out.edges(node, keys=True, data=True))
            for i, (u, w, key, d) in enumerate(edges):
                other = w if u == node else u
                new_node = f"{node}#{i}"
                out.add_node(new_node, **out.nodes[node])
                out.add_edge(new_node, other, key=key, **d)
                out.remove_edge(u, w, key)
            out.remove_node(node)
            actions.append({"node": node, "action": "node split", "degree": f["degree"]})

        else:
            actions.append({"node": node, "action": "quarantined for review",
                            "voltages": f["voltages"]})
    return out, actions

The transformer insertion is deliberately marked inferred=True. A downstream query can then choose to trust inferred transformers for screening and exclude them for anything that carries a commitment — which is the honest treatment of a connection the dataset never asserted.

Splitting a mixed-voltage node and inserting an inferred transformer Two graph fragments. Before: a single node with four incident edges, two at 345 kilovolts and two at 138, annotated as allowing a free voltage crossing. After: the node has been split into a 345 kilovolt bus carrying its two edges and a 138 kilovolt bus carrying its two, joined by a short transformer edge drawn with a dashed stroke and labelled inferred equals true. A note records that a screening query may traverse inferred transformers while a bankable query filters them out. One node, two voltage classes, no transformer before 345 kV 345 kV 138 kV 138 kV a path may cross for free after 345 kV bus 138 kV bus transformer · inferred=true The transformer is marked inferred because the source never asserted it. A screening query traverses it; a query behind a commitment filters it out — one attribute, two levels of confidence.

Fallback routing and performance tuning

  • Detect before you snap harder. A rising count of mixed-voltage nodes as the snapping tolerance grows is the clearest signal that the tolerance has passed the point of usefulness.
  • Rank findings by query impact. A mixed-voltage node on a corridor nobody screens matters less than one on the shortest path from a live portfolio; sorting by betweenness centrality puts the consequential ones first.
  • Keep the repair out of the source layer. Apply it when building the graph, so a source refresh does not silently discard the repairs or, worse, keep them alongside newly corrected data.
  • Cache the repaired graph with its provenance. The repair depends on the snapping tolerance and the source vintage; a cached graph without both is not reusable.
Findings ranked by screen impact, not by count A table of three inconsistency classes with, for each, the number found, the share of screened paths affected, and the resulting priority. Fourteen unmapped transformers affect 41 percent of screened paths and rank first. Thirty-eight snapping artefacts affect 12 percent and rank second. Two hundred and twenty-six mis-tagged voltages affect 6 percent and rank third, because most sit on spurs no query traverses. A note explains that ranking by betweenness centrality turns an unmanageable list into a short one. 278 findings — fourteen of them matter most unmapped transformers on high-betweenness corridors 14 found 41% of screened paths snapping artefacts invent short paths 38 found 12% of screened paths mis-tagged voltages mostly on spurs nobody screens 226 found 6% of screened paths Sorted by count, the 226 mis-tagged voltages look like the problem. Sorted by betweenness, fourteen unmapped transformers are — and fourteen is a list a data steward can actually work through.

Downstream validation

python
def assert_voltage_topology_clean(g, *, allow_inferred: bool = True) -> dict:
    """No node may mix voltages without a transformer, inferred or otherwise."""
    remaining = find_voltage_inconsistencies(g)
    assert not remaining, (
        f"{len(remaining)} nodes still mix voltage classes without a transformer: "
        f"{[r['node'] for r in remaining][:5]}"
    )
    inferred = [
        (u, v) for u, v, d in g.edges(data=True)
        if d.get("kind") == "transformer" and d.get("inferred")
    ]
    if not allow_inferred:
        assert not inferred, f"{len(inferred)} inferred transformers present in a strict query"
    return {"inferred_transformers": len(inferred)}

What each inconsistency costs a screen

The three classes distort a screening result in different directions, which is why the report ranks them rather than merely counting them.

An unmapped transformer makes a screen optimistic: the graph lets a query cross voltage levels for free, so a project appears to reach a bulk substation through a path that in reality needs a transformer that may not exist or may be fully loaded. This is the class that produced the opening scenario, and it is the most consequential.

A mis-tagged voltage cuts both ways. A circuit tagged too low is excluded from a usable-voltage subgraph, so a real path disappears and the screen is pessimistic. Tagged too high, it is included and the screen over-promises. Because the tag is wrong rather than missing, no amount of graph reasoning finds it — only comparison with neighbours or with an authoritative source.

A snapping artefact makes a screen optimistic in a different way: it invents a connection between two circuits that merely cross, producing paths that are shorter than anything buildable. It is also the easiest to prevent, because it is a parameter choice rather than a data defect, and the tolerance sweep in snapping transmission lines to substation nodes finds the value where it stops happening.

Reporting the three separately, with a count and an example each, is what lets a data steward fix the right thing — and what stops a team responding to all three by widening a tolerance that caused one of them.

Frequently asked questions

Should inferred transformers be trusted in a screen?

For a first-pass screen, yes, flagged. For anything that carries a commitment, no. The distinction is easy to implement — an inferred attribute and a query-time filter — and it prevents the most common misuse, which is a bankable study resting on a connection the source dataset never asserted.

How do I tell a mis-tagged voltage from a genuine step-down?

By the neighbourhood. A genuine step-down happens at a substation and has other evidence: a mapped yard, a name, sometimes an explicit transformer. A mis-tag is usually one way in a run of otherwise consistent ways, so comparing a circuit’s voltage against the mode of its connected component finds them quickly.

What about DC ties and back-to-back converters?

They legitimately connect two systems without an AC transformer and will be flagged by any check that assumes AC topology. Tag them explicitly as converters at ingestion; there are few enough of them nationally that a maintained list is practical, and they are exactly the assets a screen most needs to represent correctly.

Does this need to run on every refresh?

Yes, and the useful output is the delta rather than the level. A source refresh that adds twelve new mixed-voltage nodes has changed something specific, and the twelve are a short list to inspect. The absolute count says more about the source’s mapping conventions than about this month’s data.

Can the same detection find missing circuits?

Not directly — it finds contradictions, and a missing circuit is an absence rather than a contradiction. What it does surface indirectly is dead-end circuits: a high-voltage line terminating at a degree-one node in open country almost always means the continuation was not mapped, and that is worth reporting alongside the voltage findings.

Where should the repair live in the pipeline?

At graph-build time, driven by the source layers, so the source stays a faithful copy of what was published and the graph carries the interpretation. Repairing the source layer instead makes a later refresh either overwrite the repairs or preserve them against corrected data, and both outcomes are worse than rebuilding a graph.