Zambia Model#
PyPSA Modelling Exercise — Zambian Power System#
Overview#
In this exercise you will build a stochastic optimisation model using PyPSA.
Provided Files#
File |
Description |
|---|---|
|
System diagram with model topology and all static component data |
|
Time-series data (e.g. load profiles, renewable capacity factors) |
|
Template notebook — complete your work here |
Your Task#
Using the template notebook, build the PyPSA network.
lesson_number = 5
print(f'lesson{lesson_number}')
lesson5
Prepare Google Colab Environment#
#@title Connect to Google Drive {display-mode:"form"}
CONNECT_TO_DRIVE = False #@param {type:"boolean"}
import os
if CONNECT_TO_DRIVE:
from google.colab import drive
# Mount Google Drive
drive.mount('/content/drive')
# Define the desired working directory path
working_dir = '/content/drive/MyDrive/hello-pypsa'
# Create the directory if it doesn't exist
if not os.path.exists(working_dir):
os.makedirs(working_dir)
print(f"Directory '{working_dir}' created.")
else:
print(f"Directory '{working_dir}' already exists.")
# Change the current working directory
os.chdir(working_dir)
print(f"Current working directory: {os.getcwd()}")
else:
print("Not connecting to Google Drive.")
import os
#@title Install Packages {display-mode:"form"}
INSTALL_PACKAGES = False #@param {type:"boolean"}
# Check if packages have already been installed in this session to prevent re-installation
if INSTALL_PACKAGES and not os.environ.get('PYPSA_PACKAGES_INSTALLED'):
!pip install pypsa pypsa[excel] folium mapclassify cartopy
!pip install git+https://github.com/PriyeshGosai/pypsa_network_viewer.git
os.environ['PYPSA_PACKAGES_INSTALLED'] = 'true'
elif not INSTALL_PACKAGES:
print("Skipping package installation.")
else:
print("PyPSA packages are already installed for this session.")
#@title Download the file for this notebook {display-mode:"form"}
DOWNLOAD_FILE = False #@param {type:"boolean"}
if DOWNLOAD_FILE:
!gdown "https://drive.google.com/uc?id=1My8j2qRcjjhVhC5bL657oYTk7-OKDxkE"
else:
print("Skipping file download.")
import pypsa
import pandas as pd
pypsa.options.api.new_components_api = True
pd.options.plotting.backend = "plotly"
zm = pypsa.Network('zambia_model.xlsx')
# zm.optimize.add_load_shedding()
# zm.optimize.add_load_shedding(p_nom = 10000 , marginal_cost=1000)
zm.optimize()
df_demand = pd.read_excel(
'Zambia Data.xlsx',
sheet_name='Demand Data',
index_col=0, # first column as index
header=[0, 1] # first two rows form a MultiIndex column
)
marginal_cost_dict = {
'Zambia Coal':{'high':40,'medium':30,'low':20},
'ZESCO':{'high':45,'medium':35,'low':25}
}
installed_capacity_dict = {
'Zambia Coal':{'high':500,'medium':400,'low':300},
'ZESCO':{'high':2400,'medium':2300,'low':2200}
}
zm.generators.static.loc[['Zambia Coal','ZESCO'],'marginal_cost']
# ── 1. Define scenarios & probabilities ─────────────────────────────────────
# set_scenarios() modifies the network IN PLACE — do NOT assign its return value
zm.set_scenarios({"Low": 0.2, "Medium": 0.5, "High": 0.3})
# ── 2. Static: marginal costs (Zambia Coal) ──────────────────────────────────
zm.generators.static.loc[("Low", "Zambia Coal"), "marginal_cost"] = marginal_cost_dict["Zambia Coal"]["low"]
zm.generators.static.loc[("Medium", "Zambia Coal"), "marginal_cost"] = marginal_cost_dict["Zambia Coal"]["medium"]
zm.generators.static.loc[("High", "Zambia Coal"), "marginal_cost"] = marginal_cost_dict["Zambia Coal"]["high"]
# ── 3. Static: marginal costs (ZESCO) ────────────────────────────────────────
zm.generators.static.loc[("Low", "ZESCO"), "marginal_cost"] = marginal_cost_dict["ZESCO"]["low"]
zm.generators.static.loc[("Medium", "ZESCO"), "marginal_cost"] = marginal_cost_dict["ZESCO"]["medium"]
zm.generators.static.loc[("High", "ZESCO"), "marginal_cost"] = marginal_cost_dict["ZESCO"]["high"]
# ── 4. Static: installed capacity / p_nom (Zambia Coal) ──────────────────────
zm.generators.static.loc[("Low", "Zambia Coal"), "p_nom"] = installed_capacity_dict["Zambia Coal"]["low"]
zm.generators.static.loc[("Medium", "Zambia Coal"), "p_nom"] = installed_capacity_dict["Zambia Coal"]["medium"]
zm.generators.static.loc[("High", "Zambia Coal"), "p_nom"] = installed_capacity_dict["Zambia Coal"]["high"]
# ── 5. Dynamic: demand load profile (Zambia Load) ────────────────────────────
zm.loads.dynamic.p_set.loc[:, ("Low", "Zambia Load")] = df_demand[("Low", "Zambia")].values
zm.loads.dynamic.p_set.loc[:, ("Medium", "Zambia Load")] = df_demand[("Medium", "Zambia")].values
zm.loads.dynamic.p_set.loc[:, ("High", "Zambia Load")] = df_demand[("High", "Zambia")].values
# ── 6. Disable cyclic SOC for stochastic solve ──────────────────
# PyPSA bug: cyclic_state_of_charge=True conflicts with scenario MultiIndex indexing.
# Disable it per-scenario so the constraint builder doesn't hit a 2D boolean issue.
zm.storage_units.static.loc[("Low", "New Zambia HPP"), "cyclic_state_of_charge"] = False
zm.storage_units.static.loc[("Medium", "New Zambia HPP"), "cyclic_state_of_charge"] = False
zm.storage_units.static.loc[("High", "New Zambia HPP"), "cyclic_state_of_charge"] = False
# ── 14. Solve ─────────────────────────────────────────────────────────────────
zm.optimize(solver_name="highs")
zm.buses.static
zm.generators.static.p_nom_opt
zm.generators.dynamic.p_max_pu['Low'].plot()
zm.loads.dynamic.p_set['Low'].plot()
zm.loads.dynamic.p_set['Medium'].plot()
zm.loads.dynamic.p_set['High'].plot()
import plotly.graph_objects as go
fig = go.Figure()
for scenario in ['Low', 'Medium', 'High']:
s = zm.loads.dynamic.p_set[scenario].sum(axis=1)
fig.add_trace(go.Scatter(x=s.index, y=s.values, name=scenario))
fig.update_layout(xaxis_title='Time', yaxis_title='Load (MW)')
fig.show()
zm.generators.dynamic.p.sum()
# ── DIAGNOSTIC: Why is p_nom_opt the same across scenarios? ──────────────────
print("=" * 60)
print("1. EXTENDABLE vs FIXED generators")
print("=" * 60)
# p_nom_extendable=True → investment decision (first-stage) → MUST be same across scenarios
# p_nom_extendable=False → p_nom_opt = p_nom (just reflects the input)
ext = zm.generators.static[["p_nom", "p_nom_extendable", "p_nom_opt"]].xs("Low", level="scenario")
print(ext.to_string())
print()
print("=" * 60)
print("2. p_nom INPUT per scenario (was it set differently?)")
print("=" * 60)
# Shows which generators actually received different p_nom per scenario
p_nom_by_scenario = zm.generators.static["p_nom"].unstack("scenario")[["Low", "Medium", "High"]]
print(p_nom_by_scenario.to_string())
print()
print("=" * 60)
print("3. p_nom_opt OUTPUT per scenario")
print("=" * 60)
p_nom_opt_by_scenario = zm.generators.static["p_nom_opt"].unstack("scenario")[["Low", "Medium", "High"]]
print(p_nom_opt_by_scenario.to_string())
print()
print("=" * 60)
print("4. DISPATCH (p) — this SHOULD differ across scenarios")
print(" Mean dispatch per generator per scenario:")
print("=" * 60)
for scenario in ["Low", "Medium", "High"]:
print(f"\n Scenario: {scenario}")
print(zm.generators.dynamic.p[scenario].mean().to_string())
import plotly.graph_objects as go
gen_total = zm.generators.dynamic.p.sum().unstack("scenario")[["Low", "Medium", "High"]]
fig = go.Figure()
for scenario in ["Low", "Medium", "High"]:
fig.add_trace(go.Bar(
name=scenario,
x=gen_total.index,
y=gen_total[scenario]
))
fig.update_layout(
barmode="group",
title="Total Generation by Scenario",
xaxis_title="Generator",
yaxis_title="Total Generation (MWh)",
xaxis_tickangle=-45
)
fig.show()