Distribution Feeder Synthesis — Schweitzer et al. (2017)

Demonstrates the full Schweitzer algorithm for generating synthetic MV radial distribution feeders.

Pipeline: Parameters → Node generation → Tree connection → Node properties → Cable types → Cable lengths

[1]:
import numpy as np
import networkx as nx
import matplotlib.pyplot as plt
from collections import Counter

from powergrid_synth import DistributionGrid, GridVisualizer
from powergrid_synth.distribution import (
    SchweetzerFeederGenerator,
    DistributionSynthParams,
    DistributionInputModel,
    compute_emergent_properties,
    validate_tree,
    compare_feeders,
    kl_divergence_discrete,
    fit_params_from_feeders,
)

1. Generate a Single Feeder with Default Parameters (Table III)

[2]:
gen = SchweetzerFeederGenerator(seed=42)
G = DistributionGrid.from_nx(
    gen.generate_feeder(n_nodes=50, total_load_mw=8.0, total_gen_mw=1.0, v_nom_kv=10.0)
)

print(f"Nodes: {G.number_of_nodes()}, Edges: {G.number_of_edges()}")
print(f"Root: {G.root}, Max hop: {G.max_hop}, Is radial: {G.is_radial}")
print(f"Nominal voltage: {G.graph['v_nom_kv']} kV")
Nodes: 50, Edges: 49
Root: 0, Max hop: 14, Is radial: True
Nominal voltage: 10.0 kV

2. Validate Tree Structure

[3]:
issues = validate_tree(G)
if issues:
    print("Issues found:")
    for issue in issues:
        print(f"  - {issue}")
else:
    print("Valid radial tree (no issues)")
print(f"Total load: {G.total_load_mw:.2f} MW, Total gen: {G.total_gen_mw:.2f} MW")
print(f"Load nodes: {len(G.nodes_by_type('load'))}, Injection: {len(G.nodes_by_type('injection'))}, "
      f"Intermediate: {len(G.nodes_by_type('intermediate'))}")
Valid radial tree (no issues)
Total load: 13.52 MW, Total gen: 0.96 MW
Load nodes: 44, Injection: 1, Intermediate: 5

3. Emergent Properties

[4]:
props = compute_emergent_properties(G)
for key, val in props.items():
    if isinstance(val, float):
        print(f"  {key:25s} = {val:.4f}")
    else:
        print(f"  {key:25s} = {val}")
  n_nodes                   = 50
  n_edges                   = 49
  max_hop                   = 14
  mean_degree               = 1.9600
  total_load_mw             = 13.5204
  total_gen_mw              = 0.9573
  frac_intermediate         = 0.1000
  frac_injection            = 0.0200
  mean_length_km            = 0.4659
  max_length_km             = 3.3962
  total_length_km           = 22.8270

4. Visualize Feeder Topology

[5]:
viz = GridVisualizer()
viz.plot_grid(
    G,
    layout="hierarchical_tree",
    title="Synthetic MV Feeder (50 nodes, seed=42)",
    figsize=(12, 6),
)
Calculating layout 'hierarchical_tree'...
../_images/examples_DistributionSynth_9_1.png

5. Statistical Distributions of the Generated Feeder

[6]:
fig, axes = plt.subplots(2, 3, figsize=(15, 8))

# Hop distance distribution
hops = [G.nodes[n]["h"] for n in G.nodes]
axes[0, 0].hist(hops, bins=range(max(hops) + 2), edgecolor="black", alpha=0.7, color="steelblue")
axes[0, 0].set_title("Hop Distance")
axes[0, 0].set_xlabel("h")

# Degree distribution
degrees = [d for _, d in G.degree()]
axes[0, 1].hist(degrees, bins=range(1, max(degrees) + 2), edgecolor="black", alpha=0.7, color="coral")
axes[0, 1].set_title("Degree")
axes[0, 1].set_xlabel("degree")

# Cable length distribution
lengths = [G.edges[e]["length_km"] for e in G.edges]
axes[0, 2].hist(lengths, bins=30, edgecolor="black", alpha=0.7, color="seagreen")
axes[0, 2].set_title("Cable Length (km)")
axes[0, 2].set_xlabel("km")

