Skip to content

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

bash
pip install pyconvexity

Download 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):

python
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

python
    # 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:

python
    # 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 capacity
  • build_year: When this capacity option becomes available
  • capital_cost: One-time investment cost per MW
  • lifetime: How many years the asset operates
python
    # 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

python
    # 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:

python
    # 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

python
    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:

  1. How much capacity to build for each technology
  2. When to build it (2020, 2021, 2022, 2023, or 2024)
  3. Where to build it (Ghana vs. Togo)
  4. 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:

python
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 .xlsx file 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.

© Copyright 2025 Bayesian Energy