Building pandapower Networks from UKPN LTDS Data¶
This tutorial demonstrates how to fetch UK Power Networks' Long Term Development Statement (LTDS) data and use it with pandapower for power system analysis.
Important Note about CIM Data¶
The LTDS CIM dataset is a "Shared" dataset that requires special access. To request access:
- Register and login to the UKPN Open Data Portal
- Visit the LTDS CIM page and complete the Shared Data Request Form
Once approved, CIM data is published as XML file attachments (one per licence area: EPN, SPN, LPN). You can download the XML files directly from the portal.
This tutorial uses publicly available LTDS tables (Table 2a, 2b, 3a, 5) which are accessible via the standard API.
What is CIM?¶
The Common Information Model (CIM) is an IEC standard (IEC 61970/61968) that provides a structured representation of power system data. It enables interoperable data exchange between Distribution Network Operators (DNOs), software systems, and network modelling tools.
UKPN publishes LTDS data in CIM format, which includes:
- Network topology (buses, lines, transformers)
- Equipment specifications
- Connectivity information
What is pandapower?¶
pandapower is an open-source Python library for power system modeling, analysis, and optimization. It combines the data analysis capabilities of pandas with power system calculation functions.
Prerequisites:
- Complete 01-getting-started.ipynb first
- Have your
UKPN_API_KEYenvironment variable set - These tutorials require additional dependencies. Install them with
pip install "ukpyn[all]"— see Tutorial 01 for full setup instructions
pip install ukpyn pandapower
1. Fetch LTDS Data from UKPN¶
First, let's use the ltds orchestrator to fetch data from the UK Power Networks Open Data Portal. We'll use publicly available LTDS tables (not CIM, which requires special access).
import pandas as pd
from ukpyn import ltds
# Check available LTDS datasets
print("Available LTDS datasets:")
print(ltds.available_datasets)
# Expected output:
# Available LTDS datasets:
# ['table_2a', 'transformer_2w', 'table_2b', 'transformer_3w', 'table_3a',
# 'observed_peak_demand', 'table_3a_transposed', 'table_5', 'generation',
# 'table_6', 'connection_interest', 'infrastructure_projects', 'projects', 'cim']
# The CIM dataset is attachment-only (XML files) and requires "Shared" access.
# It has no tabular records, so this will return 0 records.
# To get CIM XML files, request access via the UKPN portal.
# cim_data = ltds.get_cim(licence_area="EPN", limit=100)
# print(f"Total CIM records available: {cim_data.total_count}")
# print(f"Records fetched: {len(cim_data.records)}")
# if not cim_data.records:
# print("\nNote: The CIM dataset contains no tabular records.")
# print("CIM data is provided as XML attachments on the UKPN Open Data Portal.")
# print("Request access at: https://ukpowernetworks.opendatasoft.com/explore/forms/cim-access-request-form/")
# Expected output:
# Total CIM records available: 0
# Records fetched: 0
# Inspect the structure of CIM records
# if cim_data.records:
# first_record = cim_data.records[0]
# print("CIM Record fields:")
# if first_record.fields:
# for key, value in first_record.fields.items():
# print(f" {key}: {value}")
2. Choose a Grid Supply Point and Fetch Transformer Data¶
To build a meaningful pandapower network, we'll focus on a single Grid Supply Point (GSP) and fetch all its associated data. LTDS Table 2a contains 2-winding transformer data with real impedance values we can use directly.
# Pick a GSP — Amersham has a manageable number of transformers
GSP_NAME = "Amersham"
LICENCE_AREA = "Eastern Power Networks (EPN)"
# Fetch all 2-winding transformers for this GSP
transformers_2w = ltds.get_table_2a(
licence_area=LICENCE_AREA,
where=f"gridsupplypoint='{GSP_NAME}'",
limit=100,
)
print(f"GSP: {GSP_NAME}")
print(f"2-Winding Transformers: {transformers_2w.total_count} records")
# Convert to DataFrame
df_transformers = pd.DataFrame([r.fields for r in transformers_2w.records if r.fields])
display(
df_transformers[
[
"hv_substation",
"voltage_hv",
"lv_substation",
"voltage_lv",
"transformer_rating_mva_winter",
"positive_sequence_impedance_x_percent",
]
]
)
# Also check for 3-winding transformers at this GSP
transformers_3w = ltds.get_table_2b(
licence_area=LICENCE_AREA,
where=f"gridsupplypoint='{GSP_NAME}'",
limit=100,
)
print(f"3-Winding Transformers at {GSP_NAME}: {transformers_3w.total_count} records")
if transformers_3w.records:
df_transformers_3w = pd.DataFrame(
[r.fields for r in transformers_3w.records if r.fields]
)
display(df_transformers_3w.head())
else:
print("(None found — this GSP uses 2-winding transformers only)")
# Show the full field set for reference
if transformers_2w.records:
print("All Table 2a fields:")
for key, value in transformers_2w.records[0].fields.items():
print(f" {key}: {value}")
3. Fetch Demand Data for the GSP¶
Table 3a contains observed peak demand at primary substations. We filter to our chosen GSP and to Winter season (typically the peak).
# Fetch demand data for this GSP (Winter season = peak demand)
demand_data = ltds.get_table_3a(
licence_area=LICENCE_AREA,
where=f"gridsupplypoint='{GSP_NAME}' AND season='Winter'",
limit=100,
)
df_demand = pd.DataFrame([r.fields for r in demand_data.records if r.fields])
print(f"Demand records at {GSP_NAME} (Winter): {len(df_demand)}")
display(
df_demand[
[
"substation",
"maximum_demand_24_25_mw",
"maximum_demand_24_25_pf",
"firm_capacity_mw",
"unutilised_capacity_percent",
]
]
)
# Show all demand fields for reference
if demand_data.records:
print("All Table 3a fields:")
for key, value in demand_data.records[0].fields.items():
print(f" {key}: {value}")
4. Fetch Generation Data for the GSP¶
Table 5 contains distributed generation capacity. We filter to our GSP to see what generation is connected.
# Fetch generation data for this GSP
generation_data = ltds.get_table_5(
licence_area=LICENCE_AREA,
where=f"gridsupplypoint='{GSP_NAME}'",
limit=100,
)
df_generation = pd.DataFrame([r.fields for r in generation_data.records if r.fields])
print(f"Generation records at {GSP_NAME}: {len(df_generation)}")
if not df_generation.empty:
display(
df_generation[
[
"substation",
"fuel_type",
"installedcapacity_mva",
"connection_voltage_kv",
"connected_accepted",
]
]
)
else:
print("(No generation records found for this GSP)")
5. Build a pandapower Network from Real LTDS Data¶
Now we'll create a pandapower network directly from the LTDS transformer, demand, and generation data for our chosen GSP. The Table 2a data gives us voltages, ratings, and impedances — everything pandapower needs.
import math
import pandapower as pp
net = pp.create_empty_network(name=f"UKPN {GSP_NAME} GSP")
# --- Step 1: Create buses from unique substations in transformer data ---
bus_map = {} # substation name -> bus index
for _, row in df_transformers.iterrows():
# HV side bus
hv_name = row["hv_substation"]
if hv_name not in bus_map:
bus_map[hv_name] = pp.create_bus(
net, vn_kv=float(row["voltage_hv"]), name=hv_name
)
# LV side bus
lv_name = row["lv_substation"]
if lv_name not in bus_map:
bus_map[lv_name] = pp.create_bus(
net, vn_kv=float(row["voltage_lv"]), name=lv_name
)
print(f"Created {len(net.bus)} buses from transformer data:")
display(net.bus[["name", "vn_kv"]])
# --- Step 2: Create transformers from Table 2a ---
for _, row in df_transformers.iterrows():
hv_bus = bus_map[row["hv_substation"]]
lv_bus = bus_map[row["lv_substation"]]
sn_mva = float(row["transformer_rating_mva_winter"])
vn_hv = float(row["voltage_hv"])
vn_lv = float(row["voltage_lv"])
# Impedance from LTDS (percent on transformer base)
vkr = float(row["positive_sequence_impedance_r_percent"]) / 100
vkx = float(row["positive_sequence_impedance_x_percent"]) / 100
vk = math.sqrt(vkr**2 + vkx**2)
pp.create_transformer_from_parameters(
net,
hv_bus=hv_bus,
lv_bus=lv_bus,
sn_mva=sn_mva,
vn_hv_kv=vn_hv,
vn_lv_kv=vn_lv,
vk_percent=vk,
vkr_percent=vkr,
pfe_kw=0, # Not in LTDS data — assume negligible
i0_percent=0.1, # Typical no-load current
name=f"{row['hv_substation']} → {row['lv_substation']}",
)
print(f"Created {len(net.trafo)} transformers")
display(
net.trafo[["name", "sn_mva", "vn_hv_kv", "vn_lv_kv", "vk_percent", "vkr_percent"]]
)
# --- Step 3: Add external grid at the highest-voltage bus ---
# Find the GSP-level bus (highest voltage)
gsp_bus_idx = net.bus["vn_kv"].idxmax()
pp.create_ext_grid(net, bus=gsp_bus_idx, vm_pu=1.0, name=f"{GSP_NAME} Grid Connection")
print(
f"External grid at bus {gsp_bus_idx} ({net.bus.loc[gsp_bus_idx, 'name']}, "
f"{net.bus.loc[gsp_bus_idx, 'vn_kv']:.0f} kV)"
)
6. Add Loads and Generation from LTDS Data¶
We'll match demand records (Table 3a) and generation records (Table 5) to buses by substation name.
# --- Step 4: Add loads from Table 3a demand data ---
loads_added = 0
for _, row in df_demand.iterrows():
sub_name = row["substation"]
if sub_name not in bus_map:
continue # Substation not in our transformer topology
p_mw = row.get("maximum_demand_24_25_mw")
pf = row.get("maximum_demand_24_25_pf")
if p_mw is None or pd.isna(p_mw):
continue
p_mw = float(p_mw)
pf = float(pf) if pf and not pd.isna(pf) else 0.95
q_mvar = p_mw * math.tan(math.acos(pf))
pp.create_load(
net,
bus=bus_map[sub_name],
p_mw=p_mw,
q_mvar=q_mvar,
name=sub_name,
)
loads_added += 1
print(f"Added {loads_added} loads from Table 3a")
display(net.load[["name", "bus", "p_mw", "q_mvar"]])
# --- Step 5: Add generation from Table 5 ---
gens_added = 0
if not df_generation.empty:
# Aggregate generation by substation (sum installed capacity)
gen_by_sub = df_generation.groupby("substation")["installedcapacity_mva"].sum()
for sub_name, capacity_mva in gen_by_sub.items():
if sub_name not in bus_map or pd.isna(capacity_mva):
continue
pp.create_sgen(
net,
bus=bus_map[sub_name],
p_mw=float(capacity_mva), # Approximate: MVA ≈ MW at unity PF
q_mvar=0,
name=f"DG at {sub_name}",
)
gens_added += 1
print(f"Added {gens_added} distributed generators from Table 5")
if len(net.sgen) > 0:
display(net.sgen[["name", "bus", "p_mw"]])
# Network summary before power flow
print(f"\n--- Network Summary for {GSP_NAME} ---")
print(f" Buses: {len(net.bus)}")
print(f" Transformers: {len(net.trafo)}")
print(f" Loads: {len(net.load)}")
print(f" Generators: {len(net.sgen)}")
print(f" Ext grids: {len(net.ext_grid)}")
print(f"\n Total load: {net.load['p_mw'].sum():.1f} MW")
if len(net.sgen) > 0:
print(f" Total DG: {net.sgen['p_mw'].sum():.1f} MW")
7. Run Power Flow Analysis¶
With the network built entirely from LTDS data, we can run a Newton-Raphson power flow.
# Run power flow calculation
pp.runpp(net, algorithm="nr", calculate_voltage_angles=True)
print("Power flow converged!\n")
print("Bus Results:")
display(net.res_bus.join(net.bus["name"]))
# Transformer loading results
print("Transformer Loading:")
trafo_results = net.res_trafo[
["p_hv_mw", "q_hv_mvar", "p_lv_mw", "q_lv_mvar", "loading_percent"]
].copy()
trafo_results.insert(0, "name", net.trafo["name"])
display(trafo_results)
# External grid power flow
print("External Grid Power Flow:")
display(net.res_ext_grid)
8. Visualize the Network¶
A custom schematic showing the GSP topology, transformer loading, and bus voltages.
import matplotlib.pyplot as plt
# Organise buses by voltage level for a layered layout
voltage_levels = sorted(net.bus["vn_kv"].unique(), reverse=True)
level_y = {vn: i for i, vn in enumerate(voltage_levels)}
# Assign x positions: spread buses at each voltage level horizontally
bus_positions = {}
for vn in voltage_levels:
buses_at_level = net.bus[net.bus["vn_kv"] == vn].index.tolist()
n = len(buses_at_level)
for j, bus_idx in enumerate(buses_at_level):
x = (j - (n - 1) / 2) * 1.5
y = level_y[vn] * 2
bus_positions[bus_idx] = (x, y)
fig, ax = plt.subplots(
figsize=(max(12, len(net.bus) * 1.5), 4 + len(voltage_levels) * 2)
)
# Draw transformer connections
for i, row in net.trafo.iterrows():
hv_pos = bus_positions[row["hv_bus"]]
lv_pos = bus_positions[row["lv_bus"]]
loading = net.res_trafo.loc[i, "loading_percent"]
colour = "red" if loading > 80 else "darkorange" if loading > 50 else "grey"
ax.plot(
[hv_pos[0], lv_pos[0]], [hv_pos[1], lv_pos[1]], lw=1.5, color=colour, zorder=0
)
mid_x = (hv_pos[0] + lv_pos[0]) / 2
mid_y = (hv_pos[1] + lv_pos[1]) / 2
ax.text(
mid_x + 0.15, mid_y, f"{loading:.0f}%", fontsize=7, color=colour, va="center"
)
# Draw buses
for bus_idx, (x, y) in bus_positions.items():
vm = net.res_bus.loc[bus_idx, "vm_pu"]
name = net.bus.loc[bus_idx, "name"]
vn = net.bus.loc[bus_idx, "vn_kv"]
colour = "red" if vm < 0.94 else "darkorange" if vm < 0.97 else "steelblue"
ax.plot([x - 0.5, x + 0.5], [y, y], lw=4, color=colour, solid_capstyle="round")
# Shorten label for readability
short_name = name.replace(" Primary", "").replace(" Grid", "")
ax.text(x, y + 0.25, f"{short_name}", fontsize=7, ha="center", va="bottom")
ax.text(
x, y - 0.25, f"{vm:.3f} pu", fontsize=6, ha="center", va="top", color=colour
)
# Draw external grid arrow
ext_bus = int(net.ext_grid.loc[0, "bus"])
ex, ey = bus_positions[ext_bus]
ax.annotate(
"",
xy=(ex, ey + 0.3),
xytext=(ex, ey + 1.0),
arrowprops={"arrowstyle": "->", "color": "green", "lw": 2},
)
p_grid = net.res_ext_grid.loc[0, "p_mw"]
ax.text(
ex,
ey + 1.1,
f"National Grid\n{p_grid:.1f} MW",
ha="center",
fontsize=9,
color="green",
)
# Draw loads as downward arrows
for _i, row in net.load.iterrows():
bx, by = bus_positions[row["bus"]]
ax.annotate(
"",
xy=(bx + 0.3, by),
xytext=(bx + 0.7, by - 0.6),
arrowprops={"arrowstyle": "->", "color": "red", "lw": 1},
)
ax.text(
bx + 0.75,
by - 0.65,
f"{row['p_mw']:.1f} MW",
fontsize=6,
color="red",
ha="center",
)
# Voltage level labels on the right
for vn, y_idx in level_y.items():
ax.text(
ax.get_xlim()[1] + 0.2,
y_idx * 2,
f"{vn:.0f} kV",
fontsize=9,
va="center",
color="grey",
fontstyle="italic",
)
ax.set_aspect("equal")
ax.axis("off")
ax.set_title(
f"UKPN {GSP_NAME} GSP — Power Flow Results", fontsize=13, fontweight="bold"
)
plt.tight_layout()
plt.show()
9. Building a Larger Network from LTDS Data¶
Here's a function template to automate network creation from LTDS data.
# Try a different GSP — change the name and re-run from Section 2!
# Some GSPs with interesting topologies:
# "Barking 132kV", "Canterbury North 132kV", "Rayleigh Main GSP 132kV"
# List all GSPs available in EPN
all_transformers = ltds.get_table_2a(licence_area=LICENCE_AREA, limit=100)
df_all = pd.DataFrame([r.fields for r in all_transformers.records if r.fields])
gsp_counts = df_all["gridsupplypoint"].value_counts()
print(f"Grid Supply Points in EPN ({len(gsp_counts)} total):\n")
display(gsp_counts.head(20))
10. Export Network Data¶
You can export the pandapower network to various formats for further analysis.
from pathlib import Path
save_dir = None # Set to a directory (e.g. "exports") to enable writing files.
# Export to JSON (optional)
if save_dir:
output_dir = Path(save_dir)
output_dir.mkdir(parents=True, exist_ok=True)
json_file = output_dir / "ukpn_network.json"
pp.to_json(net, str(json_file))
print(f"Network exported to {json_file}")
else:
print("JSON export skipped; set save_dir to enable writing.")
# Export to Excel (optional)
if save_dir:
output_dir = Path(save_dir)
output_dir.mkdir(parents=True, exist_ok=True)
xlsx_file = output_dir / "ukpn_network.xlsx"
try:
pp.to_excel(net, str(xlsx_file))
print(f"Network exported to {xlsx_file}")
except Exception as e:
print(f"Excel export requires openpyxl: {e}")
else:
print("Excel export skipped; set save_dir to enable writing.")
# Also export the raw LTDS data for reference (optional)
from pathlib import Path
csv_data = ltds.export("table_2a", format="csv")
save_dir = None # Set to a directory (e.g. "exports") to enable writing files.
if save_dir:
output_file = Path(save_dir) / "ltds_transformers.csv"
output_file.parent.mkdir(parents=True, exist_ok=True)
with open(output_file, "wb") as f:
f.write(csv_data)
print(f"LTDS transformer data exported to {output_file}")
else:
print("CSV export skipped; set save_dir to enable writing.")
Summary¶
This tutorial demonstrated:
- Targeting a single GSP to build a coherent network model
- Fetching real transformer data (Table 2a) with impedances, ratings, and voltages
- Fetching demand data (Table 3a) for observed peak loads
- Fetching generation data (Table 5) for distributed generation capacity
- Building a pandapower network directly from LTDS data
- Running power flow analysis with Newton-Raphson
- Visualizing results — bus voltages, transformer loading, power flows
- Exporting the network for further analysis
Next Steps¶
- Try a larger GSP with more substations (see Section 9 for a list)
- Combine with time-series powerflow data for dynamic studies
- Add line data from CIM exports for full network models
- Use DFES headroom data for scenario analysis