# Node type breakdown
type_counts = Counter(G.nodes[n]["node_type"] for n in G.nodes)
axes[1, 0].bar(type_counts.keys(), type_counts.values(), color=["gray", "green", "red"])
axes[1, 0].set_title("Node Types")

# Load distribution
load_vals = [G.nodes[n]["P_mw"] for n in G.nodes if G.nodes[n].get("P_mw", 0) > 0]
axes[1, 1].hist(load_vals, bins=20, edgecolor="black", alpha=0.7, color="tomato")
axes[1, 1].set_title("Load per Node (MW)")
axes[1, 1].set_xlabel("MW")

# Power factor distribution
pf_vals = [G.nodes[n]["pf"] for n in G.nodes]
axes[1, 2].hist(pf_vals, bins=20, edgecolor="black", alpha=0.7, color="mediumpurple")
axes[1, 2].set_title("Power Factor")
axes[1, 2].set_xlabel("cos φ")

plt.suptitle("Feeder Property Distributions", fontsize=14)
plt.tight_layout()
plt.show()
../_images/examples_DistributionSynth_11_0.png

6. Generate an Ensemble and Compare

[7]:
# Generate multiple feeders with different seeds
ensemble = []
for seed in range(10):
    g = SchweetzerFeederGenerator(seed=seed)
    feeder = DistributionGrid.from_nx(
        g.generate_feeder(n_nodes=50, total_load_mw=8.0, total_gen_mw=1.0)
    )
    ensemble.append(feeder)

print(f"Generated {len(ensemble)} feeders")
for i, f in enumerate(ensemble):
    print(f"  Feeder {i}: {f.number_of_nodes()} nodes, max_hop={f.max_hop}, "
          f"load={f.total_load_mw:.2f} MW, gen={f.total_gen_mw:.2f} MW, "
          f"radial={f.is_radial}")
Generated 10 feeders
  Feeder 0: 50 nodes, max_hop=15, load=9.91 MW, gen=0.92 MW, radial=True
  Feeder 1: 50 nodes, max_hop=13, load=7.72 MW, gen=1.01 MW, radial=True
  Feeder 2: 50 nodes, max_hop=14, load=8.58 MW, gen=0.99 MW, radial=True
  Feeder 3: 50 nodes, max_hop=14, load=7.66 MW, gen=1.04 MW, radial=True
  Feeder 4: 50 nodes, max_hop=13, load=7.83 MW, gen=0.96 MW, radial=True
  Feeder 5: 50 nodes, max_hop=15, load=9.58 MW, gen=1.08 MW, radial=True
  Feeder 6: 50 nodes, max_hop=13, load=8.71 MW, gen=0.92 MW, radial=True
  Feeder 7: 50 nodes, max_hop=15, load=9.94 MW, gen=1.06 MW, radial=True
  Feeder 8: 50 nodes, max_hop=14, load=8.85 MW, gen=0.97 MW, radial=True
  Feeder 9: 50 nodes, max_hop=12, load=8.13 MW, gen=0.95 MW, radial=True

7. KL-Divergence Comparison Between Two Feeders

[8]:
# Compare the first two feeders of the ensemble
kl_results = compare_feeders(ensemble[0], ensemble[1])
print("KL-divergence between feeder 0 and feeder 1:")
for metric, val in kl_results.items():
    print(f"  {metric:25s} = {val:.4f}")
KL-divergence between feeder 0 and feeder 1:
  hop_distance_kl           = 1.9638
  degree_kl                 = 0.4858
  cable_length_kl           = 3.1603

8. 3D KDE Input Model

[9]:
# Fit the input model from the ensemble
input_model = DistributionInputModel(seed=0)
input_model.fit(ensemble)

# Sample new input configurations
samples = input_model.sample(5)
print("Sampled input configurations (n_nodes, load_MW, gen_MW):")
for s in samples:
    print(f"  N={s.n_nodes}, Load={s.total_load_mw:.2f} MW, Gen={s.total_gen_mw:.2f} MW")
