Multi-Year Capacity Expansion Planning
Overview
This tutorial demonstrates capacity expansion planning over multiple years using pyconvexity. We'll build a 5-year model (2020-2024) for Ghana and Togo that optimizes when to invest in new generation, transmission, and storage capacity to meet growing demand while reducing emissions.
What you'll learn
- Multi-period investment optimization
- Creating extendable capacity (generators, links, batteries)
- Modeling technology constraints (e.g., no new gas after 2025)
- Working with growing demand profiles over time
- Adding progressive emissions reduction constraints
- Understanding when the model decides to build vs. delay investments
Key concept: Unlike single-period models that optimize dispatch, capacity expansion models optimize both dispatch AND investment decisions. The model decides not just how to operate existing assets, but when to build new ones to minimize total system cost over the planning horizon.
Prerequisites
pip install pyconvexityDownload Example Files
The Planning Problem
Ghana and Togo face growing electricity demand (3% annually) and want to:
- Meet increasing demand cost-effectively
- Phase out gas generation (no new gas after 2025)
- Reduce CO2 emissions to zero by 2024
- Consider investments in: biomass, hydro, transmission, batteries
The model must decide the optimal timing and sizing of investments across 5 years.
Step 1: Set up multi-year database
This model spans 5 years with 6-hourly resolution (4 snapshots per day):
import pyconvexity as px
import numpy as np
from datetime import datetime, timedelta
import os
# Create database
db_path = "ghana_togo_expansion.db"
px.create_database_with_schema(db_path)
with px.database_context(db_path) as conn:
# Define 5-year period (2020-2024)
start_time = datetime(2020, 1, 1, 0, 0, 0)
end_time = datetime(2024, 12, 31, 18, 0, 0)
network_req = px.CreateNetworkRequest(
name="Ghana-Togo Capacity Expansion 2020-2024",
description="Multi-year capacity expansion with 6-hourly resolution",
start_time=start_time.strftime("%Y-%m-%d %H:%M:%S"),
end_time=end_time.strftime("%Y-%m-%d %H:%M:%S"),
time_resolution="PT6H", # 6-hourly
)
px.create_network(conn, network_req)Why 6-hourly? This captures daily demand variation while keeping the problem size manageable. Each year has ~1,460 snapshots (365 days × 4 snapshots/day).
Step 2: Create carriers and buses
# Create carriers
carriers = {}
carrier_data = {
"AC": {"co2": 0.0, "color": "#1f77b4", "nice": "Electricity"},
"gas": {"co2": 0.4, "color": "#ff7f0e", "nice": "Natural Gas"},
"biomass": {"co2": 0.0, "color": "#2ca02c", "nice": "Biomass"},
"hydro": {"co2": 0.0, "color": "#17becf", "nice": "Hydroelectric"},
}
for name, data in carrier_data.items():
carrier_id = px.create_carrier(
conn,
name=name,
co2_emissions=data["co2"],
color=data["color"],
nice_name=data["nice"],
)
carriers[name] = carrier_id
# Create buses for Ghana and Togo
country_coords = {
"Ghana": {"latitude": 7.9, "longitude": -1.0},
"Togo": {"latitude": 8.0, "longitude": 1.2},
}
bus_ids = {}
for country, coords in country_coords.items():
bus_id = px.create_component(
conn,
component_type="BUS",
name=country,
carrier_id=carriers["AC"],
latitude=coords["latitude"],
longitude=coords["longitude"],
)
bus_ids[country] = bus_id
px.set_static_attribute(conn, bus_id, "v_nom", px.StaticValue(400.0))Step 3: Create extendable generators
This is where capacity expansion differs from operational models. We create generators with p_nom_extendable=True and different build years:
# Technology parameters
tech_params = {
"gas": {
"marginal_cost": 60.0,
"capital_cost": 800000.0, # $/MW
"lifetime": 25.0,
"build_years": list(range(2020, 2026)), # Can only build until 2025
},
"biomass": {
"marginal_cost": 45.0,
"capital_cost": 2000000.0,
"lifetime": 30.0,
"build_years": list(range(2020, 2025)), # Can build throughout
},
"hydro": {
"marginal_cost": 5.0,
"capital_cost": 3000000.0,
"lifetime": 50.0,
"build_years": list(range(2020, 2025)),
},
}
# Create generators for each technology and build year
for country in ["Ghana", "Togo"]:
coords = country_coords[country]
for tech_name, params in tech_params.items():
for build_year in params["build_years"]:
gen_name = f"{country} {tech_name} {build_year}"
gen_id = px.create_component(
conn,
component_type="GENERATOR",
name=gen_name,
bus_id=bus_ids[country],
carrier_id=carriers[tech_name],
latitude=coords["latitude"],
longitude=coords["longitude"],
)
# Key attributes for capacity expansion
px.set_static_attribute(conn, gen_id, "p_nom", px.StaticValue(0.0))
px.set_static_attribute(conn, gen_id, "p_nom_extendable", px.StaticValue(True))
px.set_static_attribute(conn, gen_id, "marginal_cost",
px.StaticValue(float(params["marginal_cost"])))
px.set_static_attribute(conn, gen_id, "capital_cost",
px.StaticValue(float(params["capital_cost"])))
px.set_static_attribute(conn, gen_id, "build_year",
px.StaticValue(int(build_year)))
px.set_static_attribute(conn, gen_id, "lifetime",
px.StaticValue(float(params["lifetime"])))Critical details:
p_nom_extendable=True: Model can add capacitybuild_year: When this capacity option becomes availablecapital_cost: One-time investment cost per MWlifetime: How many years the asset operates
Step 4: Create extendable transmission link
# Ghana-Togo interconnector
link_id = px.create_component(
conn,
component_type="LINK",
name="Ghana-Togo Interconnector",
bus0_id=bus_ids["Ghana"],
bus1_id=bus_ids["Togo"],
carrier_id=carriers["AC"],
latitude=(country_coords["Ghana"]["latitude"] + country_coords["Togo"]["latitude"]) / 2.0,
longitude=(country_coords["Ghana"]["longitude"] + country_coords["Togo"]["longitude"]) / 2.0,
)
px.set_static_attribute(conn, link_id, "p_nom", px.StaticValue(0.0))
px.set_static_attribute(conn, link_id, "p_nom_extendable", px.StaticValue(True))
px.set_static_attribute(conn, link_id, "p_min_pu", px.StaticValue(-1.0))
px.set_static_attribute(conn, link_id, "capital_cost", px.StaticValue(500000.0))
px.set_static_attribute(conn, link_id, "build_year", px.StaticValue(2020))
px.set_static_attribute(conn, link_id, "lifetime", px.StaticValue(40.0))Step 5: Create extendable battery storage
# Batteries for each country and build year
for country in ["Ghana", "Togo"]:
coords = country_coords[country]
for build_year in range(2020, 2025):
battery_name = f"{country} Battery {build_year}"
battery_id = px.create_component(
conn,
component_type="STORAGE_UNIT",
name=battery_name,
bus_id=bus_ids[country],
carrier_id=carriers["AC"],
latitude=coords["latitude"] + 0.1,
longitude=coords["longitude"] + 0.1,
)
px.set_static_attribute(conn, battery_id, "p_nom", px.StaticValue(0.0))
px.set_static_attribute(conn, battery_id, "p_nom_extendable", px.StaticValue(True))
px.set_static_attribute(conn, battery_id, "capital_cost", px.StaticValue(600000.0))
px.set_static_attribute(conn, battery_id, "build_year", px.StaticValue(int(build_year)))
px.set_static_attribute(conn, battery_id, "lifetime", px.StaticValue(15.0))
px.set_static_attribute(conn, battery_id, "max_hours", px.StaticValue(4.0))
px.set_static_attribute(conn, battery_id, "efficiency_store", px.StaticValue(0.9))
px.set_static_attribute(conn, battery_id, "efficiency_dispatch", px.StaticValue(0.9))Step 6: Create growing demand profiles
Unlike simple models with static demand, we model demand growth over time:
# Base demand in 2020
base_demand_2020 = {
"Ghana": 2000.0, # MW
"Togo": 300.0,
}
annual_growth_rate = 0.03 # 3% per year
for country in ["Ghana", "Togo"]:
load_id = px.create_component(
conn,
component_type="LOAD",
name=f"{country} Load",
bus_id=bus_ids[country],
carrier_id=carriers["AC"],
latitude=country_coords[country]["latitude"] + 0.15,
longitude=country_coords[country]["longitude"] + 0.15,
)
# Generate demand time series with growth
demand_values = []
for timestamp in timestamps:
year = timestamp.year
year_index = year - 2020
# Apply annual growth
base_demand = base_demand_2020[country] * ((1 + annual_growth_rate) ** year_index)
# Add seasonal variation
day_of_year = timestamp.timetuple().tm_yday
seasonal_factor = 1.0 + 0.1 * np.sin(2 * np.pi * day_of_year / 365.25)
# Daily pattern (higher during day)
hour = timestamp.hour
daily_factor = 1.1 if 6 <= hour <= 22 else 0.8
# Random variation
random_factor = 1.0 + np.random.uniform(-0.15, 0.15)
demand = base_demand * seasonal_factor * random_factor * daily_factor
demand_values.append(max(0.0, demand))
px.set_timeseries_attribute(conn, load_id, "p_set", demand_values)Key insight: By 2024, demand is ~15% higher than 2020 due to 3% annual growth. The model must anticipate this when making investment decisions.
Step 7: Solve the expansion problem
conn.commit()
print("Solving network with multi-investment period optimization...")
result = px.solve_network(
db_path=db_path,
solver_name="highs",
progress_callback=lambda progress, msg: print(f" {progress}%: {msg}"),
)
print("Optimization complete!")
print(f"Success: {result.get('success', False)}")
print(f"Objective: {result.get('objective_value', 'N/A')}")Understanding the Results
The optimizer solves a complex problem: minimize total system cost (capital + operational) while meeting demand and emissions constraints.
What the model decides:
- How much capacity to build for each technology
- When to build it (2020, 2021, 2022, 2023, or 2024)
- Where to build it (Ghana vs. Togo)
- How to operate all capacity (existing + new) each hour
Expected outcomes:
- Early investments in cheap-to-operate hydro (despite high capital cost)
- Gas phase-out as emissions limits tighten
- Biomass investments to replace gas
- Battery and transmission to handle variability
- Investment timing balances capital costs vs. future operational savings
Key Insights
Why multi-period matters: If we solved each year independently, we'd miss:
- Future demand growth (underinvest early, scramble later)
- Gas phase-out constraint (overinvest in gas, regret later)
- Economies of scale (build incrementally vs. all-at-once)
Computational note: This problem has ~7,300 time snapshots and dozens of investment options. The solver must optimize both dispatch (hourly) and investment (yearly) decisions simultaneously.
Export to Excel
Export the model and results to Excel for detailed analysis of investment decisions:
from pyconvexity.io.excel_exporter import ExcelModelExporter
xlsx_path = db_path.replace('.db', '.xlsx')
exporter = ExcelModelExporter()
exporter.export_model_to_excel(db_path=db_path, output_path=xlsx_path)The Excel export includes per-year statistics showing how capacity investments and costs evolve over the planning horizon.
Next steps
- Analyze investment timing: When does the model decide to build what?
- Review in Excel: Open the
.xlsxfile to see per-year statistics and capacity additions - Sensitivity analysis: How do results change with different discount rates or capital costs?
- Extend planning horizon: Try 2020-2030 to see longer-term patterns
- Add more constraints: Renewable portfolio standards, technology diversity requirements
Key Takeaway
This example demonstrates the power of capacity expansion modeling: the model doesn't just operate the system optimally, it designs the optimal system to build. This is essential for long-term planning in rapidly evolving energy systems.

