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.
- 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.
- 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.
- 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.
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.
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
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.
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.
Downstream validation
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.
Related
- Network Attribute Validation — the parent workflow and its schema gate
- Modeling Substation Connectivity Graphs with NetworkX — the graph this check runs over
- Snapping Transmission Lines to Substation Nodes with Shapely — the tolerance that causes the artefact class
- Enforcing Voltage Class Schemas with pandera — catching mis-tagged values before they reach the graph
Back to Network Attribute Validation for Grid Infrastructure