Skip to content

Building a Three-Country Electricity Market Model

Overview

This tutorial demonstrates how to use pyconvexity to programmatically build and solve an electricity market model. We'll create a three-country system (South Africa, Mozambique, and Eswatini) with power plants, loads, and cross-border transmission links.

What you'll learn

  • How to create and work with pyconvexity database files
  • Setting up networks, time periods, and carriers
  • Creating components (buses, generators, loads, links)
  • Working with static and time series attributes
  • Solving optimization models

Key concept:

pyconvexity stores your entire model in a SQLite database (.db file). This approach allows you to version control your models, share them easily, and programmatically manipulate them outside of the Convexity app.

Prerequisites

bash
pip install pyconvexity

Download Example Files

Step 1: Define your system parameters

First, let's define the characteristics of our electricity market. We have three countries with different generation mixes and costs:

python
import pyconvexity as px
import numpy as np
from datetime import datetime, timedelta
import os

# Marginal costs for each technology (EUR/MWh)
marginal_costs = {"Wind": 0, "Hydro": 0, "Coal": 30, "Gas": 60, "Oil": 80}

# Power plant capacities in MW for each country
power_plant_p_nom = {
    "South Africa": {"Coal": 35000, "Wind": 3000, "Gas": 8000, "Oil": 2000},
    "Mozambique": {"Hydro": 1200},
    "Eswatini": {"Hydro": 600},
}

# Transmission capacities between countries (MW)
transmission = {
    "South Africa": {"Mozambique": 500, "Eswatini": 250},
    "Mozambique": {"Eswatini": 100},
}

# Baseload demand for each country (MW)
baseload = {"South Africa": 22000, "Mozambique": 250, "Eswatini": 150}

# Geographic coordinates for visualization
country_coords = {
    "South Africa": {"latitude": -25.7, "longitude": 28.2},
    "Mozambique": {"latitude": -25.9, "longitude": 32.6},
    "Eswatini": {"latitude": -26.3, "longitude": 31.1},
}

Step 2: Create the database

pyconvexity uses SQLite databases to store models. Create a new database with the required schema:

python
# Define where to save the database file
db_path = "three_country_market.db"

# Create the database with `pyconvexity` schema
px.create_database_with_schema(db_path)

This creates an empty database with all the necessary tables for storing networks, components, and time series data.

Step 3: Create the network and time periods

Now we'll create a network that represents our 24-hour electricity market:

python
with px.database_context(db_path) as conn:
    # Define time range (24 hours)
    start_time = datetime.now().replace(hour=0, minute=0, second=0, microsecond=0)
    end_time = start_time + timedelta(hours=23)
    
    # Create network
    network_req = px.CreateNetworkRequest(
        name="Three Country Electricity Market",
        description="Three bidding zones connected by transmission",
        start_time=start_time.strftime("%Y-%m-%d %H:%M:%S"),
        end_time=end_time.strftime("%Y-%m-%d %H:%M:%S"),
        time_resolution="PT1H",  # Hourly resolution (ISO 8601 format)
    )
    
    px.create_network(conn, network_req)

Note: The database_context ensures the database connection is properly managed and changes are committed.

Step 4: Create carriers (energy types)

Carriers represent different types of energy or commodities. We need to create carriers before creating components:

python
    # Define carriers with colors for visualization
    carriers = {}
    carrier_names = ["AC", "Wind", "Hydro", "Coal", "Gas", "Oil"]
    carrier_colors = {
        "AC": "#1f77b4",
        "Wind": "#2ca02c",
        "Hydro": "#17becf",
        "Coal": "#8C8C8C",
        "Gas": "#CCCCCC",
        "Oil": "#d62728",
    }
    
    # Create each carrier
    for carrier_name in carrier_names:
        carrier_id = px.create_carrier(
            conn,
            name=carrier_name,
            co2_emissions=0.0,  # Could set actual emissions here
            color=carrier_colors.get(carrier_name, "#808080"),
            nice_name=carrier_name,
        )
        carriers[carrier_name] = carrier_id

Step 5: Create buses (network nodes)

Buses represent locations in the network where energy is produced, consumed, or transmitted:

python
    countries = ["Eswatini", "Mozambique", "South Africa"]
    bus_ids = {}
    
    # Create a bus for each country
    for country in countries:
        coords = country_coords[country]
        
        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
        
        # Set bus voltage (optional)
        px.set_static_attribute(conn, bus_id, "v_nom", px.StaticValue(400.0))

Step 6: Create generators

Now we add power plants for each country with their capacities and costs:

python
    # Track generator positions for spacing
    generator_counter = {}
    
    for country in countries:
        generator_counter[country] = 0
        coords = country_coords[country]
        
        for tech in power_plant_p_nom[country]:
            gen_name = f"{country} {tech}"
            
            # Offset generators slightly for visualization
            offset_lat = (generator_counter[country] % 3 - 1) * 0.3
            offset_lon = (generator_counter[country] // 3) * 0.3
            gen_lat = coords["latitude"] + offset_lat
            gen_lon = coords["longitude"] + offset_lon
            generator_counter[country] += 1
            
            # Create generator
            gen_id = px.create_component(
                conn,
                component_type="GENERATOR",
                name=gen_name,
                bus_id=bus_ids[country],
                carrier_id=carriers[tech],
                latitude=gen_lat,
                longitude=gen_lon,
            )
            
            # Set capacity and cost
            px.set_static_attribute(
                conn, gen_id, "p_nom", 
                px.StaticValue(float(power_plant_p_nom[country][tech]))
            )
            px.set_static_attribute(
                conn, gen_id, "marginal_cost", 
                px.StaticValue(float(marginal_costs[tech]))
            )

Step 7: Create loads with time series

Loads represent electricity demand. We'll create varying demand profiles over 24 hours:

python
    for country in countries:
        load_name = f"{country} load"
        coords = country_coords[country]
        
        # Offset load location slightly from bus
        load_lat = coords["latitude"] + 0.1
        load_lon = coords["longitude"] + 0.1
        
        # Create load component
        load_id = px.create_component(
            conn,
            component_type="LOAD",
            name=load_name,
            bus_id=bus_ids[country],
            carrier_id=carriers["AC"],
            latitude=load_lat,
            longitude=load_lon,
        )
        
        # Generate 24-hour demand profile with ±20% variation
        load_variation = baseload[country] * (1 + np.random.uniform(-0.2, 0.2, size=24))
        
        # Set time series attribute
        px.set_timeseries_attribute(conn, load_id, "p_set", load_variation.tolist())

Key difference from static attributes: Time series attributes vary over time, so we pass a list of 24 values (one per hour).

Links represent controllable power transfer between regions (like HVDC or NTC):

python
    for country in countries:
        if country not in transmission:
            continue
        
        for other_country in countries:
            if other_country not in transmission[country]:
                continue
            
            link_name = f"{country} - {other_country} link"
            
            # Position link at midpoint between countries
            coords0 = country_coords[country]
            coords1 = country_coords[other_country]
            link_lat = (coords0["latitude"] + coords1["latitude"]) / 2.0
            link_lon = (coords0["longitude"] + coords1["longitude"]) / 2.0
            
            # Create link
            link_id = px.create_component(
                conn,
                component_type="LINK",
                name=link_name,
                bus0_id=bus_ids[country],
                bus1_id=bus_ids[other_country],
                carrier_id=carriers["AC"],
                latitude=link_lat,
                longitude=link_lon,
            )
            
            # Set transmission capacity
            px.set_static_attribute(
                conn, link_id, "p_nom", 
                px.StaticValue(float(transmission[country][other_country]))
            )
            # Allow bidirectional flow
            px.set_static_attribute(conn, link_id, "p_min_pu", px.StaticValue(-1.0))
    
    # Commit all changes
    conn.commit()
    print("Model created successfully!")

Step 9: Solve the optimization

Now that our model is stored in the database, we can solve it:

python
# Solve the network
print("Solving network...")
result = px.solve_network(
    db_path=db_path,
    solver_name="highs",  # Free open-source solver
    progress_callback=lambda progress, msg: print(f"  {progress}%: {msg}"),
)

print(f"Optimization complete!")
print(f"Success: {result.get('success', False)}")
print(f"Objective: {result.get('objective_value', 'N/A')}")

The solver will minimize total system cost while meeting demand constraints, respecting transmission limits, and generator capacities.

Export to Excel

After solving, you can export the complete model and results to Excel for analysis or sharing:

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)
print(f"Exported to: {xlsx_path}")

The Excel workbook includes sheets for each component type, timeseries data, carriers, network configuration, and solve statistics.

Next steps

  • View results: Open the .db file in Convexity to visualize dispatch, prices, and flows
  • Analyze in Excel: Open the .xlsx file to explore results in a spreadsheet
  • Modify parameters: Change capacities, costs, or demand and re-solve
  • Extract results programmatically: Use pyconvexity to read optimization results and create custom analyses
  • Automate scenarios: Run multiple scenarios by varying parameters in a loop

© Copyright 2025 Bayesian Energy