Sampled input configurations (n_nodes, load_MW, gen_MW):
  N=50, Load=9.49 MW, Gen=0.96 MW
  N=50, Load=8.23 MW, Gen=0.99 MW
  N=50, Load=9.12 MW, Gen=0.92 MW
  N=50, Load=8.96 MW, Gen=0.86 MW
  N=50, Load=8.00 MW, Gen=0.99 MW
[10]:
# Generate feeders from sampled inputs
gen_from_kde = SchweetzerFeederGenerator(seed=99)
for i, s in enumerate(samples):
    f = DistributionGrid.from_nx(
        gen_from_kde.generate_feeder(s.n_nodes, s.total_load_mw, s.total_gen_mw)
    )
    print(f"  Feeder {i}: {f.number_of_nodes()} nodes, {f.number_of_edges()} edges, "
          f"radial={f.is_radial}, load={f.total_load_mw:.2f} MW")
  Feeder 0: 50 nodes, 49 edges, radial=True, load=19.15 MW
  Feeder 1: 50 nodes, 49 edges, radial=True, load=8.50 MW
  Feeder 2: 50 nodes, 49 edges, radial=True, load=8.83 MW
  Feeder 3: 50 nodes, 49 edges, radial=True, load=10.63 MW
  Feeder 4: 50 nodes, 49 edges, radial=True, load=8.12 MW

9. Fit Parameters from Synthetic Ensemble (Round-Trip)

[11]:
# Demonstrate fitting parameters back from generated feeders
fitted_params = fit_params_from_feeders(ensemble)

defaults = DistributionSynthParams()
print(f"{'Parameter':30s} {'Default':>10s} {'Fitted':>10s}")
print("-" * 52)
print(f"{'hop_dist.r':30s} {defaults.hop_dist.r:10.3f} {fitted_params.hop_dist.r:10.3f}")
print(f"{'hop_dist.p':30s} {defaults.hop_dist.p:10.3f} {fitted_params.hop_dist.p:10.3f}")
print(f"{'degree_dist.pi':30s} {defaults.degree_dist.pi:10.3f} {fitted_params.degree_dist.pi:10.3f}")
print(f"{'cable_length.x0':30s} {defaults.cable_length.x0:10.3f} {fitted_params.cable_length.x0:10.3f}")
print(f"{'cable_length.gamma':30s} {defaults.cable_length.gamma:10.3f} {fitted_params.cable_length.gamma:10.3f}")
Parameter                         Default     Fitted
----------------------------------------------------
hop_dist.r                          3.140      3.883
hop_dist.p                          0.410      0.465
degree_dist.pi                      0.850      0.809
cable_length.x0                     0.119      0.166
cable_length.gamma                  0.159      0.090

10. Custom Parameters

[12]:
from powergrid_synth.distribution import NegBinomParams, ModifiedCauchyParams

# Create a feeder with deeper topology and shorter cables
custom = DistributionSynthParams(
    hop_dist=NegBinomParams(r=5.0, p=0.3),            # more hops
    cable_length=ModifiedCauchyParams(x0=0.05, gamma=0.08),  # shorter cables
)
gen_custom = SchweetzerFeederGenerator(params=custom, seed=77)
G_custom = DistributionGrid.from_nx(
    gen_custom.generate_feeder(n_nodes=60, total_load_mw=10.0, total_gen_mw=2.0)
)

print(f"Custom feeder: {G_custom.number_of_nodes()} nodes")
print(f"  Root: {G_custom.root}, Max hop: {G_custom.max_hop}, Is radial: {G_custom.is_radial}")
print(f"  Load: {G_custom.total_load_mw:.2f} MW, Gen: {G_custom.total_gen_mw:.2f} MW")

viz.plot_grid(
    G_custom,
    layout="hierarchical_tree",
    title="Custom Parameters: Deeper Topology, Shorter Cables",
    figsize=(12, 6),
)
Custom feeder: 60 nodes
  Root: 0, Max hop: 23, Is radial: True
  Load: 10.01 MW, Gen: 2.30 MW
Calculating layout 'hierarchical_tree'...
../_images/examples_DistributionSynth_22_1.png