Tutorial 4: Interconnection and multinodal networks#
Introduction#
This tutorial demonstrates how to create and analyze multi-nodal power systems using PyPSA. We’ll explore how to model interconnected power systems and understand the role of network elements in power system analysis. We’ll also see how complex interconnected systems can be simplified while maintaining their essential characteristics.
from pypsa import Network
import pandas as pd
import matplotlib.pyplot as plt
import seaborn as sns
sns.set_theme("notebook", style="white")
plt.rc("axes.spines", top=False, right=False)
# Load the timeseries data
ts = pd.read_csv("data/timeseries.csv", index_col=0, parse_dates=True)
costs = pd.read_csv("data/costs.csv", index_col=0)
e_price = pd.read_csv("data/electricity_prices_interconnector_DE.csv")
e_price.index = ts.index
e_price.columns = ['DE']
# Set the same tech limits as before
tech_limits = {
"wind": 150e3,
"solar": 300e3,
}
Creating a two-bus system#
Let’s create a system with two buses: one representing a load center and another representing a generation center with a biogas plant.
# Initialize network
n = Network()
n.set_snapshots(ts.index)
n.add("Carrier", "electricity")
# Add buses
n.add("Bus", "load_bus", carrier="electricity")
n.add("Bus", "generation_bus", carrier="electricity")
# Add load to the load bus
n.add(
"Load",
"demand",
bus="load_bus",
p_set=ts.load,
)
# Add renewable generators to the load bus (from previous tutorial)
for tech in ["wind", "solar"]:
n.add(
"Generator",
tech,
bus="load_bus",
p_max_pu=ts[tech],
capital_cost=costs.at[tech, "capital_cost"],
marginal_cost=costs.at[tech, "marginal_cost"],
p_nom_extendable=True,
p_nom_max=tech_limits[tech],
)
# add battery storage
EP_RATIO = 6
n.add(
"StorageUnit",
"battery storage",
bus="load_bus",
max_hours=EP_RATIO,
capital_cost=costs.at["battery inverter", "capital_cost"]
+ EP_RATIO * costs.at["battery storage", "capital_cost"],
efficiency_store=costs.at["battery inverter", "efficiency"],
efficiency_dispatch=costs.at["battery inverter", "efficiency"],
p_nom_extendable=True,
cyclic_state_of_charge=True,
)
# add long-duration flow battery
FLOW_EP_RATIO = 100
n.add(
"StorageUnit",
"flow battery",
bus="load_bus",
max_hours=FLOW_EP_RATIO,
capital_cost=costs.at["flow battery power", "capital_cost"]
+ FLOW_EP_RATIO * costs.at["flow battery energy", "capital_cost"],
efficiency_store=costs.at["flow battery power", "efficiency"],
efficiency_dispatch=costs.at["flow battery power", "efficiency"],
p_nom_extendable=True,
cyclic_state_of_charge=True,
)
# Add biogas generator to the generation bus
n.add(
"Generator",
"biogas ccgt",
bus="generation_bus",
capital_cost=costs.at["biogas ccgt", "capital_cost"],
marginal_cost=costs.at["biogas ccgt", "marginal_cost"],
p_nom=12000, # 12 GW capacity
p_nom_extendable=False,
)
# Add network element connecting the buses
n.add(
"Link",
"transmission_line",
bus0="generation_bus",
bus1="load_bus",
p_nom=12000, # 12 GW capacity
efficiency=0.95, # 5% losses in transmission
)
Index(['transmission_line'], dtype='object')
Analyzing the Two-Bus System#
Let’s optimize this system and analyze its behavior.
# Optimize the system
n.optimize(solver_name="highs")
Visualisation#
# Plot the monthly energy balance
fig, axes = plt.subplots(4, 1, figsize=(10, 14))
plt.subplots_adjust(hspace=0.6)
### 1 -- Energy balance
ax = axes[0]
capacity_data = pd.concat(
[
n.generators.p_nom_opt,
n.storage_units.p_nom_opt,
],
axis=1,
).div(1e3)
capacity_data.columns = ["generation", "storage"]
capacity_data.plot(kind="barh", ax=ax)
ax.set_title("Installed capacities (GW)")
ax.set_xlabel("")
for container in ax.containers:
# Get the labels only for non-zero values
labels = [f"{v:.0f}" if v != 0 else "" for v in container.datavalues]
ax.bar_label(container, labels=labels, label_type="edge", fontsize=9, padding=3)
### 2 -- Energy balance
ax = axes[1]
generation = n.generators_t.p.resample("ME").sum().div(1e6)
generation.index = generation.index.strftime("%b")
generation.plot(
kind="bar", ax=ax, legend=True, stacked=True
)
ax.set_title("Monthly energy generation")
ax.set_ylabel("Energy (TWh)")
ax.set_xlabel("")
### 3 -- State of charge
ax = axes[2]
state_of_charge = n.storage_units_t.state_of_charge / (
n.storage_units.max_hours * n.storage_units.p_nom_opt
)
state_of_charge.index = n.snapshots
state_of_charge = state_of_charge.resample("D").mean().mul(100)
state_of_charge.plot(
kind="line", ax=ax, legend=True, title="Usage of storage"
)
ax.set_ylabel("State of charge (%)")
ax.set_xlabel("")
fig.suptitle(
"Baseline scenario with interconnection to dispatchable generation", fontsize=14
)
### 4 -- Capacity factors
ax = axes[3]
combined_cf = n.generators_t.p.resample("ME").mean() / n.generators.p_nom_opt
combined_cf.index = combined_cf.index.strftime("%b")
combined_cf.plot(
ax=ax, kind="bar", title="Monthly capacity factor", legend=True, position=0.5
)
ax.set_xlabel("")
Text(0.5, 0, '')
Analysis#
Renewable capacity remains important: 300 GW solar, 150 GW wind
(at the upper limit still)Storage mix: 72 GW short-duration batteries, 28 GW long-duration flow batteries
(a significant reduction in long-duration flow batteries from 50 GW)Clear seasonal generation patterns:
Wind peaks in winter (Dec-Feb)
Solar peaks in summer (May-Aug)
Biogas provides winter backup
Distinct storage behavior:
Batteries: Regular daily cycling year-round
Flow batteries: Deep seasonal charging/discharging patterns
The multinodal network analysis reveals a renewable-dominated system with strategic deployment of storage and dispatchable generation. The large renewable capacities (450 GW total) form the backbone of generation, while the storage portfolio combines short-duration batteries for daily balancing with long-duration flow batteries for seasonal energy shifting. The biogas CCGT, though limited in capacity, provides crucial winter backup when renewable generation is lower.
Storage operation shows clear technological specialization: batteries handle daily variations while flow batteries manage seasonal imbalances, particularly storing summer solar surplus for winter use. This complementary storage strategy, combined with the seasonal patterns of wind and solar generation, enables the system to maintain reliability across all timescales despite the high renewable penetration.
Note
The statement about batteries handling daily variations does not become clear from the state-of-charge because it is averaged out on a daily resolution. We choose to apply this resampling to improve legibility of the more seasonal patterns. When plotting the data at the original resolution, we observe short-cyclic behaviour of the battery storage. Try plotting it yourself!
Simple market-based interconnection model#
In real power systems, interconnections link to markets with many different power plants and technologies. This is more complex than our previous model that only used a biogas plant on the other side of the connection. We can simplify this by treating the interconnection as a generator that can buy and sell electricity at market prices. Key aspects of this approach:
Uses real market prices instead of modeling plants
Treats the connection as a simple import/export generator
Much simpler than modeling the entire connected market
Example: using German prices for Dutch-German connection
This simplification works best when:
The modeled system is small compared to the connected market
Price changes from our trades would be negligible
Analysis is at provincial level or smaller
We don’t need to capture detailed market interactions
Main limitations:
Ignores how our demand affects market prices (merit-order effects)
Not suitable for large systems like whole countries
Assumes unlimited availability at market price
May oversimplify complex market dynamics
Despite these limitations, for regional studies this simplified approach offers a good balance between accuracy and model complexity.
Note
In our case study of the Dutch power system, this market-based simplification is not appropriate. The Netherlands represents a significant share of European electricity markets and faces real interconnector constraints. We use this simplified approach purely for educational purposes to demonstrate market coupling concepts.
Initiating and optimising our network#
# Create a new simplified network
n_simple = Network()
n_simple.set_snapshots(ts.index)
# Add single bus
n_simple.add("Bus", "load_bus")
# Add load
n_simple.add(
"Load",
"demand",
bus="load_bus",
p_set=ts.load,
)
# Add renewable generators
for tech in ["wind", "solar"]:
n_simple.add(
"Generator",
tech,
bus="load_bus",
p_max_pu=ts[tech],
capital_cost=costs.at[tech, "capital_cost"],
marginal_cost=costs.at[tech, "marginal_cost"],
p_nom_extendable=True,
p_nom_max=tech_limits[tech],
)
# add battery storage
EP_RATIO = 6
n_simple.add(
"StorageUnit",
"battery storage",
bus="load_bus",
max_hours=EP_RATIO,
capital_cost=costs.at["battery inverter", "capital_cost"]
+ EP_RATIO * costs.at["battery storage", "capital_cost"],
efficiency_store=costs.at["battery inverter", "efficiency"],
efficiency_dispatch=costs.at["battery inverter", "efficiency"],
p_nom_extendable=True,
cyclic_state_of_charge=True,
)
# add long-duration flow battery
FLOW_EP_RATIO = 100
n_simple.add(
"StorageUnit",
"flow battery",
bus="load_bus",
max_hours=FLOW_EP_RATIO,
capital_cost=costs.at["flow battery power", "capital_cost"]
+ FLOW_EP_RATIO * costs.at["flow battery energy", "capital_cost"],
efficiency_store=costs.at["flow battery power", "efficiency"],
efficiency_dispatch=costs.at["flow battery power", "efficiency"],
p_nom_extendable=True,
cyclic_state_of_charge=True,
)
# Add market connection as a generator with time-varying prices
n_simple.add(
"Generator",
"market_connection",
bus="load_bus",
marginal_cost=e_price["DE"], # Time-varying prices
p_nom=12000, # Same capacity as transmission line
)
Index(['market_connection'], dtype='object')
# Optimize the simplified system
n_simple.optimize(solver_name="highs")
# make sure we keep both networks available
n_prior = n
n = n_simple
Analyzing the simple market-based interconnection model#
Visualisation#
# Plot the monthly energy balance
fig, axes = plt.subplots(4, 1, figsize=(10, 14))
plt.subplots_adjust(hspace=0.6)
### 1 -- Energy balance
ax = axes[0]
capacity_data = pd.concat(
[
n.generators.p_nom_opt,
n.storage_units.p_nom_opt,
],
axis=1,
).div(1e3)
capacity_data.columns = ["generation", "storage"]
capacity_data.plot(kind="barh", ax=ax)
ax.set_title("Installed capacities (GW)")
ax.set_xlabel("")
for container in ax.containers:
# Get the labels only for non-zero values
labels = [f"{v:.0f}" if v != 0 else "" for v in container.datavalues]
ax.bar_label(container, labels=labels, label_type="edge", fontsize=9, padding=3)
### 2 -- Energy balance
ax = axes[1]
generation = n.generators_t.p.resample("ME").sum().div(1e6)
generation.index = generation.index.strftime("%b")
generation.plot(
kind="bar", ax=ax, legend=True, stacked=True
)
ax.set_title("Monthly energy generation")
ax.set_ylabel("Energy (TWh)")
ax.set_xlabel("")
### 3 -- State of charge
ax = axes[2]
state_of_charge = n.storage_units_t.state_of_charge / (
n.storage_units.max_hours * n.storage_units.p_nom_opt
)
state_of_charge.index = n.snapshots
state_of_charge = state_of_charge.resample("D").mean().mul(100)
state_of_charge.plot(
kind="line", ax=ax, legend=True, title="Usage of storage"
)
ax.set_ylabel("State of charge (%)")
ax.set_xlabel("")
fig.suptitle(
"Baseline scenario with interconnection to dispatchable generation", fontsize=14
)
### 4 -- Capacity factors
ax = axes[3]
combined_cf = n.generators_t.p.resample("ME").mean() / n.generators.p_nom_opt
combined_cf.index = combined_cf.index.strftime("%b")
combined_cf.plot(
ax=ax, kind="bar", title="Monthly capacity factor", legend=True, position=0.5
)
ax.set_xlabel("")
Text(0.5, 0, '')
Analysis#
Storage slightly changes: battery storage from 72 GW to 66 GW, flow battery from 28 GW to 36 GW
Renewable capacities remain at upper limit: 300 GW solar and 150 GW wind
Both models use 12 GW interconnection capacity
Market connection shows very high utilization (capacity factors), remarkably especially in summer months
The market-based model demonstrates more flexible operation compared to the biogas CCGT scenario. While both models maintain the same renewable and interconnection capacities, the market connection’s dynamic pricing enables more frequent utilization throughout the year, as shown by higher capacity factors. This is particularly evident in summer months, where the market connection actively trades power while the biogas CCGT was barely used.
The shift in storage capacities (less short-term, more long-term storage) suggests that market-based trading provides better short-term flexibility than the biogas plant, reducing the need for battery storage. However, the increase in flow battery capacity indicates a greater need for seasonal storage to take advantage of market price variations throughout the year. This shows how access to market-based trading can influence the optimal mix of system flexibility options.
Understanding the simplification#
This simplification represents how the Dutch power system is connected to the broader European electricity market:
The transmission line capacity (12 GW) represents the Netherlands’ total interconnection capacity with neighboring countries.
The time-varying prices represent the German electricity market prices, which influence when the Netherlands imports or exports electricity.
When local renewable generation is cheaper than the market price, the system will use local generation.
When the market price is lower than local generation costs, the system will import power through the “market connection”.
This simplified model captures the essential economic behavior of the interconnected system while being computationally more efficient. The key insights are:
The Netherlands is not an isolated system but part of an integrated European electricity market
Market prices in neighboring countries significantly influence local dispatch decisions
Interconnection capacity constraints can limit the ability to import/export power
Local renewable generation competes with import options based on relative costs
Conclusion#
We’ve learned how to:
Create a multi-nodal power system with interconnections
Add dispatchable generation (biogas) to the system
Model transmission constraints between nodes
Simplify complex interconnected systems while maintaining their essential characteristics
Understand how the Netherlands’ power system interacts with the European electricity market
This understanding is crucial for energy system planning, as it helps balance the development of local generation capacity with interconnection capabilities.