European High-Voltage Network — PyPSA-Eur Data#
This notebook demonstrates two complete NPAP workflows on the real European high-voltage grid (220 kV – 750 kV, 35 countries) derived from OpenStreetMap data, as described in:
Xiong, B., Fioriti, D., Neumann, F., Riepin, I., & Brown, T. (2025). Modelling the high-voltage grid using open data for Europe and beyond. Scientific Data, 12(1). https://doi.org/10.1038/s41597-025-04550-7
The dataset is hosted on Zenodo: Xiong, B., Fioriti, D., Neumann, F., Riepin, I., & Brown, T. (2026). Prebuilt electricity network for PyPSA-Eur based on OpenStreetMap data. Zenodo. 10.5281/zenodo.18619025
We illustrate the full NPAP workflow for a simple voltage-unaware (Section 2) and voltage-aware (Section 3) treatment of the electricity network graph data. The following table provides an overview of the corresponding different features.
Simple |
Voltage-aware |
|
|---|---|---|
Data loader |
|
|
Edge types |
all uniform |
line / trafo / DC link |
AC islands |
✗ |
✓ |
Partition strategy |
|
|
Per-type aggregation |
✗ |
✓ |
1. Setup & Data Download#
import networkx as nx
import pandas as pd
from npap.datasets import fetch_pypsa_eur
import npap
from npap import AggregationProfile
First, we download the network data from Zenodo and perform some minor preprocessing steps
to follow NPAP’s naming conventions that can be checked in the fetch_pypsa_eur function.
data_files = fetch_pypsa_eur()
Dataset cached in /Users/taaarmar/.cache/npap (2.7 MB).
These files are kept for future runs and are never removed automatically. To delete them, run:
from npap.datasets import clear_data_home; clear_data_home()
2. Simple (Voltage-Unaware) Workflow#
The csv_files strategy only requires a node file and an edge file. Every edge
is treated uniformly — no type differentiation, no AC-island detection.
This makes it a fast starting point when the full grid topology is not needed.
2.1 Load#
manager = npap.PartitionAggregatorManager()
graph = manager.load_data(
strategy="csv_files",
node_file=str(data_files["buses.csv"]),
edge_file=str(data_files["lines.csv"]),
)
print(f"Graph type : {type(graph).__name__}")
print(f"Nodes : {graph.number_of_nodes():,}")
print(f"Edges : {graph.number_of_edges():,}")
Graph type : MultiDiGraph
Nodes : 6,863
Edges : 9,162
/Users/taaarmar/git/NPAP/npap/input/csv_loader.py:197: UserWarning: Parallel edges detected in CSV edge file. A MultiDiGraph will be created. Call manager.aggregate_parallel_edges() to collapse parallel edges before partitioning.
log_warning(
Note: parallel edges detected The cell above will emit a warning: “Parallel edges detected. A MultiDiGraph will be created.”
This is expected: the PyPSA-Eur dataset contains many double-circuit lines, i.e., two transmission lines sharing the same pair of buses. The
csv_filesstrategy preserves them as aMultiDiGraph. At the moment, NPAP can only partitionDiGraphwithout parallel edges. Therefore, the parallel edges of theMultiDiGraphmust be aggregated into a single equivalent before partitioning. For that, we need to provide edge strategies that define how the attributes are aggregated.
if isinstance(graph, nx.MultiDiGraph):
edges_before = graph.number_of_edges()
graph = manager.aggregate_parallel_edges(
edge_properties={
"x": "equivalent_reactance",
"r": "equivalent_reactance",
"s_nom": "sum",
"length": "average",
},
default_strategy="sum",
warn_on_defaults=False,
)
print(f"Edges before aggregation: {edges_before:,}")
print(f"Edges after aggregation: {graph.number_of_edges():,}")
print(f"Aggregated : {edges_before - graph.number_of_edges():,} parallel edges")
Edges before aggregation: 9,162
Edges after aggregation: 8,065
Aggregated : 1,097 parallel edges
Now we can illustrate the network. Note that the graph will not be fully connected, e.g., the UK and continental Europe, as the edges between them (DC links), are missing.
manager.plot_network(style="simple", title="European HV Grid — Simple")
2.2 Partitioning#
We use geographical k-medoids with Haversine distance to create geographically compact clusters.
N_CLUSTERS = 50
partition = manager.partition(
strategy="geographical_kmedoids_haversine",
n_clusters=N_CLUSTERS,
)
cluster_sizes = pd.Series({k: len(v) for k, v in partition.mapping.items()})
print(f"Clusters created : {partition.n_clusters}")
print(
f"Cluster size — min: {cluster_sizes.min()}, "
f"max: {cluster_sizes.max()}, mean: {cluster_sizes.mean():.1f}"
)
Clusters created : 50
Cluster size — min: 34, max: 310, mean: 137.3
manager.plot_network(style="clustered", title=f"Simple — Partitioned ({N_CLUSTERS} clusters)")
2.3 Aggregation#
Each cluster is collapsed into a single bus. Because the simple graph contains
only AC lines (all edges have x and r), we apply the
equivalent_reactance formula uniformly.
profile = AggregationProfile(
topology_strategy="simple",
node_properties={
"lat": "average",
"lon": "average",
"voltage": "first",
},
edge_properties={
"x": "equivalent_reactance",
"r": "equivalent_reactance",
"s_nom": "sum",
"length": "average",
},
default_node_strategy="average",
default_edge_strategy="sum",
warn_on_defaults=False,
)
nodes_before = manager.get_current_graph().number_of_nodes()
edges_before = manager.get_current_graph().number_of_edges()
agg_graph = manager.aggregate(profile=profile)
print(
f"Nodes: {nodes_before:,} -> {agg_graph.number_of_nodes():,} "
f"({agg_graph.number_of_nodes() / nodes_before:.1%} of original)"
)
print(
f"Edges: {edges_before:,} -> {agg_graph.number_of_edges():,} "
f"({agg_graph.number_of_edges() / edges_before:.1%} of original)"
)
Nodes: 6,863 -> 50 (0.7% of original)
Edges: 8,065 -> 153 (1.9% of original)
/Users/taaarmar/git/NPAP/npap/aggregation/basic_strategies.py:327: UserWarning: No numeric values found for node property 'tags'. Falling back to first available value.
log_warning(
/Users/taaarmar/git/NPAP/npap/aggregation/basic_strategies.py:327: UserWarning: No numeric values found for node property 'geometry'. Falling back to first available value.
log_warning(
/Users/taaarmar/git/NPAP/npap/aggregation/basic_strategies.py:327: UserWarning: No numeric values found for node property 'symbol'. Falling back to first available value.
log_warning(
/Users/taaarmar/git/NPAP/npap/aggregation/basic_strategies.py:327: UserWarning: No numeric values found for node property 'under_construction'. Falling back to first available value.
log_warning(
/Users/taaarmar/git/NPAP/npap/aggregation/basic_strategies.py:327: UserWarning: No numeric values found for node property 'country'. Falling back to first available value.
log_warning(
/Users/taaarmar/git/NPAP/npap/aggregation/basic_strategies.py:327: UserWarning: No numeric values found for node property 'dc'. Falling back to first available value.
log_warning(
Note: We receive a couple of warnings here, as we are aggregating over various node types, e.g., AC and DC nodes, with different attributes. While this is not a problem for most applications, if it should be avoided, one should account for it during partitioning.
manager.plot_network(style="simple", title=f"Simple — Aggregated ({N_CLUSTERS} nodes)")
3. Voltage-Aware Workflow#
The va_loader ingests all five PyPSA-Eur files and builds a physically
detailed grid: every edge is classified as an AC line, transformer, or
DC link, and each bus is assigned to an AC island — the set of buses
reachable without crossing a DC link. This richer representation enables
voltage-aware partitioning that never merges buses from different islands or
different voltage levels.
3.1 Load#
va_manager = npap.PartitionAggregatorManager()
va_graph = va_manager.load_data(
strategy="va_loader",
node_file=str(data_files["buses.csv"]),
line_file=str(data_files["lines.csv"]),
transformer_file=str(data_files["transformers.csv"]),
converter_file=str(data_files["converters.csv"]),
link_file=str(data_files["links.csv"]),
)
print(f"Graph type : {type(va_graph).__name__}")
print(f"Nodes : {va_graph.number_of_nodes():,}")
print(f"Edges : {va_graph.number_of_edges():,}")
/Users/taaarmar/git/NPAP/npap/input/va_loader.py:900: UserWarning: 6 DC link(s) skipped due to missing references
log_warning(
/Users/taaarmar/git/NPAP/npap/input/va_loader.py:213: UserWarning: Parallel edges detected in voltage-aware loader. A MultiDiGraph will be created. Call manager.aggregate_parallel_edges() to collapse parallel edges before partitioning.
log_warning(
/Users/taaarmar/git/NPAP/npap/input/va_loader.py:373: UserWarning: Found 71 isolated node(s) with no connections. These will be removed.
log_warning(
Graph type : MultiDiGraph
Nodes : 6,792
Edges : 10,073
Note — parallel edges detected Like with the simple loader, the
va_loaderreturns aMultiDiGraphbecause the PyPSA-Eur dataset contains many double-circuit lines — two transmission lines sharing the same tower between the same pair of buses.Unlike the simple case, the VA graph contains mixed edge types: AC lines and transformers both carry an
x(reactance) attribute, but DC links do not. Becauseaggregate_parallel_edgesprocesses every edge — not only parallel ones — theequivalent_reactancestrategy would raise an error whenever it encounters a DC link group that has nox. We therefore use"average"forxandr: for single (non-parallel) AC edges, the average equals the original value; for parallel AC lines, it is a conservative approximation of the parallel-combination formula; and for DC links it falls back safely to0.0.
if isinstance(va_graph, nx.MultiDiGraph):
edges_before = va_graph.number_of_edges()
va_graph = va_manager.aggregate_parallel_edges(
edge_properties={
"x": "average",
"r": "average",
"s_nom": "sum",
"length": "average",
},
default_strategy="first",
warn_on_defaults=False,
)
print(f"Edges before: {edges_before:,}")
print(f"Edges after : {va_graph.number_of_edges():,}")
print(f"Collapsed : {edges_before - va_graph.number_of_edges():,} parallel edges")
else:
print("No parallel edges found — va_graph is already a DiGraph.")
Edges before: 10,073
Edges after : 8,974
Collapsed : 1,099 parallel edges
Note — voltage level harmonisation The raw dataset contains lines on different voltage levels of similar magnitude (e.g. 380 kV and 400 kV). The voltage-aware partitioning strategy treats each distinct voltage level independently, so those deviations would create spurious small or singleton clusters. Therefore, we first group all buses to the two main European transmission levels, i.e., 220 kV and 380 kV, before partitioning.
summary = va_manager.group_by_voltage_levels([220, 380])
print("Voltage distribution after grouping:")
for v, count in sorted(summary["voltage_distribution"].items()):
print(f" {v:>6.0f} kV: {count:>5,} buses")
print(f"\nNodes with inferred/missing voltage: {summary['missing_handled']}")
Voltage distribution after grouping:
220 kV: 3,993 buses
380 kV: 2,799 buses
Nodes with inferred/missing voltage: 0
va_manager.plot_network(style="voltage_aware", title="European HV Grid — Voltage-Aware Load")
3.2 Partitioning#
We use the voltage-aware geographical k-medoids strategy with Haversine distance. This guarantees that:
Buses in different AC islands are never merged.
Buses at different voltage levels are never merged.
Clusters are geographically compact.
va_partition = va_manager.partition(
strategy="va_geographical_kmedoids_haversine",
n_clusters=N_CLUSTERS,
)
cluster_sizes = pd.Series({k: len(v) for k, v in va_partition.mapping.items()})
print(f"Clusters created : {va_partition.n_clusters}")
print(
f"Cluster size — min: {cluster_sizes.min()}, "
f"max: {cluster_sizes.max()}, mean: {cluster_sizes.mean():.1f}"
)
Clusters created : 50
Cluster size — min: 4, max: 370, mean: 135.8
va_manager.plot_network(
style="clustered", title=f"Voltage-Aware — Partitioned ({N_CLUSTERS} clusters)"
)
3.3 Aggregation#
Each cluster of buses is collapsed into a single representative bus.
Because the VA graph contains three distinct edge types, we use
edge_type_properties to apply the physically correct strategy per type:
Edge type |
Property |
Strategy |
|---|---|---|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
Node coordinates and country are preserved via average / first.
va_profile = AggregationProfile(
topology_strategy="simple",
node_properties={
"lat": "average",
"lon": "average",
"voltage": "first",
"country": "first",
},
edge_type_properties={
"line": {
"x": "equivalent_reactance",
"r": "equivalent_reactance",
"s_nom": "sum",
"b": "sum",
"circuits": "sum",
"length": "average",
},
"trafo": {
"x": "equivalent_reactance",
"s_nom": "sum",
},
"dc_link": {
"p_nom": "sum",
"length": "average",
},
},
default_node_strategy="average",
default_edge_strategy="sum",
warn_on_defaults=False,
)
nodes_before = va_manager.get_current_graph().number_of_nodes()
edges_before = va_manager.get_current_graph().number_of_edges()
va_agg_graph = va_manager.aggregate(profile=va_profile)
print(
f"Nodes: {nodes_before:,} -> {va_agg_graph.number_of_nodes():,} "
f"({va_agg_graph.number_of_nodes() / nodes_before:.1%} of original)"
)
print(
f"Edges: {edges_before:,} -> {va_agg_graph.number_of_edges():,} "
f"({va_agg_graph.number_of_edges() / edges_before:.1%} of original)"
)
Nodes: 6,792 -> 50 (0.7% of original)
Edges: 8,974 -> 166 (1.8% of original)
/Users/taaarmar/git/NPAP/npap/aggregation/basic_strategies.py:327: UserWarning: No numeric values found for node property 'tags'. Falling back to first available value.
log_warning(
/Users/taaarmar/git/NPAP/npap/aggregation/basic_strategies.py:327: UserWarning: No numeric values found for node property 'geometry'. Falling back to first available value.
log_warning(
/Users/taaarmar/git/NPAP/npap/aggregation/basic_strategies.py:327: UserWarning: No numeric values found for node property 'symbol'. Falling back to first available value.
log_warning(
/Users/taaarmar/git/NPAP/npap/aggregation/basic_strategies.py:327: UserWarning: No numeric values found for node property 'under_construction'. Falling back to first available value.
log_warning(
/Users/taaarmar/git/NPAP/npap/aggregation/basic_strategies.py:327: UserWarning: No numeric values found for node property 'dc'. Falling back to first available value.
log_warning(
va_manager.plot_network(
style="voltage_aware", title=f"Voltage-Aware — Aggregated ({N_CLUSTERS} nodes)"
)